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,
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
one collapsed turn activity block. `AgentText` and `AgentThought` rows that
appear *between* tool rows in the same turn are absorbed into the group
(intermediate narration); the last `AgentText` with no subsequent tool stays
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.
one collapsed turn activity block. Opening it reveals independently
expandable tool calls, including any raw command, input, output, and diff
content the adapter supplied.
- The web client's user, agent, thought, tool-detail, and notice text blocks
copy their complete source text when clicked or keyboard-activated.
- 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
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
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
- A 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
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
queuing in the daemon — one active turn per session, never a hidden
server-side queue of prompts.
queuing — one active turn per session, never a hidden queue of prompts.
- Cancel sends `session/cancel`, clears any pending approval as `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
+3 -4
View File
@@ -663,9 +663,8 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
applyNav();
};
// Pointer hover previews only while Ctrl is held — plain hover was too
// disruptive when sweeping the sidebar. Keyboard walk (j/k) still previews
// unconditionally via applyNav.
// Hover previews the row under the pointer (docs/PREVIEW.md). Touch is left
// out: there is no hover there, and a tap already opens the session.
let hovered: string | null = null;
const setHovered = (id: string | null) => {
if (hovered === id) return;
@@ -673,7 +672,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
opts.onPreview?.(id);
};
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");
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);
});
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 { el } = twoFolders({ onPreview: (id) => seen.push(id) });
const card = el.querySelectorAll<HTMLElement>(".tree-leaf")[0];
// Plain hover — no preview.
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]);
// Deduplicated while still on the same card.
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse", ctrlKey: true }));
// Moving within the same card is not a new preview.
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse" }));
expect(seen).toHaveLength(1);
tree(el).dispatchEvent(new PointerEvent("pointerleave", { pointerType: "mouse" }));
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 { el } = twoFolders({ onPreview: (id) => seen.push(id) });
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([]);
});
});
+24 -53
View File
@@ -11,7 +11,7 @@ import { attachDirAutocomplete } from "./dirAutocomplete.js";
import { parentDir } from "./paths.js";
import { filterProjects, groupProjects, isStale, sortProjects } 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 type { BranchInfo, CreateSessionRequest, Project, Session, SpawnPreset } from "./types.js";
@@ -140,12 +140,6 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
const form = document.createElement("form");
form.className = "spawn-form";
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">
<span class="field-label">Project</span>
<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" />
</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">
<button type="button" class="tree-folder-header wt-toggle" aria-expanded="false">
<span class="chevron">▾</span>
@@ -348,69 +348,37 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
command = preset!.command;
}
updateStructuredAvailability();
for (const card of cmdTiles.querySelectorAll(".harness-card")) {
card.classList.toggle("is-active", (card as HTMLElement).dataset.id === id);
for (const tile of cmdTiles.querySelectorAll(".tile")) {
tile.classList.toggle("is-active", (tile as HTMLElement).dataset.id === id);
}
emit();
}
/** Re-highlight the operator's already-picked card after `renderCommandTiles`
* rebuilds the grid from scratch (new cards carry no `is-active` class) —
/** Re-highlight the operator's already-picked tile after `renderCommandTiles`
* rebuilds the row from scratch (new tiles carry no `is-active` class) —
* without touching `command`, since that was already settled. */
function reapplyActiveTile(): void {
for (const card of cmdTiles.querySelectorAll(".harness-card")) {
card.classList.toggle("is-active", (card as HTMLElement).dataset.id === selectedPresetId);
for (const tile of cmdTiles.querySelectorAll(".tile")) {
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 {
cmdTiles.replaceChildren();
for (const p of presets) {
const isCustom = p.command === "";
const card = document.createElement("button");
card.type = "button";
card.className = isCustom ? "harness-card harness-custom" : "harness-card";
card.dataset.id = p.id;
const toolId = presetToolId(p);
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", () => {
const tile = document.createElement("button");
tile.type = "button";
tile.className = "tile";
tile.dataset.id = p.id;
tile.innerHTML = `<span class="tile-label">${escapeHtml(p.label)}</span>`;
tile.addEventListener("click", () => {
commandTouched = true;
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
// `applyInitialCommand()` the same way `nameTouched` guards a late name
// 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.className = `project-box${isStale(project, new Date()) ? " is-stale" : ""}`;
box.dataset.cwd = project.path;
const trees = worktreesOf(group).length;
// The parent directory only — the last segment is the name line above it.
// Unabbreviated beyond `~`, since the stem is what tells two projects
// sharing a basename apart (#95).
const parent = abbreviateCwd(parentDir(project.path), homeDir);
const meta = trees > 0 ? `${trees} worktree${trees === 1 ? "" : "s"}` : "";
box.innerHTML = `
<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));
return box;
}
+1 -1
View File
@@ -139,7 +139,7 @@ describe("spawnForm Enter-to-submit (#97)", () => {
const form = await mount([MAESTRO]);
box(form, MAESTRO.path).click();
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")!;
raw.value = "bash -l";
raw.dispatchEvent(new Event("input"));
+3 -3
View File
@@ -63,7 +63,7 @@ describe("spawnForm preset selection vs. late /api/info (#76)", () => {
await drain();
// 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",
)!;
zshTile.click();
@@ -88,7 +88,7 @@ describe("spawnForm preset selection vs. late /api/info (#76)", () => {
document.body.appendChild(form);
await drain();
const customTile = [...form.querySelectorAll<HTMLButtonElement>(".harness-card")].find(
const customTile = [...form.querySelectorAll<HTMLButtonElement>(".tile")].find(
(t) => t.dataset.id === "custom",
)!;
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.
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",
);
expect(fishTile).toBeDefined();
+2 -2
View File
@@ -52,14 +52,14 @@ describe("spawn modal", () => {
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: () => {} });
await flush();
const form = modal()!.querySelector("form.spawn-form")!;
expect(document.activeElement).toBe(form.querySelector(".project-filter"));
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 () => {
+14 -78
View File
@@ -134,7 +134,7 @@ describe("structured connection: data flow", () => {
} });
input.dispatchEvent(paste);
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(sendStructuredPrompt).not.toHaveBeenCalled();
const remove = conn.el.querySelector<HTMLButtonElement>('[aria-label="Remove notes.txt"]')!;
@@ -413,39 +413,6 @@ describe("structured connection: data flow", () => {
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", () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
@@ -693,7 +660,7 @@ describe("structured connection: turn/composer gating", () => {
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 conn = mount({ sockets: [ws] });
ws.open();
@@ -701,13 +668,13 @@ describe("structured connection: turn/composer gating", () => {
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
const cancel = conn.el.querySelector(".structured-cancel") as HTMLButtonElement;
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((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 });
expect(input.disabled).toBe(false);
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();
});
@@ -770,7 +737,7 @@ describe("structured connection: turn/composer gating", () => {
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(() => {}));
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
@@ -783,43 +750,12 @@ describe("structured connection: turn/composer gating", () => {
input.value = "only once";
form.dispatchEvent(new SubmitEvent("submit", { bubbles: true, cancelable: true }));
input.value = "queued next";
form.dispatchEvent(new SubmitEvent("submit", { bubbles: true, cancelable: true }));
expect(input.disabled).toBe(false);
expect(send.disabled).toBe(false);
expect(input.disabled).toBe(true);
expect(send.disabled).toBe(true);
expect(sendStructuredPrompt).toHaveBeenCalledTimes(1);
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();
});
@@ -871,7 +807,7 @@ describe("structured connection: turn/composer gating", () => {
expect(sendStructuredPrompt).toHaveBeenCalledTimes(2);
expect(input.value).toBe("");
expect(input.disabled).toBe(false);
expect(input.disabled).toBe(true);
expect(conn.el.textContent).not.toContain("stale failure");
conn.dispose();
});
@@ -1190,7 +1126,7 @@ describe("structured connection: reconnection/error/loading", () => {
second.open();
second.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
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: idleState });
@@ -1242,12 +1178,12 @@ describe("structured connection: reconnection/error/loading", () => {
second.open();
second.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
second.emit({ type: "reduced_state", state: idleState });
expect(input.disabled).toBe(false);
expect(input.disabled).toBe(true);
accept();
await Promise.resolve().then(() => Promise.resolve());
expect(input.disabled).toBe(false);
expect(input.disabled).toBe(true);
expect(second.closed).toBe(true);
third.open();
@@ -1648,7 +1584,7 @@ describe("structured connection: pane lifecycle", () => {
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 conn = mount({ sockets: [ws] });
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: [] } } });
conn.focusTerminal();
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 });
conn.focusTerminal();
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>
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="m7 5 5 5-5 5M12 10H3"/></svg>
</button>
<button type="button" class="btn btn-icon structured-cancel" hidden aria-label="Cancel turn" title="Cancel turn">
<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>
<button type="button" class="btn structured-cancel" hidden>Cancel turn</button>
</form>
</div>
</div>
@@ -137,14 +133,11 @@ export function createStructuredConnection(
const commandMenu = el.querySelector(".structured-command-menu") as HTMLDivElement;
const input = el.querySelector(".structured-input") as HTMLTextAreaElement;
const sendBtn = el.querySelector(".structured-send") as HTMLButtonElement;
const sendLabel = sendBtn.querySelector("span") as HTMLSpanElement;
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 fileInput = el.querySelector(".structured-file-input") as HTMLInputElement;
const attachmentEl = el.querySelector(".structured-attachments") as HTMLDivElement;
type Attachment = { file: File; path?: string };
type QueuedPrompt = { text: string; attachments: Attachment[] };
let attachments: Attachment[] = [];
let uploading = false;
let pasteCount = 0;
@@ -162,7 +155,7 @@ export function createStructuredConnection(
remove.type = "button";
remove.textContent = "×";
remove.setAttribute("aria-label", `Remove ${attachment.file.name}`);
remove.disabled = uploading;
remove.disabled = input.disabled;
remove.addEventListener("click", () => {
attachments = attachments.filter((item) => item !== attachment);
renderAttachments();
@@ -173,7 +166,7 @@ export function createStructuredConnection(
}
function addFiles(files: File[]): void {
if (uploading || disposed) return;
if (input.disabled || disposed) return;
for (const file of files) {
if (file.size > 20 * 1024 * 1024) {
showError(`${file.name} exceeds 20 MiB. Choose a smaller file.`);
@@ -200,7 +193,7 @@ export function createStructuredConnection(
});
input.addEventListener("paste", (event) => {
const clipboard = event.clipboardData;
if (!clipboard || uploading) return;
if (!clipboard || input.disabled) return;
if (clipboard.files.length) {
event.preventDefault();
addFiles(Array.from(clipboard.files));
@@ -251,8 +244,6 @@ export function createStructuredConnection(
let disposed = false;
let optimisticTurnStartedAt: 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.
const FOLLOW_BOTTOM_THRESHOLD_PX = 64;
@@ -402,53 +393,17 @@ export function createStructuredConnection(
return el;
}
/** Group tool rows (and intermediate agent text between them) by turn.
* An agent_text/agent_thought row is "intermediate" when a tool row follows
* 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>();
function toolGroupsByTurn(): Map<string, TranscriptRow[]> {
const groups = new Map<string, TranscriptRow[]>();
let turnId = "before-first-message";
for (const row of rows) {
if (row.kind === "user") turnId = row.id;
if (row.kind !== "tool_call" && row.kind !== "tool_diff") continue;
const gid = `turn:${turnId}`;
toolCounts.set(gid, (toolCounts.get(gid) ?? 0) + 1);
const id = `turn:${turnId}`;
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;
}
@@ -458,9 +413,9 @@ export function createStructuredConnection(
const focusedGroupId = active?.closest<HTMLElement>("[data-tool-group-id]")?.dataset.toolGroupId;
const restoreToolFocus = active?.classList.contains("st-tool-head") ?? false;
const restoreGroupFocus = active?.classList.contains("st-tool-group-head") ?? false;
const groups = activityGroupsByTurn();
const groups = toolGroupsByTurn();
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);
rowEls.clear();
@@ -470,30 +425,28 @@ export function createStructuredConnection(
for (const row of rows) {
if (row.kind === "user") turnId = row.id;
const groupId = `turn:${turnId}`;
const group = groups.get(groupId);
if (group && groupedRowIds.has(row.id)) continue;
if (group && (row.kind === "tool_call" || row.kind === "tool_diff") && !groupedRowIds.has(row.id)) {
// First ungrouped tool in this turn — render the whole activity group.
const memberEls = group.rows.flatMap((memberRow) => {
groupedRowIds.add(memberRow.id);
const memberEl = buildRowElement(memberRow);
if (!memberEl) return [];
rowEls.set(memberRow.id, memberEl);
return [memberEl];
const groupRows = groups.get(groupId) ?? [];
if ((row.kind === "tool_call" || row.kind === "tool_diff") && groupRows.length > 1) {
if (groupedRowIds.has(row.id)) continue;
const toolEls = groupRows.flatMap((toolRow) => {
groupedRowIds.add(toolRow.id);
const toolEl = buildRowElement(toolRow);
if (!toolEl) return [];
rowEls.set(toolRow.id, toolEl);
return [toolEl];
});
const groupEl = buildToolGroup(
groupId,
group.rows,
memberEls,
groupRows,
toolEls,
expandedGroupIds.has(groupId),
(expanded) => {
if (expanded) expandedGroupIds.add(groupId);
else expandedGroupIds.delete(groupId);
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");
}
groupEls.set(groupId, groupEl);
@@ -653,19 +606,16 @@ export function createStructuredConnection(
}
function renderComposerState(): void {
const active = uploading || state.turn.responding || state.turn.thinking || awaitingBeginGeneration !== null;
const projectedActive = state.turn.responding || state.turn.thinking;
input.disabled = false;
attachBtn.disabled = uploading;
fileInput.disabled = uploading;
input.disabled = active;
attachBtn.disabled = active;
fileInput.disabled = active;
renderAttachments();
sendBtn.disabled = false;
sendBtn.hidden = false;
sendLabel.textContent = "Send";
sendBtn.disabled = active;
sendBtn.hidden = projectedActive;
cancelBtn.hidden = !projectedActive;
queueEl.hidden = promptQueue.length === 0;
queueEl.textContent = promptQueue.length === 1
? "1 message queued"
: `${promptQueue.length} messages queued`;
if (active) closeCommandMenu();
renderActivity();
}
@@ -727,7 +677,6 @@ export function createStructuredConnection(
}
renderConfig();
renderComposerState();
dispatchQueuedPrompt();
}
function closeConfigMenu(): void {
@@ -927,14 +876,17 @@ export function createStructuredConnection(
if (!commandMenu.contains(document.activeElement)) closeCommandMenu();
}, 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;
const generation = ++submissionGeneration;
const submissionSocketGeneration = socketGeneration;
awaitingBeginGeneration = generation;
optimisticTurnStartedAt = Date.now();
acceptedSubmission = null;
promptRequestPending = true;
input.value = "";
autoResize();
clearError();
@@ -951,7 +903,6 @@ export function createStructuredConnection(
};
void send().then(
() => {
promptRequestPending = false;
if (disposed || generation !== submissionGeneration) return;
uploading = false;
if (attachments === submittedAttachments) {
@@ -965,17 +916,11 @@ export function createStructuredConnection(
// A replacement socket may have captured idle before the POST claimed
// the prompt. Reconnect again so its snapshot cannot predate the 204.
if (acceptanceSocketGeneration > submissionSocketGeneration) reconnectForReconciliation();
renderComposerState();
dispatchQueuedPrompt();
},
(e: unknown) => {
promptRequestPending = false;
uploading = false;
if (disposed || generation !== submissionGeneration) return;
if (awaitingBeginGeneration === generation) awaitingBeginGeneration = null;
if (attachments.length === 0 && submittedAttachments.length > 0) {
attachments = submittedAttachments;
}
if (input.value === "") {
input.value = text;
autoResize();
@@ -984,32 +929,6 @@ export function createStructuredConnection(
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", () => {
clearError();
+1 -3
View File
@@ -581,9 +581,7 @@ export function buildToolGroup(
children: HTMLElement[],
expanded: boolean,
onExpandedChange?: (expanded: boolean) => void,
toolCount?: number,
): HTMLElement {
const count = toolCount ?? rows.length;
const wrap = el("div", "st-tool-group");
wrap.dataset.toolGroupId = id;
const head = document.createElement("button");
@@ -595,7 +593,7 @@ export function buildToolGroup(
glyph.textContent = "›";
glyph.setAttribute("aria-hidden", "true");
const title = el("span", "st-tool-group-title");
title.textContent = `${count} tool calls`;
title.textContent = `${rows.length} tool calls`;
head.append(glyph, title);
const body = el("div", "st-tool-group-body st-disclosure");
+40 -100
View File
@@ -2290,9 +2290,6 @@ input.leaf-rename:focus {
.structured-main {
min-width: 0;
min-height: 0;
max-width: 820px;
margin-inline: auto;
width: 100%;
display: flex;
flex-direction: column;
flex: 1 1 auto;
@@ -2898,26 +2895,6 @@ input.leaf-rename:focus {
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] {
display: none;
}
@@ -3093,7 +3070,6 @@ input.leaf-rename:focus {
hairline that doubles as the focus indicator. Inner metrics line the draft
up with the transcript's own 12px gutter. */
.structured-composer {
position: relative;
flex: 0 0 auto;
padding: var(--sp-1) var(--sp-1) max(var(--sp-1), env(safe-area-inset-bottom));
border-top: 1px solid var(--border);
@@ -3104,21 +3080,8 @@ input.leaf-rename:focus {
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 {
border-top-color: var(--border);
border-top-color: var(--accent);
}
.structured-input-wrap {
@@ -3234,9 +3197,7 @@ input.leaf-rename:focus {
}
.structured-composer .structured-cancel {
position: relative;
width: 40px;
padding: 0;
padding: 0 var(--sp-3);
border-color: color-mix(in oklab, var(--danger) 35%, var(--border));
color: var(--danger);
}
@@ -3245,23 +3206,6 @@ input.leaf-rename:focus {
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) {
/* 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. */
@@ -3651,76 +3595,59 @@ input.leaf-rename:focus {
color: var(--ink);
}
/* Harness cards: the first decision in the spawn form — which tool runs. */
.harness-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
/* One-click tiles: host row + command row. */
.tile-row {
display: flex;
flex-wrap: wrap;
gap: var(--sp-2);
}
.harness-card {
.tile {
flex: 1 1 auto;
min-width: 96px;
display: flex;
flex-direction: column;
align-items: center;
gap: var(--sp-2);
padding: var(--sp-3) var(--sp-2);
gap: 2px;
align-items: flex-start;
padding: var(--sp-2) var(--sp-3);
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--r-2);
color: var(--ink);
font-family: inherit;
cursor: pointer;
text-align: center;
text-align: left;
transition:
border-color var(--dur-fast) var(--ease-out),
background var(--dur-fast) var(--ease-out);
}
.harness-card:hover {
.tile:hover {
border-color: var(--border-strong);
background: var(--surface-3);
}
.harness-card.is-active {
.tile.is-active {
border-color: var(--accent);
background: var(--accent-tint);
}
.harness-icon {
display: flex;
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);
.tile-label {
font-size: var(--fs-14);
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
and subdued color separate it from the named harnesses. */
.harness-custom {
border-style: dashed;
.tile-sub {
font-size: var(--fs-12);
color: var(--muted);
font-family: var(--font-mono);
}
.harness-custom .harness-label {
font-weight: 500;
.tile-more {
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
@@ -3749,7 +3676,7 @@ input.leaf-rename:focus {
.picker.projects {
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
align-content: start;
gap: var(--sp-2);
max-height: 300px;
@@ -3805,6 +3732,19 @@ input.leaf-rename:focus {
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 {
align-self: flex-start;
padding: 4px 0;