diff --git a/web/workspace/deno.json b/web/workspace/deno.json index d88a8cb70..6c244499d 100644 --- a/web/workspace/deno.json +++ b/web/workspace/deno.json @@ -6,7 +6,7 @@ "dev": "deno run -A npm:vite@7.2.7 dev", "dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787", "check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json", - "test": "deno test --allow-read=src,test,tests,../../docs --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts", + "test": "deno test --allow-read=src,test,tests,../../docs --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts", "test:component": "deno run -A npm:vitest@3.2.4 run --config vitest.config.ts", "build": "deno run -A npm:vite@7.2.7 build", "preview": "deno run -A npm:vite@7.2.7 preview", diff --git a/web/workspace/src/lib/workspace/console/ComposerInput.browser.spec.ts b/web/workspace/src/lib/workspace/console/ComposerInput.browser.spec.ts new file mode 100644 index 000000000..db0498320 --- /dev/null +++ b/web/workspace/src/lib/workspace/console/ComposerInput.browser.spec.ts @@ -0,0 +1,150 @@ +// @vitest-environment happy-dom +import { cleanup, fireEvent, render, waitFor } from "@testing-library/svelte"; +import { afterEach, expect, test, vi } from "vitest"; +import { EditorView } from "@codemirror/view"; +import ComposerInput from "./ComposerInput.svelte"; + +afterEach(cleanup); +function editor(container: HTMLElement) { + return EditorView.findFromDOM( + container.querySelector(".cm-editor") as HTMLElement, + )!; +} +function type(view: EditorView, value: string, cursor = value.length) { + view.focus(); + view.dispatch({ + changes: { from: 0, to: view.state.doc.length, insert: value }, + selection: { anchor: cursor }, + }); +} +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +test("commands auto-open with descriptions; ambiguous Tab does not choose the first; arrows beat history", async () => { + const oncommand = vi.fn(); + const onsubmit = vi.fn(); + const ui = render(ComposerInput, { + historyScope: "completion-test", + oncommand, + onsubmit, + }); + const cm = editor(ui.container); + type(cm, ":"); + await ui.findByRole("listbox"); + expect(ui.getAllByRole("option")).toHaveLength(6); + expect(ui.getByText("Request immediate Worker context compaction.")).not + .toBeNull(); + await fireEvent.keyDown(cm.contentDOM, { key: "Tab" }); + expect(cm.state.doc.toString()).toBe(":"); + expect(ui.getByRole("status").textContent).toContain("Select a command"); + await fireEvent.keyDown(cm.contentDOM, { key: "ArrowDown" }); + expect(ui.getAllByRole("option")[0].getAttribute("aria-selected")).toBe( + "true", + ); + expect(cm.contentDOM.getAttribute("aria-activedescendant")).toBe( + ui.getAllByRole("option")[0].id, + ); + await fireEvent.keyDown(cm.contentDOM, { key: "ArrowUp" }); + expect(ui.getAllByRole("option")[5].getAttribute("aria-selected")).toBe( + "true", + ); + await fireEvent.keyDown(cm.contentDOM, { key: "Escape" }); + expect(ui.queryByRole("listbox")).toBeNull(); + expect(cm.state.doc.toString()).toBe(":"); + type(cm, ":comp"); + await ui.findByRole("option"); + await fireEvent.keyDown(cm.contentDOM, { key: "Tab" }); + expect(cm.state.doc.toString()).toBe(":compact "); + expect(oncommand).not.toHaveBeenCalled(); + await fireEvent.keyDown(cm.contentDOM, { key: "Enter" }); + expect(oncommand).toHaveBeenCalledOnce(); + expect(onsubmit).not.toHaveBeenCalled(); +}); + +test("click selects a command and IME/Shift-Enter do not confirm it", async () => { + const oncommand = vi.fn(); + const ui = render(ComposerInput, { + historyScope: "completion-ime", + oncommand, + }); + const cm = editor(ui.container); + type(cm, ":rew"); + await ui.findByRole("option"); + await fireEvent.keyDown(cm.contentDOM, { + key: "Enter", + isComposing: true, + keyCode: 229, + }); + expect(oncommand).not.toHaveBeenCalled(); + type(cm, ":rew"); + await ui.findByRole("option"); + await fireEvent.keyDown(cm.contentDOM, { key: "Enter", shiftKey: true }); + expect(oncommand).not.toHaveBeenCalled(); + type(cm, ":rew"); + const option = await ui.findByRole("option"); + await fireEvent.pointerDown(option); + await fireEvent.click(option); + expect(cm.state.doc.toString()).toBe(":rewind "); + expect(oncommand).toHaveBeenCalledOnce(); +}); + +test("file popup scrolls selection, drills into directories and Enter accepts without sending", async () => { + const onsubmit = vi.fn(); + const resolveFileCompletions = vi.fn(async (prefix: string) => + prefix === "src/" ? [{ value: "src/main.rs" }] : [ + { value: "src", is_dir: true }, + ...Array.from({ length: 8 }, (_, i) => ({ value: `file-${i}` })), + ] + ); + const ui = render(ComposerInput, { + historyScope: "completion-files", + onsubmit, + resolveFileCompletions, + }); + const cm = editor(ui.container); + type(cm, "read @"); + await waitFor(() => expect(ui.getAllByRole("option")).toHaveLength(6)); + await fireEvent.keyDown(cm.contentDOM, { key: "ArrowUp" }); + expect(ui.getAllByRole("option")[5].textContent).toContain("file-7"); + await fireEvent.keyDown(cm.contentDOM, { key: "ArrowDown" }); + await fireEvent.keyDown(cm.contentDOM, { key: "Tab" }); + expect(cm.state.doc.toString()).toBe("read @src/"); + await waitFor(() => expect(ui.getAllByRole("option")).toHaveLength(1)); + await fireEvent.keyDown(cm.contentDOM, { key: "Enter" }); + expect(cm.state.doc.toString()).toBe("read @src/main.rs "); + expect(onsubmit).not.toHaveBeenCalled(); +}); + +test("cursor changes, Escape, scope changes and disable discard late file results", async () => { + const old = deferred>(); + const resolveFileCompletions = vi.fn(() => old.promise); + const ui = render(ComposerInput, { + historyScope: "completion-race", + completionScope: "a", + resolveFileCompletions, + }); + const cm = editor(ui.container); + type(cm, "@old"); + await waitFor(() => expect(resolveFileCompletions).toHaveBeenCalledOnce()); + await fireEvent.keyDown(cm.contentDOM, { key: "Escape" }); + old.resolve([{ value: "old-result" }]); + await Promise.resolve(); + expect(ui.queryByRole("listbox")).toBeNull(); + type(cm, "@new"); + await ui.findByRole("listbox"); + cm.dispatch({ selection: { anchor: 0 } }); + await waitFor(() => expect(ui.queryByRole("listbox")).toBeNull()); + type(cm, ":comp"); + await ui.findByRole("listbox"); + await ui.rerender({ completionScope: "b" }); + expect(ui.queryByRole("listbox")).toBeNull(); + type(cm, ":rew"); + await ui.findByRole("listbox"); + await ui.rerender({ disabled: true }); + expect(ui.queryByRole("listbox")).toBeNull(); +}); diff --git a/web/workspace/src/lib/workspace/console/ComposerInput.svelte b/web/workspace/src/lib/workspace/console/ComposerInput.svelte index 770b9a12d..6654f02e4 100644 --- a/web/workspace/src/lib/workspace/console/ComposerInput.svelte +++ b/web/workspace/src/lib/workspace/console/ComposerInput.svelte @@ -46,6 +46,7 @@ type ComposerHistoryDirection, type ComposerHistoryEntry, } from "$lib/workspace/console/composer-history.ts"; + import { applyCompletion, completionSelection, completionTokenAt, localCommandCompletions, type ComposerCompletionEntry, type ComposerCompletionToken } from "./composer-completion"; import { shouldSubmitChatKey } from "$lib/workspace/console/chat-submit.ts"; interface Props { @@ -57,6 +58,9 @@ onkeydown?: (event: KeyboardEvent) => void; onsubmit?: () => void; onpasteimages?: (files: File[]) => void; + oncommand?: () => void; + completionScope?: string; + resolveFileCompletions?: (prefix: string, signal: AbortSignal) => Promise; } let { @@ -68,6 +72,9 @@ onkeydown, onsubmit, onpasteimages, + oncommand, + completionScope = "", + resolveFileCompletions, }: Props = $props(); let mountElement: HTMLDivElement; @@ -78,6 +85,135 @@ let nextPasteKey = 1; const editable = new Compartment(); + const completionId = $props.id(); + let completionToken = $state(null); + let completionEntries = $state([]); + let completionSelected = $state(null); + let completionBusy = $state(false); + let completionMessage = $state(""); + let completionAbort: AbortController | null = null; + let completionVersion = 0; + let completionContext = ""; + const completionStart = $derived(Math.max(0, (completionSelected ?? 0) - 5)); + const visibleCompletions = $derived(completionEntries.slice(completionStart, completionStart + 6)); + + function closeCompletion(): void { + completionVersion++; + completionAbort?.abort(); + completionAbort = null; + completionToken = null; + completionEntries = []; + completionSelected = null; + completionBusy = false; + completionMessage = ""; + } + + function refreshCompletion(force = false): void { + if (!view) return; + const selection = view.state.selection.main; + const document = view.state.doc.toString(); + const context = JSON.stringify([document, selection.from, selection.to]); + if (!force && context === completionContext) return; + completionContext = context; + closeCompletion(); + if (disabled || !view.hasFocus || view.composing || !selection.empty) return; + const token = completionTokenAt(document, selection.head); + if (!token) return; + completionToken = token; + if (token.kind === "command") { + completionEntries = localCommandCompletions(token.prefix); + if (!completionEntries.length) completionMessage = "No matching commands"; + return; + } + if (!resolveFileCompletions) { closeCompletion(); return; } + completionBusy = true; + const version = completionVersion; + const abort = new AbortController(); + completionAbort = abort; + void resolveFileCompletions(token.prefix, abort.signal).then((entries) => { + if (version !== completionVersion || abort.signal.aborted) return; + completionEntries = entries; + completionSelected = entries.length ? 0 : null; + if (!entries.length) completionMessage = "No matching files"; + }).catch((error) => { + if (version !== completionVersion || abort.signal.aborted) return; + completionMessage = error instanceof Error ? error.message : String(error); + }).finally(() => { + if (version === completionVersion) completionBusy = false; + }); + } + + function acceptCompletion(action: "tab" | "accept", index = completionSelected): void { + if (!view || !completionToken || disabled) return; + const token = completionToken; + const document = view.state.doc.toString(); + if (token.kind === "command" && index === null) { + const exact = completionEntries.findIndex((entry) => entry.value === token.prefix); + if (exact >= 0) index = exact; + } + if (index === null && completionEntries.length === 1) index = 0; + if (index === null && completionEntries.length > 1) { + completionMessage = "Select a command with ↑/↓ or keep typing"; + return; + } + const entry = index === null ? undefined : completionEntries[index]; + if (!entry) return; + const result = applyCompletion(document, token, entry, action); + const to = document.length - (result.value.length - result.cursor); + closeCompletion(); + view.dispatch({ + changes: { from: token.start, to, insert: result.value.slice(token.start, result.cursor) }, + selection: EditorSelection.cursor(result.cursor), userEvent: "input.complete", + }); + view.focus(); + if (token.kind === "command" && action === "accept") oncommand?.(); + else queueMicrotask(() => refreshCompletion(true)); + } + + function completionKeydown(event: KeyboardEvent): boolean { + if (disabled || event.isComposing || view?.composing || event.keyCode === 229) return false; + if (event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) return false; + if (event.key === "Escape" && completionToken) { + event.preventDefault(); closeCompletion(); return true; + } + if (completionToken && completionEntries.length && (event.key === "ArrowUp" || event.key === "ArrowDown")) { + event.preventDefault(); + completionSelected = completionSelection(completionSelected, completionEntries.length, event.key === "ArrowDown" ? 1 : -1); + completionMessage = ""; + return true; + } + if (event.key === "Tab") { + if (!completionToken) refreshCompletion(true); + if (!completionToken) return false; + event.preventDefault(); acceptCompletion("tab"); return true; + } + if (event.key === "Enter" && completionToken && completionEntries.length) { + event.preventDefault(); acceptCompletion("accept"); return true; + } + if (event.key === "Enter" && view?.state.doc.toString().trimStart().startsWith(":")) { + event.preventDefault(); oncommand?.(); return true; + } + return false; + } + + $effect(() => { + const activeId = completionSelected === null ? undefined : completionId + "-" + completionSelected; + const open = completionToken !== null; + if (!view) return; + view.contentDOM.setAttribute("aria-autocomplete", "list"); + if (open) view.contentDOM.setAttribute("aria-controls", completionId); + else view.contentDOM.removeAttribute("aria-controls"); + if (open && activeId) view.contentDOM.setAttribute("aria-activedescendant", activeId); + else view.contentDOM.removeAttribute("aria-activedescendant"); + }); + + $effect(() => { + void completionScope; + void disabled; + closeCompletion(); + completionContext = ""; + }); + const registerPaste = StateEffect.define<{ key: number; paste: ComposerPaste }>(); const pasteRegistry = StateField.define>({ create: () => new Map(), @@ -333,6 +469,7 @@ state: EditorState.create({ extensions: [ history(), + Prec.highest(EditorView.domEventHandlers({ keydown: completionKeydown })), Prec.highest(keymap.of([ { key: "Mod-z", @@ -381,12 +518,16 @@ spellcheck: "true", }), EditorView.updateListener.of((update) => { + if (update.docChanged || update.selectionSet) queueMicrotask(() => refreshCompletion()); if (update.docChanged && !restoringHistory) composerHistory.cancelNavigation(); if (update.docChanged || update.transactions.some((tx) => tx.effects.length > 0)) { emitChange(); } }), Prec.high(EditorView.domEventHandlers({ + focus() { queueMicrotask(() => refreshCompletion(true)); return false; }, + blur() { closeCompletion(); return false; }, + compositionend() { queueMicrotask(() => refreshCompletion(true)); return false; }, paste(event) { return handlePasteEvent(event); }, @@ -440,6 +581,7 @@ emitChange(); return () => { + closeCompletion(); view?.destroy(); view = null; }; @@ -546,16 +688,83 @@ } -
+
+ {#if completionToken} +
+
+ {#each visibleCompletions as entry, offset (entry.value)} + {@const index = completionStart + offset} + + {/each} +
+
+ {#if completionBusy}Loading files…{:else if completionMessage}{completionMessage}{:else}↑↓ select · Tab complete · Enter confirm · Esc close{/if} +
+
+ {/if} +
+
diff --git a/web/workspace/src/lib/workspace/console/composer-command.test.ts b/web/workspace/src/lib/workspace/console/composer-command.test.ts index 91ad58690..20eb1f28f 100644 --- a/web/workspace/src/lib/workspace/console/composer-command.test.ts +++ b/web/workspace/src/lib/workspace/console/composer-command.test.ts @@ -12,6 +12,15 @@ function assertEquals(actual: T, expected: T): void { if (a !== e) throw new Error(`Expected ${e}, got ${a}`); } +Deno.test("compact and rewind remain explicit Composer commands", () => { + assertEquals(buildComposerSegmentsRequest([{ kind: "text", content: ":compact" }]), { + ok: true, request: { kind: "compact", content: "" }, notice: "compact requested", + }); + assertEquals(buildComposerRequest(":rewind"), { + ok: true, request: { kind: "list_rewind_targets", content: "" }, notice: "rewind targets requested", + }); +}); + Deno.test("parseSigilSegments turns file sigils into file refs", () => { assertEquals(parseSigilSegments("read @src/main.rs"), [ { kind: "text", content: "read " }, diff --git a/web/workspace/src/lib/workspace/console/composer-command.ts b/web/workspace/src/lib/workspace/console/composer-command.ts index 4560d9c2e..84e816a3f 100644 --- a/web/workspace/src/lib/workspace/console/composer-command.ts +++ b/web/workspace/src/lib/workspace/console/composer-command.ts @@ -22,7 +22,7 @@ type CommandSpec = { description: string; }; -const COMMANDS: Record = { +export const COMMANDS: Record = { help: { usage: ":help [command]", description: diff --git a/web/workspace/src/lib/workspace/console/composer-completion.test.ts b/web/workspace/src/lib/workspace/console/composer-completion.test.ts index 0f62ed036..b30738fef 100644 --- a/web/workspace/src/lib/workspace/console/composer-completion.test.ts +++ b/web/workspace/src/lib/workspace/console/composer-completion.test.ts @@ -1,5 +1,6 @@ import { applyCompletion, + completionSelection, completionTokenAt, localCommandCompletions, } from "./composer-completion.ts"; @@ -49,6 +50,43 @@ Deno.test("applyCompletion replaces the active token and advances the cursor", ( }); }); +Deno.test("completion scope, aliases and middle-of-token replacement match command semantics", () => { + assertEquals(completionTokenAt("explain :comp", 13), null); + assertEquals(completionTokenAt(":notify @file", 13), null); + assertEquals(completionTokenAt("@\uFFF91\uFFFB", 4), null); + assertEquals(localCommandCompletions("roll").map((entry) => entry.value), [ + "rewind", + ]); + assertEquals(localCommandCompletions("?").map((entry) => entry.value), [ + "help", + ]); + const value = ":notify old argument"; + const token = completionTokenAt(value, 4)!; + assertEquals( + applyCompletion(value, token, { value: "peer" }).value, + ":peer old argument", + ); + const file = "see @sr trailing"; + assertEquals( + applyCompletion(file, completionTokenAt(file, 7)!, { + value: "src/", + is_dir: true, + }).value, + "see @src/ trailing", + ); + assertEquals( + applyCompletion( + "@ma", + completionTokenAt("@ma", 3)!, + { value: "main.rs" }, + "tab", + ).value, + "@main.rs", + ); + assertEquals(completionSelection(null, 6, -1), 5); + assertEquals(completionSelection(5, 6, 1), 0); +}); + Deno.test("localCommandCompletions filters colon commands", () => { assertEquals(localCommandCompletions("com").map((entry) => entry.value), [ "compact", diff --git a/web/workspace/src/lib/workspace/console/composer-completion.ts b/web/workspace/src/lib/workspace/console/composer-completion.ts index d71cfdec2..4098f626f 100644 --- a/web/workspace/src/lib/workspace/console/composer-completion.ts +++ b/web/workspace/src/lib/workspace/console/composer-completion.ts @@ -1,5 +1,6 @@ -export type ComposerCompletionKind = "command" | "file"; +import { COMMANDS } from "./composer-command.ts"; +export type ComposerCompletionKind = "command" | "file"; export type ComposerCompletionToken = { sigil: ":" | "@"; kind: ComposerCompletionKind; @@ -7,27 +8,22 @@ export type ComposerCompletionToken = { end: number; prefix: string; }; - export type ComposerCompletionEntry = { value: string; is_dir?: boolean; description?: string; + usage?: string; }; - -export type CompletionApplyResult = { - value: string; - cursor: number; -}; - -export const COLON_COMMAND_COMPLETIONS: ComposerCompletionEntry[] = [ - { value: "help", description: "Show commands" }, - { value: "noop", description: "No-op" }, - { value: "compact", description: "Compact Worker context" }, - { value: "rewind", description: "List rewind targets" }, - { value: "rollback", description: "Alias for rewind" }, - { value: "peer", description: "Register metadata peer" }, - { value: "notify", description: "Send Worker notification" }, -]; +export type CompletionApplyResult = { value: string; cursor: number }; +const ALIASES: Record = { "?": "help", rollback: "rewind" }; +export const COLON_COMMAND_COMPLETIONS: ComposerCompletionEntry[] = Object + .entries(COMMANDS) + .filter(([name]) => !ALIASES[name]) + .map(([value, spec]) => ({ + value, + description: spec.description, + usage: spec.usage, + })); export function completionTokenAt( value: string, @@ -35,18 +31,22 @@ export function completionTokenAt( ): ComposerCompletionToken | null { const boundedCursor = Math.max(0, Math.min(cursor, value.length)); const before = value.slice(0, boundedCursor); - const match = /(^|\s)([:@])([^\s]*)$/.exec(before); - if (!match) { - return null; - } - const sigil = match[2] as ComposerCompletionToken["sigil"]; + const match = /(^|\s)([:@])([^\s\uFFF9\uFFFB]*)$/.exec(before); + if (!match) return null; + const sigil = match[2] as ":" | "@"; const prefix = match[3] ?? ""; - const tokenStart = before.length - prefix.length - sigil.length; + const start = before.length - prefix.length - 1; + // Commands are only recognized at the start of the draft; their arguments + // must not accidentally become commands or file-reference completions. + if (sigil === ":" && value.slice(0, start).trim()) return null; + if (sigil === "@" && value.trimStart().startsWith(":")) return null; + const tail = /^[^\s\uFFF9\uFFFB]*/.exec(value.slice(boundedCursor))?.[0] ?? + ""; return { sigil, - kind: completionKindForSigil(sigil), - start: tokenStart, - end: boundedCursor, + kind: sigil === ":" ? "command" : "file", + start, + end: boundedCursor + tail.length, prefix, }; } @@ -54,36 +54,43 @@ export function completionTokenAt( export function localCommandCompletions( prefix: string, ): ComposerCompletionEntry[] { - const normalized = prefix.toLowerCase(); return COLON_COMMAND_COMPLETIONS.filter((entry) => - entry.value.toLowerCase().startsWith(normalized) + entry.value.startsWith(prefix) || Object.entries(ALIASES).some( + ([alias, name]) => name === entry.value && alias.startsWith(prefix), + ) ); } +export function completionSelection( + selected: number | null, + count: number, + direction: 1 | -1, +): number | null { + if (!count) return null; + return selected === null + ? (direction === 1 ? 0 : count - 1) + : (selected + direction + count) % count; +} + export function applyCompletion( value: string, token: ComposerCompletionToken, entry: ComposerCompletionEntry, + action: "tab" | "accept" = "accept", ): CompletionApplyResult { - const suffix = entry.is_dir ? "/" : " "; + const suffix = entry.is_dir + ? (entry.value.endsWith("/") ? "" : "/") + : token.kind === "file" && action === "tab" + ? "" + : " "; const replacement = `${token.sigil}${entry.value}${suffix}`; - const restStart = !entry.is_dir && value[token.end] === " " + const restStart = suffix === " " && value[token.end] === " " ? token.end + 1 : token.end; - const next = `${value.slice(0, token.start)}${replacement}${ - value.slice(restStart) - }`; - const cursor = token.start + replacement.length; - return { value: next, cursor }; -} - -function completionKindForSigil( - sigil: ComposerCompletionToken["sigil"], -): ComposerCompletionKind { - switch (sigil) { - case ":": - return "command"; - case "@": - return "file"; - } + return { + value: `${value.slice(0, token.start)}${replacement}${ + value.slice(restStart) + }`, + cursor: token.start + replacement.length, + }; } diff --git a/web/workspace/src/lib/workspace/console/file-completions.test.ts b/web/workspace/src/lib/workspace/console/file-completions.test.ts new file mode 100644 index 000000000..323757201 --- /dev/null +++ b/web/workspace/src/lib/workspace/console/file-completions.test.ts @@ -0,0 +1,70 @@ +import { FileCompletions } from "./file-completions.ts"; +import { deepStrictEqual as assertEquals, rejects } from "node:assert/strict"; + +async function assertRejects( + fn: () => Promise, + _type: typeof Error, + message: string, +) { + await rejects( + fn, + (error: unknown) => + error instanceof Error && error.message.includes(message), + ); +} + +declare const Deno: { test(name: string, fn: () => Promise): void }; + +Deno.test("file completion serializes the latest prefix behind cancelled wire replies", async () => { + const sent: string[] = []; + const lane = new FileCompletions((prefix) => sent.push(prefix)); + const first = new AbortController(); + const a = lane.request("a", first.signal); + first.abort(); + assertEquals(await a, []); + const second = new AbortController(); + const b = lane.request("b", second.signal); + second.abort(); + const c = lane.request("c", new AbortController().signal); + assertEquals(await b, []); + assertEquals(sent, ["a"]); + lane.receive([{ value: "old-a" }]); + assertEquals(sent, ["a", "c"]); + lane.receive([{ value: "current-c" }]); + assertEquals(await c, [{ value: "current-c" }]); + lane.close(); +}); + +Deno.test("file completion timeout blocks late responses until a new connection", async () => { + const sent: string[] = []; + const lane = new FileCompletions((prefix) => sent.push(prefix), 5); + await assertRejects( + () => lane.request("a", new AbortController().signal), + Error, + "timed out", + ); + lane.receive([{ value: "late-a" }]); + await assertRejects( + () => lane.request("b", new AbortController().signal), + Error, + "timed out", + ); + assertEquals(sent, ["a"]); + lane.reset(); + const c = lane.request("c", new AbortController().signal); + lane.receive([{ value: "c" }]); + assertEquals(await c, [{ value: "c" }]); + lane.close(); +}); + +Deno.test("file completion close rejects both active and queued requests", async () => { + const lane = new FileCompletions(() => {}); + const a = lane.request("a", new AbortController().signal); + const b = lane.request("b", new AbortController().signal); + const outcomes = Promise.allSettled([a, b]); + lane.close(new Error("disconnected")); + assertEquals((await outcomes).map((value) => value.status), [ + "rejected", + "rejected", + ]); +}); diff --git a/web/workspace/src/lib/workspace/console/file-completions.ts b/web/workspace/src/lib/workspace/console/file-completions.ts new file mode 100644 index 000000000..4ad688fc7 --- /dev/null +++ b/web/workspace/src/lib/workspace/console/file-completions.ts @@ -0,0 +1,106 @@ +import type { ComposerCompletionEntry } from "./composer-completion.ts"; + +type Request = { + prefix: string; + signal: AbortSignal; + resolve: (entries: ComposerCompletionEntry[]) => void; + reject: (error: Error) => void; + abort: () => void; +}; + +/** The wire reply has no request id/prefix. Never overlap requests on a lane, + * including cancelled requests: their reply must be consumed before the next. + * A timeout poisons the lane until reconnect, rather than misrouting a late reply. + */ +export class FileCompletions { + private active: Request | null = null; + private queued: Request | null = null; + private timer: ReturnType | undefined; + private failure: Error | null = null; + + constructor( + private send: (prefix: string) => void, + private timeoutMs = 30_000, + ) {} + + get pending(): boolean { + return this.active !== null; + } + + request( + prefix: string, + signal: AbortSignal, + ): Promise { + if (signal.aborted) return Promise.resolve([]); + if (this.failure) return Promise.reject(this.failure); + return new Promise((resolve, reject) => { + const request: Request = { + prefix, + signal, + resolve, + reject, + abort: () => { + resolve([]); + if (this.queued === request) this.queued = null; + }, + }; + signal.addEventListener("abort", request.abort, { once: true }); + if (this.queued) this.finish(this.queued, []); + this.queued = request; + this.pump(); + }); + } + + receive(entries: ComposerCompletionEntry[]): void { + if (!this.active) return; + clearTimeout(this.timer); + const request = this.active; + this.active = null; + this.finish(request, entries); + this.pump(); + } + + close(error = new Error("Worker completion connection closed.")): void { + this.failure = error; + clearTimeout(this.timer); + for (const request of [this.active, this.queued]) { + if (!request) continue; + request.signal.removeEventListener("abort", request.abort); + request.reject(error); + } + this.active = this.queued = null; + } + + reset(): void { + this.close(); + this.failure = null; + } + + private finish(request: Request, entries: ComposerCompletionEntry[]): void { + request.signal.removeEventListener("abort", request.abort); + request.resolve(request.signal.aborted ? [] : entries); + } + + private pump(): void { + if (this.active || !this.queued || this.failure) return; + const request = this.queued; + this.queued = null; + if (request.signal.aborted) { + this.finish(request, []); + return; + } + this.active = request; + this.timer = setTimeout( + () => + this.close( + new Error("Worker completion request timed out; reconnect to retry."), + ), + this.timeoutMs, + ); + try { + this.send(request.prefix); + } catch (error) { + this.close(error instanceof Error ? error : new Error(String(error))); + } + } +} diff --git a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts index e647baa0e..fb99edd9e 100644 --- a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts +++ b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts @@ -985,7 +985,8 @@ Deno.test("Worker Console paste chips preserve typed draft and target authority" composerInput.includes('key: "Mod-z"') && composerInput.includes("if (!view || view.state.readOnly) return") && composerInput.includes("if (currentView.state.readOnly) return false") && - consolePage.includes("activeComposerTargetKey !== targetKey") && + consolePage.includes("completionScope={activeComposerTargetKey}") && + composerInput.includes("version !== completionVersion") && consolePage.includes("if (!composerEditable) return") && composerInput.includes('chip.setAttribute("aria-label", label)') && composerInput.includes("preserveExactText = false") && @@ -1489,6 +1490,28 @@ Deno.test("Web Console switches main and direct SubWorker views from the Tasks r ); }); +Deno.test("Composer owns TUI-style completion and replaces Compact/Rewind header buttons", async () => { + const page = await Deno.readTextFile(new URL("./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte", import.meta.url)); + const input = await Deno.readTextFile(new URL("./ComposerInput.svelte", import.meta.url)); + const header = page.slice(page.indexOf('
{ + const page = await Deno.readTextFile(new URL( + "./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte", import.meta.url, + )); + const component = await Deno.readTextFile(new URL("./ConsoleTasks.svelte", import.meta.url)); + const header = page.slice(page.indexOf('
{"), "Only the mini summary should own Tasks toggling"); + assert(component.includes("aria-expanded={paneOpen}") && component.includes("aria-controls={paneId}") && component.includes("onclick={onTogglePane}"), "Tasks summary must be an accessible pane toggle"); + assert(!/\.task-pane\s*\{[^}]*display:\s*none/.test(component), "Narrow layouts must not hide the opened Tasks pane"); + assert(page.includes("grid-template-rows: minmax(0, 1fr) minmax(0, 1fr)"), "Narrow layouts should bound transcript and task scrolling separately"); +}); + Deno.test("Pending inputs are single-line previews between Tasks and Composer", async () => { const page = await Deno.readTextFile(new URL( "./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte", diff --git a/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte index 42cc831e6..83cca483c 100644 --- a/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte @@ -17,12 +17,7 @@ buildComposerSegmentsRequest, type WorkerConsoleInputRequest, } from "$lib/workspace/console/composer-command"; - import { - completionTokenAt, - localCommandCompletions, - type ComposerCompletionEntry, - type ComposerCompletionToken, - } from "$lib/workspace/console/composer-completion"; + import { FileCompletions } from "$lib/workspace/console/file-completions"; import WorkerRunStatus from "$lib/workspace/console/WorkerRunStatus.svelte"; import { resolveWorkerControlShortcut } from "$lib/workspace/console/worker-control-shortcuts"; import { @@ -141,10 +136,6 @@ let nextAttachmentId = 1; let fileInput: HTMLInputElement | null = null; let isDraggingFiles = $state(false); - let completionEntries = $state([]); - let completionToken = $state(null); - let completionBusy = $state(false); - let completionError = $state(null); let sending = $state(false); let sendError = $state(null); let rewindTargets = $state([]); @@ -164,11 +155,9 @@ submissions: [], }); let pendingSubmissionItems = $derived(pendingSubmissions.submissions ?? []); - let pendingCompletionRequest: { - resolve: (entries: ComposerCompletionEntry[]) => void; - reject: (error: Error) => void; - timeout: number; - } | null = null; + const fileCompletions = new FileCompletions((prefix) => sendProtocolMethod({ + method: "list_completions", params: { kind: "file", prefix }, + })); let streamDiagnostics = $state([]); let workerDetailsOpen = $state(false); let taskPaneOpen = $state(false); @@ -181,9 +170,9 @@ (SvelteComponent & ComposerInputHandle) | null >(null); const composerDrafts = new Map(); - let activeComposerTargetKey = untrack(() => + let activeComposerTargetKey = $state(untrack(() => runtimeId && workerId ? `${workspaceId}:${runtimeId}:${workerId}` : "", - ); + )); let autoFollowConsole = $state(true); const consoleViewScroll = new Map(); const CONSOLE_BOTTOM_THRESHOLD_PX = 48; @@ -749,96 +738,11 @@ scheduleObservationFlush(); } - async function applyComposerCompletion() { - if (!composerEditable || !composerInputElement) return; - const input = composerInputElement; - const targetKey = activeComposerTargetKey; - const document = draft.document; - const token = completionTokenAt( - document, - input.cursor(), - ); - completionToken = token; - completionError = null; - if (!token) { - completionEntries = []; - return; - } - - completionBusy = true; - try { - const entries = await resolveCompletionEntries(token); - if ( - !composerEditable || - composerInputElement !== input || - activeComposerTargetKey !== targetKey || - draft.document !== document - ) { - return; - } - completionEntries = entries; - if (entries.length === 0) { - completionError = `No completions for ${token.sigil}${token.prefix}`; - return; - } - input.replaceRange( - token.start, - token.end, - `${entries[0].value} `, - ); - } catch (error) { - completionError = - error instanceof Error ? error.message : String(error); - } finally { - completionBusy = false; - } - } - - async function resolveCompletionEntries( - token: ComposerCompletionToken, - ): Promise { - if (token.kind === "command") { - return localCommandCompletions(token.prefix); - } - const completionKind = token.kind; - const completionPrefix = token.prefix; - return new Promise((resolve, reject) => { - if (pendingCompletionRequest) { - rejectPendingCompletion( - new Error("Superseded by a newer completion request."), - ); - } - const timeout = window.setTimeout(() => { - rejectPendingCompletion( - new Error("Worker completion request timed out."), - ); - }, 30_000); - pendingCompletionRequest = { resolve, reject, timeout }; - try { - sendProtocolMethod({ - method: "list_completions", - params: { kind: completionKind, prefix: completionPrefix }, - }); - } catch (error) { - rejectPendingCompletion( - error instanceof Error ? error : new Error(String(error)), - ); - } - }); - } - function handleComposerKeydown(event: KeyboardEvent) { if (event.key === "PageUp" || event.key === "PageDown") { event.preventDefault(); scrollConsoleByPage(event.key === "PageDown" ? 1 : -1); - return; } - if (event.key !== "Tab") { - return; - } - event.preventDefault(); - if (!composerEditable) return; - void applyComposerCompletion(); } function scrollConsoleByPage(direction: 1 | -1) { @@ -946,10 +850,6 @@ sendWorkerControl(command); } - function requestRewindTargets() { - sendControl({ method: "list_rewind_targets" }, "Rewind target request"); - } - function rewindTo(target: RewindTarget) { sendControl( { @@ -1050,7 +950,15 @@ }); } + function handleComposerCommand() { + void submitDraft(composerInputElement?.snapshot() ?? draft); + } + function handleComposerSubmit() { + if ((composerInputElement?.snapshot() ?? draft).document.trimStart().startsWith(":")) { + handleComposerCommand(); + return; + } if (workerRunning) { sendWorkerControl("cancel"); return; @@ -1205,6 +1113,7 @@ return; } if (!command.request) { + if (command.notice) pushWorkspaceAlert("info", command.notice, { title: "Console command" }); composerInputElement?.clear(); return; } @@ -1216,7 +1125,10 @@ hasText: value.content.trim().length > 0, hasAttachments: attachments.length > 0, }; - if (!canDeliverComposerDraft(deliveryState)) { + // Commands are controls, not idle-only chat submissions. Keep the + // removed header controls available while the Worker is busy too. + const isCommand = delivery === "submit" && command.request.kind !== "user"; + if (isCommand ? !composerEditable : !canDeliverComposerDraft(deliveryState)) { return; } @@ -1236,7 +1148,9 @@ sendError = null; try { const method = composerRequestToProtocolMethod(request); - if (!sendComposerDelivery(deliveryState, method, sendProtocolMethod)) { + if (isCommand) { + sendProtocolMethod(method); + } else if (!sendComposerDelivery(deliveryState, method, sendProtocolMethod)) { return; } composerInputElement?.recordHistory(value); @@ -1428,6 +1342,7 @@ "The live subscription did not provide an initial snapshot.", ); } + fileCompletions.reset(); protocolState = "open"; } else if ( frame.frame === "event" && @@ -1480,7 +1395,10 @@ ); protocolSubscription = subscription; return () => { - if (protocolSubscription === subscription) protocolSubscription = null; + if (protocolSubscription === subscription) { + protocolSubscription = null; + fileCompletions.close(); + } subscription.close(); }; } @@ -1494,13 +1412,7 @@ function handleProtocolCommandEvent(event: ProtocolEvent) { if (event.event === "completions") { - const pending = pendingCompletionRequest; - if (!pending) { - return; - } - pendingCompletionRequest = null; - window.clearTimeout(pending.timeout); - pending.resolve(event.data.entries); + if (event.data.kind === "file") fileCompletions.receive(event.data.entries); return; } if (event.event === "rewind_targets") { @@ -1517,7 +1429,7 @@ } if (event.event === "error") { const error = new Error(event.data.message); - if (pendingCompletionRequest) { + if (fileCompletions.pending) { rejectPendingCompletion(error); } streamDiagnostics = [ @@ -1532,13 +1444,7 @@ } function rejectPendingCompletion(error: Error) { - const pending = pendingCompletionRequest; - if (!pending) { - return; - } - pendingCompletionRequest = null; - window.clearTimeout(pending.timeout); - pending.reject(error); + fileCompletions.close(error); } function mergeDiagnostics(...groups: Diagnostic[][]): Diagnostic[] { @@ -1756,36 +1662,6 @@ Normal - - - - {#if completionBusy || completionError || completionEntries.length > 0} -
- {#if completionBusy} - completing… - {:else if completionError} - {completionError} - {:else} - Tab: {completionToken?.sigil}{completionEntries[0] - ?.value} - {#if completionEntries.length > 1} - {completionEntries.length - 1} more - {/if} - {/if} -
- {/if}
{#if workerRunning || workerPaused} @@ -2703,16 +2571,6 @@ stroke-width: 2; } - .composer-completions { - display: flex; - flex-wrap: wrap; - gap: var(--space-2); - align-items: center; - color: var(--text-muted); - font-size: var(--font-size-compact); - line-height: var(--line-height-compact); - } - .composer-submit-actions { display: flex; align-items: center; @@ -2722,6 +2580,7 @@ @media (max-width: 960px) { .console-history.with-task-pane { grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) minmax(0, 1fr); } .console-header { diff --git a/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/console-display.browser.spec.ts b/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/console-display.browser.spec.ts index 11ac32c05..bcc93adc3 100644 --- a/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/console-display.browser.spec.ts +++ b/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/console-display.browser.spec.ts @@ -15,6 +15,7 @@ import type { SessionSnapshot, } from "$lib/generated/protocol"; import type { Worker } from "$lib/workspace/sidebar/types"; +import { EditorView } from "@codemirror/view"; import ConsolePage from "./+page.svelte"; const multiplexer = vi.hoisted(() => { @@ -318,6 +319,95 @@ test("queue-only state keeps the notification column empty and disables cancella await waitFor(() => expect((screen.getByRole("button", { name: "Cancel queued input 1" }) as HTMLButtonElement).disabled).toBe(true)); }); +test.each(["idle", "running", "paused"] as const)("Composer completion executes Compact/Rewind without header controls or Submit (%s)", async (state) => { + vi.stubGlobal("fetch", vi.fn(async () => Response.json({ availability: "live_protocol" }))); + const view = render(ConsolePage, { data: pageData() }); + await waitFor(() => expect(multiplexer.subscribe).toHaveBeenCalledOnce()); + const frame = subscribedFrame(emptySession()); + const snapshot = frame.message.payload.snapshot.data.events[0]; + if (snapshot.event === "snapshot" && state !== "idle") { + snapshot.data.state.state = { kind: "busy", state: { kind: "run", state } }; + } + latestListener().onFrame(frame); + await screen.findByText("No conversation to display"); + expect(screen.queryByRole("button", { name: "Compact" })).toBeNull(); + expect(screen.queryByRole("button", { name: "Rewind" })).toBeNull(); + const cm = EditorView.findFromDOM(view.container.querySelector(".cm-editor") as HTMLElement)!; + cm.focus(); + cm.dispatch({ changes: { from: 0, insert: ":comp" }, selection: { anchor: 5 } }); + await screen.findByRole("option"); + await fireEvent.keyDown(cm.contentDOM, { key: "Enter" }); + await waitFor(() => expect(multiplexer.sendWorkerMethod).toHaveBeenCalledWith({ + method: "compact", params: { command: expect.objectContaining({ command_id: 1 }) }, + })); + await waitFor(() => expect(cm.state.doc.toString()).toBe("")); + cm.dispatch({ changes: { from: 0, insert: ":rew" }, selection: { anchor: 4 } }); + const option = await screen.findByRole("option"); + await fireEvent.pointerDown(option); + await fireEvent.click(option); + await waitFor(() => expect(multiplexer.sendWorkerMethod).toHaveBeenCalledWith({ method: "list_rewind_targets" })); + expect(multiplexer.sendWorkerMethod.mock.calls.every(([method]) => method.method !== "submit" && method.method !== "cancel")).toBe(true); +}); + +test("file completions consume stale responses before the latest prefix and close with the connection", async () => { + vi.stubGlobal("fetch", vi.fn(async () => Response.json({ availability: "live_protocol" }))); + const view = render(ConsolePage, { data: pageData() }); + await waitFor(() => expect(multiplexer.subscribe).toHaveBeenCalledOnce()); + latestListener().onFrame(subscribedFrame(emptySession())); + await screen.findByText("No conversation to display"); + const cm = EditorView.findFromDOM(view.container.querySelector(".cm-editor") as HTMLElement)!; + cm.focus(); + cm.dispatch({ changes: { from: 0, insert: "@old" }, selection: { anchor: 4 } }); + await waitFor(() => expect(multiplexer.sendWorkerMethod).toHaveBeenCalledWith({ method: "list_completions", params: { kind: "file", prefix: "old" } })); + cm.dispatch({ changes: { from: 1, to: 4, insert: "new" }, selection: { anchor: 4 } }); + await settleMicrotasks(); + expect(multiplexer.sendWorkerMethod).toHaveBeenCalledOnce(); + const receive = (value: string) => latestListener().onFrame({ + frame: "event", message: { event: "event", data: { payload: { + event: "worker_protocol", data: { event: { event: "completions", data: { kind: "file", entries: [{ value, is_dir: false }] } } }, + } } }, + }); + receive("old-result"); + await waitFor(() => expect(multiplexer.sendWorkerMethod).toHaveBeenCalledWith({ method: "list_completions", params: { kind: "file", prefix: "new" } })); + expect(screen.queryByRole("option")).toBeNull(); + receive("new-result"); + expect((await screen.findByRole("option")).textContent).toContain("@new-result"); + latestListener().onStatus?.("closed", "connection lost"); + await waitFor(() => expect(screen.queryByRole("listbox")).toBeNull()); + receive("late-result"); + await settleMicrotasks(); + expect(screen.queryByRole("listbox")).toBeNull(); +}); + +test("mini task summary opens and closes details without a header Tasks button", async () => { + vi.stubGlobal("fetch", vi.fn(async () => Response.json({ availability: "live_protocol" }))); + const view = render(ConsolePage, { data: pageData() }); + await waitFor(() => expect(multiplexer.subscribe).toHaveBeenCalledOnce()); + const session = emptySession(); + session.entries.push({ + kind: "tool_call", entry_id: "task-create", timestamp: 1, provenance: "model_output", derived_from: [], + call_id: "task-call", name: "TaskCreate", + arguments: JSON.stringify({ subject: "Inspect layout", description: "Detailed task description" }), + }); + latestListener().onFrame(subscribedFrame(session)); + const summary = await screen.findByRole("button", { name: /1 task — pending/ }); + expect(screen.queryByRole("complementary", { name: "Worker tasks" })).toBeNull(); + expect(view.container.querySelector(".console-header")?.textContent).not.toContain("Tasks"); + await fireEvent.click(screen.getByRole("button", { name: "Details" })); + const details = await screen.findByRole("complementary", { name: "Worker detail" }); + expect(details.textContent).not.toContain("Capabilities"); + expect(details.textContent).not.toContain("follow-up spawn"); + await fireEvent.click(summary); + const pane = await screen.findByRole("complementary", { name: "Worker tasks" }); + expect(summary.getAttribute("aria-expanded")).toBe("true"); + expect(summary.getAttribute("aria-controls")).toBe(pane.id); + expect(pane.textContent).toContain("Detailed task description"); + expect(screen.queryByRole("complementary", { name: "Worker detail" })).toBeNull(); + await fireEvent.click(summary); + await waitFor(() => expect(screen.queryByRole("complementary", { name: "Worker tasks" })).toBeNull()); + expect(summary.getAttribute("aria-expanded")).toBe("false"); +}); + test("turn navigation jumps only the transcript to its user message and stops bottom-follow", async () => { vi.stubGlobal("fetch", vi.fn(async () => Response.json({ availability: "live_protocol" }))); const view = render(ConsolePage, { data: pageData() });