Compare commits

..
1 Commits
Author SHA1 Message Date
Miguel PalhasandClaude Opus 4.6 19087ec613 fix(maestro): add missing auto_approve field in TUI tests
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-07 11:31:27 +01:00
12 changed files with 134 additions and 402 deletions
-17
View File
@@ -1,17 +0,0 @@
{
"permissions": {
"allow": [
"Bash(git add:*)",
"Bash(git commit:*)",
"Bash(git fetch:*)",
"Bash(git merge:*)",
"Bash(npx tsc:*)",
"Bash(npm run build:*)",
"Bash(npm install:*)",
"Bash(./node_modules/.bin/tsc:*)",
"Bash(npm test:*)",
"Bash(cargo check:*)",
"Bash(cargo clippy:*)"
]
}
}
+5 -13
View File
@@ -190,13 +190,9 @@ choices, not daemon ones.
from the transcript; the TUI may show the current plan in its status line, from the transcript; the TUI may show the current plan in its status line,
while the web client deliberately has no stats or plan rail. while the web client deliberately has no stats or plan rail.
- The web client groups two or more tool rows after the same `User` row into - The web client groups two or more tool rows after the same `User` row into
one collapsed turn activity block. `AgentText` and `AgentThought` rows that one collapsed turn activity block. Opening it reveals independently
appear *between* tool rows in the same turn are absorbed into the group expandable tool calls, including any raw command, input, output, and diff
(intermediate narration); the last `AgentText` with no subsequent tool stays content the adapter supplied.
outside as the prominent final answer. The header still reads
"N tool calls" (counting only tool rows). Opening the group reveals tools
and intermediate text in chronological order, with the text rendered in a
subdued style.
- The web client's user, agent, thought, tool-detail, and notice text blocks - The web client's user, agent, thought, tool-detail, and notice text blocks
copy their complete source text when clicked or keyboard-activated. copy their complete source text when clicked or keyboard-activated.
- The web client consumes `available_commands_update` notices as the live - The web client consumes `available_commands_update` notices as the live
@@ -350,15 +346,11 @@ thing a callback can be attributed to, so at most one turn may ever be
in flight, and a cancelled turn must fully settle before the next prompt is in flight, and a cancelled turn must fully settle before the next prompt is
allowed to claim the daemon's idea of "the active turn." allowed to claim the daemon's idea of "the active turn."
- Web clients may collect a visible FIFO of operator messages while a turn is - A prompt is appended as a durable `User` row before it claims the turn and
active. They submit each queued message only after the projected turn becomes
idle; the queue is client-local and unsent messages do not survive a reload.
- A submitted prompt is appended as a durable `User` row before it claims the turn and
dispatches to ACP, so the transcript never depends on an adapter echoing the dispatches to ACP, so the transcript never depends on an adapter echoing the
operator's input. If that append fails, the prompt is not dispatched. While a operator's input. If that append fails, the prompt is not dispatched. While a
turn is active, a second prompt gets `409 turn_in_flight` rather than turn is active, a second prompt gets `409 turn_in_flight` rather than
queuing in the daemon — one active turn per session, never a hidden queuing — one active turn per session, never a hidden queue of prompts.
server-side queue of prompts.
- Cancel sends `session/cancel`, clears any pending approval as `Cancelled`, - Cancel sends `session/cancel`, clears any pending approval as `Cancelled`,
and ends the turn's client-visible lifecycle immediately. If the cancelled and ends the turn's client-visible lifecycle immediately. If the cancelled
turn was still *dispatched* to ACP (waiting on its response), the daemon turn was still *dispatched* to ACP (waiting on its response), the daemon
+3 -4
View File
@@ -663,9 +663,8 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
applyNav(); applyNav();
}; };
// Pointer hover previews only while Ctrl is held — plain hover was too // Hover previews the row under the pointer (docs/PREVIEW.md). Touch is left
// disruptive when sweeping the sidebar. Keyboard walk (j/k) still previews // out: there is no hover there, and a tap already opens the session.
// unconditionally via applyNav.
let hovered: string | null = null; let hovered: string | null = null;
const setHovered = (id: string | null) => { const setHovered = (id: string | null) => {
if (hovered === id) return; if (hovered === id) return;
@@ -673,7 +672,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
opts.onPreview?.(id); opts.onPreview?.(id);
}; };
treeHost.addEventListener("pointerover", (e) => { treeHost.addEventListener("pointerover", (e) => {
if (e.pointerType === "touch" || !e.ctrlKey) return; if (e.pointerType === "touch") return;
const row = (e.target as HTMLElement | null)?.closest<HTMLElement>(".tree-leaf"); const row = (e.target as HTMLElement | null)?.closest<HTMLElement>(".tree-leaf");
setHovered(row?.dataset.sessionId ?? null); setHovered(row?.dataset.sessionId ?? null);
}); });
+5 -11
View File
@@ -182,32 +182,26 @@ describe("sidebar preview requests (docs/PREVIEW.md)", () => {
expect(seen.at(-1)).toBe(null); expect(seen.at(-1)).toBe(null);
}); });
it("ctrl+hover previews a card, plain hover does not", () => { it("asks for a preview of the hovered card, and clears it on the way out", () => {
const seen: (string | null)[] = []; const seen: (string | null)[] = [];
const { el } = twoFolders({ onPreview: (id) => seen.push(id) }); const { el } = twoFolders({ onPreview: (id) => seen.push(id) });
const card = el.querySelectorAll<HTMLElement>(".tree-leaf")[0]; const card = el.querySelectorAll<HTMLElement>(".tree-leaf")[0];
// Plain hover — no preview.
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse" })); card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse" }));
expect(seen).toEqual([]);
// Ctrl+hover — preview fires.
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse", ctrlKey: true }));
expect(seen).toEqual([A]); expect(seen).toEqual([A]);
// Moving within the same card is not a new preview.
// Deduplicated while still on the same card. card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse" }));
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse", ctrlKey: true }));
expect(seen).toHaveLength(1); expect(seen).toHaveLength(1);
tree(el).dispatchEvent(new PointerEvent("pointerleave", { pointerType: "mouse" })); tree(el).dispatchEvent(new PointerEvent("pointerleave", { pointerType: "mouse" }));
expect(seen.at(-1)).toBe(null); expect(seen.at(-1)).toBe(null);
}); });
it("ignores touch even with ctrl", () => { it("ignores touch, where a tap already opens the session", () => {
const seen: (string | null)[] = []; const seen: (string | null)[] = [];
const { el } = twoFolders({ onPreview: (id) => seen.push(id) }); const { el } = twoFolders({ onPreview: (id) => seen.push(id) });
const card = el.querySelectorAll<HTMLElement>(".tree-leaf")[0]; const card = el.querySelectorAll<HTMLElement>(".tree-leaf")[0];
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "touch", ctrlKey: true })); card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "touch" }));
expect(seen).toEqual([]); expect(seen).toEqual([]);
}); });
}); });
+24 -53
View File
@@ -11,7 +11,7 @@ import { attachDirAutocomplete } from "./dirAutocomplete.js";
import { parentDir } from "./paths.js"; import { parentDir } from "./paths.js";
import { filterProjects, groupProjects, isStale, sortProjects } from "./projectList.js"; import { filterProjects, groupProjects, isStale, sortProjects } from "./projectList.js";
import type { ProjectGroup } from "./projectList.js"; import type { ProjectGroup } from "./projectList.js";
import { splitCommand, toolIconSvg, TOOL_LABELS } from "./toolIcon.js"; import { splitCommand } from "./toolIcon.js";
import { abbreviateCwd } from "./types.js"; import { abbreviateCwd } from "./types.js";
import type { BranchInfo, CreateSessionRequest, Project, Session, SpawnPreset } from "./types.js"; import type { BranchInfo, CreateSessionRequest, Project, Session, SpawnPreset } from "./types.js";
@@ -140,12 +140,6 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
const form = document.createElement("form"); const form = document.createElement("form");
form.className = "spawn-form"; form.className = "spawn-form";
form.innerHTML = ` form.innerHTML = `
<div class="field">
<span class="field-label">Harness</span>
<div class="harness-grid cmd-tiles"></div>
<input class="raw-input cmd-raw" name="command_raw" type="text" hidden
placeholder="bash -l" autocomplete="off" />
</div>
<div class="field"> <div class="field">
<span class="field-label">Project</span> <span class="field-label">Project</span>
<input class="raw-input project-filter" type="text" placeholder="Filter projects…" <input class="raw-input project-filter" type="text" placeholder="Filter projects…"
@@ -157,6 +151,12 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
placeholder="/home/user/project" inputmode="text" autocomplete="off" /> placeholder="/home/user/project" inputmode="text" autocomplete="off" />
</div> </div>
</div> </div>
<div class="field">
<span class="field-label">Command</span>
<div class="tile-row cmd-tiles"></div>
<input class="raw-input cmd-raw" name="command_raw" type="text" hidden
placeholder="bash -l" autocomplete="off" />
</div>
<div class="advanced"> <div class="advanced">
<button type="button" class="tree-folder-header wt-toggle" aria-expanded="false"> <button type="button" class="tree-folder-header wt-toggle" aria-expanded="false">
<span class="chevron">▾</span> <span class="chevron">▾</span>
@@ -348,69 +348,37 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
command = preset!.command; command = preset!.command;
} }
updateStructuredAvailability(); updateStructuredAvailability();
for (const card of cmdTiles.querySelectorAll(".harness-card")) { for (const tile of cmdTiles.querySelectorAll(".tile")) {
card.classList.toggle("is-active", (card as HTMLElement).dataset.id === id); tile.classList.toggle("is-active", (tile as HTMLElement).dataset.id === id);
} }
emit(); emit();
} }
/** Re-highlight the operator's already-picked card after `renderCommandTiles` /** Re-highlight the operator's already-picked tile after `renderCommandTiles`
* rebuilds the grid from scratch (new cards carry no `is-active` class) — * rebuilds the row from scratch (new tiles carry no `is-active` class) —
* without touching `command`, since that was already settled. */ * without touching `command`, since that was already settled. */
function reapplyActiveTile(): void { function reapplyActiveTile(): void {
for (const card of cmdTiles.querySelectorAll(".harness-card")) { for (const tile of cmdTiles.querySelectorAll(".tile")) {
card.classList.toggle("is-active", (card as HTMLElement).dataset.id === selectedPresetId); tile.classList.toggle("is-active", (tile as HTMLElement).dataset.id === selectedPresetId);
} }
} }
/** Resolve a preset id to a tool icon id. The preset's command is the
* source of truth — `splitCommand` extracts the tool the same way the
* sidebar does. For the "Custom…" preset (empty command) there is no
* tool to show, and a terminal prompt stands in. */
function presetToolId(p: SpawnPreset): string | null {
if (!p.command) return null;
return splitCommand(p.command).tool;
}
function renderCommandTiles(): void { function renderCommandTiles(): void {
cmdTiles.replaceChildren(); cmdTiles.replaceChildren();
for (const p of presets) { for (const p of presets) {
const isCustom = p.command === ""; const tile = document.createElement("button");
const card = document.createElement("button"); tile.type = "button";
card.type = "button"; tile.className = "tile";
card.className = isCustom ? "harness-card harness-custom" : "harness-card"; tile.dataset.id = p.id;
card.dataset.id = p.id; tile.innerHTML = `<span class="tile-label">${escapeHtml(p.label)}</span>`;
const toolId = presetToolId(p); tile.addEventListener("click", () => {
const iconHtml = isCustom ? customIcon() : toolId ? harnessIcon(toolId) : harnessIcon("shell");
const label = TOOL_LABELS[toolId ?? ""] ?? p.label;
card.innerHTML =
`<span class="harness-icon">${iconHtml}</span>` +
`<span class="harness-label">${escapeHtml(isCustom ? "Custom\u2026" : label)}</span>`;
card.addEventListener("click", () => {
commandTouched = true; commandTouched = true;
selectPreset(p.id); selectPreset(p.id);
}); });
cmdTiles.appendChild(card); cmdTiles.appendChild(tile);
} }
} }
/** Render a harness card icon at card size (24x24). Re-uses the SVG bodies
* from toolIcon.ts but at a larger viewport so the marks breathe. */
function harnessIcon(toolId: string): string {
// toolIconSvg renders at 13x13 with viewBox 0 0 16 16 — we want the
// same shape at 24x24 for the card context.
const small = toolIconSvg(toolId);
if (!small) return "";
return small
.replace(/width="13"/g, 'width="24"')
.replace(/height="13"/g, 'height="24"');
}
/** An ellipsis mark for the "Custom…" escape hatch. */
function customIcon(): string {
return `<svg class="tool-icon" viewBox="0 0 16 16" width="24" height="24" role="img" aria-label="Custom command"><title>Custom command</title><g fill="currentColor"><circle cx="4" cy="8" r="1.3"/><circle cx="8" cy="8" r="1.3"/><circle cx="12" cy="8" r="1.3"/></g></svg>`;
}
// Set once the operator picks a tile or types a custom command — guards // Set once the operator picks a tile or types a custom command — guards
// `applyInitialCommand()` the same way `nameTouched` guards a late name // `applyInitialCommand()` the same way `nameTouched` guards a late name
// batch: a late `/api/info` resolution must never overwrite a field the // batch: a late `/api/info` resolution must never overwrite a field the
@@ -680,13 +648,16 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
box.type = "button"; box.type = "button";
box.className = `project-box${isStale(project, new Date()) ? " is-stale" : ""}`; box.className = `project-box${isStale(project, new Date()) ? " is-stale" : ""}`;
box.dataset.cwd = project.path; box.dataset.cwd = project.path;
const trees = worktreesOf(group).length;
// The parent directory only — the last segment is the name line above it. // The parent directory only — the last segment is the name line above it.
// Unabbreviated beyond `~`, since the stem is what tells two projects // Unabbreviated beyond `~`, since the stem is what tells two projects
// sharing a basename apart (#95). // sharing a basename apart (#95).
const parent = abbreviateCwd(parentDir(project.path), homeDir); const parent = abbreviateCwd(parentDir(project.path), homeDir);
const meta = trees > 0 ? `${trees} worktree${trees === 1 ? "" : "s"}` : "";
box.innerHTML = ` box.innerHTML = `
<span class="project-name">${escapeHtml(project.name)}</span> <span class="project-name">${escapeHtml(project.name)}</span>
<span class="project-path">${escapeHtml(parent)}</span>`; <span class="project-path">${escapeHtml(parent)}</span>
<span class="project-meta">${escapeHtml(meta)}</span>`;
box.addEventListener("click", () => selectProject(group)); box.addEventListener("click", () => selectProject(group));
return box; return box;
} }
+1 -1
View File
@@ -139,7 +139,7 @@ describe("spawnForm Enter-to-submit (#97)", () => {
const form = await mount([MAESTRO]); const form = await mount([MAESTRO]);
box(form, MAESTRO.path).click(); box(form, MAESTRO.path).click();
await drain(); await drain();
form.querySelector<HTMLButtonElement>('.cmd-tiles .harness-card[data-id="custom"]')!.click(); form.querySelector<HTMLButtonElement>('.cmd-tiles .tile[data-id="custom"]')!.click();
const raw = form.querySelector<HTMLInputElement>(".cmd-raw")!; const raw = form.querySelector<HTMLInputElement>(".cmd-raw")!;
raw.value = "bash -l"; raw.value = "bash -l";
raw.dispatchEvent(new Event("input")); raw.dispatchEvent(new Event("input"));
+3 -3
View File
@@ -63,7 +63,7 @@ describe("spawnForm preset selection vs. late /api/info (#76)", () => {
await drain(); await drain();
// Fallback presets are up; the operator picks "zsh" before /api/info lands. // Fallback presets are up; the operator picks "zsh" before /api/info lands.
const zshTile = [...form.querySelectorAll<HTMLButtonElement>(".harness-card")].find( const zshTile = [...form.querySelectorAll<HTMLButtonElement>(".tile")].find(
(t) => t.dataset.id === "zsh", (t) => t.dataset.id === "zsh",
)!; )!;
zshTile.click(); zshTile.click();
@@ -88,7 +88,7 @@ describe("spawnForm preset selection vs. late /api/info (#76)", () => {
document.body.appendChild(form); document.body.appendChild(form);
await drain(); await drain();
const customTile = [...form.querySelectorAll<HTMLButtonElement>(".harness-card")].find( const customTile = [...form.querySelectorAll<HTMLButtonElement>(".tile")].find(
(t) => t.dataset.id === "custom", (t) => t.dataset.id === "custom",
)!; )!;
customTile.click(); customTile.click();
@@ -116,7 +116,7 @@ describe("spawnForm preset selection vs. late /api/info (#76)", () => {
// Untouched: the refined preset list is still free to settle the command. // Untouched: the refined preset list is still free to settle the command.
expect(values?.command).toBe("claude"); expect(values?.command).toBe("claude");
const fishTile = [...form.querySelectorAll<HTMLButtonElement>(".harness-card")].find( const fishTile = [...form.querySelectorAll<HTMLButtonElement>(".tile")].find(
(t) => t.dataset.id === "fish", (t) => t.dataset.id === "fish",
); );
expect(fishTile).toBeDefined(); expect(fishTile).toBeDefined();
+2 -2
View File
@@ -52,14 +52,14 @@ describe("spawn modal", () => {
expect(host.querySelector(".form-actions .cancel")).toBeTruthy(); expect(host.querySelector(".form-actions .cancel")).toBeTruthy();
}); });
it("opens with the project filter focused, harness before project", async () => { it("opens with the project filter focused, ahead of the command tiles", async () => {
openSpawnModal({ onSuccess: () => {} }); openSpawnModal({ onSuccess: () => {} });
await flush(); await flush();
const form = modal()!.querySelector("form.spawn-form")!; const form = modal()!.querySelector("form.spawn-form")!;
expect(document.activeElement).toBe(form.querySelector(".project-filter")); expect(document.activeElement).toBe(form.querySelector(".project-filter"));
const fields = [...form.querySelectorAll(".field .field-label")].map((el) => el.textContent); const fields = [...form.querySelectorAll(".field .field-label")].map((el) => el.textContent);
expect(fields.slice(0, 2)).toEqual(["Harness", "Project"]); expect(fields.slice(0, 2)).toEqual(["Project", "Command"]);
}); });
it("titles a seeded open as a clone", async () => { it("titles a seeded open as a clone", async () => {
+14 -78
View File
@@ -134,7 +134,7 @@ describe("structured connection: data flow", () => {
} }); } });
input.dispatchEvent(paste); input.dispatchEvent(paste);
conn.el.querySelector("form")!.dispatchEvent(new Event("submit", { cancelable: true })); conn.el.querySelector("form")!.dispatchEvent(new Event("submit", { cancelable: true }));
await vi.waitFor(() => expect(conn.el.textContent).toContain("Upload failed")); await vi.waitFor(() => expect(input.disabled).toBe(false));
expect(input.value).toBe("Read this"); expect(input.value).toBe("Read this");
expect(sendStructuredPrompt).not.toHaveBeenCalled(); expect(sendStructuredPrompt).not.toHaveBeenCalled();
const remove = conn.el.querySelector<HTMLButtonElement>('[aria-label="Remove notes.txt"]')!; const remove = conn.el.querySelector<HTMLButtonElement>('[aria-label="Remove notes.txt"]')!;
@@ -413,39 +413,6 @@ describe("structured connection: data flow", () => {
conn.dispose(); conn.dispose();
}); });
it("absorbs intermediate agent text into tool group, keeps final text outside", () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
ws.open();
ws.emit({
type: "transcript_snapshot",
snapshot: {
cursor: 8,
rows: [
row("user:1", "user", { content: { type: "text", text: "do it" } }),
row("agent:1", "agent_text", { content: { type: "text", text: "Let me check" } }),
toolRow("tool:1", "one"),
toolRow("tool:2", "two"),
row("agent:2", "agent_text", { content: { type: "text", text: "Found issues, fixing" } }),
toolRow("tool:3", "three"),
toolRow("tool:4", "four"),
row("agent:3", "agent_text", { content: { type: "text", text: "All done" } }),
],
},
});
const groups = conn.el.querySelectorAll<HTMLElement>(".st-tool-group");
expect(groups).toHaveLength(1);
// Header counts only tool rows, not intermediate text.
expect(groups[0].querySelector(".st-tool-group-head")!.textContent).toContain("4 tool calls");
// Intermediate text (agent:2) is inside the group.
expect(conn.el.querySelector('[data-row-id="agent:2"]')?.closest(".st-tool-group")).not.toBeNull();
// First text (agent:1) is outside — it precedes all tools.
expect(conn.el.querySelector('[data-row-id="agent:1"]')?.closest(".st-tool-group")).toBeNull();
// Final text (agent:3) is outside — no tool follows it.
expect(conn.el.querySelector('[data-row-id="agent:3"]')?.closest(".st-tool-group")).toBeNull();
conn.dispose();
});
it("keeps an expanded running tool open without emitting a click when patched", () => { it("keeps an expanded running tool open without emitting a click when patched", () => {
const ws = new FakeSocket(); const ws = new FakeSocket();
const conn = mount({ sockets: [ws] }); const conn = mount({ sockets: [ws] });
@@ -693,7 +660,7 @@ describe("structured connection: turn/composer gating", () => {
conn.dispose(); conn.dispose();
}); });
it("keeps the composer available while a turn is active", () => { it("disables the composer while a turn is active and re-enables when idle", () => {
const ws = new FakeSocket(); const ws = new FakeSocket();
const conn = mount({ sockets: [ws] }); const conn = mount({ sockets: [ws] });
ws.open(); ws.open();
@@ -701,13 +668,13 @@ describe("structured connection: turn/composer gating", () => {
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement; const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
const cancel = conn.el.querySelector(".structured-cancel") as HTMLButtonElement; const cancel = conn.el.querySelector(".structured-cancel") as HTMLButtonElement;
ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } }); ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } });
expect(input.disabled).toBe(false); expect(input.disabled).toBe(true);
expect(cancel.hidden).toBe(false); expect(cancel.hidden).toBe(false);
expect((conn.el.querySelector(".structured-send span") as HTMLElement).textContent).toBe("Send"); expect((conn.el.querySelector(".structured-send") as HTMLButtonElement).hidden).toBe(true);
ws.emit({ type: "reduced_state", state: idleState }); ws.emit({ type: "reduced_state", state: idleState });
expect(input.disabled).toBe(false); expect(input.disabled).toBe(false);
expect(cancel.hidden).toBe(true); expect(cancel.hidden).toBe(true);
expect((conn.el.querySelector(".structured-send span") as HTMLElement).textContent).toBe("Send"); expect((conn.el.querySelector(".structured-send") as HTMLButtonElement).hidden).toBe(false);
conn.dispose(); conn.dispose();
}); });
@@ -770,7 +737,7 @@ describe("structured connection: turn/composer gating", () => {
conn.dispose(); conn.dispose();
}); });
it("queues a second message before begin-turn is projected", () => { it("disables immediately and dispatches only once before begin-turn is projected", () => {
vi.mocked(sendStructuredPrompt).mockImplementationOnce(() => new Promise(() => {})); vi.mocked(sendStructuredPrompt).mockImplementationOnce(() => new Promise(() => {}));
const ws = new FakeSocket(); const ws = new FakeSocket();
const conn = mount({ sockets: [ws] }); const conn = mount({ sockets: [ws] });
@@ -783,43 +750,12 @@ describe("structured connection: turn/composer gating", () => {
input.value = "only once"; input.value = "only once";
form.dispatchEvent(new SubmitEvent("submit", { bubbles: true, cancelable: true })); form.dispatchEvent(new SubmitEvent("submit", { bubbles: true, cancelable: true }));
input.value = "queued next";
form.dispatchEvent(new SubmitEvent("submit", { bubbles: true, cancelable: true })); form.dispatchEvent(new SubmitEvent("submit", { bubbles: true, cancelable: true }));
expect(input.disabled).toBe(false); expect(input.disabled).toBe(true);
expect(send.disabled).toBe(false); expect(send.disabled).toBe(true);
expect(sendStructuredPrompt).toHaveBeenCalledTimes(1); expect(sendStructuredPrompt).toHaveBeenCalledTimes(1);
expect(sendStructuredPrompt).toHaveBeenCalledWith("s1", "only once"); expect(sendStructuredPrompt).toHaveBeenCalledWith("s1", "only once");
expect(conn.el.querySelector(".structured-queue")?.textContent).toBe("1 message queued");
conn.dispose();
});
it("dispatches queued messages in order as turns become idle", async () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
ws.open();
ws.emit({ type: "reduced_state", state: idleState });
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
const submit = (text: string) => {
input.value = text;
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
};
submit("first");
ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } });
submit("second");
submit("third");
expect(sendStructuredPrompt).toHaveBeenCalledTimes(1);
expect(conn.el.querySelector(".structured-queue")?.textContent).toBe("2 messages queued");
await Promise.resolve().then(() => Promise.resolve());
ws.emit({ type: "reduced_state", state: idleState });
expect(sendStructuredPrompt).toHaveBeenNthCalledWith(2, "s1", "second");
ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } });
await Promise.resolve().then(() => Promise.resolve());
ws.emit({ type: "reduced_state", state: idleState });
expect(sendStructuredPrompt).toHaveBeenNthCalledWith(3, "s1", "third");
expect((conn.el.querySelector(".structured-queue") as HTMLElement).hidden).toBe(true);
conn.dispose(); conn.dispose();
}); });
@@ -871,7 +807,7 @@ describe("structured connection: turn/composer gating", () => {
expect(sendStructuredPrompt).toHaveBeenCalledTimes(2); expect(sendStructuredPrompt).toHaveBeenCalledTimes(2);
expect(input.value).toBe(""); expect(input.value).toBe("");
expect(input.disabled).toBe(false); expect(input.disabled).toBe(true);
expect(conn.el.textContent).not.toContain("stale failure"); expect(conn.el.textContent).not.toContain("stale failure");
conn.dispose(); conn.dispose();
}); });
@@ -1190,7 +1126,7 @@ describe("structured connection: reconnection/error/loading", () => {
second.open(); second.open();
second.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } }); second.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
second.emit({ type: "reduced_state", state: idleState }); second.emit({ type: "reduced_state", state: idleState });
expect(input.disabled).toBe(false); expect(input.disabled).toBe(true);
second.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } }); second.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } });
second.emit({ type: "reduced_state", state: idleState }); second.emit({ type: "reduced_state", state: idleState });
@@ -1242,12 +1178,12 @@ describe("structured connection: reconnection/error/loading", () => {
second.open(); second.open();
second.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } }); second.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
second.emit({ type: "reduced_state", state: idleState }); second.emit({ type: "reduced_state", state: idleState });
expect(input.disabled).toBe(false); expect(input.disabled).toBe(true);
accept(); accept();
await Promise.resolve().then(() => Promise.resolve()); await Promise.resolve().then(() => Promise.resolve());
expect(input.disabled).toBe(false); expect(input.disabled).toBe(true);
expect(second.closed).toBe(true); expect(second.closed).toBe(true);
third.open(); third.open();
@@ -1648,7 +1584,7 @@ describe("structured connection: pane lifecycle", () => {
conn.dispose(); conn.dispose();
}); });
it("focusTerminal keeps the composer reachable during an active turn", () => { it("focusTerminal focuses the composer only when it is enabled", () => {
const ws = new FakeSocket(); const ws = new FakeSocket();
const conn = mount({ sockets: [ws] }); const conn = mount({ sockets: [ws] });
ws.open(); ws.open();
@@ -1656,7 +1592,7 @@ describe("structured connection: pane lifecycle", () => {
ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } }); ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } });
conn.focusTerminal(); conn.focusTerminal();
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement; const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
expect(document.activeElement).toBe(input); expect(document.activeElement).not.toBe(input);
ws.emit({ type: "reduced_state", state: idleState }); ws.emit({ type: "reduced_state", state: idleState });
conn.focusTerminal(); conn.focusTerminal();
expect(document.activeElement).toBe(input); expect(document.activeElement).toBe(input);
+36 -117
View File
@@ -113,11 +113,7 @@ export function createStructuredConnection(
<button type="submit" class="btn btn-primary structured-send"><span>Send</span> <button type="submit" class="btn btn-primary structured-send"><span>Send</span>
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="m7 5 5 5-5 5M12 10H3"/></svg> <svg viewBox="0 0 20 20" aria-hidden="true"><path d="m7 5 5 5-5 5M12 10H3"/></svg>
</button> </button>
<button type="button" class="btn btn-icon structured-cancel" hidden aria-label="Cancel turn" title="Cancel turn"> <button type="button" class="btn structured-cancel" hidden>Cancel turn</button>
<svg class="structured-cancel-spinner" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2" stroke-dasharray="50 14" stroke-linecap="round"/></svg>
<svg class="structured-cancel-stop" viewBox="0 0 24 24" aria-hidden="true"><rect x="6" y="6" width="12" height="12" rx="2" fill="currentColor"/></svg>
</button>
<span class="structured-queue" role="status" aria-live="polite" hidden></span>
</form> </form>
</div> </div>
</div> </div>
@@ -137,14 +133,11 @@ export function createStructuredConnection(
const commandMenu = el.querySelector(".structured-command-menu") as HTMLDivElement; const commandMenu = el.querySelector(".structured-command-menu") as HTMLDivElement;
const input = el.querySelector(".structured-input") as HTMLTextAreaElement; const input = el.querySelector(".structured-input") as HTMLTextAreaElement;
const sendBtn = el.querySelector(".structured-send") as HTMLButtonElement; const sendBtn = el.querySelector(".structured-send") as HTMLButtonElement;
const sendLabel = sendBtn.querySelector("span") as HTMLSpanElement;
const cancelBtn = el.querySelector(".structured-cancel") as HTMLButtonElement; const cancelBtn = el.querySelector(".structured-cancel") as HTMLButtonElement;
const queueEl = el.querySelector(".structured-queue") as HTMLSpanElement;
const attachBtn = el.querySelector(".structured-attach") as HTMLButtonElement; const attachBtn = el.querySelector(".structured-attach") as HTMLButtonElement;
const fileInput = el.querySelector(".structured-file-input") as HTMLInputElement; const fileInput = el.querySelector(".structured-file-input") as HTMLInputElement;
const attachmentEl = el.querySelector(".structured-attachments") as HTMLDivElement; const attachmentEl = el.querySelector(".structured-attachments") as HTMLDivElement;
type Attachment = { file: File; path?: string }; type Attachment = { file: File; path?: string };
type QueuedPrompt = { text: string; attachments: Attachment[] };
let attachments: Attachment[] = []; let attachments: Attachment[] = [];
let uploading = false; let uploading = false;
let pasteCount = 0; let pasteCount = 0;
@@ -162,7 +155,7 @@ export function createStructuredConnection(
remove.type = "button"; remove.type = "button";
remove.textContent = "×"; remove.textContent = "×";
remove.setAttribute("aria-label", `Remove ${attachment.file.name}`); remove.setAttribute("aria-label", `Remove ${attachment.file.name}`);
remove.disabled = uploading; remove.disabled = input.disabled;
remove.addEventListener("click", () => { remove.addEventListener("click", () => {
attachments = attachments.filter((item) => item !== attachment); attachments = attachments.filter((item) => item !== attachment);
renderAttachments(); renderAttachments();
@@ -173,7 +166,7 @@ export function createStructuredConnection(
} }
function addFiles(files: File[]): void { function addFiles(files: File[]): void {
if (uploading || disposed) return; if (input.disabled || disposed) return;
for (const file of files) { for (const file of files) {
if (file.size > 20 * 1024 * 1024) { if (file.size > 20 * 1024 * 1024) {
showError(`${file.name} exceeds 20 MiB. Choose a smaller file.`); showError(`${file.name} exceeds 20 MiB. Choose a smaller file.`);
@@ -200,7 +193,7 @@ export function createStructuredConnection(
}); });
input.addEventListener("paste", (event) => { input.addEventListener("paste", (event) => {
const clipboard = event.clipboardData; const clipboard = event.clipboardData;
if (!clipboard || uploading) return; if (!clipboard || input.disabled) return;
if (clipboard.files.length) { if (clipboard.files.length) {
event.preventDefault(); event.preventDefault();
addFiles(Array.from(clipboard.files)); addFiles(Array.from(clipboard.files));
@@ -251,8 +244,6 @@ export function createStructuredConnection(
let disposed = false; let disposed = false;
let optimisticTurnStartedAt: number | null = null; let optimisticTurnStartedAt: number | null = null;
let activityTimer: number | null = null; let activityTimer: number | null = null;
const promptQueue: QueuedPrompt[] = [];
let promptRequestPending = false;
// Within 64px, wheel and touch imprecision should still count as following. // Within 64px, wheel and touch imprecision should still count as following.
const FOLLOW_BOTTOM_THRESHOLD_PX = 64; const FOLLOW_BOTTOM_THRESHOLD_PX = 64;
@@ -402,53 +393,17 @@ export function createStructuredConnection(
return el; return el;
} }
/** Group tool rows (and intermediate agent text between them) by turn. function toolGroupsByTurn(): Map<string, TranscriptRow[]> {
* An agent_text/agent_thought row is "intermediate" when a tool row follows const groups = new Map<string, TranscriptRow[]>();
* it in the same turn — it gets absorbed into the group so the collapsed
* block preserves chronological order. The *last* agent text with no
* subsequent tool stays standalone (the prominent final answer).
* Returns groups keyed by `turn:<userId>`, each with `toolCount` (for the
* header label) and `rows` (tools + intermediate text, in order). */
function activityGroupsByTurn(): Map<string, { toolCount: number; rows: TranscriptRow[] }> {
// First: count tools per turn to know which turns qualify for grouping.
const toolCounts = new Map<string, number>();
let turnId = "before-first-message"; let turnId = "before-first-message";
for (const row of rows) { for (const row of rows) {
if (row.kind === "user") turnId = row.id; if (row.kind === "user") turnId = row.id;
if (row.kind !== "tool_call" && row.kind !== "tool_diff") continue; if (row.kind !== "tool_call" && row.kind !== "tool_diff") continue;
const gid = `turn:${turnId}`; const id = `turn:${turnId}`;
toolCounts.set(gid, (toolCounts.get(gid) ?? 0) + 1); const group = groups.get(id) ?? [];
group.push(row);
groups.set(id, group);
} }
// Second: build groups with interleaved text for turns with 2+ tools.
const groups = new Map<string, { toolCount: number; rows: TranscriptRow[] }>();
turnId = "before-first-message";
// Pending text rows that might be intermediate (not yet confirmed by a
// following tool). If no tool follows, they stay standalone.
let pendingText: TranscriptRow[] = [];
let currentGid = `turn:${turnId}`;
for (const row of rows) {
if (row.kind === "user") {
turnId = row.id;
currentGid = `turn:${turnId}`;
pendingText = [];
continue;
}
const count = toolCounts.get(currentGid) ?? 0;
if (count < 2) continue;
if (row.kind === "agent_text" || row.kind === "agent_thought") {
pendingText.push(row);
continue;
}
if (row.kind === "tool_call" || row.kind === "tool_diff") {
const group = groups.get(currentGid) ?? { toolCount: count, rows: [] };
// Flush any pending text — a tool follows, so it's intermediate.
group.rows.push(...pendingText);
pendingText = [];
group.rows.push(row);
groups.set(currentGid, group);
}
}
// pendingText at end of a turn = final text, stays outside (not flushed).
return groups; return groups;
} }
@@ -458,9 +413,9 @@ export function createStructuredConnection(
const focusedGroupId = active?.closest<HTMLElement>("[data-tool-group-id]")?.dataset.toolGroupId; const focusedGroupId = active?.closest<HTMLElement>("[data-tool-group-id]")?.dataset.toolGroupId;
const restoreToolFocus = active?.classList.contains("st-tool-head") ?? false; const restoreToolFocus = active?.classList.contains("st-tool-head") ?? false;
const restoreGroupFocus = active?.classList.contains("st-tool-group-head") ?? false; const restoreGroupFocus = active?.classList.contains("st-tool-group-head") ?? false;
const groups = activityGroupsByTurn(); const groups = toolGroupsByTurn();
const groupedRowIds = new Set<string>(); const groupedRowIds = new Set<string>();
const liveGroupIds = new Set([...groups.keys()]); const liveGroupIds = new Set([...groups].filter(([, tools]) => tools.length > 1).map(([id]) => id));
for (const id of expandedGroupIds) if (!liveGroupIds.has(id)) expandedGroupIds.delete(id); for (const id of expandedGroupIds) if (!liveGroupIds.has(id)) expandedGroupIds.delete(id);
rowEls.clear(); rowEls.clear();
@@ -470,30 +425,28 @@ export function createStructuredConnection(
for (const row of rows) { for (const row of rows) {
if (row.kind === "user") turnId = row.id; if (row.kind === "user") turnId = row.id;
const groupId = `turn:${turnId}`; const groupId = `turn:${turnId}`;
const group = groups.get(groupId); const groupRows = groups.get(groupId) ?? [];
if (group && groupedRowIds.has(row.id)) continue; if ((row.kind === "tool_call" || row.kind === "tool_diff") && groupRows.length > 1) {
if (group && (row.kind === "tool_call" || row.kind === "tool_diff") && !groupedRowIds.has(row.id)) { if (groupedRowIds.has(row.id)) continue;
// First ungrouped tool in this turn — render the whole activity group. const toolEls = groupRows.flatMap((toolRow) => {
const memberEls = group.rows.flatMap((memberRow) => { groupedRowIds.add(toolRow.id);
groupedRowIds.add(memberRow.id); const toolEl = buildRowElement(toolRow);
const memberEl = buildRowElement(memberRow); if (!toolEl) return [];
if (!memberEl) return []; rowEls.set(toolRow.id, toolEl);
rowEls.set(memberRow.id, memberEl); return [toolEl];
return [memberEl];
}); });
const groupEl = buildToolGroup( const groupEl = buildToolGroup(
groupId, groupId,
group.rows, groupRows,
memberEls, toolEls,
expandedGroupIds.has(groupId), expandedGroupIds.has(groupId),
(expanded) => { (expanded) => {
if (expanded) expandedGroupIds.add(groupId); if (expanded) expandedGroupIds.add(groupId);
else expandedGroupIds.delete(groupId); else expandedGroupIds.delete(groupId);
settleDisclosureLayout(); settleDisclosureLayout();
}, },
group.toolCount,
); );
if (group.rows.some((r) => enteringRowIds.has(r.id))) { if (groupRows.some((toolRow) => enteringRowIds.has(toolRow.id))) {
groupEl.classList.add("st-row-enter"); groupEl.classList.add("st-row-enter");
} }
groupEls.set(groupId, groupEl); groupEls.set(groupId, groupEl);
@@ -653,19 +606,16 @@ export function createStructuredConnection(
} }
function renderComposerState(): void { function renderComposerState(): void {
const active = uploading || state.turn.responding || state.turn.thinking || awaitingBeginGeneration !== null;
const projectedActive = state.turn.responding || state.turn.thinking; const projectedActive = state.turn.responding || state.turn.thinking;
input.disabled = false; input.disabled = active;
attachBtn.disabled = uploading; attachBtn.disabled = active;
fileInput.disabled = uploading; fileInput.disabled = active;
renderAttachments(); renderAttachments();
sendBtn.disabled = false; sendBtn.disabled = active;
sendBtn.hidden = false; sendBtn.hidden = projectedActive;
sendLabel.textContent = "Send";
cancelBtn.hidden = !projectedActive; cancelBtn.hidden = !projectedActive;
queueEl.hidden = promptQueue.length === 0; if (active) closeCommandMenu();
queueEl.textContent = promptQueue.length === 1
? "1 message queued"
: `${promptQueue.length} messages queued`;
renderActivity(); renderActivity();
} }
@@ -727,7 +677,6 @@ export function createStructuredConnection(
} }
renderConfig(); renderConfig();
renderComposerState(); renderComposerState();
dispatchQueuedPrompt();
} }
function closeConfigMenu(): void { function closeConfigMenu(): void {
@@ -927,14 +876,17 @@ export function createStructuredConnection(
if (!commandMenu.contains(document.activeElement)) closeCommandMenu(); if (!commandMenu.contains(document.activeElement)) closeCommandMenu();
}, 0); }, 0);
}); });
function dispatchPrompt(text: string, submittedAttachments: Attachment[] = []): void { composerForm.addEventListener("submit", (e) => {
e.preventDefault();
const text = input.value;
if ((!text.trim() && attachments.length === 0) || input.disabled) return;
const submittedAttachments = attachments;
uploading = submittedAttachments.length > 0; uploading = submittedAttachments.length > 0;
const generation = ++submissionGeneration; const generation = ++submissionGeneration;
const submissionSocketGeneration = socketGeneration; const submissionSocketGeneration = socketGeneration;
awaitingBeginGeneration = generation; awaitingBeginGeneration = generation;
optimisticTurnStartedAt = Date.now(); optimisticTurnStartedAt = Date.now();
acceptedSubmission = null; acceptedSubmission = null;
promptRequestPending = true;
input.value = ""; input.value = "";
autoResize(); autoResize();
clearError(); clearError();
@@ -951,7 +903,6 @@ export function createStructuredConnection(
}; };
void send().then( void send().then(
() => { () => {
promptRequestPending = false;
if (disposed || generation !== submissionGeneration) return; if (disposed || generation !== submissionGeneration) return;
uploading = false; uploading = false;
if (attachments === submittedAttachments) { if (attachments === submittedAttachments) {
@@ -965,17 +916,11 @@ export function createStructuredConnection(
// A replacement socket may have captured idle before the POST claimed // A replacement socket may have captured idle before the POST claimed
// the prompt. Reconnect again so its snapshot cannot predate the 204. // the prompt. Reconnect again so its snapshot cannot predate the 204.
if (acceptanceSocketGeneration > submissionSocketGeneration) reconnectForReconciliation(); if (acceptanceSocketGeneration > submissionSocketGeneration) reconnectForReconciliation();
renderComposerState();
dispatchQueuedPrompt();
}, },
(e: unknown) => { (e: unknown) => {
promptRequestPending = false;
uploading = false; uploading = false;
if (disposed || generation !== submissionGeneration) return; if (disposed || generation !== submissionGeneration) return;
if (awaitingBeginGeneration === generation) awaitingBeginGeneration = null; if (awaitingBeginGeneration === generation) awaitingBeginGeneration = null;
if (attachments.length === 0 && submittedAttachments.length > 0) {
attachments = submittedAttachments;
}
if (input.value === "") { if (input.value === "") {
input.value = text; input.value = text;
autoResize(); autoResize();
@@ -984,32 +929,6 @@ export function createStructuredConnection(
renderComposerState(); renderComposerState();
}, },
); );
}
function dispatchQueuedPrompt(): void {
const active = state.turn.responding || state.turn.thinking || awaitingBeginGeneration !== null;
if (disposed || active || promptRequestPending || promptQueue.length === 0) return;
const next = promptQueue.shift()!;
dispatchPrompt(next.text, next.attachments);
}
composerForm.addEventListener("submit", (e) => {
e.preventDefault();
const text = input.value;
if ((!text.trim() && attachments.length === 0) || uploading) return;
const submittedAttachments = attachments;
const active = state.turn.responding || state.turn.thinking || awaitingBeginGeneration !== null;
if (active || promptQueue.length > 0) {
promptQueue.push({ text, attachments: submittedAttachments });
attachments = [];
input.value = "";
autoResize();
clearError();
renderComposerState();
dispatchQueuedPrompt();
return;
}
dispatchPrompt(text, submittedAttachments);
}); });
cancelBtn.addEventListener("click", () => { cancelBtn.addEventListener("click", () => {
clearError(); clearError();
+1 -3
View File
@@ -581,9 +581,7 @@ export function buildToolGroup(
children: HTMLElement[], children: HTMLElement[],
expanded: boolean, expanded: boolean,
onExpandedChange?: (expanded: boolean) => void, onExpandedChange?: (expanded: boolean) => void,
toolCount?: number,
): HTMLElement { ): HTMLElement {
const count = toolCount ?? rows.length;
const wrap = el("div", "st-tool-group"); const wrap = el("div", "st-tool-group");
wrap.dataset.toolGroupId = id; wrap.dataset.toolGroupId = id;
const head = document.createElement("button"); const head = document.createElement("button");
@@ -595,7 +593,7 @@ export function buildToolGroup(
glyph.textContent = "›"; glyph.textContent = "›";
glyph.setAttribute("aria-hidden", "true"); glyph.setAttribute("aria-hidden", "true");
const title = el("span", "st-tool-group-title"); const title = el("span", "st-tool-group-title");
title.textContent = `${count} tool calls`; title.textContent = `${rows.length} tool calls`;
head.append(glyph, title); head.append(glyph, title);
const body = el("div", "st-tool-group-body st-disclosure"); const body = el("div", "st-tool-group-body st-disclosure");
+40 -100
View File
@@ -2290,9 +2290,6 @@ input.leaf-rename:focus {
.structured-main { .structured-main {
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
max-width: 820px;
margin-inline: auto;
width: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex: 1 1 auto; flex: 1 1 auto;
@@ -2898,26 +2895,6 @@ input.leaf-rename:focus {
border-radius: var(--r-1); border-radius: var(--r-1);
} }
/* intermediate agent text inside a tool group — subdued so the final
answer outside the group stays the prominent one. */
.st-tool-group-body .st-row-agent {
color: var(--muted);
font-size: var(--fs-12);
padding: var(--sp-1) var(--sp-2);
}
.st-tool-group-body .st-row-agent .st-body > :first-child {
margin-top: 0;
}
.st-tool-group-body .st-row-agent .st-body > :last-child {
margin-bottom: 0;
}
.st-tool-group-body .st-row-thought {
padding: var(--sp-1) var(--sp-2);
}
.st-tool-group-body[hidden] { .st-tool-group-body[hidden] {
display: none; display: none;
} }
@@ -3093,7 +3070,6 @@ input.leaf-rename:focus {
hairline that doubles as the focus indicator. Inner metrics line the draft hairline that doubles as the focus indicator. Inner metrics line the draft
up with the transcript's own 12px gutter. */ up with the transcript's own 12px gutter. */
.structured-composer { .structured-composer {
position: relative;
flex: 0 0 auto; flex: 0 0 auto;
padding: var(--sp-1) var(--sp-1) max(var(--sp-1), env(safe-area-inset-bottom)); padding: var(--sp-1) var(--sp-1) max(var(--sp-1), env(safe-area-inset-bottom));
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
@@ -3104,21 +3080,8 @@ input.leaf-rename:focus {
transition: border-color var(--dur-fast) var(--ease-out); transition: border-color var(--dur-fast) var(--ease-out);
} }
.structured-queue {
position: absolute;
right: var(--sp-2);
bottom: calc(100% + var(--sp-1));
padding: 3px var(--sp-2);
border: 1px solid var(--border);
border-radius: var(--r-pill);
background: var(--surface);
color: var(--muted);
font-size: var(--fs-11);
box-shadow: var(--shadow-1);
}
.structured-composer:focus-within { .structured-composer:focus-within {
border-top-color: var(--border); border-top-color: var(--accent);
} }
.structured-input-wrap { .structured-input-wrap {
@@ -3234,9 +3197,7 @@ input.leaf-rename:focus {
} }
.structured-composer .structured-cancel { .structured-composer .structured-cancel {
position: relative; padding: 0 var(--sp-3);
width: 40px;
padding: 0;
border-color: color-mix(in oklab, var(--danger) 35%, var(--border)); border-color: color-mix(in oklab, var(--danger) 35%, var(--border));
color: var(--danger); color: var(--danger);
} }
@@ -3245,23 +3206,6 @@ input.leaf-rename:focus {
background: var(--danger-tint); background: var(--danger-tint);
} }
.structured-cancel-stop {
width: 16px;
height: 16px;
}
.structured-cancel-spinner {
position: absolute;
inset: 4px;
width: auto;
height: auto;
animation: cancel-spin 0.8s linear infinite;
}
@keyframes cancel-spin {
to { transform: rotate(360deg); }
}
@media (max-width: 520px) { @media (max-width: 520px) {
/* One row cannot hold a draft plus three controls at phone width — the /* One row cannot hold a draft plus three controls at phone width — the
draft gets a line of its own and the controls drop below it. */ draft gets a line of its own and the controls drop below it. */
@@ -3651,76 +3595,59 @@ input.leaf-rename:focus {
color: var(--ink); color: var(--ink);
} }
/* Harness cards: the first decision in the spawn form — which tool runs. */ /* One-click tiles: host row + command row. */
.harness-grid { .tile-row {
display: grid; display: flex;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); flex-wrap: wrap;
gap: var(--sp-2); gap: var(--sp-2);
} }
.harness-card { .tile {
flex: 1 1 auto;
min-width: 96px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; gap: 2px;
gap: var(--sp-2); align-items: flex-start;
padding: var(--sp-3) var(--sp-2); padding: var(--sp-2) var(--sp-3);
background: var(--surface-2); background: var(--surface-2);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--r-2); border-radius: var(--r-2);
color: var(--ink); color: var(--ink);
font-family: inherit; font-family: inherit;
cursor: pointer; cursor: pointer;
text-align: center; text-align: left;
transition: transition:
border-color var(--dur-fast) var(--ease-out), border-color var(--dur-fast) var(--ease-out),
background var(--dur-fast) var(--ease-out); background var(--dur-fast) var(--ease-out);
} }
.harness-card:hover { .tile:hover {
border-color: var(--border-strong); border-color: var(--border-strong);
background: var(--surface-3);
} }
.harness-card.is-active { .tile.is-active {
border-color: var(--accent); border-color: var(--accent);
background: var(--accent-tint); background: var(--accent-tint);
} }
.harness-icon { .tile-label {
display: flex; font-size: var(--fs-14);
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
color: var(--muted);
}
.harness-card.is-active .harness-icon {
color: var(--ink);
}
.harness-icon .tool-icon {
display: block;
}
.harness-label {
font-size: var(--fs-13);
font-weight: 600; font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
} }
/* The "Custom…" card is an escape hatch, not a peer tool — dashed border .tile-sub {
and subdued color separate it from the named harnesses. */ font-size: var(--fs-12);
.harness-custom {
border-style: dashed;
color: var(--muted); color: var(--muted);
font-family: var(--font-mono);
} }
.harness-custom .harness-label { .tile-more {
font-weight: 500; flex: 0 0 auto;
min-width: 0;
justify-content: center;
align-items: center;
color: var(--muted);
} }
/* Working-directory picker (#95). Two steps: a grid of project boxes, then the /* Working-directory picker (#95). Two steps: a grid of project boxes, then the
@@ -3749,7 +3676,7 @@ input.leaf-rename:focus {
.picker.projects { .picker.projects {
display: grid; display: grid;
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
align-content: start; align-content: start;
gap: var(--sp-2); gap: var(--sp-2);
max-height: 300px; max-height: 300px;
@@ -3805,6 +3732,19 @@ input.leaf-rename:focus {
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.project-meta {
font-family: var(--font-mono);
font-size: var(--fs-12);
color: var(--faint);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.project-meta:empty {
display: none;
}
.project-other { .project-other {
align-self: flex-start; align-self: flex-start;
padding: 4px 0; padding: 4px 0;