Merge remote-tracking branch 'origin/main' into recorder-2

This commit is contained in:
Miguel Palhas
2026-09-07 10:41:59 +01:00
20 changed files with 855 additions and 66 deletions
+9
View File
@@ -184,6 +184,15 @@ export async function sendStructuredPrompt(id: Uuid, prompt: string): Promise<vo
);
}
export async function uploadStructuredFile(id: Uuid, file: File): Promise<{ path: string }> {
return jsonOrError(await fetch(`${routes.structuredAttachment(id)}?name=${encodeURIComponent(file.name)}`, {
method: "POST",
credentials: "same-origin",
headers: { "content-type": "application/octet-stream" },
body: file,
}));
}
/** Cancel a structured session's active ACP turn (#138). */
export async function cancelStructuredTurn(id: Uuid): Promise<void> {
await jsonOrError(
+4
View File
@@ -47,6 +47,10 @@ export function structuredPrompt(id: Uuid): string {
return `${session(id)}/structured/prompt`;
}
export function structuredAttachment(id: Uuid): string {
return `${session(id)}/structured/attachments`;
}
/** `POST /api/sessions/:id/structured/cancel` — cancel an ACP turn. */
export function structuredCancel(id: Uuid): string {
return `${session(id)}/structured/cancel`;
+11 -1
View File
@@ -375,13 +375,23 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
// operator has already touched (#76).
let commandTouched = false;
// Set once the operator works the checkbox themselves. Until then every
// command change re-derives the default (on for Claude Code and Codex, off
// for everything else); afterwards their choice outranks it, the same guard
// `commandTouched` gives the command field.
let structuredTouched = false;
function updateStructuredAvailability(): void {
const supported = supportsStructuredView(command);
structuredWrap.hidden = !supported;
if (!supported) structuredView.checked = false;
else if (!structuredTouched) structuredView.checked = true;
}
structuredView.addEventListener("change", emit);
structuredView.addEventListener("change", () => {
structuredTouched = true;
emit();
});
cmdRaw.addEventListener("input", () => {
if (selectedPresetId === "custom" || presets.find((p) => p.id === selectedPresetId)?.command === "") {
+42 -6
View File
@@ -44,7 +44,7 @@ describe("spawn form structured view", () => {
vi.unstubAllGlobals();
});
it("is default-off and shown for resolved Claude Code and Codex commands", async () => {
it("is default-on and shown for resolved Claude Code and Codex commands", async () => {
stubApi();
handle = createSpawnForm({ onSuccess() {} });
document.body.appendChild(handle.el);
@@ -57,7 +57,7 @@ describe("spawn form structured view", () => {
handle.el.querySelector<HTMLButtonElement>(".wt-toggle")!.click();
expect(disclosure.hidden).toBe(false);
expect(wrap.hidden).toBe(false);
expect(checkbox.checked).toBe(false);
expect(checkbox.checked).toBe(true);
const custom = handle.el.querySelector<HTMLButtonElement>('[data-id="custom"]')!;
custom.click();
@@ -65,13 +65,34 @@ describe("spawn form structured view", () => {
command.value = "FOO=1 /usr/local/bin/codex";
command.dispatchEvent(new Event("input"));
expect(wrap.hidden).toBe(false);
expect(checkbox.checked).toBe(true);
command.value = "opencode";
command.dispatchEvent(new Event("input"));
expect(wrap.hidden).toBe(true);
expect(checkbox.checked).toBe(false);
});
it("clears a checked value when Codex changes to an unsupported command", async () => {
it("keeps an unchecked box unchecked across a command change", async () => {
stubApi();
handle = createSpawnForm({ onSuccess() {} });
document.body.appendChild(handle.el);
await drain();
const checkbox = handle.el.querySelector<HTMLInputElement>("input[name=structured_view]")!;
expect(checkbox.checked).toBe(true);
checkbox.click();
expect(checkbox.checked).toBe(false);
handle.el.querySelector<HTMLButtonElement>('[data-id="custom"]')!.click();
const command = handle.el.querySelector<HTMLInputElement>(".cmd-raw")!;
command.value = "codex";
command.dispatchEvent(new Event("input"));
expect(checkbox.checked).toBe(false);
});
it("clears the checked default when Codex changes to an unsupported command", async () => {
stubApi();
handle = createSpawnForm({ onSuccess() {} });
document.body.appendChild(handle.el);
@@ -83,7 +104,6 @@ describe("spawn form structured view", () => {
command.dispatchEvent(new Event("input"));
const checkbox = handle.el.querySelector<HTMLInputElement>("input[name=structured_view]")!;
checkbox.click();
expect(checkbox.checked).toBe(true);
command.value = "opencode";
@@ -93,7 +113,7 @@ describe("spawn form structured view", () => {
expect(checkbox.checked).toBe(false);
});
it.each(["claude", "codex"])("emits Structured for a selected %s command", async (tool) => {
it.each(["claude", "codex"])("emits Structured for a %s command by default", async (tool) => {
const requests = stubApi();
handle = createSpawnForm({
seed: { cwd: "/srv/work", cwdRawVisible: true, worktreeEnabled: false },
@@ -109,7 +129,6 @@ describe("spawn form structured view", () => {
command.dispatchEvent(new Event("input"));
}
handle.el.querySelector<HTMLInputElement>("input[name=structured_view]")!.click();
handle.el.requestSubmit();
await drain();
@@ -119,4 +138,21 @@ describe("spawn form structured view", () => {
view: "Structured",
});
});
it("emits no view once the operator unticks the box", async () => {
const requests = stubApi();
handle = createSpawnForm({
seed: { cwd: "/srv/work", cwdRawVisible: true, worktreeEnabled: false },
onSuccess() {},
});
document.body.appendChild(handle.el);
await drain();
handle.el.querySelector<HTMLInputElement>("input[name=structured_view]")!.click();
handle.el.requestSubmit();
await drain();
expect(requests).toHaveLength(1);
expect(JSON.parse(String(requests[0].body)).view).toBeUndefined();
});
});
+49
View File
@@ -6,6 +6,7 @@ vi.mock("./api.js", async () => {
return {
...actual,
sendStructuredPrompt: vi.fn().mockResolvedValue(undefined),
uploadStructuredFile: vi.fn().mockResolvedValue({ path: "/uploads/pasted.txt" }),
cancelStructuredTurn: vi.fn().mockResolvedValue(undefined),
resolveStructuredApproval: vi.fn().mockResolvedValue(undefined),
setStructuredConfig: vi.fn().mockResolvedValue(undefined),
@@ -16,6 +17,7 @@ import {
cancelStructuredTurn,
resolveStructuredApproval,
sendStructuredPrompt,
uploadStructuredFile,
setStructuredConfig,
} from "./api.js";
import { TOGGLE_DRAWER_EVENT } from "./drawer.js";
@@ -95,6 +97,53 @@ function mount(opts: {
describe("structured connection: data flow", () => {
beforeEach(() => vi.clearAllMocks());
it("converts long pasted text into a removable file and sends it without reply text", async () => {
const conn = mount({ sockets: [new FakeSocket()] });
const input = conn.el.querySelector("textarea")!;
const paste = new Event("paste", { bubbles: true, cancelable: true });
Object.defineProperty(paste, "clipboardData", { value: {
files: [], getData: () => "a".repeat(4000),
} });
input.dispatchEvent(paste);
expect(paste.defaultPrevented).toBe(true);
expect(input.value).toBe("");
expect(conn.el.querySelector(".structured-attachments")!.textContent).toContain("pasted-1.txt");
conn.el.querySelector("form")!.dispatchEvent(new Event("submit", { cancelable: true }));
await vi.waitFor(() => expect(sendStructuredPrompt).toHaveBeenCalledWith("s1", expect.stringContaining("/uploads/pasted.txt")));
expect(uploadStructuredFile).toHaveBeenCalledWith("s1", expect.objectContaining({ name: "pasted-1.txt", size: 4000 }));
conn.dispose();
});
it("leaves short pastes alone", () => {
const conn = mount({ sockets: [new FakeSocket()] });
const paste = new Event("paste", { bubbles: true, cancelable: true });
Object.defineProperty(paste, "clipboardData", { value: { files: [], getData: () => "hello" } });
conn.el.querySelector("textarea")!.dispatchEvent(paste);
expect(paste.defaultPrevented).toBe(false);
conn.dispose();
});
it("retains failed uploads for retry and lets the operator remove them", async () => {
vi.mocked(uploadStructuredFile).mockRejectedValueOnce(new Error("Upload failed"));
const conn = mount({ sockets: [new FakeSocket()] });
const input = conn.el.querySelector("textarea")!;
input.value = "Read this";
const paste = new Event("paste", { bubbles: true, cancelable: true });
Object.defineProperty(paste, "clipboardData", { value: {
files: [new File(["content"], "notes.txt")], getData: () => "",
} });
input.dispatchEvent(paste);
conn.el.querySelector("form")!.dispatchEvent(new Event("submit", { cancelable: true }));
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"]')!;
expect(remove).not.toBeNull();
remove.click();
expect(conn.el.querySelector<HTMLElement>(".structured-attachments")!.hidden).toBe(true);
conn.dispose();
});
it("renders every row from the initial snapshot", () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
+106 -8
View File
@@ -10,6 +10,7 @@ import {
cancelStructuredTurn,
resolveStructuredApproval,
sendStructuredPrompt,
uploadStructuredFile,
setStructuredConfig,
structuredWsUrl,
} from "./api.js";
@@ -90,7 +91,12 @@ export function createStructuredConnection(
<span class="structured-activity-text">Thinking</span>
</div>
<form class="structured-composer">
<button type="button" class="btn btn-ghost btn-icon structured-attach" aria-label="Attach files" title="Attach files (up to 20 MiB each)">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><path d="m8 12 7-7a3 3 0 0 1 4 4L9 19a5 5 0 0 1-7-7L12 2M5 14l9-9"/></svg>
</button>
<input class="structured-file-input" type="file" multiple hidden>
<div class="structured-input-wrap">
<div class="structured-attachments" aria-label="Attachments" aria-live="polite" hidden></div>
<div id="structured-command-menu-${sessionId}" class="structured-command-menu"
role="listbox" hidden></div>
<textarea class="structured-input" rows="1" placeholder="Reply, or / for commands…"
@@ -128,6 +134,79 @@ export function createStructuredConnection(
const input = el.querySelector(".structured-input") as HTMLTextAreaElement;
const sendBtn = el.querySelector(".structured-send") as HTMLButtonElement;
const cancelBtn = el.querySelector(".structured-cancel") as HTMLButtonElement;
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 };
let attachments: Attachment[] = [];
let uploading = false;
let pasteCount = 0;
function renderAttachments(): void {
attachmentEl.replaceChildren();
attachmentEl.hidden = attachments.length === 0;
for (const attachment of attachments) {
const chip = document.createElement("span");
chip.className = "structured-attachment";
const label = document.createElement("span");
label.textContent = attachment.file.name;
label.title = `${attachment.file.name} (${attachment.file.size.toLocaleString()} bytes)`;
const remove = document.createElement("button");
remove.type = "button";
remove.textContent = "×";
remove.setAttribute("aria-label", `Remove ${attachment.file.name}`);
remove.disabled = input.disabled;
remove.addEventListener("click", () => {
attachments = attachments.filter((item) => item !== attachment);
renderAttachments();
});
chip.append(label, remove);
attachmentEl.append(chip);
}
}
function addFiles(files: File[]): void {
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.`);
} else if (attachments.length >= 10) {
showError("Attach up to 10 files per message.");
} else {
attachments.push({ file });
}
}
renderAttachments();
}
attachBtn.addEventListener("click", () => fileInput.click());
fileInput.addEventListener("change", () => {
addFiles(Array.from(fileInput.files ?? []));
fileInput.value = "";
});
composerForm.addEventListener("dragover", (event) => {
if (event.dataTransfer?.types.includes("Files")) event.preventDefault();
});
composerForm.addEventListener("drop", (event) => {
if (!event.dataTransfer?.files.length) return;
event.preventDefault();
addFiles(Array.from(event.dataTransfer.files));
});
input.addEventListener("paste", (event) => {
const clipboard = event.clipboardData;
if (!clipboard || input.disabled) return;
if (clipboard.files.length) {
event.preventDefault();
addFiles(Array.from(clipboard.files));
return;
}
const text = clipboard.getData("text/plain");
if (text.length < 4000 && text.split("\n").length < 40) return;
if (attachments.length >= 10 || new Blob([text]).size > 20 * 1024 * 1024) return;
event.preventDefault();
addFiles([new File([text], `pasted-${++pasteCount}.txt`, { type: "text/plain" })]);
input.setRangeText("", input.selectionStart, input.selectionEnd, "end");
autoResize();
});
disconnectBtn.addEventListener("click", () => opts.onRequestClose?.(sessionId));
// Mobile-only (CSS hides it on desktop, where the sidebar is always in
@@ -527,9 +606,12 @@ export function createStructuredConnection(
}
function renderComposerState(): void {
const active = state.turn.responding || state.turn.thinking || awaitingBeginGeneration !== null;
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;
renderAttachments();
sendBtn.disabled = active;
sendBtn.hidden = projectedActive;
cancelBtn.hidden = !projectedActive;
@@ -797,7 +879,9 @@ export function createStructuredConnection(
composerForm.addEventListener("submit", (e) => {
e.preventDefault();
const text = input.value;
if (!text.trim() || input.disabled) return;
if ((!text.trim() && attachments.length === 0) || input.disabled) return;
const submittedAttachments = attachments;
uploading = submittedAttachments.length > 0;
const generation = ++submissionGeneration;
const submissionSocketGeneration = socketGeneration;
awaitingBeginGeneration = generation;
@@ -807,13 +891,26 @@ export function createStructuredConnection(
autoResize();
clearError();
renderComposerState();
sendStructuredPrompt(sessionId, text).then(
const send = async () => {
for (const attachment of submittedAttachments) {
if (disposed) return;
if (!attachment.path) attachment.path = (await uploadStructuredFile(sessionId, attachment.file)).path;
}
if (disposed) return;
const references = submittedAttachments.map(({ file, path }) =>
`Attached file ${JSON.stringify(file.name)}: ${JSON.stringify(path)}`);
await sendStructuredPrompt(sessionId, [text, ...references].filter(Boolean).join("\n\n"));
};
void send().then(
() => {
if (
disposed
|| generation !== submissionGeneration
|| awaitingBeginGeneration !== generation
) return;
if (disposed || generation !== submissionGeneration) return;
uploading = false;
if (attachments === submittedAttachments) {
attachments = [];
renderAttachments();
}
renderComposerState();
if (awaitingBeginGeneration !== generation) return;
const acceptanceSocketGeneration = socketGeneration;
acceptedSubmission = { generation, socketGeneration: acceptanceSocketGeneration };
// A replacement socket may have captured idle before the POST claimed
@@ -821,6 +918,7 @@ export function createStructuredConnection(
if (acceptanceSocketGeneration > submissionSocketGeneration) reconnectForReconciliation();
},
(e: unknown) => {
uploading = false;
if (disposed || generation !== submissionGeneration) return;
if (awaitingBeginGeneration === generation) awaitingBeginGeneration = null;
if (input.value === "") {
+71 -23
View File
@@ -3064,31 +3064,24 @@ input.leaf-rename:focus {
}
/* The composer is a sibling of the scrolling transcript, not part of it: it
stays reachable at the pane edge without changing transcript scroll math. */
stays reachable at the pane edge without changing transcript scroll math.
It is a flush band at the foot of the pane rather than a floating card: its
own `--surface` fill is what separates it from the transcript, over a
hairline that doubles as the focus indicator. Inner metrics line the draft
up with the transcript's own 12px gutter. */
.structured-composer {
flex: 0 0 auto;
margin: var(--sp-2) var(--sp-3) max(var(--sp-2), env(safe-area-inset-bottom));
padding: var(--sp-1);
border: 1px solid var(--border-strong);
border-radius: var(--r-3);
padding: var(--sp-1) var(--sp-1) max(var(--sp-1), env(safe-area-inset-bottom));
border-top: 1px solid var(--border);
background: var(--surface);
display: flex;
align-items: flex-end;
gap: var(--sp-1);
box-shadow:
inset 0 1px 0 color-mix(in oklab, var(--ink) 6%, transparent),
var(--shadow-1);
transition:
border-color var(--dur-fast) var(--ease-out),
box-shadow var(--dur-fast) var(--ease-out);
transition: border-color var(--dur-fast) var(--ease-out);
}
.structured-composer:focus-within {
border-color: var(--accent);
box-shadow:
inset 0 1px 0 color-mix(in oklab, var(--ink) 8%, transparent),
0 0 0 3px var(--accent-tint),
var(--shadow-1);
border-top-color: var(--accent);
}
.structured-input-wrap {
@@ -3097,6 +3090,45 @@ input.leaf-rename:focus {
min-width: 0;
}
.structured-attachments {
display: flex;
flex-wrap: wrap;
gap: var(--sp-1);
padding: var(--sp-1);
}
.structured-attachments[hidden] { display: none; }
.structured-attachment {
display: inline-flex;
align-items: center;
min-width: 0;
max-width: 100%;
padding-left: var(--sp-2);
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--r-1);
font-size: var(--fs-11);
}
.structured-attachment > span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.structured-attachment button {
flex-shrink: 0;
min-width: var(--tap);
min-height: var(--tap);
border: 0;
background: transparent;
color: var(--muted);
}
.structured-attachment button:hover { color: var(--ink); }
.structured-attachment button:disabled { opacity: 0.5; }
.structured-input {
display: block;
width: 100%;
@@ -3114,6 +3146,10 @@ input.leaf-rename:focus {
line-height: 20px;
}
.structured-input:focus-visible {
outline: none;
}
.structured-composer .structured-send,
.structured-composer .structured-cancel {
height: 40px;
@@ -3171,17 +3207,29 @@ input.leaf-rename:focus {
}
@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. */
.structured-composer {
margin: var(--sp-1) 0 0;
padding-bottom: calc(var(--sp-1) + env(safe-area-inset-bottom, 0px));
border-right: 0;
border-bottom: 0;
border-left: 0;
border-radius: var(--r-2) var(--r-2) 0 0;
flex-wrap: wrap;
justify-content: flex-end;
padding-left: var(--sp-2);
padding-right: var(--sp-2);
}
/* `order` keeps the draft on the first line: without it the attach button,
which precedes it in the DOM, wraps onto a line of its own. */
.structured-input-wrap {
flex: 1 1 100%;
order: -1;
}
.structured-composer .structured-config {
margin-right: auto;
}
.structured-config-menu {
right: calc(-1 * (var(--control-h) + var(--sp-2)));
left: 0;
right: auto;
}
}