From d208ee7c508f24243ff2d3025db18ca26216636e Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Mon, 19 Oct 2026 09:34:06 -0400 Subject: [PATCH 01/10] tui3: /helpers makes this conversation a new team's manager and asks it for helpers A person deep in a piece of work can now turn it into a team effort with the conversation that knows the work in charge. `/helpers` (or `+ Make team` beside Tasks in the right-hand column of a chat in no team) makes a new top-level team with the conversation in front as its manager in one store edit, narrows the strip to it, and once the store has taken the edit sends the conversation one turn: propose two or three helpers, ask, start nobody until the person agrees. The person answers in words. Yes is team_start under the usual approval posture; no is the new team_dissolve, which forgets a team that has never held anyone but its manager (no member, former member, subteam or Traffic). It is on the belt only while that is true, for every team however it was made, and the store checks again under its lock. Every rule a team made on the Teams page meets is met here: a fresh name, the next colour, one conversation managing one team and its subteams, and the store's refusal taking the window back. The manual's team manager page has the command, when it is not offered, and cancelling. Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/chatv3.go | 6 + cmd/codeaf/chatv3_approvalfloor_test.go | 5 +- internal/manual/chat/commands.md | 1 + .../manual/chat/conversations-and-teams.md | 4 +- internal/manual/chat/home.md | 2 +- internal/manual/chat/screen.md | 7 +- internal/manual/chat/team-manager.md | 63 +++- internal/manual/chat/teams-page.md | 3 +- internal/manual/chat_test.go | 29 ++ internal/session/actioncategory.go | 2 +- internal/session/team.go | 16 +- internal/session/team_dissolve.go | 73 +++++ internal/session/team_dissolve_test.go | 149 +++++++++ internal/session/tools_team.go | 2 +- internal/teams/dissolve.go | 103 ++++++ internal/teams/dissolve_test.go | 117 +++++++ internal/tui3/app.go | 7 + internal/tui3/caption.go | 47 +-- internal/tui3/commands.go | 6 + internal/tui3/helpers.go | 209 ++++++++++++ internal/tui3/helpers_test.go | 301 ++++++++++++++++++ internal/tui3/homeslash.go | 8 + internal/tui3/sidecol.go | 26 ++ internal/tui3/teams.go | 16 + internal/tui3/teamseam.go | 4 +- internal/tui3/teamwritesaid.go | 5 + 26 files changed, 1176 insertions(+), 35 deletions(-) create mode 100644 internal/session/team_dissolve.go create mode 100644 internal/session/team_dissolve_test.go create mode 100644 internal/teams/dissolve.go create mode 100644 internal/teams/dissolve_test.go create mode 100644 internal/tui3/helpers.go create mode 100644 internal/tui3/helpers_test.go diff --git a/cmd/codeaf/chatv3.go b/cmd/codeaf/chatv3.go index a7fc5a69ad..da53035360 100644 --- a/cmd/codeaf/chatv3.go +++ b/cmd/codeaf/chatv3.go @@ -2108,6 +2108,12 @@ func v3BuiltinApprovals() map[string]any { // A conflict raised to the manager above the parties: a packet and a // line of Traffic, and nothing spent or started. "team_raise": "allow", + // A manager taking back a team that never held anyone, which the + // person declined in its own conversation: one record nobody used is + // forgotten, and nothing is started, stopped or spent. A team that + // was used is refused by the store ([teams.Dissolve]), whatever this + // row says. + "team_dissolve": "allow", } } diff --git a/cmd/codeaf/chatv3_approvalfloor_test.go b/cmd/codeaf/chatv3_approvalfloor_test.go index abcfc4dced..c23dd0f19f 100644 --- a/cmd/codeaf/chatv3_approvalfloor_test.go +++ b/cmd/codeaf/chatv3_approvalfloor_test.go @@ -209,13 +209,14 @@ func TestTheFloorDoesNotWidenAnySafetyFloor(t *testing.T) { // THE TEAM VERBS SPLIT READ AND MESSAGE FROM START (internal/session's // tools_team.go). A manager looking at its team, sending it a line, ending a // member's turn and a member posting to its room stay inside the team the person -// made and are logged in its traffic, so they sit on the floor; a new member is a +// made and are logged in its traffic, so they sit on the floor, and so does a +// manager taking back a team that never held anyone; a new member is a // new conversation that spends money, so team_start is the blanket mode's and // asks under the shipped default. func TestTheTeamVerbsAreAllowedAndTeamStartAsks(t *testing.T) { dir := v3Profile(t, map[string]any{"tools.approvalMode": "prompt"}) policy := gateOf(t, dir) - for _, tool := range []string{"team_status", "team_read", "team_send", "team_stop", "team_post", "team_decide", "team_escalate", "team_close_report", "team_raise"} { + for _, tool := range []string{"team_status", "team_read", "team_send", "team_stop", "team_post", "team_decide", "team_escalate", "team_close_report", "team_raise", "team_dissolve"} { wantAction(t, policy, tool, `{"to":"web","text":"x","handle":"web"}`, approval.ActionAllow) } wantAction(t, policy, "team_start", `{"handle":"docs","brief":"write the README"}`, approval.ActionPrompt) diff --git a/internal/manual/chat/commands.md b/internal/manual/chat/commands.md index fa98ec23ff..dba355616d 100644 --- a/internal/manual/chat/commands.md +++ b/internal/manual/chat/commands.md @@ -323,6 +323,7 @@ command). | `/spend` | none | none | opens the spend place, what this machine has cost, by the day (also `alt+5`) | | `/wall` | | | every saved conversation at once, with live or saved previews and card-based team creation (also `alt+v`, or `▦` under the box) | | `/teams` | | | the teams page: your teams as a tree, what waits on you, member previews and the selected team's interaction table (also `alt+2`, or `teams` on the tab bar) | +| `/helpers` | | `` | makes this conversation a new team's manager, which proposes helpers for you to accept, change or cancel (also `+ Make team` beside `Tasks` in the right-hand column); on home it says to open a conversation first | | `/cost` | `/usage`, `/tokens` | — | prints what this conversation has spent, and on what | | `/effort` | `/think`, `/thinking` | — | opens this conversation's thinking levels; says it is unavailable when the session has no dial | | `/effort` | `/think`, `/thinking` | `` | sets this conversation's thinking level; an unknown rung lists the accepted levels and changes nothing | diff --git a/internal/manual/chat/conversations-and-teams.md b/internal/manual/chat/conversations-and-teams.md index 1df8c532ae..278373f14d 100644 --- a/internal/manual/chat/conversations-and-teams.md +++ b/internal/manual/chat/conversations-and-teams.md @@ -239,7 +239,9 @@ it does not select an overlay. A **manager** is a conversation that runs the team: you talk to it, it sends work to members and reports progress. Teams offers **Choose AI manager** for existing members and **+ Manager** -when a team has none. Add a new member first when you need a new conversation to replace +when a team has none. In the conversation you are working in, **`/helpers`** (or `+ Make +team` in the right-hand column) makes a new team with that conversation as its manager, +which then proposes helpers for you to accept, change or cancel. Add a new member first when you need a new conversation to replace an existing manager. All teams has a dedicated Global manager card. `+ Global manager` creates its optional conversation. Its reports are the managers of open top-level teams; subteam managers report to their parent. Its card's `x` deletes the global-manager conversation with the diff --git a/internal/manual/chat/home.md b/internal/manual/chat/home.md index 3c4a572a26..efd3d20d93 100644 --- a/internal/manual/chat/home.md +++ b/internal/manual/chat/home.md @@ -1342,7 +1342,7 @@ one behind your back. This is every fate, in the words the drop-up draws them in | **`this list is /resume`** | `/resume` `/sessions` | Says `this list is /resume · enter opens a row` — home *is* that list. | | **`onto home's tray`** | `/attach ` | The file — or picture — rides on home's own tray into the conversation you open next. Home says `attached · notes.md · rides with the next conversation`. A bare `/attach` opens the browser aimed at the next conversation's folder, and a file chosen there lands on the tray. | | **`opens a conversation here first`** | `/files` · `/folder` `/place` `/dir` · `/manual` · `/permissions` `/perms` · `/connect` · `/harness` · `/subharness` · `/skill` `/skills` · `/copy` · `/select` · `/rewind` `/undo` `/back` · `/compact` · `/export` `/save` · `/task ` | Opens a conversation at the target — the folder at the right of the keys row and the model on the rule above the box — then runs there. Home closes, exactly as `enter` closes it. `/manual` is on this road since 2026-09-22: it is a question put to the model, so it needs a conversation to be asked in. `/folder` joined it the same day — it gives THIS conversation a folder, and home has no this; the pin it used to be here is `/project`. | -| **`answers here`** | `/help` · `/status` · `/cost` · `/cache` · `/budget` · `/debug` · `/stop` · `/remember` · `/always` · `/always ` · a word nobody defined | Answers with a note, and the first line of that note is put on home's own line under the box. `there is no command called /pricing · / lists them` is now something you can read. | +| **`answers here`** | `/help` · `/status` · `/cost` · `/cache` · `/budget` · `/debug` · `/stop` · `/remember` · `/always` · `/always ` · `/helpers` · a word nobody defined | Answers with a note, and the first line of that note is put on home's own line under the box. `there is no command called /pricing · / lists them` is now something you can read. `/helpers` makes a team of the conversation you are working in, and home has none in front, so it says `/helpers makes a team of the conversation you are in · open one first`. | | **`opens the crew panel`** | `/crew` and every `/crew` shortcut | Home steps aside and the crew panel opens over the conversation behind it; `esc` on the panel brings you back to home. A shortcut opens it with a tick on the row it changed. | | **`runs on the conversation behind home`** | `/land` · `/land ` · `/workspace ` · `/delete` | `/delete` asks to permanently delete the conversation behind Home; it opens its confirmation here and never starts a new conversation. The other commands act on the conversation this window is holding behind the screen — not on the one `enter` would open — and its answer is echoed onto home's line. | | **`a fresh conversation behind home`** | `/new` `/clear` `/clean` `/reset` | Replaces the conversation behind the screen and says `started a fresh conversation behind home`. It is not the same act as `enter`, which opens a conversation at the target. | diff --git a/internal/manual/chat/screen.md b/internal/manual/chat/screen.md index 2c466a1803..b7b7d29af7 100644 --- a/internal/manual/chat/screen.md +++ b/internal/manual/chat/screen.md @@ -31,7 +31,8 @@ row is not there and the box sits straight under the gap. Beside the conversation, on the right, the column (the right-hand bar, sidebar, task panel, whatever you call it) takes a quarter of the frame, 28 to 40 columns (30 at 120), from the session's first keystroke, before any tasks exist. It is the same column in every -chat, and its header is `Tasks N`, or `Tasks N · Traffic N new` in a chat in a team. An untouched empty +chat, and its header is `Tasks N`, or `Tasks N · Traffic N new` in a chat in a team; a chat in +no team also has `+ Make team` there (`/helpers`, on the team manager page). An untouched empty conversation opens without it: no column, no doors, no rule, no telemetry, just the centred greeting with the message box inside it (see *The empty screen* page); the column stands the moment you type, or at once if a task is already here. Under the @@ -3327,7 +3328,9 @@ Sections are one blank line apart; the band's rule is the one rule in the column The top of the right-hand column, above the sections under its list: -- **The header** is `Tasks N`, and in a chat in a team `Tasks N · Traffic N new`. The word +- **The header** is `Tasks N`, and in a chat in a team `Tasks N · Traffic N new`. In a chat + in no team, where it fits, `Tasks N · + Make team`: pressing it is `/helpers`, which makes + this conversation a new team's manager and asks it to propose helpers. The word in front is bold ink and the other dim; each is a button with a hover ground and a hint, and the other word keeps its count, so what arrives on the Traffic while you read the tasks still says `2 new`. Which word is in front is remembered for the session per kind diff --git a/internal/manual/chat/team-manager.md b/internal/manual/chat/team-manager.md index f7ef7c74d6..1489a1281e 100644 --- a/internal/manual/chat/team-manager.md +++ b/internal/manual/chat/team-manager.md @@ -55,6 +55,9 @@ its tab strip and never scrolls away: - **Choose AI manager** on Teams assigns an existing member. To use a new conversation, first use Add chat, then Choose AI manager. The Chats dropdown and conversation grid offer no manager-assignment controls. +- **`/helpers`**, or **`+ Make team`** in the right-hand column, in the conversation you are + working in: a new team is made with that conversation as its manager, and it proposes + helpers (see **Turn this conversation into a team**). The manager's place reads **`◆ Manager`**. Its tile in the team-agnostic grid keeps its ordinary title and position. The tab's hint names its team and title. `alt+m` goes to the manager from any conversation in the team. @@ -66,6 +69,60 @@ If the handle is already taken in that team, a number is added. Another manager `team_send` before its first turn. Once its title is made, the ordinary title-based handle choice may replace that fallback. +## Turn this conversation into a team — /helpers, the + Make team button, convert a chat, bring in helpers + +You are working in a conversation, the work has grown, and you want helpers with this +conversation in charge. Type `/helpers`, or `/helpers `, or press +**`+ Make team`** beside `Tasks` at the top of the right-hand column. Two things happen: + +1. A new top-level team is made with **this conversation as its manager** and only member, + in one save. It is named after the conversation's folder, or a word such as `harbor` + when a team already has that name, and takes the next free colour. The strip narrows to + it, the tab reads `◆ Manager`, and the column turns to the team's Traffic. The rules of + every team apply: a name no other team has, and one conversation manages one team and + its subteams. Rename it or change its colour in Teams, under `Settings`. +2. Once the team is saved, the conversation is sent one message, shown as `/helpers`: it + proposes two or three helpers — a handle, the part of the work, and the brief — and asks + you to accept them, change the list, or cancel. It starts nobody until you agree. + +The receipt reads `made the team lab · this conversation is its manager`. Reply in your own +words; there is no card. Yes, and it starts each helper with `team_start`, under your +approval posture like any start. A different list, and it proposes again. Cancel, and it +takes the team back with `team_dissolve` (see **Cancel a team nobody joined**). Nothing is +copied: the manager keeps this conversation, its history and its tasks. + +## When /helpers is not offered, and what it says + +`+ Make team` is drawn only in a chat in no team, where it can work and where it fits; a +narrow column drops it first. `/helpers` always answers: + +- On home, or any page: `/helpers makes a team of the conversation you are in · open one first`. +- On the welcome screen of a new chat: `/helpers makes a team of a conversation you have started · say what you are working on first`. +- In a conversation that already manages a team: `this conversation already manages harbor · ask it for helpers`, and the strip shows that team. +- On a connection that holds one conversation at a time: `helpers open beside this conversation, and a connection holds one conversation at a time`. +- Over `--host` to an engine without teams: `managers are not available over --host yet`. +- While the first one is still being saved: `the team is still being saved`. + +If the save is refused — another window made this conversation another team's manager — +the strip goes back, the reason is said, and nothing is sent. If you leave the +conversation before the save finishes, the team stands and the message is not sent: +`made the team lab · its manager is the conversation you left; ask it for helpers there`. + +## Cancel a team nobody joined — team_dissolve + +Say no to the proposed helpers, or "cancel", and the manager calls `team_dissolve`. The +team is forgotten as if it had never been made: no row under `Show closed`, nothing +stopped, and the conversation, its history and its tasks stay. The strip goes back to all +your chats and the column to `Tasks`, with `+ Make team` again. + +A manager has `team_dissolve` only while its team has never held anyone but it: no +member, no former member, no subteam, and nothing in its Traffic. The rule is the same for +every team, however it was made. It does not ask first, because it can only remove a team +nothing happened in. Once anyone joins, it leaves at the manager's next step, and a +remembered call says `team_dissolve is no longer one of your tools: the team has held +members or exchanged messages, so only the person can disband it, from Teams.` A team +that was used ends with `Disband` on Teams, which keeps its history. + ## The manager's screen While the manager is in front, the message box says `to ◆ manager of harbor`, using the @@ -283,6 +340,7 @@ on one waits for you. | `team_decide` | answers a decision packet waiting on the manager, most often a member's question: an option, or its own words | no | | `team_escalate` | sends a packet waiting on the manager up, to its own manager or to you, with the reason it is not the manager's to decide | no | | `team_close_report` | brings you the team's closing report (done, left, where the files are) after you asked it to wrap up | no | +| `team_dissolve` | takes back a team that has never held anyone but its manager, when you decline it; the team is forgotten and the conversation goes on alone. Only offered while the team is that empty (see **Cancel a team nobody joined**) | no | | `team_raise` | raises a conflict to the manager above every party (see **Conflicts between members and teams**); members have it too | no | ## Adding existing sessions and removing members through the manager @@ -312,8 +370,9 @@ Like any tool, each can be set to ask or allow in `/settings` under the tool app ## What happens when a conversation stops being the manager At its next step, a conversation removed as manager loses `team_status`, `team_read`, -`team_send`, `team_stop`, `team_start`, `team_add`, `team_remove`, `team_decide`, `team_escalate` and -`team_close_report`. If it is still a member of a managed team, it keeps `team_post` and +`team_send`, `team_stop`, `team_start`, `team_add`, `team_remove`, `team_decide`, `team_escalate`, +`team_close_report`, and `team_dissolve` if it had it. A team it dissolved with +`team_dissolve` takes them all the same way. If it is still a member of a managed team, it keeps `team_post` and `team_raise`; outside a team it loses those too. A remembered call to a removed manager tool says `team_send is no longer one of your tools: this conversation no longer manages a team.` A removed member tool says `team_post is no longer one of your tools: this diff --git a/internal/manual/chat/teams-page.md b/internal/manual/chat/teams-page.md index 5ba1058986..3d2741825d 100644 --- a/internal/manual/chat/teams-page.md +++ b/internal/manual/chat/teams-page.md @@ -438,7 +438,8 @@ changes fields, arrows move, `space` selects a highlighted member, `enter` creat cancels. No internal IDs are shown. A duplicate name or invalid parent is refused. You may create an empty team and add members afterward. Creation never assigns a manager or -opens its members. Both routes land on the new team overview. A failed save is reported there. +opens its members. To make a team from the conversation you are working in, with that +conversation as its manager, use `/helpers` there (the team manager page has it). Both routes land on the new team overview. A failed save is reported there. Chats also offers `+ New AI team` in its full saved-conversation grid: select cards, then name and create. The full conversation grid itself lives only in Chats. diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index c0524e43d1..d99e5ab4a3 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -4116,3 +4116,32 @@ func TestChangedInputQuestionReachesRetainedHistory(t *testing.T) { t.Errorf("%q does not reach what-i-remember · %q; it reached %v", question, title, where) } } + +// /helpers: the questions a person in the middle of some work asks when they +// want it to become a team, and the ones they ask about saying no, reach the +// sections that answer them by name, not just the manager's page. +func TestHelpersQuestionsReachTheirSections(t *testing.T) { + for _, probe := range []struct{ question, title string }{ + {"how do I turn this conversation into a team", "Turn this conversation into a team"}, + {"convert this chat to a team with it as the manager", "Turn this conversation into a team"}, + {"bring in helpers for what I am working on", "Turn this conversation into a team"}, + {"what does the + Make team button do", "Turn this conversation into a team"}, + {"why is /helpers not offered here", "When /helpers is not offered"}, + {"/helpers says open one first", "When /helpers is not offered"}, + {"I said no to the helpers, how do I cancel the team", "Cancel a team nobody joined"}, + {"what does team_dissolve do", "Cancel a team nobody joined"}, + } { + found := Chat().Search(probe.question, DefaultResults) + reached := false + where := make([]string, 0, len(found)) + for _, section := range found { + where = append(where, section.Page+" · "+section.Title) + if section.Page == "team-manager" && strings.Contains(section.Title, probe.title) { + reached = true + } + } + if !reached { + t.Errorf("%q does not reach team-manager · %q; it reached %v", probe.question, probe.title, where) + } + } +} diff --git a/internal/session/actioncategory.go b/internal/session/actioncategory.go index bbc3242e84..2b9a388ac0 100644 --- a/internal/session/actioncategory.go +++ b/internal/session/actioncategory.go @@ -260,7 +260,7 @@ func ActionCategoryForTool(tool string) ActionCategory { case "propose_task", "quick_task", "divide_work", "workspace_fork", "automation", // A manager starting a member or ending its turn, or deciding what a // member asked it. - "team_start", "team_stop", "team_decide", "team_add", "team_remove": + "team_start", "team_stop", "team_decide", "team_add", "team_remove", "team_dissolve": return ActionCoordinate // Keeping the account of the work rather than doing it. `items` is a quick diff --git a/internal/session/team.go b/internal/session/team.go index 9ebde11827..f183a90449 100644 --- a/internal/session/team.go +++ b/internal/session/team.go @@ -103,6 +103,10 @@ type teamRole struct { // manager is the global manager, and its other members are the top-level // teams' managers. root bool + // dissolvable says this conversation manages the team and the team has + // never held anyone but it ([teams.File.Dissolvable]), which is when its + // manager is given team_dissolve (team_dissolve.go). + dissolvable bool // key is the conversation key this conversation is stored under in the // team, the one spelling of [Agent.teamKeysLocked] the file holds. An event // this conversation writes names it ([teams.Entry.Member]). @@ -402,6 +406,7 @@ func rolesFor(file *teams.File, keys []string, d teams.Defaults) []teamRole { derived: member.HandleDerived(), joinedAt: member.JoinedAt, } + role.dissolvable = role.manager && file.Dissolvable(team.ID) if hasHome { role.questionsUp = file.Effective(home.Team, d).QuestionsUp } @@ -511,16 +516,20 @@ func (a *Agent) teamBoundary() string { // armTeamTools puts the verbs these roles give onto the belt. func (a *Agent) armTeamTools(roles []teamRole) { var arriving []bare.Tool - manager, member := false, false + manager, member, dissolvable := false, false, false for _, role := range roles { manager = manager || role.manager member = member || (!role.manager && role.managed) + dissolvable = dissolvable || role.dissolvable } if manager { arriving = append(arriving, a.managerTools()...) arriving = append(arriving, a.packetTools()...) arriving = append(arriving, a.wrapUpTools()...) } + if dissolvable { + arriving = append(arriving, a.dissolveTools()...) + } if member { arriving = append(arriving, a.memberTools()...) } @@ -581,6 +590,11 @@ func (a *Agent) retireTeamTools(allowed map[string]bool) { if teamManagerToolName(tool.Name) { a.teamRetired[tool.Name] = teamRetiredManager } + // team_dissolve leaves a conversation that still manages its team + // when the team was used, and that is a different sentence. + if tool.Name == teamDissolveToolName && allowed[teamStatusToolName] { + a.teamRetired[tool.Name] = teamRetiredDissolve + } } a.tools = kept a.definitions, _ = toolDefinitions(kept) diff --git a/internal/session/team_dissolve.go b/internal/session/team_dissolve.go new file mode 100644 index 0000000000..3b9d218935 --- /dev/null +++ b/internal/session/team_dissolve.go @@ -0,0 +1,73 @@ +package session + +// team_dissolve: a manager taking back a team that never held anyone. +// +// A conversation the person turns into a team (the chat's `/helpers`) is its +// manager from that moment, with nobody under it, and its first job is to +// propose helpers. The person answers in their own words, and one answer is +// "no, cancel it". The manager has every verb for growing a team and, until +// this one, none for taking the team back, so a declined proposal left an empty +// team on the Teams page that only the person could disband — and disbanding +// keeps a read-only history row for a team that never did anything. +// +// THE VERB IS ON THE BELT ONLY WHILE IT CAN WORK. It is armed at a boundary that +// finds this conversation managing a team that has never held anyone but it +// ([teams.File.Dissolvable]), and it leaves at the first boundary after anyone +// else joins, with the reason said to a model that calls it from memory. A +// capability that cannot work is absent, not broken. The call itself checks +// again, under the store's lock, and also refuses a team whose Traffic has +// anything in it ([teams.Dissolve]), because a start already written there is a +// member on its way. +// +// IT IS ONE RULE FOR EVERY TEAM. Nothing here asks which door made the team: a +// team made on the Teams page and given a manager there is dissolvable by the +// same rule while it is just as empty. Disbanding a team that was used stays the +// person's, on Teams. + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + "github.com/Agent-Field/codeaf/internal/exec/bare" + "github.com/Agent-Field/codeaf/internal/teams" +) + +const teamDissolveToolName = "team_dissolve" + +const teamDissolveDescription = "Take back the team you manage while it has never held anyone but you: it is forgotten as if it had never been made, nobody is stopped, and this conversation goes on as an ordinary one without your team verbs. " + + "Use it only when the person declines the team, for example by saying no to the helpers you proposed and asking to cancel. " + + "It is offered only until anyone else joins the team or anything is said in its traffic; after that the person disbands the team from Teams." + +const teamDissolveSchema = `{"type":"object","properties":{` + teamArgSchema + `},"additionalProperties":false}` + +// teamRetiredDissolve is why team_dissolve left the belt of a conversation that +// still manages a team: the team was used, so it is not the manager's to undo. +const teamRetiredDissolve = "the team has held members or exchanged messages, so only the person can disband it, from Teams" + +// dissolveTools is the manager's one verb for an untouched team. +func (a *Agent) dissolveTools() []bare.Tool { + return []bare.Tool{ + {Name: teamDissolveToolName, Description: teamDissolveDescription, Schema: json.RawMessage(teamDissolveSchema), Execute: a.teamDissolveTool}, + } +} + +func (a *Agent) teamDissolveTool(ctx context.Context, args json.RawMessage) (string, bool, error) { + var p struct{ Team string } + if err := decodeToolArguments(args, &p); err != nil { + return invalidArgumentsPrefix + err.Error(), true, nil + } + t, role, refusal := a.teamTarget(p.Team, true) + if refusal != "" { + return refusal, true, nil + } + err := teams.Dissolve(a.config.teamProfile(), t.ID, role.key) + if errors.Is(err, teams.ErrNotDissolvable) || errors.Is(err, teams.ErrRoot) { + return fmt.Sprintf("%q has already held members or exchanged messages, so it is not yours to take back. Nothing was changed; the person can disband it from Teams.", t.Name), true, nil + } + if err != nil { + return "The team could not be dissolved: " + err.Error(), true, nil + } + return fmt.Sprintf("Dissolved %q. Nobody was started or stopped, and this conversation goes on as an ordinary one; your team verbs leave at your next step.", t.Name), false, nil +} diff --git a/internal/session/team_dissolve_test.go b/internal/session/team_dissolve_test.go new file mode 100644 index 0000000000..144c081d12 --- /dev/null +++ b/internal/session/team_dissolve_test.go @@ -0,0 +1,149 @@ +package session + +// team_dissolve, as tests: it is on the belt exactly while the team it would +// take back has never held anyone but its manager, it leaves with a reason the +// moment anybody joins, and a call it answers forgets the team and nothing else. +// +// Like the rest of the team tests, every fixture is a real teams.json and a +// real Traffic log, written through internal/teams. + +import ( + "context" + "encoding/json" + "os" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/teams" +) + +// newHelpersFixture is the team the chat's `/helpers` makes: the fixture's +// manager conversation as manager and only member, and the web and parser +// transcripts beside it, in no team, to be added by a test. +func newHelpersFixture(t *testing.T) teamFixture { + t.Helper() + fixture := newTeamFixture(t, true) + if err := teams.Update(fixture.profile, func(f *teams.File) error { + off := false + team := teams.Team{ID: fixture.teamID, Name: "harbor"} + team.Settings.Wake = &off + f.Teams = []teams.Team{team} + if err := f.AddMember(fixture.teamID, teams.Member{Key: convKeyOf(t, fixture.manager), File: fixture.manager, Word: "Harbor manager", Handle: "boss"}); err != nil { + return err + } + return f.SetManager(fixture.teamID, convKeyOf(t, fixture.manager)) + }); err != nil { + t.Fatal(err) + } + return fixture +} + +func TestADissolvableTeamsManagerIsOfferedTeamDissolveAndAUsedOneIsNot(t *testing.T) { + fresh := newHelpersFixture(t) + manager := teamAgent(t, fresh, fresh.manager, nil, nil) + manager.teamBoundary() + if !holds(manager, teamDissolveToolName) { + t.Fatal("the manager of a team that never held anyone was not offered team_dissolve") + } + for _, name := range []string{teamStatusToolName, teamSendToolName, teamStartToolName, teamCloseReportToolName} { + if !holds(manager, name) { + t.Errorf("a dissolvable team's manager lacks %s, which every manager has", name) + } + } + + used := newTeamFixture(t, true) + busy := teamAgent(t, used, used.manager, nil, nil) + busy.teamBoundary() + if holds(busy, teamDissolveToolName) { + t.Fatal("the manager of a team with members was offered team_dissolve") + } + web := teamAgent(t, used, used.web, nil, nil) + web.teamBoundary() + if holds(web, teamDissolveToolName) { + t.Fatal("a member was offered team_dissolve") + } +} + +// THE VERB LEAVES WHEN ANYONE JOINS, BY ANY ROUTE, and a call from memory is +// told why. The other manager verbs stay: the conversation still manages. +func TestTeamDissolveLeavesWhenAMemberJoinsAndSaysWhy(t *testing.T) { + fixture := newHelpersFixture(t) + manager := teamAgent(t, fixture, fixture.manager, nil, nil) + manager.teamBoundary() + if err := teams.Update(fixture.profile, func(f *teams.File) error { + return f.AddMember(fixture.teamID, teams.Member{Key: convKeyOf(t, fixture.web), File: fixture.web, Word: "web frontend", Handle: "web"}) + }); err != nil { + t.Fatal(err) + } + manager.teamBoundary() + if holds(manager, teamDissolveToolName) { + t.Fatal("team_dissolve stayed after a member joined") + } + if !holds(manager, teamSendToolName) || !holds(manager, teamStartToolName) { + t.Fatal("a member joining took the manager's other verbs") + } + result := manager.executeTool(context.Background(), nil, nil, withdrawnCall("c1", teamDissolveToolName, `{}`), "") + if want := "team_dissolve is no longer one of your tools: " + teamRetiredDissolve + "."; result.text != want { + t.Fatalf("a retired team_dissolve answered %q, want %q", result.text, want) + } +} + +// A CALL FORGETS THE TEAM, and only the team: the transcript is where it was, +// and at the next boundary every team verb leaves because there is no team. +func TestTeamDissolveForgetsTheTeamAndTheVerbsLeaveWithIt(t *testing.T) { + fixture := newHelpersFixture(t) + manager := teamAgent(t, fixture, fixture.manager, nil, nil) + manager.teamBoundary() + said, failed, err := manager.teamDissolveTool(context.Background(), json.RawMessage(`{}`)) + if failed || err != nil { + t.Fatalf("dissolve: %s %v", said, err) + } + if !strings.Contains(said, `Dissolved "harbor"`) { + t.Fatalf("dissolve said %q", said) + } + file, err := teams.Load(fixture.profile) + if err != nil { + t.Fatal(err) + } + if _, ok := file.Team(fixture.teamID); ok || len(file.ClosedTeams()) != 0 { + t.Fatal("the team is still in the file, open or as history") + } + if _, err := os.Stat(fixture.manager); err != nil { + t.Fatal("dissolving touched the manager's transcript") + } + manager.teamBoundary() + for _, name := range teamToolNames { + if holds(manager, name) { + t.Errorf("%s stayed on the belt of a conversation whose team was dissolved", name) + } + } + result := manager.executeTool(context.Background(), nil, nil, withdrawnCall("c2", teamSendToolName, `{}`), "") + if want := "team_send is no longer one of your tools: " + teamRetiredManager + "."; result.text != want { + t.Fatalf("team_send after dissolving answered %q, want %q", result.text, want) + } +} + +// A START ALREADY IN THE TRAFFIC IS A MEMBER ON ITS WAY. A call made after the +// boundary armed the verb, with a team_start written since, is refused and +// changes nothing. +func TestTeamDissolveRefusesATeamWithTrafficAndWritesNothing(t *testing.T) { + fixture := newHelpersFixture(t) + manager := teamAgent(t, fixture, fixture.manager, nil, nil) + manager.teamBoundary() + appendTraffic(t, fixture, teams.Entry{Kind: teams.KindStart, From: teams.FromManager, To: "docs", Text: "write the README"}) + before, err := os.ReadFile(teams.Path(fixture.profile)) + if err != nil { + t.Fatal(err) + } + said, failed, _ := manager.teamDissolveTool(context.Background(), json.RawMessage(`{}`)) + if !failed || !strings.Contains(said, "not yours to take back") { + t.Fatalf("dissolving a team with traffic said %q (failed %v)", said, failed) + } + after, err := os.ReadFile(teams.Path(fixture.profile)) + if err != nil { + t.Fatal(err) + } + if string(before) != string(after) { + t.Fatal("a refused dissolve wrote the teams file") + } +} diff --git a/internal/session/tools_team.go b/internal/session/tools_team.go index 5ae9424eb7..f892a033d5 100644 --- a/internal/session/tools_team.go +++ b/internal/session/tools_team.go @@ -68,7 +68,7 @@ const teamGroup = "team" var teamToolNames = []string{ teamStatusToolName, teamReadToolName, teamSendToolName, teamStopToolName, teamStartToolName, - teamDecideToolName, teamEscalateToolName, teamCloseReportToolName, + teamDecideToolName, teamEscalateToolName, teamCloseReportToolName, teamDissolveToolName, teamPostToolName, teamRaiseToolName, teamAddToolName, teamRemoveToolName, } diff --git a/internal/teams/dissolve.go b/internal/teams/dissolve.go new file mode 100644 index 0000000000..7197c2dc40 --- /dev/null +++ b/internal/teams/dissolve.go @@ -0,0 +1,103 @@ +package teams + +import ( + "errors" + "fmt" + "os" +) + +// ── DISSOLVING A TEAM THAT NEVER HELD ANYONE ───────────────────────────────── +// +// A conversation the person turns into a team (the chat's `/helpers`) is made +// its manager at once, with nobody under it, and is asked to propose helpers. +// The person may decline them all. What they are declining is the team itself, +// so the manager is given one way to take it back while there is still nothing +// to take back: a team that has never held anyone but its manager, has no team +// under it and has said nothing in its Traffic is forgotten whole, as if it had +// never been made. The conversation is untouched and goes on as an ordinary one. +// +// IT IS NOT DISBANDING. Disbanding (lifecycle.go) is the person's, from Teams, +// and keeps the team as read-only history, because a team that ran has a record +// worth keeping. A team that never ran has none, so there is nothing to keep and +// no history row to leave behind. The moment the team has held a second +// conversation, under any route, this door is shut for good, and the team ends +// the way every other team does. +// +// NOTHING HERE KNOWS WHICH DOOR MADE THE TEAM. A team made on the Teams page +// and given a manager there, and then never used, is dissolvable by the same +// rule: one rule for every team, whichever way it was made. + +// ErrNotDissolvable is [Dissolve] asked to forget a team that has held more than +// its manager, has a team under it, or has Traffic. +var ErrNotDissolvable = errors.New("teams: this team has already held members or exchanged messages; the person disbands it from Teams") + +// Dissolvable reports whether team id has never held anyone but its manager: it +// is open, it is not the root, it has a manager, that manager is its one member, +// it has no former member or former manager, and no team sits under it. It reads +// the file only; [Dissolve] also asks the Traffic. +func (f *File) Dissolvable(id string) bool { + t, ok := f.Team(id) + if !ok || t.Closed() || t.Root || t.Manager == "" { + return false + } + if len(t.Members) != 1 || t.Members[0].Key != t.Manager { + return false + } + if len(t.FormerMembers) > 0 || t.FormerManager != "" { + return false + } + return len(f.Children(id)) == 0 +} + +// Dissolve forgets team id when it is [File.Dissolvable], manager is still its +// manager, and its Traffic is empty. It is checked and written under the +// store's lock, so a member added since the caller looked is seen. The team's +// directory goes with it. No conversation is touched. +func Dissolve(profileDir, id, manager string) error { + err := Update(profileDir, func(f *File) error { + t, ok := f.Team(id) + if !ok { + return fmt.Errorf("no team %s", id) + } + if t.Manager != manager { + return errors.New("this conversation no longer manages that team") + } + if !f.Dissolvable(id) || trafficSaid(profileDir, id) { + return ErrNotDissolvable + } + kept := f.Teams[:0:0] + for _, other := range f.Teams { + if other.ID != id { + kept = append(kept, other) + } + } + f.Teams = kept + return nil + }) + if err != nil { + return err + } + if safeTeamID(id) == nil { + if err := os.RemoveAll(TeamDir(profileDir, id)); err != nil { + return err + } + } + forgetPackets(profileDir) + return nil +} + +// trafficSaid reports whether team id's Traffic has anything in it, either +// file of it. An unreadable log counts as said, so a doubt keeps the team. +func trafficSaid(profileDir, id string) bool { + path := TrafficPath(profileDir, id) + for _, file := range []string{path, trafficRotated(path)} { + info, err := os.Stat(file) + if err == nil && info.Size() > 0 { + return true + } + if err != nil && !os.IsNotExist(err) { + return true + } + } + return false +} diff --git a/internal/teams/dissolve_test.go b/internal/teams/dissolve_test.go new file mode 100644 index 0000000000..b3aaffec47 --- /dev/null +++ b/internal/teams/dissolve_test.go @@ -0,0 +1,117 @@ +package teams + +import ( + "errors" + "os" + "testing" + "time" +) + +// leadOnly is a profile holding one team, `solo`, whose one member is its +// manager, beside a second team that has nothing to do with it. +func leadOnly(t *testing.T) string { + t.Helper() + dir := t.TempDir() + must(t, Update(dir, func(f *File) error { + f.Teams = append(f.Teams, Team{ID: "aaaaaaaaaaaa", Name: "solo"}, Team{ID: "cccccccccccc", Name: "yard"}) + must(t, f.AddMember("aaaaaaaaaaaa", Member{Key: "lead", File: "lead", Word: "the lead"})) + must(t, f.AddMember("cccccccccccc", Member{Key: "lead", File: "lead", Word: "the lead"})) + must(t, f.AddMember("cccccccccccc", Member{Key: "y1", File: "y1", Word: "yard work"})) + return f.SetManager("aaaaaaaaaaaa", "lead") + })) + return dir +} + +// A TEAM THAT NEVER HELD ANYONE BUT ITS MANAGER IS FORGOTTEN WHOLE: its record +// and its directory go, and the manager's other memberships stay. +func TestDissolveForgetsATeamThatOnlyEverHeldItsManager(t *testing.T) { + dir := leadOnly(t) + must(t, os.MkdirAll(TeamDir(dir, "aaaaaaaaaaaa"), 0o700)) + must(t, Dissolve(dir, "aaaaaaaaaaaa", "lead")) + f, err := Load(dir) + must(t, err) + if _, ok := f.Team("aaaaaaaaaaaa"); ok { + t.Fatal("the dissolved team is still in the file") + } + if len(f.ClosedTeams()) != 0 { + t.Fatal("dissolving left a disbanded row behind") + } + yard, ok := f.Team("cccccccccccc") + if !ok || !yard.Holds("lead") { + t.Fatal("dissolving touched the manager's other membership") + } + if _, err := os.Stat(TeamDir(dir, "aaaaaaaaaaaa")); !os.IsNotExist(err) { + t.Fatalf("the team's directory survived: %v", err) + } +} + +// EVERY SIGN THAT THE TEAM WAS USED SHUTS THE DOOR, and a refusal writes +// nothing: a second member, a removed member, a former manager, a team under +// it, a line of Traffic, a disband, and a caller that is not its manager. +func TestDissolveRefusesATeamThatWasUsed(t *testing.T) { + cases := []struct { + name string + used func(t *testing.T, dir string) + manager string + }{ + {"a second member", func(t *testing.T, dir string) { + must(t, Update(dir, func(f *File) error { return f.AddMember("aaaaaaaaaaaa", Member{Key: "web", File: "web"}) })) + }, "lead"}, + {"a member since removed", func(t *testing.T, dir string) { + must(t, Update(dir, func(f *File) error { return f.AddMember("aaaaaaaaaaaa", Member{Key: "web", File: "web"}) })) + must(t, Update(dir, func(f *File) error { return f.RemoveMember("aaaaaaaaaaaa", "web") })) + }, "lead"}, + {"a team under it", func(t *testing.T, dir string) { + must(t, Update(dir, func(f *File) error { + f.Teams = append(f.Teams, Team{ID: "bbbbbbbbbbbb", Name: "under", Parent: "aaaaaaaaaaaa"}) + return nil + })) + }, "lead"}, + {"a line of traffic", func(t *testing.T, dir string) { + must(t, AppendTraffic(dir, "aaaaaaaaaaaa", Entry{Kind: KindNote, From: FromManager, To: ToEveryone, Text: "hello"})) + }, "lead"}, + {"a disband", func(t *testing.T, dir string) { + must(t, Update(dir, func(f *File) error { return f.Disband("aaaaaaaaaaaa", time.Time{}, "") })) + }, "lead"}, + {"another conversation asking", func(t *testing.T, dir string) {}, "y1"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + dir := leadOnly(t) + c.used(t, dir) + before, err := os.ReadFile(Path(dir)) + must(t, err) + if err := Dissolve(dir, "aaaaaaaaaaaa", c.manager); err == nil { + t.Fatal("a used team was dissolved") + } + after, err := os.ReadFile(Path(dir)) + must(t, err) + if string(before) != string(after) { + t.Fatal("a refused dissolve wrote the teams file") + } + }) + } +} + +// THE ROOT IS NEVER DISSOLVABLE, even holding only its global manager. +func TestTheRootIsNotDissolvable(t *testing.T) { + f := &File{Teams: []Team{{ID: "rrrrrrrrrrrr", Name: "All teams", Root: true, Manager: "g", Members: []Member{{Key: "g"}}}}} + if f.Dissolvable("rrrrrrrrrrrr") { + t.Fatal("the root reads as dissolvable") + } +} + +// A TEAM ANOTHER WRITER FILLED SINCE THE CALLER LOOKED is refused under the +// store's lock, with the error a caller can name. +func TestDissolveSeesAMemberAddedSinceTheCallerLooked(t *testing.T) { + dir := leadOnly(t) + f, err := Load(dir) + must(t, err) + if !f.Dissolvable("aaaaaaaaaaaa") { + t.Fatal("the fixture is not dissolvable") + } + must(t, Update(dir, func(f *File) error { return f.AddMember("aaaaaaaaaaaa", Member{Key: "web", File: "web"}) })) + if err := Dissolve(dir, "aaaaaaaaaaaa", "lead"); !errors.Is(err, ErrNotDissolvable) { + t.Fatalf("dissolve answered %v, want ErrNotDissolvable", err) + } +} diff --git a/internal/tui3/app.go b/internal/tui3/app.go index 3384e04d88..2bbd96483c 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -1462,6 +1462,9 @@ type app struct { deletedSessionRows map[tasksKey]bool deleteTask func(file, id string) error tmemberStart teamMemberStart + // helpers is a `/helpers` waiting for the store to take its team before + // the conversation is asked for its helpers (helpers.go). + helpers helpersPending // tp is the teams page's own state: its selection, its reading of the // store and the targets it drew (teamspage.go). tp teamsPage @@ -7972,6 +7975,10 @@ func (a *app) slash(line string) tea.Cmd { // THE TEAM-LEVEL VIEW, which is a place (place_teams.go). return a.showPage(pageTeams) + case "helpers": + // THIS CONVERSATION AS A TEAM'S MANAGER, asked for helpers (helpers.go). + return a.runHelpersCommand(rest) + case "spend": // AND THE WHOLE MACHINE'S BILL, which is a place and not a note. This word // was an alias of /cost until this wave, so the one guess a developer makes diff --git a/internal/tui3/caption.go b/internal/tui3/caption.go index f0be25c4e7..f411b1b435 100644 --- a/internal/tui3/caption.go +++ b/internal/tui3/caption.go @@ -378,6 +378,8 @@ func teamCaptionGloss(e entry) string { return "passing a decision up" case "team_close_report": return "writing the closing report" + case "team_dissolve": + return "dissolving the team" } return "" } @@ -478,7 +480,7 @@ func captionVerb(tool string) string { return "listing" case "team_send", "team_post": return "sending" - case "team_start", "team_stop", "team_read", "team_status", "team_raise", "team_decide", "team_escalate", "team_close_report": + case "team_start", "team_stop", "team_read", "team_status", "team_raise", "team_decide", "team_escalate", "team_close_report", "team_dissolve": return "managing" default: return firstNonEmpty(tool, "working") @@ -494,27 +496,28 @@ func captionVerb(tool string) string { // change, and guessing one would garble the only part of the row that is quoted // rather than composed. var captionPastVerbs = map[string]string{ - "asking": "asked", - "building": "built", - "checking": "checked", - "deciding": "decided", - "editing": "edited", - "fetching": "fetched", - "listing": "listed", - "looking": "looked", - "managing": "managed", - "messaging": "messaged", - "passing": "passed", - "posting": "posted", - "raising": "raised", - "reading": "read", - "running": "ran", - "searching": "searched", - "sending": "sent", - "starting": "started", - "stopping": "stopped", - "working": "worked", - "writing": "wrote", + "asking": "asked", + "building": "built", + "checking": "checked", + "deciding": "decided", + "dissolving": "dissolved", + "editing": "edited", + "fetching": "fetched", + "listing": "listed", + "looking": "looked", + "managing": "managed", + "messaging": "messaged", + "passing": "passed", + "posting": "posted", + "raising": "raised", + "reading": "read", + "running": "ran", + "searching": "searched", + "sending": "sent", + "starting": "started", + "stopping": "stopped", + "working": "worked", + "writing": "wrote", } // captionPast is the one door onto that table: a floor caption composed in the diff --git a/internal/tui3/commands.go b/internal/tui3/commands.go index 2f2d97b076..4b6244df53 100644 --- a/internal/tui3/commands.go +++ b/internal/tui3/commands.go @@ -155,6 +155,12 @@ var commands = []command{ // conversations big, and this is the team-level view, every member open or // not, the manager's conversation and what waits on you (place_teams.go). {name: "teams", desc: "your teams, their managers and what waits on you · " + placeChord(pageTeams)}, + // AND THE ONE DOOR THAT STARTS FROM THE CONVERSATION RATHER THAN FROM THE + // TEAM, under the page it fills (helpers.go): this conversation becomes a + // new team's manager and proposes helpers. Two rows, the way /manual has + // two, because the words after it go to the manager as what to help with. + {name: "helpers", desc: "makes this conversation a new team's manager, which proposes helpers for you to accept, change or cancel"}, + {name: "helpers", args: "", desc: "…the same, with what you want help with"}, {name: "spend", desc: "what this machine has cost, by the day · " + placeChord(pageSpend)}, // It sits AFTER /compact and before /help because those two are the pair a // person reads together when a conversation has gone wrong: compacting is diff --git a/internal/tui3/helpers.go b/internal/tui3/helpers.go new file mode 100644 index 0000000000..3937c18d17 --- /dev/null +++ b/internal/tui3/helpers.go @@ -0,0 +1,209 @@ +package tui3 + +// /helpers — THIS CONVERSATION BECOMES A TEAM'S MANAGER, AND IS ASKED FOR HELPERS. +// +// Every other door onto a team starts from the team: the Teams page's `+ New AI +// team`, the conversation grid's `+ New team`, Organize, and a manager's own +// sub-team start. Each makes a team first and a manager after, if at all, and +// the manager `+ AI manager` makes is a NEW conversation that knows nothing yet. +// This door starts from the other end. The person is already deep in a piece of +// work, it has grown, and they want it to become a team effort with the +// conversation that knows the work running it. +// +// SO IT IS TWO THINGS, IN THIS ORDER, AND NOTHING MORE. +// +// 1. A new top-level team is made with the conversation in front as its +// manager and only member, in ONE store edit ([app.teamCreateLed]): the +// same edit the Teams page's dialog makes, with the manager line added, so +// every rule a team made there meets is met here — a fresh name, a colour, +// one conversation managing one team and its descendants, no deleted +// conversation readmitted. The strip narrows to it, so the person sees +// `◆ Manager` and the column turns to the team's Traffic. +// 2. Once the store has TAKEN that edit, and only then, the conversation is +// sent one turn, shown in the transcript as `/helpers` and carrying the +// words below ([helpersAskOf]): propose two or three helpers, ask the +// person, start nobody until they agree. Waiting for the store is what +// makes the turn's first step find the conversation already a manager, with +// its verbs on the belt ([teamsWrote], [app.helpersAsked]). +// +// THE PERSON ANSWERS IN THEIR OWN WORDS. There is no card. Yes is a reply the +// manager acts on with `team_start`, which runs under the approval posture every +// other start does; a different list is a reply it proposes again from; and no +// is a reply it acts on with `team_dissolve`, which takes back a team that has +// never held anyone but its manager and is on the belt only while that is true +// (internal/session's team_dissolve.go). After the first helper joins, the team +// ends the way every other team does: disbanded by the person, on Teams. +// +// IT IS ABSENT WHERE IT CANNOT WORK, as the column's door says by not being +// drawn ([app.helpersOffered]): over --host to an engine without the teams +// doors, on a connection that holds one conversation at a time (a helper opens +// beside the manager), on the welcome screen before a conversation exists, and +// for a conversation that already manages a team. + +import ( + "fmt" + "math/rand/v2" + "strings" + + tea "charm.land/bubbletea/v2" +) + +// The words this door says. Each is quoted in the manual exactly as spelled. +const ( + // helpersDoorWord is the side column's door in a chat in no team, and + // helpersDoorHint what the hint line says over it. + helpersDoorWord = "+ Make team" + helpersDoorHint = "Make this conversation the manager of a new team, and ask it to propose helpers" + // helpersPlaceWord is /helpers typed where no conversation is in front. + helpersPlaceWord = "/helpers makes a team of the conversation you are in · open one first" + // helpersFreshWord is /helpers on the welcome screen, before there is a + // conversation to make a team of. + helpersFreshWord = "/helpers makes a team of a conversation you have started · say what you are working on first" + // helpersManagesWord is /helpers in a conversation that already manages a + // team; the strip is narrowed to that team. + helpersManagesWord = "this conversation already manages %s · ask it for helpers" + // helpersPendingWord is a second /helpers while the first is being saved. + helpersPendingWord = "the team is still being saved" + // helpersMadeWord is the receipt, once the store has the team. + helpersMadeWord = "made the team %s · this conversation is its manager" + // helpersAwayWord is the receipt when the person left the conversation + // before the store answered: the team is made and the ask is not sent. + helpersAwayWord = "made the team %s · its manager is the conversation you left; ask it for helpers there" +) + +// helpersShared is the refusal on a connection that holds one conversation at +// a time: a helper is a conversation that opens beside its manager. +var helpersShared = "helpers open beside this conversation, and " + oneConversationWord + +// helpersAskOf is the turn the conversation is sent, for team name, with the +// person's own words after /helpers, if any, at the end. It is written as the +// person speaking, because it is their message: the transcript shows what they +// typed and the model reads this behind it, the way /manual works +// (manualcmd.go). It names both verbs, so the yes and the no each have one. +func helpersAskOf(name, rest string) string { + ask := fmt.Sprintf("I have just turned this conversation into a team called %q, and you are its manager: you keep this conversation and everything in it, and nobody else is in the team yet. "+ + "Propose two or three helpers for the work we are doing here. For each, give a handle (2 to 12 lowercase letters or digits), the part of the work it would take, and the brief you would give it. "+ + "Then ask me to accept them, change the list, or cancel. Start nobody until I agree. "+ + "When I agree, start each one with team_start. If I cancel, take the team back with team_dissolve.", name) + if rest = strings.TrimSpace(rest); rest != "" { + ask += " What I want help with: " + rest + } + return ask +} + +// helpersPending is one /helpers between its edit and the store's answer: the +// edit's notice tie, the conversation it made a manager, the team's name, and +// the turn to send once the store has taken it. due says the answer is in and +// [app.helpersAsked] has not acted on it yet. +type helpersPending struct { + said teamWriteSaid + key string + name string + ask string + shown string + due bool +} + +// helpersTab is the conversation in front as a strip tab, and false when there +// is none to make a team of. +func (a *app) helpersTab() (chatTab, bool) { + if a.agent == nil || a.startingChat() { + return chatTab{}, false + } + key := a.frontTabKey() + if key == "" { + return chatTab{}, false + } + return chatTab{key: key, file: a.file, where: a.workspace, word: a.conversationName()}, true +} + +// helpersOffered reports whether the side column draws its door: the same +// refusals [app.runHelpersCommand] would give, asked of memory only, because a +// frame asks it. +func (a *app) helpersOffered() bool { + if a.teamsOff() || a.shared || !a.wall.loaded || a.helpers.said.pending { + return false + } + if a.agent == nil || a.startingChat() || a.pageShowing() { + return false + } + key := a.frontTabKey() + if key == "" { + return false + } + _, manages := a.teamManaged(key) + return !manages +} + +// runHelpersCommand is /helpers, and the column's door: the team made, and the +// ask queued behind the store's answer. +func (a *app) runHelpersCommand(rest string) tea.Cmd { + if a.pageShowing() { + a.note(helpersPlaceWord) + return nil + } + if a.teamsOff() { + a.note(teamHostedWord) + return nil + } + if a.shared { + a.note(helpersShared) + return nil + } + tab, ok := a.helpersTab() + if !ok { + a.note(helpersFreshWord) + return nil + } + if a.helpers.said.pending { + a.note(helpersPendingWord) + return nil + } + a.teamsEnsure() + if t, manages := a.teamManaged(tab.key); manages { + a.note(fmt.Sprintf(helpersManagesWord, t.Name)) + return a.teamActivate(t.ID) + } + name := teamFreshName([]chatTab{tab}, a.teamNames(), "", rand.IntN) + hue := nextTeamHue(a.teamHues(""), teamReservedHues(a.pal)) + id, err := a.teamCreateLed(name, []chatTab{tab}, hue, tab.key) + if err != nil { + // THE EDIT REFUSED WHAT THIS WINDOW HOLDS, so nothing was queued and + // nothing changed: the store's own rules, in the store's own words. + a.note("could not make a team of this conversation: " + err.Error()) + return nil + } + // A REFUSAL FROM THE STORE PUTS THE WINDOW BACK the way a refused `+ AI + // manager` does ([app.teamsTake]): the overlay closes and the store's + // teams are taken, so a team the store never had is not drawn. + a.teamsWatchManagerStart(tab.key) + shown := "/helpers" + if rest = strings.TrimSpace(rest); rest != "" { + shown += " " + rest + } + a.helpers = helpersPending{said: a.teamWriteWatch(nil), key: tab.key, name: name, ask: helpersAskOf(name, rest), shown: shown} + return a.teamActivate(id) +} + +// helpersAsked is a /helpers whose write has come back: the receipt and the +// turn when the store took the team, and nothing when it refused it, because +// [app.teamsTake] has already said why. +func (a *app) helpersAsked() tea.Cmd { + p := a.helpers + if !p.due { + return nil + } + a.helpers = helpersPending{} + if p.said.why != "" { + return nil + } + // THE PERSON MAY HAVE LEFT. The turn goes to the conversation in front, + // and a manager's first words sent to another conversation would be the + // wrong conversation's; so the team stands and the ask is left to them. + if a.frontTabKey() != p.key { + a.note(fmt.Sprintf(helpersAwayWord, p.name)) + return nil + } + a.note(fmt.Sprintf(helpersMadeWord, p.name)) + return a.submitShown(p.ask, p.shown, nil) +} diff --git a/internal/tui3/helpers_test.go b/internal/tui3/helpers_test.go new file mode 100644 index 0000000000..4db102d161 --- /dev/null +++ b/internal/tui3/helpers_test.go @@ -0,0 +1,301 @@ +package tui3 + +import ( + "fmt" + "strings" + "testing" + + teamstore "github.com/Agent-Field/codeaf/internal/teams" +) + +// helpersApp is [tabApp] with its teams on a profile of the test's own and no +// team yet: a person three conversations into their work, in front of +// "Shipping the parser" in /tmp/lab. +func helpersApp(t *testing.T) (*app, *fakeAgent) { + t.Helper() + a, _, _ := tabApp(t) + a.profileDir = t.TempDir() + a.teamsEnsure() + fake, _ := a.agent.(*fakeAgent) + if fake == nil { + t.Fatal("the fixture's agent is not the fake") + } + return a, fake +} + +// helpersDisk is the teams this window's profile holds on disk. +func helpersDisk(t *testing.T, a *app) []team { + t.Helper() + disk, err := loadTeams(a.profileDir, nil) + if err != nil { + t.Fatal(err) + } + return disk +} + +// lastPersonLine is the newest line of the person's in the transcript. +func lastPersonLine(t *testing.T, a *app) string { + t.Helper() + for i := len(a.entries) - 1; i >= 0; i-- { + if a.entries[i].kind == entryUser { + return a.entries[i].text + } + } + t.Fatal("no line of the person's in the transcript") + return "" +} + +// transcriptSays reports whether any note in the transcript holds want. +func transcriptSays(a *app, want string) bool { + for _, e := range a.entries { + if e.kind == entryNote && strings.Contains(e.text, want) { + return true + } + } + return false +} + +// /helpers MAKES A TEAM WITH THIS CONVERSATION AS ITS MANAGER, and only then +// asks it for helpers. The team is on disk with the conversation as manager and +// only member; the strip is narrowed to it; the column is the manager's; the +// transcript says `/helpers …` and the model was handed the ask, naming both the +// verb that grows the team and the one that takes it back. +func TestHelpersMakesThisConversationTheManagerAndAsksForHelpers(t *testing.T) { + a, fake := helpersApp(t) + front := a.frontTabKey() + before := len(fake.sent) + typeLine(t, a, "/helpers the flaky tests") + teamsFlush(t, a) + + disk := helpersDisk(t, a) + if len(disk) != 1 { + t.Fatalf("/helpers left %d teams on disk: %+v", len(disk), disk) + } + made := disk[0] + if made.Manager != front || len(made.Members) != 1 || made.Members[0].Key != front { + t.Fatalf("the team is not this conversation alone, as manager: %+v", made) + } + if made.Name != "lab" || made.Parent != "" { + t.Fatalf("the team was named %q under %q, want the folder's name at the top level", made.Name, made.Parent) + } + if active, ok := a.teamActive(); !ok || active.ID != made.ID { + t.Fatal("the strip was not narrowed to the new team") + } + if a.sideKind() != sideKindManager { + t.Fatal("the column does not read this conversation as the team's manager") + } + if hint := a.trafficHint(); !strings.Contains(hint, "manager of lab") { + t.Fatalf("the composer says %q, not that it talks to the manager", hint) + } + + if len(fake.sent) != before+1 { + t.Fatalf("/helpers sent %d messages, want one", len(fake.sent)-before) + } + sent := fake.sent[before] + if sent != helpersAskOf("lab", "the flaky tests") { + t.Fatalf("the model was handed %q", sent) + } + for _, want := range []string{"team_start", "team_dissolve", "Start nobody until I agree", "the flaky tests"} { + if !strings.Contains(sent, want) { + t.Fatalf("the ask does not say %q: %q", want, sent) + } + } + if shown := lastPersonLine(t, a); shown != "/helpers the flaky tests" { + t.Fatalf("the transcript says %q, not what was typed", shown) + } + if !transcriptSays(a, fmt.Sprintf(helpersMadeWord, "lab")) { + t.Fatal("no receipt says the team was made") + } +} + +// THE ASK WAITS FOR THE STORE. Until the write that carries the team is back +// the model is sent nothing, so its first step cannot run as anything but the +// manager; a second /helpers meanwhile makes no second team. +func TestHelpersAsksOnlyOnceTheStoreHasTheTeam(t *testing.T) { + a, fake := helpersApp(t) + before := len(fake.sent) + spend(t, a, a.runHelpersCommand("")) + if len(fake.sent) != before { + t.Fatal("the ask went out before the store took the team") + } + spend(t, a, a.runHelpersCommand("")) + if !transcriptSays(a, helpersPendingWord) { + t.Fatal("a second /helpers while the first was saving did not say so") + } + teamsFlush(t, a) + if len(helpersDisk(t, a)) != 1 { + t.Fatalf("two /helpers made %d teams", len(helpersDisk(t, a))) + } + if len(fake.sent) != before+1 { + t.Fatalf("the store's answer sent %d asks, want one", len(fake.sent)-before) + } +} + +// A TEAM MADE THIS WAY OBEYS THE RULES OF EVERY TEAM. A name already taken is +// not reused; a conversation that already manages a team is not made a second +// team's manager — it is shown its team, and nothing is sent. +func TestHelpersKeepsTheRulesEveryTeamKeeps(t *testing.T) { + a, fake := helpersApp(t) + if _, err := a.teamMake("lab", nil); err != nil { + t.Fatal(err) + } + teamsFlush(t, a) + typeLine(t, a, "/helpers") + teamsFlush(t, a) + var made team + for _, t := range helpersDisk(t, a) { + if t.Manager != "" { + made = t + } + } + if made.ID == "" || strings.EqualFold(made.Name, "lab") { + t.Fatalf("the new team took a name another team has: %+v", helpersDisk(t, a)) + } + + sent := len(fake.sent) + typeLine(t, a, "/helpers") + teamsFlush(t, a) + if n := len(helpersDisk(t, a)); n != 2 { + t.Fatalf("a manager's /helpers made another team: %d teams", n) + } + if !transcriptSays(a, fmt.Sprintf(helpersManagesWord, made.Name)) { + t.Fatal("a manager's /helpers did not say which team it manages") + } + if len(fake.sent) != sent { + t.Fatal("a manager's /helpers sent the ask again") + } + if active, ok := a.teamActive(); !ok || active.ID != made.ID { + t.Fatal("a manager's /helpers did not show the team it manages") + } +} + +// THE STORE'S REFUSAL IS FINAL. Another window made this conversation another +// team's manager after this one loaded: the window's own check passes, the +// store refuses with the one-manager rule, the window takes the store's teams +// back, closes the overlay, and the model is asked nothing. +func TestHelpersRefusedByTheStoreAsksNothing(t *testing.T) { + a, fake := helpersApp(t) + front := a.frontTabKey() + elsewhere := teamstore.NewID() + if err := teamstore.Update(a.profileDir, func(f *teamstore.File) error { + f.Teams = append(f.Teams, teamstore.Team{ID: elsewhere, Name: "orbit"}) + if err := f.AddMember(elsewhere, teamstore.Member{Key: front, File: a.file, Where: a.workspace}); err != nil { + return err + } + return f.SetManager(elsewhere, front) + }); err != nil { + t.Fatal(err) + } + before := len(fake.sent) + spend(t, a, a.runHelpersCommand("")) + teamsFlush(t, a) + if len(fake.sent) != before { + t.Fatal("a refused team still asked the model for helpers") + } + disk := helpersDisk(t, a) + if len(disk) != 1 || disk[0].ID != elsewhere { + t.Fatalf("the store holds %+v, want only the other window's team", disk) + } + if _, ok := a.teamActive(); ok { + t.Fatal("the overlay stayed on a team the store refused") + } + if !transcriptSays(a, "a conversation can manage one team and its descendants") { + t.Fatal("the refusal was not said in the store's words") + } +} + +// WHERE IT CANNOT WORK IT SAYS SO AND MAKES NOTHING: home, which has no +// conversation in front; a connection that holds one conversation at a time, +// where a helper could not open beside its manager; and the welcome screen. +func TestHelpersRefusesWhereItCannotWork(t *testing.T) { + if got := homeFate("helpers", ""); got != fateAnswers { + t.Fatalf("the drop-up says /helpers %q on home", got) + } + + a, fake := helpersApp(t) + a.shared = true + spend(t, a, a.runHelpersCommand("")) + if !transcriptSays(a, helpersShared) || len(helpersDisk(t, a)) != 0 || len(fake.sent) != 0 { + t.Fatal("a one-conversation connection made a team") + } + + a, fake = helpersApp(t) + a.welcome.open, a.welcome.start = true, true + spend(t, a, a.runHelpersCommand("")) + if !transcriptSays(a, helpersFreshWord) || len(helpersDisk(t, a)) != 0 || len(fake.sent) != 0 { + t.Fatal("the welcome screen made a team") + } +} + +// THE COLUMN'S DOOR IS /helpers PRESSED. A chat in no team has `+ Make team` +// beside its Tasks word; pressing it makes the team and asks, as typing does; +// and the chat, now a manager, has the Traffic word there instead. +func TestTheColumnsMakeTeamDoorIsHelpers(t *testing.T) { + a, fake := helpersApp(t) + line, row := a.sideHeadRow(40) + if !strings.Contains(plain(line), helpersDoorWord) { + t.Fatalf("a chat in no team has no %q in its column's header: %q", helpersDoorWord, plain(line)) + } + var door *sideDoor + for i := range row.doors { + if row.doors[i].act.kind == sideActHelpers { + door = &row.doors[i] + } + } + if door == nil || !strings.HasPrefix(door.hint, helpersDoorHint) { + t.Fatalf("the header's doors are %+v", row.doors) + } + before := len(fake.sent) + cmd, _ := a.sidePress(row, door.span.from) + spend(t, a, cmd) + teamsFlush(t, a) + if len(helpersDisk(t, a)) != 1 || len(fake.sent) != before+1 || lastPersonLine(t, a) != "/helpers" { + t.Fatal("pressing the door did not do what /helpers does") + } + line, _ = a.sideHeadRow(40) + if strings.Contains(plain(line), helpersDoorWord) || !strings.Contains(plain(line), sideTrafficWord) { + t.Fatalf("the manager's header is %q", plain(line)) + } +} + +// THE MANAGER'S NO PUTS THE CHAT BACK AS IT WAS. The manager's team_dissolve +// writes the store from the engine (internal/teams' Dissolve); the window's +// Traffic read finds the team gone, closes its overlay, and the column is a +// plain chat's again, with its door. Nothing else in the window moved. +func TestADissolvedTeamLeavesThePlainChatAndItsDoor(t *testing.T) { + a, _ := helpersApp(t) + front := a.frontTabKey() + typeLine(t, a, "/helpers") + teamsFlush(t, a) + made := helpersDisk(t, a)[0] + tabs := len(a.tabList()) + if err := teamstore.Dissolve(a.profileDir, made.ID, front); err != nil { + t.Fatal(err) + } + trafficReadNow(t, a) + if _, ok := a.teamByID(made.ID); ok { + t.Fatal("the window still holds the dissolved team") + } + if _, ok := a.teamActive(); ok { + t.Fatal("the strip is still narrowed to the dissolved team") + } + if a.sideKind() != sideKindPlain || a.frontTabKey() != front { + t.Fatal("the chat in front is not the same plain chat") + } + if got := len(a.tabList()); got != tabs { + t.Fatalf("dissolving changed the strip from %d tabs to %d", tabs, got) + } + line, _ := a.sideHeadRow(40) + if !strings.Contains(plain(line), helpersDoorWord) { + t.Fatalf("the plain chat's door did not come back: %q", plain(line)) + } +} + +// A NARROW COLUMN LOSES THE DOOR, and keeps the key that hides it. +func TestANarrowColumnLosesTheMakeTeamDoorFirst(t *testing.T) { + a, _ := helpersApp(t) + line, _ := a.sideHeadRow(sideColsMin - 6) + if strings.Contains(plain(line), helpersDoorWord) { + t.Fatalf("the door was squeezed into %q", plain(line)) + } +} diff --git a/internal/tui3/homeslash.go b/internal/tui3/homeslash.go index e0bcbe94cd..ff397df8e2 100644 --- a/internal/tui3/homeslash.go +++ b/internal/tui3/homeslash.go @@ -273,6 +273,14 @@ func homeFate(word, rest string) string { // BOTH FORMS ANSWER HERE, and home is where a rule is the person's // everywhere rather than one project's (memory.go's [app.runAlways]). return fateAnswers + case "helpers": + // IT ANSWERS HERE, AND DOES NOTHING ELSE. /helpers makes a team of the + // conversation a person has been working in (helpers.go), and home has + // none in front: opening a fresh one would make a manager of a + // conversation that knows nothing, which is `+ AI manager`'s job, and + // acting on the one behind home would send its ask where nobody can + // read it. So it says where it works. + return fateAnswers } return "" } diff --git a/internal/tui3/sidecol.go b/internal/tui3/sidecol.go index c497c59422..7ad00a3b48 100644 --- a/internal/tui3/sidecol.go +++ b/internal/tui3/sidecol.go @@ -154,6 +154,9 @@ const ( sideActHide // sideActTeams opens the teams page, where a packet is answered. sideActTeams + // sideActHelpers makes the chat in front a new team's manager and asks + // it for helpers: /helpers, pressed (helpers.go). + sideActHelpers ) // sideAct is what a press does. @@ -370,6 +373,26 @@ func (a *app) sideHeadRow(width int) (string, *sideRow) { trafficAct = sideAct{kind: sideActView, view: sideTraffic} } word(sideTasksWord, itoa(len(a.taskOrder)), false, view == sideTasks, tasksAct, "Show this chat's tasks"+hintSegment+"click") + // A CHAT IN NO TEAM IS OFFERED ONE where the Traffic word would stand: the + // door /helpers is (helpers.go), drawn dim like the other word, and only + // where it can work and where it fits beside the key that hides the + // column, which keeps its cell of air. A narrow column loses it first; + // /helpers is always there. + if !team && a.helpersOffered() { + need := ansi.StringWidth(sideWordSep+helpersDoorWord) + 1 + ansi.StringWidth(a.chords.say(sideHideKey)) + if used+need <= width { + b.WriteString(a.pal.dim(sideWordSep)) + used += ansi.StringWidth(sideWordSep) + w := ansi.StringWidth(helpersDoorWord) + painted := a.pal.dim(helpersDoorWord) + if hot == len(row.doors) { + painted = a.pal.cursor(a.pal.ink(helpersDoorWord), 0) + } + row.doors = append(row.doors, sideDoor{span: hudSpan{from: used, to: used + w}, hint: helpersDoorHint + hintSegment + "click", act: sideAct{kind: sideActHelpers}}) + b.WriteString(painted) + used += w + } + } if team { n, fresh := a.sideTrafficCount(t, kind, handle) // WITH THE TRAFFIC IN FRONT NOTHING IN IT IS NEW TO THE WORD: the `new` @@ -837,6 +860,9 @@ func (a *app) sideDo(act sideAct) tea.Cmd { case sideActTeams: a.railTake(false) return a.showPage(pageTeams) + case sideActHelpers: + a.railTake(false) + return a.runHelpersCommand("") } return nil } diff --git a/internal/tui3/teams.go b/internal/tui3/teams.go index b9cfcec979..24823bae26 100644 --- a/internal/tui3/teams.go +++ b/internal/tui3/teams.go @@ -445,6 +445,19 @@ func (a *app) teamCreateIn(name string, tabs []chatTab, hue teamHueSpec, parent } func (a *app) teamMakeInChecked(name string, tabs []chatTab, hue teamHueSpec, parent string, freshOnly bool) (string, error) { + return a.teamMakeLed(name, tabs, hue, parent, freshOnly, "") +} + +// teamCreateLed is [app.teamCreateIn] at the top level with manager, one of +// tabs' keys, made the new team's manager IN THE SAME EDIT, so no reader of the +// store ever sees the team without it and a refusal of either refuses both. It +// is `/helpers`'s door (helpers.go); every rule a team made on the Teams page +// meets is met here, because it is the same edit with one more line. +func (a *app) teamCreateLed(name string, tabs []chatTab, hue teamHueSpec, manager string) (string, error) { + return a.teamMakeLed(name, tabs, hue, "", true, manager) +} + +func (a *app) teamMakeLed(name string, tabs []chatTab, hue teamHueSpec, parent string, freshOnly bool, manager string) (string, error) { a.teamsEnsure() name = strings.TrimSpace(name) if name == "" { @@ -485,6 +498,9 @@ func (a *app) teamMakeInChecked(name string, tabs []chatTab, hue teamHueSpec, pa } f.Teams = append(f.Teams, made) id = fresh + if manager != "" { + return f.SetManager(fresh, manager) + } return nil } // A team remade under its name keeps what its members already had (a diff --git a/internal/tui3/teamseam.go b/internal/tui3/teamseam.go index 83fa43463b..a038be9c98 100644 --- a/internal/tui3/teamseam.go +++ b/internal/tui3/teamseam.go @@ -401,7 +401,9 @@ func (a *app) teamsWrite() tea.Cmd { } return func(bool) tea.Cmd { a.teamsTake(teamsWrote{teams: teams, stamp: stamp, err: err, covers: covers, seqs: seqs, refused: refusedBy}) - return nil + // A /helpers whose team this write carried is asked for its + // helpers now, and not before (helpers.go). + return a.helpersAsked() } })) } diff --git a/internal/tui3/teamwritesaid.go b/internal/tui3/teamwritesaid.go index df98324747..be93ce6302 100644 --- a/internal/tui3/teamwritesaid.go +++ b/internal/tui3/teamwritesaid.go @@ -61,6 +61,11 @@ func (s *teamWriteSaid) settle(w teamsWrote) bool { // a slow write over a connection still shows its notice for the whole time. func (a *app) teamsWriteSettled(w teamsWrote) { a.tmemberStart.said.settle(w) + // A /helpers acts on its answer from the write's own command, which can + // hand back a turn ([app.helpersAsked]); here it is only marked due. + if a.helpers.said.settle(w) { + a.helpers.due = true + } if a.tp.disbandSaid.settle(w) { a.teamsDisbandSaid(a.tp.disbandName, a.tp.disbandSaid.why) } From 1792d21d124e2fe48e9207199a34045d4df1678b Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Mon, 19 Oct 2026 11:50:18 -0400 Subject: [PATCH 02/10] tui3: /helpers lives in the Teams dropdown, and says what it asks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The column button beside Tasks is gone. The Teams dropdown's last row, under a rule, is now always `+ New from this session`: it puts `/helpers` and a default ask in the message box, in place of what was there (kept on the drafts ring), and sends nothing until the person presses enter. Because the row is always there, the `Teams ▾` chip shows with no teams yet; only over --host to an engine without teams is it absent. The dropdown no longer prints an up/down, enter, esc footer. Nothing hidden is sent any more. /helpers sends exactly the words after it, and a bare /helpers sends the default ask and shows it in full in the transcript. The ask is in the person's words and names no tool; the model knows it manages the team from the role note every manager gets. A helper the manager starts opens as a tab on that team's strip: when the manager is in front, the strip is set to the team's overlay, without moving focus or the draft. The cursor no longer lands on the new row when no team is shown, and the long-list window follows the cursor's line, now that a rule sits between the teams and the last row. Co-Authored-By: Claude Opus 5.5 --- internal/manual/chat/commands.md | 2 +- .../manual/chat/conversations-and-teams.md | 19 +- internal/manual/chat/screen.md | 7 +- internal/manual/chat/team-manager.md | 43 ++-- internal/manual/chat_test.go | 3 +- internal/tui3/chatsnavigation_test.go | 12 +- internal/tui3/commands.go | 9 +- internal/tui3/helpers.go | 187 +++++++------- internal/tui3/helpers_test.go | 238 ++++++++++++++---- internal/tui3/sidecol.go | 26 -- internal/tui3/teammenu.go | 70 +++++- internal/tui3/teammenu_test.go | 46 +++- internal/tui3/teamoverlay_test.go | 3 +- internal/tui3/teamtraffic.go | 10 + internal/tui3/wallclick_test.go | 7 +- internal/tui3/wallcontract.go | 1 + 16 files changed, 447 insertions(+), 236 deletions(-) diff --git a/internal/manual/chat/commands.md b/internal/manual/chat/commands.md index dba355616d..1cc0fb649e 100644 --- a/internal/manual/chat/commands.md +++ b/internal/manual/chat/commands.md @@ -323,7 +323,7 @@ command). | `/spend` | none | none | opens the spend place, what this machine has cost, by the day (also `alt+5`) | | `/wall` | | | every saved conversation at once, with live or saved previews and card-based team creation (also `alt+v`, or `▦` under the box) | | `/teams` | | | the teams page: your teams as a tree, what waits on you, member previews and the selected team's interaction table (also `alt+2`, or `teams` on the tab bar) | -| `/helpers` | | `` | makes this conversation a new team's manager, which proposes helpers for you to accept, change or cancel (also `+ Make team` beside `Tasks` in the right-hand column); on home it says to open a conversation first | +| `/helpers` | | `` | makes this conversation a new team's manager, then sends your words after it (bare, a default ask to suggest two or three helpers, shown in full) for you to accept, change or cancel; `+ New from this session`, last in the Teams dropdown, puts `/helpers` and that ask in the box; on home it says to open a conversation first | | `/cost` | `/usage`, `/tokens` | — | prints what this conversation has spent, and on what | | `/effort` | `/think`, `/thinking` | — | opens this conversation's thinking levels; says it is unavailable when the session has no dial | | `/effort` | `/think`, `/thinking` | `` | sets this conversation's thinking level; an unknown rung lists the accepted levels and changes nothing | diff --git a/internal/manual/chat/conversations-and-teams.md b/internal/manual/chat/conversations-and-teams.md index 278373f14d..278ec769b0 100644 --- a/internal/manual/chat/conversations-and-teams.md +++ b/internal/manual/chat/conversations-and-teams.md @@ -7,10 +7,11 @@ with no team overlay selected. Clearing the team's visual overlay does not end membership, change reporting authority, or stop background work. The composer has no team-recipient hint and the sidebar uses the ordinary task view. -With no overlay, the strip shows **Teams ▾**. Selecting a team replaces it with a coloured +With no overlay, the strip shows **Teams ▾**, even before you have a team. Selecting a team replaces it with a coloured team chip and a separate **x**. Click the name to switch teams; click x to remove the overlay and restore ordinary Chats. Clearing selects **All teams** in Teams without -changing membership or background work. The dropdown lists actual teams only, with no **None** option. +changing membership or background work. The dropdown lists actual teams, with no **None** option, +and ends with **+ New from this session**, which makes a team of the conversation in front. The **▦ All** button still opens the independent conversation grid. The Teams dropdown is the only team selector in Chats; there are no membership buttons @@ -216,14 +217,18 @@ them; Undo does not reopen them. Organization otherwise only adds memberships: i not rename teams or remove conversations. Nothing runs automatically. Press Organize again to refresh suggestions. The conversation grid has no Organize or Undo control. -## The team switcher on the tab strip +## The team switcher on the tab strip — the Teams dropdown and + New from this session The dropdown is first on the Chats tab strip. With no overlay its label is **Teams ▾**; with one selected it shows that team's name, colour and a separate **x** to clear it. The dropdown offers **All teams** when a global manager exists, then the active team hierarchy. All teams selects the global-manager overlay; it is separate from the team-agnostic `▦ All` grid. A root without a manager or with a known missing manager transcript has no All teams choice. -The picker has no None option or management actions, and is absent when no overlay is available. +The picker has no None option or management actions. Its last row, under a rule, is always +**+ New from this session**: it puts `/helpers` and a default ask in the message box, replacing +what was there (that draft is kept in `/drafts`), and nothing happens until you edit it +and press enter (see *Turn this conversation into a team* on the team manager page). So the +chip is there even with no teams yet; it is absent only over `--host` to an engine without teams. Closed teams are available through Show closed on Teams, not through this picker. Selecting a team restores its conversation and horizontal strip position. A new overlay @@ -239,9 +244,9 @@ it does not select an overlay. A **manager** is a conversation that runs the team: you talk to it, it sends work to members and reports progress. Teams offers **Choose AI manager** for existing members and **+ Manager** -when a team has none. In the conversation you are working in, **`/helpers`** (or `+ Make -team` in the right-hand column) makes a new team with that conversation as its manager, -which then proposes helpers for you to accept, change or cancel. Add a new member first when you need a new conversation to replace +when a team has none. In the conversation you are working in, **`/helpers`** (or **`+ New +from this session`**, last in the Teams dropdown) makes a new team with that conversation +as its manager, which then proposes helpers for you to accept, change or cancel. Add a new member first when you need a new conversation to replace an existing manager. All teams has a dedicated Global manager card. `+ Global manager` creates its optional conversation. Its reports are the managers of open top-level teams; subteam managers report to their parent. Its card's `x` deletes the global-manager conversation with the diff --git a/internal/manual/chat/screen.md b/internal/manual/chat/screen.md index b7b7d29af7..2c466a1803 100644 --- a/internal/manual/chat/screen.md +++ b/internal/manual/chat/screen.md @@ -31,8 +31,7 @@ row is not there and the box sits straight under the gap. Beside the conversation, on the right, the column (the right-hand bar, sidebar, task panel, whatever you call it) takes a quarter of the frame, 28 to 40 columns (30 at 120), from the session's first keystroke, before any tasks exist. It is the same column in every -chat, and its header is `Tasks N`, or `Tasks N · Traffic N new` in a chat in a team; a chat in -no team also has `+ Make team` there (`/helpers`, on the team manager page). An untouched empty +chat, and its header is `Tasks N`, or `Tasks N · Traffic N new` in a chat in a team. An untouched empty conversation opens without it: no column, no doors, no rule, no telemetry, just the centred greeting with the message box inside it (see *The empty screen* page); the column stands the moment you type, or at once if a task is already here. Under the @@ -3328,9 +3327,7 @@ Sections are one blank line apart; the band's rule is the one rule in the column The top of the right-hand column, above the sections under its list: -- **The header** is `Tasks N`, and in a chat in a team `Tasks N · Traffic N new`. In a chat - in no team, where it fits, `Tasks N · + Make team`: pressing it is `/helpers`, which makes - this conversation a new team's manager and asks it to propose helpers. The word +- **The header** is `Tasks N`, and in a chat in a team `Tasks N · Traffic N new`. The word in front is bold ink and the other dim; each is a button with a hover ground and a hint, and the other word keeps its count, so what arrives on the Traffic while you read the tasks still says `2 new`. Which word is in front is remembered for the session per kind diff --git a/internal/manual/chat/team-manager.md b/internal/manual/chat/team-manager.md index 1489a1281e..5edd4a7ec8 100644 --- a/internal/manual/chat/team-manager.md +++ b/internal/manual/chat/team-manager.md @@ -55,9 +55,9 @@ its tab strip and never scrolls away: - **Choose AI manager** on Teams assigns an existing member. To use a new conversation, first use Add chat, then Choose AI manager. The Chats dropdown and conversation grid offer no manager-assignment controls. -- **`/helpers`**, or **`+ Make team`** in the right-hand column, in the conversation you are - working in: a new team is made with that conversation as its manager, and it proposes - helpers (see **Turn this conversation into a team**). +- **`/helpers`**, or **`+ New from this session`** last in the Teams dropdown, in the + conversation you are working in: a new team is made with that conversation as its + manager, and it proposes helpers (see **Turn this conversation into a team**). The manager's place reads **`◆ Manager`**. Its tile in the team-agnostic grid keeps its ordinary title and position. The tab's hint names its team and title. `alt+m` goes to the manager from any conversation in the team. @@ -69,32 +69,33 @@ If the handle is already taken in that team, a number is added. Another manager `team_send` before its first turn. Once its title is made, the ordinary title-based handle choice may replace that fallback. -## Turn this conversation into a team — /helpers, the + Make team button, convert a chat, bring in helpers +## Turn this conversation into a team — /helpers, + New from this session, convert a chat, bring in helpers You are working in a conversation, the work has grown, and you want helpers with this -conversation in charge. Type `/helpers`, or `/helpers `, or press -**`+ Make team`** beside `Tasks` at the top of the right-hand column. Two things happen: +conversation in charge. Open the **Teams ▾** dropdown at the left of the tab strip and choose +its last row, **`+ New from this session`**. It puts this in the message box, in place of +what was there, and sends nothing: + +`/helpers Suggest two or three helpers for what we're working on here. For each, say what it would take on and the brief you'd give it. Don't start anyone until I agree, and if I say cancel, undo the team.` + +Edit the words into the team you want, then press enter; typing `/helpers` yourself does +the same, and a bare `/helpers` sends exactly that default. Two things happen: 1. A new top-level team is made with **this conversation as its manager** and only member, - in one save. It is named after the conversation's folder, or a word such as `harbor` - when a team already has that name, and takes the next free colour. The strip narrows to - it, the tab reads `◆ Manager`, and the column turns to the team's Traffic. The rules of - every team apply: a name no other team has, and one conversation manages one team and - its subteams. Rename it or change its colour in Teams, under `Settings`. -2. Once the team is saved, the conversation is sent one message, shown as `/helpers`: it - proposes two or three helpers — a handle, the part of the work, and the brief — and asks - you to accept them, change the list, or cancel. It starts nobody until you agree. + in one save, named after the conversation's folder (or a word such as `harbor` when that + name is taken), in the next free colour. The strip narrows to it and reads `◆ Manager`. + Every team's rules apply; rename it in Teams, under `Settings`. +2. Once it is saved, your words after `/helpers` are sent as your message, exactly as the + transcript shows them. The conversation now knows it is the team's manager. The receipt reads `made the team lab · this conversation is its manager`. Reply in your own -words; there is no card. Yes, and it starts each helper with `team_start`, under your -approval posture like any start. A different list, and it proposes again. Cancel, and it -takes the team back with `team_dissolve` (see **Cancel a team nobody joined**). Nothing is -copied: the manager keeps this conversation, its history and its tasks. +words. Yes, and it starts each helper with `team_start`, under your approval posture; each +opens as a tab on this team's strip, while you stay in front of the manager. A different +list, and it proposes again. Cancel, and it takes the team back with `team_dissolve`. ## When /helpers is not offered, and what it says -`+ Make team` is drawn only in a chat in no team, where it can work and where it fits; a -narrow column drops it first. `/helpers` always answers: +`/helpers`, and `+ New from this session`, answer with a sentence and change nothing: - On home, or any page: `/helpers makes a team of the conversation you are in · open one first`. - On the welcome screen of a new chat: `/helpers makes a team of a conversation you have started · say what you are working on first`. @@ -113,7 +114,7 @@ conversation before the save finishes, the team stands and the message is not se Say no to the proposed helpers, or "cancel", and the manager calls `team_dissolve`. The team is forgotten as if it had never been made: no row under `Show closed`, nothing stopped, and the conversation, its history and its tasks stay. The strip goes back to all -your chats and the column to `Tasks`, with `+ Make team` again. +your chats and the column to `Tasks`. A manager has `team_dissolve` only while its team has never held anyone but it: no member, no former member, no subteam, and nothing in its Traffic. The rule is the same for diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index d99e5ab4a3..95f3d47c92 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -4125,7 +4125,8 @@ func TestHelpersQuestionsReachTheirSections(t *testing.T) { {"how do I turn this conversation into a team", "Turn this conversation into a team"}, {"convert this chat to a team with it as the manager", "Turn this conversation into a team"}, {"bring in helpers for what I am working on", "Turn this conversation into a team"}, - {"what does the + Make team button do", "Turn this conversation into a team"}, + {"what does + New from this session in the Teams dropdown do", "Turn this conversation into a team"}, + {"what does /helpers send to the model", "Turn this conversation into a team"}, {"why is /helpers not offered here", "When /helpers is not offered"}, {"/helpers says open one first", "When /helpers is not offered"}, {"I said no to the helpers, how do I cancel the team", "Cancel a team nobody joined"}, diff --git a/internal/tui3/chatsnavigation_test.go b/internal/tui3/chatsnavigation_test.go index 7a109a8e0d..224d7afd95 100644 --- a/internal/tui3/chatsnavigation_test.go +++ b/internal/tui3/chatsnavigation_test.go @@ -194,18 +194,24 @@ func TestTeamMenuLongTreeScrollsAndTinyResizeCannotChoose(t *testing.T) { if !a.teamMenu.card.holds(a.teamMenu.card.x0, a.teamMenu.card.y0) || len(a.teamMenu.hits) >= len(a.teamMenuRows()) { t.Fatal("long picker was not bounded") } - for range 37 { + // The cursor counts the rows it can land on; the last is `+ New from this + // session`, under a rule that is a line and not a pick (teammenu.go). + picks := teamMenuPicks(a.teamMenuRows()) + for range len(picks) { drive(t, a, key("down")) menuFrame(t, a) } - chosen := a.teamMenuRows()[a.teamMenu.cursor] + chosen := picks[a.teamMenu.cursor] + if chosen.code != wallPopHelpers { + t.Fatalf("the last row the cursor reaches is %+v", chosen) + } hit := menuHit(t, a, chosen.code, chosen.id) if !a.teamMenu.card.holds(hit.x0, hit.y0) || a.teamMenu.top == 0 { t.Fatal("selected last row is offscreen") } drive(t, a, tea.MouseWheelMsg{X: hit.x0, Y: hit.y0, Button: tea.MouseWheelUp}) menuFrame(t, a) - if !a.teamMenu.on || a.teamMenu.cursor >= 37 || a.wall.activeID != harbor { + if !a.teamMenu.on || a.teamMenu.cursor >= len(picks) || a.wall.activeID != harbor { t.Fatal("wheel escaped the picker or selected a team") } for width := 22; width <= 80; width++ { diff --git a/internal/tui3/commands.go b/internal/tui3/commands.go index 4b6244df53..a547e1e222 100644 --- a/internal/tui3/commands.go +++ b/internal/tui3/commands.go @@ -157,10 +157,11 @@ var commands = []command{ {name: "teams", desc: "your teams, their managers and what waits on you · " + placeChord(pageTeams)}, // AND THE ONE DOOR THAT STARTS FROM THE CONVERSATION RATHER THAN FROM THE // TEAM, under the page it fills (helpers.go): this conversation becomes a - // new team's manager and proposes helpers. Two rows, the way /manual has - // two, because the words after it go to the manager as what to help with. - {name: "helpers", desc: "makes this conversation a new team's manager, which proposes helpers for you to accept, change or cancel"}, - {name: "helpers", args: "", desc: "…the same, with what you want help with"}, + // new team's manager and is sent the words after the command. Two rows, + // the way /manual has two, because bare it sends a default ask, written + // out in the transcript, and with words it sends those instead. + {name: "helpers", desc: "makes this conversation a new team's manager and asks it to suggest helpers, for you to accept, change or cancel"}, + {name: "helpers", args: "", desc: "…the same, sending your own words instead of the default ask"}, {name: "spend", desc: "what this machine has cost, by the day · " + placeChord(pageSpend)}, // It sits AFTER /compact and before /help because those two are the pair a // person reads together when a conversation has gone wrong: compacting is diff --git a/internal/tui3/helpers.go b/internal/tui3/helpers.go index 3937c18d17..7e1815a289 100644 --- a/internal/tui3/helpers.go +++ b/internal/tui3/helpers.go @@ -19,26 +19,33 @@ package tui3 // one conversation managing one team and its descendants, no deleted // conversation readmitted. The strip narrows to it, so the person sees // `◆ Manager` and the column turns to the team's Traffic. -// 2. Once the store has TAKEN that edit, and only then, the conversation is -// sent one turn, shown in the transcript as `/helpers` and carrying the -// words below ([helpersAskOf]): propose two or three helpers, ask the -// person, start nobody until they agree. Waiting for the store is what -// makes the turn's first step find the conversation already a manager, with -// its verbs on the belt ([teamsWrote], [app.helpersAsked]). +// 2. Once the store has TAKEN that edit, and only then, the person's words +// after `/helpers` are sent to the conversation as their message. Waiting +// for the store is what makes the turn's first step find the conversation +// already a manager, with its role note and its verbs ([teamsWrote], +// [app.helpersAsked]). +// +// NOTHING IS SENT THAT THE PERSON DID NOT SEE. The words the model reads are +// the words in the transcript after `/helpers`, exactly. A bare `/helpers` is +// [helpersPrompt], and the transcript shows it written out after the command, +// so the line on screen is the whole of what was asked. What the model knows +// beyond those words is what every manager knows: the role note internal/session +// lands at its first step, and its verbs' own descriptions. +// +// THE TEAMS DROPDOWN'S LAST ROW IS THE SAME DOOR, ONE STEP EARLIER. `+ New from +// this session` (teammenu.go) puts `/helpers ` and [helpersPrompt] in the box, +// in place of whatever was there, and sends nothing: the person reads the +// request, changes it into the team they want, and presses enter. // // THE PERSON ANSWERS IN THEIR OWN WORDS. There is no card. Yes is a reply the // manager acts on with `team_start`, which runs under the approval posture every -// other start does; a different list is a reply it proposes again from; and no -// is a reply it acts on with `team_dissolve`, which takes back a team that has -// never held anyone but its manager and is on the belt only while that is true -// (internal/session's team_dissolve.go). After the first helper joins, the team -// ends the way every other team does: disbanded by the person, on Teams. -// -// IT IS ABSENT WHERE IT CANNOT WORK, as the column's door says by not being -// drawn ([app.helpersOffered]): over --host to an engine without the teams -// doors, on a connection that holds one conversation at a time (a helper opens -// beside the manager), on the welcome screen before a conversation exists, and -// for a conversation that already manages a team. +// other start does, and each helper it starts opens as a tab in this team's +// overlay (teamtraffic.go's [app.trafficStarted]); a different list is a reply +// it proposes again from; and cancel is a reply it acts on with +// `team_dissolve`, which takes back a team that has never held anyone but its +// manager and is on the belt only while that is true (internal/session's +// team_dissolve.go). After the first helper joins, the team ends the way every +// other team does: disbanded by the person, on Teams. import ( "fmt" @@ -48,12 +55,19 @@ import ( tea "charm.land/bubbletea/v2" ) +// helpersPrompt is what a bare /helpers asks, and what the dropdown's row puts +// in the box after `/helpers `. It is the person speaking, in words they would +// use, because it is their message and it is on their screen: it names no tool +// and nothing the person could not have typed. Its three asks are the three +// things the person needs from the manager: a proposal to read, nobody started +// before they agree, and a way to say no. +const helpersPrompt = "Suggest two or three helpers for what we're working on here. For each, say what it would take on and the brief you'd give it. Don't start anyone until I agree, and if I say cancel, undo the team." + // The words this door says. Each is quoted in the manual exactly as spelled. const ( - // helpersDoorWord is the side column's door in a chat in no team, and - // helpersDoorHint what the hint line says over it. - helpersDoorWord = "+ Make team" - helpersDoorHint = "Make this conversation the manager of a new team, and ask it to propose helpers" + // helpersRowWord is the Teams dropdown's last row, which puts /helpers and + // [helpersPrompt] in the box. + helpersRowWord = "+ New from this session" // helpersPlaceWord is /helpers typed where no conversation is in front. helpersPlaceWord = "/helpers makes a team of the conversation you are in · open one first" // helpersFreshWord is /helpers on the welcome screen, before there is a @@ -67,7 +81,7 @@ const ( // helpersMadeWord is the receipt, once the store has the team. helpersMadeWord = "made the team %s · this conversation is its manager" // helpersAwayWord is the receipt when the person left the conversation - // before the store answered: the team is made and the ask is not sent. + // before the store answered: the team is made and the message is not sent. helpersAwayWord = "made the team %s · its manager is the conversation you left; ask it for helpers there" ) @@ -75,26 +89,10 @@ const ( // a time: a helper is a conversation that opens beside its manager. var helpersShared = "helpers open beside this conversation, and " + oneConversationWord -// helpersAskOf is the turn the conversation is sent, for team name, with the -// person's own words after /helpers, if any, at the end. It is written as the -// person speaking, because it is their message: the transcript shows what they -// typed and the model reads this behind it, the way /manual works -// (manualcmd.go). It names both verbs, so the yes and the no each have one. -func helpersAskOf(name, rest string) string { - ask := fmt.Sprintf("I have just turned this conversation into a team called %q, and you are its manager: you keep this conversation and everything in it, and nobody else is in the team yet. "+ - "Propose two or three helpers for the work we are doing here. For each, give a handle (2 to 12 lowercase letters or digits), the part of the work it would take, and the brief you would give it. "+ - "Then ask me to accept them, change the list, or cancel. Start nobody until I agree. "+ - "When I agree, start each one with team_start. If I cancel, take the team back with team_dissolve.", name) - if rest = strings.TrimSpace(rest); rest != "" { - ask += " What I want help with: " + rest - } - return ask -} - // helpersPending is one /helpers between its edit and the store's answer: the // edit's notice tie, the conversation it made a manager, the team's name, and -// the turn to send once the store has taken it. due says the answer is in and -// [app.helpersAsked] has not acted on it yet. +// the message to send once the store has taken it. due says the answer is in +// and [app.helpersAsked] has not acted on it yet. type helpersPending struct { said teamWriteSaid key string @@ -117,53 +115,45 @@ func (a *app) helpersTab() (chatTab, bool) { return chatTab{key: key, file: a.file, where: a.workspace, word: a.conversationName()}, true } -// helpersOffered reports whether the side column draws its door: the same -// refusals [app.runHelpersCommand] would give, asked of memory only, because a -// frame asks it. -func (a *app) helpersOffered() bool { - if a.teamsOff() || a.shared || !a.wall.loaded || a.helpers.said.pending { - return false - } - if a.agent == nil || a.startingChat() || a.pageShowing() { - return false - } - key := a.frontTabKey() - if key == "" { - return false - } - _, manages := a.teamManaged(key) - return !manages -} - -// runHelpersCommand is /helpers, and the column's door: the team made, and the -// ask queued behind the store's answer. -func (a *app) runHelpersCommand(rest string) tea.Cmd { - if a.pageShowing() { - a.note(helpersPlaceWord) - return nil - } - if a.teamsOff() { - a.note(teamHostedWord) - return nil - } - if a.shared { - a.note(helpersShared) - return nil +// helpersRefusal is why /helpers cannot make a team of the conversation in +// front, "" when it can, and the team it already manages when that is why. It +// is asked by the command and by the dropdown's row, so the row never fills the +// box with a request the command would refuse. +func (a *app) helpersRefusal() (string, team) { + switch { + case a.pageShowing(): + return helpersPlaceWord, team{} + case a.teamsOff(): + return teamHostedWord, team{} + case a.shared: + return helpersShared, team{} } tab, ok := a.helpersTab() if !ok { - a.note(helpersFreshWord) - return nil + return helpersFreshWord, team{} } if a.helpers.said.pending { - a.note(helpersPendingWord) - return nil + return helpersPendingWord, team{} } a.teamsEnsure() if t, manages := a.teamManaged(tab.key); manages { - a.note(fmt.Sprintf(helpersManagesWord, t.Name)) - return a.teamActivate(t.ID) + return fmt.Sprintf(helpersManagesWord, t.Name), t + } + return "", team{} +} + +// runHelpersCommand is /helpers: the team made, and the person's words queued +// behind the store's answer. With no words after the command they are +// [helpersPrompt], and the transcript shows them. +func (a *app) runHelpersCommand(rest string) tea.Cmd { + if why, managed := a.helpersRefusal(); why != "" { + a.note(why) + if managed.ID != "" { + return a.teamActivate(managed.ID) + } + return nil } + tab, _ := a.helpersTab() name := teamFreshName([]chatTab{tab}, a.teamNames(), "", rand.IntN) hue := nextTeamHue(a.teamHues(""), teamReservedHues(a.pal)) id, err := a.teamCreateLed(name, []chatTab{tab}, hue, tab.key) @@ -177,17 +167,17 @@ func (a *app) runHelpersCommand(rest string) tea.Cmd { // manager` does ([app.teamsTake]): the overlay closes and the store's // teams are taken, so a team the store never had is not drawn. a.teamsWatchManagerStart(tab.key) - shown := "/helpers" - if rest = strings.TrimSpace(rest); rest != "" { - shown += " " + rest + ask := strings.TrimSpace(rest) + if ask == "" { + ask = helpersPrompt } - a.helpers = helpersPending{said: a.teamWriteWatch(nil), key: tab.key, name: name, ask: helpersAskOf(name, rest), shown: shown} + a.helpers = helpersPending{said: a.teamWriteWatch(nil), key: tab.key, name: name, ask: ask, shown: "/helpers " + ask} return a.teamActivate(id) } // helpersAsked is a /helpers whose write has come back: the receipt and the -// turn when the store took the team, and nothing when it refused it, because -// [app.teamsTake] has already said why. +// message when the store took the team, and nothing when it refused it, +// because [app.teamsTake] has already said why. func (a *app) helpersAsked() tea.Cmd { p := a.helpers if !p.due { @@ -197,7 +187,7 @@ func (a *app) helpersAsked() tea.Cmd { if p.said.why != "" { return nil } - // THE PERSON MAY HAVE LEFT. The turn goes to the conversation in front, + // THE PERSON MAY HAVE LEFT. The message goes to the conversation in front, // and a manager's first words sent to another conversation would be the // wrong conversation's; so the team stands and the ask is left to them. if a.frontTabKey() != p.key { @@ -207,3 +197,30 @@ func (a *app) helpersAsked() tea.Cmd { a.note(fmt.Sprintf(helpersMadeWord, p.name)) return a.submitShown(p.ask, p.shown, nil) } + +// helpersFill is the Teams dropdown's `+ New from this session`: `/helpers ` +// and [helpersPrompt] put in the box, in place of what was there, for the +// person to read, change and send. Nothing is made until they press enter. +// What was in the box goes onto the drafts ring first, as a draft restored +// over it does (draftpage.go), so replacing it loses nothing. Where /helpers +// would refuse, the row says so instead and leaves the box alone. +func (a *app) helpersFill() tea.Cmd { + a.closeTeamMenu() + if a.wall.on { + a.closeWall() + } + if why, managed := a.helpersRefusal(); why != "" { + a.note(why) + if managed.ID != "" { + return a.teamActivate(managed.ID) + } + return nil + } + line := "/helpers " + helpersPrompt + if live := a.input.String(); strings.TrimSpace(live) != "" && live != line { + a.drafts.push([]rune(live)) + } + a.input.setText(line) + a.touch() + return a.syncLists() +} diff --git a/internal/tui3/helpers_test.go b/internal/tui3/helpers_test.go index 4db102d161..2bca725380 100644 --- a/internal/tui3/helpers_test.go +++ b/internal/tui3/helpers_test.go @@ -55,16 +55,30 @@ func transcriptSays(a *app, want string) bool { return false } +// helpersMenuRow is the Teams dropdown's `+ New from this session` row, and +// whether it is the last row. +func helpersMenuRow(t *testing.T, a *app) (teamMenuRow, bool) { + t.Helper() + rows := a.teamMenuRows() + for i, r := range rows { + if !r.rule && r.code == wallPopHelpers { + return r, i == len(rows)-1 + } + } + t.Fatalf("the dropdown has no %q row: %+v", helpersRowWord, rows) + return teamMenuRow{}, false +} + // /helpers MAKES A TEAM WITH THIS CONVERSATION AS ITS MANAGER, and only then -// asks it for helpers. The team is on disk with the conversation as manager and -// only member; the strip is narrowed to it; the column is the manager's; the -// transcript says `/helpers …` and the model was handed the ask, naming both the -// verb that grows the team and the one that takes it back. -func TestHelpersMakesThisConversationTheManagerAndAsksForHelpers(t *testing.T) { +// sends the person's words. The team is on disk with the conversation as +// manager and only member; the strip is narrowed to it; the column is the +// manager's; and the model is handed exactly the words the transcript shows +// after `/helpers`, and nothing else. +func TestHelpersMakesThisConversationTheManagerAndSendsWhatWasTyped(t *testing.T) { a, fake := helpersApp(t) front := a.frontTabKey() before := len(fake.sent) - typeLine(t, a, "/helpers the flaky tests") + typeLine(t, a, "/helpers suggest one helper for the flaky tests") teamsFlush(t, a) disk := helpersDisk(t, a) @@ -87,20 +101,13 @@ func TestHelpersMakesThisConversationTheManagerAndAsksForHelpers(t *testing.T) { if hint := a.trafficHint(); !strings.Contains(hint, "manager of lab") { t.Fatalf("the composer says %q, not that it talks to the manager", hint) } - if len(fake.sent) != before+1 { t.Fatalf("/helpers sent %d messages, want one", len(fake.sent)-before) } - sent := fake.sent[before] - if sent != helpersAskOf("lab", "the flaky tests") { - t.Fatalf("the model was handed %q", sent) - } - for _, want := range []string{"team_start", "team_dissolve", "Start nobody until I agree", "the flaky tests"} { - if !strings.Contains(sent, want) { - t.Fatalf("the ask does not say %q: %q", want, sent) - } + if sent := fake.sent[before]; sent != "suggest one helper for the flaky tests" { + t.Fatalf("the model was handed %q, not the words that were typed", sent) } - if shown := lastPersonLine(t, a); shown != "/helpers the flaky tests" { + if shown := lastPersonLine(t, a); shown != "/helpers suggest one helper for the flaky tests" { t.Fatalf("the transcript says %q, not what was typed", shown) } if !transcriptSays(a, fmt.Sprintf(helpersMadeWord, "lab")) { @@ -108,6 +115,32 @@ func TestHelpersMakesThisConversationTheManagerAndAsksForHelpers(t *testing.T) { } } +// A BARE /helpers SAYS ITS ASK OUT LOUD. The model is handed [helpersPrompt], +// and the transcript shows that same sentence after the command, so nothing was +// asked that the person cannot read. The sentence is the person's own kind of +// words: it names no tool and none of codeaf's machinery. +func TestABareHelpersShowsTheAskItSends(t *testing.T) { + a, fake := helpersApp(t) + typeLine(t, a, "/helpers") + teamsFlush(t, a) + if len(fake.sent) != 1 || fake.sent[0] != helpersPrompt { + t.Fatalf("a bare /helpers sent %q", fake.sent) + } + if shown := lastPersonLine(t, a); shown != "/helpers "+helpersPrompt { + t.Fatalf("the transcript says %q, not the ask that was sent", shown) + } + for _, internal := range []string{"team_", "tool", "manager", "codeaf", "handle"} { + if strings.Contains(strings.ToLower(helpersPrompt), internal) { + t.Errorf("the default ask says %q, which is codeaf's word and not the person's: %q", internal, helpersPrompt) + } + } + for _, want := range []string{"two or three helpers", "Don't start anyone until I agree", "cancel"} { + if !strings.Contains(helpersPrompt, want) { + t.Errorf("the default ask does not say %q: %q", want, helpersPrompt) + } + } +} + // THE ASK WAITS FOR THE STORE. Until the write that carries the team is back // the model is sent nothing, so its first step cannot run as anything but the // manager; a second /helpers meanwhile makes no second team. @@ -227,42 +260,151 @@ func TestHelpersRefusesWhereItCannotWork(t *testing.T) { } } -// THE COLUMN'S DOOR IS /helpers PRESSED. A chat in no team has `+ Make team` -// beside its Tasks word; pressing it makes the team and asks, as typing does; -// and the chat, now a manager, has the Traffic word there instead. -func TestTheColumnsMakeTeamDoorIsHelpers(t *testing.T) { - a, fake := helpersApp(t) - line, row := a.sideHeadRow(40) - if !strings.Contains(plain(line), helpersDoorWord) { - t.Fatalf("a chat in no team has no %q in its column's header: %q", helpersDoorWord, plain(line)) - } - var door *sideDoor - for i := range row.doors { - if row.doors[i].act.kind == sideActHelpers { - door = &row.doors[i] +// THE TEAMS DROPDOWN IS THERE WITH NO TEAMS, and its last row is `+ New from +// this session`. With teams, that row stays last, under a rule. The dropdown +// says nothing about its keys: no footer of up/down, enter and esc. +func TestTheTeamsDropdownIsThereWithNoTeamsAndEndsWithNewFromThisSession(t *testing.T) { + a, _ := helpersApp(t) + if len(a.wall.teams) != 0 { + t.Fatalf("the fixture has teams: %+v", a.wall.teams) + } + if word := a.tabTeamWord(); !strings.Contains(word, "Teams") { + t.Fatalf("with no teams the strip's chip is %q", word) + } + a.openTeamMenu() + if !a.teamMenu.on { + t.Fatal("the dropdown did not open with no teams") + } + if _, last := helpersMenuRow(t, a); !last { + t.Fatal("+ New from this session is not the last row") + } + frame, _ := menuFrame(t, a) + if !strings.Contains(frame, helpersRowWord) { + t.Fatalf("the dropdown does not draw %q:\n%s", helpersRowWord, frame) + } + for _, hint := range []string{"up/down move", "enter choose", "esc cancel"} { + if strings.Contains(frame, hint) { + t.Errorf("the dropdown still says %q:\n%s", hint, frame) } } - if door == nil || !strings.HasPrefix(door.hint, helpersDoorHint) { - t.Fatalf("the header's doors are %+v", row.doors) + + if _, err := a.teamMake("harbor", nil); err != nil { + t.Fatal(err) + } + rows := a.teamMenuRows() + if len(rows) != 3 || rows[0].code != wallPopTeam || !rows[1].rule || rows[2].code != wallPopHelpers { + t.Fatalf("with a team the rows are %+v, want the team, a rule, and the row", rows) + } +} + +// `+ New from this session` IS /helpers ONE STEP EARLIER. Chosen by the +// keyboard, it puts `/helpers ` and the default ask in the box in place of +// what was there (which goes onto the drafts ring), makes nothing and sends +// nothing; enter then does exactly what typing it would. +func TestNewFromThisSessionPutsHelpersInTheBoxAndEnterSendsIt(t *testing.T) { + a, fake := helpersApp(t) + a.input.insert("half a thought") + a.openTeamMenu() + picks := teamMenuPicks(a.teamMenuRows()) + for range picks { + drive(t, a, key("down")) + } + drive(t, a, key("enter")) + if a.teamMenu.on { + t.Fatal("the dropdown stayed up after its row was chosen") + } + if got := a.input.String(); got != "/helpers "+helpersPrompt { + t.Fatalf("the box holds %q", got) + } + if len(a.drafts.entries) == 0 || string(a.drafts.entries[len(a.drafts.entries)-1].text) != "half a thought" { + t.Fatal("what was in the box was not kept on the drafts ring") } - before := len(fake.sent) - cmd, _ := a.sidePress(row, door.span.from) - spend(t, a, cmd) teamsFlush(t, a) - if len(helpersDisk(t, a)) != 1 || len(fake.sent) != before+1 || lastPersonLine(t, a) != "/helpers" { - t.Fatal("pressing the door did not do what /helpers does") + if len(helpersDisk(t, a)) != 0 || len(fake.sent) != 0 { + t.Fatal("choosing the row made a team or sent something before enter") } - line, _ = a.sideHeadRow(40) - if strings.Contains(plain(line), helpersDoorWord) || !strings.Contains(plain(line), sideTrafficWord) { - t.Fatalf("the manager's header is %q", plain(line)) + + drive(t, a, key("enter")) + teamsFlush(t, a) + if len(helpersDisk(t, a)) != 1 || len(fake.sent) != 1 || fake.sent[0] != helpersPrompt { + t.Fatalf("enter did not do what /helpers does: teams %d, sent %q", len(helpersDisk(t, a)), fake.sent) + } + if shown := lastPersonLine(t, a); shown != "/helpers "+helpersPrompt { + t.Fatalf("the transcript says %q", shown) + } +} + +// WHERE /helpers WOULD REFUSE, THE ROW SAYS SO AND LEAVES THE BOX ALONE: in a +// conversation that already manages a team, choosing it shows that team. +func TestNewFromThisSessionInAManagerSaysWhichTeamAndLeavesTheBox(t *testing.T) { + a, _ := helpersApp(t) + typeLine(t, a, "/helpers") + teamsFlush(t, a) + made := helpersDisk(t, a)[0] + a.input.insert("keep this") + row, _ := helpersMenuRow(t, a) + spend(t, a, a.teamMenuDo(row)) + if got := a.input.String(); got != "keep this" { + t.Fatalf("the row replaced the box in a manager: %q", got) + } + if !transcriptSays(a, fmt.Sprintf(helpersManagesWord, made.Name)) { + t.Fatal("the row did not say which team this conversation manages") + } +} + +// THE HELPERS IT STARTS OPEN AS TABS IN THE TEAM'S OVERLAY. The manager's +// team_start is carried out by this window behind the manager; the new +// conversation is a member, held open, its tab on the strip, and the strip is +// this team's even if the person had cleared the overlay meanwhile. The person +// stays in front of the manager, with what they were typing. +func TestHelpersStartedOpenAsTabsInTheTeamsOverlay(t *testing.T) { + a, _ := helpersApp(t) + typeLine(t, a, "/helpers") + teamsFlush(t, a) + made := helpersDisk(t, a)[0] + front := a.frontTabKey() + spend(t, a, a.teamOverlayChoose("")) + if _, ok := a.teamActive(); ok { + t.Fatal("the overlay did not clear") + } + n := 0 + a.start = func(workspace string) (Conversation, error) { + n++ + return Conversation{Agent: &fakeAgent{model: "m"}, SessionFile: fmt.Sprintf("/tmp/lab/helper-%d.jsonl", n), Workspace: workspace}, nil + } + a.input.insert("half a reply") + for _, handle := range []string{"tests", "docs"} { + trafficAppend(t, a, made.ID, teamstore.Entry{Kind: teamstore.KindStart, From: teamstore.FromManager, To: handle, Text: "take the " + handle}) + } + trafficReadNow(t, a) + trafficReadNow(t, a) + if n != 2 { + t.Fatalf("two starts opened %d conversations", n) + } + if active, ok := a.teamActive(); !ok || active.ID != made.ID { + t.Fatal("the strip is not the team's after its helpers started") + } + if a.frontTabKey() != front || a.input.String() != "half a reply" { + t.Fatal("a start moved the person off the manager or out of their words") + } + a.touch() + row := plain(a.tabsRow(a.width)) + for _, handle := range []string{"tests", "docs"} { + m, ok := mustTeam(t, a, made.ID).ByHandle(handle) + if !ok || a.behind[m.Key] == nil { + t.Fatalf("@%s is not a member held open: %+v", handle, mustTeam(t, a, made.ID).Members) + } + if !strings.Contains(row, "@"+handle) { + t.Fatalf("@%s has no tab on the team's strip: %q", handle, row) + } } } // THE MANAGER'S NO PUTS THE CHAT BACK AS IT WAS. The manager's team_dissolve // writes the store from the engine (internal/teams' Dissolve); the window's // Traffic read finds the team gone, closes its overlay, and the column is a -// plain chat's again, with its door. Nothing else in the window moved. -func TestADissolvedTeamLeavesThePlainChatAndItsDoor(t *testing.T) { +// plain chat's again. Nothing else in the window moved. +func TestADissolvedTeamLeavesThePlainChat(t *testing.T) { a, _ := helpersApp(t) front := a.frontTabKey() typeLine(t, a, "/helpers") @@ -285,17 +427,7 @@ func TestADissolvedTeamLeavesThePlainChatAndItsDoor(t *testing.T) { if got := len(a.tabList()); got != tabs { t.Fatalf("dissolving changed the strip from %d tabs to %d", tabs, got) } - line, _ := a.sideHeadRow(40) - if !strings.Contains(plain(line), helpersDoorWord) { - t.Fatalf("the plain chat's door did not come back: %q", plain(line)) - } -} - -// A NARROW COLUMN LOSES THE DOOR, and keeps the key that hides it. -func TestANarrowColumnLosesTheMakeTeamDoorFirst(t *testing.T) { - a, _ := helpersApp(t) - line, _ := a.sideHeadRow(sideColsMin - 6) - if strings.Contains(plain(line), helpersDoorWord) { - t.Fatalf("the door was squeezed into %q", plain(line)) + if _, last := helpersMenuRow(t, a); !last { + t.Fatal("the dropdown lost its last row") } } diff --git a/internal/tui3/sidecol.go b/internal/tui3/sidecol.go index 7ad00a3b48..c497c59422 100644 --- a/internal/tui3/sidecol.go +++ b/internal/tui3/sidecol.go @@ -154,9 +154,6 @@ const ( sideActHide // sideActTeams opens the teams page, where a packet is answered. sideActTeams - // sideActHelpers makes the chat in front a new team's manager and asks - // it for helpers: /helpers, pressed (helpers.go). - sideActHelpers ) // sideAct is what a press does. @@ -373,26 +370,6 @@ func (a *app) sideHeadRow(width int) (string, *sideRow) { trafficAct = sideAct{kind: sideActView, view: sideTraffic} } word(sideTasksWord, itoa(len(a.taskOrder)), false, view == sideTasks, tasksAct, "Show this chat's tasks"+hintSegment+"click") - // A CHAT IN NO TEAM IS OFFERED ONE where the Traffic word would stand: the - // door /helpers is (helpers.go), drawn dim like the other word, and only - // where it can work and where it fits beside the key that hides the - // column, which keeps its cell of air. A narrow column loses it first; - // /helpers is always there. - if !team && a.helpersOffered() { - need := ansi.StringWidth(sideWordSep+helpersDoorWord) + 1 + ansi.StringWidth(a.chords.say(sideHideKey)) - if used+need <= width { - b.WriteString(a.pal.dim(sideWordSep)) - used += ansi.StringWidth(sideWordSep) - w := ansi.StringWidth(helpersDoorWord) - painted := a.pal.dim(helpersDoorWord) - if hot == len(row.doors) { - painted = a.pal.cursor(a.pal.ink(helpersDoorWord), 0) - } - row.doors = append(row.doors, sideDoor{span: hudSpan{from: used, to: used + w}, hint: helpersDoorHint + hintSegment + "click", act: sideAct{kind: sideActHelpers}}) - b.WriteString(painted) - used += w - } - } if team { n, fresh := a.sideTrafficCount(t, kind, handle) // WITH THE TRAFFIC IN FRONT NOTHING IN IT IS NEW TO THE WORD: the `new` @@ -860,9 +837,6 @@ func (a *app) sideDo(act sideAct) tea.Cmd { case sideActTeams: a.railTake(false) return a.showPage(pageTeams) - case sideActHelpers: - a.railTake(false) - return a.runHelpersCommand("") } return nil } diff --git a/internal/tui3/teammenu.go b/internal/tui3/teammenu.go index 5dfad56751..9e52e9f058 100644 --- a/internal/tui3/teammenu.go +++ b/internal/tui3/teammenu.go @@ -10,11 +10,19 @@ import ( "github.com/Agent-Field/codeaf/internal/tui2/tokens" ) -// The strip's switcher chooses only a team overlay. The chip's separate clear +// The strip's switcher chooses a team overlay. The chip's separate clear // action restores ordinary Chats; selecting a team restores its last conversation and strip position. // Management belongs to Teams, so this menu cannot change a membership or manager. // It is modal: arrows choose a row, enter activates it, and escape or a press -// outside closes the menu without changing the view. +// outside closes the menu without changing the view. It says none of that in a +// footer: a list of rows under a chip is read and worked the way every list is. +// +// ITS LAST ROW IS ALWAYS `+ New from this session`, under a rule, which makes a +// team of the conversation in front by putting `/helpers` in the box +// (helpers.go). Because that row is always there, the chip is there too, with no +// team yet: `Teams ▾` is how a person with none finds how to make one from what +// they are doing. Over --host to an engine without the teams doors nothing here +// can work, and the row, and with no teams the chip, are absent. // teamMenu is the switcher's state: whether it is up, the row the keyboard is // on, the row the pointer is on, and where the last frame drew it and its rows, @@ -48,9 +56,20 @@ func (a *app) teamMenuRows() []teamMenuRow { for _, r := range a.teamsOpenTree() { rows = append(rows, teamMenuRow{code: wallPopTeam, id: r.id, depth: r.depth}) } + if a.teamMenuHelpers() { + if len(rows) > 0 { + rows = append(rows, teamMenuRow{rule: true}) + } + rows = append(rows, teamMenuRow{code: wallPopHelpers}) + } return rows } +// teamMenuHelpers reports whether the switcher carries `+ New from this +// session`: everywhere a team can be kept. Where /helpers would refuse for the +// conversation in front, the row is still there and says why when chosen. +func (a *app) teamMenuHelpers() bool { return !a.teamsOff() } + // A picker with no available overlay is absent, including retained teams and // an optional global manager that has not been created. func (a *app) teamMenuOffers(t team) bool { @@ -58,6 +77,9 @@ func (a *app) teamMenuOffers(t team) bool { } func (a *app) teamMenuAvailable() bool { + if a.teamMenuHelpers() { + return true + } for _, t := range a.wall.teams { if a.teamMenuOffers(t) { return true @@ -87,7 +109,9 @@ func (a *app) openTeamMenu() { } a.teamMenu = teamMenu{on: true} for i, r := range picks { - if r.id == a.wall.activeID { + // Only a team's row is the team shown: `+ New from this session` has + // no id, and with no team shown would otherwise match the empty one. + if r.code == wallPopTeam && r.id == a.wall.activeID { a.teamMenu.cursor = i break } @@ -114,10 +138,15 @@ func (a *app) teamMenuFront() chatTab { // teamMenuDo is one row chosen, by the keyboard or the pointer. func (a *app) teamMenuDo(r teamMenuRow) tea.Cmd { - if r.code != wallPopTeam { + switch { + case r.rule: return nil + case r.code == wallPopHelpers: + return a.helpersFill() + case r.code == wallPopTeam: + return a.teamOverlayChoose(r.id) } - return a.teamOverlayChoose(r.id) + return nil } // The chip's clear action and menu selections share the same transition, so @@ -276,7 +305,14 @@ func (a *app) teamMenuCard(width, height int) wallCard { var lines []line for _, r := range rows { ln := line{row: r} + if r.rule { + lines = append(lines, ln) + continue + } switch r.code { + case wallPopHelpers: + ln.left = pal.ink(helpersRowWord) + ln.leftW = ansi.StringWidth(helpersRowWord) case wallPopTeam: t, _ := a.teamByID(r.id) radio := off @@ -320,19 +356,28 @@ func (a *app) teamMenuCard(width, height int) wallCard { } w := inner + 2 + 2*padX top := tabStripRow + 1 - footer := teamFooter(pal, inner, teamHint("up/down", "move"), teamHint("enter", "choose"), teamHint("esc", "cancel")) - capacity := height - top - 2 - len(footer) + capacity := height - top - 2 if w > width-2 || capacity < 1 { return wallCard{} } m := &a.teamMenu - m.cursor = min(max(m.cursor, 0), len(lines)-1) + m.cursor = min(max(m.cursor, 0), len(picks)-1) + // THE CURSOR COUNTS PICKS AND THE WINDOW COUNTS LINES, and the rule above + // the last row is a line that is not a pick; the window is kept on the + // line the cursor's pick is drawn on. + at := 0 + for i, ln := range lines { + if !ln.row.rule && ln.row.code == picks[m.cursor].code && ln.row.id == picks[m.cursor].id { + at = i + break + } + } m.top = min(max(m.top, 0), max(len(lines)-capacity, 0)) - if m.cursor < m.top { - m.top = m.cursor + if at < m.top { + m.top = at } - if m.cursor >= m.top+capacity { - m.top = m.cursor - capacity + 1 + if at >= m.top+capacity { + m.top = at - capacity + 1 } end := min(m.top+capacity, len(lines)) title := "Teams" @@ -360,6 +405,5 @@ func (a *app) teamMenuCard(width, height int) wallCard { hits: []wallHit{{x0: 0, y0: 0, x1: inner, y1: 1, kind: wallHitPopRow, arg: ln.row.code, id: ln.row.id}}, }) } - cardLines = append(cardLines, footer...) return wallCardBuild(pal, ansi.Truncate(title, w-5, g.more), cardLines, x, top, w, padX, 0) } diff --git a/internal/tui3/teammenu_test.go b/internal/tui3/teammenu_test.go index bf8d1f0a0b..5069437c54 100644 --- a/internal/tui3/teammenu_test.go +++ b/internal/tui3/teammenu_test.go @@ -193,8 +193,16 @@ func TestTeamMenuOffersOnlyOverlayChoices(t *testing.T) { t.Fatalf("management action %q remains in overlay picker", word) } } - if len(a.teamMenuRows()) != len(a.teamsOpenTree()) { - t.Fatal("picker has extra rows") + // Its rows are the open teams and, last under a rule, `+ New from this + // session`, which puts /helpers in the box and edits no team (helpers.go). + rows := a.teamMenuRows() + if len(rows) != len(a.teamsOpenTree())+2 || !rows[len(rows)-2].rule || rows[len(rows)-1].code != wallPopHelpers { + t.Fatalf("picker has extra rows: %+v", rows) + } + for _, r := range rows[:len(rows)-2] { + if r.code != wallPopTeam { + t.Fatalf("picker has a row that is not a team: %+v", r) + } } } @@ -246,13 +254,16 @@ func TestTeamMenuChoosesOverlayFromGridWithoutFilteringIt(t *testing.T) { } } -// WITH NO TEAM SHOWN THE CHIP IS A QUIET `Teams ▾` while there are teams, and -// is not there at all while there are none. +// WITH NO TEAM SHOWN THE CHIP IS A QUIET `Teams ▾`, with teams and without: +// with none, its one row is `+ New from this session` (helpers.go). func TestTeamMenuQuietChipWithNoTeamShown(t *testing.T) { a, _, _ := tabApp(t) a.teamsEnsure() - if row := plain(a.tabsRow(a.width)); strings.Contains(row, "▾") { - t.Fatalf("a chip with no teams: %q", row) + if row := plain(a.tabsRow(a.width)); !strings.Contains(row, " Teams ▾ ") { + t.Fatalf("no chip with no teams: %q", row) + } + if rows := a.teamMenuRows(); len(rows) != 1 || rows[0].code != wallPopHelpers { + t.Fatalf("with no teams the switcher's rows are %+v", rows) } a, _, _ = menuApp(t) a.teamActivate("") @@ -296,13 +307,17 @@ func TestTeamMenuWithoutAvailableTeamsHasNoPlaceholderChoice(t *testing.T) { t.Fatal(err) } a.teamOverlayChoose("") - row := plain(a.tabsRow(a.width)) - if len(a.teamMenuRows()) != 0 || a.wall.chip.pressable() || strings.Contains(row, "Teams ▾") { - t.Fatal("retained teams or an absent global manager left a placeholder picker") + // No disbanded team and no absent global manager is a choice; the one + // row left is `+ New from this session`, which is not a placeholder but + // the way to make a team from here (helpers.go). + rows := a.teamMenuRows() + if len(rows) != 1 || rows[0].code != wallPopHelpers { + t.Fatalf("retained teams or an absent global manager left a placeholder choice: %+v", rows) } a.openTeamMenu() - if a.teamMenu.on || len(a.teamMenuCard(a.width, a.height).rows) != 0 { - t.Fatal("empty overlay picker opens or draws a placeholder choice") + card := a.teamMenuCard(a.width, a.height) + if !a.teamMenu.on || len(card.hits) != 1 || card.hits[0].arg != wallPopHelpers { + t.Fatalf("the picker with no team to choose draws %+v", card.hits) } } @@ -330,9 +345,14 @@ func TestTeamMenuDismissesWhenRefreshRemovesItsLastChoice(t *testing.T) { a.teamAdopt(nil) } a.Update(nil) - if a.teamMenu.on || len(a.teamMenu.hits) != 0 { - t.Fatal("refresh left an invisible picker owning keyboard input") + // THE PICKER NEVER OWNS THE KEYBOARD INVISIBLY. Its last team + // choice gone, it still has `+ New from this session`, so it + // stays up and draws that row; esc gives the keys back. + menuFrame(t, a) + if a.teamMenu.on && (len(a.teamMenu.hits) != 1 || a.teamMenu.hits[0].arg != wallPopHelpers) { + t.Fatalf("refresh left a picker drawing %+v", a.teamMenu.hits) } + a.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) a.Update(tea.KeyPressMsg{Code: 'z', Text: "z"}) if !strings.Contains(a.input.String(), "z") { t.Fatal("typing did not return to the ordinary conversation") diff --git a/internal/tui3/teamoverlay_test.go b/internal/tui3/teamoverlay_test.go index 2fd43308c7..538871255d 100644 --- a/internal/tui3/teamoverlay_test.go +++ b/internal/tui3/teamoverlay_test.go @@ -382,7 +382,8 @@ func TestTeamOverlayRootUsesCurrentManagersAndProtectsAutomaticMemberships(t *te a.teamViewSet(root) a.teamOverlaySync() for _, row := range a.teamMenuRows() { - if row.code != wallPopTeam { + // The rule and `+ New from this session` edit no membership. + if !row.rule && row.code != wallPopTeam && row.code != wallPopHelpers { t.Fatal("root offers ordinary membership editing") } } diff --git a/internal/tui3/teamtraffic.go b/internal/tui3/teamtraffic.go index d8d84d7a05..9619fbcdee 100644 --- a/internal/tui3/teamtraffic.go +++ b/internal/tui3/teamtraffic.go @@ -552,6 +552,16 @@ func (a *app) trafficStarted(id, handle string, conv Conversation, err error) te } } if t.Manager != "" && t.Manager == a.frontTabKey() { + // THE STRIP SHOWS THE TEAM WHOSE MEMBER JUST STARTED, when the person + // is in front of that team's manager: the new tab is on the strip + // because the strip is this team's. It is the same conversation in + // front and the same keyboard, so nothing moves the person's focus; + // only the overlay is set, the way choosing the team in the + // dropdown sets it. On the wall or a page the person is somewhere + // else, and the strip is left as it is. + if a.wall.activeID != t.ID && !a.wall.on && !a.pageShowing() && !t.Closed() { + a.teamViewSet(t.ID) + } a.feed.note(a.teamManagerMark() + " started @" + handle + " · its tab is on the strip") } a.touch() diff --git a/internal/tui3/wallclick_test.go b/internal/tui3/wallclick_test.go index 797dd862d2..d38c84ea1b 100644 --- a/internal/tui3/wallclick_test.go +++ b/internal/tui3/wallclick_test.go @@ -159,12 +159,13 @@ func TestWallKeepsTeamControlsOnTeamsPage(t *testing.T) { } // WHILE A TEAM NARROWS THE STRIP, THE STRIP SAYS WHICH: a chip at its left -// end, which opens the team switcher (teammenu.go). +// end, which opens the team switcher (teammenu.go). With none shown, and none +// made yet, the chip is the quiet `Teams ▾`, whose last row makes one. func TestTabTeamChipNamesTheShownTeam(t *testing.T) { a, _, _ := tabApp(t) plainRow := plain(a.tabsRow(a.width)) - if strings.Contains(plainRow, "▾") { - t.Fatalf("a chip with no team shown: %q", plainRow) + if !strings.Contains(plainRow, " Teams ▾ ") || strings.Contains(plainRow, "●") { + t.Fatalf("the chip with no team shown: %q", plainRow) } tabs := a.tabList() i, err := a.teamMake("harbor", tabs[:1]) diff --git a/internal/tui3/wallcontract.go b/internal/tui3/wallcontract.go index 1a818ae35c..896c45d8d3 100644 --- a/internal/tui3/wallcontract.go +++ b/internal/tui3/wallcontract.go @@ -245,6 +245,7 @@ const ( wallPopConfirm = -3 // Delete, confirmed wallPopKeep = -4 // Keep, the delete undone wallPopManager = -6 // Make manager, or Remove manager, in the team shown + wallPopHelpers = -7 // + New from this session: /helpers in the box (helpers.go) ) // wallPopKind is which popover is up. From 661d51ccb1e7f347da2a9e49166aae37ae623c24 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Mon, 19 Oct 2026 12:14:16 -0400 Subject: [PATCH 03/10] tui3: + New from this session only with no team selected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With a team's overlay on the strip, the Teams dropdown no longer offers `+ New from this session`, and /helpers refuses with `/helpers works with no team selected · clear with × first, or use + Add subteam on Teams for a team inside it`. The team /helpers makes is always top-level, and making it from inside another team's overlay read as nesting. Nesting keeps its own doors: + Add subteam on Teams, and a manager's sub-team start. Co-Authored-By: Claude Opus 5.5 --- internal/manual/chat/commands.md | 2 +- .../manual/chat/conversations-and-teams.md | 14 ++++--- internal/manual/chat/team-manager.md | 5 ++- internal/manual/chat_test.go | 27 ++++++------ internal/tui3/chatsnavigation_test.go | 6 +-- internal/tui3/helpers.go | 15 +++++++ internal/tui3/helpers_test.go | 41 +++++++++++++++++++ internal/tui3/teammenu.go | 33 +++++++++++---- internal/tui3/teammenu_test.go | 9 +++- 9 files changed, 116 insertions(+), 36 deletions(-) diff --git a/internal/manual/chat/commands.md b/internal/manual/chat/commands.md index 1cc0fb649e..81300e0eed 100644 --- a/internal/manual/chat/commands.md +++ b/internal/manual/chat/commands.md @@ -323,7 +323,7 @@ command). | `/spend` | none | none | opens the spend place, what this machine has cost, by the day (also `alt+5`) | | `/wall` | | | every saved conversation at once, with live or saved previews and card-based team creation (also `alt+v`, or `▦` under the box) | | `/teams` | | | the teams page: your teams as a tree, what waits on you, member previews and the selected team's interaction table (also `alt+2`, or `teams` on the tab bar) | -| `/helpers` | | `` | makes this conversation a new team's manager, then sends your words after it (bare, a default ask to suggest two or three helpers, shown in full) for you to accept, change or cancel; `+ New from this session`, last in the Teams dropdown, puts `/helpers` and that ask in the box; on home it says to open a conversation first | +| `/helpers` | | `` | makes this conversation a new team's manager, then sends your words after it (bare, a default ask to suggest two or three helpers, shown in full) for you to accept, change or cancel; `+ New from this session`, last in the Teams dropdown while no team is selected, puts `/helpers` and that ask in the box; with a team selected it refuses; on home it says to open a conversation first | | `/cost` | `/usage`, `/tokens` | — | prints what this conversation has spent, and on what | | `/effort` | `/think`, `/thinking` | — | opens this conversation's thinking levels; says it is unavailable when the session has no dial | | `/effort` | `/think`, `/thinking` | `` | sets this conversation's thinking level; an unknown rung lists the accepted levels and changes nothing | diff --git a/internal/manual/chat/conversations-and-teams.md b/internal/manual/chat/conversations-and-teams.md index 278ec769b0..e6081039cc 100644 --- a/internal/manual/chat/conversations-and-teams.md +++ b/internal/manual/chat/conversations-and-teams.md @@ -11,7 +11,7 @@ With no overlay, the strip shows **Teams ▾**, even before you have a team. Sel team chip and a separate **x**. Click the name to switch teams; click x to remove the overlay and restore ordinary Chats. Clearing selects **All teams** in Teams without changing membership or background work. The dropdown lists actual teams, with no **None** option, -and ends with **+ New from this session**, which makes a team of the conversation in front. +and, with no team selected, ends with **+ New from this session**, which makes a team of the conversation in front. The **▦ All** button still opens the independent conversation grid. The Teams dropdown is the only team selector in Chats; there are no membership buttons @@ -224,11 +224,13 @@ with one selected it shows that team's name, colour and a separate **x** to clea The dropdown offers **All teams** when a global manager exists, then the active team hierarchy. All teams selects the global-manager overlay; it is separate from the team-agnostic `▦ All` grid. A root without a manager or with a known missing manager transcript has no All teams choice. -The picker has no None option or management actions. Its last row, under a rule, is always -**+ New from this session**: it puts `/helpers` and a default ask in the message box, replacing -what was there (that draft is kept in `/drafts`), and nothing happens until you edit it -and press enter (see *Turn this conversation into a team* on the team manager page). So the -chip is there even with no teams yet; it is absent only over `--host` to an engine without teams. +The picker has no None option or management actions. With **no team selected**, its last row, +under a rule, is **+ New from this session**: it puts `/helpers` and a default ask in the message +box, replacing what was there (that draft is kept in `/drafts`), and nothing happens until you +edit it and press enter (see *Turn this conversation into a team* on the team manager page). So +the chip is there even with no teams yet; it is absent only over `--host` to an engine without +teams. With a team selected the row is not offered: it makes a top-level team, and a team inside +another is `+ Add subteam` on Teams. Closed teams are available through Show closed on Teams, not through this picker. Selecting a team restores its conversation and horizontal strip position. A new overlay diff --git a/internal/manual/chat/team-manager.md b/internal/manual/chat/team-manager.md index 5edd4a7ec8..6d6f56349a 100644 --- a/internal/manual/chat/team-manager.md +++ b/internal/manual/chat/team-manager.md @@ -72,8 +72,8 @@ handle choice may replace that fallback. ## Turn this conversation into a team — /helpers, + New from this session, convert a chat, bring in helpers You are working in a conversation, the work has grown, and you want helpers with this -conversation in charge. Open the **Teams ▾** dropdown at the left of the tab strip and choose -its last row, **`+ New from this session`**. It puts this in the message box, in place of +conversation in charge. With no team selected, open the **Teams ▾** dropdown at the left of +the tab strip and choose its last row, **`+ New from this session`**. It puts this in the message box, in place of what was there, and sends nothing: `/helpers Suggest two or three helpers for what we're working on here. For each, say what it would take on and the brief you'd give it. Don't start anyone until I agree, and if I say cancel, undo the team.` @@ -100,6 +100,7 @@ list, and it proposes again. Cancel, and it takes the team back with `team_disso - On home, or any page: `/helpers makes a team of the conversation you are in · open one first`. - On the welcome screen of a new chat: `/helpers makes a team of a conversation you have started · say what you are working on first`. - In a conversation that already manages a team: `this conversation already manages harbor · ask it for helpers`, and the strip shows that team. +- With a team selected on the strip: `/helpers works with no team selected · clear harbor with × first, or use + Add subteam on Teams for a team inside it`. The dropdown has no `+ New from this session` row then: the team it makes is always top-level. - On a connection that holds one conversation at a time: `helpers open beside this conversation, and a connection holds one conversation at a time`. - Over `--host` to an engine without teams: `managers are not available over --host yet`. - While the first one is still being saved: `the team is still being saved`. diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index 95f3d47c92..c9f06a52ce 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -4119,30 +4119,31 @@ func TestChangedInputQuestionReachesRetainedHistory(t *testing.T) { // /helpers: the questions a person in the middle of some work asks when they // want it to become a team, and the ones they ask about saying no, reach the -// sections that answer them by name, not just the manager's page. +// sections that answer them by name, not just the pages they are on. func TestHelpersQuestionsReachTheirSections(t *testing.T) { - for _, probe := range []struct{ question, title string }{ - {"how do I turn this conversation into a team", "Turn this conversation into a team"}, - {"convert this chat to a team with it as the manager", "Turn this conversation into a team"}, - {"bring in helpers for what I am working on", "Turn this conversation into a team"}, - {"what does + New from this session in the Teams dropdown do", "Turn this conversation into a team"}, - {"what does /helpers send to the model", "Turn this conversation into a team"}, - {"why is /helpers not offered here", "When /helpers is not offered"}, - {"/helpers says open one first", "When /helpers is not offered"}, - {"I said no to the helpers, how do I cancel the team", "Cancel a team nobody joined"}, - {"what does team_dissolve do", "Cancel a team nobody joined"}, + for _, probe := range []struct{ question, page, title string }{ + {"how do I turn this conversation into a team", "team-manager", "Turn this conversation into a team"}, + {"convert this chat to a team with it as the manager", "team-manager", "Turn this conversation into a team"}, + {"bring in helpers for what I am working on", "team-manager", "Turn this conversation into a team"}, + {"what does /helpers send to the model", "team-manager", "Turn this conversation into a team"}, + {"what does + New from this session in the Teams dropdown do", "conversations-and-teams", "the Teams dropdown and + New from this session"}, + {"why is + New from this session missing when a team is selected", "conversations-and-teams", "the Teams dropdown and + New from this session"}, + {"why is /helpers not offered here", "team-manager", "When /helpers is not offered"}, + {"/helpers says open one first", "team-manager", "When /helpers is not offered"}, + {"I said no to the helpers, how do I cancel the team", "team-manager", "Cancel a team nobody joined"}, + {"what does team_dissolve do", "team-manager", "Cancel a team nobody joined"}, } { found := Chat().Search(probe.question, DefaultResults) reached := false where := make([]string, 0, len(found)) for _, section := range found { where = append(where, section.Page+" · "+section.Title) - if section.Page == "team-manager" && strings.Contains(section.Title, probe.title) { + if section.Page == probe.page && strings.Contains(section.Title, probe.title) { reached = true } } if !reached { - t.Errorf("%q does not reach team-manager · %q; it reached %v", probe.question, probe.title, where) + t.Errorf("%q does not reach %s · %q; it reached %v", probe.question, probe.page, probe.title, where) } } } diff --git a/internal/tui3/chatsnavigation_test.go b/internal/tui3/chatsnavigation_test.go index 224d7afd95..7bd7e39fd8 100644 --- a/internal/tui3/chatsnavigation_test.go +++ b/internal/tui3/chatsnavigation_test.go @@ -194,15 +194,15 @@ func TestTeamMenuLongTreeScrollsAndTinyResizeCannotChoose(t *testing.T) { if !a.teamMenu.card.holds(a.teamMenu.card.x0, a.teamMenu.card.y0) || len(a.teamMenu.hits) >= len(a.teamMenuRows()) { t.Fatal("long picker was not bounded") } - // The cursor counts the rows it can land on; the last is `+ New from this - // session`, under a rule that is a line and not a pick (teammenu.go). + // The cursor counts the rows it can land on. With harbor selected there + // is no `+ New from this session` (teammenu.go), so the last is a team. picks := teamMenuPicks(a.teamMenuRows()) for range len(picks) { drive(t, a, key("down")) menuFrame(t, a) } chosen := picks[a.teamMenu.cursor] - if chosen.code != wallPopHelpers { + if chosen.code != wallPopTeam || a.teamMenu.cursor != len(picks)-1 { t.Fatalf("the last row the cursor reaches is %+v", chosen) } hit := menuHit(t, a, chosen.code, chosen.id) diff --git a/internal/tui3/helpers.go b/internal/tui3/helpers.go index 7e1815a289..c240be952e 100644 --- a/internal/tui3/helpers.go +++ b/internal/tui3/helpers.go @@ -37,6 +37,12 @@ package tui3 // in place of whatever was there, and sends nothing: the person reads the // request, changes it into the team they want, and presses enter. // +// BOTH ARE FOR A CONVERSATION SEEN WITH NO TEAM SELECTED. With a team's +// overlay on the strip the row is absent and the command refuses, because the +// team it makes is a top-level team and making it from inside another team +// would read as nesting; a team inside a team is `+ Add subteam` on Teams, or +// a manager's own sub-team start. +// // THE PERSON ANSWERS IN THEIR OWN WORDS. There is no card. Yes is a reply the // manager acts on with `team_start`, which runs under the approval posture every // other start does, and each helper it starts opens as a tab in this team's @@ -76,6 +82,9 @@ const ( // helpersManagesWord is /helpers in a conversation that already manages a // team; the strip is narrowed to that team. helpersManagesWord = "this conversation already manages %s · ask it for helpers" + // helpersSelectedWord is /helpers with a team selected in the strip: a + // team made here is a top-level team, and subteams have their own door. + helpersSelectedWord = "/helpers works with no team selected · clear %s with × first, or use + Add subteam on Teams for a team inside it" // helpersPendingWord is a second /helpers while the first is being saved. helpersPendingWord = "the team is still being saved" // helpersMadeWord is the receipt, once the store has the team. @@ -139,6 +148,12 @@ func (a *app) helpersRefusal() (string, team) { if t, manages := a.teamManaged(tab.key); manages { return fmt.Sprintf(helpersManagesWord, t.Name), t } + // ONLY WITH NO TEAM SELECTED, the dropdown's row and the command alike + // ([app.teamMenuHelpers] says why): a team made here is a top-level team, + // and making it from inside another team's overlay would read as nesting. + if t, selected := a.teamActive(); selected { + return fmt.Sprintf(helpersSelectedWord, t.Name), team{} + } return "", team{} } diff --git a/internal/tui3/helpers_test.go b/internal/tui3/helpers_test.go index 2bca725380..063301eca9 100644 --- a/internal/tui3/helpers_test.go +++ b/internal/tui3/helpers_test.go @@ -341,6 +341,7 @@ func TestNewFromThisSessionInAManagerSaysWhichTeamAndLeavesTheBox(t *testing.T) typeLine(t, a, "/helpers") teamsFlush(t, a) made := helpersDisk(t, a)[0] + spend(t, a, a.teamOverlayChoose("")) a.input.insert("keep this") row, _ := helpersMenuRow(t, a) spend(t, a, a.teamMenuDo(row)) @@ -352,6 +353,46 @@ func TestNewFromThisSessionInAManagerSaysWhichTeamAndLeavesTheBox(t *testing.T) } } +// WITH A TEAM SELECTED THERE IS NO `+ New from this session`, and /helpers +// refuses: the team it makes is a top-level team, and made from inside another +// team's overlay it would read as nesting. Nesting is `+ Add subteam` on Teams. +// Nothing is made, nothing is sent, the box is untouched; clearing the +// selection brings the row back. +func TestWithATeamSelectedThereIsNoNewFromThisSession(t *testing.T) { + a, fake := helpersApp(t) + harbor, err := a.teamMake("harbor", a.tabList()) + if err != nil { + t.Fatal(err) + } + teamsFlush(t, a) + spend(t, a, a.teamActivate(harbor)) + for _, r := range a.teamMenuRows() { + if r.rule || r.code == wallPopHelpers { + t.Fatalf("with a team selected the dropdown offers %+v", a.teamMenuRows()) + } + } + a.openTeamMenu() + if frame, _ := menuFrame(t, a); strings.Contains(frame, helpersRowWord) { + t.Fatalf("with a team selected the dropdown draws %q:\n%s", helpersRowWord, frame) + } + a.closeTeamMenu() + + a.input.insert("/helpers") + drive(t, a, key("enter")) + teamsFlush(t, a) + if !transcriptSays(a, fmt.Sprintf(helpersSelectedWord, "harbor")) { + t.Fatal("/helpers with a team selected did not say why it refused") + } + if n := len(helpersDisk(t, a)); n != 1 || len(fake.sent) != 0 { + t.Fatalf("/helpers with a team selected made %d teams and sent %q", n, fake.sent) + } + + spend(t, a, a.teamOverlayChoose("")) + if _, last := helpersMenuRow(t, a); !last { + t.Fatal("clearing the selection did not bring the row back last") + } +} + // THE HELPERS IT STARTS OPEN AS TABS IN THE TEAM'S OVERLAY. The manager's // team_start is carried out by this window behind the manager; the new // conversation is a member, held open, its tab on the strip, and the strip is diff --git a/internal/tui3/teammenu.go b/internal/tui3/teammenu.go index 9e52e9f058..5236f1b074 100644 --- a/internal/tui3/teammenu.go +++ b/internal/tui3/teammenu.go @@ -17,12 +17,14 @@ import ( // outside closes the menu without changing the view. It says none of that in a // footer: a list of rows under a chip is read and worked the way every list is. // -// ITS LAST ROW IS ALWAYS `+ New from this session`, under a rule, which makes a -// team of the conversation in front by putting `/helpers` in the box -// (helpers.go). Because that row is always there, the chip is there too, with no -// team yet: `Teams ▾` is how a person with none finds how to make one from what -// they are doing. Over --host to an engine without the teams doors nothing here -// can work, and the row, and with no teams the chip, are absent. +// WITH NO TEAM SELECTED ITS LAST ROW IS `+ New from this session`, under a +// rule, which makes a team of the conversation in front by putting `/helpers` +// in the box (helpers.go). Because that row is there whenever no team is, the +// chip is there too with no team yet: `Teams ▾` is how a person with none finds +// how to make one from what they are doing. With a team selected the row is +// gone ([app.teamMenuHelpers] says why). Over --host to an engine without the +// teams doors nothing here can work, and the row, and with no teams the chip, +// are absent. // teamMenu is the switcher's state: whether it is up, the row the keyboard is // on, the row the pointer is on, and where the last frame drew it and its rows, @@ -66,9 +68,22 @@ func (a *app) teamMenuRows() []teamMenuRow { } // teamMenuHelpers reports whether the switcher carries `+ New from this -// session`: everywhere a team can be kept. Where /helpers would refuse for the -// conversation in front, the row is still there and says why when chosen. -func (a *app) teamMenuHelpers() bool { return !a.teamsOff() } +// session`: only while NO team is selected, wherever a team can be kept. +// +// WITH A TEAM SELECTED THE ROW IS NOT THERE AT ALL. A new team made from a +// conversation seen through a team's overlay reads as one made inside that +// team, and it is not: /helpers always makes a top-level team. Nesting has its +// own doors, `+ Add subteam` on Teams and a manager's own sub-team start, with +// their own rules, and this row must not look like a third. Where /helpers +// would refuse for the conversation in front for another reason, the row is +// still there and says why when chosen. +func (a *app) teamMenuHelpers() bool { + if a.teamsOff() { + return false + } + _, selected := a.teamActive() + return !selected +} // A picker with no available overlay is absent, including retained teams and // an optional global manager that has not been created. diff --git a/internal/tui3/teammenu_test.go b/internal/tui3/teammenu_test.go index 5069437c54..2c0f8c21f6 100644 --- a/internal/tui3/teammenu_test.go +++ b/internal/tui3/teammenu_test.go @@ -193,11 +193,16 @@ func TestTeamMenuOffersOnlyOverlayChoices(t *testing.T) { t.Fatalf("management action %q remains in overlay picker", word) } } - // Its rows are the open teams and, last under a rule, `+ New from this + // With a team selected its rows are the open teams and nothing else. + if len(a.teamMenuRows()) != len(a.teamsOpenTree()) { + t.Fatal("picker has extra rows") + } + // With none selected they end, under a rule, with `+ New from this // session`, which puts /helpers in the box and edits no team (helpers.go). + spend(t, a, a.teamOverlayChoose("")) rows := a.teamMenuRows() if len(rows) != len(a.teamsOpenTree())+2 || !rows[len(rows)-2].rule || rows[len(rows)-1].code != wallPopHelpers { - t.Fatalf("picker has extra rows: %+v", rows) + t.Fatalf("with no team selected the rows are %+v", rows) } for _, r := range rows[:len(rows)-2] { if r.code != wallPopTeam { From 9d6b9bfcd30f989ac84502abd541aca0a9477c0a Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Mon, 19 Oct 2026 12:24:56 -0400 Subject: [PATCH 04/10] =?UTF-8?q?tui3:=20a=20=F0=9F=92=A1=20tip=20for=20/h?= =?UTF-8?q?elpers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `/helpers makes this conversation the manager of a new team` joins the tips, after /task's. It is armed only where /helpers would act: after the first exchange, with no team selected, in a conversation that manages none, never on home. It retires the first time /helpers is typed or the Teams dropdown's `+ New from this session` is chosen. The table is twenty-five tips now, and the hints page says so. Co-Authored-By: Claude Opus 5.5 --- internal/manual/chat/hints-and-tips.md | 8 +++-- internal/tui3/helpers.go | 17 ++++++++++ internal/tui3/helpers_test.go | 47 ++++++++++++++++++++++++++ internal/tui3/hometip_test.go | 9 ++--- internal/tui3/notice.go | 18 +++++++++- internal/tui3/notice_test.go | 1 + 6 files changed, 93 insertions(+), 7 deletions(-) diff --git a/internal/manual/chat/hints-and-tips.md b/internal/manual/chat/hints-and-tips.md index e3a0fcea38..6f72c0e6c3 100644 --- a/internal/manual/chat/hints-and-tips.md +++ b/internal/manual/chat/hints-and-tips.md @@ -44,7 +44,7 @@ screen you pass through, so the tip worth saying differs: a conversation gets th urgent thing that is true right now, and home gets everything in turn. Home's row does not wait for quiet; a conversation's waits 15 seconds. -Both rows draw from **one list** of twenty-four tips (below), and using a gesture on +Both rows draw from **one list** of twenty-five tips (below), and using a gesture on either retires it on both. **A conversation's tip has moved several times.** It was the keys row's lowest rung until @@ -121,7 +121,7 @@ later when the ring comes round. It jumps once and then takes its turn like the ## Every hint codeaf can show, and what makes each one go away -There are twenty-four, one list for both boxes. Each one says the moment it first appears +There are twenty-five, one list for both boxes. Each one says the moment it first appears and the gesture that retires it. The list is the program's own table (the surface refuses to build if the two disagree), so a tip you saw is on it word for word. @@ -150,6 +150,10 @@ build if the two disagree), so a tip you saw is on it word for word. it. It is the first tip a conversation says, ahead of `/task`. - `/task starts a single-shot task on the side` — after the first exchange. Retired when `/task` is typed, bare or with a brief. +- `/helpers makes this conversation the manager of a new team` — after the first exchange, + in a conversation with no team selected on the strip that does not already manage a team; + never on home. Retired the first time `/helpers` is typed or the Teams dropdown's + `+ New from this session` is chosen. - `/automations add sets one up exactly · or just say when, like every Monday at 9` — from the first minute on home and after the first exchange in a conversation. It is the other half of the `/automations` row above — the place lists them, and this is how one is made, diff --git a/internal/tui3/helpers.go b/internal/tui3/helpers.go index c240be952e..d57e9b6312 100644 --- a/internal/tui3/helpers.go +++ b/internal/tui3/helpers.go @@ -157,10 +157,26 @@ func (a *app) helpersRefusal() (string, team) { return "", team{} } +// helpersTipArmed is the 💡 tip's rule (notice.go): a conversation that has +// been spoken to, in front, with no team selected, not already a manager, and +// on a window that can keep teams and open helpers beside it. It reads memory +// only, because the table asks it at every event. +func helpersTipArmed(a *app) bool { + if a.turn < 1 || a.pageShowing() || a.teamsOff() || a.shared || !a.wall.loaded { + return false + } + if _, selected := a.teamActive(); selected { + return false + } + _, manages := a.teamManaged(a.frontTabKey()) + return !manages +} + // runHelpersCommand is /helpers: the team made, and the person's words queued // behind the store's answer. With no words after the command they are // [helpersPrompt], and the transcript shows them. func (a *app) runHelpersCommand(rest string) tea.Cmd { + a.noticeEvent(eventHelpersUsed) if why, managed := a.helpersRefusal(); why != "" { a.note(why) if managed.ID != "" { @@ -220,6 +236,7 @@ func (a *app) helpersAsked() tea.Cmd { // over it does (draftpage.go), so replacing it loses nothing. Where /helpers // would refuse, the row says so instead and leaves the box alone. func (a *app) helpersFill() tea.Cmd { + a.noticeEvent(eventHelpersUsed) a.closeTeamMenu() if a.wall.on { a.closeWall() diff --git a/internal/tui3/helpers_test.go b/internal/tui3/helpers_test.go index 063301eca9..a8f4202107 100644 --- a/internal/tui3/helpers_test.go +++ b/internal/tui3/helpers_test.go @@ -472,3 +472,50 @@ func TestADissolvedTeamLeavesThePlainChat(t *testing.T) { t.Fatal("the dropdown lost its last row") } } + +// THE 💡 TIP IS SAID WHERE /helpers WOULD ACT, AND ONLY THERE: after the first +// exchange, with no team selected, in a conversation that manages none; never +// before a word was said, never with a team on the strip, never in a manager. +// Using the command retires it for good. +func TestTheHelpersTipIsArmedWhereHelpersWouldAct(t *testing.T) { + var row notice + for _, n := range notices { + if n.id == "helpers-team" { + row = n + } + } + if row.id == "" || row.text != "/helpers makes this conversation the manager of a new team" || row.retire != eventHelpersUsed { + t.Fatalf("the /helpers tip is %+v", row) + } + a, _ := helpersApp(t) + a.turn = 0 + if row.armed(a) { + t.Fatal("the tip is armed before the first exchange") + } + a.turn = 1 + if !row.armed(a) { + t.Fatal("the tip is not armed in a spoken-to conversation with no team") + } + harbor, err := a.teamMake("harbor", a.tabList()) + if err != nil { + t.Fatal(err) + } + teamsFlush(t, a) + spend(t, a, a.teamActivate(harbor)) + if row.armed(a) { + t.Fatal("the tip is armed with a team selected") + } + spend(t, a, a.teamOverlayChoose("")) + if !row.armed(a) { + t.Fatal("clearing the team did not arm the tip again") + } + typeLine(t, a, "/helpers") + teamsFlush(t, a) + if !a.notices.retired(row.id) { + t.Fatal("using /helpers did not retire its tip") + } + spend(t, a, a.teamOverlayChoose("")) + if row.armed(a) { + t.Fatal("the tip is armed in a conversation that already manages a team") + } +} diff --git a/internal/tui3/hometip_test.go b/internal/tui3/hometip_test.go index 3ac6cf46ab..6aa95ff8db 100644 --- a/internal/tui3/hometip_test.go +++ b/internal/tui3/hometip_test.go @@ -248,10 +248,11 @@ func TestEveryTipIsOnTheManualPage(t *testing.T) { // The cut was thirty, /project made it thirty-one, and three reads of the whole // list by the owner took it to twenty-two, and the way home by two spaces made // it twenty-three on 2026-09-24. The separate conversation-budget hint makes -// twenty-four. There is ONE set: every hint draws +// twenty-four, and /helpers made it twenty-five on 2026-10-19, at the owner's +// word. There is ONE set: every hint draws // on both boxes, a news row on neither, and a row filed under home's slot does // not build. -func TestTheTableIsTwentyFourHintsAndEveryOneDrawsOnBothBoxes(t *testing.T) { +func TestTheTableIsTwentyFiveHintsAndEveryOneDrawsOnBothBoxes(t *testing.T) { hints := 0 for _, n := range notices { if n.slot != slotHint { @@ -265,8 +266,8 @@ func TestTheTableIsTwentyFourHintsAndEveryOneDrawsOnBothBoxes(t *testing.T) { t.Errorf("hint %q draws in the transcript", n.id) } } - if hints != 24 { - t.Fatalf("the table holds %d hints, want 24 — the cut is deliberate, and the manual page counts them", hints) + if hints != 25 { + t.Fatalf("the table holds %d hints, want 25 — the cut is deliberate, and the manual page counts them", hints) } news := notice{id: "noted", slot: slotNote, armed: ready, text: "x"} if news.draws(slotHint) || news.draws(slotHome) || !news.draws(slotNote) { diff --git a/internal/tui3/notice.go b/internal/tui3/notice.go index 72e84f7796..4923b2c70f 100644 --- a/internal/tui3/notice.go +++ b/internal/tui3/notice.go @@ -197,6 +197,9 @@ const ( // are doors a person already knows, and the tip is about the one they // cannot see. eventHomeGesture = "home-gesture" + // eventHelpersUsed is /helpers reaching its command, bare or with words, + // or the Teams dropdown's `+ New from this session` chosen (helpers.go). + eventHelpersUsed = "helpers-used" ) // noticeEvents is every event there is, in one list, so the table check can @@ -211,7 +214,7 @@ var noticeEvents = []string{ eventConversationBudgetShown, eventSpendOpened, eventSteered, eventQueued, eventChatStarted, eventPlaceJumped, eventRemembered, eventSubharnessOpened, - eventConnectOpened, eventAutonomyAsked, eventHomeGesture, + eventConnectOpened, eventAutonomyAsked, eventHomeGesture, eventHelpersUsed, } // notice is one thing the surface may tell a person, and the whole of the rule @@ -422,6 +425,19 @@ var notices = []notice{ text: "/task starts a single-shot task on the side", retire: eventTaskTyped, }, + { + // THE BIGGER HALF OF HANDING WORK OUT, at the owner's word on + // 2026-10-19: /task sends one piece of the work away, and /helpers + // keeps this conversation and makes it the manager of a team for the + // rest (helpers.go). It is armed only where /helpers would act: a + // conversation that has been spoken to, no team selected on the strip, + // and not already a manager ([helpersTipArmed]). It retires the first + // time /helpers is typed or the Teams dropdown's row is chosen. + id: "helpers-team", slot: slotHint, + armed: helpersTipArmed, + text: "/helpers makes this conversation the manager of a new team", + retire: eventHelpersUsed, + }, { // THE OTHER HALF OF THE SAME LESSON: an automation is made by typing it // exactly or by saying what you want and when, and the card shows the diff --git a/internal/tui3/notice_test.go b/internal/tui3/notice_test.go index e3e120dc74..b3c65c3655 100644 --- a/internal/tui3/notice_test.go +++ b/internal/tui3/notice_test.go @@ -753,6 +753,7 @@ func TestEveryRetireEventIsProvedByItsGesture(t *testing.T) { eventSubharnessOpened: func(t *testing.T, a *app) { a.slash("/subharness") }, eventConnectOpened: func(t *testing.T, a *app) { a.slash("/connect") }, eventAutonomyAsked: func(t *testing.T, a *app) { a.slash("/autonomy") }, + eventHelpersUsed: func(t *testing.T, a *app) { a.slash("/helpers") }, eventHomeGesture: func(t *testing.T, a *app) { // A door that can open a conversation is what opens home at all // ([app.homeDoorOpen]); the bare test app has none. From 4becb93520cc4fce28c35d4f9a0a19dbb86ece3f Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Mon, 19 Oct 2026 12:24:56 -0400 Subject: [PATCH 05/10] session: a conversation that has gone on may suggest /helpers, once After the person's fourth message, an interactive conversation in no team is told once, in a note of its own, that it may end a reply with one sentence suggesting a team when the work is long and has parts a few helpers could take on at once: "Want to bring in some helpers? Type /helpers and I'll suggest who." Only the person can make the team; the note says so, and says never for small work, never instead of the work, and never again once suggested or declined. It is a note and not a paragraph of prompts/system.md because the page is the fixed prefix every request pays for, held under a budget that only shrinks, and the suggestion only fits a conversation that has gone on. Tasks' workers, automation runs, home's ask here and conversations already in a team are never told. The team manager page says why the chat suggested it. Co-Authored-By: Claude Opus 5.5 --- internal/manual/chat/team-manager.md | 15 +++ internal/manual/chat_test.go | 2 + internal/session/agent.go | 7 +- internal/session/helpersnote.go | 74 ++++++++++++++ internal/session/helpersnote_test.go | 144 +++++++++++++++++++++++++++ internal/session/session.go | 3 + 6 files changed, 244 insertions(+), 1 deletion(-) create mode 100644 internal/session/helpersnote.go create mode 100644 internal/session/helpersnote_test.go diff --git a/internal/manual/chat/team-manager.md b/internal/manual/chat/team-manager.md index 6d6f56349a..87042e9e6c 100644 --- a/internal/manual/chat/team-manager.md +++ b/internal/manual/chat/team-manager.md @@ -110,6 +110,21 @@ the strip goes back, the reason is said, and nothing is sent. If you leave the conversation before the save finishes, the team stands and the message is not sent: `made the team lab · its manager is the conversation you left; ask it for helpers there`. +## Why the chat suggested bringing in helpers or /helpers + +A conversation that has gone on may end a reply with one sentence like "This could be +split up and worked on in parallel by a small team. Want to bring in some helpers? Type +/helpers and I'll suggest who." That is codeaf letting it say so: after your fourth message, +a conversation in no team is told, once, that it may suggest a team when the work is long +and has parts a few helpers could work on at once. It is told to say it at most +once, never for small or quick work, never instead of what you asked, and never again +after you pass on it. Reopening a long conversation tells it once more, and it can see in +its history that it already asked. + +It cannot make the team itself; only you can, with `/helpers` or `+ New from this session` +in the Teams dropdown. Ignore it, or say no, and nothing changes. A conversation already in +a team, a task's worker, an automation's run and home's `ask here` are never told this. + ## Cancel a team nobody joined — team_dissolve Say no to the proposed helpers, or "cancel", and the manager calls `team_dissolve`. The diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index c9f06a52ce..2d1cd63306 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -4130,6 +4130,8 @@ func TestHelpersQuestionsReachTheirSections(t *testing.T) { {"why is + New from this session missing when a team is selected", "conversations-and-teams", "the Teams dropdown and + New from this session"}, {"why is /helpers not offered here", "team-manager", "When /helpers is not offered"}, {"/helpers says open one first", "team-manager", "When /helpers is not offered"}, + {"why did the chat suggest bringing in helpers", "team-manager", "Why the chat suggested bringing in helpers"}, + {"the model asked if I want a small team working in parallel", "team-manager", "Why the chat suggested bringing in helpers"}, {"I said no to the helpers, how do I cancel the team", "team-manager", "Cancel a team nobody joined"}, {"what does team_dissolve do", "team-manager", "Cancel a team nobody joined"}, } { diff --git a/internal/session/agent.go b/internal/session/agent.go index 9f020618dc..b91967218a 100644 --- a/internal/session/agent.go +++ b/internal/session/agent.go @@ -2857,6 +2857,10 @@ func (a *Agent) landVolatileLocked() { // read made before this request ([Agent.teamBoundary]) rather than beside // the work, so it is never a step late (team.go's [teamRoleNoteOpening]). a.landTeamRoleLocked() + // AND, ONCE, IN A CONVERSATION IN NO TEAM THAT HAS GONE ON, leave to + // suggest /helpers (helpersnote.go). After the role, which is what says + // whether there is a team. + a.landHelpersNoteLocked() a.landNoteLocked(volatileNoteOpening, a.volatileBlockLocked()) // AND A MANAGER'S TEAM, in a note of its own: a team moves whenever a member // does, and riding the card's note @@ -2926,7 +2930,8 @@ func isVolatileNote(text string) bool { return strings.HasPrefix(text, volatileNoteOpening) || strings.HasPrefix(text, bashBeltFrameOpening) || strings.HasPrefix(text, teamNoteOpening) || - strings.HasPrefix(text, teamRoleNoteOpening) + strings.HasPrefix(text, teamRoleNoteOpening) || + strings.HasPrefix(text, helpersNoteOpening) } // mayBashBelt is [Config.mayBashBelt] asked of a live agent, so that the diff --git a/internal/session/helpersnote.go b/internal/session/helpersnote.go new file mode 100644 index 0000000000..0047698704 --- /dev/null +++ b/internal/session/helpersnote.go @@ -0,0 +1,74 @@ +package session + +// THE ONE NOTE THAT LETS A CONVERSATION SUGGEST /helpers. +// +// A person deep in a piece of work can turn it into a team effort with the +// conversation in charge: the chat's `/helpers` makes a new team with this +// conversation as its manager (internal/tui3's helpers.go). Nobody finds a +// command they have never heard of in the middle of their work, and the model, +// which can see the work growing, is the one placed to mention it. So a +// conversation is told, once, that it may. +// +// IT IS A NOTE AND NOT A PARAGRAPH OF THE PAGE, for two reasons that are one. +// The page (prompts/system.md) is the fixed prefix every request of every turn +// of every conversation pays for, and it is held under a budget that only +// shrinks (prefixbudget_test.go); a sentence most conversations never act on +// does not belong there. And the suggestion only fits a conversation that has +// gone on: a note that lands after [helpersNoteAfter] things the person has +// said costs nothing in the short exchanges that are most of them, and arrives +// in the long ones, which are the ones whose work may have outgrown a +// conversation. +// +// IT IS ONCE, AND SAID AS RARE. It lands at most once in a process, never in a +// conversation that already has a part in a team (its role note says what it +// is), and its words make the suggestion a single sentence at the end of a +// reply, never repeated, never for small work and never instead of the work. +// Only the person can make the team; the note says so, because a model that +// thought it could would promise one and fail. +// +// IT IS FOR A CONVERSATION A PERSON SITS IN, ON THE SURFACE THAT HAS THE +// COMMAND ([Config.mayOfferHelpers]): not a task's node or worker, not an +// automation's run, not home's short `ask here` exchange. + +// helpersNoteOpening is the note's identity: [isVolatileNote] tells it from +// the person's own words by it, and [Agent.lastNoteLocked] finds it by it. +const helpersNoteOpening = "Instructions from codeaf, not from the person: when to suggest helpers." + +// helpersNoteAfter is how many messages the person has sent before the note +// lands: past a quick question and its follow-ups, into work that is going on. +const helpersNoteAfter = 4 + +// helpersNoteBody is what the note says. +const helpersNoteBody = "If the work here has grown long, with parts that could be worked on at once by a few helpers, " + + "you may suggest a team once, briefly, at the end of a reply, in words like: " + + "\"This could be split up and worked on in parallel by a small team. Want to bring in some helpers? " + + "Type /helpers and I'll suggest who.\" " + + "Only the person can make the team, with /helpers; this conversation then becomes its manager. " + + "Not for small or quick work, never instead of doing what was asked, and never again once you have suggested it or they passed." + +// mayOfferHelpers says whether this conversation may be told it can suggest +// /helpers: one a person is sitting in ([Config.Interactive], which the +// surface sets and an automation's run clears), that is not a task's node or +// worker, not home's short `ask here` exchange, and that can be in a team at +// all ([Config.teamProfile]). +func (c Config) mayOfferHelpers() bool { + return c.Interactive && !c.InTask && c.taskID == 0 && c.AutomationRun == nil && !c.Errand && c.teamProfile() != "" +} + +// landHelpersNoteLocked lands the note when this conversation has earned it +// and has not had it. The caller holds a.mu, at the step boundary +// ([Agent.landVolatileLocked]), after the team role has been composed, so a +// conversation that is in a team is known to be one. +func (a *Agent) landHelpersNoteLocked() { + if a.helpersNoted || a.personSeq < helpersNoteAfter || !a.config.mayOfferHelpers() { + return + } + if a.teamRoleText != "" || a.lastNoteLocked(teamRoleNoteOpening) != "" { + return + } + a.helpersNoted = true + if a.lastNoteLocked(helpersNoteOpening) != "" { + return + } + a.landNoteLocked(helpersNoteOpening, helpersNoteBody) +} diff --git a/internal/session/helpersnote_test.go b/internal/session/helpersnote_test.go new file mode 100644 index 0000000000..1b864cc223 --- /dev/null +++ b/internal/session/helpersnote_test.go @@ -0,0 +1,144 @@ +package session + +// THE /helpers NOTE, AS TESTS: a conversation a person sits in, in no team, is +// told once — after it has gone on — that it may suggest /helpers; and nobody +// else is told at all. + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/codeaf/internal/manual" +) + +// helpersNotes is every /helpers note in one request, in order. +func helpersNotes(messages []ai.Message) []string { + var notes []string + for _, message := range messages { + if text := messageText(message); message.Role == "user" && strings.HasPrefix(text, helpersNoteOpening) { + notes = append(notes, text) + } + } + return notes +} + +// loneConversation is a transcript beside the fixture's team and in no team +// of its own: an ordinary conversation on the same profile. +func loneConversation(t *testing.T, fixture teamFixture) string { + t.Helper() + dir := filepath.Join(filepath.Dir(filepath.Dir(fixture.manager)), "lone") + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, placeTranscript) + if err := os.WriteFile(path, nil, 0o600); err != nil { + t.Fatal(err) + } + return path +} + +// interactive is a conversation a person is sitting in, as the chat door +// builds one. +func interactive(config *Config) { config.Interactive = true } + +// THE NOTE LANDS ONCE, AFTER THE PERSON HAS SAID [helpersNoteAfter] THINGS, and +// stays where it landed: the requests before carry none, and every request +// after carries the one note, never a second. It is the session's note and +// not the person's words, and it names the command and no tool. +func TestTheHelpersNoteLandsOnceAfterTheConversationHasGoneOn(t *testing.T) { + fixture := newTeamFixture(t, true) + turns := helpersNoteAfter + 2 + completer := oneAnswer(turns) + agent := teamAgent(t, fixture, loneConversation(t, fixture), completer, interactive) + for i := 0; i < turns; i++ { + submitAndWait(t, agent, "and the next part of the work") + } + for i := 0; i < turns; i++ { + notes := helpersNotes(completer.request(i)) + want := 0 + if i >= helpersNoteAfter-1 { + want = 1 + } + if len(notes) != want { + t.Fatalf("request %d carried %d /helpers notes, want %d:\n%s", i, len(notes), want, userTextIn(completer.request(i))) + } + } + note := helpersNotes(completer.request(turns - 1))[0] + if !isVolatileNote(note) { + t.Fatal("the note would be drawn as something the person typed") + } + for _, want := range []string{"/helpers", "Only the person can make the team", "once", "Not for small or quick work"} { + if !strings.Contains(note, want) { + t.Errorf("the note does not say %q:\n%s", want, note) + } + } + if strings.Contains(note, "team_") { + t.Errorf("the note names a tool the conversation does not have:\n%s", note) + } +} + +// THE MANUAL SAYS "after your fourth message" (team-manager.md, *Why the chat +// suggested bringing in helpers*). Move the threshold and that sentence moves +// with it, in the same change. +func TestTheHelpersNoteThresholdIsTheOneTheManualStates(t *testing.T) { + const manualSays = 4 + if helpersNoteAfter != manualSays { + t.Fatalf("the note lands after %d messages and the manual says the fourth; change both", helpersNoteAfter) + } + if !manual.Chat().Mentions("after your fourth message") { + t.Fatal("the manual no longer says when the note lands") + } +} + +// A CONVERSATION IN A TEAM IS NEVER TOLD: its role note says what it is, and +// a manager suggesting a second team would be the nesting /helpers refuses. +func TestAConversationInATeamIsNeverToldToSuggestHelpers(t *testing.T) { + fixture := newTeamFixture(t, true) + turns := helpersNoteAfter + 1 + for _, transcript := range []string{fixture.manager, fixture.web} { + completer := oneAnswer(turns) + agent := teamAgent(t, fixture, transcript, completer, interactive) + for i := 0; i < turns; i++ { + submitAndWait(t, agent, "and the next part of the work") + } + if notes := helpersNotes(completer.request(turns - 1)); len(notes) != 0 { + t.Fatalf("%s, in a team, was told to suggest helpers:\n%s", filepath.Base(filepath.Dir(transcript)), notes[0]) + } + } +} + +// NOBODY BUT A CONVERSATION A PERSON SITS IN IS TOLD: a headless one, an +// automation's run, home's `ask here` exchange, a task's node, and one with no +// profile a team could be kept in. +func TestOnlyAConversationAPersonSitsInMayOfferHelpers(t *testing.T) { + fixture := newTeamFixture(t, true) + base := Config{Interactive: true, ProfileDir: fixture.profile, SessionFile: loneConversation(t, fixture)} + if !base.mayOfferHelpers() { + t.Fatal("an interactive conversation on a profile may not offer helpers") + } + for name, change := range map[string]func(*Config){ + "headless": func(c *Config) { c.Interactive = false }, + "automation run": func(c *Config) { c.AutomationRun = &AutomationRun{} }, + "ask here": func(c *Config) { c.Errand = true }, + "task node": func(c *Config) { c.InTask = true }, + "no transcript": func(c *Config) { c.SessionFile = "" }, + } { + config := base + change(&config) + if config.mayOfferHelpers() { + t.Errorf("a %s conversation may offer helpers", name) + } + } + + completer := oneAnswer(helpersNoteAfter + 1) + agent := teamAgent(t, fixture, loneConversation(t, fixture), completer, nil) + for i := 0; i <= helpersNoteAfter; i++ { + submitAndWait(t, agent, "and the next part of the work") + } + if notes := helpersNotes(completer.request(helpersNoteAfter)); len(notes) != 0 { + t.Fatal("a headless conversation was told to suggest helpers") + } +} diff --git a/internal/session/session.go b/internal/session/session.go index dfc0666a25..87a8c5606d 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -2968,6 +2968,9 @@ type Agent struct { // [teamRoleBlock]), set at every step boundary before the request and // landed in a note of its own ahead of the others. teamRoleText string + // helpersNoted says the one note that lets this conversation suggest + // /helpers has landed in this process (helpersnote.go). + helpersNoted bool // team is this conversation's account of its teams: its key, its roles and // its Traffic cursors (team.go). It has its own lock and is never read // under mu. From a224985d43f84f470a7cd76a6eeffeac8e3f26ae Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:15:33 -0400 Subject: [PATCH 06/10] session: a member a manager started wakes with its brief again On an ordinary launch every team edit goes through the engine's teams door, which stamps a new member's join time. A member started by team_start always joins after the start that made it was written, and the join bound in the Traffic delivery dropped that start: nothing was addressed to the member, the start watch never woke it, and helpers sat idle while the manager reported them running. The unit test missed it because it added the member with no join time. A member's own start, addressed to its handle within the start grace, is now exempt from the bound; every other line from before the join is still withheld, which the new test also holds. The default /helpers ask no longer says "Don't start anyone until I agree", which the memory that notices rules kept as a standing project rule. It now says what happens next in this exchange: "I'll answer yes, ask for changes, or cancel." Co-Authored-By: Claude Opus 5.5 --- internal/manual/chat/team-manager.md | 2 +- internal/session/team.go | 19 ++++++++++- internal/session/team_wake_test.go | 49 ++++++++++++++++++++++++++++ internal/tui3/helpers.go | 15 ++++++--- internal/tui3/helpers_test.go | 2 +- 5 files changed, 80 insertions(+), 7 deletions(-) diff --git a/internal/manual/chat/team-manager.md b/internal/manual/chat/team-manager.md index 87042e9e6c..a62004dcf7 100644 --- a/internal/manual/chat/team-manager.md +++ b/internal/manual/chat/team-manager.md @@ -76,7 +76,7 @@ conversation in charge. With no team selected, open the **Teams ▾** dropdown a the tab strip and choose its last row, **`+ New from this session`**. It puts this in the message box, in place of what was there, and sends nothing: -`/helpers Suggest two or three helpers for what we're working on here. For each, say what it would take on and the brief you'd give it. Don't start anyone until I agree, and if I say cancel, undo the team.` +`/helpers Suggest two or three helpers for what we're working on here. For each, say what it would take on and the brief you'd give it. I'll answer yes, ask for changes, or cancel.` Edit the words into the team you want, then press enter; typing `/helpers` yourself does the same, and a bare `/helpers` sends exactly that default. Two things happen: diff --git a/internal/session/team.go b/internal/session/team.go index f183a90449..207b7f75ce 100644 --- a/internal/session/team.go +++ b/internal/session/team.go @@ -669,7 +669,7 @@ func (a *Agent) teamNewsLocked(profile string, roles []teamRole) string { var lines []string for _, entry := range entries { cursor = entry.ID - if !role.joinedAt.IsZero() && entry.At.Before(role.joinedAt) { + if !role.joinedAt.IsZero() && entry.At.Before(role.joinedAt) && !a.teamOwnStart(role, entry) { continue } if line := a.teamEntryLineLocked(profile, role, entry); line != "" { @@ -718,6 +718,23 @@ func (a *Agent) teamNewsLocked(profile string, roles []teamRole) string { // it, because the start carries the brief, and the brief is handed to the new // member here, marked as the manager's, on its first request ([teamBriefLine]). // The person never typed it, so it must not arrive as the person's message. +// teamOwnStart reports whether entry is the start that made this +// conversation: a manager's start addressed to its handle in the team, inside +// [teamStartGrace] of when this process began. It is the one line a member is +// owed from before it joined, the line [Agent.firstTeamCursor] reaches back +// for, and the join bound in [Agent.teamNewsLocked] must let it through. +// +// THE JOIN IS ALWAYS AFTER THE START. The manager writes the start, the +// interface opens the conversation on reading it, and only then makes it a +// member; and the engine's teams door stamps a new member's join with the time +// it took the edit (internal/remote's teams.go). A bound that took the start +// too handed every started member nothing, so none of them ever woke +// (team_wake.go), and a manager reported helpers running that were idle. +func (a *Agent) teamOwnStart(role teamRole, entry teams.Entry) bool { + return !role.manager && role.handle != "" && entry.Kind == teams.KindStart && entry.To == role.handle && + !entry.At.Before(a.startedAt.Add(-teamStartGrace)) +} + func (a *Agent) firstTeamCursor(profile string, role teamRole) string { tail, err := teams.ReadTraffic(profile, role.id, "", teamFirstLook) if err != nil { diff --git a/internal/session/team_wake_test.go b/internal/session/team_wake_test.go index 2aa8001baf..5ea17af6a1 100644 --- a/internal/session/team_wake_test.go +++ b/internal/session/team_wake_test.go @@ -65,6 +65,55 @@ func TestAStartedMemberWakesOnItsOwnWithTheBriefOnce(t *testing.T) { } } +// AND IT WAKES WHEN THE STORE STAMPED ITS JOIN, as every ordinary launch's +// does. The engine's teams door gives a new member the time it joined +// (internal/remote's teams.go), and a member a manager started always joins +// after the start that made it was written. The join bound keeps a membership +// from being handed lines from before it began; its own start is the one line +// it is owed from before, and the bound must not take that away. It used to: +// the brief was filtered out, nothing was addressed to the member, and every +// helper a manager started sat idle until the person asked why. +// +// A DIRECTIVE FROM BEFORE THE JOIN IS STILL NOT HANDED OVER: the exemption is +// the start alone. +func TestAStartedMemberWhoseJoinWasStampedStillWakesWithItsBrief(t *testing.T) { + fixture := newWakingTeamFixture(t) + appendTraffic(t, fixture, teams.Entry{Kind: teams.KindStart, From: teams.FromManager, To: "lexer", Text: "Rewrite the lexer."}) + appendTraffic(t, fixture, teams.Entry{Kind: teams.KindDirective, From: teams.FromManager, To: "lexer", Text: "Also delete the parser."}) + dir := filepath.Join(filepath.Dir(filepath.Dir(fixture.parser)), "lexer") + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + transcript := filepath.Join(dir, placeTranscript) + if err := os.WriteFile(transcript, nil, 0o600); err != nil { + t.Fatal(err) + } + completer := &scriptedCompleter{steps: []step{ + func(context.Context, []ai.Message) (*ai.Response, error) { return textResponse("on it"), nil }, + }} + teamAgent(t, fixture, transcript, completer, nil) + time.Sleep(10 * time.Millisecond) + if err := teams.Update(fixture.profile, func(file *teams.File) error { + return file.AddMember(fixture.teamID, teams.Member{Key: convKeyOf(t, transcript), File: transcript, Handle: "lexer", JoinedAt: time.Now()}) + }); err != nil { + t.Fatal(err) + } + deadline := time.Now().Add(5 * time.Second) + for completer.requests() == 0 && time.Now().Before(deadline) { + time.Sleep(20 * time.Millisecond) + } + if completer.requests() == 0 { + t.Fatal("a started member whose join was stamped never took its first turn") + } + first := userTextIn(completer.request(0)) + if !briefIn(first, "Rewrite the lexer.") { + t.Fatalf("the first request did not carry the brief:\n%s", first) + } + if strings.Contains(first, "Also delete the parser.") { + t.Fatalf("a directive from before the join was handed over:\n%s", first) + } +} + // WITH THE TEAM'S AUTO-WAKE OFF, A START OPENS THE MEMBER AND STARTS NO TURN. // The brief stays in the Traffic and is handed over on the first turn something // else starts, and the Traffic says why nothing ran. diff --git a/internal/tui3/helpers.go b/internal/tui3/helpers.go index d57e9b6312..eebcef2c7c 100644 --- a/internal/tui3/helpers.go +++ b/internal/tui3/helpers.go @@ -64,10 +64,17 @@ import ( // helpersPrompt is what a bare /helpers asks, and what the dropdown's row puts // in the box after `/helpers `. It is the person speaking, in words they would // use, because it is their message and it is on their screen: it names no tool -// and nothing the person could not have typed. Its three asks are the three -// things the person needs from the manager: a proposal to read, nobody started -// before they agree, and a way to say no. -const helpersPrompt = "Suggest two or three helpers for what we're working on here. For each, say what it would take on and the brief you'd give it. Don't start anyone until I agree, and if I say cancel, undo the team." +// and nothing the person could not have typed. It asks for a proposal to read +// and says what happens next — the person answers yes, a change, or cancel — +// which is what keeps the manager from starting anyone before they agree. +// +// IT DESCRIBES THIS EXCHANGE AND STATES NO STANDING RULE. It used to end +// "Don't start anyone until I agree, and if I say cancel, undo the team.", and +// the memory that notices rules (internal/session's memory) kept that as one +// for the whole project: a person's standing order never to start helpers +// without asking, read by every manager after. Said as the next step of this +// one conversation, there is nothing in it to keep. +const helpersPrompt = "Suggest two or three helpers for what we're working on here. For each, say what it would take on and the brief you'd give it. I'll answer yes, ask for changes, or cancel." // The words this door says. Each is quoted in the manual exactly as spelled. const ( diff --git a/internal/tui3/helpers_test.go b/internal/tui3/helpers_test.go index a8f4202107..4bd2778101 100644 --- a/internal/tui3/helpers_test.go +++ b/internal/tui3/helpers_test.go @@ -134,7 +134,7 @@ func TestABareHelpersShowsTheAskItSends(t *testing.T) { t.Errorf("the default ask says %q, which is codeaf's word and not the person's: %q", internal, helpersPrompt) } } - for _, want := range []string{"two or three helpers", "Don't start anyone until I agree", "cancel"} { + for _, want := range []string{"two or three helpers", "I'll answer yes", "cancel"} { if !strings.Contains(helpersPrompt, want) { t.Errorf("the default ask does not say %q: %q", want, helpersPrompt) } From 898f5ac545c6c8b1460c9b462ae0d67c6e4a47d2 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:23:18 -0400 Subject: [PATCH 07/10] docs: change entry for /helpers (#1881) Co-Authored-By: Claude Opus 5.5 --- docs/changes/unreleased/1881-helpers.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 docs/changes/unreleased/1881-helpers.md diff --git a/docs/changes/unreleased/1881-helpers.md b/docs/changes/unreleased/1881-helpers.md new file mode 100644 index 0000000000..d7ad349785 --- /dev/null +++ b/docs/changes/unreleased/1881-helpers.md @@ -0,0 +1,13 @@ +--- +kind: added +title: /helpers makes the conversation you are in a new team's manager, which proposes helpers +pr: 1881 +surface: [chat, engine, docs] +invalidates: + - "Every way to make a team started from the team, and a team was made without a manager; the only way to make an existing conversation one was Add chat, then Choose AI manager. `/helpers` makes a new top-level team with the conversation in front as its manager, in one save, then sends exactly the words after it (bare, a default ask shown in full) as the person's message." + - "The Teams ▾ chip was absent with no teams, and its dropdown printed an up/down, enter, esc footer. The chip shows with no teams, the footer is gone, and with no team selected the dropdown ends with `+ New from this session`, which puts `/helpers` and the default ask in the box. With a team selected the row is absent and `/helpers` refuses: the team it makes is top-level, and nesting is + Add subteam." + - "A manager had no way to take its team back. `team_dissolve` forgets a team that has never held anyone but its manager (no member, former member, subteam or Traffic); it is on the belt only while that is true, for every team however it was made, and does not ask first." + - "A member a manager started with team_start never took its first turn on an ordinary launch: the engine's teams door stamps a new member's join time, the join bound dropped the start that made it, and the member sat idle with nothing addressed to it. A member's own start is exempt from the bound now, and it wakes with its brief; every other line from before the join is still withheld." + - "There are twenty-five tips, not twenty-four: `/helpers makes this conversation the manager of a new team`." + - "An interactive conversation in no team is now told once, after the person's fourth message, that it may suggest /helpers when the work is long and has parts a few helpers could take on at once. It is a note, not a paragraph of the system prompt." +--- From 74f27ae2ccbc9ccba496c737c293908deadd9dc1 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Tue, 20 Oct 2026 13:45:50 -0400 Subject: [PATCH 08/10] tui3: + New from this session makes the team at once; /helpers asks it Choosing the Teams dropdown's row used to fill the message box and nothing else, and the change below was easy to miss. The row now makes the team the moment it is chosen: this conversation becomes its manager, the strip narrows to it and the column turns to its Traffic on the same frame, and the box is given `/helpers` and the ask for the next step. /helpers now asks the team the conversation in front manages for helpers, and makes no team. In a conversation that manages none it changes nothing and says to use + New from this session. A /helpers sent before the store has the row's team is held until it does, so the manager's first step already knows it is one. A refused save empties the box again. The default ask is one sentence: "Suggest two or three helpers for this work, and I'll say yes, change them, or cancel." The tip, the one-time suggestion note, the command rows, the manual and the change entry say where the team is made now. Co-Authored-By: Claude Opus 5.5 --- docs/changes/unreleased/1881-helpers.md | 11 +- internal/manual/chat/commands.md | 2 +- .../manual/chat/conversations-and-teams.md | 13 +- internal/manual/chat/hints-and-tips.md | 8 +- internal/manual/chat/home.md | 2 +- internal/manual/chat/team-manager.md | 94 +++-- internal/manual/chat/teams-page.md | 2 +- internal/manual/chat_test.go | 5 +- internal/session/helpersnote.go | 13 +- internal/session/helpersnote_test.go | 2 +- internal/tui3/commands.go | 12 +- internal/tui3/helpers.go | 280 ++++++++------ internal/tui3/helpers_test.go | 362 +++++++++--------- internal/tui3/notice.go | 16 +- internal/tui3/teammenu.go | 13 +- internal/tui3/teamseam.go | 6 +- internal/tui3/teamwritesaid.go | 5 +- 17 files changed, 451 insertions(+), 395 deletions(-) diff --git a/docs/changes/unreleased/1881-helpers.md b/docs/changes/unreleased/1881-helpers.md index d7ad349785..d6da0afbcf 100644 --- a/docs/changes/unreleased/1881-helpers.md +++ b/docs/changes/unreleased/1881-helpers.md @@ -1,13 +1,14 @@ --- kind: added -title: /helpers makes the conversation you are in a new team's manager, which proposes helpers +title: + New from this session makes this chat a team's manager, and /helpers asks it for helpers pr: 1881 surface: [chat, engine, docs] invalidates: - - "Every way to make a team started from the team, and a team was made without a manager; the only way to make an existing conversation one was Add chat, then Choose AI manager. `/helpers` makes a new top-level team with the conversation in front as its manager, in one save, then sends exactly the words after it (bare, a default ask shown in full) as the person's message." - - "The Teams ▾ chip was absent with no teams, and its dropdown printed an up/down, enter, esc footer. The chip shows with no teams, the footer is gone, and with no team selected the dropdown ends with `+ New from this session`, which puts `/helpers` and the default ask in the box. With a team selected the row is absent and `/helpers` refuses: the team it makes is top-level, and nesting is + Add subteam." + - "Every way to make a team started from the team, and a team was made without a manager; the only way to make an existing conversation one was Add chat, then Choose AI manager. The Teams ▾ dropdown's `+ New from this session` makes a new top-level team with the conversation in front as its manager the moment it is chosen, in one save, selects it, and puts `/helpers` and a one-sentence default ask in the box." + - "`/helpers` asks the team the conversation in front manages for helpers: it sends exactly the words after it (bare, the default ask, shown in full) as the person's message, and makes no team. In a conversation that manages none it changes nothing and says to make one with + New from this session." + - "The Teams ▾ chip was absent with no teams, and its dropdown printed an up/down, enter, esc footer. The chip shows with no teams, the footer is gone, and with no team selected the dropdown ends with `+ New from this session`. With a team selected the row is absent: the team it makes is top-level, and nesting is + Add subteam." - "A manager had no way to take its team back. `team_dissolve` forgets a team that has never held anyone but its manager (no member, former member, subteam or Traffic); it is on the belt only while that is true, for every team however it was made, and does not ask first." - "A member a manager started with team_start never took its first turn on an ordinary launch: the engine's teams door stamps a new member's join time, the join bound dropped the start that made it, and the member sat idle with nothing addressed to it. A member's own start is exempt from the bound now, and it wakes with its brief; every other line from before the join is still withheld." - - "There are twenty-five tips, not twenty-four: `/helpers makes this conversation the manager of a new team`." - - "An interactive conversation in no team is now told once, after the person's fourth message, that it may suggest /helpers when the work is long and has parts a few helpers could take on at once. It is a note, not a paragraph of the system prompt." + - "There are twenty-five tips, not twenty-four: `+ New from this session in the Teams menu makes this chat a team's manager`." + - "An interactive conversation in no team is now told once, after the person's fourth message, that it may suggest + New from this session when the work is long and has parts a few helpers could take on at once. It is a note, not a paragraph of the system prompt." --- diff --git a/internal/manual/chat/commands.md b/internal/manual/chat/commands.md index 81300e0eed..66ff67a0d6 100644 --- a/internal/manual/chat/commands.md +++ b/internal/manual/chat/commands.md @@ -323,7 +323,7 @@ command). | `/spend` | none | none | opens the spend place, what this machine has cost, by the day (also `alt+5`) | | `/wall` | | | every saved conversation at once, with live or saved previews and card-based team creation (also `alt+v`, or `▦` under the box) | | `/teams` | | | the teams page: your teams as a tree, what waits on you, member previews and the selected team's interaction table (also `alt+2`, or `teams` on the tab bar) | -| `/helpers` | | `` | makes this conversation a new team's manager, then sends your words after it (bare, a default ask to suggest two or three helpers, shown in full) for you to accept, change or cancel; `+ New from this session`, last in the Teams dropdown while no team is selected, puts `/helpers` and that ask in the box; with a team selected it refuses; on home it says to open a conversation first | +| `/helpers` | | `` | asks the team this conversation manages to suggest helpers: sends your words after it, or bare a one-sentence default ask, shown in full; makes no team, and in a conversation that manages none it says to make one with `+ New from this session`, last in the Teams dropdown while no team is selected, which makes the team at once and puts `/helpers` and the ask in the box; on home it says to open that conversation first | | `/cost` | `/usage`, `/tokens` | — | prints what this conversation has spent, and on what | | `/effort` | `/think`, `/thinking` | — | opens this conversation's thinking levels; says it is unavailable when the session has no dial | | `/effort` | `/think`, `/thinking` | `` | sets this conversation's thinking level; an unknown rung lists the accepted levels and changes nothing | diff --git a/internal/manual/chat/conversations-and-teams.md b/internal/manual/chat/conversations-and-teams.md index e6081039cc..5815d11972 100644 --- a/internal/manual/chat/conversations-and-teams.md +++ b/internal/manual/chat/conversations-and-teams.md @@ -225,9 +225,10 @@ The dropdown offers **All teams** when a global manager exists, then the active All teams selects the global-manager overlay; it is separate from the team-agnostic `▦ All` grid. A root without a manager or with a known missing manager transcript has no All teams choice. The picker has no None option or management actions. With **no team selected**, its last row, -under a rule, is **+ New from this session**: it puts `/helpers` and a default ask in the message -box, replacing what was there (that draft is kept in `/drafts`), and nothing happens until you -edit it and press enter (see *Turn this conversation into a team* on the team manager page). So +under a rule, is **+ New from this session**: it makes a team at once with the conversation in +front as its manager, selects it, and puts `/helpers` and a default ask in the message box, +replacing what was there (that draft is kept in `/drafts`); enter sends the ask (see *Turn +this conversation into a team* on the team manager page). So the chip is there even with no teams yet; it is absent only over `--host` to an engine without teams. With a team selected the row is not offered: it makes a top-level team, and a team inside another is `+ Add subteam` on Teams. @@ -246,9 +247,9 @@ it does not select an overlay. A **manager** is a conversation that runs the team: you talk to it, it sends work to members and reports progress. Teams offers **Choose AI manager** for existing members and **+ Manager** -when a team has none. In the conversation you are working in, **`/helpers`** (or **`+ New -from this session`**, last in the Teams dropdown) makes a new team with that conversation -as its manager, which then proposes helpers for you to accept, change or cancel. Add a new member first when you need a new conversation to replace +when a team has none. In the conversation you are working in, **`+ New from this session`**, +last in the Teams dropdown, makes a new team with that conversation as its manager, and +**`/helpers`** asks it to propose helpers for you to accept, change or cancel. Add a new member first when you need a new conversation to replace an existing manager. All teams has a dedicated Global manager card. `+ Global manager` creates its optional conversation. Its reports are the managers of open top-level teams; subteam managers report to their parent. Its card's `x` deletes the global-manager conversation with the diff --git a/internal/manual/chat/hints-and-tips.md b/internal/manual/chat/hints-and-tips.md index 6f72c0e6c3..22804e36be 100644 --- a/internal/manual/chat/hints-and-tips.md +++ b/internal/manual/chat/hints-and-tips.md @@ -150,10 +150,10 @@ build if the two disagree), so a tip you saw is on it word for word. it. It is the first tip a conversation says, ahead of `/task`. - `/task starts a single-shot task on the side` — after the first exchange. Retired when `/task` is typed, bare or with a brief. -- `/helpers makes this conversation the manager of a new team` — after the first exchange, - in a conversation with no team selected on the strip that does not already manage a team; - never on home. Retired the first time `/helpers` is typed or the Teams dropdown's - `+ New from this session` is chosen. +- `+ New from this session in the Teams menu makes this chat a team's manager` — after the + first exchange, in a conversation with no team selected on the strip that does not already + manage a team; never on home. Retired the first time that row is chosen or `/helpers` is + typed. - `/automations add sets one up exactly · or just say when, like every Monday at 9` — from the first minute on home and after the first exchange in a conversation. It is the other half of the `/automations` row above — the place lists them, and this is how one is made, diff --git a/internal/manual/chat/home.md b/internal/manual/chat/home.md index efd3d20d93..91b392c1a7 100644 --- a/internal/manual/chat/home.md +++ b/internal/manual/chat/home.md @@ -1342,7 +1342,7 @@ one behind your back. This is every fate, in the words the drop-up draws them in | **`this list is /resume`** | `/resume` `/sessions` | Says `this list is /resume · enter opens a row` — home *is* that list. | | **`onto home's tray`** | `/attach ` | The file — or picture — rides on home's own tray into the conversation you open next. Home says `attached · notes.md · rides with the next conversation`. A bare `/attach` opens the browser aimed at the next conversation's folder, and a file chosen there lands on the tray. | | **`opens a conversation here first`** | `/files` · `/folder` `/place` `/dir` · `/manual` · `/permissions` `/perms` · `/connect` · `/harness` · `/subharness` · `/skill` `/skills` · `/copy` · `/select` · `/rewind` `/undo` `/back` · `/compact` · `/export` `/save` · `/task ` | Opens a conversation at the target — the folder at the right of the keys row and the model on the rule above the box — then runs there. Home closes, exactly as `enter` closes it. `/manual` is on this road since 2026-09-22: it is a question put to the model, so it needs a conversation to be asked in. `/folder` joined it the same day — it gives THIS conversation a folder, and home has no this; the pin it used to be here is `/project`. | -| **`answers here`** | `/help` · `/status` · `/cost` · `/cache` · `/budget` · `/debug` · `/stop` · `/remember` · `/always` · `/always ` · `/helpers` · a word nobody defined | Answers with a note, and the first line of that note is put on home's own line under the box. `there is no command called /pricing · / lists them` is now something you can read. `/helpers` makes a team of the conversation you are working in, and home has none in front, so it says `/helpers makes a team of the conversation you are in · open one first`. | +| **`answers here`** | `/help` · `/status` · `/cost` · `/cache` · `/budget` · `/debug` · `/stop` · `/remember` · `/always` · `/always ` · `/helpers` · a word nobody defined | Answers with a note, and the first line of that note is put on home's own line under the box. `there is no command called /pricing · / lists them` is now something you can read. `/helpers` asks the team a conversation manages for helpers, and home has none in front, so it says `/helpers asks the team a conversation manages for helpers · open that conversation first`. | | **`opens the crew panel`** | `/crew` and every `/crew` shortcut | Home steps aside and the crew panel opens over the conversation behind it; `esc` on the panel brings you back to home. A shortcut opens it with a tick on the row it changed. | | **`runs on the conversation behind home`** | `/land` · `/land ` · `/workspace ` · `/delete` | `/delete` asks to permanently delete the conversation behind Home; it opens its confirmation here and never starts a new conversation. The other commands act on the conversation this window is holding behind the screen — not on the one `enter` would open — and its answer is echoed onto home's line. | | **`a fresh conversation behind home`** | `/new` `/clear` `/clean` `/reset` | Replaces the conversation behind the screen and says `started a fresh conversation behind home`. It is not the same act as `enter`, which opens a conversation at the target. | diff --git a/internal/manual/chat/team-manager.md b/internal/manual/chat/team-manager.md index a62004dcf7..37a7987228 100644 --- a/internal/manual/chat/team-manager.md +++ b/internal/manual/chat/team-manager.md @@ -55,9 +55,9 @@ its tab strip and never scrolls away: - **Choose AI manager** on Teams assigns an existing member. To use a new conversation, first use Add chat, then Choose AI manager. The Chats dropdown and conversation grid offer no manager-assignment controls. -- **`/helpers`**, or **`+ New from this session`** last in the Teams dropdown, in the - conversation you are working in: a new team is made with that conversation as its - manager, and it proposes helpers (see **Turn this conversation into a team**). +- **`+ New from this session`**, last in the Teams dropdown, in the conversation you are + working in: a new team is made at once with that conversation as its manager, and + `/helpers` then asks it for helpers (see **Turn this conversation into a team**). The manager's place reads **`◆ Manager`**. Its tile in the team-agnostic grid keeps its ordinary title and position. The tab's hint names its team and title. `alt+m` goes to the manager from any conversation in the team. @@ -69,61 +69,59 @@ If the handle is already taken in that team, a number is added. Another manager `team_send` before its first turn. Once its title is made, the ordinary title-based handle choice may replace that fallback. -## Turn this conversation into a team — /helpers, + New from this session, convert a chat, bring in helpers +## Turn this conversation into a team — + New from this session, /helpers, convert a chat, bring in helpers You are working in a conversation, the work has grown, and you want helpers with this conversation in charge. With no team selected, open the **Teams ▾** dropdown at the left of -the tab strip and choose its last row, **`+ New from this session`**. It puts this in the message box, in place of -what was there, and sends nothing: - -`/helpers Suggest two or three helpers for what we're working on here. For each, say what it would take on and the brief you'd give it. I'll answer yes, ask for changes, or cancel.` - -Edit the words into the team you want, then press enter; typing `/helpers` yourself does -the same, and a bare `/helpers` sends exactly that default. Two things happen: - -1. A new top-level team is made with **this conversation as its manager** and only member, - in one save, named after the conversation's folder (or a word such as `harbor` when that - name is taken), in the next free colour. The strip narrows to it and reads `◆ Manager`. - Every team's rules apply; rename it in Teams, under `Settings`. -2. Once it is saved, your words after `/helpers` are sent as your message, exactly as the - transcript shows them. The conversation now knows it is the team's manager. - -The receipt reads `made the team lab · this conversation is its manager`. Reply in your own -words. Yes, and it starts each helper with `team_start`, under your approval posture; each -opens as a tab on this team's strip, while you stay in front of the manager. A different -list, and it proposes again. Cancel, and it takes the team back with `team_dissolve`. - -## When /helpers is not offered, and what it says - -`/helpers`, and `+ New from this session`, answer with a sentence and change nothing: - -- On home, or any page: `/helpers makes a team of the conversation you are in · open one first`. -- On the welcome screen of a new chat: `/helpers makes a team of a conversation you have started · say what you are working on first`. -- In a conversation that already manages a team: `this conversation already manages harbor · ask it for helpers`, and the strip shows that team. -- With a team selected on the strip: `/helpers works with no team selected · clear harbor with × first, or use + Add subteam on Teams for a team inside it`. The dropdown has no `+ New from this session` row then: the team it makes is always top-level. +the tab strip and choose its last row, **`+ New from this session`**. On the spot: + +- A new top-level team is made with **this conversation as its manager** and only member, + named after the conversation's folder (or a word such as `harbor` when that name is + taken), in the next free colour. The strip narrows to it and reads `◆ Manager`, and the + column turns to the team's Traffic. Every team's rules apply; rename it in Teams, under + `Settings`. Once it is saved: `made the team lab · this conversation is its manager`. +- The message box is given this, in place of what was there (kept in `/drafts`): + `/helpers Suggest two or three helpers for this work, and I'll say yes, change them, or cancel.` + +Edit it and press enter. **`/helpers`** asks the team this conversation manages for helpers: +it sends your words after it, exactly as the transcript shows them, and a bare `/helpers` +sends that default. It makes no team. Reply to the proposal in your own words. Yes, and it +starts each helper with `team_start`, under your approval posture; each opens as a tab on +this team's strip and starts working at once, while you stay in front of the manager. A +different list, and it proposes again. Cancel, and it takes the team back with +`team_dissolve`. Changed your mind before sending anything? `Disband` the team on Teams. + +## When + New from this session or /helpers says no + +They change nothing and say why: + +- `/helpers` in a conversation that manages no team: `/helpers asks this conversation's team for helpers, and it has none yet · make one with + New from this session in the Teams menu`. +- On home, or any page: `/helpers asks the team a conversation manages for helpers · open that conversation first`. +- The row on the welcome screen of a new chat: `+ New from this session makes a team of a conversation you have started · say what you are working on first`. +- The row in a conversation that already manages a team: `this conversation already manages harbor · ask it with /helpers`, and the strip shows that team. +- With a team selected on the strip the dropdown has no `+ New from this session` row: the team it makes is always top-level, and a team inside a team is `+ Add subteam` on Teams. - On a connection that holds one conversation at a time: `helpers open beside this conversation, and a connection holds one conversation at a time`. - Over `--host` to an engine without teams: `managers are not available over --host yet`. -- While the first one is still being saved: `the team is still being saved`. +- The row chosen again while its team is being saved: `the team is still being saved`. If the save is refused — another window made this conversation another team's manager — -the strip goes back, the reason is said, and nothing is sent. If you leave the -conversation before the save finishes, the team stands and the message is not sent: -`made the team lab · its manager is the conversation you left; ask it for helpers there`. +the strip goes back, the box is emptied, and the reason is said. A `/helpers` sent before +the save finished is held and sent once it is saved. -## Why the chat suggested bringing in helpers or /helpers +## Why the chat suggested bringing in helpers A conversation that has gone on may end a reply with one sentence like "This could be -split up and worked on in parallel by a small team. Want to bring in some helpers? Type -/helpers and I'll suggest who." That is codeaf letting it say so: after your fourth message, -a conversation in no team is told, once, that it may suggest a team when the work is long -and has parts a few helpers could work on at once. It is told to say it at most -once, never for small or quick work, never instead of what you asked, and never again -after you pass on it. Reopening a long conversation tells it once more, and it can see in -its history that it already asked. - -It cannot make the team itself; only you can, with `/helpers` or `+ New from this session` -in the Teams dropdown. Ignore it, or say no, and nothing changes. A conversation already in -a team, a task's worker, an automation's run and home's `ask here` are never told this. +split up and worked on in parallel by a small team. Want to bring in some helpers? Choose ++ New from this session in the Teams menu and I'll suggest who." That is codeaf letting it +say so: after your fourth message, a conversation in no team is told, once, that it may +suggest a team when the work is long and has parts a few helpers could work on at once. It +is told to say it at most once, never for small or quick work, never instead of what you +asked, and never again after you pass on it. Reopening a long conversation tells it once +more, and it can see in its history that it already asked. + +It cannot make the team itself; only you can, with `+ New from this session` in the Teams +dropdown. Ignore it, or say no, and nothing changes. A conversation already in a team, a +task's worker, an automation's run and home's `ask here` are never told this. ## Cancel a team nobody joined — team_dissolve diff --git a/internal/manual/chat/teams-page.md b/internal/manual/chat/teams-page.md index 3d2741825d..756f4c659f 100644 --- a/internal/manual/chat/teams-page.md +++ b/internal/manual/chat/teams-page.md @@ -439,7 +439,7 @@ cancels. No internal IDs are shown. A duplicate name or invalid parent is refuse You may create an empty team and add members afterward. Creation never assigns a manager or opens its members. To make a team from the conversation you are working in, with that -conversation as its manager, use `/helpers` there (the team manager page has it). Both routes land on the new team overview. A failed save is reported there. +conversation as its manager, choose `+ New from this session` in the Teams dropdown there (the team manager page has it). Both routes land on the new team overview. A failed save is reported there. Chats also offers `+ New AI team` in its full saved-conversation grid: select cards, then name and create. The full conversation grid itself lives only in Chats. diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index 2d1cd63306..f211a09364 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -4128,8 +4128,9 @@ func TestHelpersQuestionsReachTheirSections(t *testing.T) { {"what does /helpers send to the model", "team-manager", "Turn this conversation into a team"}, {"what does + New from this session in the Teams dropdown do", "conversations-and-teams", "the Teams dropdown and + New from this session"}, {"why is + New from this session missing when a team is selected", "conversations-and-teams", "the Teams dropdown and + New from this session"}, - {"why is /helpers not offered here", "team-manager", "When /helpers is not offered"}, - {"/helpers says open one first", "team-manager", "When /helpers is not offered"}, + {"why does /helpers say this conversation has no team", "team-manager", "When + New from this session or /helpers says no"}, + {"/helpers says open that conversation first", "team-manager", "When + New from this session or /helpers says no"}, + {"what does /helpers do in a manager", "team-manager", "Turn this conversation into a team"}, {"why did the chat suggest bringing in helpers", "team-manager", "Why the chat suggested bringing in helpers"}, {"the model asked if I want a small team working in parallel", "team-manager", "Why the chat suggested bringing in helpers"}, {"I said no to the helpers, how do I cancel the team", "team-manager", "Cancel a team nobody joined"}, diff --git a/internal/session/helpersnote.go b/internal/session/helpersnote.go index 0047698704..1a0a8a148e 100644 --- a/internal/session/helpersnote.go +++ b/internal/session/helpersnote.go @@ -1,11 +1,12 @@ package session -// THE ONE NOTE THAT LETS A CONVERSATION SUGGEST /helpers. +// THE ONE NOTE THAT LETS A CONVERSATION SUGGEST BRINGING IN HELPERS. // // A person deep in a piece of work can turn it into a team effort with the -// conversation in charge: the chat's `/helpers` makes a new team with this -// conversation as its manager (internal/tui3's helpers.go). Nobody finds a -// command they have never heard of in the middle of their work, and the model, +// conversation in charge: the chat's Teams menu has `+ New from this session`, +// which makes a new team with this conversation as its manager, and `/helpers` +// then asks it for helpers (internal/tui3's helpers.go). Nobody finds a menu +// row they have never heard of in the middle of their work, and the model, // which can see the work growing, is the one placed to mention it. So a // conversation is told, once, that it may. // @@ -42,8 +43,8 @@ const helpersNoteAfter = 4 const helpersNoteBody = "If the work here has grown long, with parts that could be worked on at once by a few helpers, " + "you may suggest a team once, briefly, at the end of a reply, in words like: " + "\"This could be split up and worked on in parallel by a small team. Want to bring in some helpers? " + - "Type /helpers and I'll suggest who.\" " + - "Only the person can make the team, with /helpers; this conversation then becomes its manager. " + + "Choose + New from this session in the Teams menu and I'll suggest who.\" " + + "Only the person can make the team, with that menu row; this conversation then becomes its manager. " + "Not for small or quick work, never instead of doing what was asked, and never again once you have suggested it or they passed." // mayOfferHelpers says whether this conversation may be told it can suggest diff --git a/internal/session/helpersnote_test.go b/internal/session/helpersnote_test.go index 1b864cc223..8c7b677eb0 100644 --- a/internal/session/helpersnote_test.go +++ b/internal/session/helpersnote_test.go @@ -70,7 +70,7 @@ func TestTheHelpersNoteLandsOnceAfterTheConversationHasGoneOn(t *testing.T) { if !isVolatileNote(note) { t.Fatal("the note would be drawn as something the person typed") } - for _, want := range []string{"/helpers", "Only the person can make the team", "once", "Not for small or quick work"} { + for _, want := range []string{"+ New from this session in the Teams menu", "Only the person can make the team", "once", "Not for small or quick work"} { if !strings.Contains(note, want) { t.Errorf("the note does not say %q:\n%s", want, note) } diff --git a/internal/tui3/commands.go b/internal/tui3/commands.go index a547e1e222..85b8599526 100644 --- a/internal/tui3/commands.go +++ b/internal/tui3/commands.go @@ -155,12 +155,12 @@ var commands = []command{ // conversations big, and this is the team-level view, every member open or // not, the manager's conversation and what waits on you (place_teams.go). {name: "teams", desc: "your teams, their managers and what waits on you · " + placeChord(pageTeams)}, - // AND THE ONE DOOR THAT STARTS FROM THE CONVERSATION RATHER THAN FROM THE - // TEAM, under the page it fills (helpers.go): this conversation becomes a - // new team's manager and is sent the words after the command. Two rows, - // the way /manual has two, because bare it sends a default ask, written - // out in the transcript, and with words it sends those instead. - {name: "helpers", desc: "makes this conversation a new team's manager and asks it to suggest helpers, for you to accept, change or cancel"}, + // AND THE ASK A MANAGER IS GIVEN FOR HELPERS, under the page its team is on + // (helpers.go): the team is made by the Teams menu's `+ New from this + // session`, and this sends the manager the words after the command. Two + // rows, the way /manual has two, because bare it sends a default ask, + // written out in the transcript, and with words it sends those instead. + {name: "helpers", desc: "asks the team this conversation manages to suggest helpers · make the team with + New from this session in the Teams menu"}, {name: "helpers", args: "", desc: "…the same, sending your own words instead of the default ask"}, {name: "spend", desc: "what this machine has cost, by the day · " + placeChord(pageSpend)}, // It sits AFTER /compact and before /help because those two are the pair a diff --git a/internal/tui3/helpers.go b/internal/tui3/helpers.go index eebcef2c7c..f9c89f322c 100644 --- a/internal/tui3/helpers.go +++ b/internal/tui3/helpers.go @@ -1,6 +1,7 @@ package tui3 -// /helpers — THIS CONVERSATION BECOMES A TEAM'S MANAGER, AND IS ASKED FOR HELPERS. +// + New from this session, AND /helpers — THIS CONVERSATION BECOMES A TEAM'S +// MANAGER, AND THE TEAM IS ASKED FOR HELPERS. // // Every other door onto a team starts from the team: the Teams page's `+ New AI // team`, the conversation grid's `+ New team`, Organize, and a manager's own @@ -10,38 +11,40 @@ package tui3 // work, it has grown, and they want it to become a team effort with the // conversation that knows the work running it. // -// SO IT IS TWO THINGS, IN THIS ORDER, AND NOTHING MORE. +// IT IS TWO STEPS, AND EACH ONE ANSWERS AT ONCE. // -// 1. A new top-level team is made with the conversation in front as its -// manager and only member, in ONE store edit ([app.teamCreateLed]): the -// same edit the Teams page's dialog makes, with the manager line added, so -// every rule a team made there meets is met here — a fresh name, a colour, -// one conversation managing one team and its descendants, no deleted -// conversation readmitted. The strip narrows to it, so the person sees -// `◆ Manager` and the column turns to the team's Traffic. -// 2. Once the store has TAKEN that edit, and only then, the person's words -// after `/helpers` are sent to the conversation as their message. Waiting -// for the store is what makes the turn's first step find the conversation -// already a manager, with its role note and its verbs ([teamsWrote], -// [app.helpersAsked]). +// 1. The Teams dropdown's last row, `+ New from this session` (teammenu.go), +// MAKES THE TEAM WHEN IT IS CHOSEN ([app.helpersNewTeam]): a new top-level +// team with the conversation in front as its manager and only member, in +// ONE store edit ([app.teamCreateLed]) — the same edit the Teams page's +// dialog makes, with the manager line added, so every rule a team made +// there meets is met here. The strip narrows to it on the same frame, so +// the person sees `◆ Manager` and the column turns to the team's Traffic: +// the choice has an answer nobody can miss. The box is given `/helpers` +// and [helpersPrompt], in place of what was there, for the second step. +// 2. `/helpers` ASKS THE TEAM THIS CONVERSATION MANAGES FOR HELPERS +// ([app.runHelpersCommand]): the words after it, or [helpersPrompt], sent +// as the person's message, and nothing else. It makes no team. In a +// conversation that manages none it changes nothing and says where the +// team is made. +// +// IT USED TO BE ONE STEP, and that was the defect. `/helpers` made the team and +// sent the ask, and the row only filled the box; a person who chose the row saw +// nothing happen above, and the words that appeared in the box below were +// missed. A team made on the click is the reaction; helpers make helpers. // // NOTHING IS SENT THAT THE PERSON DID NOT SEE. The words the model reads are -// the words in the transcript after `/helpers`, exactly. A bare `/helpers` is -// [helpersPrompt], and the transcript shows it written out after the command, -// so the line on screen is the whole of what was asked. What the model knows +// the words in the transcript after `/helpers`, exactly. What the model knows // beyond those words is what every manager knows: the role note internal/session -// lands at its first step, and its verbs' own descriptions. -// -// THE TEAMS DROPDOWN'S LAST ROW IS THE SAME DOOR, ONE STEP EARLIER. `+ New from -// this session` (teammenu.go) puts `/helpers ` and [helpersPrompt] in the box, -// in place of whatever was there, and sends nothing: the person reads the -// request, changes it into the team they want, and presses enter. +// lands at its first step, and its verbs' own descriptions. A /helpers pressed +// before the store has the team is held until it does ([app.helpersSettled]), +// so the turn's first step finds the conversation already a manager. // -// BOTH ARE FOR A CONVERSATION SEEN WITH NO TEAM SELECTED. With a team's -// overlay on the strip the row is absent and the command refuses, because the -// team it makes is a top-level team and making it from inside another team -// would read as nesting; a team inside a team is `+ Add subteam` on Teams, or -// a manager's own sub-team start. +// THE ROW IS FOR A CONVERSATION SEEN WITH NO TEAM SELECTED. With a team's +// overlay on the strip the row is absent (teammenu.go's [app.teamMenuHelpers]), +// because the team it makes is a top-level team and making it from inside +// another team would read as nesting; a team inside a team is `+ Add subteam` +// on Teams, or a manager's own sub-team start. // // THE PERSON ANSWERS IN THEIR OWN WORDS. There is no card. Yes is a reply the // manager acts on with `team_start`, which runs under the approval posture every @@ -50,8 +53,7 @@ package tui3 // it proposes again from; and cancel is a reply it acts on with // `team_dissolve`, which takes back a team that has never held anyone but its // manager and is on the belt only while that is true (internal/session's -// team_dissolve.go). After the first helper joins, the team ends the way every -// other team does: disbanded by the person, on Teams. +// team_dissolve.go). Disband on Teams ends it too, and keeps it as history. import ( "fmt" @@ -62,57 +64,60 @@ import ( ) // helpersPrompt is what a bare /helpers asks, and what the dropdown's row puts -// in the box after `/helpers `. It is the person speaking, in words they would -// use, because it is their message and it is on their screen: it names no tool -// and nothing the person could not have typed. It asks for a proposal to read -// and says what happens next — the person answers yes, a change, or cancel — -// which is what keeps the manager from starting anyone before they agree. +// in the box after `/helpers `. It is the person speaking, in one sentence of +// words they would use, because it is their message and it is on their screen: +// it names no tool and nothing the person could not have typed. It asks for a +// proposal and says what happens next — the person answers yes, a change, or +// cancel — which is what keeps the manager from starting anyone first. // -// IT DESCRIBES THIS EXCHANGE AND STATES NO STANDING RULE. It used to end -// "Don't start anyone until I agree, and if I say cancel, undo the team.", and -// the memory that notices rules (internal/session's memory) kept that as one -// for the whole project: a person's standing order never to start helpers -// without asking, read by every manager after. Said as the next step of this -// one conversation, there is nothing in it to keep. -const helpersPrompt = "Suggest two or three helpers for what we're working on here. For each, say what it would take on and the brief you'd give it. I'll answer yes, ask for changes, or cancel." +// IT DESCRIBES THIS EXCHANGE AND STATES NO STANDING RULE. An earlier wording +// ended "Don't start anyone until I agree, and if I say cancel, undo the team.", +// and the memory that notices rules (internal/session's memory) kept that as a +// standing order for the whole project, read by every manager after. Said as +// the next step of this one conversation, there is nothing in it to keep. +const helpersPrompt = "Suggest two or three helpers for this work, and I'll say yes, change them, or cancel." // The words this door says. Each is quoted in the manual exactly as spelled. const ( - // helpersRowWord is the Teams dropdown's last row, which puts /helpers and - // [helpersPrompt] in the box. + // helpersRowWord is the Teams dropdown's last row, which makes the team + // and puts /helpers and [helpersPrompt] in the box. helpersRowWord = "+ New from this session" - // helpersPlaceWord is /helpers typed where no conversation is in front. - helpersPlaceWord = "/helpers makes a team of the conversation you are in · open one first" - // helpersFreshWord is /helpers on the welcome screen, before there is a + // helpersPlaceWord is /helpers, or the row, where no conversation is in + // front. + helpersPlaceWord = "/helpers asks the team a conversation manages for helpers · open that conversation first" + // helpersFreshWord is the row on the welcome screen, before there is a // conversation to make a team of. - helpersFreshWord = "/helpers makes a team of a conversation you have started · say what you are working on first" - // helpersManagesWord is /helpers in a conversation that already manages a + helpersFreshWord = "+ New from this session makes a team of a conversation you have started · say what you are working on first" + // helpersNotManagerWord is /helpers in a conversation that manages no + // team: it says where the team is made, and changes nothing. + helpersNotManagerWord = "/helpers asks this conversation's team for helpers, and it has none yet · make one with + New from this session in the Teams menu" + // helpersManagesWord is the row in a conversation that already manages a // team; the strip is narrowed to that team. - helpersManagesWord = "this conversation already manages %s · ask it for helpers" - // helpersSelectedWord is /helpers with a team selected in the strip: a - // team made here is a top-level team, and subteams have their own door. - helpersSelectedWord = "/helpers works with no team selected · clear %s with × first, or use + Add subteam on Teams for a team inside it" - // helpersPendingWord is a second /helpers while the first is being saved. + helpersManagesWord = "this conversation already manages %s · ask it with /helpers" + // helpersPendingWord is the row chosen again while its team is being saved. helpersPendingWord = "the team is still being saved" // helpersMadeWord is the receipt, once the store has the team. helpersMadeWord = "made the team %s · this conversation is its manager" // helpersAwayWord is the receipt when the person left the conversation - // before the store answered: the team is made and the message is not sent. - helpersAwayWord = "made the team %s · its manager is the conversation you left; ask it for helpers there" + // before the store answered, with a /helpers waiting: the team is made and + // the message is not sent. + helpersAwayWord = "made the team %s · its manager is the conversation you left; ask it with /helpers there" ) // helpersShared is the refusal on a connection that holds one conversation at // a time: a helper is a conversation that opens beside its manager. var helpersShared = "helpers open beside this conversation, and " + oneConversationWord -// helpersPending is one /helpers between its edit and the store's answer: the -// edit's notice tie, the conversation it made a manager, the team's name, and -// the message to send once the store has taken it. due says the answer is in -// and [app.helpersAsked] has not acted on it yet. +// helpersPending is one team the row made, between its edit and the store's +// answer: the edit's notice tie, the conversation it made a manager, the +// team's name, the line it put in the box, and a /helpers pressed meanwhile +// (ask and shown), held until the store has the team. due says the answer is +// in and [app.helpersSettled] has not acted on it yet. type helpersPending struct { said teamWriteSaid key string name string + box string ask string shown string due bool @@ -131,11 +136,27 @@ func (a *app) helpersTab() (chatTab, bool) { return chatTab{key: key, file: a.file, where: a.workspace, word: a.conversationName()}, true } -// helpersRefusal is why /helpers cannot make a team of the conversation in -// front, "" when it can, and the team it already manages when that is why. It -// is asked by the command and by the dropdown's row, so the row never fills the -// box with a request the command would refuse. -func (a *app) helpersRefusal() (string, team) { +// helpersTeam is the open team the conversation with key manages, the global +// manager's `All teams` aside: it holds managers rather than helpers. Memory +// only. +func (a *app) helpersTeam(key string) (team, bool) { + if !a.wall.loaded || key == "" { + return team{}, false + } + if t, ok := a.teamActive(); ok && t.Manager == key && !t.Closed() && !t.Root { + return t, true + } + for _, t := range a.wall.teams { + if t.Manager == key && !t.Closed() && !t.Root { + return t, true + } + } + return team{}, false +} + +// helpersRowRefusal is why the row cannot make a team of the conversation in +// front, "" when it can, and the team it already manages when that is why. +func (a *app) helpersRowRefusal() (string, team) { switch { case a.pageShowing(): return helpersPlaceWord, team{} @@ -152,22 +173,17 @@ func (a *app) helpersRefusal() (string, team) { return helpersPendingWord, team{} } a.teamsEnsure() - if t, manages := a.teamManaged(tab.key); manages { + if t, manages := a.helpersTeam(tab.key); manages { return fmt.Sprintf(helpersManagesWord, t.Name), t } - // ONLY WITH NO TEAM SELECTED, the dropdown's row and the command alike - // ([app.teamMenuHelpers] says why): a team made here is a top-level team, - // and making it from inside another team's overlay would read as nesting. - if t, selected := a.teamActive(); selected { - return fmt.Sprintf(helpersSelectedWord, t.Name), team{} - } return "", team{} } // helpersTipArmed is the 💡 tip's rule (notice.go): a conversation that has // been spoken to, in front, with no team selected, not already a manager, and -// on a window that can keep teams and open helpers beside it. It reads memory -// only, because the table asks it at every event. +// on a window that can keep teams and open helpers beside it — which is where +// the row would make its team. It reads memory only, because the table asks it +// at every event. func helpersTipArmed(a *app) bool { if a.turn < 1 || a.pageShowing() || a.teamsOff() || a.shared || !a.wall.loaded { return false @@ -175,16 +191,23 @@ func helpersTipArmed(a *app) bool { if _, selected := a.teamActive(); selected { return false } - _, manages := a.teamManaged(a.frontTabKey()) + _, manages := a.helpersTeam(a.frontTabKey()) return !manages } -// runHelpersCommand is /helpers: the team made, and the person's words queued -// behind the store's answer. With no words after the command they are -// [helpersPrompt], and the transcript shows them. -func (a *app) runHelpersCommand(rest string) tea.Cmd { +// helpersNewTeam is the Teams dropdown's `+ New from this session`: the team +// made and shown at once, and `/helpers ` and [helpersPrompt] put in the box, +// in place of what was there, for the person to read, change and send. What +// was in the box goes onto the drafts ring first, as a draft restored over it +// does (draftpage.go), so replacing it loses nothing. Where the row cannot +// make a team it says why and leaves the box alone. +func (a *app) helpersNewTeam() tea.Cmd { a.noticeEvent(eventHelpersUsed) - if why, managed := a.helpersRefusal(); why != "" { + a.closeTeamMenu() + if a.wall.on { + a.closeWall() + } + if why, managed := a.helpersRowRefusal(); why != "" { a.note(why) if managed.ID != "" { return a.teamActivate(managed.ID) @@ -205,61 +228,84 @@ func (a *app) runHelpersCommand(rest string) tea.Cmd { // manager` does ([app.teamsTake]): the overlay closes and the store's // teams are taken, so a team the store never had is not drawn. a.teamsWatchManagerStart(tab.key) + line := "/helpers " + helpersPrompt + if live := a.input.String(); strings.TrimSpace(live) != "" && live != line { + a.drafts.push([]rune(live)) + } + a.input.setText(line) + a.helpers = helpersPending{said: a.teamWriteWatch(nil), key: tab.key, name: name, box: line} + a.touch() + return tea.Batch(a.teamActivate(id), a.syncLists()) +} + +// runHelpersCommand is /helpers: the person's words, or [helpersPrompt], sent +// to the conversation in front as their message, when it manages a team. The +// transcript shows exactly what was sent. A team the row is still saving holds +// the words until the store has it. +func (a *app) runHelpersCommand(rest string) tea.Cmd { + a.noticeEvent(eventHelpersUsed) + switch { + case a.pageShowing(): + a.note(helpersPlaceWord) + return nil + case a.teamsOff(): + a.note(teamHostedWord) + return nil + case a.shared: + a.note(helpersShared) + return nil + } ask := strings.TrimSpace(rest) if ask == "" { ask = helpersPrompt } - a.helpers = helpersPending{said: a.teamWriteWatch(nil), key: tab.key, name: name, ask: ask, shown: "/helpers " + ask} - return a.teamActivate(id) + shown := "/helpers " + ask + front := a.frontTabKey() + if a.helpers.said.pending && a.helpers.key == front && front != "" { + a.helpers.ask, a.helpers.shown = ask, shown + return nil + } + a.teamsEnsure() + t, manages := a.helpersTeam(front) + if a.agent == nil || a.startingChat() || !manages { + a.note(helpersNotManagerWord) + return nil + } + // THE STRIP SHOWS THE TEAM BEING ASKED. The conversation in front is its + // manager, so this moves no focus. + var show tea.Cmd + if active, ok := a.teamActive(); !ok || active.ID != t.ID { + show = a.teamActivate(t.ID) + } + return tea.Batch(show, a.submitShown(ask, shown, nil)) } -// helpersAsked is a /helpers whose write has come back: the receipt and the -// message when the store took the team, and nothing when it refused it, -// because [app.teamsTake] has already said why. -func (a *app) helpersAsked() tea.Cmd { +// helpersSettled is a row's team whose write has come back: the receipt when +// the store took the team, and a /helpers held meanwhile sent; and, when it +// refused it, the box given back its own words, because [app.teamsTake] has +// already said why and the window has taken the store's teams back. +func (a *app) helpersSettled() tea.Cmd { p := a.helpers if !p.due { return nil } a.helpers = helpersPending{} if p.said.why != "" { + if a.input.String() == p.box { + a.input.reset() + a.touch() + } return nil } - // THE PERSON MAY HAVE LEFT. The message goes to the conversation in front, - // and a manager's first words sent to another conversation would be the - // wrong conversation's; so the team stands and the ask is left to them. if a.frontTabKey() != p.key { - a.note(fmt.Sprintf(helpersAwayWord, p.name)) + if p.ask != "" { + a.note(fmt.Sprintf(helpersAwayWord, p.name)) + } return nil } a.note(fmt.Sprintf(helpersMadeWord, p.name)) - return a.submitShown(p.ask, p.shown, nil) -} - -// helpersFill is the Teams dropdown's `+ New from this session`: `/helpers ` -// and [helpersPrompt] put in the box, in place of what was there, for the -// person to read, change and send. Nothing is made until they press enter. -// What was in the box goes onto the drafts ring first, as a draft restored -// over it does (draftpage.go), so replacing it loses nothing. Where /helpers -// would refuse, the row says so instead and leaves the box alone. -func (a *app) helpersFill() tea.Cmd { - a.noticeEvent(eventHelpersUsed) - a.closeTeamMenu() - if a.wall.on { - a.closeWall() - } - if why, managed := a.helpersRefusal(); why != "" { - a.note(why) - if managed.ID != "" { - return a.teamActivate(managed.ID) - } + if p.ask == "" { return nil } - line := "/helpers " + helpersPrompt - if live := a.input.String(); strings.TrimSpace(live) != "" && live != line { - a.drafts.push([]rune(live)) - } - a.input.setText(line) - a.touch() - return a.syncLists() + return a.submitShown(p.ask, p.shown, nil) } diff --git a/internal/tui3/helpers_test.go b/internal/tui3/helpers_test.go index 4bd2778101..f3e68f30b7 100644 --- a/internal/tui3/helpers_test.go +++ b/internal/tui3/helpers_test.go @@ -69,144 +69,220 @@ func helpersMenuRow(t *testing.T, a *app) (teamMenuRow, bool) { return teamMenuRow{}, false } -// /helpers MAKES A TEAM WITH THIS CONVERSATION AS ITS MANAGER, and only then -// sends the person's words. The team is on disk with the conversation as -// manager and only member; the strip is narrowed to it; the column is the -// manager's; and the model is handed exactly the words the transcript shows -// after `/helpers`, and nothing else. -func TestHelpersMakesThisConversationTheManagerAndSendsWhatWasTyped(t *testing.T) { - a, fake := helpersApp(t) - front := a.frontTabKey() - before := len(fake.sent) - typeLine(t, a, "/helpers suggest one helper for the flaky tests") +// helpersMakeTeam chooses `+ New from this session` and lets the store take +// the team, as a person's click and the loop after it do. It hands back the +// team as the store holds it. +func helpersMakeTeam(t *testing.T, a *app) team { + t.Helper() + row, _ := helpersMenuRow(t, a) + spend(t, a, a.teamMenuDo(row)) teamsFlush(t, a) - - disk := helpersDisk(t, a) - if len(disk) != 1 { - t.Fatalf("/helpers left %d teams on disk: %+v", len(disk), disk) - } - made := disk[0] - if made.Manager != front || len(made.Members) != 1 || made.Members[0].Key != front { - t.Fatalf("the team is not this conversation alone, as manager: %+v", made) + front := a.frontTabKey() + for _, made := range helpersDisk(t, a) { + if made.Manager == front { + return made + } } - if made.Name != "lab" || made.Parent != "" { - t.Fatalf("the team was named %q under %q, want the folder's name at the top level", made.Name, made.Parent) + t.Fatalf("the row made no team this conversation manages: %+v", helpersDisk(t, a)) + return team{} +} + +// `+ New from this session` MAKES THE TEAM WHEN IT IS CHOSEN. On the same frame, +// before the store has answered, the strip is the new team's, the column is +// the manager's and the box holds `/helpers` and the default ask, with what was +// there kept on the drafts ring; nothing is sent. Once the store has it, the +// team is on disk with this conversation as manager and only member, and the +// receipt says so. Enter then sends the ask, exactly as it reads. +func TestNewFromThisSessionMakesTheTeamAtOnce(t *testing.T) { + a, fake := helpersApp(t) + front := a.frontTabKey() + a.input.insert("half a thought") + a.openTeamMenu() + for range teamMenuPicks(a.teamMenuRows()) { + drive(t, a, key("down")) } - if active, ok := a.teamActive(); !ok || active.ID != made.ID { - t.Fatal("the strip was not narrowed to the new team") + drive(t, a, key("enter")) + if a.teamMenu.on { + t.Fatal("the dropdown stayed up after its row was chosen") } - if a.sideKind() != sideKindManager { - t.Fatal("the column does not read this conversation as the team's manager") + active, ok := a.teamActive() + if !ok || active.Manager != front || a.sideKind() != sideKindManager { + t.Fatal("choosing the row did not show the new team, with this conversation as its manager, at once") } if hint := a.trafficHint(); !strings.Contains(hint, "manager of lab") { t.Fatalf("the composer says %q, not that it talks to the manager", hint) } - if len(fake.sent) != before+1 { - t.Fatalf("/helpers sent %d messages, want one", len(fake.sent)-before) + if got := a.input.String(); got != "/helpers "+helpersPrompt { + t.Fatalf("the box holds %q", got) } - if sent := fake.sent[before]; sent != "suggest one helper for the flaky tests" { - t.Fatalf("the model was handed %q, not the words that were typed", sent) + if len(a.drafts.entries) == 0 || string(a.drafts.entries[0].text) != "half a thought" { + t.Fatal("what was in the box was not kept on the drafts ring") } - if shown := lastPersonLine(t, a); shown != "/helpers suggest one helper for the flaky tests" { - t.Fatalf("the transcript says %q, not what was typed", shown) + if len(fake.sent) != 0 { + t.Fatalf("choosing the row sent %q", fake.sent) + } + + teamsFlush(t, a) + disk := helpersDisk(t, a) + if len(disk) != 1 { + t.Fatalf("the row left %d teams on disk: %+v", len(disk), disk) + } + made := disk[0] + if made.Manager != front || len(made.Members) != 1 || made.Members[0].Key != front || made.Name != "lab" || made.Parent != "" { + t.Fatalf("the team is not this conversation alone, as manager, at the top level: %+v", made) } if !transcriptSays(a, fmt.Sprintf(helpersMadeWord, "lab")) { t.Fatal("no receipt says the team was made") } + if len(fake.sent) != 0 { + t.Fatalf("the store's answer sent %q before enter", fake.sent) + } + + drive(t, a, key("enter")) + if len(fake.sent) != 1 || fake.sent[0] != helpersPrompt { + t.Fatalf("enter sent %q", fake.sent) + } + if shown := lastPersonLine(t, a); shown != "/helpers "+helpersPrompt { + t.Fatalf("the transcript says %q", shown) + } + if n := len(helpersDisk(t, a)); n != 1 { + t.Fatalf("/helpers made another team: %d", n) + } } -// A BARE /helpers SAYS ITS ASK OUT LOUD. The model is handed [helpersPrompt], -// and the transcript shows that same sentence after the command, so nothing was -// asked that the person cannot read. The sentence is the person's own kind of -// words: it names no tool and none of codeaf's machinery. -func TestABareHelpersShowsTheAskItSends(t *testing.T) { +// /helpers IN A MANAGER SENDS EXACTLY ITS WORDS. The model is handed what the +// transcript shows after the command and nothing else, and no team is made. +func TestHelpersSendsTheManagerExactlyItsWords(t *testing.T) { a, fake := helpersApp(t) - typeLine(t, a, "/helpers") + helpersMakeTeam(t, a) + a.input.reset() + typeLine(t, a, "/helpers suggest one helper for the flaky tests") teamsFlush(t, a) - if len(fake.sent) != 1 || fake.sent[0] != helpersPrompt { - t.Fatalf("a bare /helpers sent %q", fake.sent) + if len(fake.sent) != 1 || fake.sent[0] != "suggest one helper for the flaky tests" { + t.Fatalf("the model was handed %q", fake.sent) } - if shown := lastPersonLine(t, a); shown != "/helpers "+helpersPrompt { - t.Fatalf("the transcript says %q, not the ask that was sent", shown) + if shown := lastPersonLine(t, a); shown != "/helpers suggest one helper for the flaky tests" { + t.Fatalf("the transcript says %q", shown) + } + if n := len(helpersDisk(t, a)); n != 1 { + t.Fatalf("/helpers made a team: %d on disk", n) } - for _, internal := range []string{"team_", "tool", "manager", "codeaf", "handle"} { +} + +// THE DEFAULT ASK IS ONE SENTENCE IN THE PERSON'S WORDS. It names no tool and +// none of codeaf's machinery, and it says what happens next rather than laying +// down a rule a memory could keep. +func TestTheDefaultHelpersAskIsOneSentenceInThePersonsWords(t *testing.T) { + if strings.Count(helpersPrompt, ".") != 1 || !strings.HasSuffix(helpersPrompt, ".") { + t.Fatalf("the default ask is not one sentence: %q", helpersPrompt) + } + for _, internal := range []string{"team_", "tool", "manager", "codeaf", "handle", "don't", "until"} { if strings.Contains(strings.ToLower(helpersPrompt), internal) { - t.Errorf("the default ask says %q, which is codeaf's word and not the person's: %q", internal, helpersPrompt) + t.Errorf("the default ask says %q: %q", internal, helpersPrompt) } } - for _, want := range []string{"two or three helpers", "I'll answer yes", "cancel"} { + for _, want := range []string{"two or three helpers", "yes", "cancel"} { if !strings.Contains(helpersPrompt, want) { t.Errorf("the default ask does not say %q: %q", want, helpersPrompt) } } } -// THE ASK WAITS FOR THE STORE. Until the write that carries the team is back -// the model is sent nothing, so its first step cannot run as anything but the -// manager; a second /helpers meanwhile makes no second team. -func TestHelpersAsksOnlyOnceTheStoreHasTheTeam(t *testing.T) { +// /helpers BEFORE THE STORE HAS THE TEAM IS HELD, NOT LOST. The ask waits for +// the write that carries the team, so the model's first step finds the +// conversation already a manager; it is sent once, after the receipt. (The +// command is run directly: the loop that carries a key also carries the write, +// in the same message.) +func TestHelpersBeforeTheStoreHasTheTeamIsHeld(t *testing.T) { a, fake := helpersApp(t) - before := len(fake.sent) + row, _ := helpersMenuRow(t, a) + spend(t, a, a.teamMenuDo(row)) spend(t, a, a.runHelpersCommand("")) - if len(fake.sent) != before { + if len(fake.sent) != 0 { t.Fatal("the ask went out before the store took the team") } - spend(t, a, a.runHelpersCommand("")) - if !transcriptSays(a, helpersPendingWord) { - t.Fatal("a second /helpers while the first was saving did not say so") - } teamsFlush(t, a) - if len(helpersDisk(t, a)) != 1 { - t.Fatalf("two /helpers made %d teams", len(helpersDisk(t, a))) + if len(fake.sent) != 1 || fake.sent[0] != helpersPrompt { + t.Fatalf("the store's answer sent %q", fake.sent) } - if len(fake.sent) != before+1 { - t.Fatalf("the store's answer sent %d asks, want one", len(fake.sent)-before) + if shown := lastPersonLine(t, a); shown != "/helpers "+helpersPrompt { + t.Fatalf("the transcript says %q", shown) + } + if n := len(helpersDisk(t, a)); n != 1 { + t.Fatalf("%d teams on disk", n) } } -// A TEAM MADE THIS WAY OBEYS THE RULES OF EVERY TEAM. A name already taken is -// not reused; a conversation that already manages a team is not made a second -// team's manager — it is shown its team, and nothing is sent. -func TestHelpersKeepsTheRulesEveryTeamKeeps(t *testing.T) { +// /helpers MAKES NO TEAM. In a conversation that manages none — with no team +// selected, or seen as an ordinary member of the team selected — it changes +// nothing, sends nothing, and says where the team is made. On home it answers +// the same way. +func TestHelpersInAConversationThatManagesNoTeamSaysWhereTheTeamIsMade(t *testing.T) { + if got := homeFate("helpers", ""); got != fateAnswers { + t.Fatalf("the drop-up says /helpers %q on home", got) + } a, fake := helpersApp(t) - if _, err := a.teamMake("lab", nil); err != nil { + typeLine(t, a, "/helpers") + teamsFlush(t, a) + if !transcriptSays(a, helpersNotManagerWord) || len(helpersDisk(t, a)) != 0 || len(fake.sent) != 0 { + t.Fatal("/helpers in a conversation in no team made a team or sent something") + } + + harbor, err := a.teamMake("harbor", a.tabList()) + if err != nil { t.Fatal(err) } teamsFlush(t, a) + spend(t, a, a.teamActivate(harbor)) + a.entries = nil typeLine(t, a, "/helpers") teamsFlush(t, a) - var made team - for _, t := range helpersDisk(t, a) { - if t.Manager != "" { - made = t - } + if !transcriptSays(a, helpersNotManagerWord) || len(fake.sent) != 0 { + t.Fatal("/helpers in a member of the selected team, not its manager, did not refuse") + } + if got := mustTeam(t, a, harbor); got.Manager != "" { + t.Fatalf("/helpers gave the selected team a manager: %q", got.Manager) } - if made.ID == "" || strings.EqualFold(made.Name, "lab") { +} + +// THE ROW KEEPS THE RULES EVERY TEAM KEEPS. A name already taken is not reused; +// in a conversation that already manages a team it makes no second one, +// shows the one it manages, and leaves the box alone. +func TestNewFromThisSessionKeepsTheRulesEveryTeamKeeps(t *testing.T) { + a, _ := helpersApp(t) + if _, err := a.teamMake("lab", nil); err != nil { + t.Fatal(err) + } + teamsFlush(t, a) + made := helpersMakeTeam(t, a) + if strings.EqualFold(made.Name, "lab") { t.Fatalf("the new team took a name another team has: %+v", helpersDisk(t, a)) } - sent := len(fake.sent) - typeLine(t, a, "/helpers") + spend(t, a, a.teamOverlayChoose("")) + a.input.setText("keep this") + row, _ := helpersMenuRow(t, a) + spend(t, a, a.teamMenuDo(row)) teamsFlush(t, a) if n := len(helpersDisk(t, a)); n != 2 { - t.Fatalf("a manager's /helpers made another team: %d teams", n) + t.Fatalf("the row in a manager made another team: %d teams", n) } if !transcriptSays(a, fmt.Sprintf(helpersManagesWord, made.Name)) { - t.Fatal("a manager's /helpers did not say which team it manages") + t.Fatal("the row did not say which team this conversation manages") } - if len(fake.sent) != sent { - t.Fatal("a manager's /helpers sent the ask again") + if got := a.input.String(); got != "keep this" { + t.Fatalf("the row replaced the box in a manager: %q", got) } if active, ok := a.teamActive(); !ok || active.ID != made.ID { - t.Fatal("a manager's /helpers did not show the team it manages") + t.Fatal("the row in a manager did not show the team it manages") } } // THE STORE'S REFUSAL IS FINAL. Another window made this conversation another // team's manager after this one loaded: the window's own check passes, the // store refuses with the one-manager rule, the window takes the store's teams -// back, closes the overlay, and the model is asked nothing. -func TestHelpersRefusedByTheStoreAsksNothing(t *testing.T) { +// back, closes the overlay, gives the box back, and the model is asked nothing. +func TestNewFromThisSessionRefusedByTheStorePutsTheWindowBack(t *testing.T) { a, fake := helpersApp(t) front := a.frontTabKey() elsewhere := teamstore.NewID() @@ -219,42 +295,43 @@ func TestHelpersRefusedByTheStoreAsksNothing(t *testing.T) { }); err != nil { t.Fatal(err) } - before := len(fake.sent) - spend(t, a, a.runHelpersCommand("")) + row, _ := helpersMenuRow(t, a) + spend(t, a, a.teamMenuDo(row)) teamsFlush(t, a) - if len(fake.sent) != before { - t.Fatal("a refused team still asked the model for helpers") + if len(fake.sent) != 0 { + t.Fatal("a refused team still sent something") } disk := helpersDisk(t, a) if len(disk) != 1 || disk[0].ID != elsewhere { t.Fatalf("the store holds %+v, want only the other window's team", disk) } - if _, ok := a.teamActive(); ok { + if active, ok := a.teamActive(); ok && active.ID != elsewhere { t.Fatal("the overlay stayed on a team the store refused") } + if got := a.input.String(); got != "" { + t.Fatalf("the box still holds the refused team's ask: %q", got) + } if !transcriptSays(a, "a conversation can manage one team and its descendants") { t.Fatal("the refusal was not said in the store's words") } } -// WHERE IT CANNOT WORK IT SAYS SO AND MAKES NOTHING: home, which has no -// conversation in front; a connection that holds one conversation at a time, -// where a helper could not open beside its manager; and the welcome screen. -func TestHelpersRefusesWhereItCannotWork(t *testing.T) { - if got := homeFate("helpers", ""); got != fateAnswers { - t.Fatalf("the drop-up says /helpers %q on home", got) - } - +// WHERE THE ROW CANNOT WORK IT SAYS SO AND MAKES NOTHING: a connection that +// holds one conversation at a time, where a helper could not open beside its +// manager, and the welcome screen. +func TestNewFromThisSessionRefusesWhereItCannotWork(t *testing.T) { a, fake := helpersApp(t) a.shared = true - spend(t, a, a.runHelpersCommand("")) + row, _ := helpersMenuRow(t, a) + spend(t, a, a.teamMenuDo(row)) if !transcriptSays(a, helpersShared) || len(helpersDisk(t, a)) != 0 || len(fake.sent) != 0 { t.Fatal("a one-conversation connection made a team") } a, fake = helpersApp(t) a.welcome.open, a.welcome.start = true, true - spend(t, a, a.runHelpersCommand("")) + row, _ = helpersMenuRow(t, a) + spend(t, a, a.teamMenuDo(row)) if !transcriptSays(a, helpersFreshWord) || len(helpersDisk(t, a)) != 0 || len(fake.sent) != 0 { t.Fatal("the welcome screen made a team") } @@ -297,69 +374,12 @@ func TestTheTeamsDropdownIsThereWithNoTeamsAndEndsWithNewFromThisSession(t *test } } -// `+ New from this session` IS /helpers ONE STEP EARLIER. Chosen by the -// keyboard, it puts `/helpers ` and the default ask in the box in place of -// what was there (which goes onto the drafts ring), makes nothing and sends -// nothing; enter then does exactly what typing it would. -func TestNewFromThisSessionPutsHelpersInTheBoxAndEnterSendsIt(t *testing.T) { - a, fake := helpersApp(t) - a.input.insert("half a thought") - a.openTeamMenu() - picks := teamMenuPicks(a.teamMenuRows()) - for range picks { - drive(t, a, key("down")) - } - drive(t, a, key("enter")) - if a.teamMenu.on { - t.Fatal("the dropdown stayed up after its row was chosen") - } - if got := a.input.String(); got != "/helpers "+helpersPrompt { - t.Fatalf("the box holds %q", got) - } - if len(a.drafts.entries) == 0 || string(a.drafts.entries[len(a.drafts.entries)-1].text) != "half a thought" { - t.Fatal("what was in the box was not kept on the drafts ring") - } - teamsFlush(t, a) - if len(helpersDisk(t, a)) != 0 || len(fake.sent) != 0 { - t.Fatal("choosing the row made a team or sent something before enter") - } - - drive(t, a, key("enter")) - teamsFlush(t, a) - if len(helpersDisk(t, a)) != 1 || len(fake.sent) != 1 || fake.sent[0] != helpersPrompt { - t.Fatalf("enter did not do what /helpers does: teams %d, sent %q", len(helpersDisk(t, a)), fake.sent) - } - if shown := lastPersonLine(t, a); shown != "/helpers "+helpersPrompt { - t.Fatalf("the transcript says %q", shown) - } -} - -// WHERE /helpers WOULD REFUSE, THE ROW SAYS SO AND LEAVES THE BOX ALONE: in a -// conversation that already manages a team, choosing it shows that team. -func TestNewFromThisSessionInAManagerSaysWhichTeamAndLeavesTheBox(t *testing.T) { - a, _ := helpersApp(t) - typeLine(t, a, "/helpers") - teamsFlush(t, a) - made := helpersDisk(t, a)[0] - spend(t, a, a.teamOverlayChoose("")) - a.input.insert("keep this") - row, _ := helpersMenuRow(t, a) - spend(t, a, a.teamMenuDo(row)) - if got := a.input.String(); got != "keep this" { - t.Fatalf("the row replaced the box in a manager: %q", got) - } - if !transcriptSays(a, fmt.Sprintf(helpersManagesWord, made.Name)) { - t.Fatal("the row did not say which team this conversation manages") - } -} - -// WITH A TEAM SELECTED THERE IS NO `+ New from this session`, and /helpers -// refuses: the team it makes is a top-level team, and made from inside another -// team's overlay it would read as nesting. Nesting is `+ Add subteam` on Teams. -// Nothing is made, nothing is sent, the box is untouched; clearing the +// WITH A TEAM SELECTED THERE IS NO `+ New from this session`: the team it +// makes is a top-level team, and made from inside another team's overlay it +// would read as nesting. Nesting is `+ Add subteam` on Teams. Clearing the // selection brings the row back. func TestWithATeamSelectedThereIsNoNewFromThisSession(t *testing.T) { - a, fake := helpersApp(t) + a, _ := helpersApp(t) harbor, err := a.teamMake("harbor", a.tabList()) if err != nil { t.Fatal(err) @@ -376,17 +396,6 @@ func TestWithATeamSelectedThereIsNoNewFromThisSession(t *testing.T) { t.Fatalf("with a team selected the dropdown draws %q:\n%s", helpersRowWord, frame) } a.closeTeamMenu() - - a.input.insert("/helpers") - drive(t, a, key("enter")) - teamsFlush(t, a) - if !transcriptSays(a, fmt.Sprintf(helpersSelectedWord, "harbor")) { - t.Fatal("/helpers with a team selected did not say why it refused") - } - if n := len(helpersDisk(t, a)); n != 1 || len(fake.sent) != 0 { - t.Fatalf("/helpers with a team selected made %d teams and sent %q", n, fake.sent) - } - spend(t, a, a.teamOverlayChoose("")) if _, last := helpersMenuRow(t, a); !last { t.Fatal("clearing the selection did not bring the row back last") @@ -400,9 +409,7 @@ func TestWithATeamSelectedThereIsNoNewFromThisSession(t *testing.T) { // stays in front of the manager, with what they were typing. func TestHelpersStartedOpenAsTabsInTheTeamsOverlay(t *testing.T) { a, _ := helpersApp(t) - typeLine(t, a, "/helpers") - teamsFlush(t, a) - made := helpersDisk(t, a)[0] + made := helpersMakeTeam(t, a) front := a.frontTabKey() spend(t, a, a.teamOverlayChoose("")) if _, ok := a.teamActive(); ok { @@ -413,7 +420,7 @@ func TestHelpersStartedOpenAsTabsInTheTeamsOverlay(t *testing.T) { n++ return Conversation{Agent: &fakeAgent{model: "m"}, SessionFile: fmt.Sprintf("/tmp/lab/helper-%d.jsonl", n), Workspace: workspace}, nil } - a.input.insert("half a reply") + a.input.setText("half a reply") for _, handle := range []string{"tests", "docs"} { trafficAppend(t, a, made.ID, teamstore.Entry{Kind: teamstore.KindStart, From: teamstore.FromManager, To: handle, Text: "take the " + handle}) } @@ -448,9 +455,7 @@ func TestHelpersStartedOpenAsTabsInTheTeamsOverlay(t *testing.T) { func TestADissolvedTeamLeavesThePlainChat(t *testing.T) { a, _ := helpersApp(t) front := a.frontTabKey() - typeLine(t, a, "/helpers") - teamsFlush(t, a) - made := helpersDisk(t, a)[0] + made := helpersMakeTeam(t, a) tabs := len(a.tabList()) if err := teamstore.Dissolve(a.profileDir, made.ID, front); err != nil { t.Fatal(err) @@ -473,19 +478,19 @@ func TestADissolvedTeamLeavesThePlainChat(t *testing.T) { } } -// THE 💡 TIP IS SAID WHERE /helpers WOULD ACT, AND ONLY THERE: after the first +// THE 💡 TIP IS SAID WHERE THE ROW WOULD ACT, AND ONLY THERE: after the first // exchange, with no team selected, in a conversation that manages none; never // before a word was said, never with a team on the strip, never in a manager. -// Using the command retires it for good. -func TestTheHelpersTipIsArmedWhereHelpersWouldAct(t *testing.T) { +// Choosing the row retires it for good. +func TestTheHelpersTipIsArmedWhereTheRowWouldAct(t *testing.T) { var row notice for _, n := range notices { if n.id == "helpers-team" { row = n } } - if row.id == "" || row.text != "/helpers makes this conversation the manager of a new team" || row.retire != eventHelpersUsed { - t.Fatalf("the /helpers tip is %+v", row) + if row.id == "" || !strings.HasPrefix(row.text, helpersRowWord) || row.retire != eventHelpersUsed { + t.Fatalf("the tip is %+v", row) } a, _ := helpersApp(t) a.turn = 0 @@ -509,10 +514,9 @@ func TestTheHelpersTipIsArmedWhereHelpersWouldAct(t *testing.T) { if !row.armed(a) { t.Fatal("clearing the team did not arm the tip again") } - typeLine(t, a, "/helpers") - teamsFlush(t, a) + helpersMakeTeam(t, a) if !a.notices.retired(row.id) { - t.Fatal("using /helpers did not retire its tip") + t.Fatal("choosing the row did not retire its tip") } spend(t, a, a.teamOverlayChoose("")) if row.armed(a) { diff --git a/internal/tui3/notice.go b/internal/tui3/notice.go index 4923b2c70f..0db586d10f 100644 --- a/internal/tui3/notice.go +++ b/internal/tui3/notice.go @@ -427,15 +427,17 @@ var notices = []notice{ }, { // THE BIGGER HALF OF HANDING WORK OUT, at the owner's word on - // 2026-10-19: /task sends one piece of the work away, and /helpers - // keeps this conversation and makes it the manager of a team for the - // rest (helpers.go). It is armed only where /helpers would act: a - // conversation that has been spoken to, no team selected on the strip, - // and not already a manager ([helpersTipArmed]). It retires the first - // time /helpers is typed or the Teams dropdown's row is chosen. + // 2026-10-19: /task sends one piece of the work away, and the Teams + // dropdown's `+ New from this session` keeps this conversation and makes + // it the manager of a team for the rest (helpers.go). It names the row + // and not /helpers, because /helpers asks a team that already exists. It + // is armed only where the row would act: a conversation that has been + // spoken to, no team selected on the strip, and not already a manager + // ([helpersTipArmed]). It retires the first time the row is chosen or + // /helpers is typed. id: "helpers-team", slot: slotHint, armed: helpersTipArmed, - text: "/helpers makes this conversation the manager of a new team", + text: "+ New from this session in the Teams menu makes this chat a team's manager", retire: eventHelpersUsed, }, { diff --git a/internal/tui3/teammenu.go b/internal/tui3/teammenu.go index 5236f1b074..54e9da84b2 100644 --- a/internal/tui3/teammenu.go +++ b/internal/tui3/teammenu.go @@ -18,8 +18,9 @@ import ( // footer: a list of rows under a chip is read and worked the way every list is. // // WITH NO TEAM SELECTED ITS LAST ROW IS `+ New from this session`, under a -// rule, which makes a team of the conversation in front by putting `/helpers` -// in the box (helpers.go). Because that row is there whenever no team is, the +// rule, which makes a team of the conversation in front when it is chosen, shows +// it, and puts `/helpers` in the box to ask it for helpers (helpers.go). +// Because that row is there whenever no team is, the // chip is there too with no team yet: `Teams ▾` is how a person with none finds // how to make one from what they are doing. With a team selected the row is // gone ([app.teamMenuHelpers] says why). Over --host to an engine without the @@ -72,10 +73,10 @@ func (a *app) teamMenuRows() []teamMenuRow { // // WITH A TEAM SELECTED THE ROW IS NOT THERE AT ALL. A new team made from a // conversation seen through a team's overlay reads as one made inside that -// team, and it is not: /helpers always makes a top-level team. Nesting has its +// team, and it is not: the row always makes a top-level team. Nesting has its // own doors, `+ Add subteam` on Teams and a manager's own sub-team start, with -// their own rules, and this row must not look like a third. Where /helpers -// would refuse for the conversation in front for another reason, the row is +// their own rules, and this row must not look like a third. Where the row +// cannot make a team of the conversation in front for another reason, it is // still there and says why when chosen. func (a *app) teamMenuHelpers() bool { if a.teamsOff() { @@ -157,7 +158,7 @@ func (a *app) teamMenuDo(r teamMenuRow) tea.Cmd { case r.rule: return nil case r.code == wallPopHelpers: - return a.helpersFill() + return a.helpersNewTeam() case r.code == wallPopTeam: return a.teamOverlayChoose(r.id) } diff --git a/internal/tui3/teamseam.go b/internal/tui3/teamseam.go index a038be9c98..ffd8a260fd 100644 --- a/internal/tui3/teamseam.go +++ b/internal/tui3/teamseam.go @@ -401,9 +401,9 @@ func (a *app) teamsWrite() tea.Cmd { } return func(bool) tea.Cmd { a.teamsTake(teamsWrote{teams: teams, stamp: stamp, err: err, covers: covers, seqs: seqs, refused: refusedBy}) - // A /helpers whose team this write carried is asked for its - // helpers now, and not before (helpers.go). - return a.helpersAsked() + // A team the Teams row made is said made, and a /helpers held for it + // sent, now that this write carried it (helpers.go). + return a.helpersSettled() } })) } diff --git a/internal/tui3/teamwritesaid.go b/internal/tui3/teamwritesaid.go index be93ce6302..fead41acab 100644 --- a/internal/tui3/teamwritesaid.go +++ b/internal/tui3/teamwritesaid.go @@ -61,8 +61,9 @@ func (s *teamWriteSaid) settle(w teamsWrote) bool { // a slow write over a connection still shows its notice for the whole time. func (a *app) teamsWriteSettled(w teamsWrote) { a.tmemberStart.said.settle(w) - // A /helpers acts on its answer from the write's own command, which can - // hand back a turn ([app.helpersAsked]); here it is only marked due. + // A team the Teams row made acts on its answer from the write's own + // command, which can hand back a turn ([app.helpersSettled]); here it is + // only marked due. if a.helpers.said.settle(w) { a.helpers.due = true } From d19902e71e469e4a2e74b138989b72b1aeb16183 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Tue, 20 Oct 2026 14:06:45 -0400 Subject: [PATCH 09/10] tui3: the team + New from this session makes is named after the conversation The click still makes the team at once, under its folder's name, so the strip changes on the frame. Beside the line, the naming role is asked once (five seconds at most, on the cheap naming model) for one or two words naming the work, from the conversation's title or opening words, and the team takes the answer. The rename is a teams-store edit applied only while the team still has the name it was made with and no other team has the new one, so a name the person chose here or in another window is never replaced, and a team that is gone stays gone. A failure, a timeout or an answer that is not a name leaves the folder's name. The receipt says the final name once; a name that arrives after it is said on its own line. session's NameTeamWords asks the short question and cuts to the limit; NameTeam is the group's three. Teams.Name carries an optional words field; an engine without it answers the group's question and the surface cuts the answer to two words. Co-Authored-By: Claude Opus 5.5 --- docs/changes/unreleased/1881-helpers.md | 1 + internal/manual/chat/team-manager.md | 24 +++- internal/manual/chat_test.go | 2 + internal/remote/teamask.go | 36 +++++- internal/remote/teamask_test.go | 37 ++++++ internal/remote/wire_teams.go | 6 + internal/session/teamname.go | 42 +++++-- internal/session/teamname_test.go | 17 +++ internal/tui3/app.go | 3 + internal/tui3/helpers.go | 150 +++++++++++++++++++++-- internal/tui3/helpers_test.go | 151 ++++++++++++++++++++++++ internal/tui3/offlooplaw_test.go | 1 + 12 files changed, 450 insertions(+), 20 deletions(-) diff --git a/docs/changes/unreleased/1881-helpers.md b/docs/changes/unreleased/1881-helpers.md index d6da0afbcf..acd10d0492 100644 --- a/docs/changes/unreleased/1881-helpers.md +++ b/docs/changes/unreleased/1881-helpers.md @@ -5,6 +5,7 @@ pr: 1881 surface: [chat, engine, docs] invalidates: - "Every way to make a team started from the team, and a team was made without a manager; the only way to make an existing conversation one was Add chat, then Choose AI manager. The Teams ▾ dropdown's `+ New from this session` makes a new top-level team with the conversation in front as its manager the moment it is chosen, in one save, selects it, and puts `/helpers` and a one-sentence default ask in the box." + - "The team the row makes is named twice: the folder's name on the click, then one or two words from the naming model over the conversation's title, applied only while the team still has its first name and no other team has the new one. `Teams.Name` takes an optional `words` limit; an engine without it answers up to three words, which the surface cuts to two." - "`/helpers` asks the team the conversation in front manages for helpers: it sends exactly the words after it (bare, the default ask, shown in full) as the person's message, and makes no team. In a conversation that manages none it changes nothing and says to make one with + New from this session." - "The Teams ▾ chip was absent with no teams, and its dropdown printed an up/down, enter, esc footer. The chip shows with no teams, the footer is gone, and with no team selected the dropdown ends with `+ New from this session`. With a team selected the row is absent: the team it makes is top-level, and nesting is + Add subteam." - "A manager had no way to take its team back. `team_dissolve` forgets a team that has never held anyone but its manager (no member, former member, subteam or Traffic); it is on the belt only while that is true, for every team however it was made, and does not ask first." diff --git a/internal/manual/chat/team-manager.md b/internal/manual/chat/team-manager.md index 37a7987228..2000d46830 100644 --- a/internal/manual/chat/team-manager.md +++ b/internal/manual/chat/team-manager.md @@ -76,10 +76,10 @@ conversation in charge. With no team selected, open the **Teams ▾** dropdown a the tab strip and choose its last row, **`+ New from this session`**. On the spot: - A new top-level team is made with **this conversation as its manager** and only member, - named after the conversation's folder (or a word such as `harbor` when that name is - taken), in the next free colour. The strip narrows to it and reads `◆ Manager`, and the - column turns to the team's Traffic. Every team's rules apply; rename it in Teams, under - `Settings`. Once it is saved: `made the team lab · this conversation is its manager`. + in the next free colour, and a moment later named after this conversation (see **How a + team made from a conversation is named**). The strip narrows to it and reads `◆ Manager`, + and the column turns to the team's Traffic. Every team's rules apply. Once it is saved: + `made the team guitar pickups · this conversation is its manager`. - The message box is given this, in place of what was there (kept in `/drafts`): `/helpers Suggest two or three helpers for this work, and I'll say yes, change them, or cancel.` @@ -91,6 +91,22 @@ this team's strip and starts working at once, while you stay in front of the man different list, and it proposes again. Cancel, and it takes the team back with `team_dissolve`. Changed your mind before sending anything? `Disband` the team on Teams. +## How a team made from a conversation is named — + New from this session's team name, rename it + +`+ New from this session` names the team twice. On the click it takes the conversation's +folder name, lowercased (`codeaf` for `~/Code/CodeAF`), or, when a team already has that +name, a word such as `harbor`, so the strip can change at once. At the same time the +model that names your conversations is asked, once, for one or two words naming the work, +from the conversation's title or its opening words; it waits at most five seconds. When +the answer comes the team takes it, and the chip changes to it. If it came after the +receipt, a line says `named the team guitar pickups`. + +The first name stays when there is no naming model or no answer in time, when the answer +is not a name, when another team already has that name, or when the team was renamed or +disbanded first, here or in another window. A suggested name never replaces one you chose. +Rename the team any time in Teams, under `Settings`. It costs one short call on the naming +model. + ## When + New from this session or /helpers says no They change nothing and say why: diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index f211a09364..ca79e55f61 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -4131,6 +4131,8 @@ func TestHelpersQuestionsReachTheirSections(t *testing.T) { {"why does /helpers say this conversation has no team", "team-manager", "When + New from this session or /helpers says no"}, {"/helpers says open that conversation first", "team-manager", "When + New from this session or /helpers says no"}, {"what does /helpers do in a manager", "team-manager", "Turn this conversation into a team"}, + {"how is the team from + New from this session named", "team-manager", "How a team made from a conversation is named"}, + {"why did my new team's name change", "team-manager", "How a team made from a conversation is named"}, {"why did the chat suggest bringing in helpers", "team-manager", "Why the chat suggested bringing in helpers"}, {"the model asked if I want a small team working in parallel", "team-manager", "Why the chat suggested bringing in helpers"}, {"I said no to the helpers, how do I cancel the team", "team-manager", "Cancel a team nobody joined"}, diff --git a/internal/remote/teamask.go b/internal/remote/teamask.go index b3de818ac3..7a2c9c2534 100644 --- a/internal/remote/teamask.go +++ b/internal/remote/teamask.go @@ -25,6 +25,13 @@ type teamAskDoor interface { ProposeTeams(ctx context.Context, in session.TeamProposalInput) (session.TeamProposal, error) } +// teamAskWordsDoor is the naming ask with a word limit, which the engine +// answers when a request carries one ([TeamNameArgs.Words]). An agent without +// it is asked the group's question, as before. +type teamAskWordsDoor interface { + NameTeamWords(ctx context.Context, titles []string, words int) (string, error) +} + // teamAskKnown is [Welcome.TeamAsk]: whether the agent this engine has open // answers both asks. func teamAskKnown(agent any) bool { _, ok := agent.(teamAskDoor); return ok } @@ -69,7 +76,12 @@ func teamAskCall(agent WrappedAgent, call Frame) (json.RawMessage, bool, error) } ctx, cancel := teamAskWithin(args.Budget) defer cancel() - name, err := door.NameTeam(ctx, args.Titles) + var name string + if worded, ok := agent.(teamAskWordsDoor); ok && args.Words > 0 { + name, err = worded.NameTeamWords(ctx, args.Titles, args.Words) + } else { + name, err = door.NameTeam(ctx, args.Titles) + } if err != nil { return nil, true, err } @@ -131,6 +143,28 @@ func (a *Agent) NameTeam(ctx context.Context, titles []string) (string, error) { return name, nil } +// NameTeamWords is [Agent.NameTeam] for a name of at most words words. The +// limit rides the request; an engine from before it answers the group's +// question, and the caller cuts that answer to the words it asked for. +func (a *Agent) NameTeamWords(ctx context.Context, titles []string, words int) (string, error) { + if !a.c.Welcome().TeamAsk { + return "", errors.New(teamAskOffWord) + } + budget, ok := teamAskBudget(ctx) + if !ok { + return "", context.DeadlineExceeded + } + payload, err := a.c.call(ctx, MethodTeamsName, TeamNameArgs{Titles: titles, Budget: budget, Words: words}) + if err != nil { + return "", err + } + var name string + if err := json.Unmarshal(payload, &name); err != nil { + return "", err + } + return name, nil +} + // ProposeTeams asks the engine's naming role which teams the conversations in // in could form: the wall's teamProposer door, over the wire, refused here for // an engine without it as [Agent.NameTeam] is. diff --git a/internal/remote/teamask_test.go b/internal/remote/teamask_test.go index cb6b9f39c3..e449d1ceab 100644 --- a/internal/remote/teamask_test.go +++ b/internal/remote/teamask_test.go @@ -190,3 +190,40 @@ func TestAnOlderEngineIsNotAskedForATeamName(t *testing.T) { // The actual engine, rather than only a fixture, must answer both asks, or the // flag would be false on every real engine and the wall would never ask. var _ teamAskDoor = (*session.Agent)(nil) + +// And the word-limited ask, which a team made from one conversation is named +// through (internal/tui3's helpers.go). +var _ teamAskWordsDoor = (*session.Agent)(nil) + +// wordsAgent is [askAgent] with the word-limited ask, which remembers the +// limit it was given. +type wordsAgent struct { + *askAgent + words int +} + +func (a *wordsAgent) NameTeamWords(ctx context.Context, titles []string, words int) (string, error) { + a.titles, a.words = titles, words + a.note(ctx) + return "pickups", nil +} + +// THE WORD LIMIT CROSSES, AND AN ENGINE WITHOUT IT IS ASKED THE GROUP'S +// QUESTION. A request with Words reaches an engine's NameTeamWords with that +// limit; the same request to an engine whose agent has only NameTeam is +// answered by NameTeam, whose answer the surface cuts down. +func TestTheTeamNameWordLimitCrossesAndFallsBack(t *testing.T) { + worded := &wordsAgent{askAgent: &askAgent{fakeAgent: &fakeAgent{model: "m"}}} + loop := askLoop(t, worded) + name, err := loop.Client.Agent().NameTeamWords(context.Background(), []string{"guitar pickups"}, 2) + if err != nil || name != "pickups" || worded.words != 2 || len(worded.titles) != 1 { + t.Fatalf("the limit did not cross: %q, %v, words %d, titles %q", name, err, worded.words, worded.titles) + } + + plain := &askAgent{fakeAgent: &fakeAgent{model: "m"}} + loop = askLoop(t, plain) + name, err = loop.Client.Agent().NameTeamWords(context.Background(), []string{"guitar pickups"}, 2) + if err != nil || name != "harbor" || plain.calls != 1 { + t.Fatalf("an engine without the limit was not asked the group's question: %q, %v", name, err) + } +} diff --git a/internal/remote/wire_teams.go b/internal/remote/wire_teams.go index 6efeb92f94..a5731c6b92 100644 --- a/internal/remote/wire_teams.go +++ b/internal/remote/wire_teams.go @@ -119,9 +119,15 @@ const ( // TeamNameArgs is the titles a group is named from, and how long the wall // will wait. Zero Budget uses the engine's ceiling; a negative Budget has // already expired and never starts a model call. +// +// Words is the most words the name may have, zero for the group's three. It +// is optional on the wire both ways: an engine from before it reads a request +// without it and answers up to three words, which the surface cuts to what it +// asked for (internal/tui3's helpers.go). type TeamNameArgs struct { Titles []string `json:"titles"` Budget time.Duration `json:"budget,omitempty"` + Words int `json:"words,omitempty"` } // TeamProposeArgs is everything one Organize ask is about, and how long the diff --git a/internal/session/teamname.go b/internal/session/teamname.go index efb4a8d30a..f655a892b9 100644 --- a/internal/session/teamname.go +++ b/internal/session/teamname.go @@ -26,8 +26,14 @@ import ( // already and a slow answer is simply not used. // teamNameAsk is the instruction, last in the user message for the reason -// titleSystem's comment gives. -const teamNameAsk = "Give these conversations one short shared name: one to three plain lowercase words, no quotes. Answer with the name only." +// titleSystem's comment gives; teamNameAskShort is the same for a name of at +// most two words, which is what a team made of one conversation is given +// (internal/tui3's helpers.go): a team named for one piece of work wants the +// work's name, and a third word is where a small model starts describing it. +const ( + teamNameAsk = "Give these conversations one short shared name: one to three plain lowercase words, no quotes. Answer with the name only." + teamNameAskShort = "Give this work one short name: one or two plain lowercase words, no quotes. Answer with the name only." +) // teamNameClip bounds each title handed to the namer, and teamNameTitles how // many are sent: a name for a group is read off a handful of short titles. @@ -44,6 +50,22 @@ const teamNameWords = 3 // bounds with ctx, and returns the name or an error; an answer that is not a // name is an error, never a name. func (a *Agent) NameTeam(ctx context.Context, titles []string) (string, error) { + return a.NameTeamWords(ctx, titles, teamNameWords) +} + +// NameTeamWords is [Agent.NameTeam] for a name of at most words words: two +// asks the short question ([teamNameAskShort]), and anything else, or a number +// out of range, the group's. The answer is cut to that many words whatever the +// model said. +func (a *Agent) NameTeamWords(ctx context.Context, titles []string, words int) (string, error) { + instruction := teamNameAsk + if words <= 0 || words > teamNameWords { + words = teamNameWords + } + if words <= 2 { + instruction = teamNameAskShort + } + clean := func(raw string) string { return cleanTeamNameIn(raw, words, instruction) } var lines []string for _, t := range titles { if t = strings.TrimSpace(t); t != "" && len(lines) < teamNameTitles { @@ -59,11 +81,11 @@ func (a *Agent) NameTeam(ctx context.Context, titles []string) (string, error) { if closed { return "", errors.New("the conversation is closed") } - ask := "Conversations:\n" + strings.Join(lines, "\n") + "\n\n" + teamNameAsk + ask := "Conversations:\n" + strings.Join(lines, "\n") + "\n\n" + instruction response, named, err := a.callRoleChecked(withDetachedUsage(ctx), roles.RoleTitle, model, []ai.Message{textMessage("system", titleSystem), textMessage("user", ask)}, func(response *ai.Response, named string) bool { - if cleanTeamName(response.Text()) != "" { + if clean(response.Text()) != "" { return true } a.addDetachedUsageAs(response, named, 1, auxRoleTitle) @@ -76,7 +98,7 @@ func (a *Agent) NameTeam(ctx context.Context, titles []string) (string, error) { return "", errEmptyAnswer } a.addDetachedUsageAs(response, named, 1, auxRoleTitle) - name := cleanTeamName(response.Text()) + name := clean(response.Text()) if name == "" { return "", errInvalidName } @@ -88,7 +110,11 @@ func (a *Agent) NameTeam(ctx context.Context, titles []string) (string, error) { // the speaker), lowercased, cut to [teamNameWords] words, and with anything but // letters, digits and inner hyphens taken off each word. It is "" when nothing // usable is left. -func cleanTeamName(raw string) string { +func cleanTeamName(raw string) string { return cleanTeamNameIn(raw, teamNameWords, teamNameAsk) } + +// cleanTeamNameIn is [cleanTeamName] cut to limit words, refusing an answer +// that echoes instruction, the ask the model was given. +func cleanTeamNameIn(raw string, limit int, instruction string) string { title := cleanTitle(raw) if title == "" { return "" @@ -105,13 +131,13 @@ func cleanTeamName(raw string) string { if w != "" { words = append(words, w) } - if len(words) == teamNameWords { + if len(words) == limit { break } } name := strings.Join(words, " ") // A small model that answers with the instruction has named nothing. - if len(words) >= 2 && strings.Contains(strings.ToLower(teamNameAsk), name) { + if len(words) >= 2 && strings.Contains(strings.ToLower(instruction), name) { return "" } return name diff --git a/internal/session/teamname_test.go b/internal/session/teamname_test.go index a207304326..851a492000 100644 --- a/internal/session/teamname_test.go +++ b/internal/session/teamname_test.go @@ -20,3 +20,20 @@ func TestCleanTeamName(t *testing.T) { } } } + +// A NAME FOR ONE PIECE OF WORK IS ONE OR TWO WORDS (internal/tui3's helpers.go): +// the same cleaning, cut at two, and an echo of the short ask is nothing. +func TestCleanTeamNameForOnePieceOfWorkKeepsTwoWords(t *testing.T) { + for raw, want := range map[string]string{ + "fizzbuzz": "fizzbuzz", + "Guitar Pickups": "guitar pickups", + "the relay audit follow-up": "the relay", + "\"flaky tests\".": "flaky tests", + "Give this work one short": "", + "": "", + } { + if got := cleanTeamNameIn(raw, 2, teamNameAskShort); got != want { + t.Errorf("cleanTeamNameIn(%q, 2) = %q, want %q", raw, got, want) + } + } +} diff --git a/internal/tui3/app.go b/internal/tui3/app.go index 2bbd96483c..0fbf6c11b2 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -1465,6 +1465,9 @@ type app struct { // helpers is a `/helpers` waiting for the store to take its team before // the conversation is asked for its helpers (helpers.go). helpers helpersPending + // helpersName is the one name asked for a team the Teams row made + // (helpers.go). + helpersName helpersNaming // tp is the teams page's own state: its selection, its reading of the // store and the targets it drew (teamspage.go). tp teamsPage diff --git a/internal/tui3/helpers.go b/internal/tui3/helpers.go index f9c89f322c..6ae06bfd1a 100644 --- a/internal/tui3/helpers.go +++ b/internal/tui3/helpers.go @@ -56,11 +56,15 @@ package tui3 // team_dissolve.go). Disband on Teams ends it too, and keeps it as history. import ( + "context" "fmt" "math/rand/v2" "strings" tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" + + teamstore "github.com/Agent-Field/codeaf/internal/teams" ) // helpersPrompt is what a bare /helpers asks, and what the dropdown's row puts @@ -110,12 +114,14 @@ var helpersShared = "helpers open beside this conversation, and " + oneConversat // helpersPending is one team the row made, between its edit and the store's // answer: the edit's notice tie, the conversation it made a manager, the -// team's name, the line it put in the box, and a /helpers pressed meanwhile -// (ask and shown), held until the store has the team. due says the answer is -// in and [app.helpersSettled] has not acted on it yet. +// team's id and the name it was made with, the line it put in the box, and a +// /helpers pressed meanwhile (ask and shown), held until the store has the +// team. due says the answer is in and [app.helpersSettled] has not acted on +// it yet. type helpersPending struct { said teamWriteSaid key string + id string name string box string ask string @@ -123,6 +129,132 @@ type helpersPending struct { due bool } +// ── THE TEAM'S NAME, FROM THE CONVERSATION ────────────────────────────────── +// +// THE ROW NAMES THE TEAM TWICE, and the first name is only a placeholder. A +// click has to answer on its frame, and no model answers on a frame, so the +// team is made at once under its folder's name ([teamFreshName], the same word +// the conversation grid offers). Beside the loop, the naming role is asked — +// once, bounded by [teamNameWait], on the cheap model conversations name +// themselves with — for one or two words naming the work in this +// conversation, read off its title or its opening words. When that answer +// comes, the team takes it. +// +// THE ANSWER NEVER OVERWRITES A CHOICE. It is applied inside the store's own +// edit, and only while the team still has the name it was made with: a team +// the person has renamed meanwhile, a team that is gone, and a name another +// team already has all leave the team exactly as it is, and so does a failure, +// a timeout or an answer that is not a name. Nothing is said for any of them; +// the folder's name was a real name all along. + +// helpersNameWords is the most words a team made from one conversation is +// named with: the work's name, not a description of it. +const helpersNameWords = 2 + +// helpersNamedWord is said in the manager's conversation when the team takes +// its name from the conversation. +const helpersNamedWord = "named the team %s" + +// helpersNamer is the naming ask with a word limit (internal/session's +// teamname.go, and over the wire internal/remote's teamask.go). An agent that +// only has the group's ask ([teamNamer]) is asked that, and its answer is cut +// to [helpersNameWords]. +type helpersNamer interface { + NameTeamWords(ctx context.Context, titles []string, words int) (string, error) +} + +// helpersNaming is the one name being asked for: which ask it is (gen), and +// the team, the name it was made with, and its manager. +type helpersNaming struct { + gen int + id string + from string + key string +} + +// helpersAskName asks for team id's name, beside the loop, from title: the +// conversation in front, which the row has just made the team's manager. +func (a *app) helpersAskName(id, from, key, title string) tea.Cmd { + a.helpersName.gen++ + a.helpersName = helpersNaming{gen: a.helpersName.gen, id: id, from: from, key: key} + title = strings.TrimSpace(title) + if title == "" || a.agent == nil { + return nil + } + worded, hasWords := a.agent.(helpersNamer) + grouped, hasGroup := a.agent.(teamNamer) + if !hasWords && !hasGroup { + return nil + } + gen := a.helpersName.gen + return a.besideLine(func() func(bool) tea.Cmd { + ctx, cancel := context.WithTimeout(context.Background(), teamNameWait) + defer cancel() + var name string + var err error + if hasWords { + name, err = worded.NameTeamWords(ctx, []string{title}, helpersNameWords) + } else { + name, err = grouped.NameTeam(ctx, []string{title}) + } + return func(bool) tea.Cmd { + a.helpersNamed(gen, name, err) + return a.wake() + } + }) +} + +// helpersNamed takes answer gen as the team's name, when it is a name and the +// team will still take it (see above). The edit itself checks again under the +// store's lock, so a rename made in another window is not undone either. +func (a *app) helpersNamed(gen int, name string, err error) { + n := a.helpersName + if gen != n.gen || err != nil { + return + } + words := strings.Fields(strings.ToLower(strings.TrimSpace(name))) + if len(words) > helpersNameWords { + words = words[:helpersNameWords] + } + name = ansi.Truncate(strings.Join(words, " "), teamNameCells, "") + if name == "" || strings.EqualFold(name, n.from) { + return + } + t, ok := a.teamByID(n.id) + if !ok || t.Closed() || t.Name != n.from { + return + } + for _, other := range a.wall.teams { + if other.ID != n.id && strings.EqualFold(other.Name, name) { + return + } + } + id, from := n.id, n.from + if a.teamEdit(func(f *teamstore.File) error { + i := teamIndex(f.Teams, id) + if i < 0 || f.Teams[i].Closed() || f.Teams[i].Name != from { + return nil + } + for j, other := range f.Teams { + if j != i && strings.EqualFold(other.Name, name) { + return nil + } + } + f.Teams[i].Name = name + return nil + }) != nil { + return + } + // THE RECEIPT SAYS THE NAME ONCE. A name in before the store has taken + // the team is in the receipt that follows ([app.helpersSettled] reads the + // team's name as it is then); one after it is said on its own line. + waiting := a.helpers.id == n.id && a.helpers.said.pending + if a.frontTabKey() == n.key && !waiting { + a.note(fmt.Sprintf(helpersNamedWord, name)) + } + a.touch() +} + // helpersTab is the conversation in front as a strip tab, and false when there // is none to make a team of. func (a *app) helpersTab() (chatTab, bool) { @@ -233,9 +365,9 @@ func (a *app) helpersNewTeam() tea.Cmd { a.drafts.push([]rune(live)) } a.input.setText(line) - a.helpers = helpersPending{said: a.teamWriteWatch(nil), key: tab.key, name: name, box: line} + a.helpers = helpersPending{said: a.teamWriteWatch(nil), key: tab.key, id: id, name: name, box: line} a.touch() - return tea.Batch(a.teamActivate(id), a.syncLists()) + return tea.Batch(a.teamActivate(id), a.syncLists(), a.helpersAskName(id, name, tab.key, tab.word)) } // runHelpersCommand is /helpers: the person's words, or [helpersPrompt], sent @@ -297,13 +429,17 @@ func (a *app) helpersSettled() tea.Cmd { } return nil } + name := p.name + if t, ok := a.teamByID(p.id); ok { + name = t.Name + } if a.frontTabKey() != p.key { if p.ask != "" { - a.note(fmt.Sprintf(helpersAwayWord, p.name)) + a.note(fmt.Sprintf(helpersAwayWord, name)) } return nil } - a.note(fmt.Sprintf(helpersMadeWord, p.name)) + a.note(fmt.Sprintf(helpersMadeWord, name)) if p.ask == "" { return nil } diff --git a/internal/tui3/helpers_test.go b/internal/tui3/helpers_test.go index f3e68f30b7..880ea42a51 100644 --- a/internal/tui3/helpers_test.go +++ b/internal/tui3/helpers_test.go @@ -1,6 +1,7 @@ package tui3 import ( + "context" "fmt" "strings" "testing" @@ -523,3 +524,153 @@ func TestTheHelpersTipIsArmedWhereTheRowWouldAct(t *testing.T) { t.Fatal("the tip is armed in a conversation that already manages a team") } } + +// wordNamingAgent is the fake conversation with the naming ask: it answers name +// (or err), and keeps what it was asked. +type wordNamingAgent struct { + *fakeAgent + name string + err error + titles []string + words int +} + +func (n *wordNamingAgent) NameTeamWords(_ context.Context, titles []string, words int) (string, error) { + n.titles, n.words = append([]string(nil), titles...), words + return n.name, n.err +} + +// groupNamingAgent has only the group's ask, as an engine from before the word +// limit does: up to three words, which the surface cuts to two. +type groupNamingAgent struct { + *fakeAgent + name string +} + +func (n *groupNamingAgent) NameTeam(context.Context, []string) (string, error) { return n.name, nil } + +// THE TEAM IS NAMED AFTER THE CONVERSATION. The click makes it under the +// folder's name at once; the naming role is asked for two words over this +// conversation's title, and the team takes the answer, cleaned — on disk, on +// the strip, and in the receipt, which says the final name once. +func TestNewFromThisSessionNamesTheTeamAfterTheConversation(t *testing.T) { + a, fake := helpersApp(t) + namer := &wordNamingAgent{fakeAgent: fake, name: "Parser Port"} + a.agent = namer + made := helpersMakeTeam(t, a) + if len(namer.titles) != 1 || namer.titles[0] != "Shipping the parser" || namer.words != helpersNameWords { + t.Fatalf("the namer was asked %q for %d words", namer.titles, namer.words) + } + if made.Name != "parser port" { + t.Fatalf("the team on disk is called %q, want the conversation's name", made.Name) + } + if active, ok := a.teamActive(); !ok || active.Name != "parser port" { + t.Fatal("the strip does not show the team's new name") + } + if !transcriptSays(a, fmt.Sprintf(helpersMadeWord, "parser port")) { + t.Fatal("the receipt does not name the team by its final name") + } + if transcriptSays(a, fmt.Sprintf(helpersNamedWord, "parser port")) { + t.Fatal("the name was said twice") + } +} + +// AN OLDER ENGINE'S THREE WORDS ARE CUT TO TWO. +func TestAGroupNamersAnswerIsCutToTwoWords(t *testing.T) { + a, fake := helpersApp(t) + a.agent = &groupNamingAgent{fakeAgent: fake, name: "guitar pickup history"} + if made := helpersMakeTeam(t, a); made.Name != "guitar pickup" { + t.Fatalf("the team is called %q", made.Name) + } +} + +// A NAME THAT ARRIVES AFTER THE RECEIPT IS SAID ON ITS OWN LINE. +func TestALateNameIsSaid(t *testing.T) { + a, _ := helpersApp(t) + made := helpersMakeTeam(t, a) + a.helpersNamed(a.helpersName.gen, "guitar pickups", nil) + teamsFlush(t, a) + if got := mustTeam(t, a, made.ID).Name; got != "guitar pickups" { + t.Fatalf("the team is called %q", got) + } + if disk := helpersDisk(t, a); disk[0].Name != "guitar pickups" { + t.Fatalf("the name did not reach the disk: %q", disk[0].Name) + } + if !transcriptSays(a, fmt.Sprintf(helpersNamedWord, "guitar pickups")) { + t.Fatal("a late name was not said") + } +} + +// THE ANSWER NEVER OVERWRITES A CHOICE OR TAKES A NAME. The team keeps the +// name it has when the person renamed it here, another window renamed it in +// the store, another team has the suggested name, the ask failed, the answer +// is not a name, or a newer ask replaced it; a team that is gone stays gone. +func TestTheSuggestedNameNeverOverwritesAChoice(t *testing.T) { + cases := []struct { + name string + setup func(t *testing.T, a *app, id string) (gen int, answer string, err error) + want string + }{ + {"renamed here", func(t *testing.T, a *app, id string) (int, string, error) { + if err := a.teamRename(id, "mine"); err != nil { + t.Fatal(err) + } + teamsFlush(t, a) + return a.helpersName.gen, "guitar pickups", nil + }, "mine"}, + {"renamed in another window", func(t *testing.T, a *app, id string) (int, string, error) { + if err := teamstore.Update(a.profileDir, func(f *teamstore.File) error { + f.Teams[teamIndex(f.Teams, id)].Name = "theirs" + return nil + }); err != nil { + t.Fatal(err) + } + return a.helpersName.gen, "guitar pickups", nil + }, "theirs"}, + {"name taken", func(t *testing.T, a *app, id string) (int, string, error) { + if _, err := a.teamMake("guitar pickups", nil); err != nil { + t.Fatal(err) + } + teamsFlush(t, a) + return a.helpersName.gen, "Guitar Pickups", nil + }, "lab"}, + {"ask failed", func(t *testing.T, a *app, id string) (int, string, error) { + return a.helpersName.gen, "guitar pickups", fmt.Errorf("no model") + }, "lab"}, + {"not a name", func(t *testing.T, a *app, id string) (int, string, error) { + return a.helpersName.gen, " ", nil + }, "lab"}, + {"an older ask", func(t *testing.T, a *app, id string) (int, string, error) { + return a.helpersName.gen - 1, "guitar pickups", nil + }, "lab"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + a, _ := helpersApp(t) + made := helpersMakeTeam(t, a) + gen, answer, err := c.setup(t, a, made.ID) + a.helpersNamed(gen, answer, err) + teamsFlush(t, a) + for _, team := range helpersDisk(t, a) { + if team.ID == made.ID && team.Name != c.want { + t.Fatalf("the team is called %q on disk, want %q", team.Name, c.want) + } + } + if got := mustTeam(t, a, made.ID).Name; got != c.want { + t.Fatalf("the window calls the team %q, want %q", got, c.want) + } + }) + } + + a, _ := helpersApp(t) + made := helpersMakeTeam(t, a) + if err := teamstore.Dissolve(a.profileDir, made.ID, made.Manager); err != nil { + t.Fatal(err) + } + trafficReadNow(t, a) + a.helpersNamed(a.helpersName.gen, "guitar pickups", nil) + teamsFlush(t, a) + if len(helpersDisk(t, a)) != 0 { + t.Fatal("naming a team that was gone brought it back") + } +} diff --git a/internal/tui3/offlooplaw_test.go b/internal/tui3/offlooplaw_test.go index 99cc75ec13..b0009f781c 100644 --- a/internal/tui3/offlooplaw_test.go +++ b/internal/tui3/offlooplaw_test.go @@ -440,6 +440,7 @@ var doorsBesideTheLine = map[string]string{ "Delegates": "reads the programs the engine machine's build carries to generate their command rows at the launch and on a switch (delegate.go); nobody pressed for it", "NameTeam": "asks a model for a suggested team name under a budget of seconds; it changes nothing on the engine, typing overrides it, and a message sent while it thinks must not wait behind it", "ProposeTeams": "asks a model for Organize's proposals under a budget of seconds; it changes nothing on the engine (Apply writes through the teams store), and nothing after it depends on the engine having seen it", + "NameTeamWords": "asks a model for the name of a team the Teams row just made, under a budget of seconds; it changes nothing on the engine (the rename is a teams-store edit, and only while the team keeps its first name), and the /helpers sent right after the click must not wait behind it", } func TestOnlyReadsNobodyPressedForAreAskedBesideTheLine(t *testing.T) { From 23e4f4576b8d1f11afee834d3bfea60d08163f47 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Tue, 20 Oct 2026 18:13:23 -0400 Subject: [PATCH 10/10] fix(teams): bind startup briefs to memberships and retire used-team dissolve --- docs/changes/unreleased/1881-helpers.md | 2 +- internal/manual/chat/team-manager.md | 6 +- internal/remote/teams.go | 1 + internal/remote/teams_test.go | 5 +- internal/session/team.go | 41 ++++----- internal/session/team_dissolve.go | 3 +- .../session/team_dissolve_traffic_test.go | 47 ++++++++++ internal/session/team_nest.go | 4 +- .../session/team_start_membership_test.go | 88 +++++++++++++++++++ internal/session/team_wake.go | 3 +- internal/session/team_wake_test.go | 11 ++- internal/teams/dissolve.go | 6 +- internal/teams/member_json.go | 2 +- internal/teams/member_json_test.go | 3 +- internal/teams/root.go | 2 +- internal/teams/teams.go | 3 + internal/tui3/teamtraffic.go | 6 +- internal/tui3/teamtraffic_test.go | 2 +- 18 files changed, 187 insertions(+), 48 deletions(-) create mode 100644 internal/session/team_dissolve_traffic_test.go create mode 100644 internal/session/team_start_membership_test.go diff --git a/docs/changes/unreleased/1881-helpers.md b/docs/changes/unreleased/1881-helpers.md index acd10d0492..110556a37f 100644 --- a/docs/changes/unreleased/1881-helpers.md +++ b/docs/changes/unreleased/1881-helpers.md @@ -9,7 +9,7 @@ invalidates: - "`/helpers` asks the team the conversation in front manages for helpers: it sends exactly the words after it (bare, the default ask, shown in full) as the person's message, and makes no team. In a conversation that manages none it changes nothing and says to make one with + New from this session." - "The Teams ▾ chip was absent with no teams, and its dropdown printed an up/down, enter, esc footer. The chip shows with no teams, the footer is gone, and with no team selected the dropdown ends with `+ New from this session`. With a team selected the row is absent: the team it makes is top-level, and nesting is + Add subteam." - "A manager had no way to take its team back. `team_dissolve` forgets a team that has never held anyone but its manager (no member, former member, subteam or Traffic); it is on the belt only while that is true, for every team however it was made, and does not ask first." - - "A member a manager started with team_start never took its first turn on an ordinary launch: the engine's teams door stamps a new member's join time, the join bound dropped the start that made it, and the member sat idle with nothing addressed to it. A member's own start is exempt from the bound now, and it wakes with its brief; every other line from before the join is still withheld." + - "A member a manager started with team_start never took its first turn on an ordinary launch: the engine's teams door stamps a new member's join time, the join bound dropped the start that made it, and the member sat idle with nothing addressed to it. The start admitted for that specific membership is exempt from the bound now, and it wakes with its brief; every other line from before the join is still withheld, including a removed member's start when its handle is reused." - "There are twenty-five tips, not twenty-four: `+ New from this session in the Teams menu makes this chat a team's manager`." - "An interactive conversation in no team is now told once, after the person's fourth message, that it may suggest + New from this session when the work is long and has parts a few helpers could take on at once. It is a note, not a paragraph of the system prompt." --- diff --git a/internal/manual/chat/team-manager.md b/internal/manual/chat/team-manager.md index 2000d46830..8a5cee1996 100644 --- a/internal/manual/chat/team-manager.md +++ b/internal/manual/chat/team-manager.md @@ -149,7 +149,7 @@ your chats and the column to `Tasks`. A manager has `team_dissolve` only while its team has never held anyone but it: no member, no former member, no subteam, and nothing in its Traffic. The rule is the same for every team, however it was made. It does not ask first, because it can only remove a team -nothing happened in. Once anyone joins, it leaves at the manager's next step, and a +nothing happened in. Once anyone joins or Traffic is written, it leaves at the manager's next step, and a remembered call says `team_dissolve is no longer one of your tools: the team has held members or exchanged messages, so only the person can disband it, from Teams.` A team that was used ends with `Disband` on Teams, which keeps its history. @@ -553,7 +553,9 @@ it. The traffic says so. A member busy in one long command reads a message when that command returns, which is what `team_stop` is for. Nothing is delivered twice, and a conversation that joins a team is not -handed the team's earlier history. A conversation reopened later is handed what was said to it +handed the team's earlier history. A new member receives only the start that created its +membership from before it joined; reusing a removed member's handle does not bring back that +member's brief. A conversation reopened later is handed what was said to it while it was closed. The traffic itself is kept in the profile of the machine the conversations run on, in diff --git a/internal/remote/teams.go b/internal/remote/teams.go index cb939a1ee4..ab3170ddec 100644 --- a/internal/remote/teams.go +++ b/internal/remote/teams.go @@ -130,6 +130,7 @@ func teamsUpdate(dir string, args TeamsUpdateArgs) (TeamsReading, error) { member := &args.Teams[i].Members[j] if old, exists := previous.Member(member.Key); exists { member.JoinedAt = old.JoinedAt + member.StartID = old.StartID } else { member.JoinedAt = time.Now() } diff --git a/internal/remote/teams_test.go b/internal/remote/teams_test.go index 5e22c5aa08..fe04cbcbc7 100644 --- a/internal/remote/teams_test.go +++ b/internal/remote/teams_test.go @@ -139,7 +139,7 @@ func TestTeamMembershipDeliveryBoundaryUsesTheEngineClock(t *testing.T) { t.Fatal(err) } before := time.Now() - wrote, err := loop.Client.TeamsUpdate(initial.Stamp, []teamstore.Team{{ID: "abcdefabcdef", Name: "Clock test", Members: []teamstore.Member{{Key: "/srv/member.jsonl", JoinedAt: before.Add(time.Hour)}}}}) + wrote, err := loop.Client.TeamsUpdate(initial.Stamp, []teamstore.Team{{ID: "abcdefabcdef", Name: "Clock test", Members: []teamstore.Member{{Key: "/srv/member.jsonl", JoinedAt: before.Add(time.Hour), StartID: "000000000042"}}}}) after := time.Now() if err != nil { t.Fatal(err) @@ -148,8 +148,9 @@ func TestTeamMembershipDeliveryBoundaryUsesTheEngineClock(t *testing.T) { if joined.Before(before) || joined.After(after) { t.Fatalf("delivery boundary borrowed the client's clock: %v", joined) } + wrote.Teams[0].Members[0].StartID = "000000000099" again, err := loop.Client.TeamsUpdate(wrote.Stamp, wrote.Teams) - if err != nil || !again.Teams[0].Members[0].JoinedAt.Equal(joined) { + if err != nil || !again.Teams[0].Members[0].JoinedAt.Equal(joined) || again.Teams[0].Members[0].StartID != "000000000042" { t.Fatal("unrelated write changed membership delivery boundary") } } diff --git a/internal/session/team.go b/internal/session/team.go index 207b7f75ce..1947d1e7c0 100644 --- a/internal/session/team.go +++ b/internal/session/team.go @@ -130,6 +130,7 @@ type teamRole struct { // chooses again once (handlepick.go). derived bool joinedAt time.Time + startID string } // fileStamp is what a stat says about a file, and the whole of how this file @@ -405,6 +406,7 @@ func rolesFor(file *teams.File, keys []string, d teams.Defaults) []teamRole { questionsUp: effective.QuestionsUp, derived: member.HandleDerived(), joinedAt: member.JoinedAt, + startID: member.StartID, } role.dissolvable = role.manager && file.Dissolvable(team.ID) if hasHome { @@ -520,7 +522,9 @@ func (a *Agent) armTeamTools(roles []teamRole) { for _, role := range roles { manager = manager || role.manager member = member || (!role.manager && role.managed) - dissolvable = dissolvable || role.dissolvable + // Traffic can change without teams.json changing, so the cached role + // alone cannot say whether this verb can still work at this boundary. + dissolvable = dissolvable || (role.dissolvable && !teams.HasTraffic(a.config.teamProfile(), role.id)) } if manager { arriving = append(arriving, a.managerTools()...) @@ -702,27 +706,11 @@ func (a *Agent) teamNewsLocked(profile string, roles []teamRole) string { return strings.Join(groups, "\n\n") } -// firstTeamCursor is where a conversation starts reading a team it has no -// cursor for. -// -// A FRESH CONVERSATION DOES NOT REPLAY THE TEAM'S HISTORY. It starts at the last -// entry written before this process began, so what it is handed is what was said -// while it was listening, and a member added to a team with a month of Traffic -// is not handed the month. -// -// THE ONE EXCEPTION IS ITS OWN START. A member the manager started with -// `team_start` is opened by the interface after the start is written, and the -// manager may have said something to it in between; so a start addressed to -// this conversation's handle, written shortly before this process began, is -// where it starts reading instead. It starts reading AT the start and not after -// it, because the start carries the brief, and the brief is handed to the new -// member here, marked as the manager's, on its first request ([teamBriefLine]). -// The person never typed it, so it must not arrive as the person's message. // teamOwnStart reports whether entry is the start that made this -// conversation: a manager's start addressed to its handle in the team, inside -// [teamStartGrace] of when this process began. It is the one line a member is -// owed from before it joined, the line [Agent.firstTeamCursor] reaches back -// for, and the join bound in [Agent.teamNewsLocked] must let it through. +// conversation: the manager's start admitted for this membership, inside +// [teamStartGrace] of when this process began. Only that brief crosses the +// join boundary. Legacy memberships with no join boundary retain the old +// handle-based startup path; a stamped join needs the exact start identity. // // THE JOIN IS ALWAYS AFTER THE START. The manager writes the start, the // interface opens the conversation on reading it, and only then makes it a @@ -731,10 +719,14 @@ func (a *Agent) teamNewsLocked(profile string, roles []teamRole) string { // too handed every started member nothing, so none of them ever woke // (team_wake.go), and a manager reported helpers running that were idle. func (a *Agent) teamOwnStart(role teamRole, entry teams.Entry) bool { - return !role.manager && role.handle != "" && entry.Kind == teams.KindStart && entry.To == role.handle && - !entry.At.Before(a.startedAt.Add(-teamStartGrace)) + return !role.manager && role.handle != "" && entry.Kind == teams.KindStart && entry.From == teams.FromManager && + entry.To == role.handle && !entry.At.Before(a.startedAt.Add(-teamStartGrace)) && + ((role.startID != "" && entry.ID == role.startID) || (role.startID == "" && role.joinedAt.IsZero())) } +// firstTeamCursor is where a conversation starts reading a team it has no +// cursor for. A fresh conversation skips the team's earlier history except +// its own start, whose brief is handed over as the manager's words. func (a *Agent) firstTeamCursor(profile string, role teamRole) string { tail, err := teams.ReadTraffic(profile, role.id, "", teamFirstLook) if err != nil { @@ -747,8 +739,7 @@ func (a *Agent) firstTeamCursor(profile string, role teamRole) string { if !entry.At.After(born) { cursor = entry.ID } - if role.handle != "" && entry.Kind == teams.KindStart && entry.To == role.handle && - !entry.At.Before(born.Add(-teamStartGrace)) { + if a.teamOwnStart(role, entry) { started = entry.ID } // AND THE DIRECTIVE IT WAS OPENED FOR. A member nobody had open is diff --git a/internal/session/team_dissolve.go b/internal/session/team_dissolve.go index 3b9d218935..d57c8d3b85 100644 --- a/internal/session/team_dissolve.go +++ b/internal/session/team_dissolve.go @@ -13,7 +13,8 @@ package session // THE VERB IS ON THE BELT ONLY WHILE IT CAN WORK. It is armed at a boundary that // finds this conversation managing a team that has never held anyone but it // ([teams.File.Dissolvable]), and it leaves at the first boundary after anyone -// else joins, with the reason said to a model that calls it from memory. A +// else joins or Traffic is written, with the reason said to a model that calls +// it from memory. A // capability that cannot work is absent, not broken. The call itself checks // again, under the store's lock, and also refuses a team whose Traffic has // anything in it ([teams.Dissolve]), because a start already written there is a diff --git a/internal/session/team_dissolve_traffic_test.go b/internal/session/team_dissolve_traffic_test.go new file mode 100644 index 0000000000..d12ed2e973 --- /dev/null +++ b/internal/session/team_dissolve_traffic_test.go @@ -0,0 +1,47 @@ +package session + +import ( + "context" + "os" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/teams" +) + +func TestTeamDissolveLeavesAfterTrafficMakesItUnusable(t *testing.T) { + f := newHelpersFixture(t) + a := teamAgent(t, f, f.manager, nil, nil) + a.teamBoundary() + if !holds(a, teamDissolveToolName) { + t.Fatal("unused team did not offer dissolve") + } + appendTraffic(t, f, teams.Entry{Kind: teams.KindNote, From: teams.FromManager, To: teams.ToRoom, Text: "The plan is ready."}) + a.teamBoundary() + if holds(a, teamDissolveToolName) { + t.Fatal("team_dissolve remains offered after traffic permanently makes it refuse") + } + if !holds(a, teamSendToolName) { + t.Fatal("traffic took away the manager's other tools") + } + result := a.executeTool(context.Background(), nil, nil, withdrawnCall("c1", teamDissolveToolName, `{}`), "") + if want := "team_dissolve is no longer one of your tools: " + teamRetiredDissolve + "."; result.text != want { + t.Fatalf("a remembered call said %q, want %q", result.text, want) + } + if err := a.Close(); err != nil { + t.Fatal(err) + } + // Rotating the log does not make its history safe to forget either. + path := teams.TrafficPath(f.profile, f.teamID) + if err := os.Rename(path, strings.TrimSuffix(path, ".jsonl")+".1.jsonl"); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, nil, 0o600); err != nil { + t.Fatal(err) + } + reopened := teamAgent(t, f, f.manager, nil, nil) + reopened.teamBoundary() + if holds(reopened, teamDissolveToolName) { + t.Fatal("a manager reopened after traffic was offered team_dissolve") + } +} diff --git a/internal/session/team_nest.go b/internal/session/team_nest.go index ebc802935a..062156dc55 100644 --- a/internal/session/team_nest.go +++ b/internal/session/team_nest.go @@ -218,7 +218,7 @@ func (a *Agent) teamStartSubTeam(team teams.Team, role teamRole, handle, brief, } for _, m := range members { reporting := m.Home - m.Home, m.Started = false, false + m.Home, m.Started, m.StartID = false, false, "" if err := f.AddMember(child.ID, m); err != nil { return err } @@ -317,7 +317,7 @@ func (a *Agent) claimSubTeams(profile string, claims []subTeamClaim) { if !ok { m = teams.Member{Key: claim.key} } - m.Home, m.Started = false, false + m.Home, m.Started, m.StartID = false, false, "" if err := f.AddMember(child.ID, m); err != nil { return err } diff --git a/internal/session/team_start_membership_test.go b/internal/session/team_start_membership_test.go new file mode 100644 index 0000000000..48d02e538b --- /dev/null +++ b/internal/session/team_start_membership_test.go @@ -0,0 +1,88 @@ +package session + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/codeaf/internal/teams" +) + +// A reused handle does not carry the former membership's brief across the +// new join boundary, even when both memberships were created moments apart. +func TestAReusedHandleDoesNotReplayAnotherMembershipsStart(t *testing.T) { + for _, tc := range []struct { + name string + sameKey bool + withStart bool + }{ + {name: "replacement conversation"}, + {name: "same conversation rejoins", sameKey: true}, + {name: "replacement has its own start", withStart: true}, + } { + t.Run(tc.name, func(t *testing.T) { + f := newTeamFixture(t, true) + key := convKeyOf(t, f.web) + if err := teams.Update(f.profile, func(file *teams.File) error { return file.RemoveMember(f.teamID, key) }); err != nil { + t.Fatal(err) + } + const stale = "Rewrite the old worker's billing subsystem." + oldID, err := teams.AppendTrafficID(f.profile, f.teamID, teams.Entry{Kind: teams.KindStart, From: teams.FromManager, To: "worker", Text: stale}) + if err != nil { + t.Fatal(err) + } + if err := teams.Update(f.profile, func(file *teams.File) error { + return file.AddMember(f.teamID, teams.Member{Key: key, File: f.web, Handle: "worker", JoinedAt: time.Now(), StartID: oldID}) + }); err != nil { + t.Fatal(err) + } + if err := teams.Update(f.profile, func(file *teams.File) error { return file.RemoveMember(f.teamID, key) }); err != nil { + t.Fatal(err) + } + journal := f.web + if !tc.sameKey { + dir := filepath.Join(filepath.Dir(filepath.Dir(f.web)), "replacement") + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + journal = filepath.Join(dir, placeTranscript) + if err := os.WriteFile(journal, nil, 0o600); err != nil { + t.Fatal(err) + } + } + const fresh = "Write the replacement worker's migration guide." + startID := "" + if tc.withStart { + startID, err = teams.AppendTrafficID(f.profile, f.teamID, teams.Entry{Kind: teams.KindStart, From: teams.FromManager, To: "worker", Text: fresh}) + if err != nil { + t.Fatal(err) + } + } + if err := teams.Update(f.profile, func(file *teams.File) error { + return file.AddMember(f.teamID, teams.Member{Key: convKeyOf(t, journal), File: journal, Handle: "worker", JoinedAt: time.Now(), StartID: startID}) + }); err != nil { + t.Fatal(err) + } + c := &scriptedCompleter{steps: []step{func(context.Context, []ai.Message) (*ai.Response, error) { return textResponse("done"), nil }}} + a := teamAgent(t, f, journal, c, nil) + if got := len(a.teamStarts(f.profile)) > 0; got != tc.withStart { + t.Fatalf("membership startup = %v, want %v", got, tc.withStart) + } + collect(t, mustSubmit(t, a, "What is my current assignment?")) + text := userTextIn(c.request(0)) + if strings.Contains(text, stale) { + t.Fatalf("new membership received the former member's start: %s", text) + } + if tc.withStart && !briefIn(text, fresh) { + t.Fatalf("new membership did not receive its own start: %s", text) + } + if news := a.teamBoundary(); strings.Contains(news, fresh) || strings.Contains(news, stale) { + t.Fatalf("a brief was delivered twice: %s", news) + } + }) + } +} diff --git a/internal/session/team_wake.go b/internal/session/team_wake.go index 60044dcffa..41173b857f 100644 --- a/internal/session/team_wake.go +++ b/internal/session/team_wake.go @@ -159,8 +159,7 @@ func (a *Agent) teamStarts(profile string) []teamRole { continue } for _, entry := range tail { - if entry.Kind == teams.KindStart && entry.To == role.handle && - !entry.At.Before(a.startedAt.Add(-teamStartGrace)) { + if a.teamOwnStart(role, entry) { started = append(started, role) break } diff --git a/internal/session/team_wake_test.go b/internal/session/team_wake_test.go index 5ea17af6a1..d8e238f67b 100644 --- a/internal/session/team_wake_test.go +++ b/internal/session/team_wake_test.go @@ -78,7 +78,12 @@ func TestAStartedMemberWakesOnItsOwnWithTheBriefOnce(t *testing.T) { // the start alone. func TestAStartedMemberWhoseJoinWasStampedStillWakesWithItsBrief(t *testing.T) { fixture := newWakingTeamFixture(t) - appendTraffic(t, fixture, teams.Entry{Kind: teams.KindStart, From: teams.FromManager, To: "lexer", Text: "Rewrite the lexer."}) + appendTraffic(t, fixture, teams.Entry{Kind: teams.KindStart, From: teams.FromManager, To: "lexer", Text: "An unrelated older brief."}) + startID, err := teams.AppendTrafficID(fixture.profile, fixture.teamID, teams.Entry{Kind: teams.KindStart, From: teams.FromManager, To: "lexer", Text: "Rewrite the lexer."}) + if err != nil { + t.Fatal(err) + } + appendTraffic(t, fixture, teams.Entry{Kind: teams.KindStart, From: teams.FromManager, To: "lexer", Text: "An unrelated newer brief."}) appendTraffic(t, fixture, teams.Entry{Kind: teams.KindDirective, From: teams.FromManager, To: "lexer", Text: "Also delete the parser."}) dir := filepath.Join(filepath.Dir(filepath.Dir(fixture.parser)), "lexer") if err := os.MkdirAll(dir, 0o700); err != nil { @@ -94,7 +99,7 @@ func TestAStartedMemberWhoseJoinWasStampedStillWakesWithItsBrief(t *testing.T) { teamAgent(t, fixture, transcript, completer, nil) time.Sleep(10 * time.Millisecond) if err := teams.Update(fixture.profile, func(file *teams.File) error { - return file.AddMember(fixture.teamID, teams.Member{Key: convKeyOf(t, transcript), File: transcript, Handle: "lexer", JoinedAt: time.Now()}) + return file.AddMember(fixture.teamID, teams.Member{Key: convKeyOf(t, transcript), File: transcript, Handle: "lexer", JoinedAt: time.Now(), StartID: startID}) }); err != nil { t.Fatal(err) } @@ -109,7 +114,7 @@ func TestAStartedMemberWhoseJoinWasStampedStillWakesWithItsBrief(t *testing.T) { if !briefIn(first, "Rewrite the lexer.") { t.Fatalf("the first request did not carry the brief:\n%s", first) } - if strings.Contains(first, "Also delete the parser.") { + if strings.Contains(first, "Also delete the parser.") || strings.Contains(first, "An unrelated") { t.Fatalf("a directive from before the join was handed over:\n%s", first) } } diff --git a/internal/teams/dissolve.go b/internal/teams/dissolve.go index 7197c2dc40..eed976276c 100644 --- a/internal/teams/dissolve.go +++ b/internal/teams/dissolve.go @@ -62,7 +62,7 @@ func Dissolve(profileDir, id, manager string) error { if t.Manager != manager { return errors.New("this conversation no longer manages that team") } - if !f.Dissolvable(id) || trafficSaid(profileDir, id) { + if !f.Dissolvable(id) || HasTraffic(profileDir, id) { return ErrNotDissolvable } kept := f.Teams[:0:0] @@ -86,9 +86,9 @@ func Dissolve(profileDir, id, manager string) error { return nil } -// trafficSaid reports whether team id's Traffic has anything in it, either +// HasTraffic reports whether team id's Traffic has anything in it, either // file of it. An unreadable log counts as said, so a doubt keeps the team. -func trafficSaid(profileDir, id string) bool { +func HasTraffic(profileDir, id string) bool { path := TrafficPath(profileDir, id) for _, file := range []string{path, trafficRotated(path)} { info, err := os.Stat(file) diff --git a/internal/teams/member_json.go b/internal/teams/member_json.go index 6f029e0f2b..971e993817 100644 --- a/internal/teams/member_json.go +++ b/internal/teams/member_json.go @@ -17,7 +17,7 @@ type wireMember struct { var knownMemberFields = map[string]bool{ "key": true, "file": true, "where": true, "word": true, "handle": true, - "home": true, "independent": true, "joined_at": true, "started": true, "handle_by": true, + "home": true, "independent": true, "joined_at": true, "start_id": true, "started": true, "handle_by": true, } func (m *Member) UnmarshalJSON(raw []byte) error { diff --git a/internal/teams/member_json_test.go b/internal/teams/member_json_test.go index 8db0eece59..d7887ccbd1 100644 --- a/internal/teams/member_json_test.go +++ b/internal/teams/member_json_test.go @@ -20,11 +20,12 @@ func TestMemberRoundTripPreservesFutureFieldsAndAbsentJoinBoundary(t *testing.T) must(t, json.Unmarshal(data, &member)) } member.JoinedAt = time.Unix(42, 0).UTC() + member.StartID = "000000000042" data, err := json.Marshal(member) must(t, err) var back Member must(t, json.Unmarshal(data, &back)) - if !back.JoinedAt.Equal(member.JoinedAt) { + if !back.JoinedAt.Equal(member.JoinedAt) || back.StartID != member.StartID { t.Fatalf("join boundary: %s", data) } } diff --git a/internal/teams/root.go b/internal/teams/root.go index 2315515dba..c3ff18cd7b 100644 --- a/internal/teams/root.go +++ b/internal/teams/root.go @@ -150,7 +150,7 @@ func seatTopManagers(teams []Team, rootID string) bool { if !ok { continue } - m.Home, m.Started = false, false + m.Home, m.Started, m.StartID = false, false, "" if m.Handle != "" && handleProblem(teams[r], m.Key, m.Handle) != nil { m.Handle = "" } diff --git a/internal/teams/teams.go b/internal/teams/teams.go index 9311a5af4c..0cc764dd8a 100644 --- a/internal/teams/teams.go +++ b/internal/teams/teams.go @@ -37,6 +37,9 @@ type Member struct { // JoinedAt bounds delivery for a conversation added through membership editing. // An ended membership cannot regain directives from its previous lifetime. JoinedAt time.Time `json:"joined_at,omitempty"` + // StartID is the Traffic start admitted when this membership was created. + // Only that brief may cross its join boundary; handles can be reused. + StartID string `json:"start_id,omitempty"` // Started says this membership was made by the team manager's team_start // (a [KindStart] the interface carried out), which is the second rule a // home is picked by. diff --git a/internal/tui3/teamtraffic.go b/internal/tui3/teamtraffic.go index 9619fbcdee..3b0aafa7b7 100644 --- a/internal/tui3/teamtraffic.go +++ b/internal/tui3/teamtraffic.go @@ -505,7 +505,7 @@ func (a *app) trafficStart(t team, e teamstore.Entry) tea.Cmd { err = fmt.Errorf("inherit approvals: %w", err) } } - return func(bool) tea.Cmd { return a.trafficStarted(id, handle, conv, err) } + return func(bool) tea.Cmd { return a.trafficStarted(id, handle, e.ID, conv, err) } }) } @@ -516,7 +516,7 @@ func (a *app) trafficStartRefused(handle, why string) { // trafficStarted is the start's conversation back from the engine: held // behind, joined to the team under its handle, and said beside the manager. -func (a *app) trafficStarted(id, handle string, conv Conversation, err error) tea.Cmd { +func (a *app) trafficStarted(id, handle, startID string, conv Conversation, err error) tea.Cmd { if err != nil || conv.Agent == nil { why := "the conversation did not open" if err != nil { @@ -536,7 +536,7 @@ func (a *app) trafficStarted(id, handle string, conv Conversation, err error) te a.trafficBehindTop(key) a.chatTabBar = tabBar{} if t.ID != "" { - m := teamMember{Key: key, File: conv.SessionFile, Where: conv.Workspace, Handle: handle} + m := teamMember{Key: key, File: conv.SessionFile, Where: conv.Workspace, Handle: handle, StartID: startID} if err := a.teamEdit(func(f *teamstore.File) error { if err := f.AddMember(t.ID, m); err != nil { return err diff --git a/internal/tui3/teamtraffic_test.go b/internal/tui3/teamtraffic_test.go index 4a1fadbd42..b3a47865c8 100644 --- a/internal/tui3/teamtraffic_test.go +++ b/internal/tui3/teamtraffic_test.go @@ -391,7 +391,7 @@ func TestTrafficStopAndStartAreDoneOnceAndNeverReplayed(t *testing.T) { } teamsFlush(t, a) disk, _ := loadTeams(a.profileDir, nil) - if got, ok := disk[0].ByHandle("lexer"); !ok || got.Key != m.Key { + if got, ok := disk[0].ByHandle("lexer"); !ok || got.Key != m.Key || got.StartID != a.traffic.rows[harbor][1].ID || got.StartID == "" { t.Fatalf("the new member did not reach the disk: %+v", disk[0].Members) } // The column says what the stop and the start were for, under General.