Let the lead reorder tabs on what the pane already shows
This commit is contained in:
+1
-1
@@ -111,7 +111,7 @@ Each user has one long-running lead agent, shown in the left pane. It is not tie
|
|||||||
- **Signals.** Every published event goes through `Manager.Observe`. These become signals in `lead_inputs` for every user who has a lead session: `inbox.created` (a question with its choices and recommendation, a report's text cut to 1500 characters, an error or rate limit), `inbox.updated` when a human answered a question or error, `task.created` (naming the TODO item it was started from, if any), `task.merged` and `task.discarded`. The human switching the right pane is not a signal. A signal caused by the lead itself (actor `<login> (lead)`) is skipped, as is the update from an answer it gave. A task's `task.created` event carries the actor that created it, separately from its `created_by` login: tasks the lead creates with `create_task` or `start_todo` keep the human's login as `created_by` but have actor `<login> (lead)`. Whenever the lead is idle, byre sends the oldest held message from the human as its own turn, or else every held signal as one `byre` message headed "Signals since your last turn (n)". Its guidance tells it not to respond to the human's own actions, and to make its final message exactly `NO_REPLY` when a batch needs nothing from it. A transcript (`quietReply` in `web/src/task.ts`) never draws an `agent.message` that is exactly `NO_REPLY`, hides the live partial message while its text is still a prefix of it, and when that turn ends removes the whole turn, from its `byre` message through anything the lead did, so nothing of it is drawn.
|
- **Signals.** Every published event goes through `Manager.Observe`. These become signals in `lead_inputs` for every user who has a lead session: `inbox.created` (a question with its choices and recommendation, a report's text cut to 1500 characters, an error or rate limit), `inbox.updated` when a human answered a question or error, `task.created` (naming the TODO item it was started from, if any), `task.merged` and `task.discarded`. The human switching the right pane is not a signal. A signal caused by the lead itself (actor `<login> (lead)`) is skipped, as is the update from an answer it gave. A task's `task.created` event carries the actor that created it, separately from its `created_by` login: tasks the lead creates with `create_task` or `start_todo` keep the human's login as `created_by` but have actor `<login> (lead)`. Whenever the lead is idle, byre sends the oldest held message from the human as its own turn, or else every held signal as one `byre` message headed "Signals since your last turn (n)". Its guidance tells it not to respond to the human's own actions, and to make its final message exactly `NO_REPLY` when a batch needs nothing from it. A transcript (`quietReply` in `web/src/task.ts`) never draws an `agent.message` that is exactly `NO_REPLY`, hides the live partial message while its text is still a prefix of it, and when that turn ends removes the whole turn, from its `byre` message through anything the lead did, so nothing of it is drawn.
|
||||||
- **Tools.** `list_tasks`, `read_transcript` (a task's events in brief, the last `limit` or those after `after_seq`), `read_inbox`, `answer_question` (an `option`, `other` or `dismiss` response recorded as the user, through the same checks as `POST /api/inbox/responses`), `send_message` (starts the task's agent if it has none; refused while it is mid-turn), `create_task`, `start_todo`, `interrupt`, `compact_task`, `merge_task`, `discard_task`, `show` and `compact_self`. The lead also has Claude Code's own tools and reads worktrees by path. Messages it sends task agents have actor `<login> (lead)`, so they don't make the user a participant; tasks, merges, discards and answers are done as the user. Its guidance says to answer a question only when the answer is clear or the human gave it, and to merge or discard only when the human asks.
|
- **Tools.** `list_tasks`, `read_transcript` (a task's events in brief, the last `limit` or those after `after_seq`), `read_inbox`, `answer_question` (an `option`, `other` or `dismiss` response recorded as the user, through the same checks as `POST /api/inbox/responses`), `send_message` (starts the task's agent if it has none; refused while it is mid-turn), `create_task`, `start_todo`, `interrupt`, `compact_task`, `merge_task`, `discard_task`, `show` and `compact_self`. The lead also has Claude Code's own tools and reads worktrees by path. Messages it sends task agents have actor `<login> (lead)`, so they don't make the user a participant; tasks, merges, discards and answers are done as the user. Its guidance says to answer a question only when the answer is clear or the human gave it, and to merge or discard only when the human asks.
|
||||||
- **Focus.** `users.focus` holds what the user's right pane shows: `project_id`, `task_id`, `tabs` (the tabs put first, in order) and `tab` (the selected one). With a `task_id`, `project_id` is always that task's project, filled in by the server; with only a `project_id`, the pane shows that project with no task, which only the Project tab has anything for. `GET /api/me/focus` reads it; `PUT /api/me/focus` and the lead's `show` set it, refusing an unknown tab, a task that isn't active or an unknown project, and record a `focus.updated` event owned by the user. The page applies the focus on load and on every `focus.updated` it didn't cause itself; the human's own changes (⌘K, the navbar crumb, clicking a tab, a task link, a notification) are sent with `PUT /api/me/focus`. The URL follows the focus: `#/task/<id>`, `#/project/<id>`, or `#/` for neither.
|
- **Focus.** `users.focus` holds what the user's right pane shows: `project_id`, `task_id`, `tabs` (the tabs put first, in order) and `tab` (the selected one). With a `task_id`, `project_id` is always that task's project, filled in by the server; with only a `project_id`, the pane shows that project with no task, which only the Project tab has anything for. `GET /api/me/focus` reads it; `PUT /api/me/focus` and the lead's `show` set it, refusing an unknown tab, a task that isn't active or an unknown project, and record a `focus.updated` event owned by the user. The page applies the focus on load and on every `focus.updated` it didn't cause itself; the human's own changes (⌘K, the navbar crumb, clicking a tab, a task link, a notification) are sent with `PUT /api/me/focus`. The URL follows the focus: `#/task/<id>`, `#/project/<id>`, or `#/` for neither.
|
||||||
- **Pane lock.** While the human is working in the right pane, the lead's `show` is refused with an error telling it to say in chat what it would have shown, unless the pane is finished (`Manager.paneFinished`): its task is merged or discarded, or its selected tab is `inbox` and the task has no open items. When an `inbox.updated`, `task.merged` or `task.discarded` event leaves a user's focused task finished that way, that user's signal for it gets a line "Your right pane is free: <task> has nothing left in its inbox." (or "is merged."/"is discarded."), even when the event itself makes no signal, such as a dismissed report; `inbox.updated` on a task that is no longer active adds none. The lead's guidance tells it to show the next thing that needs the human on that line, or the project's Project tab with `NO_REPLY` when nothing does. The page sends `PUT /api/me/pane-focus` with `focused` whenever that changes: true while the browser window has focus and the focused element is inside the right pane. The pane itself is focusable (`tabIndex` -1), so a click anywhere in it counts, not only on inputs and buttons; a re-render that removes the focused element hands focus back to the pane rather than counting as leaving. The page sends false on `pagehide` (with `keepalive`) and resends true when its event stream reconnects. byre keeps the flag in memory per user (`Manager.paneFocused`), so it starts false after a restart until the page resends it.
|
- **Pane lock.** A `show` of what the pane already shows (the same task, or the same project with no task, on the same selected tab; `sameView`) tells the lead so and applies only its `tabs` order, whether or not the human is in the pane, so the human's selected tab stays selected. Otherwise, while the human is working in the right pane, the lead's `show` is refused with an error telling it to say in chat what it would have shown, unless the pane is finished (`Manager.paneFinished`): its task is merged or discarded, or its selected tab is `inbox` and the task has no open items. When an `inbox.updated`, `task.merged` or `task.discarded` event leaves a user's focused task finished that way, that user's signal for it gets a line "Your right pane is free: <task> has nothing left in its inbox." (or "is merged."/"is discarded."), even when the event itself makes no signal, such as a dismissed report; `inbox.updated` on a task that is no longer active adds none. The lead's guidance tells it to show the next thing that needs the human on that line, or the project's Project tab with `NO_REPLY` when nothing does. The page sends `PUT /api/me/pane-focus` with `focused` whenever that changes: true while the browser window has focus and the focused element is inside the right pane. The pane itself is focusable (`tabIndex` -1), so a click anywhere in it counts, not only on inputs and buttons; a re-render that removes the focused element hands focus back to the pane rather than counting as leaving. The page sends false on `pagehide` (with `keepalive`) and resends true when its event stream reconnects. byre keeps the flag in memory per user (`Manager.paneFocused`), so it starts false after a restart until the page resends it.
|
||||||
- **Compaction.** The pane's **Compact** button asks the lead for a handoff (`leadHandoffPrompt`). The lead can also call `compact_self` at a logical point; when that turn ends normally, byre sends the same request. The handoff turn ends the session, and a new lead session starts from `leadHandoffPreamble` plus the handoff; held input follows once that turn ends.
|
- **Compaction.** The pane's **Compact** button asks the lead for a handoff (`leadHandoffPrompt`). The lead can also call `compact_self` at a logical point; when that turn ends normally, byre sends the same request. The handoff turn ends the session, and a new lead session starts from `leadHandoffPreamble` plus the handoff; held input follows once that turn ends.
|
||||||
- **Restarts.** On shutdown, byre marks each running lead session idle with "byre stopped mid-turn" and holds a restart notice for it, so a lead is never left `running` for a build without lead support to find. After a crash, startup resumes a running lead like a task session. Startup then delivers any held input.
|
- **Restarts.** On shutdown, byre marks each running lead session idle with "byre stopped mid-turn" and holds a restart notice for it, so a lead is never left `running` for a build without lead support to find. After a crash, startup resumes a running lead like a task session. Startup then delivers any held input.
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package agents_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -89,6 +90,22 @@ func TestLeadSignalsAndShow(t *testing.T) {
|
|||||||
t.Fatalf("focus moved while the pane was focused: %+v", f)
|
t.Fatalf("focus moved while the pane was focused: %+v", f)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_, err = m.MessageLead(e.ctx, e.user, "Call the show tool with task_id "+e.task.Id+", tabs [\"inbox\", \"changes\"] and tab \"inbox\". Then say done.", "", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("message lead: %v", err)
|
||||||
|
}
|
||||||
|
e.waitFor("show of what is already shown", func(ev api.Event) bool {
|
||||||
|
return ev.Kind == "agent.tool_result" && ev.Owner != nil && strings.Contains(fmt.Sprint(ev.Payload), "Already shown")
|
||||||
|
})
|
||||||
|
e.waitFor("lead idle after already-shown show", leadIdle)
|
||||||
|
f, err = e.store.GetFocus(e.ctx, login)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get focus: %v", err)
|
||||||
|
}
|
||||||
|
if f.Tab != api.TabInbox || !slices.Equal(f.Tabs, []api.TaskTab{api.TabInbox, api.TabChanges}) {
|
||||||
|
t.Fatalf("tabs not reordered on the shown tab: %+v", f)
|
||||||
|
}
|
||||||
|
|
||||||
err = e.store.WithdrawItem(e.ctx, created[len(created)-1].Payload["id"].(string))
|
err = e.store.WithdrawItem(e.ctx, created[len(created)-1].Payload["id"].(string))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("withdraw report: %v", err)
|
t.Fatalf("withdraw report: %v", err)
|
||||||
|
|||||||
@@ -295,7 +295,8 @@ func (m *Manager) leadTools(l *live) []mcp.Tool {
|
|||||||
"Point the human's right pane at a task, or at a project with no task: tabs are the tabs to put first, in order (the rest follow), and tab is the one to select. "+
|
"Point the human's right pane at a task, or at a project with no task: tabs are the tabs to put first, in order (the rest follow), and tab is the one to select. "+
|
||||||
"Tabs: transcript, changes, files, inbox (the task's open items), project (the project's dashboard: its active tasks and its TODO.md items); with only project_id, only project shows anything. "+
|
"Tabs: transcript, changes, files, inbox (the task's open items), project (the project's dashboard: its active tasks and its TODO.md items); with only project_id, only project shows anything. "+
|
||||||
"Omit both task_id and project_id to clear the pane. "+
|
"Omit both task_id and project_id to clear the pane. "+
|
||||||
"Refused while the human is working in the right pane, unless what it shows is finished (a merged or discarded task, or an inbox tab with nothing left).",
|
"If the pane already shows that task (or project) on that tab, only the tab order changes and you are told so, even while the human is working in the pane. "+
|
||||||
|
"Otherwise refused while the human is working in the right pane, unless what it shows is finished (a merged or discarded task, or an inbox tab with nothing left).",
|
||||||
`{"type": "object", "properties": {
|
`{"type": "object", "properties": {
|
||||||
"task_id": {"type": "string"},
|
"task_id": {"type": "string"},
|
||||||
"project_id": {"type": "string", "description": "Ignored when task_id is given."},
|
"project_id": {"type": "string", "description": "Ignored when task_id is given."},
|
||||||
@@ -308,14 +309,24 @@ func (m *Manager) leadTools(l *live) []mcp.Tool {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
m.mu.Lock()
|
|
||||||
focused := m.paneFocused[user.Login]
|
|
||||||
m.mu.Unlock()
|
|
||||||
if focused {
|
|
||||||
current, err := m.store.GetFocus(ctx, user.Login)
|
current, err := m.store.GetFocus(ctx, user.Login)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
if sameView(current, f) {
|
||||||
|
if slices.Equal(current.Tabs, f.Tabs) {
|
||||||
|
return "Already shown: the right pane shows that now, so nothing changed.", nil
|
||||||
|
}
|
||||||
|
err = m.SetFocus(ctx, user.Login, f, leadActor(user.Login))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return "Already shown on that tab; the tab order was updated.", nil
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
focused := m.paneFocused[user.Login]
|
||||||
|
m.mu.Unlock()
|
||||||
|
if focused {
|
||||||
finished, err := m.paneFinished(ctx, current)
|
finished, err := m.paneFinished(ctx, current)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -370,6 +381,17 @@ func (m *Manager) SetFocus(ctx context.Context, login string, f api.Focus, actor
|
|||||||
return m.store.SetFocus(ctx, login, f, actor)
|
return m.store.SetFocus(ctx, login, f, actor)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func sameView(a, b api.Focus) bool {
|
||||||
|
if a.Tab != b.Tab {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
at, bt := deref(a.TaskId), deref(b.TaskId)
|
||||||
|
if at != "" || bt != "" {
|
||||||
|
return at == bt
|
||||||
|
}
|
||||||
|
return deref(a.ProjectId) == deref(b.ProjectId)
|
||||||
|
}
|
||||||
|
|
||||||
const inboxEmptied = "has nothing left in its inbox"
|
const inboxEmptied = "has nothing left in its inbox"
|
||||||
|
|
||||||
func (m *Manager) paneFinished(ctx context.Context, f api.Focus) (string, error) {
|
func (m *Manager) paneFinished(ctx context.Context, f api.Focus) (string, error) {
|
||||||
|
|||||||
Reference in New Issue
Block a user