feat: add composer completion and mini task controls

This commit is contained in:
2026-09-30 13:11:28 +09:00
parent c0fd476d8b
commit 657cae1d4b
14 changed files with 876 additions and 238 deletions
+1 -1
View File
@@ -6,7 +6,7 @@
"dev": "deno run -A npm:vite@7.2.7 dev", "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", "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", "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", "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", "build": "deno run -A npm:vite@7.2.7 build",
"preview": "deno run -A npm:vite@7.2.7 preview", "preview": "deno run -A npm:vite@7.2.7 preview",
@@ -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<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((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<Array<{ value: string }>>();
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();
});
@@ -46,6 +46,7 @@
type ComposerHistoryDirection, type ComposerHistoryDirection,
type ComposerHistoryEntry, type ComposerHistoryEntry,
} from "$lib/workspace/console/composer-history.ts"; } 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"; import { shouldSubmitChatKey } from "$lib/workspace/console/chat-submit.ts";
interface Props { interface Props {
@@ -57,6 +58,9 @@
onkeydown?: (event: KeyboardEvent) => void; onkeydown?: (event: KeyboardEvent) => void;
onsubmit?: () => void; onsubmit?: () => void;
onpasteimages?: (files: File[]) => void; onpasteimages?: (files: File[]) => void;
oncommand?: () => void;
completionScope?: string;
resolveFileCompletions?: (prefix: string, signal: AbortSignal) => Promise<ComposerCompletionEntry[]>;
} }
let { let {
@@ -68,6 +72,9 @@
onkeydown, onkeydown,
onsubmit, onsubmit,
onpasteimages, onpasteimages,
oncommand,
completionScope = "",
resolveFileCompletions,
}: Props = $props(); }: Props = $props();
let mountElement: HTMLDivElement; let mountElement: HTMLDivElement;
@@ -78,6 +85,135 @@
let nextPasteKey = 1; let nextPasteKey = 1;
const editable = new Compartment(); const editable = new Compartment();
const completionId = $props.id();
let completionToken = $state<ComposerCompletionToken | null>(null);
let completionEntries = $state<ComposerCompletionEntry[]>([]);
let completionSelected = $state<number | null>(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 registerPaste = StateEffect.define<{ key: number; paste: ComposerPaste }>();
const pasteRegistry = StateField.define<ReadonlyMap<number, ComposerPaste>>({ const pasteRegistry = StateField.define<ReadonlyMap<number, ComposerPaste>>({
create: () => new Map(), create: () => new Map(),
@@ -333,6 +469,7 @@
state: EditorState.create({ state: EditorState.create({
extensions: [ extensions: [
history(), history(),
Prec.highest(EditorView.domEventHandlers({ keydown: completionKeydown })),
Prec.highest(keymap.of([ Prec.highest(keymap.of([
{ {
key: "Mod-z", key: "Mod-z",
@@ -381,12 +518,16 @@
spellcheck: "true", spellcheck: "true",
}), }),
EditorView.updateListener.of((update) => { EditorView.updateListener.of((update) => {
if (update.docChanged || update.selectionSet) queueMicrotask(() => refreshCompletion());
if (update.docChanged && !restoringHistory) composerHistory.cancelNavigation(); if (update.docChanged && !restoringHistory) composerHistory.cancelNavigation();
if (update.docChanged || update.transactions.some((tx) => tx.effects.length > 0)) { if (update.docChanged || update.transactions.some((tx) => tx.effects.length > 0)) {
emitChange(); emitChange();
} }
}), }),
Prec.high(EditorView.domEventHandlers({ Prec.high(EditorView.domEventHandlers({
focus() { queueMicrotask(() => refreshCompletion(true)); return false; },
blur() { closeCompletion(); return false; },
compositionend() { queueMicrotask(() => refreshCompletion(true)); return false; },
paste(event) { paste(event) {
return handlePasteEvent(event); return handlePasteEvent(event);
}, },
@@ -440,6 +581,7 @@
emitChange(); emitChange();
return () => { return () => {
closeCompletion();
view?.destroy(); view?.destroy();
view = null; view = null;
}; };
@@ -546,16 +688,83 @@
} }
</script> </script>
<div class="composer-input" class:disabled bind:this={mountElement}></div> <div class="composer-input" class:disabled>
{#if completionToken}
<div class="completion-popup">
<div id={completionId} role="listbox" aria-label="Composer completions" aria-busy={completionBusy}>
{#each visibleCompletions as entry, offset (entry.value)}
{@const index = completionStart + offset}
<button
id={completionId + "-" + index}
type="button" role="option" tabindex="-1"
aria-selected={completionSelected === index}
title={entry.usage ?? entry.value}
onpointerdown={(event) => event.preventDefault()}
onclick={() => acceptCompletion("accept", index)}
>
<span class="completion-value">{completionToken.sigil}{entry.value}{entry.is_dir && !entry.value.endsWith("/") ? "/" : ""}</span>
{#if entry.description}<span class="completion-description">{entry.description}</span>{/if}
</button>
{/each}
</div>
<div class="completion-hint" role="status">
{#if completionBusy}Loading files…{:else if completionMessage}{completionMessage}{:else}↑↓ select · Tab complete · Enter confirm · Esc close{/if}
</div>
</div>
{/if}
<div bind:this={mountElement}></div>
</div>
<style> <style>
.composer-input { .composer-input {
position: relative;
min-width: 0; min-width: 0;
flex: 1; flex: 1;
color: var(--text-strong); color: var(--text-strong);
font: inherit; font: inherit;
} }
.completion-popup {
position: absolute;
bottom: 100%;
left: 0;
right: 0;
z-index: 10;
padding: var(--space-1);
background: var(--bg-raised);
box-shadow: var(--shadow-overlay);
color: var(--text);
font-size: var(--font-size-compact);
line-height: var(--line-height-compact);
}
.completion-popup button {
display: flex;
gap: var(--space-3);
width: 100%;
min-width: 0;
padding: var(--space-1) var(--space-2);
border: 0;
border-radius: 0;
background: transparent;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
}
.completion-popup button:hover { background: var(--interactive-hover); }
.completion-popup button[aria-selected="true"] { background: var(--interactive-selected); }
.completion-value { flex: 0 1 auto; font-family: var(--font-mono); }
.completion-description { flex: 1 1 auto; color: var(--text-muted); }
.completion-value, .completion-description {
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.completion-hint { padding: var(--space-1) var(--space-2); color: var(--text-muted); }
.composer-input.disabled { .composer-input.disabled {
opacity: 0.56; opacity: 0.56;
} }
@@ -0,0 +1,48 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render } from "@testing-library/svelte";
import { afterEach, expect, test, vi } from "vitest";
import ConsoleTasks from "./ConsoleTasks.svelte";
import type { ConsoleTask } from "./tasks";
const tasks: ConsoleTask[] = [{ taskid: 1, subject: "Current task", description: "Task detail", status: "inprogress" }];
afterEach(cleanup);
test("mini summary opens the pane independently of Worker view tabs", async () => {
const onTogglePane = vi.fn();
const onSelectWorkerView = vi.fn();
const view = render(ConsoleTasks, {
tasks, mode: "mini", paneId: "task-pane", paneOpen: false, onTogglePane,
workerViews: [{ sessionId: null, label: "Main" }, { sessionId: "child", label: "Child" }],
onSelectWorkerView,
});
const summary = view.getByRole("button", { name: /1 task — pending/ });
expect(summary.getAttribute("aria-expanded")).toBe("false");
expect(summary.getAttribute("aria-controls")).toBe("task-pane");
expect(summary.getAttribute("type")).toBe("button");
await fireEvent.click(view.getByText("Current task"));
expect(onTogglePane).not.toHaveBeenCalled();
await fireEvent.click(summary);
expect(onTogglePane).toHaveBeenCalledOnce();
await view.rerender({ paneOpen: true });
expect(summary.getAttribute("aria-expanded")).toBe("true");
await fireEvent.click(view.getByRole("button", { name: "Child" }));
expect(onSelectWorkerView).toHaveBeenCalledWith("child");
expect(onTogglePane).toHaveBeenCalledOnce();
expect(summary.querySelector("button")).toBeNull();
});
test("an open empty pane retains its mini summary close control", async () => {
const onTogglePane = vi.fn();
const view = render(ConsoleTasks, { tasks: [], mode: "mini", paneOpen: true, onTogglePane });
await fireEvent.click(view.getByRole("button", { name: /0 tasks/ }));
expect(onTogglePane).toHaveBeenCalledOnce();
await view.rerender({ paneOpen: false });
expect(view.queryByRole("button")).toBeNull();
});
test("pane exposes the summary control target and full task details", () => {
const view = render(ConsoleTasks, { tasks, mode: "pane", paneId: "task-pane" });
expect(view.getByRole("complementary", { name: "Worker tasks" }).id).toBe("task-pane");
expect(view.getByText("Task detail")).not.toBeNull();
});
@@ -9,6 +9,9 @@
type Props = { type Props = {
tasks: ConsoleTask[]; tasks: ConsoleTask[];
mode: "mini" | "pane"; mode: "mini" | "pane";
paneOpen?: boolean;
paneId?: string;
onTogglePane?: () => void;
workerViews?: WorkerViewTab[]; workerViews?: WorkerViewTab[];
selectedWorkerViewSessionId?: string | null; selectedWorkerViewSessionId?: string | null;
onSelectWorkerView?: (sessionId: string | null) => void; onSelectWorkerView?: (sessionId: string | null) => void;
@@ -17,6 +20,9 @@
let { let {
tasks, tasks,
mode, mode,
paneOpen = false,
paneId,
onTogglePane,
workerViews = [], workerViews = [],
selectedWorkerViewSessionId = null, selectedWorkerViewSessionId = null,
onSelectWorkerView = () => {}, onSelectWorkerView = () => {},
@@ -46,7 +52,7 @@
} }
</script> </script>
{#if mode === "mini" && (tasks.length > 0 || workerViews.length > 1)} {#if mode === "mini" && (tasks.length > 0 || workerViews.length > 1 || paneOpen)}
<section class="task-mini" aria-label="Worker task summary"> <section class="task-mini" aria-label="Worker task summary">
{#each activeTasks as task (task.taskid)} {#each activeTasks as task (task.taskid)}
<div class="task-mini-row"> <div class="task-mini-row">
@@ -57,9 +63,15 @@
</div> </div>
{/each} {/each}
<div class="task-summary-row"> <div class="task-summary-row">
<span class="task-summary"> <button
type="button"
class="task-summary"
aria-expanded={paneOpen}
aria-controls={paneId}
onclick={onTogglePane}
>
{counts.total} {taskNoun(counts.total)} — pending: {counts.pending}, inprogress: {counts.inprogress}, completed: {counts.completed} {counts.total} {taskNoun(counts.total)} — pending: {counts.pending}, inprogress: {counts.inprogress}, completed: {counts.completed}
</span> </button>
{#if workerViews.length > 1} {#if workerViews.length > 1}
<span class="worker-view-tabs" role="group" aria-label="Worker transcript view"> <span class="worker-view-tabs" role="group" aria-label="Worker transcript view">
<span aria-hidden="true">[ </span> <span aria-hidden="true">[ </span>
@@ -78,7 +90,7 @@
</div> </div>
</section> </section>
{:else if mode === "pane"} {:else if mode === "pane"}
<aside class="task-pane" aria-label="Worker tasks"> <aside id={paneId} class="task-pane" aria-label="Worker tasks">
<h3>Tasks ({counts.total})</h3> <h3>Tasks ({counts.total})</h3>
{#if tasks.length === 0} {#if tasks.length === 0}
@@ -141,6 +153,12 @@
} }
.task-summary { .task-summary {
padding: 0;
border: 0;
background: transparent;
font: inherit;
text-align: left;
cursor: pointer;
flex: 1 1 auto; flex: 1 1 auto;
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
@@ -148,6 +166,16 @@
white-space: nowrap; white-space: nowrap;
} }
.task-summary:hover,
.task-summary:focus-visible {
color: var(--text);
}
.task-summary:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.worker-view-tabs { .worker-view-tabs {
display: flex; display: flex;
flex: 0 1 auto; flex: 0 1 auto;
@@ -266,9 +294,10 @@
line-height: var(--line-height-body); line-height: var(--line-height-body);
} }
@media (max-width: 900px) { @media (max-width: 960px) {
.task-pane { .task-pane {
display: none; border-left: 0;
padding-top: var(--space-3);
} }
} }
</style> </style>
@@ -12,6 +12,15 @@ function assertEquals<T>(actual: T, expected: T): void {
if (a !== e) throw new Error(`Expected ${e}, got ${a}`); 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", () => { Deno.test("parseSigilSegments turns file sigils into file refs", () => {
assertEquals(parseSigilSegments("read @src/main.rs"), [ assertEquals(parseSigilSegments("read @src/main.rs"), [
{ kind: "text", content: "read " }, { kind: "text", content: "read " },
@@ -22,7 +22,7 @@ type CommandSpec = {
description: string; description: string;
}; };
const COMMANDS: Record<string, CommandSpec> = { export const COMMANDS: Record<string, CommandSpec> = {
help: { help: {
usage: ":help [command]", usage: ":help [command]",
description: description:
@@ -1,5 +1,6 @@
import { import {
applyCompletion, applyCompletion,
completionSelection,
completionTokenAt, completionTokenAt,
localCommandCompletions, localCommandCompletions,
} from "./composer-completion.ts"; } 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", () => { Deno.test("localCommandCompletions filters colon commands", () => {
assertEquals(localCommandCompletions("com").map((entry) => entry.value), [ assertEquals(localCommandCompletions("com").map((entry) => entry.value), [
"compact", "compact",
@@ -1,5 +1,6 @@
export type ComposerCompletionKind = "command" | "file"; import { COMMANDS } from "./composer-command.ts";
export type ComposerCompletionKind = "command" | "file";
export type ComposerCompletionToken = { export type ComposerCompletionToken = {
sigil: ":" | "@"; sigil: ":" | "@";
kind: ComposerCompletionKind; kind: ComposerCompletionKind;
@@ -7,27 +8,22 @@ export type ComposerCompletionToken = {
end: number; end: number;
prefix: string; prefix: string;
}; };
export type ComposerCompletionEntry = { export type ComposerCompletionEntry = {
value: string; value: string;
is_dir?: boolean; is_dir?: boolean;
description?: string; description?: string;
usage?: string;
}; };
export type CompletionApplyResult = { value: string; cursor: number };
export type CompletionApplyResult = { const ALIASES: Record<string, string> = { "?": "help", rollback: "rewind" };
value: string; export const COLON_COMMAND_COMPLETIONS: ComposerCompletionEntry[] = Object
cursor: number; .entries(COMMANDS)
}; .filter(([name]) => !ALIASES[name])
.map(([value, spec]) => ({
export const COLON_COMMAND_COMPLETIONS: ComposerCompletionEntry[] = [ value,
{ value: "help", description: "Show commands" }, description: spec.description,
{ value: "noop", description: "No-op" }, usage: spec.usage,
{ 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 function completionTokenAt( export function completionTokenAt(
value: string, value: string,
@@ -35,18 +31,22 @@ export function completionTokenAt(
): ComposerCompletionToken | null { ): ComposerCompletionToken | null {
const boundedCursor = Math.max(0, Math.min(cursor, value.length)); const boundedCursor = Math.max(0, Math.min(cursor, value.length));
const before = value.slice(0, boundedCursor); const before = value.slice(0, boundedCursor);
const match = /(^|\s)([:@])([^\s]*)$/.exec(before); const match = /(^|\s)([:@])([^\s\uFFF9\uFFFB]*)$/.exec(before);
if (!match) { if (!match) return null;
return null; const sigil = match[2] as ":" | "@";
}
const sigil = match[2] as ComposerCompletionToken["sigil"];
const prefix = match[3] ?? ""; 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 { return {
sigil, sigil,
kind: completionKindForSigil(sigil), kind: sigil === ":" ? "command" : "file",
start: tokenStart, start,
end: boundedCursor, end: boundedCursor + tail.length,
prefix, prefix,
}; };
} }
@@ -54,36 +54,43 @@ export function completionTokenAt(
export function localCommandCompletions( export function localCommandCompletions(
prefix: string, prefix: string,
): ComposerCompletionEntry[] { ): ComposerCompletionEntry[] {
const normalized = prefix.toLowerCase();
return COLON_COMMAND_COMPLETIONS.filter((entry) => 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( export function applyCompletion(
value: string, value: string,
token: ComposerCompletionToken, token: ComposerCompletionToken,
entry: ComposerCompletionEntry, entry: ComposerCompletionEntry,
action: "tab" | "accept" = "accept",
): CompletionApplyResult { ): 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 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 + 1
: token.end; : token.end;
const next = `${value.slice(0, token.start)}${replacement}${ return {
value: `${value.slice(0, token.start)}${replacement}${
value.slice(restStart) value.slice(restStart)
}`; }`,
const cursor = token.start + replacement.length; cursor: token.start + replacement.length,
return { value: next, cursor }; };
}
function completionKindForSigil(
sigil: ComposerCompletionToken["sigil"],
): ComposerCompletionKind {
switch (sigil) {
case ":":
return "command";
case "@":
return "file";
}
} }
@@ -0,0 +1,70 @@
import { FileCompletions } from "./file-completions.ts";
import { deepStrictEqual as assertEquals, rejects } from "node:assert/strict";
async function assertRejects(
fn: () => Promise<unknown>,
_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>): 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",
]);
});
@@ -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<typeof setTimeout> | 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<ComposerCompletionEntry[]> {
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)));
}
}
}
@@ -985,7 +985,8 @@ Deno.test("Worker Console paste chips preserve typed draft and target authority"
composerInput.includes('key: "Mod-z"') && composerInput.includes('key: "Mod-z"') &&
composerInput.includes("if (!view || view.state.readOnly) return") && composerInput.includes("if (!view || view.state.readOnly) return") &&
composerInput.includes("if (currentView.state.readOnly) return false") && 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") && consolePage.includes("if (!composerEditable) return") &&
composerInput.includes('chip.setAttribute("aria-label", label)') && composerInput.includes('chip.setAttribute("aria-label", label)') &&
composerInput.includes("preserveExactText = false") && 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('<section class="console-header'), page.indexOf('{#if rewindTargets.length'));
assert(!header.includes("Compact") && !header.includes("Rewind") && !page.includes("requestRewindTargets"), "Header must defer Compact/Rewind to commands");
assert(input.includes('role="listbox"') && input.includes('role="option"') && input.includes("entry.description") && input.includes("completionStart + 6"), "Completion must expose a bounded selectable list with descriptions");
assert(input.indexOf("keydown: completionKeydown") < input.indexOf('key: "ArrowUp"'), "Completion navigation must precede input history");
assert(page.includes("fileCompletions.receive(event.data.entries)") && page.includes("fileCompletions.close()"), "File completion lifecycle must be scoped to the transport");
});
Deno.test("mini task summary owns pane toggling instead of the header", async () => {
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('<section class="console-header'), page.indexOf('{#if rewindTargets.length'));
assert(!header.includes("taskPaneOpen = !taskPaneOpen") && page.includes("onTogglePane={() => {"), "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 () => { Deno.test("Pending inputs are single-line previews between Tasks and Composer", async () => {
const page = await Deno.readTextFile(new URL( const page = await Deno.readTextFile(new URL(
"./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte", "./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte",
@@ -17,12 +17,7 @@
buildComposerSegmentsRequest, buildComposerSegmentsRequest,
type WorkerConsoleInputRequest, type WorkerConsoleInputRequest,
} from "$lib/workspace/console/composer-command"; } from "$lib/workspace/console/composer-command";
import { import { FileCompletions } from "$lib/workspace/console/file-completions";
completionTokenAt,
localCommandCompletions,
type ComposerCompletionEntry,
type ComposerCompletionToken,
} from "$lib/workspace/console/composer-completion";
import WorkerRunStatus from "$lib/workspace/console/WorkerRunStatus.svelte"; import WorkerRunStatus from "$lib/workspace/console/WorkerRunStatus.svelte";
import { resolveWorkerControlShortcut } from "$lib/workspace/console/worker-control-shortcuts"; import { resolveWorkerControlShortcut } from "$lib/workspace/console/worker-control-shortcuts";
import { import {
@@ -141,10 +136,6 @@
let nextAttachmentId = 1; let nextAttachmentId = 1;
let fileInput: HTMLInputElement | null = null; let fileInput: HTMLInputElement | null = null;
let isDraggingFiles = $state(false); let isDraggingFiles = $state(false);
let completionEntries = $state<ComposerCompletionEntry[]>([]);
let completionToken = $state<ComposerCompletionToken | null>(null);
let completionBusy = $state(false);
let completionError = $state<string | null>(null);
let sending = $state(false); let sending = $state(false);
let sendError = $state<string | null>(null); let sendError = $state<string | null>(null);
let rewindTargets = $state<RewindTarget[]>([]); let rewindTargets = $state<RewindTarget[]>([]);
@@ -164,11 +155,9 @@
submissions: [], submissions: [],
}); });
let pendingSubmissionItems = $derived(pendingSubmissions.submissions ?? []); let pendingSubmissionItems = $derived(pendingSubmissions.submissions ?? []);
let pendingCompletionRequest: { const fileCompletions = new FileCompletions((prefix) => sendProtocolMethod({
resolve: (entries: ComposerCompletionEntry[]) => void; method: "list_completions", params: { kind: "file", prefix },
reject: (error: Error) => void; }));
timeout: number;
} | null = null;
let streamDiagnostics = $state<Diagnostic[]>([]); let streamDiagnostics = $state<Diagnostic[]>([]);
let workerDetailsOpen = $state(false); let workerDetailsOpen = $state(false);
let taskPaneOpen = $state(false); let taskPaneOpen = $state(false);
@@ -181,9 +170,9 @@
(SvelteComponent & ComposerInputHandle) | null (SvelteComponent & ComposerInputHandle) | null
>(null); >(null);
const composerDrafts = new Map<string, ComposerDraftCache>(); const composerDrafts = new Map<string, ComposerDraftCache>();
let activeComposerTargetKey = untrack(() => let activeComposerTargetKey = $state(untrack(() =>
runtimeId && workerId ? `${workspaceId}:${runtimeId}:${workerId}` : "", runtimeId && workerId ? `${workspaceId}:${runtimeId}:${workerId}` : "",
); ));
let autoFollowConsole = $state(true); let autoFollowConsole = $state(true);
const consoleViewScroll = new Map<string, ConsoleViewScroll>(); const consoleViewScroll = new Map<string, ConsoleViewScroll>();
const CONSOLE_BOTTOM_THRESHOLD_PX = 48; const CONSOLE_BOTTOM_THRESHOLD_PX = 48;
@@ -749,96 +738,11 @@
scheduleObservationFlush(); 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<ComposerCompletionEntry[]> {
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) { function handleComposerKeydown(event: KeyboardEvent) {
if (event.key === "PageUp" || event.key === "PageDown") { if (event.key === "PageUp" || event.key === "PageDown") {
event.preventDefault(); event.preventDefault();
scrollConsoleByPage(event.key === "PageDown" ? 1 : -1); scrollConsoleByPage(event.key === "PageDown" ? 1 : -1);
return;
} }
if (event.key !== "Tab") {
return;
}
event.preventDefault();
if (!composerEditable) return;
void applyComposerCompletion();
} }
function scrollConsoleByPage(direction: 1 | -1) { function scrollConsoleByPage(direction: 1 | -1) {
@@ -946,10 +850,6 @@
sendWorkerControl(command); sendWorkerControl(command);
} }
function requestRewindTargets() {
sendControl({ method: "list_rewind_targets" }, "Rewind target request");
}
function rewindTo(target: RewindTarget) { function rewindTo(target: RewindTarget) {
sendControl( sendControl(
{ {
@@ -1050,7 +950,15 @@
}); });
} }
function handleComposerCommand() {
void submitDraft(composerInputElement?.snapshot() ?? draft);
}
function handleComposerSubmit() { function handleComposerSubmit() {
if ((composerInputElement?.snapshot() ?? draft).document.trimStart().startsWith(":")) {
handleComposerCommand();
return;
}
if (workerRunning) { if (workerRunning) {
sendWorkerControl("cancel"); sendWorkerControl("cancel");
return; return;
@@ -1205,6 +1113,7 @@
return; return;
} }
if (!command.request) { if (!command.request) {
if (command.notice) pushWorkspaceAlert("info", command.notice, { title: "Console command" });
composerInputElement?.clear(); composerInputElement?.clear();
return; return;
} }
@@ -1216,7 +1125,10 @@
hasText: value.content.trim().length > 0, hasText: value.content.trim().length > 0,
hasAttachments: attachments.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; return;
} }
@@ -1236,7 +1148,9 @@
sendError = null; sendError = null;
try { try {
const method = composerRequestToProtocolMethod(request); const method = composerRequestToProtocolMethod(request);
if (!sendComposerDelivery(deliveryState, method, sendProtocolMethod)) { if (isCommand) {
sendProtocolMethod(method);
} else if (!sendComposerDelivery(deliveryState, method, sendProtocolMethod)) {
return; return;
} }
composerInputElement?.recordHistory(value); composerInputElement?.recordHistory(value);
@@ -1428,6 +1342,7 @@
"The live subscription did not provide an initial snapshot.", "The live subscription did not provide an initial snapshot.",
); );
} }
fileCompletions.reset();
protocolState = "open"; protocolState = "open";
} else if ( } else if (
frame.frame === "event" && frame.frame === "event" &&
@@ -1480,7 +1395,10 @@
); );
protocolSubscription = subscription; protocolSubscription = subscription;
return () => { return () => {
if (protocolSubscription === subscription) protocolSubscription = null; if (protocolSubscription === subscription) {
protocolSubscription = null;
fileCompletions.close();
}
subscription.close(); subscription.close();
}; };
} }
@@ -1494,13 +1412,7 @@
function handleProtocolCommandEvent(event: ProtocolEvent) { function handleProtocolCommandEvent(event: ProtocolEvent) {
if (event.event === "completions") { if (event.event === "completions") {
const pending = pendingCompletionRequest; if (event.data.kind === "file") fileCompletions.receive(event.data.entries);
if (!pending) {
return;
}
pendingCompletionRequest = null;
window.clearTimeout(pending.timeout);
pending.resolve(event.data.entries);
return; return;
} }
if (event.event === "rewind_targets") { if (event.event === "rewind_targets") {
@@ -1517,7 +1429,7 @@
} }
if (event.event === "error") { if (event.event === "error") {
const error = new Error(event.data.message); const error = new Error(event.data.message);
if (pendingCompletionRequest) { if (fileCompletions.pending) {
rejectPendingCompletion(error); rejectPendingCompletion(error);
} }
streamDiagnostics = [ streamDiagnostics = [
@@ -1532,13 +1444,7 @@
} }
function rejectPendingCompletion(error: Error) { function rejectPendingCompletion(error: Error) {
const pending = pendingCompletionRequest; fileCompletions.close(error);
if (!pending) {
return;
}
pendingCompletionRequest = null;
window.clearTimeout(pending.timeout);
pending.reject(error);
} }
function mergeDiagnostics(...groups: Diagnostic[][]): Diagnostic[] { function mergeDiagnostics(...groups: Diagnostic[][]): Diagnostic[] {
@@ -1756,36 +1662,6 @@
Normal Normal
</button> </button>
</div> </div>
<button
type="button"
class="secondary-button"
disabled={protocolState !== "open"}
onclick={() => {
const method = lifecycleMethod("compact");
if (method) sendControl(method, "Compact");
}}
>
Compact
</button>
<button
type="button"
class="secondary-button"
disabled={protocolState !== "open"}
onclick={requestRewindTargets}
>
Rewind
</button>
<button
type="button"
class="secondary-button"
aria-expanded={taskPaneOpen}
onclick={() => {
taskPaneOpen = !taskPaneOpen;
if (taskPaneOpen) workerDetailsOpen = false;
}}
>
Tasks{tasks.length > 0 ? ` ${tasks.length}` : ""}
</button>
<button <button
type="button" type="button"
class="secondary-button" class="secondary-button"
@@ -1888,7 +1764,7 @@
</section> </section>
{#if taskPaneOpen} {#if taskPaneOpen}
<ConsoleTasks {tasks} mode="pane" /> <ConsoleTasks {tasks} mode="pane" paneId="console-task-pane" />
{/if} {/if}
</div> </div>
@@ -1973,6 +1849,12 @@
<ConsoleTasks <ConsoleTasks
{tasks} {tasks}
mode="mini" mode="mini"
paneId="console-task-pane"
paneOpen={taskPaneOpen}
onTogglePane={() => {
taskPaneOpen = !taskPaneOpen;
if (taskPaneOpen) workerDetailsOpen = false;
}}
workerViews={workerViews.map(({ sessionId, label }) => ({ workerViews={workerViews.map(({ sessionId, label }) => ({
sessionId, sessionId,
label, label,
@@ -2064,6 +1946,9 @@
disabled={!composerEditable} disabled={!composerEditable}
onchange={handleComposerChange} onchange={handleComposerChange}
onkeydown={handleComposerKeydown} onkeydown={handleComposerKeydown}
completionScope={activeComposerTargetKey}
resolveFileCompletions={(prefix, signal) => fileCompletions.request(prefix, signal)}
oncommand={handleComposerCommand}
onsubmit={handleComposerSubmit} onsubmit={handleComposerSubmit}
onpasteimages={addPastedImages} onpasteimages={addPastedImages}
/> />
@@ -2108,23 +1993,6 @@
<path d="M12 5V19M5 12H19" /> <path d="M12 5V19M5 12H19" />
</svg> </svg>
</button> </button>
{#if completionBusy || completionError || completionEntries.length > 0}
<div class="composer-completions" aria-live="polite">
{#if completionBusy}
<span>completing…</span>
{:else if completionError}
<span class="error">{completionError}</span>
{:else}
<span
>Tab: {completionToken?.sigil}{completionEntries[0]
?.value}</span
>
{#if completionEntries.length > 1}
<span>{completionEntries.length - 1} more</span>
{/if}
{/if}
</div>
{/if}
</div> </div>
<div class="composer-submit-actions"> <div class="composer-submit-actions">
{#if workerRunning || workerPaused} {#if workerRunning || workerPaused}
@@ -2703,16 +2571,6 @@
stroke-width: 2; 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 { .composer-submit-actions {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -2722,6 +2580,7 @@
@media (max-width: 960px) { @media (max-width: 960px) {
.console-history.with-task-pane { .console-history.with-task-pane {
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
grid-template-rows: minmax(0, 1fr) minmax(0, 1fr);
} }
.console-header { .console-header {
@@ -15,6 +15,7 @@ import type {
SessionSnapshot, SessionSnapshot,
} from "$lib/generated/protocol"; } from "$lib/generated/protocol";
import type { Worker } from "$lib/workspace/sidebar/types"; import type { Worker } from "$lib/workspace/sidebar/types";
import { EditorView } from "@codemirror/view";
import ConsolePage from "./+page.svelte"; import ConsolePage from "./+page.svelte";
const multiplexer = vi.hoisted(() => { 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)); 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 () => { 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" }))); vi.stubGlobal("fetch", vi.fn(async () => Response.json({ availability: "live_protocol" })));
const view = render(ConsolePage, { data: pageData() }); const view = render(ConsolePage, { data: pageData() });