feat: add atomic paste chips to Web Composer

This commit is contained in:
2026-09-01 19:50:26 +09:00
parent 12d96fb03d
commit c52c7ead19
8 changed files with 839 additions and 70 deletions
+2 -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 --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", "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"
}, },
@@ -16,6 +16,7 @@
"@sveltejs/kit": "npm:@sveltejs/kit@2.49.4", "@sveltejs/kit": "npm:@sveltejs/kit@2.49.4",
"@sveltejs/vite-plugin-svelte": "npm:@sveltejs/vite-plugin-svelte@6.2.1", "@sveltejs/vite-plugin-svelte": "npm:@sveltejs/vite-plugin-svelte@6.2.1",
"@codemirror/autocomplete": "npm:@codemirror/autocomplete@6.20.0", "@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/language": "npm:@codemirror/language@6.12.4",
"@codemirror/state": "npm:@codemirror/state@6.7.1", "@codemirror/state": "npm:@codemirror/state@6.7.1",
"@codemirror/view": "npm:@codemirror/view@6.43.8", "@codemirror/view": "npm:@codemirror/view@6.43.8",
+11
View File
@@ -4,6 +4,7 @@
"jsr:@std/assert@*": "1.0.19", "jsr:@std/assert@*": "1.0.19",
"jsr:@std/internal@^1.0.12": "1.0.14", "jsr:@std/internal@^1.0.12": "1.0.14",
"npm:@codemirror/autocomplete@6.20.0": "6.20.0", "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/language@6.12.4": "6.12.4",
"npm:@codemirror/state@6.7.1": "6.7.1", "npm:@codemirror/state@6.7.1": "6.7.1",
"npm:@codemirror/view@6.43.8": "6.43.8", "npm:@codemirror/view@6.43.8": "6.43.8",
@@ -47,6 +48,15 @@
"@lezer/common" "@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": { "@codemirror/language@6.12.4": {
"integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==", "integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==",
"dependencies": [ "dependencies": [
@@ -1012,6 +1022,7 @@
"workspace": { "workspace": {
"dependencies": [ "dependencies": [
"npm:@codemirror/autocomplete@6.20.0", "npm:@codemirror/autocomplete@6.20.0",
"npm:@codemirror/commands@6.9.0",
"npm:@codemirror/language@6.12.4", "npm:@codemirror/language@6.12.4",
"npm:@codemirror/state@6.7.1", "npm:@codemirror/state@6.7.1",
"npm:@codemirror/view@6.43.8", "npm:@codemirror/view@6.43.8",
@@ -0,0 +1,414 @@
<script lang="ts">
import {
Compartment,
EditorSelection,
EditorState,
Prec,
StateEffect,
StateField,
} from "@codemirror/state";
import {
Decoration,
EditorView,
keymap,
WidgetType,
type DecorationSet,
} from "@codemirror/view";
import {
defaultKeymap,
history,
historyKeymap,
isolateHistory,
} from "@codemirror/commands";
import { onMount } from "svelte";
import type { Segment } from "$lib/generated/protocol.ts";
import {
handleComposerPaste,
type ComposerPasteMeasurement,
} from "$lib/workspace/console/composer-paste.ts";
import {
composerPasteAtoms,
composerPasteToken,
pasteChipLabel,
snapshotComposerDraft,
type ComposerDraftSnapshot,
type ComposerPaste,
} from "$lib/workspace/console/composer-draft.ts";
import { shouldSubmitChatKey } from "$lib/workspace/console/chat-submit.ts";
interface Props {
disabled?: boolean;
ariaLabel?: string;
ariaKeyShortcuts?: string;
onchange?: (snapshot: ComposerDraftSnapshot) => void;
onkeydown?: (event: KeyboardEvent) => void;
onsubmit?: () => void;
}
let {
disabled = false,
ariaLabel = "Message",
ariaKeyShortcuts = "Meta+Enter Control+Enter",
onchange,
onkeydown,
onsubmit,
}: Props = $props();
let mountElement: HTMLDivElement;
let view: EditorView | null = null;
let nextPasteId = 1;
let nextPasteKey = 1;
const editable = new Compartment();
const registerPaste = StateEffect.define<{ key: number; paste: ComposerPaste }>();
const pasteRegistry = StateField.define<ReadonlyMap<number, ComposerPaste>>({
create: () => new Map(),
update(registry, transaction) {
const additions = transaction.effects.filter((effect) => effect.is(registerPaste));
if (additions.length === 0) return registry;
const next = new Map(registry);
for (const addition of additions) {
next.set(addition.value.key, addition.value.paste);
}
return next;
},
});
class PasteChipWidget extends WidgetType {
readonly paste: ComposerPaste;
constructor(paste: ComposerPaste) {
super();
this.paste = paste;
}
override eq(other: PasteChipWidget): boolean {
return other.paste.id === this.paste.id &&
other.paste.content === this.paste.content &&
other.paste.chars === this.paste.chars &&
other.paste.lines === this.paste.lines;
}
override toDOM(): HTMLElement {
const chip = document.createElement("span");
const label = pasteChipLabel(this.paste);
chip.className = "composer-paste-chip";
chip.textContent = label;
chip.title = label;
chip.setAttribute("role", "note");
chip.setAttribute("aria-label", label);
return chip;
}
override ignoreEvent(): boolean {
return false;
}
}
function pasteDecorations(state: EditorState): DecorationSet {
const registry = state.field(pasteRegistry);
return Decoration.set(
composerPasteAtoms(state.doc.toString(), registry).map((paste) =>
Decoration.replace({
widget: new PasteChipWidget(paste),
inclusive: false,
}).range(paste.from, paste.to)
),
true,
);
}
const pasteChips = [
pasteRegistry,
EditorView.decorations.of((currentView) => pasteDecorations(currentView.state)),
EditorView.atomicRanges.of((currentView) => pasteDecorations(currentView.state)),
];
function currentSnapshot(state = view?.state): ComposerDraftSnapshot {
if (!state) {
return { document: "", content: "", segments: [], pastes: [] };
}
return snapshotComposerDraft(state.doc.toString(), state.field(pasteRegistry));
}
function emitChange(): void {
onchange?.(currentSnapshot());
}
function insertPasteChip(content: string, measurement: ComposerPasteMeasurement): void {
if (!view) return;
const selection = view.state.selection.main;
const key = nextPasteKey++;
const paste: ComposerPaste = {
id: nextPasteId++,
content,
chars: measurement.charCount,
lines: measurement.logicalLineCount,
};
const token = composerPasteToken(key);
view.dispatch({
changes: { from: selection.from, to: selection.to, insert: token },
selection: EditorSelection.cursor(selection.from + token.length),
effects: registerPaste.of({ key, paste }),
annotations: isolateHistory.of("full"),
userEvent: "input.paste",
});
}
function selectedClipboardContent(state: EditorState): string | null {
const selection = state.selection.main;
if (selection.empty) return null;
const document = state.doc.sliceString(selection.from, selection.to);
const registry = state.field(pasteRegistry);
const selectedRegistry = new Map<number, ComposerPaste>();
for (const atom of composerPasteAtoms(state.doc.toString(), registry)) {
if (atom.from >= selection.from && atom.to <= selection.to) {
selectedRegistry.set(atom.key, atom);
}
}
return snapshotComposerDraft(document, selectedRegistry).content;
}
function deleteAdjacentPasteFromView(
currentView: EditorView,
direction: "backward" | "forward",
): boolean {
const selection = currentView.state.selection.main;
if (!selection.empty) return false;
const pastes = composerPasteAtoms(
currentView.state.doc.toString(),
currentView.state.field(pasteRegistry),
);
const paste = direction === "backward"
? pastes.find((candidate) => candidate.to === selection.head)
: pastes.find((candidate) => candidate.from === selection.head);
if (!paste) return false;
currentView.dispatch({
changes: { from: paste.from, to: paste.to },
selection: EditorSelection.cursor(paste.from),
annotations: isolateHistory.of("full"),
userEvent: "delete",
});
return true;
}
onMount(() => {
view = new EditorView({
parent: mountElement,
state: EditorState.create({
extensions: [
history(),
Prec.highest(keymap.of([
{
key: "Backspace",
run: (currentView) =>
deleteAdjacentPasteFromView(currentView, "backward"),
},
{
key: "Delete",
run: (currentView) =>
deleteAdjacentPasteFromView(currentView, "forward"),
},
])),
keymap.of([...defaultKeymap, ...historyKeymap]),
pasteChips,
editable.of(EditorView.editable.of(!disabled)),
EditorState.allowMultipleSelections.of(false),
EditorView.lineWrapping,
EditorView.contentAttributes.of({
"aria-label": ariaLabel,
"aria-keyshortcuts": ariaKeyShortcuts,
"aria-multiline": "true",
role: "textbox",
spellcheck: "true",
}),
EditorView.updateListener.of((update) => {
if (update.docChanged || update.transactions.some((tx) => tx.effects.length > 0)) {
emitChange();
}
}),
Prec.high(EditorView.domEventHandlers({
paste(event) {
return handleComposerPaste(event, insertPasteChip);
},
copy(event, currentView) {
const content = selectedClipboardContent(currentView.state);
if (content === null || !event.clipboardData) return false;
event.preventDefault();
event.clipboardData.setData("text/plain", content);
return true;
},
cut(event, currentView) {
if (disabled) return false;
const content = selectedClipboardContent(currentView.state);
if (content === null || !event.clipboardData) return false;
event.preventDefault();
event.clipboardData.setData("text/plain", content);
const selection = currentView.state.selection.main;
currentView.dispatch({
changes: { from: selection.from, to: selection.to },
selection: EditorSelection.cursor(selection.from),
userEvent: "delete.cut",
});
return true;
},
keydown(event) {
onkeydown?.(event);
if (event.defaultPrevented) return true;
if (
shouldSubmitChatKey(event, {
mode: "mod-enter",
modKey: "auto",
enabled: !disabled,
})
) {
event.preventDefault();
onsubmit?.();
return true;
}
return false;
},
})),
EditorView.theme({
"&": { backgroundColor: "transparent" },
".cm-scroller": { fontFamily: "inherit" },
".cm-content": { caretColor: "var(--text-strong)" },
"&.cm-focused": { outline: "none" },
}),
],
}),
});
emitChange();
return () => {
view?.destroy();
view = null;
};
});
$effect(() => {
const isDisabled = disabled;
view?.dispatch({
effects: editable.reconfigure(EditorView.editable.of(!isDisabled)),
});
});
export function snapshot(): ComposerDraftSnapshot {
return currentSnapshot();
}
export function focus(): void {
view?.focus();
}
export function containsTarget(target: EventTarget | null): boolean {
return target instanceof Node && Boolean(view?.dom.contains(target));
}
export function cursor(): number {
return view?.state.selection.main.head ?? 0;
}
export function replaceRange(from: number, to: number, content: string): void {
if (!view) return;
view.dispatch({
changes: { from, to, insert: content },
selection: EditorSelection.cursor(from + content.length),
userEvent: "input.complete",
});
}
export function clear(): void {
if (!view) return;
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: "" },
selection: EditorSelection.cursor(0),
userEvent: "input",
});
}
export function restoreSegments(segments: readonly Segment[]): void {
if (!view) return;
let document = "";
const effects: StateEffect<{ key: number; paste: ComposerPaste }>[] = [];
let highestPasteId = nextPasteId - 1;
for (const segment of segments) {
if (segment.kind === "text") {
document += segment.content;
} else if (segment.kind === "paste") {
const key = nextPasteKey++;
const paste: ComposerPaste = {
id: segment.id,
content: segment.content,
chars: segment.chars,
lines: segment.lines,
};
highestPasteId = Math.max(highestPasteId, paste.id);
document += composerPasteToken(key);
effects.push(registerPaste.of({ key, paste }));
} else if (segment.kind === "file_ref") {
document += `@${segment.path}`;
}
}
nextPasteId = highestPasteId + 1;
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: document },
selection: EditorSelection.cursor(document.length),
effects,
userEvent: "input.restore",
});
}
</script>
<div class="composer-input" class:disabled bind:this={mountElement}></div>
<style>
.composer-input {
min-width: 0;
flex: 1;
color: var(--text-strong);
font: inherit;
}
.composer-input.disabled {
opacity: 0.56;
}
.composer-input :global(.cm-editor) {
min-height: 5.35rem;
max-height: 10rem;
}
.composer-input :global(.cm-scroller) {
overflow-y: auto;
}
.composer-input :global(.cm-content) {
min-height: 5.35rem;
padding: 0.55rem 3.4rem 3rem 0.65rem;
line-height: 1.45;
}
.composer-input :global(.cm-line) {
padding: 0;
}
.composer-input :global(.composer-paste-chip) {
display: inline-flex;
align-items: center;
max-width: min(26rem, 70vw);
margin: 0 0.15rem;
padding: 0.08rem 0.42rem;
overflow: hidden;
border: 1px solid color-mix(in srgb, var(--accent) 42%, var(--line));
border-radius: 999px;
background: color-mix(in srgb, var(--accent) 10%, var(--bg-subtle));
color: var(--text-muted);
font-size: 0.72rem;
font-weight: 600;
line-height: 1.35;
text-overflow: ellipsis;
vertical-align: baseline;
white-space: nowrap;
}
</style>
@@ -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 { function buildColonCommand(commandLine: string): ComposerCommandResult {
const [name = "", ...argv] = commandLine.trim().split(/\s+/).filter(Boolean); const [name = "", ...argv] = commandLine.trim().split(/\s+/).filter(Boolean);
if (!name) { if (!name) {
@@ -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<number, ComposerPaste>([
[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<number, ComposerPaste>([
[31, original[1] as Extract<Segment, { kind: "paste" }>],
]);
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",
});
});
@@ -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<number, ComposerPaste>,
): 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<number, ComposerPaste>,
): 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}`;
}
@@ -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( const consolePage = await Deno.readTextFile(
new URL( new URL(
"./../../../routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte", "./../../../routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte",
import.meta.url, import.meta.url,
), ),
); );
const composerInput = await Deno.readTextFile(
new URL("./ComposerInput.svelte", import.meta.url),
);
assert( assert(
consolePage.includes("use:fitTextarea={{ value: draft, maxRows: 10 }}") && consolePage.includes("<ComposerInput") &&
consolePage.includes('<div class="composer-input-shell">') && consolePage.includes('<div class="composer-input-shell">') &&
!consolePage.includes("handleComposerShellClick") && !consolePage.includes("handleComposerShellClick") &&
consolePage.includes("bind:this={composerTextareaElement}") && consolePage.includes("bind:this={composerInputElement}") &&
consolePage.includes("onchange={handleComposerChange}") &&
consolePage.includes( consolePage.includes(
'event.key === "PageUp" || event.key === "PageDown"', '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("pointer-events: auto") &&
consolePage.includes('class="composer-send-icon"') && consolePage.includes('class="composer-send-icon"') &&
consolePage.includes('d="M8 6L12 2L16 6"') && consolePage.includes('d="M8 6L12 2L16 6"') &&
consolePage.includes(".console-composer textarea") && composerInput.includes("max-height: 10rem") &&
consolePage.includes("resize: none") && composerInput.includes("EditorView.lineWrapping") &&
consolePage.includes("overflow-y: hidden"), composerInput.includes("overflow-y: auto"),
"Console composer should autosize to content, cap at ten rows, wrap input and icon send button, and disable manual resize", "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);', 'const composerEditable = $derived(protocolState === "open" && !sending);',
) && ) &&
consolePage.includes('sendControl({ method: "cancel" }, "Stop")') && consolePage.includes('sendControl({ method: "cancel" }, "Stop")') &&
consolePage.includes("enabled: canSubmitDraft") && consolePage.includes("onsubmit={handleComposerSubmit}") &&
consolePage.includes("disabled={!composerEditable}") && consolePage.includes("disabled={!composerEditable}") &&
consolePage.includes("class:stop={workerRunning}") && consolePage.includes("class:stop={workerRunning}") &&
consolePage.includes('"Stop Worker"') && consolePage.includes('"Stop Worker"') &&
@@ -1,22 +1,21 @@
<script lang="ts"> <script lang="ts">
import { tick, untrack } from "svelte"; import { tick, untrack, type SvelteComponent } from "svelte";
import ConsoleLineItem from "$lib/workspace/console/ConsoleLineItem.svelte"; import ConsoleLineItem from "$lib/workspace/console/ConsoleLineItem.svelte";
import ConsoleTasks from "$lib/workspace/console/ConsoleTasks.svelte"; import ConsoleTasks from "$lib/workspace/console/ConsoleTasks.svelte";
import ConsoleTimeline from "$lib/workspace/console/ConsoleTimeline.svelte"; import ConsoleTimeline from "$lib/workspace/console/ConsoleTimeline.svelte";
import { chatSubmit } from "$lib/workspace/console/chat-submit"; import ComposerInput from "$lib/workspace/console/ComposerInput.svelte";
import type { ComposerDraftSnapshot } from "$lib/workspace/console/composer-draft";
import { import {
buildComposerRequest, buildComposerSegmentsRequest,
type WorkerConsoleInputRequest, type WorkerConsoleInputRequest,
} from "$lib/workspace/console/composer-command"; } from "$lib/workspace/console/composer-command";
import { import {
applyCompletion,
completionTokenAt, completionTokenAt,
localCommandCompletions, localCommandCompletions,
type ComposerCompletionEntry, type ComposerCompletionEntry,
type ComposerCompletionToken, type ComposerCompletionToken,
} from "$lib/workspace/console/composer-completion"; } from "$lib/workspace/console/composer-completion";
import WorkerRunStatus from "$lib/workspace/console/WorkerRunStatus.svelte"; import WorkerRunStatus from "$lib/workspace/console/WorkerRunStatus.svelte";
import { fitTextarea } from "$lib/workspace/console/textarea-fit";
import { resolveWorkerControlShortcut } from "$lib/workspace/console/worker-control-shortcuts"; import { resolveWorkerControlShortcut } from "$lib/workspace/console/worker-control-shortcuts";
import { import {
consoleWorkerViews, consoleWorkerViews,
@@ -103,7 +102,24 @@
untrack(() => data.worker?.state ?? null), untrack(() => data.worker?.state ?? null),
); );
let workerError = $state<string | null>(untrack(() => data.workerError)); let workerError = $state<string | null>(untrack(() => data.workerError));
let draft = $state(""); type ComposerInputHandle = {
snapshot(): ComposerDraftSnapshot;
focus(): void;
containsTarget(target: EventTarget | null): boolean;
cursor(): number;
replaceRange(from: number, to: number, content: string): void;
clear(): void;
restoreSegments(segments: readonly Segment[]): void;
};
const EMPTY_DRAFT: ComposerDraftSnapshot = {
document: "",
content: "",
segments: [],
pastes: [],
};
let draft = $state<ComposerDraftSnapshot>(EMPTY_DRAFT);
let completionEntries = $state<ComposerCompletionEntry[]>([]); let completionEntries = $state<ComposerCompletionEntry[]>([]);
let completionToken = $state<ComposerCompletionToken | null>(null); let completionToken = $state<ComposerCompletionToken | null>(null);
let completionBusy = $state(false); let completionBusy = $state(false);
@@ -130,7 +146,13 @@
let timelineOpen = $state(false); let timelineOpen = $state(false);
let consoleViewMode = $state<ConsoleViewMode>("overview"); let consoleViewMode = $state<ConsoleViewMode>("overview");
let consoleBodyElement: HTMLElement | null = null; let consoleBodyElement: HTMLElement | null = null;
let composerTextareaElement: HTMLTextAreaElement | null = null; let composerInputElement = $state<
(SvelteComponent & ComposerInputHandle) | null
>(null);
const composerDrafts = new Map<string, Segment[]>();
let activeComposerTargetKey = untrack(
() => `${workspaceId}:${runtimeId}:${workerId}`,
);
let timelineRailDragCleanup: (() => void) | null = null; let timelineRailDragCleanup: (() => void) | null = null;
let autoFollowConsole = $state(true); let autoFollowConsole = $state(true);
let consoleScroll = $state<ScrollMetrics>({ top: 0, height: 1, client: 1 }); let consoleScroll = $state<ScrollMetrics>({ top: 0, height: 1, client: 1 });
@@ -196,7 +218,7 @@
const inputReady = $derived(workerState === "idle"); const inputReady = $derived(workerState === "idle");
const composerEditable = $derived(protocolState === "open" && !sending); const composerEditable = $derived(protocolState === "open" && !sending);
const canSubmitDraft = $derived(inputReady && composerEditable); const canSubmitDraft = $derived(inputReady && composerEditable);
const canSend = $derived(canSubmitDraft && draft.trim().length > 0); const canSend = $derived(canSubmitDraft && draft.content.trim().length > 0);
const canStopFromComposer = $derived(workerRunning && composerEditable); const canStopFromComposer = $derived(workerRunning && composerEditable);
const composerSubmitDisabled = $derived( const composerSubmitDisabled = $derived(
workerRunning ? !canStopFromComposer : !canSend, workerRunning ? !canStopFromComposer : !canSend,
@@ -321,14 +343,11 @@
scheduleObservationFlush(); scheduleObservationFlush();
} }
async function applyComposerCompletion(event: KeyboardEvent) { async function applyComposerCompletion() {
const target = event.currentTarget; if (!composerInputElement) return;
if (!(target instanceof HTMLTextAreaElement)) {
return;
}
const token = completionTokenAt( const token = completionTokenAt(
draft, draft.document,
target.selectionStart ?? draft.length, composerInputElement.cursor(),
); );
completionToken = token; completionToken = token;
completionError = null; completionError = null;
@@ -345,10 +364,11 @@
completionError = `No completions for ${token.sigil}${token.prefix}`; completionError = `No completions for ${token.sigil}${token.prefix}`;
return; return;
} }
const applied = applyCompletion(draft, token, entries[0]); composerInputElement.replaceRange(
draft = applied.value; token.start,
await tick(); token.end,
target.setSelectionRange(applied.cursor, applied.cursor); `${entries[0].value} `,
);
composerNotice = composerNotice =
entries.length > 1 entries.length > 1
? `Completed ${token.sigil}${entries[0].value}; ${entries.length - 1} more candidate(s)` ? `Completed ${token.sigil}${entries[0].value}; ${entries.length - 1} more candidate(s)`
@@ -404,7 +424,7 @@
return; return;
} }
event.preventDefault(); event.preventDefault();
void applyComposerCompletion(event); void applyComposerCompletion();
} }
function scrollConsoleByPage(direction: 1 | -1) { function scrollConsoleByPage(direction: 1 | -1) {
@@ -465,13 +485,13 @@
} }
function handleWorkerControlShortcut(event: KeyboardEvent) { function handleWorkerControlShortcut(event: KeyboardEvent) {
const composerFocused = event.target === composerTextareaElement; const composerFocused = composerInputElement?.containsTarget(event.target) ?? false;
const command = resolveWorkerControlShortcut(event, { const command = resolveWorkerControlShortcut(event, {
protocolOpen: protocolState === "open", protocolOpen: protocolState === "open",
running: workerRunning, running: workerRunning,
paused: workerPaused, paused: workerPaused,
composerFocused, composerFocused,
draftBlank: draft.trim().length === 0, draftBlank: draft.content.trim().length === 0,
editableTarget: isEditableTarget(event.target) && !composerFocused, editableTarget: isEditableTarget(event.target) && !composerFocused,
hasSelection: targetHasSelection(event.target), hasSelection: targetHasSelection(event.target),
}); });
@@ -529,16 +549,38 @@
} }
} }
function handleComposerSubmit(value = draft) { function handleComposerChange(snapshot: ComposerDraftSnapshot) {
draft = snapshot;
composerDrafts.set(activeComposerTargetKey, [...snapshot.segments]);
}
function switchComposerTarget(target: ConsoleTarget) {
const nextKey = `${target.workspaceId}:${target.runtimeId}:${target.workerId}`;
if (nextKey === activeComposerTargetKey) return;
if (composerInputElement) {
composerDrafts.set(
activeComposerTargetKey,
[...composerInputElement.snapshot().segments],
);
}
activeComposerTargetKey = nextKey;
const restored = composerDrafts.get(nextKey) ?? [];
void tick().then(() => {
if (activeComposerTargetKey !== nextKey) return;
composerInputElement?.restoreSegments(restored);
});
}
function handleComposerSubmit() {
if (workerRunning) { if (workerRunning) {
sendControl({ method: "cancel" }, "Stop"); sendControl({ method: "cancel" }, "Stop");
return; return;
} }
void submitDraft(value); void submitDraft(composerInputElement?.snapshot() ?? draft);
} }
async function submitDraft(value = draft) { async function submitDraft(value: ComposerDraftSnapshot) {
const command = buildComposerRequest(value); const command = buildComposerSegmentsRequest(value.segments);
if (!command.ok) { if (!command.ok) {
composerNotice = null; composerNotice = null;
sendError = command.message; sendError = command.message;
@@ -546,7 +588,7 @@
} }
composerNotice = command.notice ?? null; composerNotice = command.notice ?? null;
if (!command.request) { if (!command.request) {
draft = ""; composerInputElement?.clear();
return; return;
} }
if (sending || !inputReady) { if (sending || !inputReady) {
@@ -558,7 +600,7 @@
try { try {
const method = composerRequestToProtocolMethod(command.request); const method = composerRequestToProtocolMethod(command.request);
sendProtocolMethod(method); sendProtocolMethod(method);
draft = ""; composerInputElement?.clear();
if (method.method === "run" || method.method === "notify") { if (method.method === "run" || method.method === "notify") {
liveWorkerState = "running"; liveWorkerState = "running";
} }
@@ -1242,6 +1284,7 @@
$effect(() => { $effect(() => {
const target = consoleTarget; const target = consoleTarget;
switchComposerTarget(target);
const targetWorker = data.worker; const targetWorker = data.worker;
const targetWorkerError = data.workerError; const targetWorkerError = data.workerError;
workerViewSelectionGeneration += 1; workerViewSelectionGeneration += 1;
@@ -1521,19 +1564,15 @@
<form class="console-composer" onsubmit={sendMessage}> <form class="console-composer" onsubmit={sendMessage}>
<div class="composer-input-shell"> <div class="composer-input-shell">
<textarea <ComposerInput
id="worker-console-message" bind:this={composerInputElement}
aria-label="Console input" ariaLabel="Console input"
aria-keyshortcuts="Meta+Enter Control+Enter" ariaKeyShortcuts="Meta+Enter Control+Enter"
bind:this={composerTextareaElement} disabled={!composerEditable}
bind:value={draft} onchange={handleComposerChange}
use:chatSubmit={{
enabled: canSubmitDraft,
onSubmit: (value) => handleComposerSubmit(value),
}}
use:fitTextarea={{ value: draft, maxRows: 10 }}
onkeydown={handleComposerKeydown} onkeydown={handleComposerKeydown}
disabled={!composerEditable}></textarea> onsubmit={handleComposerSubmit}
/>
<div class="composer-input-footer"> <div class="composer-input-footer">
<div class="composer-footer-slot"> <div class="composer-footer-slot">
{#if completionBusy || completionError || completionEntries.length > 0} {#if completionBusy || completionError || completionEntries.length > 0}
@@ -1875,27 +1914,6 @@
min-width: 0; min-width: 0;
} }
.console-composer textarea {
box-sizing: border-box;
width: 100%;
min-height: 5.35rem;
resize: none;
overflow-y: hidden;
border: 0;
border-radius: 14px;
background: transparent;
padding: 0.55rem 3.4rem 3rem 0.65rem;
font: inherit;
line-height: 1.45;
color: var(--text-strong);
outline: none;
cursor: text;
}
.console-composer textarea:disabled {
color: var(--text-muted);
}
.composer-send-button { .composer-send-button {
display: inline-grid; display: inline-grid;
width: 2.35rem; width: 2.35rem;