diff --git a/web/workspace/deno.json b/web/workspace/deno.json index 5955fbc1..c416e66d 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 --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/workspace-catalog.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/override-stack.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-launch.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/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/ui.test.ts", + "test": "deno test --allow-read=src,test,tests --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/workspace-catalog.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.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/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/override-stack.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-launch.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/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/ui.test.ts", "build": "deno run -A npm:vite@7.2.7 build", "preview": "deno run -A npm:vite@7.2.7 preview" }, @@ -16,6 +16,7 @@ "@sveltejs/kit": "npm:@sveltejs/kit@2.49.4", "@sveltejs/vite-plugin-svelte": "npm:@sveltejs/vite-plugin-svelte@6.2.1", "@codemirror/autocomplete": "npm:@codemirror/autocomplete@6.20.0", + "@codemirror/commands": "npm:@codemirror/commands@6.9.0", "@codemirror/language": "npm:@codemirror/language@6.12.4", "@codemirror/state": "npm:@codemirror/state@6.7.1", "@codemirror/view": "npm:@codemirror/view@6.43.8", diff --git a/web/workspace/deno.lock b/web/workspace/deno.lock index 25ae28a6..71b8b841 100644 --- a/web/workspace/deno.lock +++ b/web/workspace/deno.lock @@ -4,6 +4,7 @@ "jsr:@std/assert@*": "1.0.19", "jsr:@std/internal@^1.0.12": "1.0.14", "npm:@codemirror/autocomplete@6.20.0": "6.20.0", + "npm:@codemirror/commands@6.9.0": "6.9.0", "npm:@codemirror/language@6.12.4": "6.12.4", "npm:@codemirror/state@6.7.1": "6.7.1", "npm:@codemirror/view@6.43.8": "6.43.8", @@ -47,6 +48,15 @@ "@lezer/common" ] }, + "@codemirror/commands@6.9.0": { + "integrity": "sha512-454TVgjhO6cMufsyyGN70rGIfJxJEjcqjBG2x2Y03Y/+Fm99d3O/Kv1QDYWuG6hvxsgmjXmBuATikIIYvERX+w==", + "dependencies": [ + "@codemirror/language", + "@codemirror/state", + "@codemirror/view", + "@lezer/common" + ] + }, "@codemirror/language@6.12.4": { "integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==", "dependencies": [ @@ -1012,6 +1022,7 @@ "workspace": { "dependencies": [ "npm:@codemirror/autocomplete@6.20.0", + "npm:@codemirror/commands@6.9.0", "npm:@codemirror/language@6.12.4", "npm:@codemirror/state@6.7.1", "npm:@codemirror/view@6.43.8", diff --git a/web/workspace/src/lib/workspace/console/ComposerInput.svelte b/web/workspace/src/lib/workspace/console/ComposerInput.svelte new file mode 100644 index 00000000..11b161b4 --- /dev/null +++ b/web/workspace/src/lib/workspace/console/ComposerInput.svelte @@ -0,0 +1,414 @@ + + +
+ + diff --git a/web/workspace/src/lib/workspace/console/composer-command.ts b/web/workspace/src/lib/workspace/console/composer-command.ts index 743bd77a..dfc828b0 100644 --- a/web/workspace/src/lib/workspace/console/composer-command.ts +++ b/web/workspace/src/lib/workspace/console/composer-command.ts @@ -80,6 +80,61 @@ export function buildComposerRequest(value: string): ComposerCommandResult { }; } +export function buildComposerSegmentsRequest( + sourceSegments: readonly Segment[], +): ComposerCommandResult { + const hasPaste = sourceSegments.some((segment) => segment.kind === "paste"); + if (!hasPaste) { + const content = sourceSegments.map(segmentContent).join(""); + return buildComposerRequest(content); + } + + const content = sourceSegments.map(segmentContent).join(""); + if (!content.trim()) { + return { ok: false, message: "Input is empty." }; + } + if (content.trimStart().startsWith(":")) { + return { + ok: false, + message: + "Commands cannot include a paste chip. Remove the chip or send it as a message.", + }; + } + + const segments: Segment[] = []; + for (const segment of sourceSegments) { + if (segment.kind === "text") { + segments.push(...parseSigilSegments(segment.content)); + } else { + segments.push(segment); + } + } + return { + ok: true, + request: { + kind: "user", + content, + segments: coalesceTextSegments(segments), + }, + }; +} + +function segmentContent(segment: Segment): string { + switch (segment.kind) { + case "text": + case "paste": + return segment.content; + case "file_ref": + return `@${segment.path}`; + case "flow": + return segment.selector; + case "paste_artifact": + return ""; + default: + return ""; + } +} + function buildColonCommand(commandLine: string): ComposerCommandResult { const [name = "", ...argv] = commandLine.trim().split(/\s+/).filter(Boolean); if (!name) { diff --git a/web/workspace/src/lib/workspace/console/composer-draft.test.ts b/web/workspace/src/lib/workspace/console/composer-draft.test.ts new file mode 100644 index 00000000..973654e7 --- /dev/null +++ b/web/workspace/src/lib/workspace/console/composer-draft.test.ts @@ -0,0 +1,146 @@ +import type { Segment } from "$lib/generated/protocol.ts"; +import { + type ComposerPaste, + composerPasteToken, + pasteChipLabel, + snapshotComposerDraft, +} from "$lib/workspace/console/composer-draft.ts"; +import { buildComposerSegmentsRequest } from "$lib/workspace/console/composer-command.ts"; +import { measureComposerPaste } from "$lib/workspace/console/composer-paste.ts"; + +declare const Deno: { + test(name: string, fn: () => void): void; +}; + +function assert( + condition: unknown, + message = "assertion failed", +): asserts condition { + if (!condition) throw new Error(message); +} + +function assertEquals(actual: unknown, expected: unknown): void { + const actualJson = JSON.stringify(actual); + const expectedJson = JSON.stringify(expected); + if (actualJson !== expectedJson) { + throw new Error(`expected ${expectedJson}, received ${actualJson}`); + } +} + +function paste(id: number, content: string): ComposerPaste { + const measurement = measureComposerPaste(content); + return { + id, + content, + chars: measurement.charCount, + lines: measurement.logicalLineCount, + }; +} + +Deno.test("composer draft preserves mixed Text and Paste order exactly", () => { + const unicodeCrlf = "🙂界\r\nsecond\r\n"; + const trailingNewline = `${"x".repeat(51)}\n`; + const registry = new Map([ + [11, paste(1, unicodeCrlf)], + [12, paste(2, trailingNewline)], + ]); + const document = `before ${composerPasteToken(11)} middle ${ + composerPasteToken(12) + } after`; + + const snapshot = snapshotComposerDraft(document, registry); + + assertEquals( + snapshot.content, + `before ${unicodeCrlf} middle ${trailingNewline} after`, + ); + assertEquals(snapshot.segments, [ + { kind: "text", content: "before " }, + { + kind: "paste", + id: 1, + content: unicodeCrlf, + chars: 12, + lines: 3, + }, + { kind: "text", content: " middle " }, + { + kind: "paste", + id: 2, + content: trailingNewline, + chars: 52, + lines: 2, + }, + { kind: "text", content: " after" }, + ]); + assertEquals(snapshot.pastes.map((entry) => entry.key), [11, 12]); +}); + +Deno.test("composer paste chip label is compact and accessible", () => { + assertEquals( + pasteChipLabel({ id: 4, content: "payload", chars: 7, lines: 1 }), + "Clipboard #4 · 7 chars · 1 line", + ); +}); + +Deno.test("typed composer restoration retains Paste ids and metadata", () => { + const original: Segment[] = [ + { kind: "text", content: "prefix\n" }, + { + kind: "paste", + id: 9, + content: "alpha\r\nbeta\r\n", + chars: 13, + lines: 3, + }, + { kind: "text", content: "\nsuffix" }, + ]; + const registry = new Map([ + [31, original[1] as Extract], + ]); + const restored = snapshotComposerDraft( + `prefix\n${composerPasteToken(31)}\nsuffix`, + registry, + ); + + assertEquals(restored.segments, original); +}); + +Deno.test("mixed composer request preserves Paste and parsed file-ref boundaries", () => { + const segments: Segment[] = [ + { kind: "text", content: "inspect @src/main.rs then " }, + { + kind: "paste", + id: 2, + content: "a\r\nb\r\n", + chars: 6, + lines: 3, + }, + { kind: "text", content: " exactly" }, + ]; + + const result = buildComposerSegmentsRequest(segments); + assert(result.ok); + assertEquals(result.request, { + kind: "user", + content: "inspect @src/main.rs then a\r\nb\r\n exactly", + segments: [ + { kind: "text", content: "inspect " }, + { kind: "file_ref", path: "src/main.rs" }, + { kind: "text", content: " then " }, + segments[1], + segments[2], + ], + }); +}); + +Deno.test("plain short-paste Text retains the existing composer request path", () => { + const result = buildComposerSegmentsRequest([ + { kind: "text", content: " short\r\npaste\r\n " }, + ]); + assert(result.ok); + assertEquals(result.request, { + kind: "user", + content: "short\r\npaste", + }); +}); diff --git a/web/workspace/src/lib/workspace/console/composer-draft.ts b/web/workspace/src/lib/workspace/console/composer-draft.ts new file mode 100644 index 00000000..7a204a10 --- /dev/null +++ b/web/workspace/src/lib/workspace/console/composer-draft.ts @@ -0,0 +1,95 @@ +import type { Segment } from "$lib/generated/protocol.ts"; + +const PASTE_TOKEN_PREFIX = "\uFFF9"; +const PASTE_TOKEN_SUFFIX = "\uFFFB"; +const PASTE_TOKEN_PATTERN = /\uFFF9(\d+)\uFFFB/g; + +export interface ComposerPaste { + id: number; + content: string; + chars: number; + lines: number; +} + +export interface ComposerPasteAtom extends ComposerPaste { + key: number; + from: number; + to: number; +} + +export interface ComposerDraftSnapshot { + document: string; + content: string; + segments: Segment[]; + pastes: ComposerPasteAtom[]; +} + +export function composerPasteToken(key: number): string { + return `${PASTE_TOKEN_PREFIX}${key}${PASTE_TOKEN_SUFFIX}`; +} + +export function composerPasteAtoms( + document: string, + registry: ReadonlyMap, +): ComposerPasteAtom[] { + const atoms: ComposerPasteAtom[] = []; + for (const match of document.matchAll(PASTE_TOKEN_PATTERN)) { + const key = Number(match[1]); + const paste = registry.get(key); + if (!paste || match.index === undefined) continue; + atoms.push({ + ...paste, + key, + from: match.index, + to: match.index + match[0].length, + }); + } + return atoms; +} + +function appendTextSegment(segments: Segment[], content: string): void { + if (content.length === 0) return; + const previous = segments.at(-1); + if (previous?.kind === "text") { + previous.content += content; + } else { + segments.push({ kind: "text", content }); + } +} + +export function snapshotComposerDraft( + document: string, + registry: ReadonlyMap, +): ComposerDraftSnapshot { + const pastes = composerPasteAtoms(document, registry); + const segments: Segment[] = []; + let content = ""; + let cursor = 0; + + for (const paste of pastes) { + const text = document.slice(cursor, paste.from); + appendTextSegment(segments, text); + content += text; + segments.push({ + kind: "paste", + id: paste.id, + content: paste.content, + chars: paste.chars, + lines: paste.lines, + }); + content += paste.content; + cursor = paste.to; + } + + const trailingText = document.slice(cursor); + appendTextSegment(segments, trailingText); + content += trailingText; + + return { document, content, segments, pastes }; +} + +export function pasteChipLabel(paste: ComposerPaste): string { + const chars = paste.chars === 1 ? "char" : "chars"; + const lines = paste.lines === 1 ? "line" : "lines"; + return `Clipboard #${paste.id} · ${paste.chars} ${chars} · ${paste.lines} ${lines}`; +} 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 e4d55536..1271fbf9 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 @@ -554,18 +554,22 @@ Deno.test("Worker Console removes redundant chrome and uses shared alerts", asyn ); }); -Deno.test("Worker Console composer fits to content without manual resize", async () => { +Deno.test("Worker Console composer keeps a compact bounded chip editor", async () => { const consolePage = await Deno.readTextFile( new URL( "./../../../routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte", import.meta.url, ), ); + const composerInput = await Deno.readTextFile( + new URL("./ComposerInput.svelte", import.meta.url), + ); assert( - consolePage.includes("use:fitTextarea={{ value: draft, maxRows: 10 }}") && + consolePage.includes("') && !consolePage.includes("handleComposerShellClick") && - consolePage.includes("bind:this={composerTextareaElement}") && + consolePage.includes("bind:this={composerInputElement}") && + consolePage.includes("onchange={handleComposerChange}") && consolePage.includes( 'event.key === "PageUp" || event.key === "PageDown"', ) && @@ -577,10 +581,35 @@ Deno.test("Worker Console composer fits to content without manual resize", async consolePage.includes("pointer-events: auto") && consolePage.includes('class="composer-send-icon"') && consolePage.includes('d="M8 6L12 2L16 6"') && - consolePage.includes(".console-composer textarea") && - consolePage.includes("resize: none") && - consolePage.includes("overflow-y: hidden"), - "Console composer should autosize to content, cap at ten rows, wrap input and icon send button, and disable manual resize", + composerInput.includes("max-height: 10rem") && + composerInput.includes("EditorView.lineWrapping") && + composerInput.includes("overflow-y: auto"), + "Console composer should use the bounded chip-capable editor with wrapping, page scrolling, and the icon send button", + ); +}); + +Deno.test("Worker Console paste chips preserve typed draft and target authority", async () => { + const consolePage = await Deno.readTextFile( + new URL( + "./../../../routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte", + import.meta.url, + ), + ); + const composerInput = await Deno.readTextFile( + new URL("./ComposerInput.svelte", import.meta.url), + ); + assert( + composerInput.includes("handleComposerPaste(event, insertPasteChip)") && + composerInput.includes("EditorView.atomicRanges") && + composerInput.includes('key: "Backspace"') && + composerInput.includes('key: "Delete"') && + composerInput.includes('chip.setAttribute("aria-label", label)') && + composerInput.includes("restoreSegments(segments: readonly Segment[])") && + consolePage.includes("buildComposerSegmentsRequest(value.segments)") && + consolePage.includes("composerDrafts.set(activeComposerTargetKey") && + consolePage.includes("switchComposerTarget(target)") && + consolePage.includes('sendControl({ method: "cancel" }, "Stop")'), + "Paste chips should use shared threshold classification, atomic keyboard behavior, accessible labels, typed restore, and per-Worker draft authority", ); }); @@ -742,7 +771,7 @@ Deno.test("Worker Console page is routed by runtime_id and worker_id through bac 'const composerEditable = $derived(protocolState === "open" && !sending);', ) && consolePage.includes('sendControl({ method: "cancel" }, "Stop")') && - consolePage.includes("enabled: canSubmitDraft") && + consolePage.includes("onsubmit={handleComposerSubmit}") && consolePage.includes("disabled={!composerEditable}") && consolePage.includes("class:stop={workerRunning}") && consolePage.includes('"Stop Worker"') && diff --git a/web/workspace/src/routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte index e459f0eb..a497b64d 100644 --- a/web/workspace/src/routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte @@ -1,22 +1,21 @@