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 @@