From f46fb718c5faba16262ad07be8dddc5e01e07ce1 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sat, 5 Sep 2026 20:13:35 +0300 Subject: [PATCH] fix(chat): recall the current session's prompts by default; tidy the six merged PRs Input history (#3035) shipped with "All projects" as the default scope and only recorded prompts sent after the upgrade, so ArrowUp showed other sessions' prompts and, once switched to "Current session", nothing at all. Default to the current session and merge the visible transcript's prompts with the persisted bucket. Existing sessions recall as they did before #3035, while new prompts keep their attachments and stay recallable after a revert hides them from the transcript. Cleanup across #1855, #2297, #3072, #3178, #3035 and #3135: drop the duplicate poll guards in the file content poller, the zod schema the VS Code package cannot depend on, a copied file-URL helper and stray whitespace; move the Enter-to-send strings into the settings namespace; document OPENCHAMBER_CHATS_DIR, resolve the chats root once on the server and warm it alongside the other bootstrap calls. --- packages/docs/content/docs/de/environment.mdx | 4 + packages/docs/content/docs/environment.mdx | 4 + packages/docs/content/docs/es/environment.mdx | 4 + packages/docs/content/docs/fr/environment.mdx | 4 + packages/docs/content/docs/ja/environment.mdx | 4 + packages/docs/content/docs/ko/environment.mdx | 4 + packages/docs/content/docs/pl/environment.mdx | 4 + .../docs/content/docs/pt-br/environment.mdx | 4 + packages/docs/content/docs/tr/environment.mdx | 4 + packages/docs/content/docs/uk/environment.mdx | 4 + .../docs/content/docs/zh-cn/environment.mdx | 4 + packages/ui/src/components/chat/ChatInput.tsx | 20 +-- .../components/chat/composer/DOCUMENTATION.md | 23 ++-- .../chat/composer/editor/ComposerEditor.tsx | 2 +- .../chat/composer/keyboardPolicy.test.ts | 4 +- .../chat/composer/keyboardPolicy.ts | 8 +- .../src/components/chat/inputHistory.test.ts | 41 +++++++ .../ui/src/components/chat/inputHistory.ts | 50 ++++---- .../openchamber/OpenChamberVisualSettings.tsx | 14 +-- .../ui/src/components/views/FilesView.tsx | 3 +- .../views/fileContentPoller.test.ts | 53 ++------ .../src/components/views/fileContentPoller.ts | 12 +- .../ui/src/hooks/useQueuedMessageAutoSend.ts | 9 +- .../ui/src/lib/i18n/messages/de.settings.ts | 2 + packages/ui/src/lib/i18n/messages/de.ts | 2 - .../ui/src/lib/i18n/messages/en.settings.ts | 2 + packages/ui/src/lib/i18n/messages/en.ts | 2 - .../ui/src/lib/i18n/messages/es.settings.ts | 2 + packages/ui/src/lib/i18n/messages/es.ts | 2 - .../ui/src/lib/i18n/messages/fr.settings.ts | 2 + packages/ui/src/lib/i18n/messages/fr.ts | 2 - .../ui/src/lib/i18n/messages/ja.settings.ts | 2 + packages/ui/src/lib/i18n/messages/ja.ts | 2 - .../ui/src/lib/i18n/messages/ko.settings.ts | 2 + packages/ui/src/lib/i18n/messages/ko.ts | 2 - .../ui/src/lib/i18n/messages/pl.settings.ts | 2 + packages/ui/src/lib/i18n/messages/pl.ts | 2 - .../src/lib/i18n/messages/pt-BR.settings.ts | 2 + packages/ui/src/lib/i18n/messages/pt-BR.ts | 2 - .../ui/src/lib/i18n/messages/tr.settings.ts | 2 + packages/ui/src/lib/i18n/messages/tr.ts | 2 - .../ui/src/lib/i18n/messages/uk.settings.ts | 2 + packages/ui/src/lib/i18n/messages/uk.ts | 2 - .../src/lib/i18n/messages/zh-CN.settings.ts | 2 + packages/ui/src/lib/i18n/messages/zh-CN.ts | 2 - .../src/lib/i18n/messages/zh-TW.settings.ts | 2 + packages/ui/src/lib/i18n/messages/zh-TW.ts | 2 - packages/ui/src/lib/inputHistoryScope.ts | 2 +- packages/ui/src/lib/opencode/client.ts | 4 +- packages/ui/src/lib/persistence.ts | 28 ++--- packages/ui/src/lib/settings/search.ts | 3 +- packages/ui/src/stores/DOCUMENTATION.md | 2 +- packages/ui/src/stores/messageQueueStore.ts | 2 +- .../src/stores/useInputHistoryStore.test.ts | 4 +- .../ui/src/stores/useInputHistoryStore.ts | 9 +- packages/ui/src/stores/useUIStore.ts | 1 + packages/ui/src/sync/DOCUMENTATION.md | 2 +- packages/ui/src/sync/bootstrap.ts | 5 +- .../ui/src/sync/performance-diagnostics.ts | 2 + packages/ui/src/sync/session-ui-store.ts | 20 ++- packages/ui/src/sync/sync-context.tsx | 46 +++++++ .../ui/src/sync/user-message-history.test.ts | 115 ++++++++++++++++++ packages/ui/src/sync/user-message-history.ts | 102 ++++++++++++++++ packages/vscode/src/DOCUMENTATION.md | 1 - .../vscode/src/bridge-settings-runtime.ts | 9 -- packages/vscode/src/settings-changes.test.js | 19 --- packages/vscode/src/settings-changes.ts | 8 -- packages/web/bin/lib/cli-startup.js | 3 +- packages/web/server/index.js | 2 +- packages/web/server/lib/fs/routes.js | 12 +- .../lib/opencode/input-history-scope.js | 2 +- .../server/lib/opencode/settings-helpers.js | 11 +- .../lib/opencode/settings-helpers.test.js | 10 +- 73 files changed, 513 insertions(+), 242 deletions(-) create mode 100644 packages/ui/src/sync/user-message-history.test.ts create mode 100644 packages/ui/src/sync/user-message-history.ts delete mode 100644 packages/vscode/src/settings-changes.test.js delete mode 100644 packages/vscode/src/settings-changes.ts diff --git a/packages/docs/content/docs/de/environment.mdx b/packages/docs/content/docs/de/environment.mdx index 8e48b703..eb9406ef 100644 --- a/packages/docs/content/docs/de/environment.mdx +++ b/packages/docs/content/docs/de/environment.mdx @@ -25,6 +25,10 @@ Startet OpenChamber im Headless-Modus, wenn auf `true` oder `1` gesetzt. API-Rou Überschreibt das OpenChamber-Datenverzeichnis. Standard ist `~/.config/openchamber`. +### `OPENCHAMBER_CHATS_DIR` + +Verschiebt die verwalteten Chat-Verzeichnisse, die OpenChamber für Chats ohne Projekt anlegt. Standard ist `~/.config/openchamber/chats`. Setze ein Verzeichnis, das der OpenCode-Server lesen kann, wenn OpenChamber und OpenCode unter verschiedenen Benutzern laufen. Bestehende Chats werden nicht verschoben. + ### `OPENCHAMBER_COMPRESS_API` Steuert die Komprimierung von API-Antworten. Verwende `true` oder `1`, um sie zu erzwingen, und `false` oder `0`, um sie zu deaktivieren. diff --git a/packages/docs/content/docs/environment.mdx b/packages/docs/content/docs/environment.mdx index aaa793d6..1a6ca40e 100644 --- a/packages/docs/content/docs/environment.mdx +++ b/packages/docs/content/docs/environment.mdx @@ -25,6 +25,10 @@ Starts OpenChamber in headless mode when set to `true` or `1`. API routes stay a Overrides the OpenChamber data directory. The default is `~/.config/openchamber`. +### `OPENCHAMBER_CHATS_DIR` + +Moves the managed chat directories that OpenChamber creates for chats without a project. The default is `~/.config/openchamber/chats`. Set it to a directory the OpenCode server can read when OpenChamber and OpenCode run as different users. Existing chats are not moved. + ### `OPENCHAMBER_COMPRESS_API` Controls API response compression. Use `true` or `1` to force it on, `false` or `0` to force it off. diff --git a/packages/docs/content/docs/es/environment.mdx b/packages/docs/content/docs/es/environment.mdx index e6842ded..f4e1d0f2 100644 --- a/packages/docs/content/docs/es/environment.mdx +++ b/packages/docs/content/docs/es/environment.mdx @@ -25,6 +25,10 @@ Inicia OpenChamber en modo headless cuando vale `true` o `1`. Las rutas API sigu Cambia el directorio de datos de OpenChamber. Por defecto es `~/.config/openchamber`. +### `OPENCHAMBER_CHATS_DIR` + +Cambia la ubicación de los directorios de chat gestionados que OpenChamber crea para chats sin proyecto. Por defecto es `~/.config/openchamber/chats`. Indica un directorio que el servidor OpenCode pueda leer cuando OpenChamber y OpenCode se ejecutan con usuarios distintos. Los chats existentes no se mueven. + ### `OPENCHAMBER_COMPRESS_API` Controla la compresión de respuestas de la API. `true` o `1` la activa; `false` o `0` la desactiva. diff --git a/packages/docs/content/docs/fr/environment.mdx b/packages/docs/content/docs/fr/environment.mdx index 88af9ff6..50b2deec 100644 --- a/packages/docs/content/docs/fr/environment.mdx +++ b/packages/docs/content/docs/fr/environment.mdx @@ -25,6 +25,10 @@ Démarre OpenChamber en mode headless lorsqu’il vaut `true` ou `1`. Les routes Remplace le dossier de données OpenChamber. La valeur par défaut est `~/.config/openchamber`. +### `OPENCHAMBER_CHATS_DIR` + +Déplace les dossiers de chat gérés qu'OpenChamber crée pour les chats sans projet. La valeur par défaut est `~/.config/openchamber/chats`. Indiquez un dossier lisible par le serveur OpenCode lorsque OpenChamber et OpenCode tournent sous des utilisateurs différents. Les chats existants ne sont pas déplacés. + ### `OPENCHAMBER_COMPRESS_API` Contrôle la compression des réponses API. Utilisez `true` ou `1` pour la forcer, `false` ou `0` pour la désactiver. diff --git a/packages/docs/content/docs/ja/environment.mdx b/packages/docs/content/docs/ja/environment.mdx index af539038..260246ed 100644 --- a/packages/docs/content/docs/ja/environment.mdx +++ b/packages/docs/content/docs/ja/environment.mdx @@ -25,6 +25,10 @@ OpenChamber Web サーバーのバインドアドレスです。他のマシン OpenChamber のデータディレクトリを上書きします。デフォルトは `~/.config/openchamber` です。 +### `OPENCHAMBER_CHATS_DIR` + +プロジェクトを持たないチャット用に OpenChamber が作成する管理チャットディレクトリの場所を変更します。デフォルトは `~/.config/openchamber/chats` です。OpenChamber と OpenCode を別のユーザーで実行している場合は、OpenCode サーバーが読み取れるディレクトリを指定してください。既存のチャットは移動されません。 + ### `OPENCHAMBER_COMPRESS_API` API レスポンス圧縮を制御します。`true` または `1` で強制的に有効化し、`false` または `0` で強制的に無効化します。 diff --git a/packages/docs/content/docs/ko/environment.mdx b/packages/docs/content/docs/ko/environment.mdx index aebebe09..739c9fa4 100644 --- a/packages/docs/content/docs/ko/environment.mdx +++ b/packages/docs/content/docs/ko/environment.mdx @@ -25,6 +25,10 @@ OpenChamber 웹 서버가 바인딩할 주소입니다. 다른 컴퓨터에서 OpenChamber 데이터 디렉터리를 바꿉니다. 기본값은 `~/.config/openchamber`입니다. +### `OPENCHAMBER_CHATS_DIR` + +프로젝트가 없는 채팅을 위해 OpenChamber가 만드는 관리형 채팅 디렉터리의 위치를 바꿉니다. 기본값은 `~/.config/openchamber/chats`입니다. OpenChamber와 OpenCode가 서로 다른 사용자로 실행될 때는 OpenCode 서버가 읽을 수 있는 디렉터리를 지정하세요. 기존 채팅은 이동되지 않습니다. + ### `OPENCHAMBER_COMPRESS_API` API 응답 압축을 제어합니다. `true` 또는 `1`은 켜고, `false` 또는 `0`은 끕니다. diff --git a/packages/docs/content/docs/pl/environment.mdx b/packages/docs/content/docs/pl/environment.mdx index 9954fc79..92623867 100644 --- a/packages/docs/content/docs/pl/environment.mdx +++ b/packages/docs/content/docs/pl/environment.mdx @@ -25,6 +25,10 @@ Uruchamia OpenChamber w trybie headless, gdy ustawione na `true` lub `1`. Trasy Nadpisuje katalog danych OpenChamber. Domyślnie jest to `~/.config/openchamber`. +### `OPENCHAMBER_CHATS_DIR` + +Przenosi katalogi zarządzanych czatów, które OpenChamber tworzy dla czatów bez projektu. Domyślnie jest to `~/.config/openchamber/chats`. Ustaw katalog, który serwer OpenCode może odczytać, gdy OpenChamber i OpenCode działają jako różni użytkownicy. Istniejące czaty nie są przenoszone. + ### `OPENCHAMBER_COMPRESS_API` Steruje kompresją odpowiedzi API. `true` albo `1` ją włącza, `false` albo `0` wyłącza. diff --git a/packages/docs/content/docs/pt-br/environment.mdx b/packages/docs/content/docs/pt-br/environment.mdx index 0e592a54..f2aa19ad 100644 --- a/packages/docs/content/docs/pt-br/environment.mdx +++ b/packages/docs/content/docs/pt-br/environment.mdx @@ -25,6 +25,10 @@ Inicia o OpenChamber em modo headless quando definido como `true` ou `1`. As rot Altera o diretório de dados do OpenChamber. O padrão é `~/.config/openchamber`. +### `OPENCHAMBER_CHATS_DIR` + +Move os diretórios de chat gerenciados que o OpenChamber cria para chats sem projeto. O padrão é `~/.config/openchamber/chats`. Defina um diretório que o servidor OpenCode consiga ler quando o OpenChamber e o OpenCode rodam com usuários diferentes. Os chats existentes não são movidos. + ### `OPENCHAMBER_COMPRESS_API` Controla a compressão das respostas da API. `true` ou `1` ativa; `false` ou `0` desativa. diff --git a/packages/docs/content/docs/tr/environment.mdx b/packages/docs/content/docs/tr/environment.mdx index f50383c8..fae1dfd4 100644 --- a/packages/docs/content/docs/tr/environment.mdx +++ b/packages/docs/content/docs/tr/environment.mdx @@ -25,6 +25,10 @@ Tarayıcı arayüzü için parola. Bunu localhost dışına bind ederken, tünel OpenChamber veri dizinini geçersiz kılar. Varsayılan `~/.config/openchamber`. +### `OPENCHAMBER_CHATS_DIR` + +OpenChamber'ın projesiz sohbetler için oluşturduğu yönetilen sohbet dizinlerinin yerini değiştirir. Varsayılan `~/.config/openchamber/chats`. OpenChamber ve OpenCode farklı kullanıcılarla çalışıyorsa OpenCode sunucusunun okuyabileceği bir dizin belirtin. Mevcut sohbetler taşınmaz. + ### `OPENCHAMBER_COMPRESS_API` API yanıt sıkıştırmasını kontrol eder. `true` ya da `1` ile zorla açın, `false` ya da `0` ile zorla kapatın. diff --git a/packages/docs/content/docs/uk/environment.mdx b/packages/docs/content/docs/uk/environment.mdx index e202ab85..4693e015 100644 --- a/packages/docs/content/docs/uk/environment.mdx +++ b/packages/docs/content/docs/uk/environment.mdx @@ -25,6 +25,10 @@ OpenChamber читає ці змінні під час запуску. Для st Перевизначає директорію даних OpenChamber. Типово це `~/.config/openchamber`. +### `OPENCHAMBER_CHATS_DIR` + +Переносить директорії керованих чатів, які OpenChamber створює для чатів без проєкту. Типово це `~/.config/openchamber/chats`. Вкажіть директорію, яку може читати сервер OpenCode, якщо OpenChamber і OpenCode працюють від різних користувачів. Наявні чати не переносяться. + ### `OPENCHAMBER_COMPRESS_API` Керує стисненням API-відповідей. `true` або `1` вмикає, `false` або `0` вимикає. diff --git a/packages/docs/content/docs/zh-cn/environment.mdx b/packages/docs/content/docs/zh-cn/environment.mdx index 69d94b7f..7deb22f7 100644 --- a/packages/docs/content/docs/zh-cn/environment.mdx +++ b/packages/docs/content/docs/zh-cn/environment.mdx @@ -25,6 +25,10 @@ OpenChamber web 服务器监听的地址。使用 `0.0.0.0` 可允许其他机 覆盖 OpenChamber 数据目录。默认是 `~/.config/openchamber`。 +### `OPENCHAMBER_CHATS_DIR` + +更改 OpenChamber 为无项目聊天创建的托管聊天目录的位置。默认是 `~/.config/openchamber/chats`。当 OpenChamber 和 OpenCode 以不同用户运行时,请设置为 OpenCode 服务器可读取的目录。现有聊天不会被移动。 + ### `OPENCHAMBER_COMPRESS_API` 控制 API 响应压缩。`true` 或 `1` 强制开启,`false` 或 `0` 强制关闭。 diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index 9d7ff5a2..d9980561 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -175,7 +175,9 @@ import { buildChatInputHistorySubmissions, buildInputHistoryNavigatorIdentity, mapInputHistoryEntriesToValues, + mergeSessionInputHistory, } from './inputHistory'; +import { useUserMessageHistory } from '@/sync/sync-context'; // 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. @@ -198,7 +200,6 @@ 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) { @@ -908,13 +909,18 @@ const ChatInputComponent: React.FC = ({ ), [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 inputHistoryEntries = useInputHistoryStore(React.useCallback( + (state) => selectInputHistoryEntries(state, inputHistoryIdentity), + [inputHistoryIdentity], + )); + // Session scope also reads the visible transcript, so sessions older than + // the persisted history still recall their prompts. + const transcriptPrompts = useUserMessageHistory(currentSessionId ?? ''); const historyValues = React.useMemo( - () => mapInputHistoryEntriesToValues(inputHistoryEntries), - [inputHistoryEntries], + () => (inputHistoryScope === 'session' + ? mergeSessionInputHistory(transcriptPrompts, inputHistoryEntries) + : mapInputHistoryEntriesToValues(inputHistoryEntries)), + [inputHistoryEntries, inputHistoryScope, transcriptPrompts], ); const messageHistoryIdentity = React.useMemo( () => buildInputHistoryNavigatorIdentity(inputHistoryScope, inputHistoryIdentity), diff --git a/packages/ui/src/components/chat/composer/DOCUMENTATION.md b/packages/ui/src/components/chat/composer/DOCUMENTATION.md index 34167747..a94c538d 100644 --- a/packages/ui/src/components/chat/composer/DOCUMENTATION.md +++ b/packages/ui/src/components/chat/composer/DOCUMENTATION.md @@ -199,17 +199,22 @@ 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. + setting defaults to 40 entries. Recall reads the current session's bucket by + default; the Chat setting can widen it to every project on the runtime. - `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. + bucket the composer was given. Moving away from a position stores the + composer's current text and attachments as an overlay for that position, so + the live draft and any edit made to a recalled prompt survive a round trip + through history. Overlays never rewrite stored history; sending resets them. +- `ChatInput.tsx` applies the recalled text and attachments to the composer and + places the caret. -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. +In session scope the composer merges two sources, oldest first: the visible +transcript's user prompts (`useUserMessageHistory` in `sync-context.tsx`), so +sessions that predate the persisted store still recall, and the persisted +session bucket, which adds attachments and keeps prompts a revert hid from the +timeline. A prompt present in both collapses to the persisted entry. Global +scope reads the persisted runtime bucket only. ## Mobile diff --git a/packages/ui/src/components/chat/composer/editor/ComposerEditor.tsx b/packages/ui/src/components/chat/composer/editor/ComposerEditor.tsx index 9e2f1f97..783ed32d 100644 --- a/packages/ui/src/components/chat/composer/editor/ComposerEditor.tsx +++ b/packages/ui/src/components/chat/composer/editor/ComposerEditor.tsx @@ -234,7 +234,7 @@ export const ComposerEditor = React.forwardRef { for (const modifiers of [{ shiftKey: true, ctrlKey: true, metaKey: false }, { shiftKey: true, ctrlKey: false, metaKey: true }]) { test(`untouched mobile deferred Shift does not submit: ${JSON.stringify(modifiers)}`, () => { const event = { shiftKey: false, ctrlKey: false, metaKey: false }; - restoreDeferredEnterModifiers(event, modifiers, { preserveShift: true }); + restoreDeferredEnterModifiers(event, modifiers, true); expect(shouldSubmitEnter(policy({ isMobile: true, ...event }))).toBe(false); }); } @@ -75,7 +75,7 @@ describe('deferred Enter modifiers', () => { test('does not restore iOS auto-capitalization as Shift', () => { const event = { shiftKey: false, ctrlKey: false, metaKey: false }; - restoreDeferredEnterModifiers(event, { shiftKey: true, ctrlKey: false, metaKey: false }, { preserveShift: false }); + restoreDeferredEnterModifiers(event, { shiftKey: true, ctrlKey: false, metaKey: false }, false); expect(event).toEqual({ shiftKey: false, ctrlKey: false, metaKey: false }); }); diff --git a/packages/ui/src/components/chat/composer/keyboardPolicy.ts b/packages/ui/src/components/chat/composer/keyboardPolicy.ts index ffd277ea..f59081d9 100644 --- a/packages/ui/src/components/chat/composer/keyboardPolicy.ts +++ b/packages/ui/src/components/chat/composer/keyboardPolicy.ts @@ -28,16 +28,12 @@ export interface EnterModifierState { metaKey: boolean; } -interface DeferredEnterModifierOptions { - preserveShift?: boolean; -} - export const restoreDeferredEnterModifiers = ( event: EnterModifierState, modifiers: EnterModifierState, - options: DeferredEnterModifierOptions = {}, + preserveShift = true, ): void => { - if (options.preserveShift !== false && modifiers.shiftKey) { + if (preserveShift && modifiers.shiftKey) { Object.defineProperty(event, 'shiftKey', { value: true }); } if (modifiers.ctrlKey) Object.defineProperty(event, 'ctrlKey', { value: true }); diff --git a/packages/ui/src/components/chat/inputHistory.test.ts b/packages/ui/src/components/chat/inputHistory.test.ts index 47a39f87..841a7a3e 100644 --- a/packages/ui/src/components/chat/inputHistory.test.ts +++ b/packages/ui/src/components/chat/inputHistory.test.ts @@ -7,6 +7,7 @@ import { buildChatInputHistorySubmissions, buildInputHistoryNavigatorIdentity, mapInputHistoryEntriesToValues, + mergeSessionInputHistory, } from './inputHistory'; const ATTACHMENT: AttachedFile = { @@ -131,3 +132,43 @@ describe('buildInputHistoryNavigatorIdentity', () => { })).toBe('session\nruntime-a\n/repo\nsession-1'); }); }); + +describe('mergeSessionInputHistory', () => { + const entry = (text: string, submittedAtMs: number): InputHistoryEntry => ({ + text, + attachmentKeys: [], + restorableAttachments: [], + submittedAt: submittedAtMs * 1000, + }); + + test('recalls transcript prompts when nothing is persisted yet', () => { + const values = mergeSessionInputHistory( + [{ text: 'first', createdAt: 10 }, { text: 'second', createdAt: 20 }], + [], + ); + + expect(values.map((value) => value.text)).toEqual(['first', 'second']); + expect(values[0]?.attachments).toEqual([]); + }); + + test('interleaves persisted entries by time and collapses duplicates onto the persisted entry', () => { + const persisted: InputHistoryEntry = { + ...entry('second', 20), + restorableAttachments: [{ + key: 'server-file', + source: 'file-url', + filename: 'server.txt', + mimeType: 'text/plain', + size: 11, + reference: '/repo/server.txt', + }], + }; + const values = mergeSessionInputHistory( + [{ text: 'first', createdAt: 10 }, { text: 'second', createdAt: 20 }, { text: 'fourth', createdAt: 40 }], + [persisted, entry('reverted', 30)], + ); + + expect(values.map((value) => value.text)).toEqual(['first', 'second', 'reverted', 'fourth']); + expect(values[1]?.attachments[0]?.filename).toBe('server.txt'); + }); +}); diff --git a/packages/ui/src/components/chat/inputHistory.ts b/packages/ui/src/components/chat/inputHistory.ts index f94ca891..5795950f 100644 --- a/packages/ui/src/components/chat/inputHistory.ts +++ b/packages/ui/src/components/chat/inputHistory.ts @@ -8,6 +8,8 @@ import { type InputHistorySubmission, } from '@/stores/useInputHistoryStore'; import type { AttachedFile } from '@/stores/types/sessionTypes'; +import type { TranscriptPrompt } from '@/sync/user-message-history'; +import { toServerFileUrl } from './composer/attachments/filePaths'; type HistoryQueuedMessage = { content: string; @@ -22,30 +24,6 @@ type BuildHistorySubmissionsArgs = { 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, @@ -85,7 +63,7 @@ function materializeHistoryAttachment(attachment: InputHistoryAttachment): Attac return { id: `history-${attachment.key}`, file: new File([], attachment.filename, { type: attachment.mimeType }), - dataUrl: toFileUrl(attachment.reference), + dataUrl: toServerFileUrl(attachment.reference), mimeType: attachment.mimeType, filename: attachment.filename, size: attachment.size, @@ -109,6 +87,28 @@ export function mapInputHistoryEntriesToValues( })); } +/** + * Session-scoped recall: the visible transcript's prompts (so sessions older + * than the persisted store still recall) merged with the persisted bucket + * (attachments, and prompts a revert hid from the transcript), oldest first. + * A prompt present in both collapses to the persisted entry. + */ +export function mergeSessionInputHistory( + transcript: readonly TranscriptPrompt[], + entries: readonly InputHistoryEntry[], +): Array> { + const persistedTexts = new Set(entries.map((entry) => entry.text)); + const timed: Array<{ at: number; value: MessageHistoryValue }> = [ + ...transcript + .filter((prompt) => !persistedTexts.has(prompt.text)) + .map((prompt) => ({ at: prompt.createdAt, value: { text: prompt.text, attachments: [] } })), + ...mapInputHistoryEntriesToValues(entries) + // submittedAt is milliseconds × 1000 plus a sequence number. + .map((value, index) => ({ at: Math.floor(entries[index]!.submittedAt / 1000), value })), + ]; + return timed.sort((left, right) => left.at - right.at).map((item) => item.value); +} + export function buildInputHistoryNavigatorIdentity( scope: InputHistoryScope, identity: InputHistoryIdentity | null, diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx index 7d8b9983..ee835c8b 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx @@ -70,9 +70,7 @@ import { import { isTerminalShell } from '@/lib/terminalShell'; import { subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch'; import { formatShortcutForDisplay } from '@/lib/shortcuts'; -import { - useInputHistoryStore, -} from '@/stores/useInputHistoryStore'; +import { useInputHistoryStore } from '@/stores/useInputHistoryStore'; interface Option { id: T; @@ -738,7 +736,7 @@ export const OpenChamberVisualSettings: React.FC || shouldShow('showToolFileIcons') || shouldShow('showTurnChangedFiles') || (!isMobile && shouldShow('inputSpellcheck')) - || shouldShow('enterToSend') + || shouldShow('enterToSend') || shouldShow('reasoning') || shouldShow('expandedTools'); // First behavior section under the page header should not draw a top border on Chat-only; @@ -1512,7 +1510,7 @@ export const OpenChamberVisualSettings: React.FC /> ))} - + )}
{shouldShow('autoSaveEnabled') && ( @@ -2150,9 +2148,9 @@ export const OpenChamberVisualSettings: React.FC )} diff --git a/packages/ui/src/components/views/FilesView.tsx b/packages/ui/src/components/views/FilesView.tsx index f82aa4d3..3dc8ef7b 100644 --- a/packages/ui/src/components/views/FilesView.tsx +++ b/packages/ui/src/components/views/FilesView.tsx @@ -2134,7 +2134,6 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { } applyLoadedTextContent(content); }, - maxBytes: MAX_CONTENT_POLL_BYTES, }) : null; @@ -2165,7 +2164,7 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { if (contentPoller && latestStat.size <= MAX_CONTENT_POLL_BYTES) { // Only an observed read retires the change; a dirty buffer or a // failed read leaves the baseline so the next tick retries. - const observed = await contentPoller.poll(latestStat.size); + const observed = await contentPoller.poll(); if (observed && !cancelled) { lastLoadedFileStatRef.current = latestStat; } diff --git a/packages/ui/src/components/views/fileContentPoller.test.ts b/packages/ui/src/components/views/fileContentPoller.test.ts index 04122d17..9d227b09 100644 --- a/packages/ui/src/components/views/fileContentPoller.test.ts +++ b/packages/ui/src/components/views/fileContentPoller.test.ts @@ -2,8 +2,6 @@ import { describe, expect, test } from 'bun:test'; import { createFileContentPoller } from './fileContentPoller'; -const MAX_BYTES = 200_000; - const deferred = () => { let resolve!: (value: T) => void; const promise = new Promise((done) => { resolve = done; }); @@ -23,11 +21,10 @@ describe('createFileContentPoller', () => { getLoadedRevision: () => 0, isDirty: () => false, applyContent: (content) => applied.push(content), - maxBytes: MAX_BYTES, }); - expect(await poller.poll(12)).toBe(true); - expect(await poller.poll(12)).toBe(true); + expect(await poller.poll()).toBe(true); + expect(await poller.poll()).toBe(true); expect(reads).toBe(2); expect(applied).toEqual([]); @@ -41,10 +38,9 @@ describe('createFileContentPoller', () => { getLoadedRevision: () => 0, isDirty: () => false, applyContent: (content) => applied.push(content), - maxBytes: MAX_BYTES, }); - expect(await poller.poll(6)).toBe(false); + expect(await poller.poll()).toBe(false); expect(applied).toEqual([]); }); @@ -59,10 +55,9 @@ describe('createFileContentPoller', () => { getLoadedRevision: () => 0, isDirty: () => true, applyContent: () => undefined, - maxBytes: MAX_BYTES, }); - expect(await poller.poll(6)).toBe(false); + expect(await poller.poll()).toBe(false); expect(reads).toBe(0); }); @@ -78,10 +73,9 @@ describe('createFileContentPoller', () => { getLoadedRevision: () => 0, isDirty: () => false, applyContent: (content) => applied.push(content), - maxBytes: MAX_BYTES, }); - expect(await poller.poll(3)).toBe(true); + expect(await poller.poll()).toBe(true); expect(reads).toBe(2); expect(applied).toEqual(['abd']); @@ -97,10 +91,9 @@ describe('createFileContentPoller', () => { getLoadedRevision: () => 0, isDirty: () => dirty, applyContent: (content) => applied.push(content), - maxBytes: MAX_BYTES, }); - const polling = poller.poll(20); + const polling = poller.poll(); dirty = true; read.resolve('external edit'); await polling; @@ -117,17 +110,16 @@ describe('createFileContentPoller', () => { getLoadedRevision: () => 0, isDirty: () => false, applyContent: (content) => applied.push(content), - maxBytes: MAX_BYTES, }); - await poller.poll(7); + await poller.poll(); expect(applied).toEqual([]); - expect(await poller.poll(7)).toBe(true); + expect(await poller.poll()).toBe(true); expect(applied).toEqual(['settled']); }); - test('allows only one read in flight and ignores a disposed poll', async () => { + test('ignores a read that resolves after dispose', async () => { const read = deferred(); let reads = 0; const applied: string[] = []; @@ -140,14 +132,12 @@ describe('createFileContentPoller', () => { getLoadedRevision: () => 0, isDirty: () => false, applyContent: (content) => applied.push(content), - maxBytes: MAX_BYTES, }); - const first = poller.poll(5); - await poller.poll(5); + const pending = poller.poll(); poller.dispose(); read.resolve('after'); - await first; + expect(await pending).toBe(false); expect(reads).toBe(1); expect(applied).toEqual([]); @@ -165,10 +155,9 @@ describe('createFileContentPoller', () => { getLoadedRevision: () => loadedRevision, isDirty: () => false, applyContent: (content) => applied.push(content), - maxBytes: MAX_BYTES, }); - const polling = poller.poll(20); + const polling = poller.poll(); firstRead.resolve('external edit'); await Promise.resolve(); loadedRevision += 1; @@ -179,22 +168,4 @@ describe('createFileContentPoller', () => { expect(applied).toEqual([]); }); - test('does not read content above the polling byte limit', async () => { - let reads = 0; - const poller = createFileContentPoller({ - readContent: async () => { - reads += 1; - return 'content'; - }, - getLoadedContent: () => 'before', - getLoadedRevision: () => 0, - isDirty: () => false, - applyContent: () => undefined, - maxBytes: MAX_BYTES, - }); - - expect(await poller.poll(MAX_BYTES + 1)).toBe(false); - - expect(reads).toBe(0); - }); }); diff --git a/packages/ui/src/components/views/fileContentPoller.ts b/packages/ui/src/components/views/fileContentPoller.ts index 1608a604..fcfd99e8 100644 --- a/packages/ui/src/components/views/fileContentPoller.ts +++ b/packages/ui/src/components/views/fileContentPoller.ts @@ -4,18 +4,17 @@ type FileContentPollerOptions = { getLoadedRevision: () => number; isDirty: () => boolean; applyContent: (content: string) => void; - maxBytes: number; }; +// The caller serializes polls and decides which files qualify (text, within +// the byte limit); this owns only the read-compare-confirm-apply step. export const createFileContentPoller = (options: FileContentPollerOptions) => { let active = true; - let polling = false; return { /** Resolves true only when the poll observed the file's current content. */ - poll: async (size: number): Promise => { - if (!active || polling || options.isDirty() || size > options.maxBytes) return false; - polling = true; + poll: async (): Promise => { + if (!active || options.isDirty()) return false; const loadedContent = options.getLoadedContent(); const loadedRevision = options.getLoadedRevision(); try { @@ -23,6 +22,7 @@ export const createFileContentPoller = (options: FileContentPollerOptions) => { if (!active || options.isDirty() || loadedRevision !== options.getLoadedRevision()) return false; if (content === loadedContent) return true; + // A second read guards against applying a half-written file. const confirmedContent = await options.readContent(); if (!active || options.isDirty() || confirmedContent !== content || loadedRevision !== options.getLoadedRevision()) { return false; @@ -32,8 +32,6 @@ export const createFileContentPoller = (options: FileContentPollerOptions) => { } catch { // A failed read is not proof the file is unchanged; the next poll retries. return false; - } finally { - polling = false; } }, dispose: () => { diff --git a/packages/ui/src/hooks/useQueuedMessageAutoSend.ts b/packages/ui/src/hooks/useQueuedMessageAutoSend.ts index e4b83ef2..0e4b1c60 100644 --- a/packages/ui/src/hooks/useQueuedMessageAutoSend.ts +++ b/packages/ui/src/hooks/useQueuedMessageAutoSend.ts @@ -118,12 +118,9 @@ export const sendQueuedAutoSendPayload = ( payload.agentMentionName, payload.additionalParts.length > 0 ? payload.additionalParts : undefined, resolved.variant, - 'normal', - { - target, - historySubmissions: payload.historySubmissions, - }, - ); + 'normal', + { target, historySubmissions: payload.historySubmissions }, + ); }; const resolveSessionSendConfig = (sessionId: string) => { diff --git a/packages/ui/src/lib/i18n/messages/de.settings.ts b/packages/ui/src/lib/i18n/messages/de.settings.ts index 47b55a26..a6f0fc3f 100644 --- a/packages/ui/src/lib/i18n/messages/de.settings.ts +++ b/packages/ui/src/lib/i18n/messages/de.settings.ts @@ -2012,6 +2012,8 @@ export const settingsDict = { 'settings.openchamber.visual.field.enableSpellcheckInTextInputs': 'Rechtschreibprüfung in Texteingaben aktivieren', 'settings.openchamber.visual.field.largeTextPaste': 'Großes Texteinfügen', 'settings.openchamber.visual.field.largeTextPasteHint': 'Beim Einfügen von mehr als etwa 2.000 Zeichen oder 25 Zeilen wählen, ob der Text als Datei angehängt, direkt eingefügt oder jedes Mal nachgefragt werden soll.', + 'settings.openchamber.visual.field.enterToSend': 'Enter sendet', + 'settings.openchamber.visual.field.enterToSendHint': 'Nach der Änderung steuern Enter und Shift+Enter das Verhalten auf jeder Oberfläche. Bis dahin behält jede Oberfläche ihr bestehendes Verhalten bei.', 'settings.openchamber.visual.field.largeTextPasteAria': 'Verhalten bei großem Texteinfügen', 'settings.openchamber.visual.field.largeTextPasteOptionAria': 'Großes Texteinfügen: {option}', 'settings.openchamber.visual.option.largeTextPaste.ask.label': 'Jedes Mal fragen', diff --git a/packages/ui/src/lib/i18n/messages/de.ts b/packages/ui/src/lib/i18n/messages/de.ts index b8381a9e..cf7ade00 100644 --- a/packages/ui/src/lib/i18n/messages/de.ts +++ b/packages/ui/src/lib/i18n/messages/de.ts @@ -2105,8 +2105,6 @@ export const dict = { 'chat.chatInput.actions.linkGithubPr': 'GitHub-PR verknüpfen', 'chat.chatInput.actions.modelAgentSettings': 'Modell- und Agenteneinstellungen', 'chat.chatInput.actions.sendMessageAria': 'Nachricht senden', - 'chat.chatInput.actions.enterToSend': 'Enter sendet', - 'chat.chatInput.actions.enterToSendHint': 'Nach der Änderung steuern Enter und Shift+Enter das Verhalten auf jeder Oberfläche. Bis dahin behält jede Oberfläche ihr bestehendes Verhalten bei.', 'chat.chatInput.actions.queueMessageAria': 'Nachricht in die Warteschlange stellen', 'chat.chatInput.actions.stopGeneratingAria': 'Generierung stoppen', 'chat.chatInput.focusMode.toggleAria': 'Fokusmodus umschalten', diff --git a/packages/ui/src/lib/i18n/messages/en.settings.ts b/packages/ui/src/lib/i18n/messages/en.settings.ts index 2c3a8974..2b911a70 100644 --- a/packages/ui/src/lib/i18n/messages/en.settings.ts +++ b/packages/ui/src/lib/i18n/messages/en.settings.ts @@ -2097,6 +2097,8 @@ export const settingsDict = { 'settings.openchamber.visual.field.enableSpellcheckInTextInputs': 'Enable Spellcheck in Text Inputs', 'settings.openchamber.visual.field.largeTextPaste': 'Large text paste', 'settings.openchamber.visual.field.largeTextPasteHint': 'When pasting more than about 2,000 characters or 25 lines, choose whether to attach the text as a file, paste it inline, or ask each time.', + 'settings.openchamber.visual.field.enterToSend': 'Enter sends', + 'settings.openchamber.visual.field.enterToSendHint': 'Once changed, this controls Enter and Shift+Enter on every surface. Until then, each surface keeps its existing behavior.', 'settings.openchamber.visual.field.largeTextPasteAria': 'Large text paste behavior', 'settings.openchamber.visual.field.largeTextPasteOptionAria': 'Large text paste: {option}', 'settings.openchamber.visual.option.largeTextPaste.ask.label': 'Ask each time', diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index a365e756..3de5182c 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -2322,8 +2322,6 @@ export const dict = { 'chat.chatInput.actions.linkGithubPr': 'Link GitHub PR', 'chat.chatInput.actions.modelAgentSettings': 'Model and agent settings', 'chat.chatInput.actions.sendMessageAria': 'Send message', - 'chat.chatInput.actions.enterToSend': 'Enter sends', - 'chat.chatInput.actions.enterToSendHint': 'Once changed, this controls Enter and Shift+Enter on every surface. Until then, each surface keeps its existing behavior.', 'chat.chatInput.actions.queueMessageAria': 'Queue message', 'chat.chatInput.actions.stopGeneratingAria': 'Stop generating', 'chat.chatInput.focusMode.toggleAria': 'Toggle focus mode', diff --git a/packages/ui/src/lib/i18n/messages/es.settings.ts b/packages/ui/src/lib/i18n/messages/es.settings.ts index 81022bfe..96d178bb 100644 --- a/packages/ui/src/lib/i18n/messages/es.settings.ts +++ b/packages/ui/src/lib/i18n/messages/es.settings.ts @@ -2246,6 +2246,8 @@ export const settingsDict = { "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", + "settings.openchamber.visual.field.enterToSend": "Enter envía", + "settings.openchamber.visual.field.enterToSendHint": "Después de cambiarlo, controla Enter y Shift+Enter en todas las superficies. Hasta entonces, cada superficie mantiene su comportamiento actual.", ...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/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index 029c0e90..622ca275 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -2300,8 +2300,6 @@ export const dict: Record = { "chat.chatInput.actions.linkGithubPr": "Vincular PR de GitHub", "chat.chatInput.actions.modelAgentSettings": "Configuración del modelo y agente", "chat.chatInput.actions.sendMessageAria": "Enviar mensaje", - 'chat.chatInput.actions.enterToSend': 'Enter envía', - 'chat.chatInput.actions.enterToSendHint': 'Después de cambiarlo, controla Enter y Shift+Enter en todas las superficies. Hasta entonces, cada superficie mantiene su comportamiento actual.', "chat.chatInput.actions.queueMessageAria": "Poner mensaje en cola", "chat.chatInput.actions.stopGeneratingAria": "Detener generación", "chat.chatInput.focusMode.toggleAria": "Activar o desactivar modo de enfoque", diff --git a/packages/ui/src/lib/i18n/messages/fr.settings.ts b/packages/ui/src/lib/i18n/messages/fr.settings.ts index e4f701b2..1d4202e5 100644 --- a/packages/ui/src/lib/i18n/messages/fr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr.settings.ts @@ -2246,6 +2246,8 @@ export const settingsDict = { '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', + 'settings.openchamber.visual.field.enterToSend': 'Entrée envoie', + 'settings.openchamber.visual.field.enterToSendHint': 'Après modification, ce réglage contrôle Entrée et Maj+Entrée sur toutes les surfaces. En attendant, chaque surface conserve son comportement actuel.', ...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/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index 80bc738f..41b228dc 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -2041,8 +2041,6 @@ export const dict = { 'chat.chatInput.actions.linkGithubPr': 'Lien GitHub PR', 'chat.chatInput.actions.modelAgentSettings': 'Paramètres du modèle et de l\'agent', 'chat.chatInput.actions.sendMessageAria': 'Envoyer un message', - 'chat.chatInput.actions.enterToSend': 'Entrée envoie', - 'chat.chatInput.actions.enterToSendHint': 'Après modification, ce réglage contrôle Entrée et Maj+Entrée sur toutes les surfaces. En attendant, chaque surface conserve son comportement actuel.', 'chat.chatInput.actions.queueMessageAria': 'Message de file d\'attente', 'chat.chatInput.actions.stopGeneratingAria': 'Arrêter de générer', 'chat.chatInput.focusMode.toggleAria': 'Basculer le mode de mise au point', diff --git a/packages/ui/src/lib/i18n/messages/ja.settings.ts b/packages/ui/src/lib/i18n/messages/ja.settings.ts index 7174eaa7..1b2d43e9 100644 --- a/packages/ui/src/lib/i18n/messages/ja.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja.settings.ts @@ -2246,6 +2246,8 @@ export const settingsDict = { 'settings.openchamber.visual.field.inputHistoryLimitDescription': 'この数を減らすと、履歴内の古いプロンプトはすぐに削除されます。', 'settings.openchamber.visual.field.inputHistoryLimitAria': '記憶するプロンプト数', 'settings.openchamber.visual.field.inputHistoryLimitUnit': '件', + 'settings.openchamber.visual.field.enterToSend': 'Enterで送信', + 'settings.openchamber.visual.field.enterToSendHint': '変更すると、すべての環境でEnterとShift+Enterの動作を制御します。変更するまでは、各環境の既存の動作が維持されます。', ...linearIntegrationI18n.ja, 'settings.page.integrations.title': '連携', 'settings.page.integrations.description': 'GitHub と Linear を接続すると、OpenChamber が Issue やプルリクエストを扱えるようになります。', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index af07a5e4..388a398a 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -2318,8 +2318,6 @@ export const dict: Record = { 'chat.chatInput.actions.linkGithubPr': 'GitHub PRをリンク', 'chat.chatInput.actions.modelAgentSettings': 'モデルとエージェント設定', 'chat.chatInput.actions.sendMessageAria': 'メッセージを送信', - 'chat.chatInput.actions.enterToSend': 'Enterで送信', - 'chat.chatInput.actions.enterToSendHint': '変更すると、すべての環境でEnterとShift+Enterの動作を制御します。変更するまでは、各環境の既存の動作が維持されます。', 'chat.chatInput.actions.queueMessageAria': 'メッセージをキュー', 'chat.chatInput.actions.stopGeneratingAria': '生成を停止', 'chat.chatInput.focusMode.toggleAria': 'フォーカスモードの切り替え', diff --git a/packages/ui/src/lib/i18n/messages/ko.settings.ts b/packages/ui/src/lib/i18n/messages/ko.settings.ts index c5b23e0d..076e7983 100644 --- a/packages/ui/src/lib/i18n/messages/ko.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ko.settings.ts @@ -2246,6 +2246,8 @@ export const settingsDict = { 'settings.openchamber.visual.field.inputHistoryLimitDescription': '이 숫자를 낮추면 기록에서 오래된 프롬프트가 바로 삭제됩니다.', 'settings.openchamber.visual.field.inputHistoryLimitAria': '기억할 프롬프트 수', 'settings.openchamber.visual.field.inputHistoryLimitUnit': '개', + 'settings.openchamber.visual.field.enterToSend': 'Enter로 전송', + 'settings.openchamber.visual.field.enterToSendHint': '변경하면 모든 환경에서 Enter와 Shift+Enter의 동작을 제어합니다. 변경하기 전에는 각 환경의 기존 동작이 유지됩니다.', ...linearIntegrationI18n.ko, 'settings.page.integrations.title': '통합', 'settings.page.integrations.description': 'GitHub와 Linear를 연결하면 OpenChamber가 이슈와 풀 리퀘스트를 다룰 수 있습니다.', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index 0e9206d9..79cfe10a 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -2322,8 +2322,6 @@ export const dict: Record = { 'chat.chatInput.actions.linkGithubPr': 'GitHub PR 연결', 'chat.chatInput.actions.modelAgentSettings': '모델 및 에이전트 설정', 'chat.chatInput.actions.sendMessageAria': '보내기 메시지', - 'chat.chatInput.actions.enterToSend': 'Enter로 전송', - 'chat.chatInput.actions.enterToSendHint': '변경하면 모든 환경에서 Enter와 Shift+Enter의 동작을 제어합니다. 변경하기 전에는 각 환경의 기존 동작이 유지됩니다.', 'chat.chatInput.actions.queueMessageAria': '메시지 대기열에 추가', 'chat.chatInput.actions.stopGeneratingAria': '생성 중지', 'chat.chatInput.focusMode.toggleAria': '집중 모드 전환', diff --git a/packages/ui/src/lib/i18n/messages/pl.settings.ts b/packages/ui/src/lib/i18n/messages/pl.settings.ts index 4e62ac21..6ed2cc9b 100644 --- a/packages/ui/src/lib/i18n/messages/pl.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pl.settings.ts @@ -2239,6 +2239,8 @@ export const settingsDict = { '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', + 'settings.openchamber.visual.field.enterToSend': 'Enter wysyła', + 'settings.openchamber.visual.field.enterToSendHint': 'Po zmianie ustawienie steruje działaniem klawiszy Enter i Shift+Enter na każdej powierzchni. Do tego czasu każda powierzchnia zachowuje dotychczasowe działanie.', ...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/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index 6af2b343..779c5cf6 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -1255,8 +1255,6 @@ export const dict: Record = { 'chat.chatInput.actions.modelAgentSettings': 'Model and agent settings', 'chat.chatInput.actions.queueMessageAria': 'Queue message', 'chat.chatInput.actions.sendMessageAria': 'Send message', - 'chat.chatInput.actions.enterToSend': 'Enter wysyła', - 'chat.chatInput.actions.enterToSendHint': 'Po zmianie ustawienie steruje działaniem klawiszy Enter i Shift+Enter na każdej powierzchni. Do tego czasu każda powierzchnia zachowuje dotychczasowe działanie.', 'chat.chatInput.actions.stopGeneratingAria': 'Stop generating', 'chat.chatInput.branch': 'Gałąź', 'chat.chatInput.draftPicker.projectTitle': 'Projekt', 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 80c6bee7..53d53d67 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts @@ -2246,6 +2246,8 @@ export const settingsDict = { "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", + "settings.openchamber.visual.field.enterToSend": "Enter envia", + "settings.openchamber.visual.field.enterToSendHint": "Depois de alterada, esta opção controla Enter e Shift+Enter em todas as superfícies. Até lá, cada superfície mantém seu comportamento atual.", ...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/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 5320a017..df8cc2d1 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -2300,8 +2300,6 @@ export const dict: Record = { "chat.chatInput.actions.linkGithubPr": "Vincular PR de GitHub", "chat.chatInput.actions.modelAgentSettings": "Configurações de modelo e agente", "chat.chatInput.actions.sendMessageAria": "Enviar mensagem", - 'chat.chatInput.actions.enterToSend': 'Enter envia', - 'chat.chatInput.actions.enterToSendHint': 'Depois de alterada, esta opção controla Enter e Shift+Enter em todas as superfícies. Até lá, cada superfície mantém seu comportamento atual.', "chat.chatInput.actions.queueMessageAria": "Colocar mensagem na fila", "chat.chatInput.actions.stopGeneratingAria": "Parar geração", "chat.chatInput.focusMode.toggleAria": "Ativar ou desativar modo de foco", diff --git a/packages/ui/src/lib/i18n/messages/tr.settings.ts b/packages/ui/src/lib/i18n/messages/tr.settings.ts index b73acc87..6ad8f82a 100644 --- a/packages/ui/src/lib/i18n/messages/tr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/tr.settings.ts @@ -1959,6 +1959,8 @@ export const settingsDict = { '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.field.enterToSend': 'Enter gönderir', + 'settings.openchamber.visual.field.enterToSendHint': 'Değiştirildikten sonra Enter ve Shift+Enter davranışını tüm yüzeylerde kontrol eder. O zamana kadar her yüzey mevcut davranışını korur.', '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/tr.ts b/packages/ui/src/lib/i18n/messages/tr.ts index 7391bbd4..75f8729d 100644 --- a/packages/ui/src/lib/i18n/messages/tr.ts +++ b/packages/ui/src/lib/i18n/messages/tr.ts @@ -2263,8 +2263,6 @@ export const dict = { 'chat.chatInput.actions.linkGithubPr': 'GitHub PR\'yi bağla', 'chat.chatInput.actions.modelAgentSettings': 'Model ve agent ayarları', 'chat.chatInput.actions.sendMessageAria': 'Mesaj gönder', - 'chat.chatInput.actions.enterToSend': 'Enter gönderir', - 'chat.chatInput.actions.enterToSendHint': 'Değiştirildikten sonra Enter ve Shift+Enter davranışını tüm yüzeylerde kontrol eder. O zamana kadar her yüzey mevcut davranışını korur.', 'chat.chatInput.actions.queueMessageAria': 'Mesajı kuyruğa ekle', 'chat.chatInput.actions.stopGeneratingAria': 'Üretmeyi durdur', 'chat.chatInput.focusMode.toggleAria': 'Odak modunu aç/kapat', diff --git a/packages/ui/src/lib/i18n/messages/uk.settings.ts b/packages/ui/src/lib/i18n/messages/uk.settings.ts index f84bcfee..82039ce5 100644 --- a/packages/ui/src/lib/i18n/messages/uk.settings.ts +++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts @@ -2246,6 +2246,8 @@ export const settingsDict = { "settings.openchamber.visual.field.inputHistoryLimitDescription": "Якщо зменшити це число, старіші промпти одразу буде видалено з історії.", "settings.openchamber.visual.field.inputHistoryLimitAria": "Скільки промптів пам’ятати", "settings.openchamber.visual.field.inputHistoryLimitUnit": "промптів", + "settings.openchamber.visual.field.enterToSend": "Enter надсилає", + "settings.openchamber.visual.field.enterToSendHint": "Після зміни цей параметр керує поведінкою Enter і Shift+Enter на всіх поверхнях. До цього кожна поверхня зберігає свою поточну поведінку.", ...linearIntegrationI18n.uk, 'settings.page.integrations.title': 'Інтеграції', 'settings.page.integrations.description': 'Підключіть GitHub і Linear, щоб OpenChamber міг працювати з вашими задачами та pull request-ами.', diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 57e11ecd..784acd6a 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -2300,8 +2300,6 @@ export const dict: Record = { "chat.chatInput.actions.linkGithubPr": "Пов’язати GitHub PR", "chat.chatInput.actions.modelAgentSettings": "Параметри моделі та агента", "chat.chatInput.actions.sendMessageAria": "Надіслати повідомлення", - 'chat.chatInput.actions.enterToSend': 'Enter надсилає', - 'chat.chatInput.actions.enterToSendHint': 'Після зміни цей параметр керує поведінкою Enter і Shift+Enter на всіх поверхнях. До цього кожна поверхня зберігає свою поточну поведінку.', "chat.chatInput.actions.queueMessageAria": "Поставити повідомлення в чергу", "chat.chatInput.actions.stopGeneratingAria": "Припинити генерацію", "chat.chatInput.focusMode.toggleAria": "Перемкнути режим фокусування", 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 599d953c..03934a11 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts @@ -2246,6 +2246,8 @@ export const settingsDict = { 'settings.openchamber.visual.field.inputHistoryLimitDescription': '调低这个数字会立即从历史记录中删除较早的提示词。', 'settings.openchamber.visual.field.inputHistoryLimitAria': '要记住的提示词数量', 'settings.openchamber.visual.field.inputHistoryLimitUnit': '条', + 'settings.openchamber.visual.field.enterToSend': 'Enter 发送', + 'settings.openchamber.visual.field.enterToSendHint': '更改后,此设置会控制所有界面中的 Enter 和 Shift+Enter。更改前,各界面保持现有行为。', ...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-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 9e7e80a6..9df5dd3a 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -2288,8 +2288,6 @@ export const dict: Record = { 'chat.chatInput.actions.linkGithubPr': '关联 GitHub PR', 'chat.chatInput.actions.modelAgentSettings': '模型与智能体设置', 'chat.chatInput.actions.sendMessageAria': '发送消息', - 'chat.chatInput.actions.enterToSend': 'Enter 发送', - 'chat.chatInput.actions.enterToSendHint': '更改后,此设置会控制所有界面中的 Enter 和 Shift+Enter。更改前,各界面保持现有行为。', 'chat.chatInput.actions.queueMessageAria': '将消息加入队列', 'chat.chatInput.actions.stopGeneratingAria': '停止生成', 'chat.chatInput.focusMode.toggleAria': '切换专注模式', 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 16d4adc9..6ac83fb6 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts @@ -2246,6 +2246,8 @@ export const settingsDict = { 'settings.openchamber.visual.field.inputHistoryLimitDescription': '調低這個數字會立刻從歷史記錄移除較早的提示詞。', 'settings.openchamber.visual.field.inputHistoryLimitAria': '要記住的提示詞數量', 'settings.openchamber.visual.field.inputHistoryLimitUnit': '則', + 'settings.openchamber.visual.field.enterToSend': 'Enter 傳送', + 'settings.openchamber.visual.field.enterToSendHint': '變更後,此設定會控制所有介面中的 Enter 與 Shift+Enter。變更前,各介面會維持現有行為。', ...linearIntegrationI18n['zh-TW'], 'settings.page.integrations.title': '整合', 'settings.page.integrations.description': '連接 GitHub 和 Linear,讓 OpenChamber 可以處理你的 issue 和 pull request。', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index ad82c33e..fb537bd7 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -2292,8 +2292,6 @@ export const dict: Record = { 'chat.chatInput.actions.linkGithubPr': '關聯 GitHub PR', 'chat.chatInput.actions.modelAgentSettings': '模型與 Agent 設定', 'chat.chatInput.actions.sendMessageAria': '傳送訊息', - 'chat.chatInput.actions.enterToSend': 'Enter 傳送', - 'chat.chatInput.actions.enterToSendHint': '變更後,此設定會控制所有介面中的 Enter 與 Shift+Enter。變更前,各介面會維持現有行為。', 'chat.chatInput.actions.queueMessageAria': '將訊息加入佇列', 'chat.chatInput.actions.stopGeneratingAria': '停止生成', 'chat.chatInput.focusMode.toggleAria': '切換專注模式', diff --git a/packages/ui/src/lib/inputHistoryScope.ts b/packages/ui/src/lib/inputHistoryScope.ts index ee1d1ad1..e2ae451a 100644 --- a/packages/ui/src/lib/inputHistoryScope.ts +++ b/packages/ui/src/lib/inputHistoryScope.ts @@ -1,6 +1,6 @@ export type InputHistoryScope = 'global' | 'session'; -export const DEFAULT_INPUT_HISTORY_SCOPE: InputHistoryScope = 'global'; +export const DEFAULT_INPUT_HISTORY_SCOPE: InputHistoryScope = 'session'; export const DEFAULT_INPUT_HISTORY_LIMIT = 40; export const MIN_INPUT_HISTORY_LIMIT = 1; export const MAX_INPUT_HISTORY_LIMIT = 100; diff --git a/packages/ui/src/lib/opencode/client.ts b/packages/ui/src/lib/opencode/client.ts index 4e2a105a..20e99eec 100644 --- a/packages/ui/src/lib/opencode/client.ts +++ b/packages/ui/src/lib/opencode/client.ts @@ -1,9 +1,9 @@ import type { ContextPartMetadata } from '@/lib/messages/contextParts'; import { createOpencodeClient, OpencodeClient } from "@opencode-ai/sdk/v2"; import type { PermissionV2Request, PermissionV2Effect, PermissionV2Source } from "@opencode-ai/sdk/v2/client"; +import { z } from "zod"; import type { FilesAPI } from "../api/types"; import { getDesktopHomeDirectory } from "../desktop"; -import { z } from "zod"; import type { Session, Message, @@ -343,7 +343,7 @@ const getDesktopFilesApi = (): FilesAPI | null => { }; // /api/fs/home parsing boundary. Older servers answer without chatsRoot; -// Only a valid home response may use the legacy chats-root fallback. +// only a valid home response may use the legacy chats-root fallback. const fsAbsolutePathSchema = z.string().trim().regex(/^(?:\/|[A-Za-z]:[\\/]|\\\\)/); const fsHomeResponseSchema = z.object({ home: fsAbsolutePathSchema, chatsRoot: fsAbsolutePathSchema.optional() }); diff --git a/packages/ui/src/lib/persistence.ts b/packages/ui/src/lib/persistence.ts index 2461ceca..40306564 100644 --- a/packages/ui/src/lib/persistence.ts +++ b/packages/ui/src/lib/persistence.ts @@ -21,9 +21,7 @@ import { isInputHistoryLimit, isInputHistoryScope, } from '@/lib/inputHistoryScope'; -import { - useInputHistoryStore, -} from '@/stores/useInputHistoryStore'; +import { useInputHistoryStore } from '@/stores/useInputHistoryStore'; import { normalizeMobileKeyboardMode, setStoredMobileKeyboardMode } from '@/lib/mobileKeyboardMode'; import { runtimeFetch } from '@/lib/runtime-fetch'; import { isCapacitorApp } from '@/lib/platform'; @@ -1081,14 +1079,11 @@ const applyDesktopUiPreferences = (settings: DesktopSettings) => { store.setGitChangesViewMode(settings.gitChangesViewMode); } } - switch (settings.toolJsonViewMode) { - case 'summary': - case 'formatted': - case 'raw': - if (settings.toolJsonViewMode !== store.toolJsonViewMode) { - store.setToolJsonViewMode(settings.toolJsonViewMode); - } - break; + if (typeof settings.toolJsonViewMode === 'string' + && (settings.toolJsonViewMode === 'summary' || settings.toolJsonViewMode === 'formatted' || settings.toolJsonViewMode === 'raw')) { + if (settings.toolJsonViewMode !== store.toolJsonViewMode) { + store.setToolJsonViewMode(settings.toolJsonViewMode); + } } if (typeof settings.directoryShowHidden === 'boolean') { setDirectoryShowHidden(settings.directoryShowHidden, { persist: false }); @@ -1677,12 +1672,11 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => { ) { result.gitChangesViewMode = candidate.gitChangesViewMode; } - switch (candidate.toolJsonViewMode) { - case 'summary': - case 'formatted': - case 'raw': - result.toolJsonViewMode = candidate.toolJsonViewMode; - break; + if ( + typeof candidate.toolJsonViewMode === 'string' + && (candidate.toolJsonViewMode === 'summary' || candidate.toolJsonViewMode === 'formatted' || candidate.toolJsonViewMode === 'raw') + ) { + result.toolJsonViewMode = candidate.toolJsonViewMode; } if (typeof candidate.directoryShowHidden === 'boolean') { result.directoryShowHidden = candidate.directoryShowHidden; diff --git a/packages/ui/src/lib/settings/search.ts b/packages/ui/src/lib/settings/search.ts index 8fe2abaf..ea0e2cc2 100644 --- a/packages/ui/src/lib/settings/search.ts +++ b/packages/ui/src/lib/settings/search.ts @@ -384,7 +384,8 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [ { id: 'chat.enter-to-send', page: 'chat', - titleKey: 'chat.chatInput.actions.enterToSend', + titleKey: 'settings.openchamber.visual.field.enterToSend', + descriptionKey: 'settings.openchamber.visual.field.enterToSendHint', keywords: ['enter', 'shift enter', 'send', 'newline'], }, { diff --git a/packages/ui/src/stores/DOCUMENTATION.md b/packages/ui/src/stores/DOCUMENTATION.md index 1a66a091..a038fe39 100644 --- a/packages/ui/src/stores/DOCUMENTATION.md +++ b/packages/ui/src/stores/DOCUMENTATION.md @@ -100,7 +100,7 @@ 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. +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. Recall defaults to the current session's bucket merged with the visible transcript's user prompts; the runtime-wide bucket is opt-in through the Chat setting. 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. diff --git a/packages/ui/src/stores/messageQueueStore.ts b/packages/ui/src/stores/messageQueueStore.ts index 510fdf92..fa48213e 100644 --- a/packages/ui/src/stores/messageQueueStore.ts +++ b/packages/ui/src/stores/messageQueueStore.ts @@ -1,8 +1,8 @@ -import { createInputHistoryIdentity, createInputHistorySubmission, useInputHistoryStore } from './useInputHistoryStore'; import { create } from 'zustand'; import { devtools, persist } from 'zustand/middleware'; import { z } from 'zod'; import type { Event } from '@opencode-ai/sdk/v2'; +import { createInputHistoryIdentity, createInputHistorySubmission, useInputHistoryStore } from './useInputHistoryStore'; import { createDeferredSafeJSONStorage } from './utils/safeStorage'; import type { AttachedFile } from './types/sessionTypes'; import { contextPartMetadataSchema, type ContextPartMetadata } from '@/lib/messages/contextParts'; diff --git a/packages/ui/src/stores/useInputHistoryStore.test.ts b/packages/ui/src/stores/useInputHistoryStore.test.ts index 6c95468e..caaf1944 100644 --- a/packages/ui/src/stores/useInputHistoryStore.test.ts +++ b/packages/ui/src/stores/useInputHistoryStore.test.ts @@ -117,9 +117,9 @@ describe('useInputHistoryStore', () => { const identity = mod.createInputHistoryIdentity('runtime-a', '/repo', 'session-1'); expect(identity).not.toBeNull(); - expect(DEFAULT_INPUT_HISTORY_SCOPE).toBe('global'); + expect(DEFAULT_INPUT_HISTORY_SCOPE).toBe('session'); expect(DEFAULT_INPUT_HISTORY_LIMIT).toBe(40); - expect(mod.useInputHistoryStore.getState().scope).toBe('global'); + expect(mod.useInputHistoryStore.getState().scope).toBe('session'); expect(mod.useInputHistoryStore.getState().entryLimit).toBe(DEFAULT_INPUT_HISTORY_LIMIT); expect(mod.selectInputHistoryEntries(mod.useInputHistoryStore.getState(), identity!)).toEqual([]); }); diff --git a/packages/ui/src/stores/useInputHistoryStore.ts b/packages/ui/src/stores/useInputHistoryStore.ts index ebc28e8d..c34ad553 100644 --- a/packages/ui/src/stores/useInputHistoryStore.ts +++ b/packages/ui/src/stores/useInputHistoryStore.ts @@ -259,7 +259,7 @@ const readSnapshot = (): InputHistorySnapshot => { }); }; -const cloneEntriesWithLimit = (entries: readonly InputHistoryEntry[], limit: number): InputHistoryEntry[] => ( +const trimEntriesToLimit = (entries: readonly InputHistoryEntry[], limit: number): InputHistoryEntry[] => ( entries.slice(Math.max(0, entries.length - limit)) ); @@ -273,7 +273,7 @@ const limitNamespaces = ( key, { touchedAt: namespace.touchedAt, - entries: cloneEntriesWithLimit(namespace.entries, entryLimit), + entries: trimEntriesToLimit(namespace.entries, entryLimit), }, ] as const) .sort((left, right) => right[1].touchedAt - left[1].touchedAt) @@ -381,8 +381,7 @@ export const createInputHistoryIdentity = ( const getAttachmentReference = (attachment: AttachedFile): string | null => { if (attachment.source === 'vscode' && attachment.vscodeSource === 'file') { - const normalizedPath = normalizePath(attachment.vscodePath ?? null); - return normalizedPath; + return normalizePath(attachment.vscodePath ?? null); } const candidate = attachment.dataUrl.trim(); return candidate || null; @@ -484,7 +483,7 @@ const appendToNamespace = ( } return { touchedAt, - entries: cloneEntriesWithLimit(entries, entryLimit), + entries: trimEntriesToLimit(entries, entryLimit), }; }; diff --git a/packages/ui/src/stores/useUIStore.ts b/packages/ui/src/stores/useUIStore.ts index 4c5ff7cb..0c07fd7f 100644 --- a/packages/ui/src/stores/useUIStore.ts +++ b/packages/ui/src/stores/useUIStore.ts @@ -2256,6 +2256,7 @@ export const useUIStore = create()( const trimmed = identifier?.trim() ?? ''; set({ linearIssueFocus: trimmed || null }); }, + setInputBarOffset: (offset) => { set({ inputBarOffset: offset }); }, diff --git a/packages/ui/src/sync/DOCUMENTATION.md b/packages/ui/src/sync/DOCUMENTATION.md index 83bb0627..4b46ffe8 100644 --- a/packages/ui/src/sync/DOCUMENTATION.md +++ b/packages/ui/src/sync/DOCUMENTATION.md @@ -198,7 +198,7 @@ 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. 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. +10. Session-scoped ArrowUp and ArrowDown recall merges the visible transcript's user prompts (`useUserMessageHistory`) with the persisted input-history bucket for runtime + normalized directory + session identity. Revert markers hide prompts from the transcript source only; the persisted bucket still recalls them. Global scope reads the persisted runtime bucket alone. 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/bootstrap.ts b/packages/ui/src/sync/bootstrap.ts index 622432cf..2622f607 100644 --- a/packages/ui/src/sync/bootstrap.ts +++ b/packages/ui/src/sync/bootstrap.ts @@ -69,9 +69,10 @@ export async function bootstrapGlobal( sdk: OpencodeClient, set: (patch: Partial) => void, ) { - // Sync chat classification needs the root before session lists load. - await warmChatsRootDirectory() const results = await Promise.allSettled([ + // Sync chat classification needs the chats root before session lists load; + // it resolves alongside the other bootstrap calls, not ahead of them. + warmChatsRootDirectory(), retry(() => sdk.path.get().then((x) => set({ path: unwrap(x, "path.get") }))), retry(() => sdk.global.config.get().then((x) => set({ config: unwrap(x, "global.config.get") }))), retry(() => diff --git a/packages/ui/src/sync/performance-diagnostics.ts b/packages/ui/src/sync/performance-diagnostics.ts index 90518c3e..095eba3c 100644 --- a/packages/ui/src/sync/performance-diagnostics.ts +++ b/packages/ui/src/sync/performance-diagnostics.ts @@ -36,6 +36,7 @@ export type SyncPerformanceCounters = { questionChangeCallbacks: number sessionMessageChangeCallbacks: number sessionRenderableNotificationSkips: number + userMessageHistoryNotificationSkips: number sessionMessageRecordNotificationSkips: number materializationEnqueues: number materializationEmptyAssistantEnqueues: number @@ -78,6 +79,7 @@ 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-ui-store.ts b/packages/ui/src/sync/session-ui-store.ts index b40c4e6a..855e4f4d 100644 --- a/packages/ui/src/sync/session-ui-store.ts +++ b/packages/ui/src/sync/session-ui-store.ts @@ -99,14 +99,6 @@ import { 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 { @@ -1734,8 +1726,9 @@ export const useSessionUIStore = create()((set, get) => ({ createdDraftSession.directory ?? '', createdDraftSession.sessionId, ) - const appendSubmissions = historyIdentity && options?.historySubmissions?.length - ? () => appendInputHistorySubmissions(historyIdentity, options.historySubmissions ?? []) + const historySubmissions = options?.historySubmissions + const appendSubmissions = historyIdentity && historySubmissions?.length + ? () => useInputHistoryStore.getState().appendSubmissions(historyIdentity, historySubmissions) : undefined notifyMessageSent(createdDraftSession.sessionId) @@ -1860,13 +1853,14 @@ export const useSessionUIStore = create()((set, get) => ({ const partsWithPinnedContext = prefixParts.length > 0 ? [...prefixParts, ...(additionalParts || [])] : additionalParts - const currentHistoryIdentity = createInputHistoryIdentity( + const historyIdentity = createInputHistoryIdentity( capturedRuntimeKey, currentSessionDirectory ?? '', targetSessionId || '', ) - const appendSubmissions = currentHistoryIdentity && options?.historySubmissions?.length - ? () => appendInputHistorySubmissions(currentHistoryIdentity, options.historySubmissions ?? []) + const historySubmissions = options?.historySubmissions + const appendSubmissions = historyIdentity && historySubmissions?.length + ? () => useInputHistoryStore.getState().appendSubmissions(historyIdentity, historySubmissions) : undefined const messageRoute = await routeMessage({ diff --git a/packages/ui/src/sync/sync-context.tsx b/packages/ui/src/sync/sync-context.tsx index fb4f7ad2..45e33cde 100644 --- a/packages/ui/src/sync/sync-context.tsx +++ b/packages/ui/src/sync/sync-context.tsx @@ -86,6 +86,7 @@ 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 TranscriptPrompt, type UserMessageHistorySnapshot } from "./user-message-history" import { EMPTY_SESSION_MESSAGE_LOAD_STATE, SessionMessageLoader, @@ -3432,6 +3433,51 @@ export function useSessionRenderable(sessionID: string, directory?: string): boo return React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot) } +/** + * The user's prompts in the visible transcript of a session, oldest first. + * Session-scoped ArrowUp recall merges this with the persisted input history, + * so sessions that predate the persisted store still recall their prompts. + */ +export function useUserMessageHistory(sessionID: string, directory?: string): TranscriptPrompt[] { + 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 new file mode 100644 index 00000000..61f0532c --- /dev/null +++ b/packages/ui/src/sync/user-message-history.test.ts @@ -0,0 +1,115 @@ +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', created = 1): Message => ({ + id, + role, + sessionID: 'ses_1', + time: { created }, +} 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('lists user prompts oldest first with their creation time', () => { + const first = message('user_1', 'user', 10); + const second = message('user_2', 'user', 20); + const snapshot = buildUserMessageHistorySnapshot( + state({ + message: { ses_1: [first, message('assistant_1', 'assistant', 15), second] }, + part: { user_1: [textPart('p1', 'first')], user_2: [textPart('p2', 'second')] }, + }), + 'ses_1', + ); + + expect(snapshot.history).toEqual([ + { text: 'first', createdAt: 10 }, + { text: 'second', createdAt: 20 }, + ]); + }); + + 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.map((prompt) => prompt.text)).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.map((prompt) => prompt.text)).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.map((prompt) => prompt.text)).toEqual(['kept']); + }); +}); diff --git a/packages/ui/src/sync/user-message-history.ts b/packages/ui/src/sync/user-message-history.ts new file mode 100644 index 00000000..3046b956 --- /dev/null +++ b/packages/ui/src/sync/user-message-history.ts @@ -0,0 +1,102 @@ +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[]; +}; + +/** One prompt the user sent in a session, as the visible transcript shows it. */ +export type TranscriptPrompt = { + text: string; + /** `message.time.created`, milliseconds. */ + createdAt: number; +}; + +export type UserMessageHistorySnapshot = { + sessionID: string; + revertMessageID?: string; + records: UserMessageHistoryRecord[]; + /** Oldest first. */ + history: TranscriptPrompt[]; +}; + +const EMPTY_PARTS: Part[] = []; +const EMPTY_RECORDS: UserMessageHistoryRecord[] = []; +const EMPTY_HISTORY: TranscriptPrompt[] = []; + +export const EMPTY_USER_MESSAGE_HISTORY_SNAPSHOT: UserMessageHistorySnapshot = { + sessionID: '', + revertMessageID: undefined, + records: EMPTY_RECORDS, + history: EMPTY_HISTORY, +}; + +const getFirstTextFromParts = (parts: Part[]): string => { + for (const part of parts) { + if (part.type === 'text' && part.text.length > 0) return part.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; +}; + +/** + * The user's prompts in a session, oldest first, limited to what the visible + * transcript shows: messages at or after a revert marker are excluded. Returns + * the previous snapshot when nothing relevant changed so subscribers can skip + * work on assistant-only updates. + */ +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?.revert?.messageID; + const records: UserMessageHistoryRecord[] = []; + for (const message of messagesBefore(messages, revertMessageID)) { + 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: TranscriptPrompt[] = []; + for (const record of records) { + const text = getFirstTextFromParts(record.parts); + if (text.length > 0) { + history.push({ text, createdAt: record.message.time.created }); + } + } + + return { sessionID, revertMessageID, records, history }; +}; diff --git a/packages/vscode/src/DOCUMENTATION.md b/packages/vscode/src/DOCUMENTATION.md index 29b36777..fa988275 100644 --- a/packages/vscode/src/DOCUMENTATION.md +++ b/packages/vscode/src/DOCUMENTATION.md @@ -66,7 +66,6 @@ The webview build emits each worker as one self-contained file. VS Code webviews - `bridge-settings-runtime.ts` - Settings read/write and OpenCode skills discovery via API for bridge consumers. - - `settings-changes.ts` validates Enter preferences independently before writes; invalid values are omitted without dropping unrelated changes. - `bridge-system-runtime.ts` - System/editor/provider/quota/notification/update-check message handlers. diff --git a/packages/vscode/src/bridge-settings-runtime.ts b/packages/vscode/src/bridge-settings-runtime.ts index 85a5b438..d2b01669 100644 --- a/packages/vscode/src/bridge-settings-runtime.ts +++ b/packages/vscode/src/bridge-settings-runtime.ts @@ -5,7 +5,6 @@ import * as path from 'path'; import * as vscode from 'vscode'; import { BUILT_IN_SKILL_LOCATION, type DiscoveredSkill, type SkillScope, type SkillSource } from './opencodeConfig'; import type { BridgeContext } from './bridge'; -import { enterSettingsSchema } from './settings-changes'; const SETTINGS_KEY = 'openchamber.settings'; const OPENCHAMBER_SHARED_SETTINGS_PATH = path.join(os.homedir(), '.config', 'openchamber', 'settings.json'); @@ -293,14 +292,6 @@ export const readSettings = (ctx?: BridgeContext): Record => { export const persistSettings = async (changes: Record, ctx?: BridgeContext): Promise> => { const current = readSettings(ctx); const restChanges = stripDerived({ ...(changes || {}) }); - const enterSettings = enterSettingsSchema.parse(restChanges); - for (const key of ['enterToSend', 'enterToSendConfigured'] as const) { - if (enterSettings[key] === undefined) { - delete restChanges[key]; - } else { - restChanges[key] = enterSettings[key]; - } - } const keysToClear = new Set(); diff --git a/packages/vscode/src/settings-changes.test.js b/packages/vscode/src/settings-changes.test.js deleted file mode 100644 index 03a09a8d..00000000 --- a/packages/vscode/src/settings-changes.test.js +++ /dev/null @@ -1,19 +0,0 @@ -import { describe, expect, test } from 'bun:test'; -import { enterSettingsSchema } from './settings-changes'; - -describe('VS Code Enter settings validation', () => { - test('preserves both explicit choices', () => { - expect(enterSettingsSchema.parse({ enterToSend: true, enterToSendConfigured: false })) - .toEqual({ enterToSend: true, enterToSendConfigured: false }); - expect(enterSettingsSchema.parse({ enterToSend: false, enterToSendConfigured: true })) - .toEqual({ enterToSend: false, enterToSendConfigured: true }); - }); - - test('omits invalid fields independently', () => { - expect(enterSettingsSchema.parse({ enterToSend: 'true', enterToSendConfigured: true })) - .toEqual({ enterToSend: undefined, enterToSendConfigured: true }); - expect(enterSettingsSchema.parse({ enterToSend: false, enterToSendConfigured: 1 })) - .toEqual({ enterToSend: false, enterToSendConfigured: undefined }); - expect(enterSettingsSchema.parse({})).toEqual({}); - }); -}); diff --git a/packages/vscode/src/settings-changes.ts b/packages/vscode/src/settings-changes.ts deleted file mode 100644 index f157d5fc..00000000 --- a/packages/vscode/src/settings-changes.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { z } from 'zod'; - -// Invalid fields are omitted independently so one malformed preference cannot -// discard the other preference or any unrelated settings in the same write. -export const enterSettingsSchema = z.object({ - enterToSend: z.boolean().optional().catch(undefined), - enterToSendConfigured: z.boolean().optional().catch(undefined), -}); diff --git a/packages/web/bin/lib/cli-startup.js b/packages/web/bin/lib/cli-startup.js index 70cdbe46..da6837ad 100644 --- a/packages/web/bin/lib/cli-startup.js +++ b/packages/web/bin/lib/cli-startup.js @@ -313,8 +313,7 @@ function getCurrentUsername() { // when the user has lingering enabled. Detect it so `startup enable` can warn // that the service may otherwise stop on logout. Returns null when the state // cannot be determined (no username, loginctl unavailable, or odd output). -function getUserLingerEnabled(username) { - const user = typeof username === 'string' && username.length > 0 ? username : getCurrentUsername(); +function getUserLingerEnabled(user) { if (!user) { return null; } diff --git a/packages/web/server/index.js b/packages/web/server/index.js index cf860665..0f8068f6 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -1320,7 +1320,7 @@ const resolveMemoryProjectId = createMemoryProjectResolver({ return sanitizeProjects(settings?.projects || []).map((project) => project.path); }, resolvePrimaryWorktreeRoot, - managedProjectRoots: [path.join(OPENCHAMBER_USER_CONFIG_ROOT, 'chats'), OPENCHAMBER_CHATS_DIR], + managedProjectRoots: [...new Set([path.join(OPENCHAMBER_USER_CONFIG_ROOT, 'chats'), OPENCHAMBER_CHATS_DIR])], }); /** diff --git a/packages/web/server/lib/fs/routes.js b/packages/web/server/lib/fs/routes.js index 38aeb2ff..cda4ec5c 100644 --- a/packages/web/server/lib/fs/routes.js +++ b/packages/web/server/lib/fs/routes.js @@ -526,9 +526,12 @@ export const registerFsRoutes = (app, dependencies) => { openchamberUserConfigRoot, managedChatsRoot, } = dependencies; - const managedRoots = [openchamberUserConfigRoot, managedChatsRoot] - .filter((root) => typeof root === 'string' && root.trim().length > 0) - .map((root) => path.resolve(root)); + // Chat worktrees may live outside every project workspace; both managed + // roots stay valid filesystem targets. + const chatsRoot = typeof managedChatsRoot === 'string' && managedChatsRoot.trim() + ? path.resolve(managedChatsRoot.trim()) + : path.join(openchamberUserConfigRoot, 'chats'); + const managedRoots = [path.resolve(openchamberUserConfigRoot), chatsRoot]; const realpathCache = createRealpathCache({ realpath: fsPromises.realpath.bind(fsPromises), }); @@ -707,9 +710,6 @@ export const registerFsRoutes = (app, dependencies) => { if (!home || typeof home !== 'string' || home.length === 0) { return res.status(500).json({ error: 'Failed to resolve home directory' }); } - const chatsRoot = managedChatsRoot && managedChatsRoot.trim() - ? path.resolve(managedChatsRoot.trim()) - : path.join(openchamberUserConfigRoot, 'chats'); return res.json({ home, chatsRoot }); } catch (error) { console.error('Failed to resolve home directory:', error); diff --git a/packages/web/server/lib/opencode/input-history-scope.js b/packages/web/server/lib/opencode/input-history-scope.js index 38fe2332..ba690680 100644 --- a/packages/web/server/lib/opencode/input-history-scope.js +++ b/packages/web/server/lib/opencode/input-history-scope.js @@ -1,4 +1,4 @@ -export const DEFAULT_INPUT_HISTORY_SCOPE = 'global'; +export const DEFAULT_INPUT_HISTORY_SCOPE = 'session'; export const DEFAULT_INPUT_HISTORY_LIMIT = 40; const MIN_INPUT_HISTORY_LIMIT = 1; const MAX_INPUT_HISTORY_LIMIT = 100; diff --git a/packages/web/server/lib/opencode/settings-helpers.js b/packages/web/server/lib/opencode/settings-helpers.js index 7e55d24f..8b245548 100644 --- a/packages/web/server/lib/opencode/settings-helpers.js +++ b/packages/web/server/lib/opencode/settings-helpers.js @@ -705,12 +705,11 @@ export const createSettingsHelpers = (dependencies) => { result.gitChangesViewMode = mode; } } - switch (candidate.toolJsonViewMode) { - case 'summary': - case 'formatted': - case 'raw': - result.toolJsonViewMode = candidate.toolJsonViewMode; - break; + if (typeof candidate.toolJsonViewMode === 'string') { + const mode = candidate.toolJsonViewMode.trim(); + if (mode === 'summary' || mode === 'formatted' || mode === 'raw') { + result.toolJsonViewMode = mode; + } } if (typeof candidate.directoryShowHidden === 'boolean') { result.directoryShowHidden = candidate.directoryShowHidden; diff --git a/packages/web/server/lib/opencode/settings-helpers.test.js b/packages/web/server/lib/opencode/settings-helpers.test.js index c2a5cf48..0e0f5435 100644 --- a/packages/web/server/lib/opencode/settings-helpers.test.js +++ b/packages/web/server/lib/opencode/settings-helpers.test.js @@ -121,7 +121,7 @@ describe('settings helpers', () => { inputHistoryLimit: 40, }); expect(helpers.formatSettingsResponse({})).toMatchObject({ - inputHistoryScope: 'global', + inputHistoryScope: 'session', inputHistoryLimit: DEFAULT_INPUT_HISTORY_LIMIT, }); } finally { @@ -265,14 +265,14 @@ describe('settings helpers', () => { expect(helpers.sanitizeSettingsUpdate({ inputHistoryScope: 'workspace' })).toEqual({}); }); - it('defaults inputHistoryScope to global in formatted settings responses', () => { + it('defaults inputHistoryScope to session in formatted settings responses', () => { const helpers = createTestHelpers(); - expect(helpers.formatSettingsResponse({ inputHistoryScope: 'session' })).toMatchObject({ - inputHistoryScope: 'session', + expect(helpers.formatSettingsResponse({ inputHistoryScope: 'global' })).toMatchObject({ + inputHistoryScope: 'global', }); expect(helpers.formatSettingsResponse({})).toMatchObject({ - inputHistoryScope: 'global', + inputHistoryScope: 'session', }); });