Human-facing task state
This commit is contained in:
@@ -19,7 +19,5 @@
|
||||
|
||||
# M7 — Inbox triage
|
||||
|
||||
- Human-facing task state
|
||||
Switcher and Project rows show one derived label instead of the raw session state, by precedence: "Needs your answer" (an open inbox item), "Merging" or "Merge held", "Error", "Working", "Idle".
|
||||
- Unread summary marker
|
||||
Switcher and Project rows show a "New summary" marker, with text and an accessible label, when a task has a summary the user hasn't read. Selecting it opens the Transcript at that summary. Read state is per browser and per user, by event seq; a summary counts as read only when shown in a visible transcript or through "Mark seen", not by opening another tab or switching tasks. Task carries its latest summary seq so the marker survives reload without loading transcripts.
|
||||
|
||||
+7
-5
@@ -53,7 +53,7 @@ Agents find closed tasks with the `search_closed_tasks` MCP tool, which task age
|
||||
|
||||
`sqlite3 byre.db "SELECT id, discard_state, discarding_by, discard_session FROM tasks WHERE discard_state != '' AND status = 'active'"` lists discards in progress. Confirming Discard in the UI points the right pane away from the task at once, while its summary turn runs.
|
||||
|
||||
A Task's `merging` is true while `merging_by` is set, and `discarding` while the task is active with `discard_state` set. Clearing `merging_by` emits `task.merge_ended` (not drawn); that, `task.merge_started`, `task.discard_started` and `task.discarded` reload the project's tasks in the UI, so the task switcher and the Project tab's task list show **merging** or **discarding**, with the running dot, in place of the session state (`taskState` in `web/src/state.ts`; discarding wins over merging, and both over **reviewing**).
|
||||
A Task's `merging` is true while `merging_by` is set, and `discarding` while the task is active with `discard_state` set. Clearing `merging_by` emits `task.merge_ended` (not drawn); that, `task.merge_started`, `task.discard_started` and `task.discarded` reload the project's tasks in the UI, so the task switcher and the Project tab show the task as **Merging**, or **Working** while discarding (see "Task state" under the task switcher).
|
||||
|
||||
`POST /api/tasks/{id}/merge-in` (the transcript's **Sync from main** button, labelled with the default branch) brings the default branch into the task branch through the agent: it sends `mergeInPrompt` (`internal/agents/mergein.go`) as a `byre` message to the task's session, or starts a session with it when the task has none. The prompt has the agent run `git merge --no-ff --no-commit`, resolve conflicts, regenerate generated files rather than hand-merging them, check the build and tests, and leave the merge uncommitted; byre's end-of-turn commit concludes it as a merge commit. If resolving the conflicts took substantive changes rather than combining both sides as written, the prompt tells the agent to stop there and end its turn describing them; the turn's snapshot then gets its review like any other (see "Reviews"). byre doesn't inspect the merge itself: whether a change is substantive, and whether to stop, is the agent's call. On a Merge turn the prompt adds that byre squashes and pushes when the turn ends, and tells the agent to call the `stop_merge` MCP tool first if it stops (see below). The prompt ends by showing the task's current merge title and asking the agent to set a better one with `set_merge_title` if it no longer describes all the work on the branch. It is refused (409) while a session is `running`.
|
||||
|
||||
@@ -77,7 +77,7 @@ A task's agent starts a subtask with the `create_subtask` MCP tool (`internal/ag
|
||||
- **Reporting.** When a subtask's turn ends with a summary (`turn.ended` with `summary: true`, see "Agent sessions"), byre sends the turn's final message to the parent's agent as a `byre` message headed "Your subtask … finished a turn" (`reportToParent`), queued like any message if the parent is mid-turn, or starting the parent's agent if it has none. Nothing is sent once the parent is closed, or while the subtask is merging into it.
|
||||
- **Merging into the parent.** `POST /api/tasks/{id}/merge` with `target: parent` (the Merge dialog's "Into <parent>" choice, the lead's `merge_task` with `target: parent`, or `merge_subtask`) is refused when the task isn't a subtask (400), while its session is `running`, while it is merging into the default branch, when its parent is closed, when it has subtasks of its own, or (checked in `Store.SetParentMerge`'s transaction) while it is being discarded or already merging into its parent (409). Otherwise it records the requesting user in `tasks.parent_merge_by` (`merging_into_parent` on the Task; a `task.parent_merge_started` event, drawn as a note) and sends the parent's agent `mergeInPrompt` for the subtask's branch as a `byre` message, so the parent runs `git merge --no-ff --no-commit byre/<subtask>` and byre's end-of-turn commit concludes it. If sending fails, the flag is cleared (`task.parent_merge_stopped`). After every turn end of the parent's session, `closeMergedSubtasks` checks each of its subtasks with the flag set: once `git merge-base --is-ancestor byre/<subtask> HEAD` holds in the parent's worktree, byre removes the subtask like any closed task and marks it merged as that user, with the parent's `HEAD` as `merge_commit`; its `task.merged` event carries `parent_task_id` instead of `pushed`. A subtask that gets more commits after the request stays open until the parent merges those too. The Merge button reads **Merging into parent**, disabled, while the flag is set.
|
||||
- **Merging into the default branch.** `target: main` (the default) is the ordinary Merge, squashing the subtask onto the default branch and pushing it.
|
||||
- **UI.** The Project tab's task list draws each subtask under its parent, indented by depth with a ↳ mark whose tooltip names the parent; the ⌘K switcher prefixes a subtask's title with "<parent title> ↳". Both show **merging into parent** while the flag is set. A subtask's transcript opens with the note "subtask of <parent>", linking the parent while it is active. The Merge dialog for a subtask whose parent is active offers "Into <parent title>" (selected first; the commit title and co-authors are hidden, since the parent's own commit holds the merge) and "Into <default branch>".
|
||||
- **UI.** The Project tab's task list draws each subtask under its parent, indented by depth with a ↳ mark whose tooltip names the parent; the ⌘K switcher prefixes a subtask's title with "<parent title> ↳". Both show **Merging** while the flag is set, with a tooltip saying it waits for the parent's agent. A subtask's transcript opens with the note "subtask of <parent>", linking the parent while it is active. The Merge dialog for a subtask whose parent is active offers "Into <parent title>" (selected first; the commit title and co-authors are hidden, since the parent's own commit holds the merge) and "Into <default branch>".
|
||||
|
||||
## Running as a service
|
||||
|
||||
@@ -171,7 +171,9 @@ Messages byre itself sends an agent (actor `byre`: the restart notice, Sync and
|
||||
|
||||
**Drafts.** Unsent composer text (task and lead) and unsent inbox answers (checked choices and Other/Reply text) are kept in memory by `web/src/drafts.ts` and mirrored to the browser's localStorage under `byre.drafts.<login>`, one JSON object with `composer` (keyed by `message:<task id>` or `message:lead`, each `{text, images}`), `checked` and `texts` (both keyed by inbox item id). Writes are debounced and flushed at once when a composer or answer box loses focus, when the transcript view is disposed, and on `pagehide`. A composer draft is cleared only once its send succeeds, and an inbox answer only once its response is accepted; each send in flight is tracked on its own and the stored draft holds the text of every unconfirmed send (oldest first, then the composer's current text, separated by blank lines) and counts its images, so a reload mid-send brings back everything not yet confirmed. A failed send's text goes back into the composer ahead of whatever is there now (text typed since, or earlier failures), separated by a blank line, rather than replacing it. Drafts for tasks that are no longer active and for inbox items no longer open are pruned whenever the task list or inbox reloads. A storage read that throws or holds malformed JSON (or entries of the wrong shape) is ignored, and a write that throws is logged; drafts then live only in memory. Pending images are not stored, only their count: after a reload the composer shows how many need re-attaching, until dismissed or the next successful send.
|
||||
|
||||
The task switcher (⌘K, or the project ▸ task picker centred in the right pane's top row) lists active tasks with their session state and open inbox item count (the Project tab's task list shows the same state), and **merge held** on a task with `merge_queued_at`, live while it is open. Tasks with inbox items come first; within each group, the task least recently visited in this browser comes first (never-visited tasks before all others, ties in the server's order: projects by name, tasks newest first). Visit times are kept in the browser's localStorage under `byre.visits`, stamped when a task is selected and again when it is left. After the tasks come the projects, one row each; picking one points the right pane at the project with no task, on the Project tab. Typing filters both and adds a last row that creates a task with the typed text as its title, in the right pane's project (the first project when there is none); Enter picks the highlighted row. Text before a first colon that starts at least one project's name (ignoring case, no spaces) picks those projects instead: `by: fix` lists only their tasks whose titles contain "fix", their project rows that match, and one row per project creating the task titled "fix" there. When no project name starts with it, the whole text is the title, so "Fix: login" stays as typed. A create row shows `models[0]` from `web/src/task.ts`, the model the composer offers first for the new task's first message; the task itself starts with no session. Picking a task points the right pane at it on the Inbox tab when it has open inbox items, otherwise on the Transcript tab (see "Lead agent").
|
||||
The task switcher (⌘K, or the project ▸ task picker centred in the right pane's top row) lists active tasks with their task state and open inbox item count (the Project tab shows the same state), live while it is open. Tasks with inbox items come first; within each group, the task least recently visited in this browser comes first (never-visited tasks before all others, ties in the server's order: projects by name, tasks newest first). Visit times are kept in the browser's localStorage under `byre.visits`, stamped when a task is selected and again when it is left. After the tasks come the projects, one row each; picking one points the right pane at the project with no task, on the Project tab. Typing filters both and adds a last row that creates a task with the typed text as its title, in the right pane's project (the first project when there is none); Enter picks the highlighted row. Text before a first colon that starts at least one project's name (ignoring case, no spaces) picks those projects instead: `by: fix` lists only their tasks whose titles contain "fix", their project rows that match, and one row per project creating the task titled "fix" there. When no project name starts with it, the whole text is the title, so "Fix: login" stays as typed. A create row shows `models[0]` from `web/src/task.ts`, the model the composer offers first for the new task's first message; the task itself starts with no session. Picking a task points the right pane at it on the Inbox tab when it has open inbox items, otherwise on the Transcript tab (see "Lead agent").
|
||||
|
||||
**Task state.** The switcher and the Project tab show one label per task in place of its raw session state, the first that applies (`taskState` in `web/src/state.ts`), with a dot and a tooltip saying why: **Needs your answer** (an open `question` inbox item), **Merging** (`merging` or `merging_into_parent`), **Merge held** (`merge_queued_at`), **Error** (an open `session_error` or `rate_limited` inbox item), **Working** (the session is `running`, a review is running, or the task is discarding), **Idle** (anything else, including no session). The Project tab, in its task list and TODO items, draws a spinner in place of the dot for **Working** and **Merging**. The label follows the tasks, sessions and inbox state the page already holds, so it needs no extra fetch.
|
||||
|
||||
**Task colours.** Each task has a colour, `tasks.color` (`color` in the API), an index into the palette of `--task-0` … `--task-N` custom properties at the top of `web/style.css`, one set for light and one for dark, all at least 4.5:1 against the backgrounds so they work as text. `CreateTask` assigns it in the same transaction as the task's row: of the `store.TaskColors` colours, one no active task holds, and among those the one given out longest ago (the lowest `max(rowid)` of tasks with that colour, never-used first), so a new task doesn't take the colour of one just closed. Active tasks have distinct colours until more than `TaskColors` are active at once. `TaskColors` must match the number of palette entries. An element styled with `taskStyle(id)` (`web/src/taskcolor.ts`) sets `--task` for itself and its children from the task's `color`; `--task` defaults to the accent, which is also what a task the page doesn't know gets. The right pane sets it for the selected task, which tints the picker's row, colours the task picker's text, the selected tab's underline, the composer's border and the running border and scanner. Everywhere a task is named it shows in its colour: task titles in the switcher, the Project tab and the Status tab (`.task-name`), and task links (`a.task-link`), including any Markdown link to `#/task/<id>` or `#/task/<id>/<tab>`. The lead's guidance tells it to link every task it names that way, using `#/task/<id>/inbox` when it points the human at the task's inbox, and clicking one points the right pane at the task, selecting the named tab if there is one.
|
||||
|
||||
@@ -198,7 +200,7 @@ A project's `TODO.md` is read from the root of its default branch in the project
|
||||
|
||||
The title is the item's identity. `POST /api/projects/{id}/todo/tasks` with a `title` re-reads `TODO.md`, refuses an unknown title (404) or one an active task already has (409), creates a task from the default branch with that title and `todo_item` (the `tasks.todo_item` column, empty for other tasks), and starts its session as the requesting user with `todoPrompt` (`internal/agents/tasks.go`): the item as written, and an instruction to remove it from `TODO.md` in the worktree when the work is done. With `discuss: true` the prompt adds that the human wants to discuss the item first: the agent reads what it needs, ends its turn with its thoughts on the item (its reading, approach, trade-offs and doubts) in its final message rather than `ask_human` questions, carries on the conversation in chat, and changes nothing until told to build it. The agent's guidance names the item too (`taskGuidance`, see "Agent sessions"), so the instruction survives compaction and resumes. The agent removes the item on its own branch, so the removal lands on the default branch in the same squash commit as the work.
|
||||
|
||||
The **Project** tab is the dashboard of the right pane's project (the focused task's, or the project focused with no task). Under **Tasks** it lists the project's active tasks in the server's order (newest first), each with its session state, open inbox item count and **merge held** when it has `merge_queued_at`, as links that point the right pane at the task's transcript; the focused task is highlighted. Under **TODO** it lists the `TODO.md` items, grouped by section, with each body rendered as Markdown. An item with an active task whose `todo_item` equals its title shows that task's session state as a link to the task instead of the **Start** and **Discuss** buttons. **Start** posts the item with the composer's default model; **Discuss** posts it the same way with `discuss: true`. Neither moves the right pane (see "Start hold"). The task list follows byre's live state; the tab fetches the project's `TODO.md` when it opens, when the right pane moves to another project, when the project list changes, when the SSE stream reconnects, and on `task.merged`; a change pushed to the default branch outside byre shows the next time one of those happens.
|
||||
The **Project** tab is the dashboard of the right pane's project (the focused task's, or the project focused with no task). Under **Tasks** it lists the project's active tasks in the server's order (newest first), each with its task state (see "Task state") and open inbox item count, as links that point the right pane at the task's transcript; the focused task is highlighted. Under **TODO** it lists the `TODO.md` items, grouped by section, with each body rendered as Markdown. An item with an active task whose `todo_item` equals its title shows that task's state as a link to the task instead of the **Start** and **Discuss** buttons. **Start** posts the item with the composer's default model; **Discuss** posts it the same way with `discuss: true`. Neither moves the right pane (see "Start hold"). The task list follows byre's live state; the tab fetches the project's `TODO.md` when it opens, when the right pane moves to another project, when the project list changes, when the SSE stream reconnects, and on `task.merged`; a change pushed to the default branch outside byre shows the next time one of those happens.
|
||||
|
||||
## Inbox
|
||||
|
||||
@@ -287,7 +289,7 @@ Every new snapshot of a task gets a review: a separate agent session on the vend
|
||||
- **Screenshots.** Reviewer sessions also get the Playwright MCP server (`playwrightMCP` in `internal/agents/review.go`: `npx @playwright/mcp` at a pinned version, headless Chrome, isolated profile, absolute file paths), passed to Claude as a `stdio` entry in `--mcp-config` and to Codex as `mcp_servers` in the thread's `config`. Its automatically named files go to `worktrees/<project-id>/review-<review-id>-media`, removed with the checkout. Each `images` entry is `{path, caption}`, the path absolute or relative to the checkout; at `submit_review` byre reads every one, refuses the whole submission (nothing recorded) for a missing file or one that isn't one of `store.ImageTypes` within `store.MaxBlobBytes`, then stores each as a blob and replaces its `path` with `blob`, since the files are deleted with the checkout. They run without the Stop-hook bounce, mid-turn delivery, end-of-turn commit, snapshots or turn summaries, and their `user.message` events carry no `head`. When the reviewer's turn ends byre completes its session, kills its process and removes the worktree; a review still `running` then fails with why (no submission, with the final message; an error; an interrupt). A process that exits mid-turn fails the review the same way. Merging or discarding the task fails its running reviews ("the task was closed") and ends their reviewers. On startup, a running reviewer resumes like any session, and a running review whose reviewer isn't `running` fails.
|
||||
- **Questions.** A question's `reviews` is the review of the first snapshot the task took after the question was filed, the one at the end of the turn that asked it, computed when the item is read; the Inbox tab shows it under the question once it starts.
|
||||
- **Events.** `review.started`, `review.finished` (state `done`, `failed` or `skipped`; a skipped review emits only `review.finished`) and `review.finding` (a finding's state set, with the user as actor) carry the whole review, with the task's ids and no `agent_session_id`. A reviewer's own events carry its session id and the task's ids: the transcript skips every event of a session whose `session.created` has `role` `reviewer`, and draws each `review.finished` as a note with its outcome; the lead's `read_transcript` leaves the reviewer's events out and shows each `review.finished` with its summary and findings (`reviewLine`). `GET /api/tasks/{taskId}/sessions` and everything that looks for the task's agent (`ListSessions`) see only implementer sessions.
|
||||
- **UI.** The **Reviews** tab (`web/src/reviews.ts`) shows one review at a time, picked by snapshot (see "Snapshots" for the picker): its snapshot, state or severity counts, error, summary, screenshots and findings. The `change` field is not shown; it goes to the lead and into notes. A finding with `path` and `line` shows, under its title, the lines `line` to `end_line` plus 3 either side from that file's diff for the snapshot's step (`changes?snapshot=<n>&step=true`, the diff the reviewer was given), with new-file line numbers and the finding's own lines marked; it shows nothing when those lines aren't in the diff, and "diff not available" with the error once the task is closed. Screenshots link to the full image. Each open finding has **fix** and **ignore** buttons (also on review cards in the inbox). **fix** sends the finding's severity, title, location and detail to the task's agent as a message from the user: through `POST /api/sessions/{id}/messages` to its latest live session (queued like any message while the agent is mid-turn, see "Agent sessions"), or starting a session when it has none; the view stays where it is, so several findings can be sent in a row. Then it, or **ignore** on its own, records the finding's state (`fixed` or `ignored`) with `PUT /api/reviews/{reviewId}/findings/{index}`, the index being the finding's position in `result.findings`; the review's `finding_states` (the `reviews.finding_states` column, a JSON object from index to state) holds them, and the buttons give way to "fix sent" or "ignored". Only a `done` review's findings take a state, and there is no way to clear one. The tab loads when it opens, when the task changes, and on each `review.*` event in the task's stream. The tab follows the latest `review.*` event of the highest-numbered snapshot: while that review runs, the tab label (and its entry in the More menu) carries a spinner in place of any earlier review's count; once it finishes with open findings (those with no state), the tab (or the More menu holding it) is highlighted with a badge of their count. Each snapshot row's **review** button carries a mark over its top-right corner from that snapshot's latest `review.*` event: a spinner while the review runs, a grey ● when it was skipped, a green ✓ when it finished with no open findings, and, when it has open findings, the button highlighted with their count in a badge. A failed review leaves the button unmarked. A Task's `reviewing` is true while any of its reviews is `running`, and the global stream's `review.*` events reload that project's tasks; the task switcher shows **reviewing** with the running dot, and the Project tab's task list with the spinner it gives every busy task (`running`, `reviewing`, `merging`, `discarding`, in its task list and TODO items), in place of an `idle` session state (`taskState` in `web/src/state.ts`). `GET /api/tasks/{taskId}/reviews` lists a task's reviews oldest first. `sqlite3 byre.db "SELECT id, task_id, snapshot_n, vendor, state, error FROM reviews ORDER BY created_at DESC LIMIT 20"` shows recent ones.
|
||||
- **UI.** The **Reviews** tab (`web/src/reviews.ts`) shows one review at a time, picked by snapshot (see "Snapshots" for the picker): its snapshot, state or severity counts, error, summary, screenshots and findings. The `change` field is not shown; it goes to the lead and into notes. A finding with `path` and `line` shows, under its title, the lines `line` to `end_line` plus 3 either side from that file's diff for the snapshot's step (`changes?snapshot=<n>&step=true`, the diff the reviewer was given), with new-file line numbers and the finding's own lines marked; it shows nothing when those lines aren't in the diff, and "diff not available" with the error once the task is closed. Screenshots link to the full image. Each open finding has **fix** and **ignore** buttons (also on review cards in the inbox). **fix** sends the finding's severity, title, location and detail to the task's agent as a message from the user: through `POST /api/sessions/{id}/messages` to its latest live session (queued like any message while the agent is mid-turn, see "Agent sessions"), or starting a session when it has none; the view stays where it is, so several findings can be sent in a row. Then it, or **ignore** on its own, records the finding's state (`fixed` or `ignored`) with `PUT /api/reviews/{reviewId}/findings/{index}`, the index being the finding's position in `result.findings`; the review's `finding_states` (the `reviews.finding_states` column, a JSON object from index to state) holds them, and the buttons give way to "fix sent" or "ignored". Only a `done` review's findings take a state, and there is no way to clear one. The tab loads when it opens, when the task changes, and on each `review.*` event in the task's stream. The tab follows the latest `review.*` event of the highest-numbered snapshot: while that review runs, the tab label (and its entry in the More menu) carries a spinner in place of any earlier review's count; once it finishes with open findings (those with no state), the tab (or the More menu holding it) is highlighted with a badge of their count. Each snapshot row's **review** button carries a mark over its top-right corner from that snapshot's latest `review.*` event: a spinner while the review runs, a grey ● when it was skipped, a green ✓ when it finished with no open findings, and, when it has open findings, the button highlighted with their count in a badge. A failed review leaves the button unmarked. A Task's `reviewing` is true while any of its reviews is `running`, and the global stream's `review.*` events reload that project's tasks; the task switcher and the Project tab show such a task as **Working** (see "Task state"). `GET /api/tasks/{taskId}/reviews` lists a task's reviews oldest first. `sqlite3 byre.db "SELECT id, task_id, snapshot_n, vendor, state, error FROM reviews ORDER BY created_at DESC LIMIT 20"` shows recent ones.
|
||||
|
||||
## Workflows
|
||||
|
||||
|
||||
Vendored
+8
-9
@@ -2,7 +2,7 @@ import { get, post } from "./api.js";
|
||||
import { errorText, h, replace, showAlert } from "./dom.js";
|
||||
import { markdown } from "./markdown.js";
|
||||
import { models } from "./task.js";
|
||||
import { currentSession, findTask, focusedProject, focusTask, on, onEach, state, taskState } from "./state.js";
|
||||
import { findTask, focusedProject, focusTask, on, onEach, state, taskState } from "./state.js";
|
||||
import { taskStyle } from "./taskcolor.js";
|
||||
const refreshKinds = new Set(["task.merged", "project.created"]);
|
||||
export function mountProject(root) {
|
||||
@@ -100,12 +100,11 @@ function nested(active) {
|
||||
}
|
||||
return out;
|
||||
}
|
||||
const busyStates = new Set(["running", "reviewing", "merging", "discarding"]);
|
||||
function stateMark(s) {
|
||||
if (s !== undefined && busyStates.has(s)) {
|
||||
return h("span", { class: "spinner", title: s });
|
||||
function stateMark(ts) {
|
||||
if (ts.kind === "working" || ts.kind === "merging") {
|
||||
return h("span", { class: "spinner", title: ts.title });
|
||||
}
|
||||
return h("span", { class: `dot ${s ?? "none"}`, title: s ?? "no session" });
|
||||
return h("span", { class: `dot ${ts.kind}`, title: ts.title });
|
||||
}
|
||||
function tasksBlock(active) {
|
||||
if (active.length === 0) {
|
||||
@@ -121,7 +120,7 @@ function tasksBlock(active) {
|
||||
e.preventDefault();
|
||||
focusTask(task.id);
|
||||
},
|
||||
}, task.parent_task_id !== undefined && h("span", { class: "subtask-mark", title: `Subtask of ${findTask(task.parent_task_id)?.title ?? task.parent_task_id}` }, "↳"), stateMark(ts), h("span", { class: "task-title task-name" }, task.title), count > 0 && h("span", { class: "badge", title: "inbox items" }, count), task.merge_queued_at !== undefined && h("span", { class: "task-state warn", title: "Waiting for the merge in progress in this project to finish" }, "merge held"), task.merging_into_parent && h("span", { class: "task-state warn", title: "Waiting for the parent task's agent to merge it" }, "merging into parent"), h("span", { class: "task-state" }, ts ?? "no agent")));
|
||||
}, task.parent_task_id !== undefined && h("span", { class: "subtask-mark", title: `Subtask of ${findTask(task.parent_task_id)?.title ?? task.parent_task_id}` }, "↳"), stateMark(ts), h("span", { class: "task-title task-name" }, task.title), count > 0 && h("span", { class: "badge", title: "inbox items" }, count), h("span", { class: `task-state ${ts.kind}`, title: ts.title }, ts.label)));
|
||||
}));
|
||||
}
|
||||
function todoBlock(project, todo, active, starting, start) {
|
||||
@@ -152,7 +151,7 @@ function todoBlock(project, todo, active, starting, start) {
|
||||
const task = active.find((t) => t.todo_item === item.title);
|
||||
let action;
|
||||
if (task) {
|
||||
const sess = currentSession(task.id);
|
||||
const ts = taskState(task);
|
||||
action = h("a", {
|
||||
class: "todo-task",
|
||||
href: `#/task/${task.id}`,
|
||||
@@ -160,7 +159,7 @@ function todoBlock(project, todo, active, starting, start) {
|
||||
e.preventDefault();
|
||||
focusTask(task.id);
|
||||
},
|
||||
}, stateMark(sess?.state), sess?.state ?? "no agent");
|
||||
}, stateMark(ts), ts.label);
|
||||
}
|
||||
else {
|
||||
const busy = starting.has(`${project.id}\n${item.title}`);
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+17
-5
@@ -55,14 +55,26 @@ export function currentSession(taskId) {
|
||||
return state.sessions.get(taskId)?.findLast((s) => s.state !== "completed") ?? state.sessions.get(taskId)?.at(-1);
|
||||
}
|
||||
export function taskState(task) {
|
||||
if (task.discarding) {
|
||||
return "discarding";
|
||||
const open = state.inbox.filter((item) => item.task_id === task.id);
|
||||
if (open.some((item) => item.kind === "question")) {
|
||||
return { kind: "answer", label: "Needs your answer", title: "The agent asked a question in the inbox" };
|
||||
}
|
||||
if (task.merging) {
|
||||
return "merging";
|
||||
return { kind: "merging", label: "Merging", title: "The merge turn is running" };
|
||||
}
|
||||
const s = currentSession(task.id)?.state;
|
||||
return s === "idle" && task.reviewing ? "reviewing" : s;
|
||||
if (task.merging_into_parent) {
|
||||
return { kind: "merging", label: "Merging", title: "Waiting for the parent task's agent to merge it" };
|
||||
}
|
||||
if (task.merge_queued_at !== undefined) {
|
||||
return { kind: "held", label: "Merge held", title: "Waiting for the merge in progress in this project to finish" };
|
||||
}
|
||||
if (open.length > 0) {
|
||||
return { kind: "error", label: "Error", title: "The agent's session failed or hit a rate limit; see the inbox" };
|
||||
}
|
||||
if (task.discarding || task.reviewing || currentSession(task.id)?.state === "running") {
|
||||
return { kind: "working", label: "Working", title: task.discarding ? "Discarding" : task.reviewing ? "A review is running" : "The agent is mid-turn" };
|
||||
}
|
||||
return { kind: "idle", label: "Idle", title: "Waiting for a message" };
|
||||
}
|
||||
export function currentLead() {
|
||||
return state.leadSessions.findLast((s) => s.state !== "completed") ?? state.leadSessions.at(-1);
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -132,7 +132,7 @@ export function openSwitcher() {
|
||||
.sort((a, b) => Number(b.count > 0) - Number(a.count > 0) || a.visited - b.visited);
|
||||
rows = matches.map(({ project, task, count }) => {
|
||||
const ts = taskState(task);
|
||||
const el = h("li", { style: taskStyle(task.id) }, h("span", { class: `dot ${ts ?? "none"}`, title: ts ?? "no session" }), h("span", { class: "task-title" }, h("span", { class: "project-name" }, project.name), " ", task.parent_task_id !== undefined && h("span", { class: "subtask-mark" }, `${findTask(task.parent_task_id)?.title ?? task.parent_task_id} ↳ `), h("span", { class: "task-name" }, task.title)), count > 0 && h("span", { class: "badge", title: "inbox items" }, count), task.merge_queued_at !== undefined && h("span", { class: "task-state warn", title: "Waiting for the merge in progress in this project to finish" }, "merge held"), task.merging_into_parent && h("span", { class: "task-state warn", title: "Waiting for the parent task's agent to merge it" }, "merging into parent"), h("span", { class: "task-state" }, ts ?? "no agent"));
|
||||
const el = h("li", { style: taskStyle(task.id) }, h("span", { class: `dot ${ts.kind}`, title: ts.title }), h("span", { class: "task-title" }, h("span", { class: "project-name" }, project.name), " ", task.parent_task_id !== undefined && h("span", { class: "subtask-mark" }, `${findTask(task.parent_task_id)?.title ?? task.parent_task_id} ↳ `), h("span", { class: "task-name" }, task.title)), count > 0 && h("span", { class: "badge", title: "inbox items" }, count), h("span", { class: `task-state ${ts.kind}`, title: ts.title }, ts.label));
|
||||
return { el, pick: () => go(task.id) };
|
||||
});
|
||||
for (const p of (targets ?? state.projects).filter((p) => `${p.name} project`.toLowerCase().includes(q))) {
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+9
-13
@@ -2,7 +2,7 @@ import { get, post, type ByreEvent, type Project, type Schemas, type Task } from
|
||||
import { errorText, h, replace, showAlert } from "./dom.js";
|
||||
import { markdown } from "./markdown.js";
|
||||
import { models } from "./task.js";
|
||||
import { currentSession, findTask, focusedProject, focusTask, on, onEach, state, taskState } from "./state.js";
|
||||
import { findTask, focusedProject, focusTask, on, onEach, state, taskState, type TaskState } from "./state.js";
|
||||
import { taskStyle } from "./taskcolor.js";
|
||||
|
||||
type Todo = Schemas["Todo"];
|
||||
@@ -121,13 +121,11 @@ function nested(active: Task[]): { task: Task; depth: number }[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
const busyStates = new Set(["running", "reviewing", "merging", "discarding"]);
|
||||
|
||||
function stateMark(s: string | undefined): HTMLElement {
|
||||
if (s !== undefined && busyStates.has(s)) {
|
||||
return h("span", { class: "spinner", title: s });
|
||||
function stateMark(ts: TaskState): HTMLElement {
|
||||
if (ts.kind === "working" || ts.kind === "merging") {
|
||||
return h("span", { class: "spinner", title: ts.title });
|
||||
}
|
||||
return h("span", { class: `dot ${s ?? "none"}`, title: s ?? "no session" });
|
||||
return h("span", { class: `dot ${ts.kind}`, title: ts.title });
|
||||
}
|
||||
|
||||
function tasksBlock(active: Task[]): HTMLElement {
|
||||
@@ -157,9 +155,7 @@ function tasksBlock(active: Task[]): HTMLElement {
|
||||
stateMark(ts),
|
||||
h("span", { class: "task-title task-name" }, task.title),
|
||||
count > 0 && h("span", { class: "badge", title: "inbox items" }, count),
|
||||
task.merge_queued_at !== undefined && h("span", { class: "task-state warn", title: "Waiting for the merge in progress in this project to finish" }, "merge held"),
|
||||
task.merging_into_parent && h("span", { class: "task-state warn", title: "Waiting for the parent task's agent to merge it" }, "merging into parent"),
|
||||
h("span", { class: "task-state" }, ts ?? "no agent"),
|
||||
h("span", { class: `task-state ${ts.kind}`, title: ts.title }, ts.label),
|
||||
),
|
||||
);
|
||||
}),
|
||||
@@ -196,7 +192,7 @@ function todoBlock(project: Project, todo: Todo | string | undefined, active: Ta
|
||||
const task = active.find((t) => t.todo_item === item.title);
|
||||
let action: HTMLElement;
|
||||
if (task) {
|
||||
const sess = currentSession(task.id);
|
||||
const ts = taskState(task);
|
||||
action = h(
|
||||
"a",
|
||||
{
|
||||
@@ -207,8 +203,8 @@ function todoBlock(project: Project, todo: Todo | string | undefined, active: Ta
|
||||
focusTask(task.id);
|
||||
},
|
||||
},
|
||||
stateMark(sess?.state),
|
||||
sess?.state ?? "no agent",
|
||||
stateMark(ts),
|
||||
ts.label,
|
||||
);
|
||||
} else {
|
||||
const busy = starting.has(`${project.id}\n${item.title}`);
|
||||
|
||||
+20
-6
@@ -84,15 +84,29 @@ export function currentSession(taskId: string): AgentSession | undefined {
|
||||
return state.sessions.get(taskId)?.findLast((s) => s.state !== "completed") ?? state.sessions.get(taskId)?.at(-1);
|
||||
}
|
||||
|
||||
export function taskState(task: Task): string | undefined {
|
||||
if (task.discarding) {
|
||||
return "discarding";
|
||||
export type TaskState = { kind: "answer" | "merging" | "held" | "error" | "working" | "idle"; label: string; title: string };
|
||||
|
||||
export function taskState(task: Task): TaskState {
|
||||
const open = state.inbox.filter((item) => item.task_id === task.id);
|
||||
if (open.some((item) => item.kind === "question")) {
|
||||
return { kind: "answer", label: "Needs your answer", title: "The agent asked a question in the inbox" };
|
||||
}
|
||||
if (task.merging) {
|
||||
return "merging";
|
||||
return { kind: "merging", label: "Merging", title: "The merge turn is running" };
|
||||
}
|
||||
const s = currentSession(task.id)?.state;
|
||||
return s === "idle" && task.reviewing ? "reviewing" : s;
|
||||
if (task.merging_into_parent) {
|
||||
return { kind: "merging", label: "Merging", title: "Waiting for the parent task's agent to merge it" };
|
||||
}
|
||||
if (task.merge_queued_at !== undefined) {
|
||||
return { kind: "held", label: "Merge held", title: "Waiting for the merge in progress in this project to finish" };
|
||||
}
|
||||
if (open.length > 0) {
|
||||
return { kind: "error", label: "Error", title: "The agent's session failed or hit a rate limit; see the inbox" };
|
||||
}
|
||||
if (task.discarding || task.reviewing || currentSession(task.id)?.state === "running") {
|
||||
return { kind: "working", label: "Working", title: task.discarding ? "Discarding" : task.reviewing ? "A review is running" : "The agent is mid-turn" };
|
||||
}
|
||||
return { kind: "idle", label: "Idle", title: "Waiting for a message" };
|
||||
}
|
||||
|
||||
export function currentLead():AgentSession | undefined {
|
||||
|
||||
+2
-4
@@ -148,7 +148,7 @@ export function openSwitcher(): void {
|
||||
const el = h(
|
||||
"li",
|
||||
{ style: taskStyle(task.id) },
|
||||
h("span", { class: `dot ${ts ?? "none"}`, title: ts ?? "no session" }),
|
||||
h("span", { class: `dot ${ts.kind}`, title: ts.title }),
|
||||
h(
|
||||
"span",
|
||||
{ class: "task-title" },
|
||||
@@ -158,9 +158,7 @@ export function openSwitcher(): void {
|
||||
h("span", { class: "task-name" }, task.title),
|
||||
),
|
||||
count > 0 && h("span", { class: "badge", title: "inbox items" }, count),
|
||||
task.merge_queued_at !== undefined && h("span", { class: "task-state warn", title: "Waiting for the merge in progress in this project to finish" }, "merge held"),
|
||||
task.merging_into_parent && h("span", { class: "task-state warn", title: "Waiting for the parent task's agent to merge it" }, "merging into parent"),
|
||||
h("span", { class: "task-state" }, ts ?? "no agent"),
|
||||
h("span", { class: `task-state ${ts.kind}`, title: ts.title }, ts.label),
|
||||
);
|
||||
return { el, pick: () => go(task.id) };
|
||||
});
|
||||
|
||||
+11
-6
@@ -568,18 +568,22 @@ a.task-link {
|
||||
}
|
||||
|
||||
.dot.running,
|
||||
.dot.reviewing,
|
||||
.dot.merging,
|
||||
.dot.discarding {
|
||||
.dot.working,
|
||||
.dot.merging {
|
||||
background: var(--ok);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.dot.queued {
|
||||
.dot.answer {
|
||||
background: var(--warn);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.dot.error {
|
||||
background: var(--danger);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
@@ -2301,14 +2305,15 @@ button.danger-primary {
|
||||
|
||||
.rl-status.warn,
|
||||
.rl-windows .warn,
|
||||
.task-state.warn {
|
||||
.task-state.answer {
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
.rl-status.danger,
|
||||
.rl-windows .danger,
|
||||
.problems .danger,
|
||||
.task-state.danger {
|
||||
.task-state.danger,
|
||||
.task-state.error {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user