From 1d6b15bc048b5779f93a7ed3cef7dfbaeda85c14 Mon Sep 17 00:00:00 2001 From: Matt Visnovsky Date: Sat, 5 Sep 2026 10:19:03 -0600 Subject: [PATCH] feat(chat): persist more composer input history with global or session scope; recallable with up/down arrow keys (#3035) * feat(chat): persist input history * feat(settings): configure input history scope * fix(web): keep input history validation packaged * fix(chat): preserve input history across tabs * fix(settings): restore prompt history limit * fix(settings): keep history deletion warning visible * fix(i18n): restore Turkish Git empty state translations --------- Co-authored-by: Bohdan Triapitsyn --- packages/ui/src/components/chat/ChatInput.tsx | 67 +- .../components/chat/composer/DOCUMENTATION.md | 31 +- .../state/__tests__/useMessageHistory.test.ts | 459 +++++++++-- .../chat/composer/state/useMessageHistory.ts | 299 +++++-- .../src/components/chat/inputHistory.test.ts | 133 ++++ .../ui/src/components/chat/inputHistory.ts | 118 +++ .../sections/openchamber/OpenChamberPage.tsx | 2 + .../openchamber/OpenChamberVisualSettings.tsx | 91 ++- .../src/components/ui/number-input.test.tsx | 84 +- .../ui/src/components/ui/number-input.tsx | 91 ++- .../hooks/useQueuedMessageAutoSend.test.ts | 68 ++ .../ui/src/hooks/useQueuedMessageAutoSend.ts | 11 +- packages/ui/src/lib/api/types.ts | 3 + packages/ui/src/lib/desktop.ts | 3 + .../ui/src/lib/i18n/messages/de.settings.ts | 9 + .../ui/src/lib/i18n/messages/en.settings.ts | 9 + .../ui/src/lib/i18n/messages/es.settings.ts | 9 + .../ui/src/lib/i18n/messages/fr.settings.ts | 9 + .../ui/src/lib/i18n/messages/ja.settings.ts | 9 + .../ui/src/lib/i18n/messages/ko.settings.ts | 9 + .../ui/src/lib/i18n/messages/pl.settings.ts | 9 + .../src/lib/i18n/messages/pt-BR.settings.ts | 9 + .../ui/src/lib/i18n/messages/tr.settings.ts | 9 + .../ui/src/lib/i18n/messages/uk.settings.ts | 9 + .../src/lib/i18n/messages/zh-CN.settings.ts | 9 + .../src/lib/i18n/messages/zh-TW.settings.ts | 9 + packages/ui/src/lib/inputHistoryScope.ts | 18 + packages/ui/src/lib/persistence.test.ts | 177 +++++ packages/ui/src/lib/persistence.ts | 30 + packages/ui/src/lib/settings/search.test.ts | 22 + packages/ui/src/lib/settings/search.ts | 14 + packages/ui/src/stores/DOCUMENTATION.md | 4 + .../stores/messageQueueStore.server.test.ts | 50 +- packages/ui/src/stores/messageQueueStore.ts | 6 + .../src/stores/useInputHistoryStore.test.ts | 745 ++++++++++++++++++ .../ui/src/stores/useInputHistoryStore.ts | 572 ++++++++++++++ packages/ui/src/sync/DOCUMENTATION.md | 5 +- .../ui/src/sync/performance-diagnostics.ts | 2 - packages/ui/src/sync/session-actions.test.ts | 132 ++++ packages/ui/src/sync/session-actions.ts | 2 + .../src/sync/session-deletion-cleanup.test.ts | 20 + .../ui/src/sync/session-deletion-cleanup.ts | 3 + packages/ui/src/sync/session-ui-store.ts | 36 + packages/ui/src/sync/sync-context.tsx | 41 - .../ui/src/sync/user-message-history.test.ts | 98 --- packages/ui/src/sync/user-message-history.ts | 97 --- .../lib/opencode/input-history-scope.js | 14 + .../server/lib/opencode/settings-helpers.js | 16 + .../lib/opencode/settings-helpers.test.js | 139 ++++ 49 files changed, 3353 insertions(+), 458 deletions(-) create mode 100644 packages/ui/src/components/chat/inputHistory.test.ts create mode 100644 packages/ui/src/components/chat/inputHistory.ts create mode 100644 packages/ui/src/lib/inputHistoryScope.ts create mode 100644 packages/ui/src/stores/useInputHistoryStore.test.ts create mode 100644 packages/ui/src/stores/useInputHistoryStore.ts delete mode 100644 packages/ui/src/sync/user-message-history.test.ts delete mode 100644 packages/ui/src/sync/user-message-history.ts create mode 100644 packages/web/server/lib/opencode/input-history-scope.js diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index 2196641b..9d7ff5a2 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -3,7 +3,7 @@ import { ComposerDictation } from '@/components/dictation/ComposerDictation'; // sessionStore removed — currentSessionId comes from useSessionUIStore import { useConfigStore } from '@/stores/useConfigStore'; import { useUIStore } from '@/stores/useUIStore'; -import { createMessageQueueTarget, getMessageQueueKey, useMessageQueueStore, type QueuedContextPart, type QueuedMessage } from '@/stores/messageQueueStore'; +import { isServerOwnedMessageQueue, createMessageQueueTarget, getMessageQueueKey, useMessageQueueStore, type QueuedContextPart, type QueuedMessage } from '@/stores/messageQueueStore'; import { useAutoReviewStore } from '@/stores/useAutoReviewStore'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSelectionStore } from '@/sync/selection-store'; @@ -18,7 +18,6 @@ import { import type { AttachedFile } from '@/stores/types/sessionTypes'; import * as sessionActions from '@/sync/session-actions'; import { buildLinkedIssue, buildLinkedLinearIssue } from '@/lib/linkedIssues'; -import { useUserMessageHistory } from "@/sync/sync-context"; import { getInlineCommentDraftKey, useInlineCommentDraftStore, type InlineCommentDraft, type InlineCommentDraftTarget } from '@/stores/useInlineCommentDraftStore'; import { useSnippetsStore } from '@/stores/useSnippetsStore'; import { renderMagicPrompt } from '@/lib/magicPrompts'; @@ -166,6 +165,17 @@ import { LinkedReferenceRow } from './composer/ui/LinkedReferenceRow'; import { RevertedMessageDock } from './composer/ui/RevertedMessageDock'; import { SessionSuggestionChip } from '@/components/chat/SessionSuggestionChip'; import { SessionGoalRow } from '@/components/chat/SessionGoalRow'; +import { + createInputHistoryIdentity, + selectInputHistoryEntries, + type InputHistorySubmission, + useInputHistoryStore, +} from '@/stores/useInputHistoryStore'; +import { + buildChatInputHistorySubmissions, + buildInputHistoryNavigatorIdentity, + mapInputHistoryEntriesToValues, +} from './inputHistory'; // Lazy like in ChatMessage: a static import would pull the @pierre/diffs and // Shiki stacks into the eager startup graph for a dialog opened on demand. @@ -188,6 +198,7 @@ const MAX_MOBILE_COMPOSER_LINES = 16; */ const MOBILE_COMPOSER_BOUND_GAP_PX = 4; const EMPTY_QUEUE: QueuedMessage[] = []; +const EMPTY_INPUT_HISTORY_ENTRIES = Object.freeze([] as const); const COMPACT_CHAT_PLACEHOLDER_MAX_WIDTH = 560; const renameFileForAttachmentCitation = (file: File, filename: string): File => { if (file.name === filename) { @@ -888,9 +899,28 @@ const ChatInputComponent: React.FC = ({ const consumeDrafts = useInlineCommentDraftStore((state) => state.consumeDrafts); const hasDrafts = draftCount > 0; - // User message history for up/down arrow navigation. - // Keep this on a narrow hook instead of full session message records. - const messageHistory = useMessageHistory(useUserMessageHistory(currentSessionId ?? "")); + const inputHistoryScope = useInputHistoryStore((state) => state.scope); + const inputHistoryIdentity = React.useMemo( + () => createInputHistoryIdentity( + activeRuntimeKey, + currentSessionDirectoryForSync ?? currentDirectory ?? '', + currentSessionId ?? 'draft', + ), + [activeRuntimeKey, currentDirectory, currentSessionDirectoryForSync, currentSessionId], + ); + const inputHistoryEntries = useInputHistoryStore(React.useCallback((state) => { + const entries = selectInputHistoryEntries(state, inputHistoryIdentity); + return entries.length === 0 ? EMPTY_INPUT_HISTORY_ENTRIES : entries; + }, [inputHistoryIdentity])); + const historyValues = React.useMemo( + () => mapInputHistoryEntriesToValues(inputHistoryEntries), + [inputHistoryEntries], + ); + const messageHistoryIdentity = React.useMemo( + () => buildInputHistoryNavigatorIdentity(inputHistoryScope, inputHistoryIdentity), + [inputHistoryIdentity, inputHistoryScope], + ); + const messageHistory = useMessageHistory(historyValues, messageHistoryIdentity); // Keep messageRef in sync with message state React.useEffect(() => { @@ -1391,6 +1421,7 @@ const ChatInputComponent: React.FC = ({ sessionId?: string; directory?: string; draftSnapshot?: NonNullable; + historySubmissions?: InputHistorySubmission[]; delivery?: 'steer'; } | undefined; if (isBtwActive && btwSessionId && btwDirectory) { @@ -1439,6 +1470,19 @@ const ChatInputComponent: React.FC = ({ if (queuedOnly && queuedMessagesToSend.length === 0) return; } + const historySubmissions = buildChatInputHistorySubmissions({ + inputMode, + // Server-owned items were recorded on acceptance. VS Code records + // the full items actually taken, never the metadata projection. + queuedMessages: isServerOwnedMessageQueue() ? [] : queuedMessagesToSend, + composerText: inputSnapshot.message, + composerAttachments: attachedFiles, + includeComposer: !queuedOnly && inputSnapshot.hasContent, + }); + if (historySubmissions?.length) { + sendMessageOptions = { ...sendMessageOptions, historySubmissions }; + } + // Inline review comments and synthetic context are consumed before // assembly so a failed send can restore exactly what it took. What is // here belongs to this send: queueing took its own context with it. @@ -1910,9 +1954,10 @@ const ChatInputComponent: React.FC = ({ if (e.key === 'ArrowUp' && canNavigateHistoryUp) { e.preventDefault(); - const recalled = messageHistory.older(message); + const recalled = messageHistory.older({ text: message, attachments: attachedFiles }); if (recalled !== null) { - setMessage(recalled); + setMessage(recalled.text); + useInputStore.getState().setAttachedFiles([...recalled.attachments]); // Caret to the start, so the recalled message reads from its // beginning rather than from wherever the draft's caret was. requestAnimationFrame(() => composerRef.current?.setSelection(0, 0)); @@ -1922,8 +1967,12 @@ const ChatInputComponent: React.FC = ({ if (e.key === 'ArrowDown' && canNavigateHistoryDown) { e.preventDefault(); - const recalled = messageHistory.newer(); - if (recalled !== null) setMessage(recalled); + const recalled = messageHistory.newer({ text: message, attachments: attachedFiles }); + if (recalled !== null) { + setMessage(recalled.text); + useInputStore.getState().setAttachedFiles([...recalled.attachments]); + requestAnimationFrame(() => composerRef.current?.setSelection(recalled.text.length, recalled.text.length)); + } return; } diff --git a/packages/ui/src/components/chat/composer/DOCUMENTATION.md b/packages/ui/src/components/chat/composer/DOCUMENTATION.md index 26b0a3a9..34167747 100644 --- a/packages/ui/src/components/chat/composer/DOCUMENTATION.md +++ b/packages/ui/src/components/chat/composer/DOCUMENTATION.md @@ -23,7 +23,7 @@ existing mobile fixed-position rules unchanged. |---|---| | `language/` | What the text *means*: `@` references, `/` and `#` tokens, markdown, and which picker a caret asks for | | `editor/` | The CodeMirror view that renders the language and owns the caret | -| `state/` | Composer state with a lifecycle: drafts, mobile shell, history, popup placement, draft targeting | +| `state/` | Composer-local lifecycle state: ArrowUp/ArrowDown browsing, draft stash/restore, mobile shell, popup placement, draft targeting | | `submit/` | Turning what the user has into what gets sent | | `attachments/` | Files: paths, drop payloads | | `ui/` | Presentation | @@ -192,6 +192,25 @@ and the send path reading the same grammar. state and registers its application shortcuts locally. The selectors only consume their shared prefix while the draft target UI is mounted. +## Input recall ownership + +Prompt recall has two owners on purpose. + +- `packages/ui/src/stores/useInputHistoryStore.ts` owns the persisted source of + truth. It keeps the runtime-scoped global bucket and the runtime + directory + + session bucket, each capped by the configurable input-history limit. That + setting defaults to 40 entries. +- `state/useMessageHistory.ts` owns only keyboard traversal through whichever + bucket the composer was given. It stashes the current draft on entry and + restores it on the way back out. +- `ChatInput.tsx` owns the recalled-entry presentation. If the user edits a + recalled prompt, the UI may show an overlay state for "this came from + history", but that edit does not rewrite stored history. + +Transcript visibility is not part of this contract anymore. Revert markers may +hide older user messages from the chat timeline, but they do not decide what +ArrowUp and ArrowDown can recall. + ## Mobile `state/useMobileComposerShell.ts` and `state/useMobileViewportPin.ts` are @@ -209,12 +228,14 @@ hardware. The package has no DOM test environment, so coverage stops at the state and logic layers: the language, the submit assembly, path and drop handling, text -splicing, large-paste detection, paste-offer invalidation, message history, and -the CodeMirror language extension at the `EditorState` level. +splicing, large-paste detection, paste-offer invalidation, input-history +traversal, and the CodeMirror language extension at the `EditorState` level. Rendering, focus, keyboard behavior, IME and WKWebView are **not covered by -tests** and are verified by hand. Do not report a change to them as validated -on the strength of type-check and unit tests. +tests** and are verified by hand. That includes ArrowUp and ArrowDown recall, +caret placement after recall, restored drafts, and any edited-entry overlay. +Do not report a change to them as validated on the strength of type-check and +unit tests. Run tests per file (`bun test `): `mock.module` is process-global, so suites that install module mocks are order-dependent. diff --git a/packages/ui/src/components/chat/composer/state/__tests__/useMessageHistory.test.ts b/packages/ui/src/components/chat/composer/state/__tests__/useMessageHistory.test.ts index bff3e605..b1b79787 100644 --- a/packages/ui/src/components/chat/composer/state/__tests__/useMessageHistory.test.ts +++ b/packages/ui/src/components/chat/composer/state/__tests__/useMessageHistory.test.ts @@ -1,113 +1,408 @@ import { describe, expect, test } from 'bun:test'; +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; import { - HISTORY_IDLE, - INITIAL_HISTORY_STATE, + createHistoryState, + resetHistoryState, stepNewer, stepOlder, + syncHistoryState, type HistoryState, + type MessageHistory, + type MessageHistoryValue, + useMessageHistory, } from '../useMessageHistory'; -const HISTORY = ['newest', 'middle', 'oldest']; +type Attachment = { id: string }; -/** Apply a sequence of steps, returning the texts shown and the final state. */ -function walk( - steps: Array<{ dir: 'older' | 'newer'; draft?: string }>, - history: readonly string[] = HISTORY, -) { - let state: HistoryState = INITIAL_HISTORY_STATE; - const texts: Array = []; - for (const step of steps) { - const result = step.dir === 'older' - ? stepOlder(state, history, step.draft ?? '') - : stepNewer(state, history); - state = result.state; - texts.push(result.text); - } - return { texts, state }; +const draft = (text: string, attachments: readonly Attachment[] = []): MessageHistoryValue => ({ + text, + attachments, +}); + +const HISTORY = [ + draft('oldest'), + draft('middle'), + draft('newest'), +] as const; + +function createState(history: readonly MessageHistoryValue[] = HISTORY, identity = 'session-a') { + return createHistoryState(history, identity); } -describe('walking back', () => { - test('the first step recalls the most recent message', () => { - expect(walk([{ dir: 'older', draft: 'my draft' }]).texts).toEqual(['newest']); +function older( + state: HistoryState, + currentValue: MessageHistoryValue, + history: readonly MessageHistoryValue[] = HISTORY, +) { + return stepOlder(state, history, currentValue); +} + +function newer( + state: HistoryState, + currentValue: MessageHistoryValue, + history: readonly MessageHistoryValue[] = HISTORY, +) { + return stepNewer(state, history, currentValue); +} + +type MinimalDocument = { + nodeType: 9; + defaultView: typeof globalThis; + activeElement: null; + addEventListener: () => void; + removeEventListener: () => void; + documentElement?: MinimalContainer; + body?: MinimalContainer; +}; + +type MinimalContainer = { + nodeType: 1; + tagName: 'DIV'; + nodeName: 'DIV'; + namespaceURI: 'http://www.w3.org/1999/xhtml'; + ownerDocument: MinimalDocument; + addEventListener: () => void; + removeEventListener: () => void; +}; + +type MessageHistoryHookResult = { + current: MessageHistory | null; +}; + +function installMinimalDom() { + const descriptors = new Map(); + const setGlobal = (name: string, value: T) => { + descriptors.set(name, Object.getOwnPropertyDescriptor(globalThis, name)); + Object.defineProperty(globalThis, name, { configurable: true, writable: true, value }); + }; + class ElementStub {} + const documentStub: MinimalDocument = { + nodeType: 9, + defaultView: globalThis, + activeElement: null, + addEventListener: () => undefined, + removeEventListener: () => undefined, + }; + const container: MinimalContainer = { + nodeType: 1, + tagName: 'DIV', + nodeName: 'DIV', + namespaceURI: 'http://www.w3.org/1999/xhtml', + ownerDocument: documentStub, + addEventListener: () => undefined, + removeEventListener: () => undefined, + }; + documentStub.documentElement = container; + documentStub.body = container; + setGlobal('document', documentStub); + setGlobal('window', globalThis); + setGlobal('location', { search: '', protocol: 'http:', hostname: 'localhost' }); + setGlobal('Element', ElementStub); + setGlobal('HTMLElement', ElementStub); + setGlobal('HTMLIFrameElement', ElementStub); + setGlobal('IS_REACT_ACT_ENVIRONMENT', true); + return { + // SAFETY: the hook probe renders `null`; React only needs a stable root-like container shape here. + container: container as Element & MinimalContainer, + restore: () => { + for (const [name, descriptor] of descriptors) { + if (descriptor) Object.defineProperty(globalThis, name, descriptor); + else Reflect.deleteProperty(globalThis, name); + } + }, + }; +} + +function renderMessageHistory( + history: readonly MessageHistoryValue[], + identity: string, +) { + const dom = installMinimalDom(); + const root: Root = createRoot(dom.container); + const result: MessageHistoryHookResult = { current: null }; + + const Probe: React.FC<{ history: readonly MessageHistoryValue[]; identity: string }> = ({ history, identity }) => { + result.current = useMessageHistory(history, identity); + return null; + }; + + const render = (nextHistory: readonly MessageHistoryValue[], nextIdentity: string) => { + act(() => { + root.render(React.createElement(Probe, { history: nextHistory, identity: nextIdentity })); + }); + }; + + render(history, identity); + + return { + current() { + if (!result.current) throw new Error('message history hook did not render'); + return result.current; + }, + rerender(nextHistory: readonly MessageHistoryValue[], nextIdentity: string) { + render(nextHistory, nextIdentity); + }, + teardown() { + act(() => { + root.unmount(); + }); + dom.restore(); + }, + }; +} + +describe('message history cursor model', () => { + test('uses a 0..history.length cursor with the draft at the endpoint', () => { + const state = createState(); + expect(state.cursor).toBe(HISTORY.length); + + const newest = older(state, draft('half-written prompt')); + expect(newest.value).toEqual(draft('newest')); + expect(newest.state.cursor).toBe(HISTORY.length - 1); + + const middle = older(newest.state, draft('newest')); + expect(middle.value).toEqual(draft('middle')); + expect(middle.state.cursor).toBe(HISTORY.length - 2); + + const oldest = older(middle.state, draft('middle')); + expect(oldest.value).toEqual(draft('oldest')); + expect(oldest.state.cursor).toBe(0); }); - test('successive steps go further back', () => { - expect(walk([{ dir: 'older' }, { dir: 'older' }, { dir: 'older' }]).texts) - .toEqual(['newest', 'middle', 'oldest']); + test('clamps at both ends', () => { + const state = createState(); + expect(newer(state, draft('draft')).value).toBeNull(); + + const first = older(state, draft('draft')); + const second = older(first.state, draft('newest')); + const third = older(second.state, draft('middle')); + const clamped = older(third.state, draft('oldest')); + + expect(clamped.value).toBeNull(); + expect(clamped.state.cursor).toBe(0); }); - test('the oldest message is the end of the line', () => { - const { texts } = walk([ - { dir: 'older' }, { dir: 'older' }, { dir: 'older' }, { dir: 'older' }, - ]); - expect(texts[3]).toBeNull(); - }); + test('restores an empty draft when the user returns to the endpoint', () => { + const state = createState(); + const recalled = older(state, draft('')); + const restored = newer(recalled.state, draft('newest')); - test('reaching the end leaves the state where it was', () => { - const { state } = walk([ - { dir: 'older' }, { dir: 'older' }, { dir: 'older' }, { dir: 'older' }, - ]); - expect(state.index).toBe(2); - }); - - test('empty history recalls nothing', () => { - const { texts, state } = walk([{ dir: 'older', draft: 'x' }], []); - expect(texts).toEqual([null]); - expect(state.index).toBe(HISTORY_IDLE); - }); - - test('a single-message history has exactly one step', () => { - const { texts } = walk([{ dir: 'older' }, { dir: 'older' }], ['only']); - expect(texts).toEqual(['only', null]); + expect(restored.value).toEqual(draft('')); + expect(restored.state.cursor).toBe(HISTORY.length); }); }); -describe('coming back', () => { - test('returns toward newer messages', () => { - const { texts } = walk([{ dir: 'older' }, { dir: 'older' }, { dir: 'newer' }]); - expect(texts[2]).toBe('newest'); +describe('message history overlays', () => { + test('restores edited text for recalled entries and the original draft', () => { + const state = createState(); + + const recalledNewest = older(state, draft('original draft')); + const recalledMiddle = older(recalledNewest.state, draft('edited newest')); + const backToNewest = newer(recalledMiddle.state, draft('middle')); + const backToDraft = newer(backToNewest.state, draft('edited newest')); + + expect(backToNewest.value).toEqual(draft('edited newest')); + expect(backToDraft.value).toEqual(draft('original draft')); }); - test('stepping past the newest restores the stashed draft', () => { - const { texts, state } = walk([ - { dir: 'older', draft: 'half-written prompt' }, - { dir: 'newer' }, - ]); - expect(texts[1]).toBe('half-written prompt'); - expect(state.index).toBe(HISTORY_IDLE); + test('restores edited attachments for recalled entries and the draft endpoint', () => { + const draftAttachment = { id: 'draft-file' }; + const recalledAttachment = { id: 'edited-file' }; + + const state = createState(); + const recalledNewest = older(state, draft('draft text', [draftAttachment])); + const recalledMiddle = older(recalledNewest.state, draft('newest', [recalledAttachment])); + const backToNewest = newer(recalledMiddle.state, draft('middle')); + const backToDraft = newer(backToNewest.state, draft('newest', [recalledAttachment])); + + expect(backToNewest.value).toEqual(draft('newest', [recalledAttachment])); + expect(backToDraft.value).toEqual(draft('draft text', [draftAttachment])); }); - test('an empty draft is restored as empty rather than left on a message', () => { - const { texts } = walk([{ dir: 'older', draft: '' }, { dir: 'newer' }]); - expect(texts[1]).toBe(''); - }); + test('reset clears overlays and returns to the draft endpoint', () => { + const state = createState(); + const recalled = older(state, draft('stashed draft')); + const editedState = older(recalled.state, draft('edited newest')).state; - test('coming back when not browsing does nothing', () => { - expect(walk([{ dir: 'newer' }]).texts).toEqual([null]); - }); + const reset = resetHistoryState(editedState, HISTORY); + expect(reset.cursor).toBe(HISTORY.length); - test('the draft is stashed on entry, not overwritten by recalled text', () => { - // The second `older` passes recalled text as the current text; it must - // not replace what the user actually typed. - const { texts } = walk([ - { dir: 'older', draft: 'original draft' }, - { dir: 'older', draft: 'newest' }, - { dir: 'newer' }, - { dir: 'newer' }, - ]); - expect(texts[3]).toBe('original draft'); - }); + const backIntoHistory = older(reset, draft('fresh draft')); + expect(backIntoHistory.value).toEqual(draft('newest')); - test('the stash is cleared once restored', () => { - const { state } = walk([{ dir: 'older', draft: 'draft' }, { dir: 'newer' }]); - expect(state.stashedDraft).toBe(''); + const backToDraft = newer(backIntoHistory.state, draft('newest')); + expect(backToDraft.value).toEqual(draft('fresh draft')); }); }); -describe('a shrinking history', () => { - test('an index past the end of a shorter history cannot step further back', () => { - const state: HistoryState = { index: 5, stashedDraft: 'draft' }; - expect(stepOlder(state, HISTORY, 'x').text).toBeNull(); +describe('message history synchronization', () => { + test('resets when the active identity changes', () => { + const state = createState(); + const recalled = older(state, draft('draft')).state; + + const reset = syncHistoryState(recalled, HISTORY, 'session-b'); + expect(reset.cursor).toBe(HISTORY.length); + + const backIntoHistory = older(reset, draft('new identity draft')); + expect(backIntoHistory.value).toEqual(draft('newest')); + + const restored = newer(backIntoHistory.state, draft('newest')); + expect(restored.value).toEqual(draft('new identity draft')); + }); + + test('reset after send drops stale overlays before the sent message is appended', () => { + const state = createState(); + const recalled = older(state, draft('sent draft')).state; + const reset = resetHistoryState(recalled, HISTORY); + const appendedHistory = [...HISTORY, draft('sent draft')]; + const synced = syncHistoryState(reset, appendedHistory, 'session-a'); + + const recalledSent = older(synced, draft(''), appendedHistory); + expect(recalledSent.value).toEqual(draft('sent draft')); + + const restored = newer(recalledSent.state, draft('sent draft'), appendedHistory); + expect(restored.value).toEqual(draft('')); + }); + + test('tracks an external append while the user stays at the endpoint', () => { + const state = createState(); + const appendedHistory = [...HISTORY, draft('latest from elsewhere')]; + const synced = syncHistoryState(state, appendedHistory, 'session-a'); + + const recalled = older(synced, draft('draft at endpoint'), appendedHistory); + expect(recalled.value).toEqual(draft('latest from elsewhere')); + }); + + test('keeps the same logical entry selected while browsing during an external append', () => { + const state = createState(); + const browsingNewest = older(state, draft('draft')).state; + const appendedHistory = [...HISTORY, draft('newest appended')]; + const synced = syncHistoryState(browsingNewest, appendedHistory, 'session-a'); + + const newerEntry = newer(synced, draft('newest'), appendedHistory); + expect(newerEntry.value).toEqual(draft('newest appended')); + + const restored = newer(newerEntry.state, draft('newest appended'), appendedHistory); + expect(restored.value).toEqual(draft('draft')); + }); + + test('preserves the current logical entry when a 40-entry bucket trims its oldest value', () => { + const history = Array.from({ length: 40 }, (_, index) => draft(`message-${index}`)); + const state = createState(history); + const browsingMessage39 = older(state, draft('draft'), history).state; + const browsingMessage38 = older(browsingMessage39, draft('message-39'), history).state; + const trimmedAndAppended = [...history.slice(1), draft('message-40')]; + const synced = syncHistoryState(browsingMessage38, trimmedAndAppended, 'session-a'); + + expect(synced.cursor).toBe(37); + + const newerEntry = newer(synced, draft('message-38'), trimmedAndAppended); + expect(newerEntry.value).toEqual(draft('message-39')); + }); +}); + +describe('useMessageHistory', () => { + test('round-trips edited recalled entries and the live draft through the generic API', () => { + const rendered = renderMessageHistory(HISTORY, 'session-a'); + + try { + const draftAttachment = { id: 'draft-file' }; + const recalledAttachment = { id: 'edited-file' }; + + let recalledNewest: MessageHistoryValue | null = null; + let recalledMiddle: MessageHistoryValue | null = null; + let restoredNewest: MessageHistoryValue | null = null; + let restoredDraft: MessageHistoryValue | null = null; + + act(() => { + recalledNewest = rendered.current().older(draft('draft text', [draftAttachment])); + }); + act(() => { + recalledMiddle = rendered.current().older(draft('newest', [recalledAttachment])); + }); + act(() => { + restoredNewest = rendered.current().newer(draft('middle')); + }); + act(() => { + restoredDraft = rendered.current().newer(draft('newest', [recalledAttachment])); + }); + + expect(recalledNewest).toEqual(draft('newest')); + expect(recalledMiddle).toEqual(draft('middle')); + expect(restoredNewest).toEqual(draft('newest', [recalledAttachment])); + expect(restoredDraft).toEqual(draft('draft text', [draftAttachment])); + expect(rendered.current().isBrowsing).toBe(false); + } finally { + rendered.teardown(); + } + }); + + test('reset exits browsing and drops stale overlays', () => { + const rendered = renderMessageHistory(HISTORY, 'session-a'); + + try { + act(() => { + rendered.current().older(draft('fresh draft')); + }); + act(() => { + rendered.current().older(draft('edited newest')); + }); + + expect(rendered.current().isBrowsing).toBe(true); + + act(() => { + rendered.current().reset(); + }); + + expect(rendered.current().isBrowsing).toBe(false); + + let recalled: MessageHistoryValue | null = null; + let restoredDraft: MessageHistoryValue | null = null; + + act(() => { + recalled = rendered.current().older(draft('fresh draft')); + }); + act(() => { + restoredDraft = rendered.current().newer(draft('newest')); + }); + + expect(recalled).toEqual(draft('newest')); + expect(restoredDraft).toEqual(draft('fresh draft')); + } finally { + rendered.teardown(); + } + }); + + test('resets browsing when the identity changes', () => { + const rendered = renderMessageHistory(HISTORY, 'session-a'); + + try { + act(() => { + rendered.current().older(draft('new identity draft')); + }); + + expect(rendered.current().isBrowsing).toBe(true); + + rendered.rerender(HISTORY, 'session-b'); + + expect(rendered.current().isBrowsing).toBe(false); + + let recalled: MessageHistoryValue | null = null; + let restoredDraft: MessageHistoryValue | null = null; + + act(() => { + recalled = rendered.current().older(draft('new identity draft')); + }); + act(() => { + restoredDraft = rendered.current().newer(draft('newest')); + }); + + expect(recalled).toEqual(draft('newest')); + expect(restoredDraft).toEqual(draft('new identity draft')); + } finally { + rendered.teardown(); + } }); }); diff --git a/packages/ui/src/components/chat/composer/state/useMessageHistory.ts b/packages/ui/src/components/chat/composer/state/useMessageHistory.ts index 05112439..d5ba907b 100644 --- a/packages/ui/src/components/chat/composer/state/useMessageHistory.ts +++ b/packages/ui/src/components/chat/composer/state/useMessageHistory.ts @@ -1,99 +1,234 @@ /** * Walking back through previously sent messages with the arrow keys. * - * Entering history stashes whatever was typed so leaving it returns the user's - * own text rather than the last recalled message — the composer is not a - * terminal, and losing a half-written prompt to an arrow key is worse than not - * having history at all. - * - * Index 0 is the most recent message and higher indices are older, matching - * how the keys read: up goes further back. + * History arrives oldest to newest. The cursor spans `0..history.length`, with + * `history.length` reserved for the live draft endpoint. Moving away from a + * cursor stores the current value as an overlay for that cursor so edits survive + * round-trips through history. */ import React from 'react'; -/** Not browsing history. */ -export const HISTORY_IDLE = -1; +export type MessageHistoryValue = { + text: string; + attachments: readonly TAttachment[]; +}; -export interface HistoryState { - /** Index into the history, or HISTORY_IDLE when showing the user's draft. */ - index: number; - /** The draft stashed on entry, restored on the way back out. */ - stashedDraft: string; +export interface HistoryState { + cursor: number; + identity: string; + history: readonly MessageHistoryValue[]; + overlays: ReadonlyMap>; } -export const INITIAL_HISTORY_STATE: HistoryState = { index: HISTORY_IDLE, stashedDraft: '' }; - -/** - * The outcome of an arrow key: the next state, and the text the composer - * should show. A null text means the key does nothing and the composer keeps - * what it has. - */ -export interface HistoryStep { - state: HistoryState; - text: string | null; +export interface HistoryStep { + state: HistoryState; + value: MessageHistoryValue | null; } -const unchanged = (state: HistoryState): HistoryStep => ({ state, text: null }); - -/** Step further back in history. `currentText` is stashed on entry. */ -export function stepOlder( - state: HistoryState, - history: readonly string[], - currentText: string, -): HistoryStep { - if (history.length === 0) return unchanged(state); - - if (state.index === HISTORY_IDLE) { - return { state: { index: 0, stashedDraft: currentText }, text: history[0] }; - } - if (state.index >= history.length - 1) return unchanged(state); - - const index = state.index + 1; - return { state: { ...state, index }, text: history[index] }; -} - -/** Step back toward the draft, restoring it once past the newest message. */ -export function stepNewer(state: HistoryState, history: readonly string[]): HistoryStep { - if (state.index === HISTORY_IDLE) return unchanged(state); - - if (state.index === 0) { - return { state: INITIAL_HISTORY_STATE, text: state.stashedDraft }; - } - - const index = state.index - 1; - return { state: { ...state, index }, text: history[index] }; -} - -export interface MessageHistory { - /** True while showing a recalled message rather than the user's draft. */ +export interface MessageHistory { isBrowsing: boolean; - /** Recall an older message; returns null when already at the oldest. */ - older: (currentText: string) => string | null; - /** Return toward the draft; returns null when not browsing. */ - newer: () => string | null; - /** Leave history, discarding the stashed draft. Called after a send. */ + older: (currentValue: MessageHistoryValue) => MessageHistoryValue | null; + newer: (currentValue: MessageHistoryValue) => MessageHistoryValue | null; reset: () => void; } -export function useMessageHistory(history: readonly string[]): MessageHistory { - const [state, setState] = React.useState(INITIAL_HISTORY_STATE); - - const older = React.useCallback((currentText: string) => { - const step = stepOlder(state, history, currentText); - if (step.text === null) return null; - setState(step.state); - return step.text; - }, [history, state]); - - const newer = React.useCallback(() => { - const step = stepNewer(state, history); - if (step.text === null) return null; - setState(step.state); - return step.text; - }, [history, state]); - - const reset = React.useCallback(() => setState(INITIAL_HISTORY_STATE), []); - - return { isBrowsing: state.index !== HISTORY_IDLE, older, newer, reset }; +function createEmptyValue(): MessageHistoryValue { + return { text: '', attachments: [] }; +} + +function valuesEqual(a: MessageHistoryValue, b: MessageHistoryValue): boolean { + if (a.text !== b.text) return false; + if (a.attachments.length !== b.attachments.length) return false; + for (let index = 0; index < a.attachments.length; index += 1) { + if (!Object.is(a.attachments[index], b.attachments[index])) return false; + } + return true; +} + +function sliceEqual( + left: readonly MessageHistoryValue[], + leftStart: number, + right: readonly MessageHistoryValue[], + rightStart: number, + length: number, +): boolean { + for (let index = 0; index < length; index += 1) { + if (!valuesEqual(left[leftStart + index]!, right[rightStart + index]!)) return false; + } + return true; +} + +function countTrimmedEntries( + previousHistory: readonly MessageHistoryValue[], + nextHistory: readonly MessageHistoryValue[], +): number { + const maxOverlap = Math.min(previousHistory.length, nextHistory.length); + for (let overlap = maxOverlap; overlap >= 0; overlap -= 1) { + if (sliceEqual(previousHistory, previousHistory.length - overlap, nextHistory, 0, overlap)) { + return previousHistory.length - overlap; + } + } + return previousHistory.length; +} + +function readCursorValue( + cursor: number, + history: readonly MessageHistoryValue[], + overlays: ReadonlyMap>, +): MessageHistoryValue { + const overlay = overlays.get(cursor); + if (overlay) return overlay; + if (cursor === history.length) return createEmptyValue(); + return history[cursor] ?? createEmptyValue(); +} + +function withOverlay( + overlays: ReadonlyMap>, + cursor: number, + value: MessageHistoryValue, +): ReadonlyMap> { + const nextOverlays = new Map(overlays); + nextOverlays.set(cursor, value); + return nextOverlays; +} + +function remapCursor(oldCursor: number, oldLength: number, newLength: number, trimmed: number): number { + if (oldCursor === oldLength) return newLength; + if (oldCursor < trimmed) return Math.min(newLength, 0); + return Math.min(newLength, oldCursor - trimmed); +} + +function remapOverlays( + overlays: ReadonlyMap>, + oldLength: number, + newLength: number, + trimmed: number, +): ReadonlyMap> { + const nextOverlays = new Map>(); + for (const [cursor, value] of overlays) { + if (cursor === oldLength) { + nextOverlays.set(newLength, value); + continue; + } + if (cursor < trimmed) continue; + nextOverlays.set(Math.min(newLength, cursor - trimmed), value); + } + return nextOverlays; +} + +export function createHistoryState( + history: readonly MessageHistoryValue[], + identity: string, +): HistoryState { + return { + cursor: history.length, + identity, + history, + overlays: new Map(), + }; +} + +export function resetHistoryState( + state: HistoryState, + history: readonly MessageHistoryValue[] = state.history, +): HistoryState { + return { + cursor: history.length, + identity: state.identity, + history, + overlays: new Map(), + }; +} + +export function stepOlder( + state: HistoryState, + history: readonly MessageHistoryValue[], + currentValue: MessageHistoryValue, +): HistoryStep { + if (history.length === 0 || state.cursor === 0) { + return { state: { ...state, history }, value: null }; + } + + const overlays = withOverlay(state.overlays, state.cursor, currentValue); + const cursor = state.cursor - 1; + return { + state: { ...state, cursor, history, overlays }, + value: readCursorValue(cursor, history, overlays), + }; +} + +export function stepNewer( + state: HistoryState, + history: readonly MessageHistoryValue[], + currentValue: MessageHistoryValue, +): HistoryStep { + if (state.cursor === history.length) { + return { state: { ...state, history }, value: null }; + } + + const overlays = withOverlay(state.overlays, state.cursor, currentValue); + const cursor = state.cursor + 1; + return { + state: { ...state, cursor, history, overlays }, + value: readCursorValue(cursor, history, overlays), + }; +} + +export function syncHistoryState( + state: HistoryState, + history: readonly MessageHistoryValue[], + identity: string, +): HistoryState { + if (state.identity !== identity) { + return createHistoryState(history, identity); + } + + if (state.history === history) { + return state; + } + + const trimmed = countTrimmedEntries(state.history, history); + return { + cursor: remapCursor(state.cursor, state.history.length, history.length, trimmed), + identity, + history, + overlays: remapOverlays(state.overlays, state.history.length, history.length, trimmed), + }; +} + +export function useMessageHistory( + history: readonly MessageHistoryValue[], + identity: string, +): MessageHistory { + const [state, setState] = React.useState(() => createHistoryState(history, identity)); + + React.useEffect(() => { + setState((currentState) => syncHistoryState(currentState, history, identity)); + }, [history, identity]); + + const older = React.useCallback((currentValue: MessageHistoryValue) => { + const step = stepOlder(state, history, currentValue); + if (step.value === null) return null; + setState(step.state); + return step.value; + }, [history, state]); + + const newer = React.useCallback((currentValue: MessageHistoryValue) => { + const step = stepNewer(state, history, currentValue); + if (step.value === null) return null; + setState(step.state); + return step.value; + }, [history, state]); + + const reset = React.useCallback(() => { + setState((currentState) => resetHistoryState(currentState, history)); + }, [history]); + + return { + isBrowsing: state.cursor !== history.length, + older, + newer, + reset, + }; } diff --git a/packages/ui/src/components/chat/inputHistory.test.ts b/packages/ui/src/components/chat/inputHistory.test.ts new file mode 100644 index 00000000..47a39f87 --- /dev/null +++ b/packages/ui/src/components/chat/inputHistory.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, test } from 'bun:test'; + +import type { AttachedFile } from '@/stores/types/sessionTypes'; +import { createInputHistorySubmission, type InputHistoryAttachment, type InputHistoryEntry } from '@/stores/useInputHistoryStore'; + +import { + buildChatInputHistorySubmissions, + buildInputHistoryNavigatorIdentity, + mapInputHistoryEntriesToValues, +} from './inputHistory'; + +const ATTACHMENT: AttachedFile = { + id: 'file-1', + file: new File(['hello'], 'notes.txt', { type: 'text/plain' }), + dataUrl: 'file:///repo/notes.txt', + mimeType: 'text/plain', + filename: 'notes.txt', + size: 5, + source: 'local', + serverPath: '/repo/notes.txt', +}; + +describe('buildChatInputHistorySubmissions', () => { + test('keeps raw queued submissions first and raw composer submission last', () => { + const submissions = buildChatInputHistorySubmissions({ + inputMode: 'normal', + queuedMessages: [ + { content: '/queued one', attachments: [ATTACHMENT] }, + { content: '/queued two', attachments: [] }, + ], + composerText: '/composer raw', + composerAttachments: [ATTACHMENT], + includeComposer: true, + }); + + expect(submissions?.map((submission) => submission.text)).toEqual([ + '/queued one', + '/queued two', + '/composer raw', + ]); + expect(submissions?.[0]).toEqual(createInputHistorySubmission('/queued one', [ATTACHMENT])); + expect(submissions?.[2]).toEqual(createInputHistorySubmission('/composer raw', [ATTACHMENT])); + }); + + test('omits history submissions for shell mode', () => { + expect(buildChatInputHistorySubmissions({ + inputMode: 'shell', + queuedMessages: [{ content: 'echo hello', attachments: [ATTACHMENT] }], + composerText: 'pwd', + composerAttachments: [ATTACHMENT], + includeComposer: true, + })).toBe(undefined); + }); +}); + +describe('mapInputHistoryEntriesToValues', () => { + test('keeps chronological order and materializes supported attachments', () => { + const entries: InputHistoryEntry[] = [ + { + text: 'oldest', + attachmentKeys: ['a'], + restorableAttachments: [{ + key: 'server-file', + source: 'file-url', + filename: 'server.txt', + mimeType: 'text/plain', + size: 11, + reference: '/repo/server.txt', + }], + submittedAt: 1, + }, + { + text: 'newest', + attachmentKeys: ['b'], + restorableAttachments: [{ + key: 'vscode-file', + source: 'vscode-file', + filename: 'editor.ts', + mimeType: 'text/plain', + size: 22, + reference: '/repo/editor.ts', + }], + submittedAt: 2, + }, + ]; + + const values = mapInputHistoryEntriesToValues(entries); + + expect(values.map((value) => value.text)).toEqual(['oldest', 'newest']); + expect(values[0]?.attachments[0]?.filename).toBe('server.txt'); + expect(values[0]?.attachments[0]?.dataUrl).toBe('/repo/server.txt'); + expect(values[0]?.attachments[0]?.source).toBe('local'); + expect(values[1]?.attachments[0]?.filename).toBe('editor.ts'); + expect(values[1]?.attachments[0]?.vscodePath).toBe('/repo/editor.ts'); + expect(values[1]?.attachments[0]?.vscodeSource).toBe('file'); + expect(values[1]?.attachments[0]?.source).toBe('vscode'); + }); + + test('drops unsupported attachment descriptors', () => { + const unsupported: InputHistoryAttachment = { + key: 'bad', + source: 'file-url', + filename: 'bad.txt', + mimeType: 'text/plain', + size: 1, + reference: 'data:text/plain;base64,Zm9v', + }; + const entries: InputHistoryEntry[] = [{ + text: 'value', + attachmentKeys: ['bad'], + restorableAttachments: [unsupported], + submittedAt: 1, + }]; + + expect(mapInputHistoryEntriesToValues(entries)[0]?.attachments).toEqual([]); + }); +}); + +describe('buildInputHistoryNavigatorIdentity', () => { + test('includes scope and full identity so bucket changes reset navigation', () => { + expect(buildInputHistoryNavigatorIdentity('global', { + runtimeKey: 'runtime-a', + directory: '/repo', + sessionId: 'session-1', + })).toBe('global\nruntime-a\n/repo\nsession-1'); + + expect(buildInputHistoryNavigatorIdentity('session', { + runtimeKey: 'runtime-a', + directory: '/repo', + sessionId: 'session-1', + })).toBe('session\nruntime-a\n/repo\nsession-1'); + }); +}); diff --git a/packages/ui/src/components/chat/inputHistory.ts b/packages/ui/src/components/chat/inputHistory.ts new file mode 100644 index 00000000..f94ca891 --- /dev/null +++ b/packages/ui/src/components/chat/inputHistory.ts @@ -0,0 +1,118 @@ +import type { MessageHistoryValue } from './composer/state/useMessageHistory'; +import type { InputHistoryScope } from '@/lib/inputHistoryScope'; +import { + createInputHistorySubmission, + type InputHistoryAttachment, + type InputHistoryEntry, + type InputHistoryIdentity, + type InputHistorySubmission, +} from '@/stores/useInputHistoryStore'; +import type { AttachedFile } from '@/stores/types/sessionTypes'; + +type HistoryQueuedMessage = { + content: string; + attachments?: readonly AttachedFile[]; +}; + +type BuildHistorySubmissionsArgs = { + inputMode: 'normal' | 'shell'; + queuedMessages: readonly HistoryQueuedMessage[]; + composerText: string; + composerAttachments: readonly AttachedFile[]; + includeComposer: boolean; +}; + +const FILE_URI_PREFIX = 'file://'; + +const encodeFilePath = (filepath: string): string => { + let normalized = filepath.replace(/\\/g, '/'); + if (/^[A-Za-z]:/.test(normalized)) { + normalized = `/${normalized}`; + } + return normalized + .split('/') + .map((segment, index) => { + if (index === 1 && /^[A-Za-z]:$/.test(segment)) return segment; + return encodeURIComponent(segment); + }) + .join('/'); +}; + +const toFileUrl = (filepath: string): string => { + const normalized = filepath.replace(/\\/g, '/').trim(); + if (normalized.toLowerCase().startsWith(FILE_URI_PREFIX)) { + return normalized; + } + return `${FILE_URI_PREFIX}${encodeFilePath(normalized)}`; +}; + +export function buildChatInputHistorySubmissions({ + inputMode, + queuedMessages, + composerText, + composerAttachments, + includeComposer, +}: BuildHistorySubmissionsArgs): InputHistorySubmission[] | undefined { + if (inputMode === 'shell') return undefined; + + const submissions = queuedMessages.map((queued) => ( + createInputHistorySubmission(queued.content, queued.attachments ?? []) + )); + + if (includeComposer) { + submissions.push(createInputHistorySubmission(composerText, composerAttachments)); + } + + return submissions.length > 0 ? submissions : undefined; +} + +function materializeHistoryAttachment(attachment: InputHistoryAttachment): AttachedFile | null { + if (attachment.source === 'file-url') { + if (!attachment.reference || attachment.reference.startsWith('data:')) return null; + return { + id: `history-${attachment.key}`, + file: new File([], attachment.filename, { type: attachment.mimeType }), + dataUrl: attachment.reference, + mimeType: attachment.mimeType, + filename: attachment.filename, + size: attachment.size, + source: 'local', + serverPath: attachment.reference, + }; + } + + if (attachment.source === 'vscode-file') { + return { + id: `history-${attachment.key}`, + file: new File([], attachment.filename, { type: attachment.mimeType }), + dataUrl: toFileUrl(attachment.reference), + mimeType: attachment.mimeType, + filename: attachment.filename, + size: attachment.size, + source: 'vscode', + vscodePath: attachment.reference, + vscodeSource: 'file', + }; + } + + return null; +} + +export function mapInputHistoryEntriesToValues( + entries: readonly InputHistoryEntry[], +): Array> { + return entries.map((entry) => ({ + text: entry.text, + attachments: entry.restorableAttachments + .map(materializeHistoryAttachment) + .filter((attachment): attachment is AttachedFile => attachment !== null), + })); +} + +export function buildInputHistoryNavigatorIdentity( + scope: InputHistoryScope, + identity: InputHistoryIdentity | null, +): string { + if (!identity) return `${scope}\nmissing`; + return [scope, identity.runtimeKey, identity.directory, identity.sessionId].join('\n'); +} diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx index 29f2e7b1..57ecdcb8 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx @@ -204,6 +204,8 @@ const ChatSectionContent: React.FC = () => { 'splitAssistantMessageActions', 'subagentReadOnlyBanner', 'diffLayout', + 'inputHistoryScope', + 'inputHistoryLimit', 'dotfiles', 'fileViewerPreview', 'followUpBehavior', diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx index 2d383ea9..7d8b9983 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx @@ -61,9 +61,18 @@ import { import { SettingsInfoHint } from '@/components/sections/shared/SettingsInfoHint'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import type { TerminalShellOption } from '@/lib/api/types'; +import { + MAX_INPUT_HISTORY_LIMIT, + MIN_INPUT_HISTORY_LIMIT, + isInputHistoryLimit, + type InputHistoryScope, +} from '@/lib/inputHistoryScope'; import { isTerminalShell } from '@/lib/terminalShell'; import { subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch'; import { formatShortcutForDisplay } from '@/lib/shortcuts'; +import { + useInputHistoryStore, +} from '@/stores/useInputHistoryStore'; interface Option { id: T; @@ -279,11 +288,22 @@ const LARGE_TEXT_PASTE_BEHAVIOR_OPTIONS: Option[] = [ }, ]; +const INPUT_HISTORY_SCOPE_OPTIONS: Option[] = [ + { + id: 'global', + labelKey: 'settings.openchamber.visual.option.inputHistoryScope.global.label', + }, + { + id: 'session', + labelKey: 'settings.openchamber.visual.option.inputHistoryScope.session.label', + }, +]; + const normalizeUserMessageRenderingMode = (mode: unknown): 'markdown' | 'plain' => { return mode === 'markdown' ? 'markdown' : 'plain'; }; -type VisibleSetting = 'sessionAssist' | 'sessionGoal' | 'theme' | 'windowControlsPosition' | 'pwaInstallName' | 'pwaOrientation' | 'mobileKeyboardMode' | 'timeFormat' | 'weekStart' | 'fontSize' | 'terminalFontSize' | 'terminalShell' | 'terminalLoginShell' | 'editorFontSize' | 'spacing' | 'inputBarOffset' | 'mermaidRendering' | 'userMessageRendering' | 'chatRenderMode' | 'messageTransport' | 'activityRenderMode' | 'collapsibleUserMessages' | 'stickyUserHeader' | 'promptNavigatorEnabled' | 'wideChatLayout' | 'codeBlockLineWrap' | 'splitAssistantMessageActions' | 'subagentReadOnlyBanner' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'fileViewerPreview' | 'reasoning' | 'showToolFileIcons' | 'showTurnChangedFiles' | 'expandedTools' | 'followUpBehavior' | 'terminalQuickKeys' | 'fileEditorKeymap' | 'persistDraft' | 'inputSpellcheck' | 'largeTextPaste' | 'enterToSend' | 'reportUsage' | 'autoSaveEnabled' | 'sessionTabs'; +type VisibleSetting = 'sessionAssist' | 'sessionGoal' | 'theme' | 'windowControlsPosition' | 'pwaInstallName' | 'pwaOrientation' | 'mobileKeyboardMode' | 'timeFormat' | 'weekStart' | 'fontSize' | 'terminalFontSize' | 'terminalShell' | 'terminalLoginShell' | 'editorFontSize' | 'spacing' | 'inputBarOffset' | 'mermaidRendering' | 'userMessageRendering' | 'chatRenderMode' | 'messageTransport' | 'activityRenderMode' | 'collapsibleUserMessages' | 'stickyUserHeader' | 'promptNavigatorEnabled' | 'wideChatLayout' | 'codeBlockLineWrap' | 'splitAssistantMessageActions' | 'subagentReadOnlyBanner' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'fileViewerPreview' | 'reasoning' | 'showToolFileIcons' | 'showTurnChangedFiles' | 'expandedTools' | 'followUpBehavior' | 'inputHistoryScope' | 'inputHistoryLimit' | 'terminalQuickKeys' | 'fileEditorKeymap' | 'persistDraft' | 'inputSpellcheck' | 'largeTextPaste' | 'enterToSend' | 'reportUsage' | 'autoSaveEnabled' | 'sessionTabs'; const WINDOW_CONTROLS_POSITION_OPTIONS: Array<{ id: DesktopWindowControlsPosition; labelKey: string }> = [ { id: 'left', labelKey: 'settings.openchamber.desktopNetwork.option.windowControlsLeft' }, @@ -374,6 +394,10 @@ export const OpenChamberVisualSettings: React.FC const setFileEditorKeymap = useUIStore(state => state.setFileEditorKeymap); const followUpBehavior = useMessageQueueStore(state => state.followUpBehavior); const setFollowUpBehavior = useMessageQueueStore(state => state.setFollowUpBehavior); + const inputHistoryScope = useInputHistoryStore(state => state.scope); + const inputHistoryLimit = useInputHistoryStore(state => state.entryLimit); + const applyInputHistoryScope = useInputHistoryStore(state => state.applyScope); + const applyInputHistoryLimit = useInputHistoryStore(state => state.applyEntryLimit); const persistChatDraft = useUIStore(state => state.persistChatDraft); const setPersistChatDraft = useUIStore(state => state.setPersistChatDraft); const inputSpellcheckEnabled = useUIStore(state => state.inputSpellcheckEnabled); @@ -560,6 +584,20 @@ export const OpenChamberVisualSettings: React.FC void updateDesktopSettings({ messageStreamTransport: mode }); }, [setMessageStreamTransport]); + const handleInputHistoryScopeChange = React.useCallback((scope: InputHistoryScope) => { + applyInputHistoryScope(scope); + void updateDesktopSettings({ inputHistoryScope: scope }); + }, [applyInputHistoryScope]); + + const handleInputHistoryLimitChange = React.useCallback((value: number) => { + const nextLimit = Math.round(value); + if (!isInputHistoryLimit(nextLimit)) { + return; + } + applyInputHistoryLimit(nextLimit); + void updateDesktopSettings({ inputHistoryLimit: nextLimit }); + }, [applyInputHistoryLimit]); + const handleActivityRenderModeChange = React.useCallback((mode: 'collapsed' | 'summary') => { setActivityRenderMode(mode); void updateDesktopSettings({ activityRenderMode: mode }); @@ -667,6 +705,8 @@ export const OpenChamberVisualSettings: React.FC || shouldShow('fileViewerPreview') || shouldShow('reasoning') || shouldShow('followUpBehavior') + || shouldShow('inputHistoryScope') + || shouldShow('inputHistoryLimit') || shouldShow('persistDraft') || shouldShow('largeTextPaste') || shouldShow('showToolFileIcons') @@ -679,7 +719,9 @@ export const OpenChamberVisualSettings: React.FC const showBehaviorMessageOptions = shouldShow('userMessageRendering') || shouldShow('mermaidRendering') || (shouldShow('diffLayout') && !isVSCode) - || shouldShow('followUpBehavior'); + || shouldShow('followUpBehavior') + || shouldShow('inputHistoryScope') + || shouldShow('inputHistoryLimit'); const showBehaviorFeatureCheckboxes = shouldShow('sessionAssist') || (shouldShow('sessionGoal') && !isVSCode) || shouldShow('subagentReadOnlyBanner') @@ -1755,6 +1797,51 @@ export const OpenChamberVisualSettings: React.FC )} + + {shouldShow('inputHistoryScope') && ( + + + {INPUT_HISTORY_SCOPE_OPTIONS.map((option) => ( + handleInputHistoryScopeChange(option.id)} + label={tUnsafe(option.labelKey)} + ariaLabel={tUnsafe(option.labelKey)} + /> + ))} + + + )} + + {shouldShow('inputHistoryLimit') && ( + +
+ + + {t('settings.openchamber.visual.field.inputHistoryLimitUnit')} + +
+
+ )} )} diff --git a/packages/ui/src/components/ui/number-input.test.tsx b/packages/ui/src/components/ui/number-input.test.tsx index af4525c5..7b1fecd0 100644 --- a/packages/ui/src/components/ui/number-input.test.tsx +++ b/packages/ui/src/components/ui/number-input.test.tsx @@ -249,6 +249,7 @@ interface ControlledProps { min?: number; max?: number; step?: number; + deferExternalValueWhileFocused?: boolean; } interface ControlledHandle { @@ -258,6 +259,9 @@ interface ControlledHandle { rerenderWith(value: number): void; getButton(label: string): FakeNode | null; getButtonDisabled(label: string): boolean; + getInputValue(): string; + focusInput(): void; + pressEnter(): void; typeInput(value: string): void; blurInput(): void; unmount(): void; @@ -285,6 +289,7 @@ function mountControlled(props: ControlledProps): ControlledHandle { min: props.min, max: props.max, step: props.step, + deferExternalValueWhileFocused: props.deferExternalValueWhileFocused, onValueChange: (v: number) => recorded.push(v), }), ); @@ -339,15 +344,21 @@ function mountControlled(props: ControlledProps): ControlledHandle { } function readInputProps(): { - onChange: (e: unknown) => void; - onBlur: (e: unknown) => void; + value: string; + onChange: (event: { target: { value: string } }) => void; + onFocus?: () => void; + onBlur: () => void; + onKeyDown: (event: { key: string; defaultPrevented: boolean }) => void; } { const input = findInputNode(); const propsKey = Object.keys(input).find((k) => k.startsWith("__reactProps")); if (!propsKey) throw new Error("Input has no __reactProps"); return (input as unknown as Record void; - onBlur: (e: unknown) => void; + value: string; + onChange: (event: { target: { value: string } }) => void; + onFocus?: () => void; + onBlur: () => void; + onKeyDown: (event: { key: string; defaultPrevented: boolean }) => void; }>)[propsKey]; } @@ -372,6 +383,23 @@ function mountControlled(props: ControlledProps): ControlledHandle { const props = (btn as unknown as Record)[propsKey]; return Boolean(props.disabled); }, + getInputValue() { + return readInputProps().value; + }, + focusInput() { + const input = findInputNode(); + const props = readInputProps(); + doc.activeElement = input; + act(() => { + props.onFocus?.(); + }); + }, + pressEnter() { + const props = readInputProps(); + act(() => { + props.onKeyDown({ key: "Enter", defaultPrevented: false }); + }); + }, typeInput(value: string) { // Look the input up fresh each time so we always invoke the handler // currently bound by the most recent render. @@ -385,7 +413,7 @@ function mountControlled(props: ControlledProps): ControlledHandle { // the props object, and we want the handler bound to the latest draft. const props = readInputProps(); act(() => { - props.onBlur({}); + props.onBlur(); }); }, unmount() { @@ -535,6 +563,52 @@ describe("NumberInput rapid-click stepper", () => { }); }); + test("focused draft survives a parent rerender with the previous value until blur", () => { + withHandle( + { + initialValue: 40, + min: 1, + max: 100, + step: 1, + deferExternalValueWhileFocused: true, + }, + (handle) => { + handle.focusInput(); + handle.typeInput("100"); + + expect(handle.recorded).toEqual([]); + + handle.rerenderWith(40); + + expect(handle.getInputValue()).toBe("100"); + + handle.blurInput(); + + expect(handle.recorded).toEqual([100]); + } + ); + }); + + test("focused draft commits once when Enter settles it", () => { + withHandle( + { + initialValue: 40, + min: 1, + max: 100, + step: 1, + deferExternalValueWhileFocused: true, + }, + (handle) => { + handle.focusInput(); + handle.typeInput("100"); + + handle.pressEnter(); + + expect(handle.recorded).toEqual([100]); + } + ); + }); + test("typed value below min is clamped on blur and the stepper respects the clamped base", () => { withHandle({ initialValue: 100, min: 50, max: 200, step: 5 }, (handle) => { // 1) User types "20" (below min). handleChange commits 20, which diff --git a/packages/ui/src/components/ui/number-input.tsx b/packages/ui/src/components/ui/number-input.tsx index 14e47a07..227d08d1 100644 --- a/packages/ui/src/components/ui/number-input.tsx +++ b/packages/ui/src/components/ui/number-input.tsx @@ -16,6 +16,7 @@ interface NumberInputProps fallbackValue?: number onClear?: () => void emptyLabel?: string + deferExternalValueWhileFocused?: boolean } function clamp(value: number, min: number, max: number) { @@ -50,10 +51,13 @@ const NumberInput = React.forwardRef( className, containerClassName, onBlur, + onFocus, + onKeyDown, disabled, fallbackValue, onClear, emptyLabel = '—', + deferExternalValueWhileFocused = false, ...props }, ref @@ -61,6 +65,7 @@ const NumberInput = React.forwardRef( const { t } = useI18n() const [draft, setDraft] = React.useState(() => (value == null ? '' : String(value))) const { isMobile } = useDeviceInfo() + const isFocusedRef = React.useRef(false) const ignoreNextClickRef = React.useRef(false) const swallowNextClickCleanupRef = React.useRef<(() => void) | null>(null) @@ -100,8 +105,11 @@ const NumberInput = React.forwardRef( }, []) React.useEffect(() => { + if (deferExternalValueWhileFocused && isFocusedRef.current) { + return + } setDraft(value == null ? '' : String(value)) - }, [value]) + }, [deferExternalValueWhileFocused, value]) const baseValue = React.useMemo(() => { if (value !== undefined) return value @@ -134,6 +142,28 @@ const NumberInput = React.forwardRef( [max, min, onValueChange, step] ) + const settleDraft = React.useCallback(() => { + if (draft.trim() === '') { + if (!onClear) { + setDraft(value == null ? '' : String(value)) + } + return + } + + const parsed = Number(draft) + if (!Number.isFinite(parsed)) { + setDraft(value == null ? '' : String(value)) + return + } + + const clamped = clamp(parsed, min, max) + const normalized = normalizeToStep(clamped, step) + if (normalized !== value) { + commitValue(parsed) + } + setDraft(String(normalized)) + }, [commitValue, draft, max, min, onClear, step, value]) + const handleChange = React.useCallback( (event: React.ChangeEvent) => { const nextDraft = event.target.value @@ -149,43 +179,44 @@ const NumberInput = React.forwardRef( return } + if (deferExternalValueWhileFocused) { + return + } + commitValue(parsed) }, - [commitValue, onClear] + [commitValue, deferExternalValueWhileFocused, onClear] + ) + + const handleFocus = React.useCallback( + (event: React.FocusEvent) => { + isFocusedRef.current = true + onFocus?.(event) + }, + [onFocus] ) const handleBlur = React.useCallback( (event: React.FocusEvent) => { - if (draft.trim() === '') { - if (!onClear) { - setDraft(value == null ? '' : String(value)) - } - onBlur?.(event) - return - } - - const parsed = Number(draft) - if (!Number.isFinite(parsed)) { - setDraft(value == null ? '' : String(value)) - } else { - const clamped = clamp(parsed, min, max) - const normalized = normalizeToStep(clamped, step) - if (normalized !== value) { - // Route through commitValue so committedValueRef stays in sync with - // the typed value. Without this, a typed-then-stepper sequence - // would read a stale ref and drift. See number-input.test.tsx. - commitValue(parsed) - } else { - // No effective change, but keep the ref aligned with the prop in - // case it diverged via the baseValue useEffect. - committedValueRef.current = normalized - } - setDraft(String(normalized)) - } + isFocusedRef.current = false + settleDraft() onBlur?.(event) }, - [commitValue, draft, max, min, onBlur, onClear, step, value] + [onBlur, settleDraft] + ) + + const handleKeyDown = React.useCallback( + (event: React.KeyboardEvent) => { + onKeyDown?.(event) + if (event.defaultPrevented) { + return + } + if (deferExternalValueWhileFocused && event.key === 'Enter') { + settleDraft() + } + }, + [deferExternalValueWhileFocused, onKeyDown, settleDraft] ) const incrementDisabled = Boolean(disabled || baseValue >= max) @@ -310,7 +341,9 @@ const NumberInput = React.forwardRef( inputMode={props.inputMode ?? 'numeric'} value={draft} onChange={handleChange} + onFocus={handleFocus} onBlur={handleBlur} + onKeyDown={handleKeyDown} disabled={disabled} spellCheck={false} autoComplete="off" diff --git a/packages/ui/src/hooks/useQueuedMessageAutoSend.test.ts b/packages/ui/src/hooks/useQueuedMessageAutoSend.test.ts index 20e9a961..a50682aa 100644 --- a/packages/ui/src/hooks/useQueuedMessageAutoSend.test.ts +++ b/packages/ui/src/hooks/useQueuedMessageAutoSend.test.ts @@ -268,6 +268,67 @@ describe('buildQueuedAutoSendPayload', () => { expect(payload?.primaryAttachments[0]?.filename).toBe('notes.txt'); }); + test('retains raw queued content and attachments for history submissions', async () => { + const attachment = { + id: 'file-1', + filename: 'notes.txt', + mimeType: 'text/plain', + size: 5, + source: 'local' as const, + file: new File(['hello'], 'notes.txt', { type: 'text/plain' }), + dataUrl: 'data:text/plain;base64,aGVsbG8=', + }; + + const payload = buildQueuedAutoSendPayload([ + { + id: 'queued-raw', + text: '/plan feature from raw queue', + content: '/plan feature from raw queue', + createdAt: 1, + attachments: [attachment], + }, + ]); + + expect(payload).not.toBeNull(); + expect(payload?.historySubmissions).toEqual([ + { + text: '/plan feature from raw queue', + attachmentKeys: ['local|notes.txt|text/plain|5|data'], + restorableAttachments: [], + }, + ]); + + await sendQueuedAutoSendPayload({ + runtimeKey: 'runtime-original', + sessionId: 'session-original', + directory: '/repo', + }, { + ...payload!, + primaryText: 'sanitized transport text', + }, { + providerID: 'provider-1', + modelID: 'model-1', + agent: 'agent-1', + variant: 'variant-1', + }); + + expect(sendMessageCalls[0]?.[0]).toBe('sanitized transport text'); + expect(sendMessageCalls[0]?.[9]).toEqual({ + target: { + runtimeKey: 'runtime-original', + sessionId: 'session-original', + directory: '/repo', + }, + historySubmissions: [ + { + text: '/plan feature from raw queue', + attachmentKeys: ['local|notes.txt|text/plain|5|data'], + restorableAttachments: [], + }, + ], + }); + }); + test('auto-send targets the queued session explicitly', async () => { const payload = buildQueuedAutoSendPayload([ { @@ -307,6 +368,13 @@ describe('buildQueuedAutoSendPayload', () => { sessionId: 'session-original', directory: '/repo', }, + historySubmissions: [ + { + text: 'queued message', + attachmentKeys: [], + restorableAttachments: [], + }, + ], }, ]); }); diff --git a/packages/ui/src/hooks/useQueuedMessageAutoSend.ts b/packages/ui/src/hooks/useQueuedMessageAutoSend.ts index 281178f9..e4b83ef2 100644 --- a/packages/ui/src/hooks/useQueuedMessageAutoSend.ts +++ b/packages/ui/src/hooks/useQueuedMessageAutoSend.ts @@ -10,6 +10,7 @@ import { getDirectoryState } from '@/sync/sync-refs'; import { useDirectorySync } from '@/sync/sync-context'; import { getRuntimeKey } from '@/lib/runtime-switch'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; +import { createInputHistorySubmission } from '@/stores/useInputHistoryStore'; type SessionStatusType = 'idle' | 'busy' | 'retry'; @@ -86,6 +87,7 @@ export const buildQueuedAutoSendPayload = (queue: QueuedMessage[]) => { // file mentions resolved, and the context it was queued with following it. return { queuedMessageId: queued.id, + historySubmissions: [createInputHistorySubmission(queued.content, queued.attachments ?? [])], primaryText: queued.text, primaryAttachments: queued.attachments ?? [], agentMentionName: queued.agentMention, @@ -116,9 +118,12 @@ export const sendQueuedAutoSendPayload = ( payload.agentMentionName, payload.additionalParts.length > 0 ? payload.additionalParts : undefined, resolved.variant, - 'normal', - { target }, - ); + 'normal', + { + target, + historySubmissions: payload.historySubmissions, + }, + ); }; const resolveSessionSendConfig = (sessionId: string) => { diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index 422206d5..48b43406 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -1,5 +1,6 @@ import type { WorktreeMetadata } from '@/types/worktree'; import type { DraftStarterRef } from '@/lib/draftStarters'; +import type { InputHistoryScope } from '@/lib/inputHistoryScope'; type RuntimePlatform = 'web' | 'desktop' | 'vscode'; @@ -722,6 +723,8 @@ export interface SettingsPayload { sessionRetentionAction?: 'archive' | 'delete'; followUpBehavior?: 'steer' | 'queue'; queueModeEnabled?: boolean; + inputHistoryScope?: InputHistoryScope; + inputHistoryLimit?: number; gitmojiEnabled?: boolean; inputSpellcheckEnabled?: boolean; enterToSend?: boolean; diff --git a/packages/ui/src/lib/desktop.ts b/packages/ui/src/lib/desktop.ts index 4555e102..9e157773 100644 --- a/packages/ui/src/lib/desktop.ts +++ b/packages/ui/src/lib/desktop.ts @@ -2,6 +2,7 @@ import { z } from 'zod'; import type { ProjectEntry, RuntimeAPIs, TerminalShell } from '@/lib/api/types'; import { getInjectedBootOutcome } from '@/lib/desktopBoot'; import type { DraftStarterRef } from '@/lib/draftStarters'; +import type { InputHistoryScope } from '@/lib/inputHistoryScope'; import type { MobileKeyboardMode } from '@/lib/mobileKeyboardMode'; import { getRuntimeApiBaseUrl, getRuntimeKey } from '@/lib/runtime-switch'; import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; @@ -177,6 +178,8 @@ export type DesktopSettings = { weekStartPreference?: 'auto' | 'sunday' | 'monday'; chatRenderMode?: 'sorted' | 'live'; messageStreamTransport?: 'auto' | 'ws' | 'sse'; + inputHistoryScope?: InputHistoryScope; + inputHistoryLimit?: number; activityRenderMode?: 'collapsed' | 'summary'; mermaidRenderingMode?: 'svg' | 'ascii'; userMessageRenderingMode?: 'markdown' | 'plain'; diff --git a/packages/ui/src/lib/i18n/messages/de.settings.ts b/packages/ui/src/lib/i18n/messages/de.settings.ts index 115914f3..47b55a26 100644 --- a/packages/ui/src/lib/i18n/messages/de.settings.ts +++ b/packages/ui/src/lib/i18n/messages/de.settings.ts @@ -2138,6 +2138,15 @@ export const settingsDict = { 'settings.openchamber.visual.field.followUpBehaviorDescription': 'Wählen Sie aus, was passiert, wenn Sie Enter auf einer Follow-up-Nachricht drücken, während der Agent noch antwortet.', 'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steuerung', 'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Warteschlange', + 'settings.openchamber.visual.field.inputHistoryScope': 'Bereich des Eingabeverlaufs', + 'settings.openchamber.visual.field.inputHistoryScopeDescription': 'Wählen Sie aus, ob gesendete Prompts projektübergreifend in allen mit dieser Laufzeit verbundenen Projekten oder nur in der aktuellen Sitzung wieder abgerufen werden können.', + 'settings.openchamber.visual.section.inputHistoryScopeAria': 'Bereich des Eingabeverlaufs', + 'settings.openchamber.visual.option.inputHistoryScope.global.label': 'Alle Projekte', + 'settings.openchamber.visual.option.inputHistoryScope.session.label': 'Aktuelle Sitzung', + 'settings.openchamber.visual.field.inputHistoryLimit': 'Zu merkende Prompts', + 'settings.openchamber.visual.field.inputHistoryLimitDescription': 'Wenn Sie diese Zahl verringern, werden ältere Prompts sofort aus Ihrem Verlauf entfernt.', + 'settings.openchamber.visual.field.inputHistoryLimitAria': 'Zu merkende Prompts', + 'settings.openchamber.visual.field.inputHistoryLimitUnit': 'Prompts', 'settings.providers.page.quotaCredentials.accessToken': 'Zugriffstoken', 'settings.providers.page.quotaCredentials.usageToken': 'Nutzungs-API-Token', diff --git a/packages/ui/src/lib/i18n/messages/en.settings.ts b/packages/ui/src/lib/i18n/messages/en.settings.ts index 1ccbe99d..2c3a8974 100644 --- a/packages/ui/src/lib/i18n/messages/en.settings.ts +++ b/packages/ui/src/lib/i18n/messages/en.settings.ts @@ -2227,6 +2227,15 @@ export const settingsDict = { 'settings.openchamber.visual.field.followUpBehaviorDescription': 'Choose what happens when you press Enter on a follow-up message while the agent is still responding.', 'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer', 'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue', + 'settings.openchamber.visual.field.inputHistoryScope': 'Input history scope', + 'settings.openchamber.visual.field.inputHistoryScopeDescription': 'Choose whether submitted prompts are recalled across all projects connected to this runtime or only in the current session.', + 'settings.openchamber.visual.section.inputHistoryScopeAria': 'Input history scope', + 'settings.openchamber.visual.option.inputHistoryScope.global.label': 'All projects', + 'settings.openchamber.visual.option.inputHistoryScope.session.label': 'Current session', + 'settings.openchamber.visual.field.inputHistoryLimit': 'Prompts to remember', + 'settings.openchamber.visual.field.inputHistoryLimitDescription': 'Lowering this number removes older prompts from your history.', + 'settings.openchamber.visual.field.inputHistoryLimitAria': 'Prompts to remember', + 'settings.openchamber.visual.field.inputHistoryLimitUnit': 'prompts', ...linearIntegrationI18n.en, 'settings.page.integrations.title': 'Integrations', 'settings.page.integrations.description': 'Connect GitHub and Linear so OpenChamber can work with your issues and pull requests.', diff --git a/packages/ui/src/lib/i18n/messages/es.settings.ts b/packages/ui/src/lib/i18n/messages/es.settings.ts index 0b7f7ed0..81022bfe 100644 --- a/packages/ui/src/lib/i18n/messages/es.settings.ts +++ b/packages/ui/src/lib/i18n/messages/es.settings.ts @@ -2237,6 +2237,15 @@ export const settingsDict = { "settings.openchamber.visual.field.followUpBehaviorDescription": "Choose what happens when you press Enter on a follow-up message while the agent is still responding.", "settings.openchamber.visual.option.followUpBehavior.steer.label": "Steer", "settings.openchamber.visual.option.followUpBehavior.queue.label": "Queue", + "settings.openchamber.visual.field.inputHistoryScope": "Alcance del historial de entrada", + "settings.openchamber.visual.field.inputHistoryScopeDescription": "Elige si los prompts enviados se recuperan en todos los proyectos conectados a este runtime o solo en la sesión actual.", + "settings.openchamber.visual.section.inputHistoryScopeAria": "Alcance del historial de entrada", + "settings.openchamber.visual.option.inputHistoryScope.global.label": "Todos los proyectos", + "settings.openchamber.visual.option.inputHistoryScope.session.label": "Sesión actual", + "settings.openchamber.visual.field.inputHistoryLimit": "Prompts que recordar", + "settings.openchamber.visual.field.inputHistoryLimitDescription": "Bajar este número elimina de inmediato los prompts más antiguos de tu historial.", + "settings.openchamber.visual.field.inputHistoryLimitAria": "Prompts que recordar", + "settings.openchamber.visual.field.inputHistoryLimitUnit": "prompts", ...linearIntegrationI18n.es, 'settings.page.integrations.title': 'Integraciones', 'settings.page.integrations.description': 'Conecta GitHub y Linear para que OpenChamber pueda trabajar con tus issues y pull requests.', diff --git a/packages/ui/src/lib/i18n/messages/fr.settings.ts b/packages/ui/src/lib/i18n/messages/fr.settings.ts index 7163eba6..e4f701b2 100644 --- a/packages/ui/src/lib/i18n/messages/fr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr.settings.ts @@ -2237,6 +2237,15 @@ export const settingsDict = { 'settings.openchamber.visual.field.followUpBehaviorDescription': 'Choose what happens when you press Enter on a follow-up message while the agent is still responding.', 'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer', 'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue', + 'settings.openchamber.visual.field.inputHistoryScope': 'Portée de l\'historique de saisie', + 'settings.openchamber.visual.field.inputHistoryScopeDescription': 'Choisissez si les prompts envoyés sont rappelés dans tous les projets reliés à ce runtime ou seulement dans la session en cours.', + 'settings.openchamber.visual.section.inputHistoryScopeAria': 'Portée de l\'historique de saisie', + 'settings.openchamber.visual.option.inputHistoryScope.global.label': 'Tous les projets', + 'settings.openchamber.visual.option.inputHistoryScope.session.label': 'Session actuelle', + 'settings.openchamber.visual.field.inputHistoryLimit': 'Prompts à mémoriser', + 'settings.openchamber.visual.field.inputHistoryLimitDescription': 'Réduire ce nombre supprime aussitôt les prompts les plus anciens de votre historique.', + 'settings.openchamber.visual.field.inputHistoryLimitAria': 'Prompts à mémoriser', + 'settings.openchamber.visual.field.inputHistoryLimitUnit': 'prompts', ...linearIntegrationI18n.fr, 'settings.page.integrations.title': 'Intégrations', 'settings.page.integrations.description': 'Connectez GitHub et Linear pour qu’OpenChamber puisse travailler avec vos issues et pull requests.', diff --git a/packages/ui/src/lib/i18n/messages/ja.settings.ts b/packages/ui/src/lib/i18n/messages/ja.settings.ts index a519c59e..7174eaa7 100644 --- a/packages/ui/src/lib/i18n/messages/ja.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja.settings.ts @@ -2237,6 +2237,15 @@ export const settingsDict = { 'settings.openchamber.visual.field.followUpBehaviorDescription': 'エージェントが応答している間にフォローアップメッセージで Enter を押したときの動作を選択します。', 'settings.openchamber.visual.option.followUpBehavior.steer.label': 'ステア', 'settings.openchamber.visual.option.followUpBehavior.queue.label': 'キュー', + 'settings.openchamber.visual.field.inputHistoryScope': '入力履歴の範囲', + 'settings.openchamber.visual.field.inputHistoryScopeDescription': '送信したプロンプトを、このランタイムに接続されたすべてのプロジェクトで呼び出せるようにするか、現在のセッションだけにするかを選びます。', + 'settings.openchamber.visual.section.inputHistoryScopeAria': '入力履歴の範囲', + 'settings.openchamber.visual.option.inputHistoryScope.global.label': 'すべてのプロジェクト', + 'settings.openchamber.visual.option.inputHistoryScope.session.label': '現在のセッション', + 'settings.openchamber.visual.field.inputHistoryLimit': '記憶するプロンプト数', + 'settings.openchamber.visual.field.inputHistoryLimitDescription': 'この数を減らすと、履歴内の古いプロンプトはすぐに削除されます。', + 'settings.openchamber.visual.field.inputHistoryLimitAria': '記憶するプロンプト数', + 'settings.openchamber.visual.field.inputHistoryLimitUnit': '件', ...linearIntegrationI18n.ja, 'settings.page.integrations.title': '連携', 'settings.page.integrations.description': 'GitHub と Linear を接続すると、OpenChamber が Issue やプルリクエストを扱えるようになります。', diff --git a/packages/ui/src/lib/i18n/messages/ko.settings.ts b/packages/ui/src/lib/i18n/messages/ko.settings.ts index 68ae4846..c5b23e0d 100644 --- a/packages/ui/src/lib/i18n/messages/ko.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ko.settings.ts @@ -2237,6 +2237,15 @@ export const settingsDict = { 'settings.openchamber.visual.field.followUpBehaviorDescription': 'Choose what happens when you press Enter on a follow-up message while the agent is still responding.', 'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer', 'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue', + 'settings.openchamber.visual.field.inputHistoryScope': '입력 기록 범위', + 'settings.openchamber.visual.field.inputHistoryScopeDescription': '보낸 프롬프트를 이 런타임에 연결된 모든 프로젝트에서 다시 불러올지, 현재 세션에서만 다시 불러올지 선택합니다.', + 'settings.openchamber.visual.section.inputHistoryScopeAria': '입력 기록 범위', + 'settings.openchamber.visual.option.inputHistoryScope.global.label': '모든 프로젝트', + 'settings.openchamber.visual.option.inputHistoryScope.session.label': '현재 세션', + 'settings.openchamber.visual.field.inputHistoryLimit': '기억할 프롬프트 수', + 'settings.openchamber.visual.field.inputHistoryLimitDescription': '이 숫자를 낮추면 기록에서 오래된 프롬프트가 바로 삭제됩니다.', + 'settings.openchamber.visual.field.inputHistoryLimitAria': '기억할 프롬프트 수', + 'settings.openchamber.visual.field.inputHistoryLimitUnit': '개', ...linearIntegrationI18n.ko, 'settings.page.integrations.title': '통합', 'settings.page.integrations.description': 'GitHub와 Linear를 연결하면 OpenChamber가 이슈와 풀 리퀘스트를 다룰 수 있습니다.', diff --git a/packages/ui/src/lib/i18n/messages/pl.settings.ts b/packages/ui/src/lib/i18n/messages/pl.settings.ts index b3b5d6b8..4e62ac21 100644 --- a/packages/ui/src/lib/i18n/messages/pl.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pl.settings.ts @@ -2230,6 +2230,15 @@ export const settingsDict = { 'settings.openchamber.visual.field.followUpBehaviorDescription': 'Choose what happens when you press Enter on a follow-up message while the agent is still responding.', 'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer', 'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue', + 'settings.openchamber.visual.field.inputHistoryScope': 'Zakres historii wpisów', + 'settings.openchamber.visual.field.inputHistoryScopeDescription': 'Wybierz, czy wysłane prompty mają być przywoływane we wszystkich projektach podłączonych do tego środowiska uruchomieniowego, czy tylko w bieżącej sesji.', + 'settings.openchamber.visual.section.inputHistoryScopeAria': 'Zakres historii wpisów', + 'settings.openchamber.visual.option.inputHistoryScope.global.label': 'Wszystkie projekty', + 'settings.openchamber.visual.option.inputHistoryScope.session.label': 'Bieżąca sesja', + 'settings.openchamber.visual.field.inputHistoryLimit': 'Liczba zapamiętywanych promptów', + 'settings.openchamber.visual.field.inputHistoryLimitDescription': 'Zmniejszenie tej liczby od razu usuwa starsze prompty z historii.', + 'settings.openchamber.visual.field.inputHistoryLimitAria': 'Liczba zapamiętywanych promptów', + 'settings.openchamber.visual.field.inputHistoryLimitUnit': 'promptów', ...linearIntegrationI18n.pl, 'settings.page.integrations.title': 'Integracje', 'settings.page.integrations.description': 'Połącz GitHub i Linear, aby OpenChamber mógł pracować z Twoimi issue i pull requestami.', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts index b2c6ef2e..80c6bee7 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts @@ -2237,6 +2237,15 @@ export const settingsDict = { "settings.openchamber.visual.field.followUpBehaviorDescription": "Choose what happens when you press Enter on a follow-up message while the agent is still responding.", "settings.openchamber.visual.option.followUpBehavior.steer.label": "Steer", "settings.openchamber.visual.option.followUpBehavior.queue.label": "Queue", + "settings.openchamber.visual.field.inputHistoryScope": "Escopo do histórico de entrada", + "settings.openchamber.visual.field.inputHistoryScopeDescription": "Escolha se os prompts enviados podem ser recuperados em todos os projetos conectados a este runtime ou apenas na sessão atual.", + "settings.openchamber.visual.section.inputHistoryScopeAria": "Escopo do histórico de entrada", + "settings.openchamber.visual.option.inputHistoryScope.global.label": "Todos os projetos", + "settings.openchamber.visual.option.inputHistoryScope.session.label": "Sessão atual", + "settings.openchamber.visual.field.inputHistoryLimit": "Prompts para lembrar", + "settings.openchamber.visual.field.inputHistoryLimitDescription": "Reduzir esse número remove na hora os prompts mais antigos do seu histórico.", + "settings.openchamber.visual.field.inputHistoryLimitAria": "Prompts para lembrar", + "settings.openchamber.visual.field.inputHistoryLimitUnit": "prompts", ...linearIntegrationI18n['pt-BR'], 'settings.page.integrations.title': 'Integrações', 'settings.page.integrations.description': 'Conecte o GitHub e o Linear para que o OpenChamber possa trabalhar com suas issues e pull requests.', diff --git a/packages/ui/src/lib/i18n/messages/tr.settings.ts b/packages/ui/src/lib/i18n/messages/tr.settings.ts index 00b07c5e..b73acc87 100644 --- a/packages/ui/src/lib/i18n/messages/tr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/tr.settings.ts @@ -1950,6 +1950,15 @@ export const settingsDict = { 'settings.openchamber.visual.actions.resetSpacingAria': 'Aralığı sıfırla', 'settings.openchamber.visual.field.inputBarOffset': 'Giriş Çubuğu Ofseti', 'settings.openchamber.visual.field.inputBarOffsetTooltip': 'Ana ekran çubuğu gibi işletim sistemi düzeyindeki ekran engellerinden kaçınmak için giriş çubuğunu yukarı kaldırır.', + 'settings.openchamber.visual.field.inputHistoryScope': 'Girdi geçmişi kapsamı', + 'settings.openchamber.visual.field.inputHistoryScopeDescription': 'Gönderilen istemlerin bu çalışma zamanına bağlı tüm projelerde mi yoksa yalnızca geçerli oturumda mı geri çağrılacağını seçin.', + 'settings.openchamber.visual.section.inputHistoryScopeAria': 'Girdi geçmişi kapsamı', + 'settings.openchamber.visual.option.inputHistoryScope.global.label': 'Tüm projeler', + 'settings.openchamber.visual.option.inputHistoryScope.session.label': 'Geçerli oturum', + 'settings.openchamber.visual.field.inputHistoryLimit': 'Hatırlanacak prompt sayısı', + 'settings.openchamber.visual.field.inputHistoryLimitDescription': 'Bu sayıyı azaltmak, eski prompt\'ları geçmişinizden hemen siler.', + 'settings.openchamber.visual.field.inputHistoryLimitAria': 'Hatırlanacak prompt sayısı', + 'settings.openchamber.visual.field.inputHistoryLimitUnit': 'prompt', 'settings.openchamber.visual.actions.resetInputBarOffsetAria': 'Giriş çubuğu ofsetini sıfırla', 'settings.openchamber.visual.field.terminalQuickKeysAria': 'Terminal hızlı tuşları', 'settings.openchamber.visual.field.terminalQuickKeys': 'Terminal Hızlı Tuşları', diff --git a/packages/ui/src/lib/i18n/messages/uk.settings.ts b/packages/ui/src/lib/i18n/messages/uk.settings.ts index d5810696..f84bcfee 100644 --- a/packages/ui/src/lib/i18n/messages/uk.settings.ts +++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts @@ -2237,6 +2237,15 @@ export const settingsDict = { "settings.openchamber.visual.field.followUpBehaviorDescription": "Choose what happens when you press Enter on a follow-up message while the agent is still responding.", "settings.openchamber.visual.option.followUpBehavior.steer.label": "Steer", "settings.openchamber.visual.option.followUpBehavior.queue.label": "Queue", + "settings.openchamber.visual.field.inputHistoryScope": "Обсяг історії введення", + "settings.openchamber.visual.field.inputHistoryScopeDescription": "Виберіть, чи згадувати надіслані промпти в усіх проєктах, підключених до цього рантайму, чи лише в поточній сесії.", + "settings.openchamber.visual.section.inputHistoryScopeAria": "Обсяг історії введення", + "settings.openchamber.visual.option.inputHistoryScope.global.label": "Усі проєкти", + "settings.openchamber.visual.option.inputHistoryScope.session.label": "Поточна сесія", + "settings.openchamber.visual.field.inputHistoryLimit": "Скільки промптів пам’ятати", + "settings.openchamber.visual.field.inputHistoryLimitDescription": "Якщо зменшити це число, старіші промпти одразу буде видалено з історії.", + "settings.openchamber.visual.field.inputHistoryLimitAria": "Скільки промптів пам’ятати", + "settings.openchamber.visual.field.inputHistoryLimitUnit": "промптів", ...linearIntegrationI18n.uk, 'settings.page.integrations.title': 'Інтеграції', 'settings.page.integrations.description': 'Підключіть GitHub і Linear, щоб OpenChamber міг працювати з вашими задачами та pull request-ами.', diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts index 20d99659..599d953c 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts @@ -2237,6 +2237,15 @@ export const settingsDict = { 'settings.openchamber.visual.field.followUpBehaviorDescription': 'Choose what happens when you press Enter on a follow-up message while the agent is still responding.', 'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer', 'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue', + 'settings.openchamber.visual.field.inputHistoryScope': '输入历史范围', + 'settings.openchamber.visual.field.inputHistoryScopeDescription': '选择已提交的提示词是要在此运行时连接的所有项目中都可回忆,还是仅限当前会话。', + 'settings.openchamber.visual.section.inputHistoryScopeAria': '输入历史范围', + 'settings.openchamber.visual.option.inputHistoryScope.global.label': '所有项目', + 'settings.openchamber.visual.option.inputHistoryScope.session.label': '当前会话', + 'settings.openchamber.visual.field.inputHistoryLimit': '要记住的提示词数量', + 'settings.openchamber.visual.field.inputHistoryLimitDescription': '调低这个数字会立即从历史记录中删除较早的提示词。', + 'settings.openchamber.visual.field.inputHistoryLimitAria': '要记住的提示词数量', + 'settings.openchamber.visual.field.inputHistoryLimitUnit': '条', ...linearIntegrationI18n['zh-CN'], 'settings.page.integrations.title': '集成', 'settings.page.integrations.description': '连接 GitHub 和 Linear,让 OpenChamber 可以处理你的 issue 和拉取请求。', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts index 5225d19d..16d4adc9 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts @@ -2237,6 +2237,15 @@ export const settingsDict = { 'settings.openchamber.visual.field.followUpBehaviorDescription': 'Choose what happens when you press Enter on a follow-up message while the agent is still responding.', 'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer', 'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue', + 'settings.openchamber.visual.field.inputHistoryScope': '輸入歷史範圍', + 'settings.openchamber.visual.field.inputHistoryScopeDescription': '選擇已送出的提示詞要在連接到這個執行環境的所有專案中都能回叫,還是只限目前工作階段。', + 'settings.openchamber.visual.section.inputHistoryScopeAria': '輸入歷史範圍', + 'settings.openchamber.visual.option.inputHistoryScope.global.label': '所有專案', + 'settings.openchamber.visual.option.inputHistoryScope.session.label': '目前工作階段', + 'settings.openchamber.visual.field.inputHistoryLimit': '要記住的提示詞數量', + 'settings.openchamber.visual.field.inputHistoryLimitDescription': '調低這個數字會立刻從歷史記錄移除較早的提示詞。', + 'settings.openchamber.visual.field.inputHistoryLimitAria': '要記住的提示詞數量', + 'settings.openchamber.visual.field.inputHistoryLimitUnit': '則', ...linearIntegrationI18n['zh-TW'], 'settings.page.integrations.title': '整合', 'settings.page.integrations.description': '連接 GitHub 和 Linear,讓 OpenChamber 可以處理你的 issue 和 pull request。', diff --git a/packages/ui/src/lib/inputHistoryScope.ts b/packages/ui/src/lib/inputHistoryScope.ts new file mode 100644 index 00000000..ee1d1ad1 --- /dev/null +++ b/packages/ui/src/lib/inputHistoryScope.ts @@ -0,0 +1,18 @@ +export type InputHistoryScope = 'global' | 'session'; + +export const DEFAULT_INPUT_HISTORY_SCOPE: InputHistoryScope = 'global'; +export const DEFAULT_INPUT_HISTORY_LIMIT = 40; +export const MIN_INPUT_HISTORY_LIMIT = 1; +export const MAX_INPUT_HISTORY_LIMIT = 100; + +export const isInputHistoryScope = (value: string | null | undefined): value is InputHistoryScope => ( + value === 'global' || value === 'session' +); + +export const isInputHistoryLimit = (value: number | null | undefined): value is number => ( + value !== null + && value !== undefined + && Number.isInteger(value) + && value >= MIN_INPUT_HISTORY_LIMIT + && value <= MAX_INPUT_HISTORY_LIMIT +); diff --git a/packages/ui/src/lib/persistence.test.ts b/packages/ui/src/lib/persistence.test.ts index b2a07881..e6eb13d4 100644 --- a/packages/ui/src/lib/persistence.test.ts +++ b/packages/ui/src/lib/persistence.test.ts @@ -4,6 +4,11 @@ import type { RuntimeAPIs, SettingsPayload } from '@/lib/api/types'; import { registerRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; import { startModelPrefsAutoSave } from '@/lib/modelPrefsAutoSave'; import { startAppearanceAutoSave } from '@/lib/appearanceAutoSave'; +import { + DEFAULT_INPUT_HISTORY_LIMIT, + DEFAULT_INPUT_HISTORY_SCOPE, +} from '@/lib/inputHistoryScope'; +import { useInputHistoryStore } from '@/stores/useInputHistoryStore'; import { useUIStore } from '@/stores/useUIStore'; import { useMessageQueueStore } from '@/stores/messageQueueStore'; import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore'; @@ -30,6 +35,8 @@ type TestWindow = { let createdWindow = false; let createdLocalStorage = false; +const originalInputHistoryApplyScope = useInputHistoryStore.getState().applyScope; +const originalInputHistoryApplyEntryLimit = useInputHistoryStore.getState().applyEntryLimit; const ensureLocalStorage = (): void => { if (typeof localStorage !== 'undefined') { @@ -156,6 +163,14 @@ describe('updateDesktopSettings', () => { registerRuntimeAPIs(null); invalidateSettingsCache(); resetModelPrefsState(); + useInputHistoryStore.setState({ + entryLimit: DEFAULT_INPUT_HISTORY_LIMIT, + scope: DEFAULT_INPUT_HISTORY_SCOPE, + globalBuckets: {}, + sessionBuckets: {}, + applyEntryLimit: originalInputHistoryApplyEntryLimit, + applyScope: originalInputHistoryApplyScope, + }); }); test('waits for the debounced settings save to finish before resolving', async () => { @@ -574,6 +589,24 @@ describe('updateDesktopSettings', () => { }); }); + test('applies validated input history scope from shared settings save responses', async () => { + getWindow(); + registerSettingsSave(async () => ({ inputHistoryScope: 'session' })); + + await updateDesktopSettings({ inputHistoryScope: 'session' }); + + expect(useInputHistoryStore.getState().scope).toBe('session'); + }); + + test('applies validated input history limit from shared settings save responses', async () => { + getWindow(); + registerSettingsSave(async () => ({ inputHistoryLimit: 100 })); + + await updateDesktopSettings({ inputHistoryLimit: 100 }); + + expect(useInputHistoryStore.getState().entryLimit).toBe(100); + }); + test('does not broadcast a stale project selection over a newer pending update', async () => { const firstSave = deferred(); const savedChanges: Array> = []; @@ -816,6 +849,150 @@ describe('updateDesktopSettings', () => { expect(useUIStore.getState().autoSaveEnabled).toBe(false); }); + test('applies persisted input history scope from server settings', async () => { + getWindow(); + invalidateSettingsCache(); + registerSettingsApi(async () => ({}), async () => ({ + settings: { + inputHistoryScope: 'session', + autoSaveEnabled: true, + draftStartersCraftGoalAdded: true, + draftStartersScheduleTaskAdded: true, + }, + source: 'web', + })); + + await syncDesktopSettings(); + + expect(useInputHistoryStore.getState().scope).toBe('session'); + }); + + test('applies persisted input history limit from server settings', async () => { + getWindow(); + invalidateSettingsCache(); + registerSettingsApi(async () => ({}), async () => ({ + settings: { + inputHistoryLimit: 100, + autoSaveEnabled: true, + draftStartersCraftGoalAdded: true, + draftStartersScheduleTaskAdded: true, + }, + source: 'web', + })); + + await syncDesktopSettings(); + + expect(useInputHistoryStore.getState().entryLimit).toBe(100); + }); + + test('defaults omitted input history scope to global without writing a migration', async () => { + getWindow(); + invalidateSettingsCache(); + useInputHistoryStore.getState().applyScope('session'); + const saveCalls: Array> = []; + registerSettingsApi(async (changes) => { + saveCalls.push(changes); + return changes as SettingsPayload; + }, async () => ({ + settings: { + autoSaveEnabled: true, + draftStartersCraftGoalAdded: true, + draftStartersScheduleTaskAdded: true, + }, + source: 'web', + })); + + await syncDesktopSettings(); + + expect(useInputHistoryStore.getState().scope).toBe(DEFAULT_INPUT_HISTORY_SCOPE); + expect(saveCalls.some((changes) => changes.inputHistoryScope !== undefined)).toBe(false); + }); + + test('defaults omitted input history limit to forty without writing a migration', async () => { + getWindow(); + invalidateSettingsCache(); + useInputHistoryStore.getState().applyEntryLimit(100); + const saveCalls: Array> = []; + registerSettingsApi(async (changes) => { + saveCalls.push(changes); + return changes as SettingsPayload; + }, async () => ({ + settings: { + autoSaveEnabled: true, + draftStartersCraftGoalAdded: true, + draftStartersScheduleTaskAdded: true, + }, + source: 'web', + })); + + await syncDesktopSettings(); + + expect(useInputHistoryStore.getState().entryLimit).toBe(DEFAULT_INPUT_HISTORY_LIMIT); + expect(saveCalls.some((changes) => changes.inputHistoryLimit !== undefined)).toBe(false); + }); + + test('does not reapply the hydrated input history scope when it already matches', async () => { + getWindow(); + invalidateSettingsCache(); + useInputHistoryStore.getState().applyScope('session'); + let applyScopeCalls = 0; + useInputHistoryStore.setState({ + applyScope: (scope) => { + applyScopeCalls += 1; + originalInputHistoryApplyScope(scope); + }, + }); + registerSettingsApi(async () => ({}), async () => ({ + settings: { + inputHistoryScope: 'session', + autoSaveEnabled: true, + draftStartersCraftGoalAdded: true, + draftStartersScheduleTaskAdded: true, + }, + source: 'web', + })); + + try { + await syncDesktopSettings(); + } finally { + useInputHistoryStore.setState({ applyScope: originalInputHistoryApplyScope }); + } + + expect(applyScopeCalls).toBe(0); + expect(useInputHistoryStore.getState().scope).toBe('session'); + }); + + test('does not reapply the hydrated input history limit when it already matches', async () => { + getWindow(); + invalidateSettingsCache(); + useInputHistoryStore.getState().applyEntryLimit(100); + let applyEntryLimitCalls = 0; + useInputHistoryStore.setState({ + applyEntryLimit: (limit) => { + applyEntryLimitCalls += 1; + originalInputHistoryApplyEntryLimit(limit); + }, + }); + registerSettingsApi(async () => ({}), async () => ({ + settings: { + inputHistoryLimit: 100, + autoSaveEnabled: true, + draftStartersCraftGoalAdded: true, + draftStartersScheduleTaskAdded: true, + }, + source: 'web', + })); + + try { + await syncDesktopSettings(); + } finally { + useInputHistoryStore.setState({ applyEntryLimit: originalInputHistoryApplyEntryLimit }); + } + + expect(applyEntryLimitCalls).toBe(0); + expect(useInputHistoryStore.getState().entryLimit).toBe(100); + }); + test('autosaves autoSaveEnabled changes to shared settings', async () => { getWindow(); useUIStore.getState().setAutoSaveEnabled(true); diff --git a/packages/ui/src/lib/persistence.ts b/packages/ui/src/lib/persistence.ts index 0bc18989..2461ceca 100644 --- a/packages/ui/src/lib/persistence.ts +++ b/packages/ui/src/lib/persistence.ts @@ -15,6 +15,15 @@ import { setFilesViewShowGitignored } from '@/lib/filesViewShowGitignored'; import { loadAppearancePreferences, applyAppearancePreferences } from '@/lib/appearancePersistence'; import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; import { sanitizeStarterRefs } from '@/lib/draftStarters'; +import { + DEFAULT_INPUT_HISTORY_LIMIT, + DEFAULT_INPUT_HISTORY_SCOPE, + isInputHistoryLimit, + isInputHistoryScope, +} from '@/lib/inputHistoryScope'; +import { + useInputHistoryStore, +} from '@/stores/useInputHistoryStore'; import { normalizeMobileKeyboardMode, setStoredMobileKeyboardMode } from '@/lib/mobileKeyboardMode'; import { runtimeFetch } from '@/lib/runtime-fetch'; import { isCapacitorApp } from '@/lib/platform'; @@ -76,6 +85,8 @@ const persistRuntimeSettingsMirror = (settings: DesktopSettings, runtimeKey: str pwaAppName: settings.pwaAppName, mobileKeyboardMode: settings.mobileKeyboardMode, openCodeUpdateToastDismissedVersion: settings.openCodeUpdateToastDismissedVersion, + inputHistoryScope: settings.inputHistoryScope, + inputHistoryLimit: settings.inputHistoryLimit, dictationEnabled: settings.dictationEnabled, sttProvider: settings.sttProvider, sttServerUrl: settings.sttServerUrl, @@ -595,6 +606,8 @@ const materializeAuthoritativeUiSettings = (settings: DesktopSettings): DesktopS userMessageRenderingMode: defaults.userMessageRenderingMode, collapsibleUserMessages: defaults.collapsibleUserMessages, messageStreamTransport: 'auto', + inputHistoryScope: DEFAULT_INPUT_HISTORY_SCOPE, + inputHistoryLimit: DEFAULT_INPUT_HISTORY_LIMIT, stickyUserHeader: defaults.stickyUserHeader, promptNavigatorEnabled: defaults.promptNavigatorEnabled, wideChatLayoutEnabled: defaults.wideChatLayoutEnabled, @@ -643,6 +656,7 @@ const applyDesktopUiPreferences = (settings: DesktopSettings) => { ? window.__zustand_config_store__ ?? null : null; const queueStore = useMessageQueueStore.getState(); + const inputHistoryStore = useInputHistoryStore.getState(); if (typeof settings.workStatusPanelEnabled === 'boolean' && settings.workStatusPanelEnabled !== store.workStatusPanelEnabled) { @@ -868,6 +882,16 @@ const applyDesktopUiPreferences = (settings: DesktopSettings) => { configStore.setSettingsMessageStreamTransport(settings.messageStreamTransport); } } + if ( + typeof settings.inputHistoryScope === 'string' + && isInputHistoryScope(settings.inputHistoryScope) + && settings.inputHistoryScope !== inputHistoryStore.scope + ) { + inputHistoryStore.applyScope(settings.inputHistoryScope); + } + if (isInputHistoryLimit(settings.inputHistoryLimit) && settings.inputHistoryLimit !== inputHistoryStore.entryLimit) { + inputHistoryStore.applyEntryLimit(settings.inputHistoryLimit); + } if (typeof settings.stickyUserHeader === 'boolean' && settings.stickyUserHeader !== store.stickyUserHeader) { store.setStickyUserHeader(settings.stickyUserHeader); } @@ -1203,6 +1227,12 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => { if (typeof candidate.streamingAutoFollowEnabled === 'boolean') { result.streamingAutoFollowEnabled = candidate.streamingAutoFollowEnabled; } + if (typeof candidate.inputHistoryScope === 'string' && isInputHistoryScope(candidate.inputHistoryScope)) { + result.inputHistoryScope = candidate.inputHistoryScope; + } + if (typeof candidate.inputHistoryLimit === 'number' && isInputHistoryLimit(candidate.inputHistoryLimit)) { + result.inputHistoryLimit = candidate.inputHistoryLimit; + } if (typeof candidate.sessionRecapEnabled === 'boolean') { result.sessionRecapEnabled = candidate.sessionRecapEnabled; } diff --git a/packages/ui/src/lib/settings/search.test.ts b/packages/ui/src/lib/settings/search.test.ts index ad12372d..b22ed6cc 100644 --- a/packages/ui/src/lib/settings/search.test.ts +++ b/packages/ui/src/lib/settings/search.test.ts @@ -30,6 +30,28 @@ describe('settings search', () => { expect(results.some((result) => result.id === 'integrations.linear.mapping')).toBe(true); }); + test('finds the chat input history scope setting', () => { + const results = buildSettingsSearchResults({ + query: 'input history scope', + runtimeCtx, + t, + getPageTitle: (page) => page, + }); + + expect(results.some((result) => result.id === 'chat.input-history-scope')).toBe(true); + }); + + test('finds the chat input history limit setting by recall keywords', () => { + const results = buildSettingsSearchResults({ + query: 'remember prompts', + runtimeCtx, + t, + getPageTitle: (page) => page, + }); + + expect(results.some((result) => result.id === 'chat.input-history-limit')).toBe(true); + }); + test('hides Linear connect in VS Code', () => { const results = buildSettingsSearchResults({ query: 'linear', diff --git a/packages/ui/src/lib/settings/search.ts b/packages/ui/src/lib/settings/search.ts index f446401f..8fe2abaf 100644 --- a/packages/ui/src/lib/settings/search.ts +++ b/packages/ui/src/lib/settings/search.ts @@ -341,6 +341,20 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [ descriptionKey: 'settings.openchamber.visual.field.followUpBehaviorDescription', keywords: ['follow up', 'queue', 'steer', 'send immediately'], }, + { + id: 'chat.input-history-scope', + page: 'chat', + titleKey: 'settings.openchamber.visual.field.inputHistoryScope', + descriptionKey: 'settings.openchamber.visual.field.inputHistoryScopeDescription', + keywords: ['input history', 'composer history', 'global', 'session', 'reuse'], + }, + { + id: 'chat.input-history-limit', + page: 'chat', + titleKey: 'settings.openchamber.visual.field.inputHistoryLimit', + descriptionKey: 'settings.openchamber.visual.field.inputHistoryLimitDescription', + keywords: ['history limit', 'prompt recall', 'remember prompts', 'composer history', 'submitted prompts', 'trim history'], + }, { id: 'chat.persist-drafts', page: 'chat', diff --git a/packages/ui/src/stores/DOCUMENTATION.md b/packages/ui/src/stores/DOCUMENTATION.md index 4f8abcc2..1a66a091 100644 --- a/packages/ui/src/stores/DOCUMENTATION.md +++ b/packages/ui/src/stores/DOCUMENTATION.md @@ -100,6 +100,10 @@ Persisted session todos use a bounded composite key of runtime, normalized direc Chat composer drafts, confirmed mentions, inline-comment drafts, and pinned sessions use the same runtime/directory/session ownership rule. Chat drafts use a bounded shared envelope and notify mounted composers when authoritative deletion clears their identity, preventing unmount autosave from resurrecting deleted text. Inline drafts enforce per-session, global-session, and serialized-byte bounds. Pins retain every valid composite key across runtimes without silent age/count eviction and are never pruned from the first startup list. Confirmed local deletion and routed deletion events clear immediately; after an authoritative baseline exists, a later complete omission also cleans persisted state. Ambiguous session-only legacy drafts and pins are not claimed. +Input history keeps both runtime-wide and runtime/directory/session buckets in one bounded browser-storage envelope. The per-bucket cap is configurable from 1 through 100 and defaults to 40. Lowering the limit trims older entries from every bucket at once and cannot restore what it discards. Every scope change, limit change, append, and session cleanup rereads the latest durable envelope before applying its mutation, so a stale tab preserves history written by another tab. A failed write retains bounded before/after snapshots. The next mutation applies that local delta to the latest durable data, preserving pending appends and session deletions together with unrelated changes from other tabs. A successful durable write clears the pending delta. + +Server-owned queue acceptance records the original prompt and restorable attachments against its captured runtime/directory/session identity. Rejection records nothing. Automatic delivery and manual take do not record the accepted item again. VS Code retains recording at dispatch, using the full messages actually taken for sending. + Composer draft edits remain immediate in memory and use a trailing durable-write debounce. Pending text and confirmed mentions flush synchronously when the document becomes hidden, freezes, receives `pagehide`, switches identity, or unmounts; authoritative deletion cancels pending work before any lifecycle flush can run. The shared chat-draft envelope reuses its parsed snapshot until the storage value changes. Inline-comment draft byte accounting indexes serialized buckets and recalculates only the changed session bucket during normal edits; deferred storage still performs the final full-envelope serialization and lifecycle flush. ### `useTerminalStore.ts` diff --git a/packages/ui/src/stores/messageQueueStore.server.test.ts b/packages/ui/src/stores/messageQueueStore.server.test.ts index 63e3dda3..4b9a6686 100644 --- a/packages/ui/src/stores/messageQueueStore.server.test.ts +++ b/packages/ui/src/stores/messageQueueStore.server.test.ts @@ -1,9 +1,11 @@ import { beforeEach, describe, expect, mock, test } from "bun:test" +import { selectInputHistoryEntries, useInputHistoryStore } from "./useInputHistoryStore" import type { AttachedFile } from "./types/sessionTypes" import type { MessageQueueUpdatedEvent } from "./messageQueueStore" type FetchCall = { path: string; method: string; body: ReturnType } let calls: FetchCall[] = [] +let activeRuntimeKey = "runtime-a" let respond: (call: FetchCall) => Response = () => new Response("{}", { status: 200 }) mock.module("@/lib/runtime-fetch", () => ({ @@ -19,7 +21,7 @@ mock.module("@/lib/runtime-fetch", () => ({ })) const desktop = await import("@/lib/desktop") mock.module("@/lib/desktop", () => ({ ...desktop, isVSCodeRuntime: () => false })) -mock.module("@/lib/runtime-switch", () => ({ getRuntimeKey: () => "runtime-a" })) +mock.module("@/lib/runtime-switch", () => ({ getRuntimeKey: () => activeRuntimeKey })) mock.module("@/lib/persistence", () => ({ updateDesktopSettings: async () => undefined })) const { @@ -80,6 +82,8 @@ const attachment: AttachedFile = { } beforeEach(() => { + activeRuntimeKey = "runtime-a" + useInputHistoryStore.setState({ globalBuckets: {}, sessionBuckets: {} }) calls = [] respond = () => json({ revision: 1, session: session([]) }) // Forgetting also drops the revision guard, so each test starts unordered. @@ -149,6 +153,50 @@ describe("server-owned message queue", () => { expect(useMessageQueueStore.getState().queuedMessages[key]?.map((m) => m.id)).toEqual(["srv-1"]) }) + test("accepted queue history survives automatic delivery and manual take without recapture", async () => { + const historyTarget = { runtimeKey: 'runtime-a', directory: '/repo', sessionId: 'history-accepted' }; + const item = serverItem('history-item', 'original prompt'); + respond = () => json({ revision: 100, session: { ...session([item]), sessionId: historyTarget.sessionId } }); + const pending = useMessageQueueStore.getState().addToQueue(historyTarget, { + content: item.content, + attachments: [{ ...attachment, dataUrl: 'file:///repo/note.txt' }], + sendConfig: { providerID: 'p', modelID: 'm' }, + }); + const entries = () => selectInputHistoryEntries({ ...useInputHistoryStore.getState(), scope: 'session' }, historyTarget); + expect(entries()).toHaveLength(0); + await pending; + expect(entries().map((entry) => entry.text)).toEqual(['original prompt']); + expect(entries()[0]?.restorableAttachments[0]?.reference).toBe('file:///repo/note.txt'); + // A server delivery broadcast removes the projection, never the history. + applyMessageQueueUpdatedEvent(updated(101, { ...session([]), sessionId: historyTarget.sessionId }), historyTarget.runtimeKey); + expect(entries()).toHaveLength(1); + respond = () => json({ revision: 102, session: { ...session([]), sessionId: historyTarget.sessionId }, items: [item] }); + await useMessageQueueStore.getState().takeForSend(historyTarget); + expect(entries()).toHaveLength(1); + }); + + test("queue acceptance records the captured owner after the active runtime changes", async () => { + const historyTarget = { runtimeKey: 'runtime-a', directory: '/original', sessionId: 'history-runtime-switch' }; + respond = () => { + activeRuntimeKey = 'runtime-b'; + return json({ revision: 110, session: { ...session([]), sessionId: historyTarget.sessionId, directory: historyTarget.directory } }); + }; + await useMessageQueueStore.getState().addToQueue(historyTarget, { + content: 'for original runtime', sendConfig: { providerID: 'p', modelID: 'm' }, + }); + expect(selectInputHistoryEntries({ ...useInputHistoryStore.getState(), scope: 'session' }, historyTarget).map((entry) => entry.text)).toEqual(['for original runtime']); + expect(selectInputHistoryEntries({ ...useInputHistoryStore.getState(), scope: 'session' }, { ...historyTarget, runtimeKey: activeRuntimeKey })).toEqual([]); + }); + + test("a rejected queue acceptance records no history", async () => { + const historyTarget = { runtimeKey: 'runtime-a', directory: '/repo', sessionId: 'history-rejected' }; + respond = () => new Response('rejected', { status: 500 }); + await expect(useMessageQueueStore.getState().addToQueue(historyTarget, { + content: 'rejected prompt', sendConfig: { providerID: 'p', modelID: 'm' }, + })).rejects.toThrow(); + expect(selectInputHistoryEntries({ ...useInputHistoryStore.getState(), scope: 'session' }, historyTarget)).toEqual([]); + }); + test("addToQueue hands the captured context to the server, and a take brings it back", async () => { const context = [ { kind: "context" as const, text: "issue body", metadata: issueMetadata }, diff --git a/packages/ui/src/stores/messageQueueStore.ts b/packages/ui/src/stores/messageQueueStore.ts index d6ec620b..510fdf92 100644 --- a/packages/ui/src/stores/messageQueueStore.ts +++ b/packages/ui/src/stores/messageQueueStore.ts @@ -1,3 +1,4 @@ +import { createInputHistoryIdentity, createInputHistorySubmission, useInputHistoryStore } from './useInputHistoryStore'; import { create } from 'zustand'; import { devtools, persist } from 'zustand/middleware'; import { z } from 'zod'; @@ -548,6 +549,8 @@ export const useMessageQueueStore = create()( set((state) => removeMessageLocally(state, key, id)); throw new Error('A queued message needs a provider and model to be delivered later.'); } + const historyIdentity = createInputHistoryIdentity(target.runtimeKey, target.directory, target.sessionId); + const historySubmission = createInputHistorySubmission(message.content, message.attachments ?? []); try { const result = await requestJson(serverSessionResponseSchema, `${sessionPath(target.sessionId)}/items`, jsonInit('POST', { directory: target.directory, @@ -556,6 +559,9 @@ export const useMessageQueueStore = create()( // The optimistic entry is replaced by the server's copy of the queue. set((state) => removeMessageLocally(state, key, id)); applyServerSession(result.session, result.revision, target.runtimeKey); + if (historyIdentity) { + useInputHistoryStore.getState().appendSubmissions(historyIdentity, [historySubmission]); + } } catch (error) { set((state) => removeMessageLocally(state, key, id)); throw error; diff --git a/packages/ui/src/stores/useInputHistoryStore.test.ts b/packages/ui/src/stores/useInputHistoryStore.test.ts new file mode 100644 index 00000000..6c95468e --- /dev/null +++ b/packages/ui/src/stores/useInputHistoryStore.test.ts @@ -0,0 +1,745 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; + +import { + DEFAULT_INPUT_HISTORY_LIMIT, + DEFAULT_INPUT_HISTORY_SCOPE, + isInputHistoryLimit, + isInputHistoryScope, +} from '@/lib/inputHistoryScope'; +import type { AttachedFile } from '@/stores/types/sessionTypes'; + +const STORAGE_KEY = 'openchamber-input-history.v1'; + +const importStoreModule = async (): Promise => ( + import(`./useInputHistoryStore.ts?test=${Date.now()}-${Math.random()}`) +); + +const createFakeStorage = (): Storage => { + const store = new Map(); + const storage: Storage = { + getItem: (key) => store.get(key) ?? null, + setItem: (key, value) => { + store.set(key, String(value)); + }, + removeItem: (key) => { + store.delete(key); + }, + clear: () => { + store.clear(); + }, + key: (index) => Array.from(store.keys())[index] ?? null, + get length() { + return store.size; + }, + }; + return storage; +}; + +const createQuotaStorage = (maxEntriesPerBucket: number): Storage => { + const store = new Map(); + return { + getItem: (key) => store.get(key) ?? null, + setItem: (key, value) => { + // SAFETY: this test storage only reads back envelopes that this suite serialized. + const parsed = JSON.parse(String(value)) as { + global?: Record; + session?: Record; + }; + const counts = [ + ...Object.values(parsed.global ?? {}).map((bucket) => bucket.entries?.length ?? 0), + ...Object.values(parsed.session ?? {}).map((bucket) => bucket.entries?.length ?? 0), + ]; + if (counts.some((count) => count > maxEntriesPerBucket)) { + throw new DOMException('Quota exceeded', 'QuotaExceededError'); + } + store.set(key, String(value)); + }, + removeItem: (key) => { + store.delete(key); + }, + clear: () => { + store.clear(); + }, + key: (index) => Array.from(store.keys())[index] ?? null, + get length() { + return store.size; + }, + }; +}; + +const installWindow = (localStorage: Storage): void => { + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { + localStorage, + addEventListener: () => {}, + }, + }); +}; + +const makeAttachment = (overrides: Partial = {}): AttachedFile => ({ + id: overrides.id ?? 'attachment', + file: overrides.file ?? new File([], overrides.filename ?? 'note.txt', { type: overrides.mimeType ?? 'text/plain' }), + dataUrl: overrides.dataUrl ?? 'data:text/plain;base64,Zm9v', + mimeType: overrides.mimeType ?? 'text/plain', + filename: overrides.filename ?? 'note.txt', + size: overrides.size ?? 3, + source: overrides.source ?? 'local', + serverPath: overrides.serverPath, + vscodePath: overrides.vscodePath, + vscodeSource: overrides.vscodeSource, + sourceDocumentId: overrides.sourceDocumentId, +}); + +describe('useInputHistoryStore', () => { + const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window'); + const restoreWindow = (): void => { + if (previousWindow) { + Object.defineProperty(globalThis, 'window', previousWindow); + return; + } + Reflect.deleteProperty(globalThis, 'window'); + }; + + beforeEach(() => { + restoreWindow(); + }); + + afterEach(() => { + restoreWindow(); + }); + + test('uses defaults when the persisted envelope is missing', async () => { + const localStorage = createFakeStorage(); + installWindow(localStorage); + + const mod = await importStoreModule(); + const identity = mod.createInputHistoryIdentity('runtime-a', '/repo', 'session-1'); + + expect(identity).not.toBeNull(); + expect(DEFAULT_INPUT_HISTORY_SCOPE).toBe('global'); + expect(DEFAULT_INPUT_HISTORY_LIMIT).toBe(40); + expect(mod.useInputHistoryStore.getState().scope).toBe('global'); + expect(mod.useInputHistoryStore.getState().entryLimit).toBe(DEFAULT_INPUT_HISTORY_LIMIT); + expect(mod.selectInputHistoryEntries(mod.useInputHistoryStore.getState(), identity!)).toEqual([]); + }); + + test('treats omitted persisted maps as empty maps', async () => { + const localStorage = createFakeStorage(); + localStorage.setItem(STORAGE_KEY, JSON.stringify({ version: 1, scope: 'session' })); + installWindow(localStorage); + + const mod = await importStoreModule(); + const identity = mod.createInputHistoryIdentity('runtime-a', '/repo', 'session-1'); + + expect(identity).not.toBeNull(); + expect(mod.useInputHistoryStore.getState().scope).toBe('session'); + expect(mod.selectInputHistoryEntries(mod.useInputHistoryStore.getState(), identity!)).toEqual([]); + mod.useInputHistoryStore.getState().applyScope('global'); + expect(mod.selectInputHistoryEntries(mod.useInputHistoryStore.getState(), identity!)).toEqual([]); + }); + + test('drops malformed persisted siblings but preserves valid namespaces and entries', async () => { + const localStorage = createFakeStorage(); + localStorage.setItem(STORAGE_KEY, JSON.stringify({ + version: 1, + scope: 'session', + global: { + [JSON.stringify(['runtime-a'])]: { + touchedAt: 10, + entries: [ + { + text: 'kept', + attachmentKeys: ['text/plain|kept.txt|1|data'], + restorableAttachments: [], + submittedAt: 1, + }, + { + text: 42, + attachmentKeys: [], + restorableAttachments: [], + submittedAt: 2, + }, + ], + }, + [JSON.stringify(['runtime-b', ''])]: { + touchedAt: 20, + entries: [], + }, + }, + session: { + [JSON.stringify(['runtime-a', '/repo', 'session-1'])]: { + touchedAt: 11, + entries: [ + { + text: 'session-kept', + attachmentKeys: [], + restorableAttachments: [ + { + key: 'vscode|file.ts|text/plain|10|/repo/file.ts', + source: 'vscode-file', + filename: 'file.ts', + mimeType: 'text/plain', + size: 10, + reference: '/repo/file.ts', + }, + ], + submittedAt: 3, + }, + ], + }, + [JSON.stringify(['runtime-a', '/repo'])]: { + touchedAt: 12, + entries: [], + }, + }, + })); + installWindow(localStorage); + + const mod = await importStoreModule(); + const identity = mod.createInputHistoryIdentity('runtime-a', '/repo/', 'session-1'); + + expect(identity).not.toBeNull(); + expect(mod.useInputHistoryStore.getState().scope).toBe('session'); + expect(mod.selectInputHistoryEntries(mod.useInputHistoryStore.getState(), identity!)).toEqual([ + { + text: 'session-kept', + attachmentKeys: [], + restorableAttachments: [ + { + key: 'vscode|file.ts|text/plain|10|/repo/file.ts', + source: 'vscode-file', + filename: 'file.ts', + mimeType: 'text/plain', + size: 10, + reference: '/repo/file.ts', + }, + ], + submittedAt: 3, + }, + ]); + mod.useInputHistoryStore.getState().applyScope('global'); + expect(mod.selectInputHistoryEntries(mod.useInputHistoryStore.getState(), identity!)).toEqual([ + { + text: 'kept', + attachmentKeys: ['text/plain|kept.txt|1|data'], + restorableAttachments: [], + submittedAt: 1, + }, + ]); + }); + + test('validates scope, limit, runtime, directory, and session identity inputs', async () => { + installWindow(createFakeStorage()); + const mod = await importStoreModule(); + + expect(isInputHistoryScope('global')).toBe(true); + expect(isInputHistoryScope('session')).toBe(true); + expect(isInputHistoryScope('other')).toBe(false); + expect(isInputHistoryLimit(1)).toBe(true); + expect(isInputHistoryLimit(40)).toBe(true); + expect(isInputHistoryLimit(100)).toBe(true); + expect(isInputHistoryLimit(0)).toBe(false); + expect(isInputHistoryLimit(101)).toBe(false); + expect(isInputHistoryLimit(1.5)).toBe(false); + expect(isInputHistoryLimit(Number.NaN)).toBe(false); + expect(mod.createInputHistoryIdentity('', '/repo', 'session-1')).toBeNull(); + expect(mod.createInputHistoryIdentity('runtime-a', ' ', 'session-1')).toBeNull(); + expect(mod.createInputHistoryIdentity('runtime-a', '/repo', '')).toBeNull(); + expect(mod.createInputHistoryIdentity('runtime-a', '/repo/', 'session-1')).toEqual({ + runtimeKey: 'runtime-a', + directory: '/repo', + sessionId: 'session-1', + }); + }); + + test('serializes only restorable attachment references and bounds attachment keys', async () => { + installWindow(createFakeStorage()); + const mod = await importStoreModule(); + + const submission = mod.createInputHistorySubmission('hello', [ + makeAttachment({ + id: 'server-file', + source: 'server', + filename: 'server.ts', + mimeType: 'text/plain', + size: 12, + dataUrl: 'file:///repo/server.ts', + serverPath: '/repo/server.ts', + }), + makeAttachment({ + id: 'vscode-file', + source: 'vscode', + filename: 'editor.ts', + mimeType: 'text/plain', + size: 8, + dataUrl: 'file:///repo/editor.ts', + vscodePath: '/repo/editor.ts', + vscodeSource: 'file', + }), + makeAttachment({ + id: 'data-url', + source: 'local', + filename: 'inline.txt', + mimeType: 'text/plain', + size: 4, + dataUrl: 'data:text/plain;base64,aGV5', + }), + makeAttachment({ + id: 'http-query', + source: 'local', + filename: 'signed.png', + mimeType: 'image/png', + size: 5, + dataUrl: 'https://cdn.example.com/file.png?token=secret', + }), + makeAttachment({ + id: 'http-clean', + source: 'local', + filename: 'clean.png', + mimeType: 'image/png', + size: 6, + dataUrl: 'https://cdn.example.com/file.png', + }), + ]); + + expect(submission.text).toBe('hello'); + expect(submission.attachmentKeys).toHaveLength(5); + expect(submission.restorableAttachments).toEqual([ + { + key: 'server|server.ts|text/plain|12|file:///repo/server.ts', + source: 'file-url', + filename: 'server.ts', + mimeType: 'text/plain', + size: 12, + reference: 'file:///repo/server.ts', + }, + { + key: 'vscode|editor.ts|text/plain|8|/repo/editor.ts', + source: 'vscode-file', + filename: 'editor.ts', + mimeType: 'text/plain', + size: 8, + reference: '/repo/editor.ts', + }, + { + key: 'local|clean.png|image/png|6|https://cdn.example.com/file.png', + source: 'file-url', + filename: 'clean.png', + mimeType: 'image/png', + size: 6, + reference: 'https://cdn.example.com/file.png', + }, + ]); + }); + + test('appends every submission to both scope buckets', async () => { + installWindow(createFakeStorage()); + const mod = await importStoreModule(); + const identity = mod.createInputHistoryIdentity('runtime-a', '/repo', 'session-1'); + if (!identity) throw new Error('identity missing'); + + mod.useInputHistoryStore.getState().appendSubmissions(identity, [ + mod.createInputHistorySubmission('hello', []), + ]); + + mod.useInputHistoryStore.getState().applyScope('global'); + expect(mod.selectInputHistoryEntries(mod.useInputHistoryStore.getState(), identity)).toHaveLength(1); + mod.useInputHistoryStore.getState().applyScope('session'); + expect(mod.selectInputHistoryEntries(mod.useInputHistoryStore.getState(), identity)).toHaveLength(1); + }); + + test('suppresses adjacent duplicates per bucket without affecting sibling buckets', async () => { + installWindow(createFakeStorage()); + const mod = await importStoreModule(); + const first = mod.createInputHistoryIdentity('runtime-a', '/repo', 'session-1'); + const second = mod.createInputHistoryIdentity('runtime-a', '/repo', 'session-2'); + if (!first || !second) throw new Error('identity missing'); + + const submission = mod.createInputHistorySubmission('repeat', []); + mod.useInputHistoryStore.getState().appendSubmissions(first, [submission]); + mod.useInputHistoryStore.getState().appendSubmissions(second, [submission]); + + mod.useInputHistoryStore.getState().applyScope('global'); + expect(mod.selectInputHistoryEntries(mod.useInputHistoryStore.getState(), first).map((entry) => entry.text)).toEqual(['repeat']); + mod.useInputHistoryStore.getState().applyScope('session'); + expect(mod.selectInputHistoryEntries(mod.useInputHistoryStore.getState(), first).map((entry) => entry.text)).toEqual(['repeat']); + expect(mod.selectInputHistoryEntries(mod.useInputHistoryStore.getState(), second).map((entry) => entry.text)).toEqual(['repeat']); + }); + + test('keeps non-adjacent duplicates', async () => { + installWindow(createFakeStorage()); + const mod = await importStoreModule(); + const identity = mod.createInputHistoryIdentity('runtime-a', '/repo', 'session-1'); + if (!identity) throw new Error('identity missing'); + + mod.useInputHistoryStore.getState().appendSubmissions(identity, [ + mod.createInputHistorySubmission('first', []), + mod.createInputHistorySubmission('middle', []), + mod.createInputHistorySubmission('first', []), + ]); + + expect(mod.selectInputHistoryEntries(mod.useInputHistoryStore.getState(), identity).map((entry) => entry.text)).toEqual([ + 'first', + 'middle', + 'first', + ]); + }); + + test('caps each namespace at forty entries', async () => { + installWindow(createFakeStorage()); + const mod = await importStoreModule(); + const identity = mod.createInputHistoryIdentity('runtime-a', '/repo', 'session-1'); + if (!identity) throw new Error('identity missing'); + + mod.useInputHistoryStore.getState().appendSubmissions(identity, Array.from({ length: 45 }, (_, index) => ( + mod.createInputHistorySubmission(`entry-${index}`, []) + ))); + + const entries = mod.selectInputHistoryEntries(mod.useInputHistoryStore.getState(), identity); + expect(entries).toHaveLength(40); + expect(entries[0]?.text).toBe('entry-5'); + expect(entries.at(-1)?.text).toBe('entry-44'); + }); + + test('preserves a persisted entry limit above forty at startup', async () => { + const localStorage = createFakeStorage(); + localStorage.setItem(STORAGE_KEY, JSON.stringify({ + version: 1, + scope: 'global', + entryLimit: 100, + global: { + [JSON.stringify(['runtime-a'])]: { + touchedAt: 10, + entries: Array.from({ length: 45 }, (_, index) => ({ + text: `entry-${index}`, + attachmentKeys: [], + restorableAttachments: [], + submittedAt: index + 1, + })), + }, + }, + session: {}, + })); + installWindow(localStorage); + + const mod = await importStoreModule(); + const identity = mod.createInputHistoryIdentity('runtime-a', '/repo', 'session-1'); + if (!identity) throw new Error('identity missing'); + + expect(mod.useInputHistoryStore.getState().entryLimit).toBe(100); + expect(mod.selectInputHistoryEntries(mod.useInputHistoryStore.getState(), identity)).toHaveLength(45); + expect(mod.selectInputHistoryEntries(mod.useInputHistoryStore.getState(), identity)[0]?.text).toBe('entry-0'); + }); + + test('applies a lower entry limit immediately to global and session buckets', async () => { + installWindow(createFakeStorage()); + const mod = await importStoreModule(); + const first = mod.createInputHistoryIdentity('runtime-a', '/repo', 'session-1'); + const second = mod.createInputHistoryIdentity('runtime-a', '/repo', 'session-2'); + if (!first || !second) throw new Error('identity missing'); + + mod.useInputHistoryStore.getState().appendSubmissions(first, Array.from({ length: 4 }, (_, index) => ( + mod.createInputHistorySubmission(`first-${index}`, []) + ))); + mod.useInputHistoryStore.getState().appendSubmissions(second, [ + mod.createInputHistorySubmission('second-0', []), + mod.createInputHistorySubmission('second-1', []), + ]); + + mod.useInputHistoryStore.getState().applyEntryLimit(2); + + expect(mod.useInputHistoryStore.getState().entryLimit).toBe(2); + mod.useInputHistoryStore.getState().applyScope('global'); + expect(mod.selectInputHistoryEntries(mod.useInputHistoryStore.getState(), first).map((entry) => entry.text)).toEqual([ + 'second-0', + 'second-1', + ]); + mod.useInputHistoryStore.getState().applyScope('session'); + expect(mod.selectInputHistoryEntries(mod.useInputHistoryStore.getState(), first).map((entry) => entry.text)).toEqual([ + 'first-2', + 'first-3', + ]); + expect(mod.selectInputHistoryEntries(mod.useInputHistoryStore.getState(), second).map((entry) => entry.text)).toEqual([ + 'second-0', + 'second-1', + ]); + }); + + test('ignores invalid entry limits without mutating history', async () => { + installWindow(createFakeStorage()); + const mod = await importStoreModule(); + const identity = mod.createInputHistoryIdentity('runtime-a', '/repo', 'session-1'); + if (!identity) throw new Error('identity missing'); + + mod.useInputHistoryStore.getState().appendSubmissions(identity, [ + mod.createInputHistorySubmission('kept-0', []), + mod.createInputHistorySubmission('kept-1', []), + ]); + + mod.useInputHistoryStore.getState().applyEntryLimit(0); + + expect(mod.useInputHistoryStore.getState().entryLimit).toBe(DEFAULT_INPUT_HISTORY_LIMIT); + expect(mod.selectInputHistoryEntries(mod.useInputHistoryStore.getState(), identity).map((entry) => entry.text)).toEqual([ + 'kept-0', + 'kept-1', + ]); + }); + + test('keeps only the eight most recent global namespaces', async () => { + installWindow(createFakeStorage()); + const mod = await importStoreModule(); + + for (let index = 0; index < 9; index += 1) { + const identity = mod.createInputHistoryIdentity(`runtime-${index}`, `/repo-${index}`, 'session-1'); + if (!identity) throw new Error('identity missing'); + mod.useInputHistoryStore.getState().appendSubmissions(identity, [ + mod.createInputHistorySubmission(`entry-${index}`, []), + ]); + } + + const dropped = mod.createInputHistoryIdentity('runtime-0', '/repo-0', 'session-1'); + const kept = mod.createInputHistoryIdentity('runtime-8', '/repo-8', 'session-1'); + if (!dropped || !kept) throw new Error('identity missing'); + + mod.useInputHistoryStore.getState().applyScope('global'); + expect(mod.selectInputHistoryEntries(mod.useInputHistoryStore.getState(), dropped)).toEqual([]); + expect(mod.selectInputHistoryEntries(mod.useInputHistoryStore.getState(), kept).map((entry) => entry.text)).toEqual(['entry-8']); + }); + + test('shares one global bucket across directories in the same runtime', async () => { + installWindow(createFakeStorage()); + const mod = await importStoreModule(); + const first = mod.createInputHistoryIdentity('runtime-a', '/repo-a', 'session-1'); + const second = mod.createInputHistoryIdentity('runtime-a', '/repo-b', 'session-2'); + if (!first || !second) throw new Error('identity missing'); + + mod.useInputHistoryStore.getState().appendSubmissions(first, [mod.createInputHistorySubmission('first', [])]); + mod.useInputHistoryStore.getState().appendSubmissions(second, [mod.createInputHistorySubmission('second', [])]); + + mod.useInputHistoryStore.getState().applyScope('global'); + expect(mod.selectInputHistoryEntries(mod.useInputHistoryStore.getState(), first).map((entry) => entry.text)).toEqual(['first', 'second']); + expect(mod.selectInputHistoryEntries(mod.useInputHistoryStore.getState(), second).map((entry) => entry.text)).toEqual(['first', 'second']); + }); + + test('keeps only the fifty most recent session namespaces', async () => { + installWindow(createFakeStorage()); + const mod = await importStoreModule(); + + for (let index = 0; index < 51; index += 1) { + const identity = mod.createInputHistoryIdentity('runtime-a', '/repo', `session-${index}`); + if (!identity) throw new Error('identity missing'); + mod.useInputHistoryStore.getState().appendSubmissions(identity, [ + mod.createInputHistorySubmission(`entry-${index}`, []), + ]); + } + + const dropped = mod.createInputHistoryIdentity('runtime-a', '/repo', 'session-0'); + const kept = mod.createInputHistoryIdentity('runtime-a', '/repo', 'session-50'); + if (!dropped || !kept) throw new Error('identity missing'); + + mod.useInputHistoryStore.getState().applyScope('session'); + expect(mod.selectInputHistoryEntries(mod.useInputHistoryStore.getState(), dropped)).toEqual([]); + expect(mod.selectInputHistoryEntries(mod.useInputHistoryStore.getState(), kept).map((entry) => entry.text)).toEqual(['entry-50']); + }); + + test('selects global or session history according to the current scope', async () => { + installWindow(createFakeStorage()); + const mod = await importStoreModule(); + const first = mod.createInputHistoryIdentity('runtime-a', '/repo', 'session-1'); + const second = mod.createInputHistoryIdentity('runtime-a', '/repo', 'session-2'); + if (!first || !second) throw new Error('identity missing'); + + mod.useInputHistoryStore.getState().appendSubmissions(first, [mod.createInputHistorySubmission('first', [])]); + mod.useInputHistoryStore.getState().appendSubmissions(second, [mod.createInputHistorySubmission('second', [])]); + + mod.useInputHistoryStore.getState().applyScope('global'); + expect(mod.selectInputHistoryEntries(mod.useInputHistoryStore.getState(), first).map((entry) => entry.text)).toEqual(['first', 'second']); + mod.useInputHistoryStore.getState().applyScope('session'); + expect(mod.selectInputHistoryEntries(mod.useInputHistoryStore.getState(), first).map((entry) => entry.text)).toEqual(['first']); + }); + + test('clears only the targeted session namespace', async () => { + installWindow(createFakeStorage()); + const mod = await importStoreModule(); + const deleted = mod.createInputHistoryIdentity('runtime-a', '/repo', 'session-1'); + const retained = mod.createInputHistoryIdentity('runtime-a', '/repo', 'session-2'); + if (!deleted || !retained) throw new Error('identity missing'); + + mod.useInputHistoryStore.getState().appendSubmissions(deleted, [mod.createInputHistorySubmission('deleted', [])]); + mod.useInputHistoryStore.getState().appendSubmissions(retained, [mod.createInputHistorySubmission('retained', [])]); + + mod.useInputHistoryStore.getState().clearSession(deleted); + + mod.useInputHistoryStore.getState().applyScope('session'); + expect(mod.selectInputHistoryEntries(mod.useInputHistoryStore.getState(), deleted)).toEqual([]); + expect(mod.selectInputHistoryEntries(mod.useInputHistoryStore.getState(), retained).map((entry) => entry.text)).toEqual(['retained']); + mod.useInputHistoryStore.getState().applyScope('global'); + expect(mod.selectInputHistoryEntries(mod.useInputHistoryStore.getState(), deleted).map((entry) => entry.text)).toEqual([ + 'deleted', + 'retained', + ]); + }); + + test('stale tab append preserves history written by another tab', async () => { + const localStorage = createFakeStorage(); + installWindow(localStorage); + const tabA = await importStoreModule(); + const tabB = await importStoreModule(); + const first = tabA.createInputHistoryIdentity('runtime-a', '/repo', 'session-a'); + const second = tabB.createInputHistoryIdentity('runtime-a', '/repo', 'session-b'); + if (!first || !second) throw new Error('identity missing'); + + tabA.useInputHistoryStore.getState().appendSubmissions(first, [ + tabA.createInputHistorySubmission('from A', []), + ]); + tabB.useInputHistoryStore.getState().appendSubmissions(second, [ + tabB.createInputHistorySubmission('from B', []), + ]); + + const observer = await importStoreModule(); + observer.useInputHistoryStore.getState().applyScope('global'); + expect(observer.selectInputHistoryEntries(observer.useInputHistoryStore.getState(), first).map((entry) => entry.text)).toEqual([ + 'from A', + 'from B', + ]); + observer.useInputHistoryStore.getState().applyScope('session'); + expect(observer.selectInputHistoryEntries(observer.useInputHistoryStore.getState(), first).map((entry) => entry.text)).toEqual(['from A']); + expect(observer.selectInputHistoryEntries(observer.useInputHistoryStore.getState(), second).map((entry) => entry.text)).toEqual(['from B']); + }); + + test('stale tab append uses the newest durable entry limit', async () => { + const localStorage = createFakeStorage(); + installWindow(localStorage); + const tabA = await importStoreModule(); + const tabB = await importStoreModule(); + const identity = tabA.createInputHistoryIdentity('runtime-a', '/repo', 'session-a'); + if (!identity) throw new Error('identity missing'); + + tabB.useInputHistoryStore.getState().applyEntryLimit(2); + tabA.useInputHistoryStore.getState().appendSubmissions(identity, Array.from({ length: 5 }, (_, index) => ( + tabA.createInputHistorySubmission(`entry-${index}`, []) + ))); + + const observer = await importStoreModule(); + expect(observer.useInputHistoryStore.getState().entryLimit).toBe(2); + observer.useInputHistoryStore.getState().applyScope('session'); + expect(observer.selectInputHistoryEntries(observer.useInputHistoryStore.getState(), identity).map((entry) => entry.text)).toEqual([ + 'entry-3', + 'entry-4', + ]); + }); + + test('stale tab scope change preserves newer history buckets', async () => { + const localStorage = createFakeStorage(); + installWindow(localStorage); + const tabA = await importStoreModule(); + const first = tabA.createInputHistoryIdentity('runtime-a', '/repo', 'session-a'); + if (!first) throw new Error('identity missing'); + tabA.useInputHistoryStore.getState().appendSubmissions(first, [ + tabA.createInputHistorySubmission('from A', []), + ]); + + const tabB = await importStoreModule(); + const second = tabB.createInputHistoryIdentity('runtime-a', '/repo', 'session-b'); + if (!second) throw new Error('identity missing'); + tabB.useInputHistoryStore.getState().appendSubmissions(second, [ + tabB.createInputHistorySubmission('from B', []), + ]); + + tabA.useInputHistoryStore.getState().applyScope('session'); + + const observer = await importStoreModule(); + expect(observer.useInputHistoryStore.getState().scope).toBe('session'); + observer.useInputHistoryStore.getState().applyScope('global'); + expect(observer.selectInputHistoryEntries(observer.useInputHistoryStore.getState(), first).map((entry) => entry.text)).toEqual([ + 'from A', + 'from B', + ]); + observer.useInputHistoryStore.getState().applyScope('session'); + expect(observer.selectInputHistoryEntries(observer.useInputHistoryStore.getState(), second).map((entry) => entry.text)).toEqual(['from B']); + }); + + test('stale tab cleanup deletes only its target from newer history', async () => { + const localStorage = createFakeStorage(); + installWindow(localStorage); + const tabA = await importStoreModule(); + const deleted = tabA.createInputHistoryIdentity('runtime-a', '/repo', 'session-a'); + if (!deleted) throw new Error('identity missing'); + tabA.useInputHistoryStore.getState().appendSubmissions(deleted, [ + tabA.createInputHistorySubmission('deleted', []), + ]); + + const tabB = await importStoreModule(); + const retained = tabB.createInputHistoryIdentity('runtime-a', '/repo', 'session-b'); + if (!retained) throw new Error('identity missing'); + tabB.useInputHistoryStore.getState().appendSubmissions(retained, [ + tabB.createInputHistorySubmission('retained', []), + ]); + + tabA.useInputHistoryStore.getState().clearSession(deleted); + + const observer = await importStoreModule(); + observer.useInputHistoryStore.getState().applyScope('session'); + expect(observer.selectInputHistoryEntries(observer.useInputHistoryStore.getState(), deleted)).toEqual([]); + expect(observer.selectInputHistoryEntries(observer.useInputHistoryStore.getState(), retained).map((entry) => entry.text)).toEqual(['retained']); + observer.useInputHistoryStore.getState().applyScope('global'); + expect(observer.selectInputHistoryEntries(observer.useInputHistoryStore.getState(), deleted).map((entry) => entry.text)).toEqual([ + 'deleted', + 'retained', + ]); + }); + + test('quota fallback preserves the configured entry limit while storing fewer entries', async () => { + installWindow(createQuotaStorage(25)); + const mod = await importStoreModule(); + const identity = mod.createInputHistoryIdentity('runtime-a', '/repo', 'session-1'); + if (!identity) throw new Error('identity missing'); + + mod.useInputHistoryStore.getState().applyEntryLimit(100); + mod.useInputHistoryStore.getState().appendSubmissions(identity, Array.from({ length: 30 }, (_, index) => ( + mod.createInputHistorySubmission(`entry-${index}`, []) + ))); + + const observer = await importStoreModule(); + expect(observer.useInputHistoryStore.getState().entryLimit).toBe(100); + expect(observer.selectInputHistoryEntries(observer.useInputHistoryStore.getState(), identity)).toHaveLength(25); + expect(observer.selectInputHistoryEntries(observer.useInputHistoryStore.getState(), identity)[0]?.text).toBe('entry-5'); + }); + test('retains failed appends while reconciling newer writes from another tab', async () => { + const storage = createFakeStorage(); + installWindow(storage); + const tab = await importStoreModule(); + const otherTab = await importStoreModule(); + const identity = { runtimeKey: 'runtime-a', directory: '/repo', sessionId: 'session-a' }; + const append = (text: string) => tab.useInputHistoryStore.getState().appendSubmissions(identity, [tab.createInputHistorySubmission(text, [])]); + append('A'); + const write = storage.setItem; + storage.setItem = () => { throw new Error('write denied'); }; + append('B'); + storage.setItem = write; + otherTab.useInputHistoryStore.getState().appendSubmissions(identity, [otherTab.createInputHistorySubmission('other tab', [])]); + append('C'); + const observer = await importStoreModule(); + expect(observer.selectInputHistoryEntries(observer.useInputHistoryStore.getState(), identity).map((entry) => entry.text).sort()).toEqual(['A', 'B', 'C', 'other tab']); + }); + + test('a failed clear cannot resurrect its bucket after writes recover', async () => { + const storage = createFakeStorage(); + installWindow(storage); + const tab = await importStoreModule(); + const deleted = { runtimeKey: 'runtime-a', directory: '/repo', sessionId: 'deleted' }; + const retained = { ...deleted, sessionId: 'retained' }; + tab.useInputHistoryStore.getState().appendSubmissions(deleted, [tab.createInputHistorySubmission('A', [])]); + const write = storage.setItem; + storage.setItem = () => { throw new Error('write denied'); }; + tab.useInputHistoryStore.getState().clearSession(deleted); + storage.setItem = write; + tab.useInputHistoryStore.getState().appendSubmissions(retained, [tab.createInputHistorySubmission('B', [])]); + const observer = await importStoreModule(); + observer.useInputHistoryStore.getState().applyScope('session'); + expect(observer.selectInputHistoryEntries(observer.useInputHistoryStore.getState(), deleted)).toEqual([]); + expect(observer.selectInputHistoryEntries(observer.useInputHistoryStore.getState(), retained).map((entry) => entry.text)).toEqual(['B']); + }); + +}); diff --git a/packages/ui/src/stores/useInputHistoryStore.ts b/packages/ui/src/stores/useInputHistoryStore.ts new file mode 100644 index 00000000..ebc28e8d --- /dev/null +++ b/packages/ui/src/stores/useInputHistoryStore.ts @@ -0,0 +1,572 @@ +import { create } from 'zustand'; +import { z } from 'zod'; + +import { + DEFAULT_INPUT_HISTORY_LIMIT, + DEFAULT_INPUT_HISTORY_SCOPE, + isInputHistoryLimit, + isInputHistoryScope, + type InputHistoryScope, +} from '@/lib/inputHistoryScope'; +import { normalizePath } from '@/lib/pathNormalization'; +import type { AttachedFile } from '@/stores/types/sessionTypes'; + +export type InputHistoryIdentity = { + runtimeKey: string; + directory: string; + sessionId: string; +}; + +export type InputHistoryAttachment = { + key: string; + source: 'file-url' | 'vscode-file'; + filename: string; + mimeType: string; + size: number; + reference: string; +}; + +export type InputHistorySubmission = { + text: string; + attachmentKeys: string[]; + restorableAttachments: InputHistoryAttachment[]; +}; + +export type InputHistoryEntry = InputHistorySubmission & { + submittedAt: number; +}; + +type InputHistoryNamespace = { + touchedAt: number; + entries: InputHistoryEntry[]; +}; + +type InputHistorySnapshot = { + entryLimit: number; + scope: InputHistoryScope; + globalBuckets: Record; + sessionBuckets: Record; +}; + +type PersistedInputHistoryEnvelope = { + version: 1; + entryLimit?: number; + scope: InputHistoryScope; + global: Record; + session: Record; +}; + +type InputHistoryStoreState = InputHistorySnapshot & { + applyEntryLimit: (limit: number) => void; + applyScope: (scope: InputHistoryScope) => void; + appendSubmissions: (identity: InputHistoryIdentity, submissions: readonly InputHistorySubmission[]) => void; + clearSession: (identity: InputHistoryIdentity) => void; +}; + +const STORAGE_KEY = 'openchamber-input-history.v1'; +const GLOBAL_NAMESPACE_LIMIT = 8; +const SESSION_NAMESPACE_LIMIT = 50; +const QUOTA_RETRY_LIMITS = [40, 25, 10, 5, 1] as const; +const EMPTY_ENTRIES: readonly InputHistoryEntry[] = Object.freeze([]); + +let inMemoryStorageValue: string | null = null; +let touchSequence = 0; + +const createEmptySnapshot = ( + scope: InputHistoryScope = DEFAULT_INPUT_HISTORY_SCOPE, + entryLimit = DEFAULT_INPUT_HISTORY_LIMIT, +): InputHistorySnapshot => ({ + entryLimit, + scope, + globalBuckets: {}, + sessionBuckets: {}, +}); + +const getNextTimestamp = (): number => { + touchSequence += 1; + return Date.now() * 1000 + touchSequence; +}; + +const createGlobalBucketKey = (runtimeKey: string): string => JSON.stringify([runtimeKey]); + +const createSessionBucketKey = (runtimeKey: string, directory: string, sessionId: string): string => ( + JSON.stringify([runtimeKey, directory, sessionId]) +); + +const getDurableStorage = (): Storage | null => { + try { + return globalThis.window?.localStorage ?? null; + } catch { + return null; + } +}; + +const readPersistedValue = (): string | null => { + const storage = getDurableStorage(); + const fallback = pendingWrite ? JSON.stringify(toEnvelope(pendingWrite.base)) : inMemoryStorageValue; + if (!storage) return fallback; + try { + return storage.getItem(STORAGE_KEY) ?? (pendingWrite ? null : inMemoryStorageValue); + } catch { + return fallback; + } +}; + +const rawNamespaceRecordSchema = z.record(z.string(), z.unknown()); + +type RawNamespaceRecord = z.infer; + +const attachmentSchema = z.object({ + key: z.string().min(1), + source: z.union([z.literal('file-url'), z.literal('vscode-file')]), + filename: z.string().min(1), + mimeType: z.string().min(1), + size: z.number().finite().nonnegative(), + reference: z.string().min(1), +}); + +const entrySchema = z.object({ + text: z.string(), + attachmentKeys: z.array(z.string()), + restorableAttachments: z.array(attachmentSchema), + submittedAt: z.number().finite().nonnegative(), +}); + +const namespaceSchema = z.object({ + touchedAt: z.number().finite().nonnegative(), + entries: z.array(z.unknown()), +}); + +const parseBucketKey = (value: string, expectedLength: 1 | 3): string[] | null => { + try { + const parsed = JSON.parse(value); + const keySchema = expectedLength === 1 + ? z.tuple([z.string().trim().min(1)]) + : z.tuple([z.string().trim().min(1), z.string().trim().min(1), z.string().trim().min(1)]); + const result = keySchema.safeParse(parsed); + if (!result.success) return null; + if (expectedLength === 3) { + const directory = normalizePath(result.data[1]); + if (!directory || directory !== result.data[1]) return null; + } + return result.data; + } catch { + return null; + } +}; + +const parseNamespaces = ( + value: RawNamespaceRecord, + expectedKeyLength: 1 | 3, +): Record => { + const parsedEntries: Array<[string, InputHistoryNamespace]> = []; + for (const [key, namespaceValue] of Object.entries(value)) { + if (!parseBucketKey(key, expectedKeyLength)) continue; + const namespaceResult = namespaceSchema.safeParse(namespaceValue); + if (!namespaceResult.success) continue; + const entries = namespaceResult.data.entries + .map((entry) => entrySchema.safeParse(entry)) + .filter((result) => result.success) + .map((result) => result.data); + parsedEntries.push([key, { touchedAt: namespaceResult.data.touchedAt, entries }]); + } + return Object.fromEntries(parsedEntries); +}; + +const readDurableSnapshot = (): InputHistorySnapshot => { + const raw = readPersistedValue(); + if (raw === null) return createEmptySnapshot(); + try { + const parsed = JSON.parse(raw); + const envelopeResult = z.object({ + version: z.literal(1), + entryLimit: z.number().int().optional(), + scope: z.union([z.literal('global'), z.literal('session')]).optional(), + global: rawNamespaceRecordSchema.default({}), + session: rawNamespaceRecordSchema.default({}), + }).safeParse(parsed); + if (!envelopeResult.success) return createEmptySnapshot(); + return { + entryLimit: isInputHistoryLimit(envelopeResult.data.entryLimit) + ? envelopeResult.data.entryLimit + : DEFAULT_INPUT_HISTORY_LIMIT, + scope: envelopeResult.data.scope ?? DEFAULT_INPUT_HISTORY_SCOPE, + globalBuckets: parseNamespaces(envelopeResult.data.global, 1), + sessionBuckets: parseNamespaces(envelopeResult.data.session, 3), + }; + } catch { + const storage = getDurableStorage(); + if (storage) { + try { + storage.removeItem(STORAGE_KEY); + } catch { + // Ignore durable cleanup failures. + } + } + inMemoryStorageValue = null; + return createEmptySnapshot(); + } +}; + +// Failed writes remain local authority, but unrelated writes from another tab +// still participate in the next mutation. Keep bounded snapshots, not a retry log. +let pendingWrite: { base: InputHistorySnapshot; value: InputHistorySnapshot } | null = null; +let lastDurableSnapshot = createEmptySnapshot(); + +const reconcilePendingBuckets = ( + durable: InputHistorySnapshot['sessionBuckets'], + base: InputHistorySnapshot['sessionBuckets'], + local: InputHistorySnapshot['sessionBuckets'], +): InputHistorySnapshot['sessionBuckets'] => { + const result = { ...durable }; + for (const key of new Set([...Object.keys(base), ...Object.keys(local)])) { + if (JSON.stringify(base[key]) === JSON.stringify(local[key])) continue; + const localBucket = local[key]; + if (!localBucket) { + delete result[key]; + continue; + } + const baseEntries = new Set((base[key]?.entries ?? []).map((entry) => JSON.stringify(entry))); + const localEntries = new Set(localBucket.entries.map((entry) => JSON.stringify(entry))); + const entries = (durable[key]?.entries ?? []).filter((entry) => { + const identity = JSON.stringify(entry); + return !baseEntries.has(identity) || localEntries.has(identity); + }); + const present = new Set(entries.map((entry) => JSON.stringify(entry))); + for (const entry of localBucket.entries) { + const identity = JSON.stringify(entry); + if (!baseEntries.has(identity) && !present.has(identity)) entries.push(entry); + } + entries.sort((left, right) => left.submittedAt - right.submittedAt); + result[key] = { + touchedAt: Math.max(localBucket.touchedAt, durable[key]?.touchedAt ?? 0), + entries, + }; + } + return result; +}; + +const readSnapshot = (): InputHistorySnapshot => { + const durable = readDurableSnapshot(); + lastDurableSnapshot = durable; + if (!pendingWrite) return durable; + const { base, value } = pendingWrite; + return normalizeSnapshotLimits({ + entryLimit: value.entryLimit === base.entryLimit ? durable.entryLimit : value.entryLimit, + scope: value.scope === base.scope ? durable.scope : value.scope, + globalBuckets: reconcilePendingBuckets(durable.globalBuckets, base.globalBuckets, value.globalBuckets), + sessionBuckets: reconcilePendingBuckets(durable.sessionBuckets, base.sessionBuckets, value.sessionBuckets), + }); +}; + +const cloneEntriesWithLimit = (entries: readonly InputHistoryEntry[], limit: number): InputHistoryEntry[] => ( + entries.slice(Math.max(0, entries.length - limit)) +); + +const limitNamespaces = ( + buckets: Record, + namespaceLimit: number, + entryLimit: number, +): Record => { + const ranked = Object.entries(buckets) + .map(([key, namespace]) => [ + key, + { + touchedAt: namespace.touchedAt, + entries: cloneEntriesWithLimit(namespace.entries, entryLimit), + }, + ] as const) + .sort((left, right) => right[1].touchedAt - left[1].touchedAt) + .slice(0, namespaceLimit); + return Object.fromEntries(ranked); +}; + +const normalizeSnapshotLimits = ( + snapshot: InputHistorySnapshot, + entryLimit = snapshot.entryLimit, +): InputHistorySnapshot => ({ + entryLimit: snapshot.entryLimit, + scope: snapshot.scope, + globalBuckets: limitNamespaces(snapshot.globalBuckets, GLOBAL_NAMESPACE_LIMIT, entryLimit), + sessionBuckets: limitNamespaces(snapshot.sessionBuckets, SESSION_NAMESPACE_LIMIT, entryLimit), +}); + +const toEnvelope = (snapshot: InputHistorySnapshot): PersistedInputHistoryEnvelope => ({ + version: 1, + entryLimit: snapshot.entryLimit, + scope: snapshot.scope, + global: snapshot.globalBuckets, + session: snapshot.sessionBuckets, +}); + +const isQuotaError = (error: Error | DOMException | null | undefined): boolean => { + if (error instanceof DOMException) return error.name === 'QuotaExceededError'; + return error instanceof Error && /quota/i.test(error.message); +}; + +const writeSnapshot = (snapshot: InputHistorySnapshot): InputHistorySnapshot => { + const normalized = normalizeSnapshotLimits(snapshot); + const serialized = JSON.stringify(toEnvelope(normalized)); + pendingWrite = { base: lastDurableSnapshot, value: normalized }; + const storage = getDurableStorage(); + + if (!storage) { + inMemoryStorageValue = serialized; + return normalized; + } + + try { + storage.setItem(STORAGE_KEY, serialized); + pendingWrite = null; + inMemoryStorageValue = serialized; + return normalized; + } catch (error) { + if (!(error instanceof Error) || !isQuotaError(error)) { + inMemoryStorageValue = serialized; + return normalized; + } + } + + try { + storage.removeItem(STORAGE_KEY); + } catch { + // Ignore stale durable cleanup failures. + } + + for (const entryLimit of QUOTA_RETRY_LIMITS) { + const candidate = normalizeSnapshotLimits(normalized, entryLimit); + const candidateSerialized = JSON.stringify(toEnvelope(candidate)); + try { + storage.setItem(STORAGE_KEY, candidateSerialized); + pendingWrite = null; + inMemoryStorageValue = candidateSerialized; + return candidate; + } catch (error) { + if (!(error instanceof Error) || !isQuotaError(error)) { + pendingWrite = { base: lastDurableSnapshot, value: candidate }; + inMemoryStorageValue = candidateSerialized; + return candidate; + } + } + } + + const emptySnapshot = createEmptySnapshot(normalized.scope, normalized.entryLimit); + const emptySerialized = JSON.stringify(toEnvelope(emptySnapshot)); + try { + storage.setItem(STORAGE_KEY, emptySerialized); + pendingWrite = null; + } catch { + pendingWrite = { base: lastDurableSnapshot, value: emptySnapshot }; + // The in-memory copy remains the only fallback. + } + inMemoryStorageValue = emptySerialized; + return emptySnapshot; +}; + +export const createInputHistoryIdentity = ( + runtimeKey: string, + directory: string, + sessionId: string, +): InputHistoryIdentity | null => { + const normalizedRuntimeKey = runtimeKey.trim(); + const normalizedDirectory = normalizePath(directory); + const normalizedSessionId = sessionId.trim(); + if (!normalizedRuntimeKey || !normalizedDirectory || !normalizedSessionId) return null; + return { + runtimeKey: normalizedRuntimeKey, + directory: normalizedDirectory, + sessionId: normalizedSessionId, + }; +}; + +const getAttachmentReference = (attachment: AttachedFile): string | null => { + if (attachment.source === 'vscode' && attachment.vscodeSource === 'file') { + const normalizedPath = normalizePath(attachment.vscodePath ?? null); + return normalizedPath; + } + const candidate = attachment.dataUrl.trim(); + return candidate || null; +}; + +const canRestoreReference = (reference: string): boolean => { + if (!reference || reference.startsWith('data:')) return false; + if ((reference.startsWith('http://') || reference.startsWith('https://')) && reference.includes('?')) { + return false; + } + return true; +}; + +const buildAttachmentKey = (attachment: AttachedFile): string => { + const reference = getAttachmentReference(attachment); + const normalizedReference = reference === null + ? 'none' + : reference.startsWith('data:') + ? 'data' + : reference.slice(0, 512); + return [attachment.source, attachment.filename, attachment.mimeType, String(attachment.size), normalizedReference].join('|'); +}; + +const toRestorableAttachment = (attachment: AttachedFile): InputHistoryAttachment | null => { + const reference = getAttachmentReference(attachment); + if (!reference || !canRestoreReference(reference)) return null; + if (attachment.source === 'vscode' && attachment.vscodeSource === 'file') { + return { + key: buildAttachmentKey(attachment), + source: 'vscode-file', + filename: attachment.filename, + mimeType: attachment.mimeType, + size: attachment.size, + reference, + }; + } + return { + key: buildAttachmentKey(attachment), + source: 'file-url', + filename: attachment.filename, + mimeType: attachment.mimeType, + size: attachment.size, + reference, + }; +}; + +export const createInputHistorySubmission = ( + text: string, + attachments: readonly AttachedFile[], +): InputHistorySubmission => ({ + text, + attachmentKeys: attachments.map(buildAttachmentKey), + restorableAttachments: attachments + .map(toRestorableAttachment) + .filter((attachment): attachment is InputHistoryAttachment => attachment !== null), +}); + +const areAttachmentsEqual = ( + left: readonly InputHistoryAttachment[], + right: readonly InputHistoryAttachment[], +): boolean => ( + left.length === right.length + && left.every((attachment, index) => { + const candidate = right[index]; + return candidate !== undefined + && attachment.key === candidate.key + && attachment.source === candidate.source + && attachment.filename === candidate.filename + && attachment.mimeType === candidate.mimeType + && attachment.size === candidate.size + && attachment.reference === candidate.reference; + }) +); + +const isDuplicateSubmission = (entry: InputHistoryEntry | undefined, submission: InputHistorySubmission): boolean => ( + entry !== undefined + && entry.text === submission.text + && entry.attachmentKeys.length === submission.attachmentKeys.length + && entry.attachmentKeys.every((key, index) => key === submission.attachmentKeys[index]) + && areAttachmentsEqual(entry.restorableAttachments, submission.restorableAttachments) +); + +const appendToNamespace = ( + namespace: InputHistoryNamespace | undefined, + submissions: readonly InputHistorySubmission[], + touchedAt: number, + entryLimit: number, +): InputHistoryNamespace => { + const entries = namespace ? [...namespace.entries] : []; + for (const submission of submissions) { + const previous = entries.at(-1); + if (isDuplicateSubmission(previous, submission)) continue; + entries.push({ + text: submission.text, + attachmentKeys: [...submission.attachmentKeys], + restorableAttachments: submission.restorableAttachments.map((attachment) => ({ ...attachment })), + submittedAt: getNextTimestamp(), + }); + } + return { + touchedAt, + entries: cloneEntriesWithLimit(entries, entryLimit), + }; +}; + +const initialSnapshot = writeSnapshot(readSnapshot()); + +export const useInputHistoryStore = create((set) => ({ + ...initialSnapshot, + applyEntryLimit: (limit) => { + if (!isInputHistoryLimit(limit)) return; + set((state) => { + const latest = readSnapshot(); + if (latest.entryLimit === limit) return { ...state, ...latest }; + const nextSnapshot = writeSnapshot({ + entryLimit: limit, + scope: latest.scope, + globalBuckets: latest.globalBuckets, + sessionBuckets: latest.sessionBuckets, + }); + return { ...state, ...nextSnapshot }; + }); + }, + applyScope: (scope) => { + if (!isInputHistoryScope(scope)) return; + set((state) => { + const latest = readSnapshot(); + if (latest.scope === scope) return { ...state, ...latest }; + const nextSnapshot = writeSnapshot({ + entryLimit: latest.entryLimit, + scope, + globalBuckets: latest.globalBuckets, + sessionBuckets: latest.sessionBuckets, + }); + return { ...state, ...nextSnapshot }; + }); + }, + appendSubmissions: (identity, submissions) => { + if (submissions.length === 0) return; + set((state) => { + const latest = readSnapshot(); + const touchedAt = getNextTimestamp(); + const globalKey = createGlobalBucketKey(identity.runtimeKey); + const sessionKey = createSessionBucketKey(identity.runtimeKey, identity.directory, identity.sessionId); + const nextSnapshot = writeSnapshot({ + entryLimit: latest.entryLimit, + scope: latest.scope, + globalBuckets: { + ...latest.globalBuckets, + [globalKey]: appendToNamespace(latest.globalBuckets[globalKey], submissions, touchedAt, latest.entryLimit), + }, + sessionBuckets: { + ...latest.sessionBuckets, + [sessionKey]: appendToNamespace(latest.sessionBuckets[sessionKey], submissions, touchedAt, latest.entryLimit), + }, + }); + return { ...state, ...nextSnapshot }; + }); + }, + clearSession: (identity) => { + set((state) => { + const latest = readSnapshot(); + const sessionKey = createSessionBucketKey(identity.runtimeKey, identity.directory, identity.sessionId); + if (!(sessionKey in latest.sessionBuckets)) return { ...state, ...latest }; + const sessionBuckets = { ...latest.sessionBuckets }; + delete sessionBuckets[sessionKey]; + const nextSnapshot = writeSnapshot({ + entryLimit: latest.entryLimit, + scope: latest.scope, + globalBuckets: latest.globalBuckets, + sessionBuckets, + }); + return { ...state, ...nextSnapshot }; + }); + }, +})); + +export const selectInputHistoryEntries = ( + state: Pick, + identity: InputHistoryIdentity | null, +): readonly InputHistoryEntry[] => { + if (!identity) return EMPTY_ENTRIES; + if (state.scope === 'session') { + return state.sessionBuckets[createSessionBucketKey(identity.runtimeKey, identity.directory, identity.sessionId)]?.entries ?? EMPTY_ENTRIES; + } + return state.globalBuckets[createGlobalBucketKey(identity.runtimeKey)]?.entries ?? EMPTY_ENTRIES; +}; diff --git a/packages/ui/src/sync/DOCUMENTATION.md b/packages/ui/src/sync/DOCUMENTATION.md index b2465c7d..a70591cf 100644 --- a/packages/ui/src/sync/DOCUMENTATION.md +++ b/packages/ui/src/sync/DOCUMENTATION.md @@ -108,7 +108,7 @@ recover its referenced parent instead of exposing an orphan-only snapshot. Session message loads use runtime, normalized directory, session ID, SDK epoch, and loader generation as commit authority. Eviction, archive, delete, move, directory disposal, and runtime switching invalidate the applicable loader generation before stale in-flight work can publish. A move invalidates both source and destination loader targets. -An authoritative `session.deleted` event also clears persisted UI state before routing metadata can be removed. Confirmed local deletion and accepted `404` deletion do the same directly instead of depending on the event echo. Cleanup is identity-owned by runtime, normalized directory, and session ID: queued messages, persisted todos, composer drafts, inline-comment drafts, and pins clear only that tuple, while the active runtime's folder store removes the session from every active or archived folder scope. Stale-runtime events and unresolved/global directory identities do not mutate persisted state. +An authoritative `session.deleted` event also clears persisted UI state before routing metadata can be removed. Confirmed local deletion and accepted `404` deletion do the same directly instead of depending on the event echo. Cleanup is identity-owned by runtime, normalized directory, and session ID: queued messages, persisted todos, composer drafts, per-session input-history buckets, inline-comment drafts, and pins clear only that tuple, while the active runtime's folder store removes the session from every active or archived folder scope. Stale-runtime events and unresolved/global directory identities do not mutate persisted state. Persisted sidebar state is never reconciled destructively from the first successful startup list. That list establishes an authoritative active+archived baseline. Only a session present in that baseline and omitted from a later complete snapshot is treated as a missed external deletion. Archive and directory moves retain the session ID across snapshots and are not deletion cleanup. This favors harmless hidden stale metadata over irreversible user-state loss when startup data is incomplete. @@ -198,7 +198,8 @@ Rules: 7. Pagination demand must carry the selected session's effective directory. It must not fall back to the sync provider directory because the visible session may belong to another worktree. 8. The ref-stable loader is disposed only after the current task when its provider unmounts. This lets React Strict Mode's development setup → cleanup → setup probe retain a usable loader for child effects, while real disposal still invalidates the preceding lifecycle's work. 9. Transcript arrays are chronological by `message.time.created`, with message ID used only as a deterministic equal-time tie-breaker. Message IDs are identity and reconciliation keys, not chronology: OpenCode's fixed-width sortable timestamp prefix rolls over, so a newer `msg_000...` can follow an older `msg_fff...`. Fetch, pagination, materialization, optimistic insertion, events, reconnect inspection, rendering, and revert/undo/redo must preserve this contract. -10. Part arrays preserve authoritative response/event order. Part IDs are identity keys and have the same rollover limitation; identity lookup/removal must not require a part array to be lexically ID-sorted. +10. Transcript visibility and revert markers do not own prompt recall. The sync layer may hide reverted user messages from the visible transcript, but ArrowUp and ArrowDown recall come from the persisted input-history store, scoped by runtime and by runtime + normalized directory + session identity. +11. Part arrays preserve authoritative response/event order. Part IDs are identity keys and have the same rollover limitation; identity lookup/removal must not require a part array to be lexically ID-sorted. Initial loads use smaller pages on constrained VS Code/mobile surfaces. Prefetch resolves only the initial renderable page; it does not eagerly download older history. The mounted chat timeline requests older pages when its viewport is underfilled or the user scrolls toward history, while mobile uses its explicit load-older action. Timeline caches, pending work, prepend snapshots, and stale checks use runtime + directory + session identity so equal session IDs in different worktrees cannot share lifecycle state. Older pages are fetched through the same loader and merged with optimistic records before publication. The same chronology contract applies in the VS Code webview because it consumes this shared loader and sync store; the extension bridge must transport OpenCode records without introducing its own ID-based ordering. diff --git a/packages/ui/src/sync/performance-diagnostics.ts b/packages/ui/src/sync/performance-diagnostics.ts index 095eba3c..90518c3e 100644 --- a/packages/ui/src/sync/performance-diagnostics.ts +++ b/packages/ui/src/sync/performance-diagnostics.ts @@ -36,7 +36,6 @@ export type SyncPerformanceCounters = { questionChangeCallbacks: number sessionMessageChangeCallbacks: number sessionRenderableNotificationSkips: number - userMessageHistoryNotificationSkips: number sessionMessageRecordNotificationSkips: number materializationEnqueues: number materializationEmptyAssistantEnqueues: number @@ -79,7 +78,6 @@ const createCounters = (): SyncPerformanceCounters => ({ questionChangeCallbacks: 0, sessionMessageChangeCallbacks: 0, sessionRenderableNotificationSkips: 0, - userMessageHistoryNotificationSkips: 0, sessionMessageRecordNotificationSkips: 0, materializationEnqueues: 0, materializationEmptyAssistantEnqueues: 0, diff --git a/packages/ui/src/sync/session-actions.test.ts b/packages/ui/src/sync/session-actions.test.ts index ce5215f2..6acf1619 100644 --- a/packages/ui/src/sync/session-actions.test.ts +++ b/packages/ui/src/sync/session-actions.test.ts @@ -1665,6 +1665,138 @@ describe("optimisticSend target directory", () => { expect(targetStore.getState().part[revertedMessage.id]).toEqual([revertedPart]) }) + test("runs appendSubmissions before revert cleanup and optimistic insertion", async () => { + const revertedMessage = { id: "msg_000000000000Reverted", role: "user", sessionID: "session-reverted", time: { created: 2 } } as Message + const targetStore = createStore({}, { + session: [{ id: "session-reverted", revert: { messageID: revertedMessage.id } } as Session], + message: { "session-reverted": [revertedMessage] }, + part: { [revertedMessage.id]: [{ id: "part_2", type: "text", text: "old branch" } as Part] }, + }) + const childStores = createChildStores([["/target/project", targetStore]]) + const callOrder: string[] = [] + + const { optimisticSend, setActionRefs, setOptimisticRefs } = await import("./session-actions") + setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/target/project") + setOptimisticRefs( + () => { + callOrder.push("optimistic-add") + }, + () => {}, + () => { + callOrder.push("revert-confirm") + }, + ) + + await optimisticSend({ + sessionId: "session-reverted", + directory: "/target/project", + content: "new branch", + providerID: "provider", + modelID: "model", + appendSubmissions: () => { + callOrder.push("append") + }, + send: async () => {}, + }) + + expect(callOrder).toEqual(["append", "revert-confirm", "optimistic-add"]) + }) + + test("runs appendSubmissions once for a definite rejection", async () => { + const targetStore = createStore({}) + const childStores = createChildStores([["/target/project", targetStore]]) + let appendCalls = 0 + + const { optimisticSend, setActionRefs, setOptimisticRefs } = await import("./session-actions") + setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/target/project") + setOptimisticRefs( + () => {}, + () => {}, + ) + + await expect(optimisticSend({ + sessionId: "session-rejected", + directory: "/target/project", + content: "hello", + providerID: "provider", + modelID: "model", + appendSubmissions: () => { + appendCalls += 1 + }, + send: async () => { throw new Error("rejected") }, + })).rejects.toThrow("rejected") + + expect(appendCalls).toBe(1) + }) + + test("runs appendSubmissions once for an ambiguous confirmation", async () => { + const targetStore = createStore({}) + const childStores = createChildStores([["/target/project", targetStore]]) + let appendCalls = 0 + + const { optimisticSend, setActionRefs, setOptimisticRefs } = await import("./session-actions") + setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/target/project") + setOptimisticRefs( + () => {}, + () => {}, + () => {}, + ) + + await optimisticSend({ + sessionId: "session-confirmed", + directory: "/target/project", + content: "hello", + providerID: "provider", + modelID: "model", + appendSubmissions: () => { + appendCalls += 1 + }, + send: async (messageID) => { + sessionMessagesResult = { + data: [{ + info: { id: messageID, role: "user", sessionID: "session-confirmed", time: { created: 1 } } as Message, + parts: [{ id: "server-part", type: "text", text: "hello" } as Part], + }], + } + const error = new Error("Failed to send message (504): gateway timeout") as Error & { status?: number } + error.status = 504 + throw error + }, + }) + + expect(appendCalls).toBe(1) + }) + + test("does not run appendSubmissions when the runtime changes before dispatch", async () => { + const targetStore = createStore({}) + const childStores = createChildStores([["/target/project", targetStore]]) + let appendCalls = 0 + const { switchRuntimeEndpoint } = await import("../lib/runtime-switch") + switchRuntimeEndpoint({ apiBaseUrl: "http://runtime-b.test", runtimeKey: "runtime-b" }) + + const { optimisticSend, setActionRefs, setOptimisticRefs } = await import("./session-actions") + setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/target/project") + setOptimisticRefs( + () => {}, + () => {}, + ) + + await expect(optimisticSend({ + sessionId: "session-race", + directory: "/target/project", + runtimeKey: "runtime-a", + content: "hello", + providerID: "provider", + modelID: "model", + appendSubmissions: () => { + appendCalls += 1 + }, + send: async () => {}, + })).rejects.toThrow("runtime changed") + + expect(appendCalls).toBe(0) + }) + test("rolls back a captured send when the runtime changes after optimistic insert", async () => { const targetStore = createStore({}) const childStores = createChildStores([["/target/project", targetStore]]) diff --git a/packages/ui/src/sync/session-actions.ts b/packages/ui/src/sync/session-actions.ts index 768ba6a5..47a7c9fb 100644 --- a/packages/ui/src/sync/session-actions.ts +++ b/packages/ui/src/sync/session-actions.ts @@ -1737,6 +1737,7 @@ export async function optimisticSend(input: { agent?: string directory?: string | null files?: Array<{ type: "file"; mime: string; url: string; filename: string }> + appendSubmissions?: () => void onOptimisticInsert?: () => void onMessageID?: (messageID: string) => void beforeOptimisticInsert?: () => void @@ -1760,6 +1761,7 @@ export async function optimisticSend(input: { await waitForConnectionOrThrow() input.beforeOptimisticInsert?.() assertRuntimeUnchanged() + input.appendSubmissions?.() const targetDirectory = input.directory ?? dir() const store = targetDirectory ? dirStoreForDirectory(targetDirectory) : dirStore() diff --git a/packages/ui/src/sync/session-deletion-cleanup.test.ts b/packages/ui/src/sync/session-deletion-cleanup.test.ts index 6c10c31b..4e1df654 100644 --- a/packages/ui/src/sync/session-deletion-cleanup.test.ts +++ b/packages/ui/src/sync/session-deletion-cleanup.test.ts @@ -4,6 +4,7 @@ import type { Todo } from '@opencode-ai/sdk/v2/client'; import { getRuntimeKey } from '@/lib/runtime-switch'; import { createChatDraftIdentity, readChatDraft, writeChatDraft } from '@/lib/chatDraftPersistence'; import { createMessageQueueTarget, getMessageQueueKey, useMessageQueueStore } from '@/stores/messageQueueStore'; +import { createInputHistoryIdentity, createInputHistorySubmission, selectInputHistoryEntries, useInputHistoryStore } from '@/stores/useInputHistoryStore'; import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore'; import { useTodosPersistStore } from '@/stores/useTodosPersistStore'; import { useInlineCommentDraftStore } from '@/stores/useInlineCommentDraftStore'; @@ -19,6 +20,7 @@ describe('cleanupPersistedSessionState', () => { useInlineCommentDraftStore.setState({ drafts: {}, touchedAt: {} }); useSessionPinnedStore.setState({ ids: new Set(), touchedAt: {} }); useSessionFoldersStore.setState({ foldersMap: {}, collapsedFolderIds: new Set() }); + useInputHistoryStore.setState({ globalBuckets: {}, sessionBuckets: {}, scope: 'session' }); }); test('clears queue and todos only for the deleted composite session', () => { @@ -81,4 +83,22 @@ describe('cleanupPersistedSessionState', () => { expect(useTodosPersistStore.getState().getSessionTodos('/repo', 'session-1')).toEqual([todo]); }); + + test('removes only the deleted session input-history bucket', () => { + const runtimeKey = getRuntimeKey(); + const deleted = createInputHistoryIdentity(runtimeKey, '/repo', 'session-1'); + const retained = createInputHistoryIdentity(runtimeKey, '/repo', 'session-2'); + if (!deleted || !retained) throw new Error('identity missing'); + + useInputHistoryStore.getState().appendSubmissions(deleted, [createInputHistorySubmission('deleted', [])]); + useInputHistoryStore.getState().appendSubmissions(retained, [createInputHistorySubmission('retained', [])]); + + cleanupPersistedSessionState({ runtimeKey, directory: '/repo', sessionId: 'session-1' }); + + useInputHistoryStore.getState().applyScope('session'); + expect(selectInputHistoryEntries(useInputHistoryStore.getState(), deleted)).toEqual([]); + expect(selectInputHistoryEntries(useInputHistoryStore.getState(), retained).map((entry) => entry.text)).toEqual(['retained']); + useInputHistoryStore.getState().applyScope('global'); + expect(selectInputHistoryEntries(useInputHistoryStore.getState(), deleted).map((entry) => entry.text)).toEqual(['deleted', 'retained']); + }); }); diff --git a/packages/ui/src/sync/session-deletion-cleanup.ts b/packages/ui/src/sync/session-deletion-cleanup.ts index 7460c621..56e30545 100644 --- a/packages/ui/src/sync/session-deletion-cleanup.ts +++ b/packages/ui/src/sync/session-deletion-cleanup.ts @@ -1,6 +1,7 @@ import { getRuntimeKey } from '@/lib/runtime-switch'; import { clearChatDraft, createChatDraftIdentity } from '@/lib/chatDraftPersistence'; import { createMessageQueueTarget, isServerOwnedMessageQueue, useMessageQueueStore } from '@/stores/messageQueueStore'; +import { createInputHistoryIdentity, useInputHistoryStore } from '@/stores/useInputHistoryStore'; import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore'; import { useTodosPersistStore } from '@/stores/useTodosPersistStore'; import { useInlineCommentDraftStore } from '@/stores/useInlineCommentDraftStore'; @@ -24,6 +25,8 @@ export const cleanupPersistedSessionState = (identity: { useSessionFoldersStore.getState().removeSessionEverywhere(identity.runtimeKey, identity.sessionId); useInlineCommentDraftStore.getState().clearSessionDrafts(identity.runtimeKey, identity.directory, identity.sessionId); useSessionPinnedStore.getState().clearPinnedSession(identity.runtimeKey, identity.directory, identity.sessionId); + const inputHistoryIdentity = createInputHistoryIdentity(identity.runtimeKey, identity.directory, identity.sessionId); + if (inputHistoryIdentity) useInputHistoryStore.getState().clearSession(inputHistoryIdentity); const chatDraftIdentity = createChatDraftIdentity(identity.runtimeKey, identity.directory, identity.sessionId); if (chatDraftIdentity) clearChatDraft(chatDraftIdentity, true); }; diff --git a/packages/ui/src/sync/session-ui-store.ts b/packages/ui/src/sync/session-ui-store.ts index 4c3dd05b..b40c4e6a 100644 --- a/packages/ui/src/sync/session-ui-store.ts +++ b/packages/ui/src/sync/session-ui-store.ts @@ -91,9 +91,22 @@ import { clearLastActiveSession, persistLastActiveSession, readLastActiveSession import { persistWorktreeTopology, readPersistedWorktreeTopology } from "./worktree-topology-cache" import { rememberRuntimeLiveStatus } from "./runtime-live-memory" import { contextTokensFromBreakdown } from "@/stores/utils/tokenUtils" +import { + createInputHistoryIdentity, + useInputHistoryStore, + type InputHistorySubmission, +} from '@/stores/useInputHistoryStore' export type { AttachedFile } +function appendInputHistorySubmissions( + identity: ReturnType, + submissions: readonly InputHistorySubmission[], +): void { + if (!identity || submissions.length === 0) return + useInputHistoryStore.getState().appendSubmissions(identity, submissions) +} + type GoalCommand = { name: string; template?: string } export function expandSlashCommandGoalObjective(content: string, commands: GoalCommand[]): string { @@ -139,6 +152,7 @@ export async function routeMessage(params: { inputMode?: "normal" | "shell" files?: Array<{ type: "file"; mime: string; url: string; filename: string }> additionalParts?: Array<{ text: string; synthetic?: boolean; metadata?: ContextPartMetadata; files?: Array<{ type: "file"; mime: string; url: string; filename: string }>; systemContext?: 'session-knowledge' }> + appendSubmissions?: () => void delivery?: 'steer' }): Promise<'command' | 'prompt' | 'shell'> { const requestDirectory = params.directory ?? undefined @@ -190,6 +204,7 @@ export async function routeMessage(params: { agent: params.agent, directory: requestDirectory, files: params.files, + appendSubmissions: params.appendSubmissions, send: (messageID) => opencodeClient.sendCommand({ runtimeKey: params.runtimeKey, id: params.sessionId, @@ -235,6 +250,7 @@ export async function routeMessage(params: { agent: params.agent, directory: requestDirectory, files: params.files, + appendSubmissions: params.appendSubmissions, send: (messageID) => opencodeClient.sendMessage({ runtimeKey: params.runtimeKey, id: params.sessionId, @@ -269,6 +285,7 @@ type SendMessageOptions = { target?: CapturedSendTarget sessionId?: string directory?: string + historySubmissions?: InputHistorySubmission[] /** Immutable copy of the new-session draft at submit time; used instead of the live draft. */ draftSnapshot?: NewSessionDraftState delivery?: 'steer' @@ -1622,6 +1639,7 @@ export const useSessionUIStore = create()((set, get) => ({ options?: SendMessageOptions, ) => { const capturedTarget = options?.target + const capturedRuntimeKey = capturedTarget?.runtimeKey ?? getRuntimeKey() if (capturedTarget && capturedTarget.runtimeKey !== getRuntimeKey()) { throw new Error("Message was not sent because the runtime changed.") } @@ -1711,6 +1729,14 @@ export const useSessionUIStore = create()((set, get) => ({ const mergedAdditionalParts = draftPrefixParts.length > 0 ? [...draftPrefixParts, ...(draftParts || [])] : draftParts + const historyIdentity = createInputHistoryIdentity( + capturedRuntimeKey, + createdDraftSession.directory ?? '', + createdDraftSession.sessionId, + ) + const appendSubmissions = historyIdentity && options?.historySubmissions?.length + ? () => appendInputHistorySubmissions(historyIdentity, options.historySubmissions ?? []) + : undefined notifyMessageSent(createdDraftSession.sessionId) @@ -1735,6 +1761,7 @@ export const useSessionUIStore = create()((set, get) => ({ variant, inputMode, files, + appendSubmissions, delivery: options?.delivery, additionalParts: mergedAdditionalParts?.map((p) => ({ text: p.text, @@ -1833,6 +1860,14 @@ export const useSessionUIStore = create()((set, get) => ({ const partsWithPinnedContext = prefixParts.length > 0 ? [...prefixParts, ...(additionalParts || [])] : additionalParts + const currentHistoryIdentity = createInputHistoryIdentity( + capturedRuntimeKey, + currentSessionDirectory ?? '', + targetSessionId || '', + ) + const appendSubmissions = currentHistoryIdentity && options?.historySubmissions?.length + ? () => appendInputHistorySubmissions(currentHistoryIdentity, options.historySubmissions ?? []) + : undefined const messageRoute = await routeMessage({ runtimeKey: capturedTarget?.runtimeKey, @@ -1846,6 +1881,7 @@ export const useSessionUIStore = create()((set, get) => ({ variant, inputMode, files, + appendSubmissions, delivery: options?.delivery, additionalParts: partsWithPinnedContext?.map((p) => ({ text: p.text, diff --git a/packages/ui/src/sync/sync-context.tsx b/packages/ui/src/sync/sync-context.tsx index 2ce170eb..fb4f7ad2 100644 --- a/packages/ui/src/sync/sync-context.tsx +++ b/packages/ui/src/sync/sync-context.tsx @@ -86,7 +86,6 @@ import { formatMessage, useI18nStore } from "@/lib/i18n" import { sessionEvents } from "@/lib/sessionEvents" import { listGlobalSessionPages } from "@/stores/globalSessions" import { areRequestArraysReferentiallyEqual, collectScopedBlockingRequests } from "./scoped-blocking-requests" -import { EMPTY_USER_MESSAGE_HISTORY_SNAPSHOT, buildUserMessageHistorySnapshot, type UserMessageHistorySnapshot } from "./user-message-history" import { EMPTY_SESSION_MESSAGE_LOAD_STATE, SessionMessageLoader, @@ -3433,46 +3432,6 @@ export function useSessionRenderable(sessionID: string, directory?: string): boo return React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot) } -export function useUserMessageHistory(sessionID: string, directory?: string): string[] { - const store = useDirectoryStore(directory) - const snapshotRef = useRef(EMPTY_USER_MESSAGE_HISTORY_SNAPSHOT) - - const getSnapshot = useCallback(() => { - const next = buildUserMessageHistorySnapshot(store.getState(), sessionID, snapshotRef.current) - snapshotRef.current = next - return next.history - }, [sessionID, store]) - - const subscribe = useCallback((notify: () => void) => { - if (!sessionID) return () => undefined - const unsubscribeMessages = subscribeDirectorySessionMessages(store, sessionID, (change) => { - if (!change.messagesChanged && !change.reset && change.partMessageIDs.length > 0) { - const records = snapshotRef.current.sessionID === sessionID ? snapshotRef.current.records : [] - const affectsUserHistory = change.partMessageIDs.some((messageID) => ( - records.some((record) => record.message.id === messageID) - )) - if (!affectsUserHistory) { - countSyncPerformance("userMessageHistoryNotificationSkips") - return - } - } - notify() - }) - const unsubscribeSession = store.subscribe((state, previous) => { - if (state.session === previous.session) return - const currentRevert = state.session.find((session) => session.id === sessionID)?.revert?.messageID - const previousRevert = previous.session.find((session) => session.id === sessionID)?.revert?.messageID - if (currentRevert !== previousRevert) notify() - }) - return () => { - unsubscribeMessages() - unsubscribeSession() - } - }, [sessionID, store]) - - return React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot) -} - /** * Get messages for a session in the old {info, parts}[] format. * Uses visible messages (filtered by revert state). diff --git a/packages/ui/src/sync/user-message-history.test.ts b/packages/ui/src/sync/user-message-history.test.ts deleted file mode 100644 index 61778930..00000000 --- a/packages/ui/src/sync/user-message-history.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { describe, expect, test } from 'bun:test'; -import type { Message, Part } from '@opencode-ai/sdk/v2/client'; -import type { State } from './types'; - -import { EMPTY_USER_MESSAGE_HISTORY_SNAPSHOT, buildUserMessageHistorySnapshot } from './user-message-history'; - -const message = (id: string, role: 'user' | 'assistant'): Message => ({ - id, - role, - sessionID: 'ses_1', - time: { created: 1 }, -} as Message); - -const textPart = (id: string, text: string): Part => ({ - id, - type: 'text', - text, -} as Part); - -const state = (partial: Partial): Pick => ({ - session: [], - message: {}, - part: {}, - ...partial, -}); - -describe('buildUserMessageHistorySnapshot', () => { - test('returns a shared empty snapshot without a session id', () => { - expect(buildUserMessageHistorySnapshot(state({}), '')).toBe(EMPTY_USER_MESSAGE_HISTORY_SNAPSHOT); - }); - - test('keeps history stable when assistant parts change', () => { - const user = message('user_1', 'user'); - const assistant = message('assistant_1', 'assistant'); - const userParts = [textPart('part_user', 'hello')]; - const first = buildUserMessageHistorySnapshot( - state({ - message: { ses_1: [user, assistant] }, - part: { user_1: userParts, assistant_1: [textPart('part_a', 'stream')] }, - }), - 'ses_1', - ); - - const second = buildUserMessageHistorySnapshot( - state({ - message: { ses_1: [user, assistant] }, - part: { user_1: userParts, assistant_1: [textPart('part_a2', 'streaming')] }, - }), - 'ses_1', - first, - ); - - expect(second).toBe(first); - expect(second.history).toEqual(['hello']); - }); - - test('updates history when a user part changes', () => { - const user = message('user_1', 'user'); - const first = buildUserMessageHistorySnapshot( - state({ - message: { ses_1: [user] }, - part: { user_1: [textPart('part_user', 'hello')] }, - }), - 'ses_1', - ); - - const second = buildUserMessageHistorySnapshot( - state({ - message: { ses_1: [user] }, - part: { user_1: [textPart('part_user_updated', 'updated')] }, - }), - 'ses_1', - first, - ); - - expect(second).not.toBe(first); - expect(second.history).toEqual(['updated']); - }); - - test('excludes user messages hidden by session revert state', () => { - const beforeRevert = message('msg_ffffffffffffBefore', 'user'); - const reverted = message('msg_000000000000Reverted', 'user'); - - const snapshot = buildUserMessageHistorySnapshot( - state({ - session: [{ id: 'ses_1', revert: { messageID: reverted.id } } as State['session'][number]], - message: { ses_1: [beforeRevert, reverted] }, - part: { - [beforeRevert.id]: [textPart('part_user_1', 'kept')], - [reverted.id]: [textPart('part_user_2', 'reverted')], - }, - }), - 'ses_1', - ); - - expect(snapshot.history).toEqual(['kept']); - }); -}); diff --git a/packages/ui/src/sync/user-message-history.ts b/packages/ui/src/sync/user-message-history.ts deleted file mode 100644 index e1a54833..00000000 --- a/packages/ui/src/sync/user-message-history.ts +++ /dev/null @@ -1,97 +0,0 @@ -import type { Message, Part } from '@opencode-ai/sdk/v2/client'; -import type { State } from './types'; -import { messagesBefore } from './message-ordering'; - -type UserMessageHistoryRecord = { - message: Message; - parts: Part[]; -}; - -export type UserMessageHistorySnapshot = { - sessionID: string; - revertMessageID?: string; - records: UserMessageHistoryRecord[]; - history: string[]; -}; - -const EMPTY_PARTS: Part[] = []; -const EMPTY_RECORDS: UserMessageHistoryRecord[] = []; -const EMPTY_HISTORY: string[] = []; - -export const EMPTY_USER_MESSAGE_HISTORY_SNAPSHOT: UserMessageHistorySnapshot = { - sessionID: '', - revertMessageID: undefined, - records: EMPTY_RECORDS, - history: EMPTY_HISTORY, -}; - -const getPartText = (part: Part): string => { - if (part?.type !== 'text') return ''; - const text = (part as { text?: unknown }).text; - return typeof text === 'string' ? text : ''; -}; - -const getFirstTextFromParts = (parts: Part[]): string => { - for (const part of parts) { - const text = getPartText(part); - if (text.length > 0) return text; - } - return ''; -}; - -const areRecordsEqual = (left: UserMessageHistoryRecord[], right: UserMessageHistoryRecord[]): boolean => { - if (left === right) return true; - if (left.length !== right.length) return false; - for (let index = 0; index < left.length; index += 1) { - if (left[index]?.message !== right[index]?.message || left[index]?.parts !== right[index]?.parts) { - return false; - } - } - return true; -}; - -export const buildUserMessageHistorySnapshot = ( - state: Pick, - sessionID: string, - previous: UserMessageHistorySnapshot = EMPTY_USER_MESSAGE_HISTORY_SNAPSHOT, -): UserMessageHistorySnapshot => { - if (!sessionID) { - return EMPTY_USER_MESSAGE_HISTORY_SNAPSHOT; - } - - const messages = state.message[sessionID] ?? []; - const session = state.session.find((candidate) => candidate.id === sessionID); - const revertMessageID = (session as { revert?: { messageID?: string } } | undefined)?.revert?.messageID; - const records: UserMessageHistoryRecord[] = []; - const visibleMessages = messagesBefore(messages, revertMessageID); - for (let index = visibleMessages.length - 1; index >= 0; index -= 1) { - const message = visibleMessages[index]; - if (message.role !== 'user') { - continue; - } - records.push({ - message, - parts: state.part[message.id] ?? EMPTY_PARTS, - }); - } - - if (records.length === 0) { - return previous.sessionID === sessionID && previous.revertMessageID === revertMessageID && previous.records.length === 0 - ? previous - : { sessionID, revertMessageID, records: EMPTY_RECORDS, history: EMPTY_HISTORY }; - } - - if (previous.sessionID === sessionID && previous.revertMessageID === revertMessageID && areRecordsEqual(previous.records, records)) { - return previous; - } - - const history: string[] = []; - for (const record of records) { - const text = getFirstTextFromParts(record.parts); - if (text.length > 0) { - history.push(text); - } - } - - return { sessionID, revertMessageID, records, history }; -}; diff --git a/packages/web/server/lib/opencode/input-history-scope.js b/packages/web/server/lib/opencode/input-history-scope.js new file mode 100644 index 00000000..38fe2332 --- /dev/null +++ b/packages/web/server/lib/opencode/input-history-scope.js @@ -0,0 +1,14 @@ +export const DEFAULT_INPUT_HISTORY_SCOPE = 'global'; +export const DEFAULT_INPUT_HISTORY_LIMIT = 40; +const MIN_INPUT_HISTORY_LIMIT = 1; +const MAX_INPUT_HISTORY_LIMIT = 100; + +export const isInputHistoryScope = (value) => ( + value === 'global' || value === 'session' +); + +export const isInputHistoryLimit = (value) => ( + Number.isInteger(value) + && value >= MIN_INPUT_HISTORY_LIMIT + && value <= MAX_INPUT_HISTORY_LIMIT +); diff --git a/packages/web/server/lib/opencode/settings-helpers.js b/packages/web/server/lib/opencode/settings-helpers.js index a514320f..7e55d24f 100644 --- a/packages/web/server/lib/opencode/settings-helpers.js +++ b/packages/web/server/lib/opencode/settings-helpers.js @@ -1,4 +1,10 @@ import { isAgentMemoryFeatureAvailable } from '../agent-memory/feature-flag.js'; +import { + DEFAULT_INPUT_HISTORY_LIMIT, + DEFAULT_INPUT_HISTORY_SCOPE, + isInputHistoryLimit, + isInputHistoryScope, +} from './input-history-scope.js'; export const createSettingsHelpers = (dependencies) => { const { @@ -140,6 +146,12 @@ export const createSettingsHelpers = (dependencies) => { if (typeof candidate.themeVariant === 'string' && (candidate.themeVariant === 'light' || candidate.themeVariant === 'dark')) { result.themeVariant = candidate.themeVariant; } + if (typeof candidate.inputHistoryScope === 'string' && isInputHistoryScope(candidate.inputHistoryScope)) { + result.inputHistoryScope = candidate.inputHistoryScope; + } + if (isInputHistoryLimit(candidate.inputHistoryLimit)) { + result.inputHistoryLimit = candidate.inputHistoryLimit; + } if (typeof candidate.useSystemTheme === 'boolean') { result.useSystemTheme = candidate.useSystemTheme; } @@ -940,6 +952,8 @@ export const createSettingsHelpers = (dependencies) => { const pwaAppName = normalizePwaAppName(settings?.pwaAppName, ''); const pwaOrientation = normalizePwaOrientation(settings?.pwaOrientation, 'system'); const mobileKeyboardMode = normalizeMobileKeyboardMode(settings?.mobileKeyboardMode, 'native'); + const inputHistoryScope = sanitized.inputHistoryScope ?? DEFAULT_INPUT_HISTORY_SCOPE; + const inputHistoryLimit = sanitized.inputHistoryLimit ?? DEFAULT_INPUT_HISTORY_LIMIT; return { ...sanitized, @@ -950,6 +964,8 @@ export const createSettingsHelpers = (dependencies) => { ...(pwaAppName ? { pwaAppName } : {}), pwaOrientation, mobileKeyboardMode, + inputHistoryScope, + inputHistoryLimit, securityScopedBookmarks: bookmarks, pinnedDirectories: normalizeStringArray(settings.pinnedDirectories), typographySizes: sanitizeTypographySizesPartial(settings.typographySizes), diff --git a/packages/web/server/lib/opencode/settings-helpers.test.js b/packages/web/server/lib/opencode/settings-helpers.test.js index f03d69eb..c2a5cf48 100644 --- a/packages/web/server/lib/opencode/settings-helpers.test.js +++ b/packages/web/server/lib/opencode/settings-helpers.test.js @@ -1,8 +1,19 @@ +import { execFileSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, readdirSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import { describe, expect, it } from 'vitest'; +import { + DEFAULT_INPUT_HISTORY_LIMIT, +} from './input-history-scope.js'; import { createSettingsHelpers } from './settings-helpers.js'; import { createSettingsNormalizationRuntime } from './settings-normalization-runtime.js'; +const testFilePath = fileURLToPath(import.meta.url); +const packagesWebDir = join(dirname(testFilePath), '..', '..', '..'); + const createTestHelpers = () => createSettingsHelpers({ normalizePathForPersistence: (value) => value, normalizeDirectoryPath: (value) => value, @@ -58,6 +69,66 @@ const createTestHelpersWithRealSanitizers = () => { }; describe('settings helpers', () => { + it('imports from the packed @openchamber/web tarball without escaping the published package', async () => { + const tempRoot = mkdtempSync(join(tmpdir(), 'settings-helpers-pack-')); + const packDir = join(tempRoot, 'pack'); + const extractDir = join(tempRoot, 'extract'); + + try { + mkdirSync(packDir); + mkdirSync(extractDir); + execFileSync('npm', ['pack', '--silent', '--pack-destination', packDir], { + cwd: packagesWebDir, + stdio: 'pipe', + }); + + const tarballName = readdirSync(packDir).find((entry) => entry.endsWith('.tgz')); + expect(tarballName).toBeTruthy(); + + execFileSync('tar', ['-xzf', join(packDir, tarballName), '-C', extractDir], { + stdio: 'pipe', + }); + + const extractedModule = await import( + pathToFileURL(join(extractDir, 'package', 'server', 'lib', 'opencode', 'settings-helpers.js')).href + ); + + const helpers = extractedModule.createSettingsHelpers({ + normalizePathForPersistence: (value) => value, + normalizeDirectoryPath: (value) => value, + normalizeTunnelBootstrapTtlMs: (value) => value, + normalizeTunnelSessionTtlMs: (value) => value, + normalizeTunnelProvider: (value) => value, + normalizeTunnelMode: (value) => value, + normalizeOptionalPath: (value) => value, + normalizeManagedRemoteTunnelHostname: (value) => value, + normalizeManagedRemoteTunnelPresets: () => undefined, + normalizeManagedRemoteTunnelPresetTokens: () => undefined, + sanitizeTypographySizesPartial: () => undefined, + normalizeStringArray: (input) => input, + sanitizeModelRefs: () => undefined, + sanitizeSkillCatalogs: () => undefined, + sanitizeProjects: () => undefined, + }); + + expect(helpers.sanitizeSettingsUpdate({ inputHistoryScope: 'global' })).toEqual({ + inputHistoryScope: 'global', + }); + expect(helpers.sanitizeSettingsUpdate({ inputHistoryScope: 'session' })).toEqual({ + inputHistoryScope: 'session', + }); + expect(helpers.sanitizeSettingsUpdate({ inputHistoryLimit: 40 })).toEqual({ + inputHistoryLimit: 40, + }); + expect(helpers.formatSettingsResponse({})).toMatchObject({ + inputHistoryScope: 'global', + inputHistoryLimit: DEFAULT_INPUT_HISTORY_LIMIT, + }); + } finally { + rmSync(tempRoot, { recursive: true, force: true }); + } + }); + it('accepts only booleans for draft starter visibility', () => { const helpers = createTestHelpers(); @@ -151,6 +222,74 @@ describe('settings helpers', () => { expect(helpers.sanitizeSettingsUpdate({ messageStreamTransport: 'websocket' })).toEqual({}); }); + it('accepts inputHistoryScope as a persisted shared setting', () => { + const helpers = createTestHelpers(); + + expect(helpers.sanitizeSettingsUpdate({ inputHistoryScope: 'global' })).toEqual({ + inputHistoryScope: 'global', + }); + expect(helpers.sanitizeSettingsUpdate({ inputHistoryScope: 'session' })).toEqual({ + inputHistoryScope: 'session', + }); + }); + + it('accepts valid inputHistoryLimit values as a persisted shared setting', () => { + const helpers = createTestHelpers(); + + expect(helpers.sanitizeSettingsUpdate({ inputHistoryLimit: 1 })).toEqual({ + inputHistoryLimit: 1, + }); + expect(helpers.sanitizeSettingsUpdate({ inputHistoryLimit: 40 })).toEqual({ + inputHistoryLimit: 40, + }); + expect(helpers.sanitizeSettingsUpdate({ inputHistoryLimit: 100 })).toEqual({ + inputHistoryLimit: 100, + }); + }); + + it('rejects invalid inputHistoryLimit values', () => { + const helpers = createTestHelpers(); + + expect(helpers.sanitizeSettingsUpdate({ inputHistoryLimit: 0 })).toEqual({}); + expect(helpers.sanitizeSettingsUpdate({ inputHistoryLimit: 101 })).toEqual({}); + expect(helpers.sanitizeSettingsUpdate({ inputHistoryLimit: 1.5 })).toEqual({}); + expect(helpers.sanitizeSettingsUpdate({ inputHistoryLimit: '40' })).toEqual({}); + expect(helpers.sanitizeSettingsUpdate({ inputHistoryLimit: Number.NaN })).toEqual({}); + expect(helpers.sanitizeSettingsUpdate({ inputHistoryLimit: Number.POSITIVE_INFINITY })).toEqual({}); + expect(helpers.sanitizeSettingsUpdate({ inputHistoryLimit: Number.NEGATIVE_INFINITY })).toEqual({}); + }); + + it('rejects invalid inputHistoryScope values', () => { + const helpers = createTestHelpers(); + + expect(helpers.sanitizeSettingsUpdate({ inputHistoryScope: 'workspace' })).toEqual({}); + }); + + it('defaults inputHistoryScope to global in formatted settings responses', () => { + const helpers = createTestHelpers(); + + expect(helpers.formatSettingsResponse({ inputHistoryScope: 'session' })).toMatchObject({ + inputHistoryScope: 'session', + }); + expect(helpers.formatSettingsResponse({})).toMatchObject({ + inputHistoryScope: 'global', + }); + }); + + it('defaults missing inputHistoryLimit to 40 in formatted settings responses and preserves valid values', () => { + const helpers = createTestHelpers(); + + expect(helpers.formatSettingsResponse({})).toMatchObject({ + inputHistoryLimit: DEFAULT_INPUT_HISTORY_LIMIT, + }); + expect(helpers.formatSettingsResponse({ inputHistoryLimit: 1 })).toMatchObject({ + inputHistoryLimit: 1, + }); + expect(helpers.formatSettingsResponse({ inputHistoryLimit: 100 })).toMatchObject({ + inputHistoryLimit: 100, + }); + }); + it('sanitizes the persisted terminal shell', () => { const helpers = createTestHelpers();