From 8344921b65be07d6091345041702bcfe1f9e7584 Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 2 Sep 2026 18:33:43 +0900 Subject: [PATCH] feat: add Composer input history navigation --- web/workspace/deno.json | 2 +- .../workspace/console/ComposerInput.svelte | 69 ++++++ .../lib/workspace/console/composer-history.ts | 193 +++++++++++++++++ .../workers/[workerId]/console/+page.svelte | 2 + web/workspace/test/composer-history.test.ts | 201 ++++++++++++++++++ 5 files changed, 466 insertions(+), 1 deletion(-) create mode 100644 web/workspace/src/lib/workspace/console/composer-history.ts create mode 100644 web/workspace/test/composer-history.test.ts diff --git a/web/workspace/deno.json b/web/workspace/deno.json index 561a1863..6768d3a1 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-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 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/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 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/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 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/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" }, diff --git a/web/workspace/src/lib/workspace/console/ComposerInput.svelte b/web/workspace/src/lib/workspace/console/ComposerInput.svelte index 36672a8c..9bb6235c 100644 --- a/web/workspace/src/lib/workspace/console/ComposerInput.svelte +++ b/web/workspace/src/lib/workspace/console/ComposerInput.svelte @@ -37,12 +37,21 @@ type ComposerPaste, type ComposerTextPaste, } from "$lib/workspace/console/composer-draft.ts"; + import { + ComposerHistory, + loadComposerHistory, + saveComposerHistory, + shouldBrowseComposerHistory, + type ComposerHistoryDirection, + type ComposerHistoryEntry, + } from "$lib/workspace/console/composer-history.ts"; import { shouldSubmitChatKey } from "$lib/workspace/console/chat-submit.ts"; interface Props { disabled?: boolean; ariaLabel?: string; ariaKeyShortcuts?: string; + historyScope: string; onchange?: (snapshot: ComposerDraftSnapshot) => void; onkeydown?: (event: KeyboardEvent) => void; onsubmit?: () => void; @@ -52,6 +61,7 @@ disabled = false, ariaLabel = "Message", ariaKeyShortcuts = "Meta+Enter Control+Enter", + historyScope, onchange, onkeydown, onsubmit, @@ -59,6 +69,8 @@ let mountElement: HTMLDivElement; let view: EditorView | null = null; + let composerHistory = new ComposerHistory(); + let restoringHistory = false; let nextPasteId = 1; let nextPasteKey = 1; const editable = new Compartment(); @@ -179,6 +191,40 @@ onchange?.(currentSnapshot()); } + function historyEntry(state: EditorState): ComposerHistoryEntry { + const snapshot = currentSnapshot(state); + return { + segments: snapshot.segments, + preserveExactText: snapshot.textPastes.length > 0, + }; + } + + function browseHistory(currentView: EditorView, direction: ComposerHistoryDirection): boolean { + const selection = currentView.state.selection.main; + const cursorLine = currentView.state.doc.lineAt(selection.head).number; + if (!shouldBrowseComposerHistory({ + direction, + cursorLine, + lineCount: currentView.state.doc.lines, + selectionEmpty: selection.empty, + readOnly: currentView.state.readOnly, + composing: currentView.composing, + })) return false; + + const entry = direction === "older" + ? composerHistory.previous(historyEntry(currentView.state)) + : composerHistory.next(); + if (!entry) return false; + + restoringHistory = true; + try { + restoreSegments(entry.segments, entry.preserveExactText); + } finally { + restoringHistory = false; + } + return true; + } + function insertPasteChip(content: string, measurement: ComposerPasteMeasurement): void { if (!view) return; const selection = view.state.selection.main; @@ -273,6 +319,10 @@ return true; } + $effect(() => { + composerHistory = loadComposerHistory(localStorage, historyScope); + }); + onMount(() => { view = new EditorView({ parent: mountElement, @@ -292,6 +342,14 @@ key: "Mod-y", run: (currentView) => currentView.state.readOnly, }, + { + key: "ArrowUp", + run: (currentView) => browseHistory(currentView, "older"), + }, + { + key: "ArrowDown", + run: (currentView) => browseHistory(currentView, "newer"), + }, { key: "Backspace", run: (currentView) => @@ -319,6 +377,7 @@ spellcheck: "true", }), EditorView.updateListener.of((update) => { + if (update.docChanged && !restoringHistory) composerHistory.cancelNavigation(); if (update.docChanged || update.transactions.some((tx) => tx.effects.length > 0)) { emitChange(); } @@ -396,6 +455,16 @@ return currentSnapshot(); } + export function recordHistory(value: ComposerDraftSnapshot): void { + const entry: ComposerHistoryEntry = { + segments: value.segments, + preserveExactText: value.textPastes.length > 0, + }; + if (composerHistory.record(entry)) { + saveComposerHistory(localStorage, historyScope, composerHistory); + } + } + export function focus(): void { view?.focus(); } diff --git a/web/workspace/src/lib/workspace/console/composer-history.ts b/web/workspace/src/lib/workspace/console/composer-history.ts new file mode 100644 index 00000000..62698094 --- /dev/null +++ b/web/workspace/src/lib/workspace/console/composer-history.ts @@ -0,0 +1,193 @@ +import type { Segment } from "$lib/generated/protocol"; + +export const COMPOSER_HISTORY_LIMIT = 30; +const COMPOSER_HISTORY_VERSION = 1; +const COMPOSER_HISTORY_KEY_PREFIX = "yoi.composer-history.v1.workspace."; + +export type ComposerHistoryDirection = "older" | "newer"; + +export type ComposerHistoryCursor = { + direction: ComposerHistoryDirection; + cursorLine: number; + lineCount: number; + selectionEmpty: boolean; + readOnly: boolean; + composing: boolean; +}; + +export type ComposerHistoryEntry = { + segments: Segment[]; + preserveExactText: boolean; +}; + +type StoredComposerHistory = { + version: typeof COMPOSER_HISTORY_VERSION; + entries: ComposerHistoryEntry[]; +}; + +type ComposerHistoryStorage = Pick; + +function cloneEntry(entry: ComposerHistoryEntry): ComposerHistoryEntry { + return { + segments: entry.segments.map((segment) => ({ ...segment })) as Segment[], + preserveExactText: entry.preserveExactText, + }; +} + +function isSegment(value: unknown): value is Segment { + if (!value || typeof value !== "object") return false; + const segment = value as Record; + if (segment.kind === "text") return typeof segment.content === "string"; + if (segment.kind === "paste") { + return typeof segment.content === "string" && + typeof segment.id === "number" && + typeof segment.chars === "number" && + typeof segment.lines === "number"; + } + if (segment.kind === "file_ref") return typeof segment.path === "string"; + return false; +} + +function isHistoryEntry(value: unknown): value is ComposerHistoryEntry { + if (!value || typeof value !== "object") return false; + const entry = value as Record; + return Array.isArray(entry.segments) && + entry.segments.every(isSegment) && + typeof entry.preserveExactText === "boolean"; +} + +function isBlankEntry(entry: ComposerHistoryEntry): boolean { + return entry.segments.length === 0 || + entry.segments.every((segment) => + segment.kind === "text" && segment.content.trim().length === 0 + ); +} + +function sameEntry( + left: ComposerHistoryEntry, + right: ComposerHistoryEntry, +): boolean { + return JSON.stringify(left) === JSON.stringify(right); +} + +export function shouldBrowseComposerHistory( + cursor: ComposerHistoryCursor, +): boolean { + if (cursor.readOnly || cursor.composing || !cursor.selectionEmpty) { + return false; + } + return cursor.direction === "older" + ? cursor.cursorLine === 1 + : cursor.cursorLine === cursor.lineCount; +} + +export function composerHistoryStorageKey(workspaceId: string): string { + return `${COMPOSER_HISTORY_KEY_PREFIX}${encodeURIComponent(workspaceId)}`; +} + +export class ComposerHistory { + #entries: ComposerHistoryEntry[]; + #index: number | null = null; + #draft: ComposerHistoryEntry | null = null; + + constructor(entries: ComposerHistoryEntry[] = []) { + this.#entries = []; + for (const entry of entries) this.record(entry); + } + + get entries(): ComposerHistoryEntry[] { + return this.#entries.map(cloneEntry); + } + + get browsing(): boolean { + return this.#index !== null; + } + + record(entry: ComposerHistoryEntry): boolean { + if (isBlankEntry(entry)) { + this.cancelNavigation(); + return false; + } + const last = this.#entries.at(-1); + if (last && sameEntry(last, entry)) { + this.cancelNavigation(); + return false; + } + this.#entries.push(cloneEntry(entry)); + if (this.#entries.length > COMPOSER_HISTORY_LIMIT) { + this.#entries.splice(0, this.#entries.length - COMPOSER_HISTORY_LIMIT); + } + this.cancelNavigation(); + return true; + } + + previous(draft: ComposerHistoryEntry): ComposerHistoryEntry | null { + if (this.#entries.length === 0) return null; + if (this.#index === null) { + this.#draft = cloneEntry(draft); + this.#index = this.#entries.length - 1; + } else if (this.#index > 0) { + this.#index -= 1; + } + return cloneEntry(this.#entries[this.#index]); + } + + next(): ComposerHistoryEntry | null { + if (this.#index === null) return null; + if (this.#index < this.#entries.length - 1) { + this.#index += 1; + return cloneEntry(this.#entries[this.#index]); + } + const draft = this.#draft + ? cloneEntry(this.#draft) + : { segments: [], preserveExactText: false }; + this.cancelNavigation(); + return draft; + } + + cancelNavigation(): void { + this.#index = null; + this.#draft = null; + } +} + +export function loadComposerHistory( + storage: ComposerHistoryStorage, + workspaceId: string, +): ComposerHistory { + try { + const raw = storage.getItem(composerHistoryStorageKey(workspaceId)); + if (!raw) return new ComposerHistory(); + const value = JSON.parse(raw) as unknown; + if (!value || typeof value !== "object") return new ComposerHistory(); + const stored = value as Partial; + if ( + stored.version !== COMPOSER_HISTORY_VERSION || + !Array.isArray(stored.entries) + ) { + return new ComposerHistory(); + } + return new ComposerHistory(stored.entries.filter(isHistoryEntry)); + } catch { + return new ComposerHistory(); + } +} + +export function saveComposerHistory( + storage: ComposerHistoryStorage, + workspaceId: string, + history: ComposerHistory, +): void { + const value: StoredComposerHistory = { + version: COMPOSER_HISTORY_VERSION, + entries: history.entries, + }; + try { + storage.setItem( + composerHistoryStorageKey(workspaceId), + JSON.stringify(value), + ); + } catch { + // History is an optional convenience; storage failures must not block input submission. + } +} 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 d8d9cdf4..4746382a 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 @@ -636,6 +636,7 @@ try { const method = composerRequestToProtocolMethod(command.request); sendProtocolMethod(method); + composerInputElement?.recordHistory(value); composerInputElement?.clear(); if (method.method === "run" || method.method === "notify") { liveWorkerState = "running"; @@ -1602,6 +1603,7 @@
= {}) { + const values = new Map(Object.entries(initial)); + return { + getItem(key: string): string | null { + return values.get(key) ?? null; + }, + setItem(key: string, value: string): void { + values.set(key, value); + }, + value(key: string): string | null { + return values.get(key) ?? null; + }, + }; +} + +Deno.test("Composer history skips blank and consecutive duplicate entries", () => { + const history = new ComposerHistory(); + + assert(!history.record(entry(" \n"))); + assert(history.record(entry("first"))); + assert(!history.record(entry("first"))); + assert(history.record(entry("second"))); + assertEquals(history.entries.map(text), ["first", "second"]); +}); + +Deno.test("Composer history keeps the newest 30 entries", () => { + const history = new ComposerHistory(); + for (let index = 0; index < COMPOSER_HISTORY_LIMIT + 4; index += 1) { + history.record(entry(`message-${index}`)); + } + + assertEquals(history.entries.length, COMPOSER_HISTORY_LIMIT); + assertEquals(text(history.entries[0] ?? null), "message-4"); + assertEquals(text(history.entries.at(-1) ?? null), "message-33"); +}); + +Deno.test("Composer history uses only the multiline input boundaries", () => { + const base = { + lineCount: 3, + selectionEmpty: true, + readOnly: false, + composing: false, + }; + + assert( + shouldBrowseComposerHistory({ ...base, direction: "older", cursorLine: 1 }), + ); + assert( + !shouldBrowseComposerHistory({ + ...base, + direction: "older", + cursorLine: 2, + }), + ); + assert( + shouldBrowseComposerHistory({ ...base, direction: "newer", cursorLine: 3 }), + ); + assert( + !shouldBrowseComposerHistory({ + ...base, + direction: "newer", + cursorLine: 2, + }), + ); + assert( + !shouldBrowseComposerHistory({ + ...base, + direction: "older", + cursorLine: 1, + selectionEmpty: false, + }), + ); + assert( + !shouldBrowseComposerHistory({ + ...base, + direction: "newer", + cursorLine: 3, + readOnly: true, + }), + ); + assert( + !shouldBrowseComposerHistory({ + ...base, + direction: "older", + cursorLine: 1, + composing: true, + }), + ); +}); + +Deno.test("Composer history navigates older and restores the draft after newer", () => { + const history = new ComposerHistory([entry("first"), entry("second")]); + + assertEquals(text(history.previous(entry("unsent draft"))), "second"); + assertEquals(text(history.previous(entry("ignored draft"))), "first"); + assertEquals(text(history.previous(entry("ignored draft"))), "first"); + assertEquals(text(history.next()), "second"); + assertEquals(text(history.next()), "unsent draft"); + assert(!history.browsing); + assertEquals(history.next(), null); +}); + +Deno.test("editing cancels Composer history navigation", () => { + const history = new ComposerHistory([entry("sent")]); + history.previous(entry("draft")); + assert(history.browsing); + + history.cancelNavigation(); + + assert(!history.browsing); + assertEquals(history.next(), null); +}); + +Deno.test("Composer history persists segments by workspace and ignores corrupt storage", () => { + const storage = memoryStorage(); + const workspaceId = "workspace / one"; + const history = new ComposerHistory(); + const paste = { + kind: "paste", + id: 7, + content: "large paste", + chars: 11, + lines: 1, + } satisfies Segment; + history.record({ segments: [paste], preserveExactText: true }); + + saveComposerHistory(storage, workspaceId, history); + const restored = loadComposerHistory(storage, workspaceId); + + assertEquals(restored.entries, history.entries); + assertEquals( + composerHistoryStorageKey(workspaceId), + "yoi.composer-history.v1.workspace.workspace%20%2F%20one", + ); + + const corrupt = memoryStorage({ + [composerHistoryStorageKey(workspaceId)]: "not-json", + }); + assertEquals(loadComposerHistory(corrupt, workspaceId).entries, []); +}); + +Deno.test("Composer input uses boundary-aware Up and Down history navigation", async () => { + const inputSource = await Deno.readTextFile( + new URL( + "../src/lib/workspace/console/ComposerInput.svelte", + import.meta.url, + ), + ); + const consoleSource = await Deno.readTextFile( + new URL( + "../src/routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte", + import.meta.url, + ), + ); + + assert(inputSource.includes('key: "ArrowUp"')); + assert(inputSource.includes('key: "ArrowDown"')); + assert(inputSource.includes("shouldBrowseComposerHistory({")); + assert(inputSource.includes("lineCount: currentView.state.doc.lines")); + assert(inputSource.includes("composerHistory.cancelNavigation()")); + assert(consoleSource.includes("historyScope={workspaceId}")); + assert(consoleSource.includes("composerInputElement?.recordHistory(value)")); +});