Move the task picker into the right pane and drop the switcher from it
This commit is contained in:
+2
-2
@@ -109,7 +109,7 @@ Agent activity becomes events with `agent_session_id` set: `user.message`, `agen
|
||||
|
||||
Messages byre itself sends an agent (actor `byre`: the restart notice, Sync and Merge prompts, Compact's handoff request and the continuation's handoff) 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 an accent border while the session is `running`, and a braille spinner on its last line, 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 an accent 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. 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. 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. A turn's final message that is at least half visible in the transcript while the page is visible dismisses its `report` inbox item, as the viewing user. 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: 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 "sending…" bubble, 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 navbar crumb) lists active tasks with their session state and open inbox item count, 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 Transcript tab (see "Lead agent").
|
||||
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, 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 Transcript tab (see "Lead agent").
|
||||
|
||||
## Lead agent
|
||||
|
||||
@@ -119,7 +119,7 @@ Each user has one long-running lead agent, shown in the left pane. It is not tie
|
||||
- **Messages.** `POST /api/me/lead/messages` sends the caller's message to their lead, starting a lead session when there is none. While the lead is mid-turn the message is held in `lead_inputs` and becomes its next turn; the composer stays enabled. `GET /api/me/lead/sessions` lists the caller's lead sessions; interrupt and compact use the usual session endpoints.
|
||||
- **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. A turn opened by a new session's handoff (its `user.message` carries `continuation: true`) keeps that message and removes only what follows it.
|
||||
- **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 right pane's task picker, 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.** 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 its selected tab is `project` (the fallback the lead shows when nothing needs the human, so the human being there doesn't hold it) or 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.
|
||||
- **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.
|
||||
|
||||
Vendored
+7
-9
@@ -1,11 +1,9 @@
|
||||
import { post } from "./api.js";
|
||||
import { errorText, h, replace, showAlert } from "./dom.js";
|
||||
import { openSettings } from "./settings.js";
|
||||
import { activeTasks, currentSession, findTask, focusedProject, focusProject, focusTab, focusTask, lastVisits, onEach, state } from "./state.js";
|
||||
import { activeTasks, currentSession, focusedProject, focusProject, focusTab, focusTask, lastVisits, onEach, state } from "./state.js";
|
||||
import { openStatus } from "./status.js";
|
||||
export function mountNavbar(root) {
|
||||
const crumb = h("button", { class: "crumb", title: "Switch task (⌘K)" });
|
||||
crumb.onclick = openSwitcher;
|
||||
const status = h("button", { class: "nav-link" }, "Status");
|
||||
status.onclick = openStatus;
|
||||
const settings = h("button", { class: "nav-link" }, "Settings");
|
||||
@@ -14,16 +12,13 @@ export function mountNavbar(root) {
|
||||
const connLabel = h("span", { class: "connection-label" }, "reconnecting…");
|
||||
const me = h("span", { class: "me" });
|
||||
const render = () => {
|
||||
const task = state.selectedTaskId ? findTask(state.selectedTaskId) : undefined;
|
||||
const project = focusedProject();
|
||||
replace(crumb, task ? `${project?.name ?? ""} ▸ ${task.title}` : (project?.name ?? "No task"), " ▾");
|
||||
conn.className = state.connected ? "connection up" : "connection down";
|
||||
conn.title = state.connected ? "live" : "reconnecting";
|
||||
connLabel.hidden = state.connected;
|
||||
replace(me, state.me?.display_name ?? "");
|
||||
};
|
||||
onEach(["me", "projects", "tasks", "selection", "focus", "connection"], render);
|
||||
replace(root, h("a", { href: "#/", class: "brand" }, "byre"), crumb, h("span", { class: "spacer" }), status, settings, connLabel, conn, me);
|
||||
onEach(["me", "connection"], render);
|
||||
replace(root, h("a", { href: "#/", class: "brand" }, "byre"), h("span", { class: "spacer" }), status, settings, connLabel, conn, me);
|
||||
render();
|
||||
addEventListener("keydown", (e) => {
|
||||
if (!(e.metaKey || e.ctrlKey) || document.querySelector("dialog[open]")) {
|
||||
@@ -39,7 +34,7 @@ export function mountNavbar(root) {
|
||||
}
|
||||
});
|
||||
}
|
||||
function openSwitcher() {
|
||||
export function openSwitcher() {
|
||||
if (document.querySelector(".switcher")) {
|
||||
return;
|
||||
}
|
||||
@@ -139,6 +134,9 @@ function openSwitcher() {
|
||||
}
|
||||
};
|
||||
backdrop.onclick = close;
|
||||
const anchor = document.querySelector(".task-picker").getBoundingClientRect();
|
||||
box.style.top = `${anchor.bottom + 4}px`;
|
||||
box.style.right = `${document.documentElement.clientWidth - anchor.right}px`;
|
||||
document.body.append(backdrop, box);
|
||||
render();
|
||||
input.focus();
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+12
-2
@@ -4,7 +4,8 @@ import { h, replace } from "./dom.js";
|
||||
import { mountFiles } from "./files.js";
|
||||
import { mountInbox } from "./inbox.js";
|
||||
import { mountLead } from "./lead.js";
|
||||
import { focusTab, on, state, tabs } from "./state.js";
|
||||
import { openSwitcher } from "./navbar.js";
|
||||
import { findTask, focusedProject, focusTab, on, onEach, state, tabs } from "./state.js";
|
||||
import { mountProject } from "./project.js";
|
||||
import { mountTranscript } from "./task.js";
|
||||
const views = {
|
||||
@@ -22,8 +23,15 @@ export function leadPane() {
|
||||
export function taskPane() {
|
||||
const strip = h("nav", { class: "pane-tabs" });
|
||||
const body = h("div", { class: "pane-body" });
|
||||
const picker = h("button", { class: "task-picker", title: "Switch task (⌘K)" });
|
||||
picker.onclick = openSwitcher;
|
||||
let shown;
|
||||
let dispose = () => { };
|
||||
const renderPicker = () => {
|
||||
const task = state.selectedTaskId ? findTask(state.selectedTaskId) : undefined;
|
||||
const project = focusedProject();
|
||||
replace(picker, task ? `${project?.name ?? ""} ▸ ${task.title}` : (project?.name ?? "No task"), " ▾");
|
||||
};
|
||||
const render = () => {
|
||||
const first = state.focus.tabs.filter((t, i, all) => tabs.includes(t) && all.indexOf(t) === i);
|
||||
const order = [...first, ...tabs.filter((t) => !first.includes(t))];
|
||||
@@ -32,7 +40,7 @@ export function taskPane() {
|
||||
const b = h("button", { class: t === tab ? "active" : "" }, views[t].label);
|
||||
b.onclick = () => focusTab(t);
|
||||
return b;
|
||||
}));
|
||||
}), h("span", { class: "spacer" }), picker);
|
||||
if (tab === shown) {
|
||||
return;
|
||||
}
|
||||
@@ -43,7 +51,9 @@ export function taskPane() {
|
||||
dispose = views[tab].mount(root);
|
||||
};
|
||||
on("focus", render);
|
||||
onEach(["projects", "tasks", "selection", "focus"], renderPicker);
|
||||
render();
|
||||
renderPicker();
|
||||
const pane = h("section", { class: "pane", tabIndex: -1 }, strip, body);
|
||||
trackPaneFocus(pane);
|
||||
return pane;
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+7
-11
@@ -1,13 +1,10 @@
|
||||
import { post } from "./api.js";
|
||||
import { errorText, h, replace, showAlert } from "./dom.js";
|
||||
import { openSettings } from "./settings.js";
|
||||
import { activeTasks, currentSession, findTask, focusedProject, focusProject, focusTab, focusTask, lastVisits, onEach, state } from "./state.js";
|
||||
import { activeTasks, currentSession, focusedProject, focusProject, focusTab, focusTask, lastVisits, onEach, state } from "./state.js";
|
||||
import { openStatus } from "./status.js";
|
||||
|
||||
export function mountNavbar(root: HTMLElement): void {
|
||||
const crumb = h("button", { class: "crumb", title: "Switch task (⌘K)" });
|
||||
crumb.onclick = openSwitcher;
|
||||
|
||||
const status = h("button", { class: "nav-link" }, "Status");
|
||||
status.onclick = openStatus;
|
||||
|
||||
@@ -19,18 +16,14 @@ export function mountNavbar(root: HTMLElement): void {
|
||||
const me = h("span", { class: "me" });
|
||||
|
||||
const render = () => {
|
||||
const task = state.selectedTaskId ? findTask(state.selectedTaskId) : undefined;
|
||||
const project = focusedProject();
|
||||
replace(crumb, task ? `${project?.name ?? ""} ▸ ${task.title}` : (project?.name ?? "No task"), " ▾");
|
||||
|
||||
conn.className = state.connected ? "connection up" : "connection down";
|
||||
conn.title = state.connected ? "live" : "reconnecting";
|
||||
connLabel.hidden = state.connected;
|
||||
replace(me, state.me?.display_name ?? "");
|
||||
};
|
||||
|
||||
onEach(["me", "projects", "tasks", "selection", "focus", "connection"], render);
|
||||
replace(root, h("a", { href: "#/", class: "brand" }, "byre"), crumb, h("span", { class: "spacer" }), status, settings, connLabel, conn, me);
|
||||
onEach(["me", "connection"], render);
|
||||
replace(root, h("a", { href: "#/", class: "brand" }, "byre"), h("span", { class: "spacer" }), status, settings, connLabel, conn, me);
|
||||
render();
|
||||
|
||||
addEventListener("keydown", (e) => {
|
||||
@@ -47,7 +40,7 @@ export function mountNavbar(root: HTMLElement): void {
|
||||
});
|
||||
}
|
||||
|
||||
function openSwitcher(): void {
|
||||
export function openSwitcher(): void {
|
||||
if (document.querySelector(".switcher")) {
|
||||
return;
|
||||
}
|
||||
@@ -158,6 +151,9 @@ function openSwitcher(): void {
|
||||
};
|
||||
backdrop.onclick = close;
|
||||
|
||||
const anchor = document.querySelector(".task-picker")!.getBoundingClientRect();
|
||||
box.style.top = `${anchor.bottom + 4}px`;
|
||||
box.style.right = `${document.documentElement.clientWidth - anchor.right}px`;
|
||||
document.body.append(backdrop, box);
|
||||
render();
|
||||
input.focus();
|
||||
|
||||
+14
-1
@@ -4,7 +4,8 @@ import { h, replace } from "./dom.js";
|
||||
import { mountFiles } from "./files.js";
|
||||
import { mountInbox } from "./inbox.js";
|
||||
import { mountLead } from "./lead.js";
|
||||
import { focusTab, on, state, tabs } from "./state.js";
|
||||
import { openSwitcher } from "./navbar.js";
|
||||
import { findTask, focusedProject, focusTab, on, onEach, state, tabs } from "./state.js";
|
||||
import { mountProject } from "./project.js";
|
||||
import { mountTranscript } from "./task.js";
|
||||
|
||||
@@ -25,9 +26,17 @@ export function leadPane(): HTMLElement {
|
||||
export function taskPane(): HTMLElement {
|
||||
const strip = h("nav", { class: "pane-tabs" });
|
||||
const body = h("div", { class: "pane-body" });
|
||||
const picker = h("button", { class: "task-picker", title: "Switch task (⌘K)" });
|
||||
picker.onclick = openSwitcher;
|
||||
let shown: TaskTab | undefined;
|
||||
let dispose: () => void = () => {};
|
||||
|
||||
const renderPicker = () => {
|
||||
const task = state.selectedTaskId ? findTask(state.selectedTaskId) : undefined;
|
||||
const project = focusedProject();
|
||||
replace(picker, task ? `${project?.name ?? ""} ▸ ${task.title}` : (project?.name ?? "No task"), " ▾");
|
||||
};
|
||||
|
||||
const render = () => {
|
||||
const first = state.focus.tabs.filter((t, i, all) => tabs.includes(t) && all.indexOf(t) === i);
|
||||
const order = [...first, ...tabs.filter((t) => !first.includes(t))];
|
||||
@@ -39,6 +48,8 @@ export function taskPane(): HTMLElement {
|
||||
b.onclick = () => focusTab(t);
|
||||
return b;
|
||||
}),
|
||||
h("span", { class: "spacer" }),
|
||||
picker,
|
||||
);
|
||||
if (tab === shown) {
|
||||
return;
|
||||
@@ -51,7 +62,9 @@ export function taskPane(): HTMLElement {
|
||||
};
|
||||
|
||||
on("focus", render);
|
||||
onEach(["projects", "tasks", "selection", "focus"], renderPicker);
|
||||
render();
|
||||
renderPicker();
|
||||
const pane = h("section", { class: "pane", tabIndex: -1 }, strip, body);
|
||||
trackPaneFocus(pane);
|
||||
return pane;
|
||||
|
||||
+8
-17
@@ -190,20 +190,6 @@ textarea {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.crumb {
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
max-width: 50%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.crumb:hover:not(:disabled) {
|
||||
background: var(--raised);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
@@ -288,6 +274,14 @@ textarea {
|
||||
padding: 8px 10px 6px;
|
||||
}
|
||||
|
||||
.pane-tabs .task-picker {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.pane-tabs button:hover:not(:disabled),
|
||||
.range-tabs button:hover:not(:disabled),
|
||||
.modes button:hover:not(:disabled) {
|
||||
@@ -1509,9 +1503,6 @@ button.danger-primary {
|
||||
|
||||
.switcher {
|
||||
position: fixed;
|
||||
top: 15vh;
|
||||
left: 50%;
|
||||
translate: -50% 0;
|
||||
width: min(560px, 90vw);
|
||||
background: var(--raised);
|
||||
border: 1px solid var(--border);
|
||||
|
||||
Reference in New Issue
Block a user