Hide empty task tabs and move tabs that don't fit into a More menu

This commit is contained in:
Ian Gulliver
2026-10-15 17:49:59 -07:00
parent a19067a665
commit 1741758127
5 changed files with 222 additions and 29 deletions
+2 -2
View File
@@ -148,7 +148,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. ⌘P (Ctrl+P elsewhere) selects the Project tab, keeping the right pane's task, and replaces the browser's print 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, 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. ⌘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
@@ -162,7 +162,7 @@ The **Project** tab is the dashboard of the right pane's project (the focused ta
Agents ask questions with the `ask_human` MCP tool. Each question carries a `context` with the background the human needs to answer without reading the transcript, and its choices are written inline in that context as Markdown list items: `- ( ) text` for pick-one choices, with exactly one `- (*) text` marking the recommendation, or `- [ ] text` for pick-any choices, with `- [x] text` marking the recommended ones. There is no separate list of options. byre parses the choices out of `context` (`internal/agents/choices.go`; list items inside code fences and blockquotes don't count) and stores their text in the payload as `options`, with `multiple` (pick-any) and `recommended`. The UI renders the context as Markdown with a radio button or checkbox in place of each choice's marker. A malformed call (no `context` or `why_blocked`, fewer than two choices, pick-one and pick-any choices mixed, a pick-one question without exactly one recommendation, two choices with the same text, an unknown attachment kind, a foreign `thread_id`) is rejected with a message saying what to fix, and nothing is filed. The call returns the item's id as `question_id`. An agent withdraws a question it no longer needs answered (the human answered it in a chat message, say) with the `cancel_question` MCP tool and that id: an `open` question of its own session becomes `dismissed` with no `resolved_by` (its `inbox.updated` event has actor `agent`), which also stops a blocking one from holding back delivery; anything else is rejected with a message saying why. Each turn's final message (as a `report`) and each agent error (a turn that ends in error without a human interrupt, a process that dies mid-turn) also files an item.
`GET /api/inbox` is one query over open items, oldest first; `project_id` and `task_id` narrow it to a project or task view, and `state` picks another state. Blocking questions and errors are `blocked`; reports and non-blocking questions are `fyi`. The Inbox tab is task-specific: it shows one of the selected task's open items at a time, the oldest, and the next one once that is answered or dismissed; it shows nothing when no task is selected. While the selected task has open items, the Inbox tab's button is highlighted in the warn colours (`.attention` in `web/style.css`) and carries their count. Answering or dismissing the task's last open item from the Inbox tab switches the pane to the Transcript tab.
`GET /api/inbox` is one query over open items, oldest first; `project_id` and `task_id` narrow it to a project or task view, and `state` picks another state. Blocking questions and errors are `blocked`; reports and non-blocking questions are `fyi`. The Inbox tab is task-specific: it shows one of the selected task's open items at a time, the oldest, and the next one once that is answered or dismissed; it shows nothing when no task is selected. The tab is hidden while the task has no open items, unless it is selected. While the selected task has open items, the Inbox tab's button is highlighted in the warn colours (`.attention` in `web/style.css`) and carries their count. Answering or dismissing the task's last open item from the Inbox tab switches the pane to the Transcript tab.
`POST /api/inbox/responses` answers any number of items in one submission. Response types and what they do to the item:
+89 -11
View File
@@ -8,7 +8,7 @@ import { openSwitcher } from "./navbar.js";
import { findTask, focusedProject, focusTab, on, onEach, state, tabs } from "./state.js";
import { mountPreview } from "./preview.js";
import { mountProject } from "./project.js";
import { mountReviews } from "./reviews.js";
import { isReviewEvent, mountReviews } from "./reviews.js";
import { mountTranscript } from "./task.js";
import { mountTerminal } from "./terminal.js";
const views = {
@@ -45,17 +45,90 @@ export function taskPane() {
}
replace(picker, task ? `${project?.name ?? ""} ▸ ${task.title}` : (project?.name ?? "No task"), " ▾");
};
const more = h("button", { type: "button", class: "more", title: "More tabs" });
const menu = h("div", { class: "tab-menu", popover: "auto" });
more.onclick = () => {
const rect = more.getBoundingClientRect();
menu.style.top = `${rect.bottom + 4}px`;
menu.style.left = `${rect.left}px`;
menu.togglePopover();
};
let buttons = [];
let drawn = "";
let waiting = 0;
const layout = () => {
for (const { button } of buttons) {
button.hidden = false;
}
more.hidden = true;
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) {
more.hidden = false;
const budget = inner - pickerWidth - more.offsetWidth - 2 * gap;
const tab = state.focus.tab;
kept = [];
for (const t of [tab, ...all.filter((t) => t !== tab)]) {
if (kept.length > 0 && total([...kept, t]) > budget) {
break;
}
kept.push(t);
}
}
const hidden = all.filter((t) => !kept.includes(t));
for (const { tab, button } of buttons) {
button.hidden = hidden.includes(tab);
}
const attention = hidden.includes("inbox");
more.classList.toggle("attention", attention);
replace(more, "More ▾", attention && h("span", { class: "badge", title: "inbox items" }, waiting));
replace(menu, ...hidden.map((t) => {
const b = h("button", { type: "button" }, views[t].label, t === "inbox" && h("span", { class: "badge", title: "inbox items" }, waiting));
b.onclick = () => {
menu.hidePopover();
focusTab(t);
};
return b;
}));
};
const available = (t) => {
if (t === state.focus.tab) {
return true;
}
switch (t) {
case "inbox":
return waiting > 0;
case "preview":
return state.transcript.some((ev) => ev.kind === "preview.exposed");
case "reviews":
return state.transcript.some(isReviewEvent);
default:
return true;
}
};
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))];
const tab = state.focus.tab;
const waiting = state.inbox.filter((item) => item.task_id === state.selectedTaskId).length;
replace(strip, ...order.map((t) => {
const attention = t === "inbox" && waiting > 0;
const b = h("button", { class: [t === tab ? "active" : "", attention ? "attention" : ""].join(" ").trim() }, views[t].label, attention && h("span", { class: "badge", title: "inbox items" }, waiting));
b.onclick = () => focusTab(t);
return b;
}), h("span", { class: "spacer" }), picker);
waiting = state.inbox.filter((item) => item.task_id === state.selectedTaskId).length;
const order = [...first, ...tabs.filter((t) => !first.includes(t))].filter(available);
const key = JSON.stringify([order, tab, waiting]);
if (key !== drawn) {
drawn = key;
buttons = order.map((t) => {
const attention = t === "inbox" && waiting > 0;
const button = h("button", { class: [t === tab ? "active" : "", attention ? "attention" : ""].join(" ").trim() }, views[t].label, attention && h("span", { class: "badge", title: "inbox items" }, waiting));
button.onclick = () => focusTab(t);
return { tab: t, button };
});
replace(strip, ...buttons.map((b) => b.button), more, h("span", { class: "spacer" }), picker, menu);
layout();
}
if (tab === shown) {
return;
}
@@ -65,10 +138,15 @@ export function taskPane() {
replace(body, root);
dispose = views[tab].mount(root);
};
onEach(["focus", "inbox", "selection"], render);
onEach(["projects", "tasks", "selection", "focus"], renderPicker);
onEach(["focus", "inbox", "selection", "transcript"], render);
onEach(["projects", "tasks", "selection", "focus"], () => {
renderPicker();
layout();
});
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
+94 -15
View File
@@ -8,7 +8,7 @@ import { openSwitcher } from "./navbar.js";
import { findTask, focusedProject, focusTab, on, onEach, state, tabs } from "./state.js";
import { mountPreview } from "./preview.js";
import { mountProject } from "./project.js";
import { mountReviews } from "./reviews.js";
import { isReviewEvent, mountReviews } from "./reviews.js";
import { mountTranscript } from "./task.js";
import { mountTerminal } from "./terminal.js";
@@ -49,27 +49,101 @@ export function taskPane(): HTMLElement {
replace(picker, task ? `${project?.name ?? ""} ▸ ${task.title}` : (project?.name ?? "No task"), " ▾");
};
const more = h("button", { type: "button", class: "more", title: "More tabs" });
const menu = h("div", { class: "tab-menu", popover: "auto" });
more.onclick = () => {
const rect = more.getBoundingClientRect();
menu.style.top = `${rect.bottom + 4}px`;
menu.style.left = `${rect.left}px`;
menu.togglePopover();
};
let buttons: { tab: TaskTab; button: HTMLButtonElement }[] = [];
let drawn = "";
let waiting = 0;
const layout = () => {
for (const { button } of buttons) {
button.hidden = false;
}
more.hidden = true;
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) {
more.hidden = false;
const budget = inner - pickerWidth - more.offsetWidth - 2 * gap;
const tab = state.focus.tab;
kept = [];
for (const t of [tab, ...all.filter((t) => t !== tab)]) {
if (kept.length > 0 && total([...kept, t]) > budget) {
break;
}
kept.push(t);
}
}
const hidden = all.filter((t) => !kept.includes(t));
for (const { tab, button } of buttons) {
button.hidden = hidden.includes(tab);
}
const attention = hidden.includes("inbox");
more.classList.toggle("attention", attention);
replace(more, "More ▾", attention && h("span", { class: "badge", title: "inbox items" }, waiting));
replace(
menu,
...hidden.map((t) => {
const b = h("button", { type: "button" }, views[t].label, t === "inbox" && h("span", { class: "badge", title: "inbox items" }, waiting));
b.onclick = () => {
menu.hidePopover();
focusTab(t);
};
return b;
}),
);
};
const available = (t: TaskTab): boolean => {
if (t === state.focus.tab) {
return true;
}
switch (t) {
case "inbox":
return waiting > 0;
case "preview":
return state.transcript.some((ev) => ev.kind === "preview.exposed");
case "reviews":
return state.transcript.some(isReviewEvent);
default:
return true;
}
};
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))];
const tab = state.focus.tab;
const waiting = state.inbox.filter((item) => item.task_id === state.selectedTaskId).length;
replace(
strip,
...order.map((t) => {
waiting = state.inbox.filter((item) => item.task_id === state.selectedTaskId).length;
const order = [...first, ...tabs.filter((t) => !first.includes(t))].filter(available);
const key = JSON.stringify([order, tab, waiting]);
if (key !== drawn) {
drawn = key;
buttons = order.map((t) => {
const attention = t === "inbox" && waiting > 0;
const b = h(
const button = h(
"button",
{ class: [t === tab ? "active" : "", attention ? "attention" : ""].join(" ").trim() },
views[t].label,
attention && h("span", { class: "badge", title: "inbox items" }, waiting),
);
b.onclick = () => focusTab(t);
return b;
}),
h("span", { class: "spacer" }),
picker,
);
button.onclick = () => focusTab(t);
return { tab: t, button };
});
replace(strip, ...buttons.map((b) => b.button), more, h("span", { class: "spacer" }), picker, menu);
layout();
}
if (tab === shown) {
return;
}
@@ -80,10 +154,15 @@ export function taskPane(): HTMLElement {
dispose = views[tab].mount(root);
};
onEach(["focus", "inbox", "selection"], render);
onEach(["projects", "tasks", "selection", "focus"], renderPicker);
onEach(["focus", "inbox", "selection", "transcript"], render);
onEach(["projects", "tasks", "selection", "focus"], () => {
renderPicker();
layout();
});
new ResizeObserver(layout).observe(strip);
render();
renderPicker();
layout();
trackPaneFocus(pane);
return pane;
}
+36
View File
@@ -350,6 +350,42 @@ a.task-link {
margin-left: 6px;
}
.pane-tabs button[hidden] {
display: none;
}
.pane-tabs button:not(.task-picker) {
flex-shrink: 0;
white-space: nowrap;
}
.tab-menu {
position: fixed;
inset: auto;
margin: 0;
padding: 4px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--raised);
color: var(--text);
box-shadow: var(--shadow);
}
.tab-menu:popover-open {
display: flex;
flex-direction: column;
}
.tab-menu button {
border: none;
background: transparent;
text-align: left;
}
.tab-menu button:hover {
background: var(--surface);
}
.pane-body,
.view,
.transcript-view,