This commit is contained in:
Ian Gulliver
2026-10-15 22:08:08 -07:00
parent 6424caaf4b
commit 0c698cd3c4
5 changed files with 28 additions and 34 deletions
+3 -3
View File
@@ -135,9 +135,9 @@ Event payloads are stored whole in `events.payload`, and `agent.tool_result` con
Messages byre itself sends an agent (actor `byre`: the restart notice, Sync and Merge prompts, Compact's handoff request) show collapsed like a tool call, labelled `byre` with the first line as the brief; expanding it shows the full text. The transcript view has a border in the task's colour (see "Task colours"; the accent in the lead pane) while the session is `running` and its transcript has an open turn (`ctx.open`: a drawn turn-starting `user.message`, not mid-turn, with no `turn.ended` after it), and a braille spinner on its last line in the same colour, cycling ⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏ every 0.8s. It is one element with one CSS animation: the ten frames are drawn side by side in an inline SVG used as the `.spinner` mask over a coloured background, and the `braille` keyframes in `web/style.css` step the mask position through them. Orion freezes animated GIFs and per-dot `step-end` opacity animations partway through a turn, so it stays a single element with a single animation. The session list (`GET /api/me/lead/sessions` or the task's sessions) is fetched separately from the event stream, so it says `running` a little before the turn's `user.message` arrives and a little after its `turn.ended`; requiring the open turn as well keeps the spinner and border from flashing on in those gaps. Consecutive tool calls (with any thinking between them) collapse into one group whose summary counts calls per tool and file, and file paths show relative to the worktree. byre's own tools show without their `mcp__byre__` prefix, in title case (`mcp__byre__create_task` shows as Create Task; `toolLabel` in `web/src/task.ts`); other tools show under the name the agent used. A Claude Code subagent call (the `Agent` or `Task` tool) stands on its own, collapsed, labelled with its `subagent_type` and `description`; expanding it shows the prompt and the call's result. Its summary carries a status from the `agent.vendor` `system` events whose `tool_use_id` names it (`task_started`: running, or running in the background; `task_progress`: its `description`; `task_notification`: its `status`, red unless `completed`). The subagent's own messages, thinking and tool calls (events whose `parent_tool_use_id` names the call) stream inline with the main agent's, in arrival order, each marked `↳ <subagent_type>` with the description as its tooltip; its messages also get an accent rule down their left edge. A run of tool calls groups only calls from the same agent, so a call from another agent starts a new group. A subagent's messages never end the main agent's live message, count as a turn's final message or hide a quiet turn. Claude Code streams a background subagent's activity, but for a foreground one only its prompt, so a foreground subagent shows only as its call, its status and its result. Selecting a task that has never had a session focuses the composer. In the composer, Enter sends and Shift-Enter adds a newline; unsent text is kept per task in memory, so it survives switching tabs or tasks but not a page reload. The composer has no `@`-file mentions or path picker: text reaches the agent exactly as typed (stream-json doesn't expand `@path` the way the interactive CLI does), and the agent reads any file the message names with its own tools. The composer is the same whether or not the task has a live session, and stays enabled while a turn runs: Send posts to the session's messages, or starts a session with the text when there is none or the last one completed. A sent message shows at once as a bubble reading "queued…" while the session is running and "sending…" otherwise, replaced by the server's `user.message` event with the same text when it arrives, and removed (text and images back in the composer) if the request fails.
The task switcher (⌘K, or the project ▸ task picker at the right end of the right pane's tab strip) lists active tasks with their session state and open inbox item count, 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. 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 session state and open inbox item count, 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. 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 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 its tab strip, colours the task picker's text, the selected tab's underline, the composer's border and the running border and spinner. Everywhere a task is named it shows in its colour: task titles in the switcher, the Project tab and the Status modal (`.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.
**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 spinner. Everywhere a task is named it shows in its colour: task titles in the switcher, the Project tab and the Status modal (`.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.
## Lead agent
@@ -153,7 +153,7 @@ Each user has one long-running lead agent, shown in the left pane. It is not tie
- **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.
The UI is two panes: the left is the lead's transcript and composer (the same view as a task transcript, with only the Compact button in its header), the right shows the focused task, or project with no task, with the Transcript, Changes, Files, Terminal, Preview, Inbox, Reviews and Project tabs, the lead's `tabs` first. Preview shows only once the task's stream has a `preview.exposed`, Reviews only once it has a `review.*` event, and Inbox only while the task has open items; the selected tab always shows. Tabs that don't fit in the strip beside the task picker (given its full width, up to 40% of the strip) move, from the end, into a **More ▾** menu (`web/src/panes.ts`); the selected tab never moves there, and More carries the Inbox highlight and count when Inbox is in it. ⌘P (Ctrl+P elsewhere) selects the Project tab, keeping the right pane's task, and replaces the browser's print shortcut. ⌘L (Ctrl+L elsewhere) puts the cursor in the lead's composer, and replaces the browser's address bar shortcut. ⌘S (Ctrl+S elsewhere) opens the right pane's task's Merge confirmation, from any tab, when the Merge button would be enabled, and replaces the browser's save shortcut.
The UI is two panes: the left is the lead's transcript and composer (the same view as a task transcript, with only the Compact button in its header), the right shows the focused task, or project with no task: a top row holding only the task picker, centred, then the tab strip, then the selected tab's view with its own header. The tabs are Transcript, Changes, Files, Terminal, Preview, Inbox, Reviews and Project, the lead's `tabs` first. Preview shows only once the task's stream has a `preview.exposed`, Reviews only once it has a `review.*` event, and Inbox only while the task has open items; the selected tab always shows. Tabs that don't fit in the strip move, from the end, into a **More ▾** menu (`web/src/panes.ts`); the selected tab never moves there, and More carries the Inbox highlight and count when Inbox is in it. ⌘P (Ctrl+P elsewhere) selects the Project tab, keeping the right pane's task, and replaces the browser's print shortcut. ⌘L (Ctrl+L elsewhere) puts the cursor in the lead's composer, and replaces the browser's address bar shortcut. ⌘S (Ctrl+S elsewhere) opens the right pane's task's Merge confirmation, from any tab, when the Merge button would be enabled, and replaces the browser's save shortcut.
## TODO
+5 -10
View File
@@ -31,7 +31,7 @@ export function taskPane() {
const body = h("div", { class: "pane-body" });
const picker = h("button", { class: "task-picker", title: "Switch task (⌘K)" });
picker.onclick = openSwitcher;
const pane = h("section", { class: "pane task-pane", tabIndex: -1 }, strip, body);
const pane = h("section", { class: "pane task-pane", tabIndex: -1 }, h("header", { class: "task-bar" }, picker), strip, body);
let shown;
let dispose = () => { };
const renderPicker = () => {
@@ -67,14 +67,13 @@ export function taskPane() {
const style = getComputedStyle(strip);
const gap = parseFloat(style.columnGap) || 0;
const inner = strip.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight);
const pickerWidth = Math.min(picker.scrollWidth, inner * 0.4);
const widths = new Map(buttons.map(({ tab, button }) => [tab, button.offsetWidth]));
const total = (kept) => kept.reduce((sum, t) => sum + widths.get(t) + gap, 0);
const all = buttons.map((b) => b.tab);
let kept = all;
if (total(all) + gap + pickerWidth > inner) {
if (total(all) > inner) {
more.hidden = false;
const budget = inner - pickerWidth - more.offsetWidth - 2 * gap;
const budget = inner - more.offsetWidth - gap;
const tab = state.focus.tab;
kept = [];
for (const t of [tab, ...all.filter((t) => t !== tab)]) {
@@ -129,7 +128,7 @@ export function taskPane() {
button.onclick = () => focusTab(t);
return { tab: t, button };
});
replace(strip, ...buttons.map((b) => b.button), more, h("span", { class: "spacer" }), picker, menu);
replace(strip, ...buttons.map((b) => b.button), more, menu);
layout();
}
if (tab === shown) {
@@ -153,14 +152,10 @@ export function taskPane() {
};
on("inbox", openAsked);
onEach(["focus", "inbox", "selection", "transcript"], render);
onEach(["projects", "tasks", "selection", "focus"], () => {
renderPicker();
layout();
});
onEach(["projects", "tasks", "selection"], renderPicker);
new ResizeObserver(layout).observe(strip);
render();
renderPicker();
layout();
trackPaneFocus(pane);
return pane;
}
+1 -1
View File
File diff suppressed because one or more lines are too long
+5 -10
View File
@@ -34,7 +34,7 @@ export function taskPane(): HTMLElement {
const body = h("div", { class: "pane-body" });
const picker = h("button", { class: "task-picker", title: "Switch task (⌘K)" });
picker.onclick = openSwitcher;
const pane = h("section", { class: "pane task-pane", tabIndex: -1 }, strip, body);
const pane = h("section", { class: "pane task-pane", tabIndex: -1 }, h("header", { class: "task-bar" }, picker), strip, body);
let shown: TaskTab | undefined;
let dispose: () => void = () => {};
@@ -73,14 +73,13 @@ export function taskPane(): HTMLElement {
const style = getComputedStyle(strip);
const gap = parseFloat(style.columnGap) || 0;
const inner = strip.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight);
const pickerWidth = Math.min(picker.scrollWidth, inner * 0.4);
const widths = new Map(buttons.map(({ tab, button }) => [tab, button.offsetWidth]));
const total = (kept: TaskTab[]) => kept.reduce((sum, t) => sum + widths.get(t)! + gap, 0);
const all = buttons.map((b) => b.tab);
let kept = all;
if (total(all) + gap + pickerWidth > inner) {
if (total(all) > inner) {
more.hidden = false;
const budget = inner - pickerWidth - more.offsetWidth - 2 * gap;
const budget = inner - more.offsetWidth - gap;
const tab = state.focus.tab;
kept = [];
for (const t of [tab, ...all.filter((t) => t !== tab)]) {
@@ -140,7 +139,7 @@ export function taskPane(): HTMLElement {
button.onclick = () => focusTab(t);
return { tab: t, button };
});
replace(strip, ...buttons.map((b) => b.button), more, h("span", { class: "spacer" }), picker, menu);
replace(strip, ...buttons.map((b) => b.button), more, menu);
layout();
}
if (tab === shown) {
@@ -166,14 +165,10 @@ export function taskPane(): HTMLElement {
on("inbox", openAsked);
onEach(["focus", "inbox", "selection", "transcript"], render);
onEach(["projects", "tasks", "selection", "focus"], () => {
renderPicker();
layout();
});
onEach(["projects", "tasks", "selection"], renderPicker);
new ResizeObserver(layout).observe(strip);
render();
renderPicker();
layout();
trackPaneFocus(pane);
return pane;
}
+14 -10
View File
@@ -297,19 +297,23 @@ textarea {
padding: 8px 10px 6px;
}
.pane-tabs .task-picker {
.task-bar {
display: flex;
justify-content: center;
padding: 0 12px;
border-bottom: 1px solid var(--border);
background: color-mix(in srgb, var(--task) 12%, var(--bg));
}
.task-bar .task-picker {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text);
}
.task-pane > .pane-tabs {
background: color-mix(in srgb, var(--task) 12%, var(--bg));
}
.task-pane > .pane-tabs .task-picker {
padding: 6px 10px;
border: none;
border-radius: 0;
background: transparent;
color: var(--task);
font-weight: 700;
}
@@ -354,7 +358,7 @@ a.task-link {
display: none;
}
.pane-tabs button:not(.task-picker) {
.pane-tabs button {
flex-shrink: 0;
white-space: nowrap;
}