Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c77965c51 | ||
|
|
49df4c2e9e | ||
|
|
65d7186e1c | ||
|
|
47654aa434 | ||
|
|
6ca2ab1707 | ||
|
|
b0b0160ccb | ||
|
|
c4cd8a691e | ||
|
|
ba0c54759b | ||
|
|
5387b29049 | ||
|
|
df51aa7be9 | ||
|
|
43bfda5a60 |
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"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:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -350,11 +350,15 @@ 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."
|
||||
|
||||
- A prompt is appended as a durable `User` row before it claims the turn and
|
||||
- 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
|
||||
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 — one active turn per session, never a hidden queue of prompts.
|
||||
queuing in the daemon — one active turn per session, never a hidden
|
||||
server-side 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
|
||||
|
||||
+4
-3
@@ -663,8 +663,9 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
||||
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.
|
||||
// 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.
|
||||
let hovered: string | null = null;
|
||||
const setHovered = (id: string | null) => {
|
||||
if (hovered === id) return;
|
||||
@@ -672,7 +673,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
||||
opts.onPreview?.(id);
|
||||
};
|
||||
treeHost.addEventListener("pointerover", (e) => {
|
||||
if (e.pointerType === "touch") return;
|
||||
if (e.pointerType === "touch" || !e.ctrlKey) return;
|
||||
const row = (e.target as HTMLElement | null)?.closest<HTMLElement>(".tree-leaf");
|
||||
setHovered(row?.dataset.sessionId ?? null);
|
||||
});
|
||||
|
||||
@@ -182,26 +182,32 @@ describe("sidebar preview requests (docs/PREVIEW.md)", () => {
|
||||
expect(seen.at(-1)).toBe(null);
|
||||
});
|
||||
|
||||
it("asks for a preview of the hovered card, and clears it on the way out", () => {
|
||||
it("ctrl+hover previews a card, plain hover does not", () => {
|
||||
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]);
|
||||
// Moving within the same card is not a new preview.
|
||||
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse" }));
|
||||
|
||||
// Deduplicated while still on the same card.
|
||||
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "mouse", ctrlKey: true }));
|
||||
expect(seen).toHaveLength(1);
|
||||
|
||||
tree(el).dispatchEvent(new PointerEvent("pointerleave", { pointerType: "mouse" }));
|
||||
expect(seen.at(-1)).toBe(null);
|
||||
});
|
||||
|
||||
it("ignores touch, where a tap already opens the session", () => {
|
||||
it("ignores touch even with ctrl", () => {
|
||||
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" }));
|
||||
card.dispatchEvent(new PointerEvent("pointerover", { bubbles: true, pointerType: "touch", ctrlKey: true }));
|
||||
expect(seen).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
+52
-20
@@ -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 } from "./toolIcon.js";
|
||||
import { splitCommand, toolIconSvg, TOOL_LABELS } from "./toolIcon.js";
|
||||
import { abbreviateCwd } from "./types.js";
|
||||
import type { BranchInfo, CreateSessionRequest, Project, Session, SpawnPreset } from "./types.js";
|
||||
|
||||
@@ -140,6 +140,12 @@ 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…"
|
||||
@@ -151,12 +157,6 @@ 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,37 +348,69 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
command = preset!.command;
|
||||
}
|
||||
updateStructuredAvailability();
|
||||
for (const tile of cmdTiles.querySelectorAll(".tile")) {
|
||||
tile.classList.toggle("is-active", (tile as HTMLElement).dataset.id === id);
|
||||
for (const card of cmdTiles.querySelectorAll(".harness-card")) {
|
||||
card.classList.toggle("is-active", (card as HTMLElement).dataset.id === id);
|
||||
}
|
||||
emit();
|
||||
}
|
||||
|
||||
/** Re-highlight the operator's already-picked tile after `renderCommandTiles`
|
||||
* rebuilds the row from scratch (new tiles carry no `is-active` class) —
|
||||
/** Re-highlight the operator's already-picked card after `renderCommandTiles`
|
||||
* rebuilds the grid from scratch (new cards carry no `is-active` class) —
|
||||
* without touching `command`, since that was already settled. */
|
||||
function reapplyActiveTile(): void {
|
||||
for (const tile of cmdTiles.querySelectorAll(".tile")) {
|
||||
tile.classList.toggle("is-active", (tile as HTMLElement).dataset.id === selectedPresetId);
|
||||
for (const card of cmdTiles.querySelectorAll(".harness-card")) {
|
||||
card.classList.toggle("is-active", (card 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 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", () => {
|
||||
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", () => {
|
||||
commandTouched = true;
|
||||
selectPreset(p.id);
|
||||
});
|
||||
cmdTiles.appendChild(tile);
|
||||
cmdTiles.appendChild(card);
|
||||
}
|
||||
}
|
||||
|
||||
/** 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
|
||||
|
||||
@@ -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 .tile[data-id="custom"]')!.click();
|
||||
form.querySelector<HTMLButtonElement>('.cmd-tiles .harness-card[data-id="custom"]')!.click();
|
||||
const raw = form.querySelector<HTMLInputElement>(".cmd-raw")!;
|
||||
raw.value = "bash -l";
|
||||
raw.dispatchEvent(new Event("input"));
|
||||
|
||||
@@ -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>(".tile")].find(
|
||||
const zshTile = [...form.querySelectorAll<HTMLButtonElement>(".harness-card")].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>(".tile")].find(
|
||||
const customTile = [...form.querySelectorAll<HTMLButtonElement>(".harness-card")].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>(".tile")].find(
|
||||
const fishTile = [...form.querySelectorAll<HTMLButtonElement>(".harness-card")].find(
|
||||
(t) => t.dataset.id === "fish",
|
||||
);
|
||||
expect(fishTile).toBeDefined();
|
||||
|
||||
@@ -52,14 +52,14 @@ describe("spawn modal", () => {
|
||||
expect(host.querySelector(".form-actions .cancel")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("opens with the project filter focused, ahead of the command tiles", async () => {
|
||||
it("opens with the project filter focused, harness before project", 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(["Project", "Command"]);
|
||||
expect(fields.slice(0, 2)).toEqual(["Harness", "Project"]);
|
||||
});
|
||||
|
||||
it("titles a seeded open as a clone", async () => {
|
||||
|
||||
+45
-14
@@ -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(input.disabled).toBe(false));
|
||||
await vi.waitFor(() => expect(conn.el.textContent).toContain("Upload failed"));
|
||||
expect(input.value).toBe("Read this");
|
||||
expect(sendStructuredPrompt).not.toHaveBeenCalled();
|
||||
const remove = conn.el.querySelector<HTMLButtonElement>('[aria-label="Remove notes.txt"]')!;
|
||||
@@ -693,7 +693,7 @@ describe("structured connection: turn/composer gating", () => {
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
it("disables the composer while a turn is active and re-enables when idle", () => {
|
||||
it("keeps the composer available while a turn is active", () => {
|
||||
const ws = new FakeSocket();
|
||||
const conn = mount({ sockets: [ws] });
|
||||
ws.open();
|
||||
@@ -701,13 +701,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(true);
|
||||
expect(input.disabled).toBe(false);
|
||||
expect(cancel.hidden).toBe(false);
|
||||
expect((conn.el.querySelector(".structured-send") as HTMLButtonElement).hidden).toBe(true);
|
||||
expect((conn.el.querySelector(".structured-send span") as HTMLElement).textContent).toBe("Send");
|
||||
ws.emit({ type: "reduced_state", state: idleState });
|
||||
expect(input.disabled).toBe(false);
|
||||
expect(cancel.hidden).toBe(true);
|
||||
expect((conn.el.querySelector(".structured-send") as HTMLButtonElement).hidden).toBe(false);
|
||||
expect((conn.el.querySelector(".structured-send span") as HTMLElement).textContent).toBe("Send");
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
@@ -770,7 +770,7 @@ describe("structured connection: turn/composer gating", () => {
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
it("disables immediately and dispatches only once before begin-turn is projected", () => {
|
||||
it("queues a second message before begin-turn is projected", () => {
|
||||
vi.mocked(sendStructuredPrompt).mockImplementationOnce(() => new Promise(() => {}));
|
||||
const ws = new FakeSocket();
|
||||
const conn = mount({ sockets: [ws] });
|
||||
@@ -783,12 +783,43 @@ 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(true);
|
||||
expect(send.disabled).toBe(true);
|
||||
expect(input.disabled).toBe(false);
|
||||
expect(send.disabled).toBe(false);
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -840,7 +871,7 @@ describe("structured connection: turn/composer gating", () => {
|
||||
|
||||
expect(sendStructuredPrompt).toHaveBeenCalledTimes(2);
|
||||
expect(input.value).toBe("");
|
||||
expect(input.disabled).toBe(true);
|
||||
expect(input.disabled).toBe(false);
|
||||
expect(conn.el.textContent).not.toContain("stale failure");
|
||||
conn.dispose();
|
||||
});
|
||||
@@ -1159,7 +1190,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(true);
|
||||
expect(input.disabled).toBe(false);
|
||||
|
||||
second.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } });
|
||||
second.emit({ type: "reduced_state", state: idleState });
|
||||
@@ -1211,12 +1242,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(true);
|
||||
expect(input.disabled).toBe(false);
|
||||
|
||||
accept();
|
||||
await Promise.resolve().then(() => Promise.resolve());
|
||||
|
||||
expect(input.disabled).toBe(true);
|
||||
expect(input.disabled).toBe(false);
|
||||
expect(second.closed).toBe(true);
|
||||
|
||||
third.open();
|
||||
@@ -1617,7 +1648,7 @@ describe("structured connection: pane lifecycle", () => {
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
it("focusTerminal focuses the composer only when it is enabled", () => {
|
||||
it("focusTerminal keeps the composer reachable during an active turn", () => {
|
||||
const ws = new FakeSocket();
|
||||
const conn = mount({ sockets: [ws] });
|
||||
ws.open();
|
||||
@@ -1625,7 +1656,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).not.toBe(input);
|
||||
expect(document.activeElement).toBe(input);
|
||||
ws.emit({ type: "reduced_state", state: idleState });
|
||||
conn.focusTerminal();
|
||||
expect(document.activeElement).toBe(input);
|
||||
|
||||
+72
-16
@@ -113,7 +113,11 @@ 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 structured-cancel" hidden>Cancel turn</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>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -133,11 +137,14 @@ 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;
|
||||
@@ -155,7 +162,7 @@ export function createStructuredConnection(
|
||||
remove.type = "button";
|
||||
remove.textContent = "×";
|
||||
remove.setAttribute("aria-label", `Remove ${attachment.file.name}`);
|
||||
remove.disabled = input.disabled;
|
||||
remove.disabled = uploading;
|
||||
remove.addEventListener("click", () => {
|
||||
attachments = attachments.filter((item) => item !== attachment);
|
||||
renderAttachments();
|
||||
@@ -166,7 +173,7 @@ export function createStructuredConnection(
|
||||
}
|
||||
|
||||
function addFiles(files: File[]): void {
|
||||
if (input.disabled || disposed) return;
|
||||
if (uploading || disposed) return;
|
||||
for (const file of files) {
|
||||
if (file.size > 20 * 1024 * 1024) {
|
||||
showError(`${file.name} exceeds 20 MiB. Choose a smaller file.`);
|
||||
@@ -193,7 +200,7 @@ export function createStructuredConnection(
|
||||
});
|
||||
input.addEventListener("paste", (event) => {
|
||||
const clipboard = event.clipboardData;
|
||||
if (!clipboard || input.disabled) return;
|
||||
if (!clipboard || uploading) return;
|
||||
if (clipboard.files.length) {
|
||||
event.preventDefault();
|
||||
addFiles(Array.from(clipboard.files));
|
||||
@@ -244,6 +251,8 @@ 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;
|
||||
@@ -542,6 +551,19 @@ export function createStructuredConnection(
|
||||
rows.push(delta.row);
|
||||
} else {
|
||||
rows[i] = delta.row;
|
||||
// Surgical DOM swap: replace just this row's element instead of
|
||||
// tearing down and rebuilding the entire transcript, which causes
|
||||
// visible flashing and breaks text selection.
|
||||
const oldEl = rowEls.get(delta.row.id);
|
||||
if (oldEl) {
|
||||
const newEl = buildRowElement(delta.row);
|
||||
if (newEl) {
|
||||
oldEl.replaceWith(newEl);
|
||||
rowEls.set(delta.row.id, newEl);
|
||||
}
|
||||
syncAvailableCommands();
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const i = rowIndex.get(delta.id);
|
||||
@@ -644,16 +666,19 @@ 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 = active;
|
||||
attachBtn.disabled = active;
|
||||
fileInput.disabled = active;
|
||||
input.disabled = false;
|
||||
attachBtn.disabled = uploading;
|
||||
fileInput.disabled = uploading;
|
||||
renderAttachments();
|
||||
sendBtn.disabled = active;
|
||||
sendBtn.hidden = projectedActive;
|
||||
sendBtn.disabled = false;
|
||||
sendBtn.hidden = false;
|
||||
sendLabel.textContent = "Send";
|
||||
cancelBtn.hidden = !projectedActive;
|
||||
if (active) closeCommandMenu();
|
||||
queueEl.hidden = promptQueue.length === 0;
|
||||
queueEl.textContent = promptQueue.length === 1
|
||||
? "1 message queued"
|
||||
: `${promptQueue.length} messages queued`;
|
||||
renderActivity();
|
||||
}
|
||||
|
||||
@@ -715,6 +740,7 @@ export function createStructuredConnection(
|
||||
}
|
||||
renderConfig();
|
||||
renderComposerState();
|
||||
dispatchQueuedPrompt();
|
||||
}
|
||||
|
||||
function closeConfigMenu(): void {
|
||||
@@ -914,17 +940,14 @@ export function createStructuredConnection(
|
||||
if (!commandMenu.contains(document.activeElement)) closeCommandMenu();
|
||||
}, 0);
|
||||
});
|
||||
composerForm.addEventListener("submit", (e) => {
|
||||
e.preventDefault();
|
||||
const text = input.value;
|
||||
if ((!text.trim() && attachments.length === 0) || input.disabled) return;
|
||||
const submittedAttachments = attachments;
|
||||
function dispatchPrompt(text: string, submittedAttachments: Attachment[] = []): void {
|
||||
uploading = submittedAttachments.length > 0;
|
||||
const generation = ++submissionGeneration;
|
||||
const submissionSocketGeneration = socketGeneration;
|
||||
awaitingBeginGeneration = generation;
|
||||
optimisticTurnStartedAt = Date.now();
|
||||
acceptedSubmission = null;
|
||||
promptRequestPending = true;
|
||||
input.value = "";
|
||||
autoResize();
|
||||
clearError();
|
||||
@@ -941,6 +964,7 @@ export function createStructuredConnection(
|
||||
};
|
||||
void send().then(
|
||||
() => {
|
||||
promptRequestPending = false;
|
||||
if (disposed || generation !== submissionGeneration) return;
|
||||
uploading = false;
|
||||
if (attachments === submittedAttachments) {
|
||||
@@ -954,11 +978,17 @@ 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();
|
||||
@@ -967,6 +997,32 @@ 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();
|
||||
|
||||
+83
-33
@@ -2290,6 +2290,9 @@ 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;
|
||||
@@ -2306,9 +2309,6 @@ input.leaf-rename:focus {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
max-width: 820px;
|
||||
margin-inline: auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.structured-activity {
|
||||
@@ -3093,6 +3093,7 @@ 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);
|
||||
@@ -3103,8 +3104,21 @@ 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(--accent);
|
||||
border-top-color: var(--border);
|
||||
}
|
||||
|
||||
.structured-input-wrap {
|
||||
@@ -3220,7 +3234,9 @@ input.leaf-rename:focus {
|
||||
}
|
||||
|
||||
.structured-composer .structured-cancel {
|
||||
padding: 0 var(--sp-3);
|
||||
position: relative;
|
||||
width: 40px;
|
||||
padding: 0;
|
||||
border-color: color-mix(in oklab, var(--danger) 35%, var(--border));
|
||||
color: var(--danger);
|
||||
}
|
||||
@@ -3229,6 +3245,23 @@ 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. */
|
||||
@@ -3618,61 +3651,78 @@ input.leaf-rename:focus {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* One-click tiles: host row + command row. */
|
||||
.tile-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
/* 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));
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.tile {
|
||||
flex: 1 1 auto;
|
||||
min-width: 96px;
|
||||
.harness-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
align-items: flex-start;
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-3) var(--sp-2);
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-2);
|
||||
color: var(--ink);
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
text-align: center;
|
||||
transition:
|
||||
border-color var(--dur-fast) var(--ease-out),
|
||||
background var(--dur-fast) var(--ease-out);
|
||||
}
|
||||
|
||||
.tile:hover {
|
||||
.harness-card:hover {
|
||||
border-color: var(--border-strong);
|
||||
background: var(--surface-3);
|
||||
}
|
||||
|
||||
.tile.is-active {
|
||||
.harness-card.is-active {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-tint);
|
||||
}
|
||||
|
||||
.tile-label {
|
||||
font-size: var(--fs-14);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tile-sub {
|
||||
font-size: var(--fs-12);
|
||||
color: var(--muted);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.tile-more {
|
||||
flex: 0 0 auto;
|
||||
min-width: 0;
|
||||
justify-content: center;
|
||||
.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);
|
||||
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;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.harness-custom .harness-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Working-directory picker (#95). Two steps: a grid of project boxes, then the
|
||||
picked project's worktree choices. Stale beyond 7 days dims but stays
|
||||
selectable. */
|
||||
|
||||
Reference in New Issue
Block a user