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.
This commit is contained in:
Bohdan Triapitsyn
2026-09-05 20:16:14 +03:00
parent 3df97908fe
commit f46fb718c5
73 changed files with 513 additions and 242 deletions
@@ -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`. Ü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` ### `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. Steuert die Komprimierung von API-Antworten. Verwende `true` oder `1`, um sie zu erzwingen, und `false` oder `0`, um sie zu deaktivieren.
@@ -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`. 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` ### `OPENCHAMBER_COMPRESS_API`
Controls API response compression. Use `true` or `1` to force it on, `false` or `0` to force it off. Controls API response compression. Use `true` or `1` to force it on, `false` or `0` to force it off.
@@ -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`. 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` ### `OPENCHAMBER_COMPRESS_API`
Controla la compresión de respuestas de la API. `true` o `1` la activa; `false` o `0` la desactiva. Controla la compresión de respuestas de la API. `true` o `1` la activa; `false` o `0` la desactiva.
@@ -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`. 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` ### `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. Contrôle la compression des réponses API. Utilisez `true` ou `1` pour la forcer, `false` ou `0` pour la désactiver.
@@ -25,6 +25,10 @@ OpenChamber Web サーバーのバインドアドレスです。他のマシン
OpenChamber のデータディレクトリを上書きします。デフォルトは `~/.config/openchamber` です。 OpenChamber のデータディレクトリを上書きします。デフォルトは `~/.config/openchamber` です。
### `OPENCHAMBER_CHATS_DIR`
プロジェクトを持たないチャット用に OpenChamber が作成する管理チャットディレクトリの場所を変更します。デフォルトは `~/.config/openchamber/chats` です。OpenChamber と OpenCode を別のユーザーで実行している場合は、OpenCode サーバーが読み取れるディレクトリを指定してください。既存のチャットは移動されません。
### `OPENCHAMBER_COMPRESS_API` ### `OPENCHAMBER_COMPRESS_API`
API レスポンス圧縮を制御します。`true` または `1` で強制的に有効化し、`false` または `0` で強制的に無効化します。 API レスポンス圧縮を制御します。`true` または `1` で強制的に有効化し、`false` または `0` で強制的に無効化します。
@@ -25,6 +25,10 @@ OpenChamber 웹 서버가 바인딩할 주소입니다. 다른 컴퓨터에서
OpenChamber 데이터 디렉터리를 바꿉니다. 기본값은 `~/.config/openchamber`입니다. OpenChamber 데이터 디렉터리를 바꿉니다. 기본값은 `~/.config/openchamber`입니다.
### `OPENCHAMBER_CHATS_DIR`
프로젝트가 없는 채팅을 위해 OpenChamber가 만드는 관리형 채팅 디렉터리의 위치를 바꿉니다. 기본값은 `~/.config/openchamber/chats`입니다. OpenChamber와 OpenCode가 서로 다른 사용자로 실행될 때는 OpenCode 서버가 읽을 수 있는 디렉터리를 지정하세요. 기존 채팅은 이동되지 않습니다.
### `OPENCHAMBER_COMPRESS_API` ### `OPENCHAMBER_COMPRESS_API`
API 응답 압축을 제어합니다. `true` 또는 `1`은 켜고, `false` 또는 `0`은 끕니다. API 응답 압축을 제어합니다. `true` 또는 `1`은 켜고, `false` 또는 `0`은 끕니다.
@@ -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`. 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` ### `OPENCHAMBER_COMPRESS_API`
Steruje kompresją odpowiedzi API. `true` albo `1` ją włącza, `false` albo `0` wyłącza. Steruje kompresją odpowiedzi API. `true` albo `1` ją włącza, `false` albo `0` wyłącza.
@@ -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`. 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` ### `OPENCHAMBER_COMPRESS_API`
Controla a compressão das respostas da API. `true` ou `1` ativa; `false` ou `0` desativa. Controla a compressão das respostas da API. `true` ou `1` ativa; `false` ou `0` desativa.
@@ -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 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` ### `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. 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.
@@ -25,6 +25,10 @@ OpenChamber читає ці змінні під час запуску. Для st
Перевизначає директорію даних OpenChamber. Типово це `~/.config/openchamber`. Перевизначає директорію даних OpenChamber. Типово це `~/.config/openchamber`.
### `OPENCHAMBER_CHATS_DIR`
Переносить директорії керованих чатів, які OpenChamber створює для чатів без проєкту. Типово це `~/.config/openchamber/chats`. Вкажіть директорію, яку може читати сервер OpenCode, якщо OpenChamber і OpenCode працюють від різних користувачів. Наявні чати не переносяться.
### `OPENCHAMBER_COMPRESS_API` ### `OPENCHAMBER_COMPRESS_API`
Керує стисненням API-відповідей. `true` або `1` вмикає, `false` або `0` вимикає. Керує стисненням API-відповідей. `true` або `1` вмикає, `false` або `0` вимикає.
@@ -25,6 +25,10 @@ OpenChamber web 服务器监听的地址。使用 `0.0.0.0` 可允许其他机
覆盖 OpenChamber 数据目录。默认是 `~/.config/openchamber`。 覆盖 OpenChamber 数据目录。默认是 `~/.config/openchamber`。
### `OPENCHAMBER_CHATS_DIR`
更改 OpenChamber 为无项目聊天创建的托管聊天目录的位置。默认是 `~/.config/openchamber/chats`。当 OpenChamber 和 OpenCode 以不同用户运行时,请设置为 OpenCode 服务器可读取的目录。现有聊天不会被移动。
### `OPENCHAMBER_COMPRESS_API` ### `OPENCHAMBER_COMPRESS_API`
控制 API 响应压缩。`true` 或 `1` 强制开启,`false` 或 `0` 强制关闭。 控制 API 响应压缩。`true` 或 `1` 强制开启,`false` 或 `0` 强制关闭。
+13 -7
View File
@@ -175,7 +175,9 @@ import {
buildChatInputHistorySubmissions, buildChatInputHistorySubmissions,
buildInputHistoryNavigatorIdentity, buildInputHistoryNavigatorIdentity,
mapInputHistoryEntriesToValues, mapInputHistoryEntriesToValues,
mergeSessionInputHistory,
} from './inputHistory'; } from './inputHistory';
import { useUserMessageHistory } from '@/sync/sync-context';
// Lazy like in ChatMessage: a static import would pull the @pierre/diffs and // 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. // 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 MOBILE_COMPOSER_BOUND_GAP_PX = 4;
const EMPTY_QUEUE: QueuedMessage[] = []; const EMPTY_QUEUE: QueuedMessage[] = [];
const EMPTY_INPUT_HISTORY_ENTRIES = Object.freeze([] as const);
const COMPACT_CHAT_PLACEHOLDER_MAX_WIDTH = 560; const COMPACT_CHAT_PLACEHOLDER_MAX_WIDTH = 560;
const renameFileForAttachmentCitation = (file: File, filename: string): File => { const renameFileForAttachmentCitation = (file: File, filename: string): File => {
if (file.name === filename) { if (file.name === filename) {
@@ -908,13 +909,18 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
), ),
[activeRuntimeKey, currentDirectory, currentSessionDirectoryForSync, currentSessionId], [activeRuntimeKey, currentDirectory, currentSessionDirectoryForSync, currentSessionId],
); );
const inputHistoryEntries = useInputHistoryStore(React.useCallback((state) => { const inputHistoryEntries = useInputHistoryStore(React.useCallback(
const entries = selectInputHistoryEntries(state, inputHistoryIdentity); (state) => selectInputHistoryEntries(state, inputHistoryIdentity),
return entries.length === 0 ? EMPTY_INPUT_HISTORY_ENTRIES : entries; [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( const historyValues = React.useMemo(
() => mapInputHistoryEntriesToValues(inputHistoryEntries), () => (inputHistoryScope === 'session'
[inputHistoryEntries], ? mergeSessionInputHistory(transcriptPrompts, inputHistoryEntries)
: mapInputHistoryEntriesToValues(inputHistoryEntries)),
[inputHistoryEntries, inputHistoryScope, transcriptPrompts],
); );
const messageHistoryIdentity = React.useMemo( const messageHistoryIdentity = React.useMemo(
() => buildInputHistoryNavigatorIdentity(inputHistoryScope, inputHistoryIdentity), () => buildInputHistoryNavigatorIdentity(inputHistoryScope, inputHistoryIdentity),
@@ -199,17 +199,22 @@ Prompt recall has two owners on purpose.
- `packages/ui/src/stores/useInputHistoryStore.ts` owns the persisted source of - `packages/ui/src/stores/useInputHistoryStore.ts` owns the persisted source of
truth. It keeps the runtime-scoped global bucket and the runtime + directory truth. It keeps the runtime-scoped global bucket and the runtime + directory
+ session bucket, each capped by the configurable input-history limit. That + 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 - `state/useMessageHistory.ts` owns only keyboard traversal through whichever
bucket the composer was given. It stashes the current draft on entry and bucket the composer was given. Moving away from a position stores the
restores it on the way back out. composer's current text and attachments as an overlay for that position, so
- `ChatInput.tsx` owns the recalled-entry presentation. If the user edits a the live draft and any edit made to a recalled prompt survive a round trip
recalled prompt, the UI may show an overlay state for "this came from through history. Overlays never rewrite stored history; sending resets them.
history", but that edit does not rewrite stored history. - `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 In session scope the composer merges two sources, oldest first: the visible
hide older user messages from the chat timeline, but they do not decide what transcript's user prompts (`useUserMessageHistory` in `sync-context.tsx`), so
ArrowUp and ArrowDown can recall. 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 ## Mobile
@@ -234,7 +234,7 @@ export const ComposerEditor = React.forwardRef<ComposerEditorHandle, ComposerEdi
// A deferred Enter loses its modifiers in the re-dispatch. // A deferred Enter loses its modifiers in the re-dispatch.
if (event.key === 'Enter' && isDeferredSyntheticEvent(event)) { if (event.key === 'Enter' && isDeferredSyntheticEvent(event)) {
const preserveShift = handlersRef.current.preserveDeferredEnterShift !== false; const preserveShift = handlersRef.current.preserveDeferredEnterShift !== false;
restoreDeferredEnterModifiers(event, lastRealEnterModsRef.current, { preserveShift }); restoreDeferredEnterModifiers(event, lastRealEnterModsRef.current, preserveShift);
clearDeferredEnterModifiers(); clearDeferredEnterModifiers();
} }
return handlersRef.current.onKeyDown?.(event) ?? false; return handlersRef.current.onKeyDown?.(event) ?? false;
@@ -57,7 +57,7 @@ describe('deferred Enter modifiers', () => {
for (const modifiers of [{ shiftKey: true, ctrlKey: true, metaKey: false }, { shiftKey: true, ctrlKey: false, metaKey: true }]) { 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)}`, () => { test(`untouched mobile deferred Shift does not submit: ${JSON.stringify(modifiers)}`, () => {
const event = { shiftKey: false, ctrlKey: false, metaKey: false }; 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); 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', () => { test('does not restore iOS auto-capitalization as Shift', () => {
const event = { shiftKey: false, ctrlKey: false, metaKey: false }; 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 }); expect(event).toEqual({ shiftKey: false, ctrlKey: false, metaKey: false });
}); });
@@ -28,16 +28,12 @@ export interface EnterModifierState {
metaKey: boolean; metaKey: boolean;
} }
interface DeferredEnterModifierOptions {
preserveShift?: boolean;
}
export const restoreDeferredEnterModifiers = ( export const restoreDeferredEnterModifiers = (
event: EnterModifierState, event: EnterModifierState,
modifiers: EnterModifierState, modifiers: EnterModifierState,
options: DeferredEnterModifierOptions = {}, preserveShift = true,
): void => { ): void => {
if (options.preserveShift !== false && modifiers.shiftKey) { if (preserveShift && modifiers.shiftKey) {
Object.defineProperty(event, 'shiftKey', { value: true }); Object.defineProperty(event, 'shiftKey', { value: true });
} }
if (modifiers.ctrlKey) Object.defineProperty(event, 'ctrlKey', { value: true }); if (modifiers.ctrlKey) Object.defineProperty(event, 'ctrlKey', { value: true });
@@ -7,6 +7,7 @@ import {
buildChatInputHistorySubmissions, buildChatInputHistorySubmissions,
buildInputHistoryNavigatorIdentity, buildInputHistoryNavigatorIdentity,
mapInputHistoryEntriesToValues, mapInputHistoryEntriesToValues,
mergeSessionInputHistory,
} from './inputHistory'; } from './inputHistory';
const ATTACHMENT: AttachedFile = { const ATTACHMENT: AttachedFile = {
@@ -131,3 +132,43 @@ describe('buildInputHistoryNavigatorIdentity', () => {
})).toBe('session\nruntime-a\n/repo\nsession-1'); })).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');
});
});
+25 -25
View File
@@ -8,6 +8,8 @@ import {
type InputHistorySubmission, type InputHistorySubmission,
} from '@/stores/useInputHistoryStore'; } from '@/stores/useInputHistoryStore';
import type { AttachedFile } from '@/stores/types/sessionTypes'; import type { AttachedFile } from '@/stores/types/sessionTypes';
import type { TranscriptPrompt } from '@/sync/user-message-history';
import { toServerFileUrl } from './composer/attachments/filePaths';
type HistoryQueuedMessage = { type HistoryQueuedMessage = {
content: string; content: string;
@@ -22,30 +24,6 @@ type BuildHistorySubmissionsArgs = {
includeComposer: boolean; 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({ export function buildChatInputHistorySubmissions({
inputMode, inputMode,
queuedMessages, queuedMessages,
@@ -85,7 +63,7 @@ function materializeHistoryAttachment(attachment: InputHistoryAttachment): Attac
return { return {
id: `history-${attachment.key}`, id: `history-${attachment.key}`,
file: new File([], attachment.filename, { type: attachment.mimeType }), file: new File([], attachment.filename, { type: attachment.mimeType }),
dataUrl: toFileUrl(attachment.reference), dataUrl: toServerFileUrl(attachment.reference),
mimeType: attachment.mimeType, mimeType: attachment.mimeType,
filename: attachment.filename, filename: attachment.filename,
size: attachment.size, 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<MessageHistoryValue<AttachedFile>> {
const persistedTexts = new Set(entries.map((entry) => entry.text));
const timed: Array<{ at: number; value: MessageHistoryValue<AttachedFile> }> = [
...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( export function buildInputHistoryNavigatorIdentity(
scope: InputHistoryScope, scope: InputHistoryScope,
identity: InputHistoryIdentity | null, identity: InputHistoryIdentity | null,
@@ -70,9 +70,7 @@ import {
import { isTerminalShell } from '@/lib/terminalShell'; import { isTerminalShell } from '@/lib/terminalShell';
import { subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch'; import { subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch';
import { formatShortcutForDisplay } from '@/lib/shortcuts'; import { formatShortcutForDisplay } from '@/lib/shortcuts';
import { import { useInputHistoryStore } from '@/stores/useInputHistoryStore';
useInputHistoryStore,
} from '@/stores/useInputHistoryStore';
interface Option<T extends string> { interface Option<T extends string> {
id: T; id: T;
@@ -738,7 +736,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|| shouldShow('showToolFileIcons') || shouldShow('showToolFileIcons')
|| shouldShow('showTurnChangedFiles') || shouldShow('showTurnChangedFiles')
|| (!isMobile && shouldShow('inputSpellcheck')) || (!isMobile && shouldShow('inputSpellcheck'))
|| shouldShow('enterToSend') || shouldShow('enterToSend')
|| shouldShow('reasoning') || shouldShow('reasoning')
|| shouldShow('expandedTools'); || shouldShow('expandedTools');
// First behavior section under the page header should not draw a top border on Chat-only; // 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<OpenChamberVisualSettingsProps>
/> />
))} ))}
</SettingsRadioGroup> </SettingsRadioGroup>
</SettingsControlGroup> </SettingsControlGroup>
)} )}
<div className={SETTINGS_OPTION_STACK_CLASS}> <div className={SETTINGS_OPTION_STACK_CLASS}>
{shouldShow('autoSaveEnabled') && ( {shouldShow('autoSaveEnabled') && (
@@ -2150,9 +2148,9 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
<SettingsCheckboxRow <SettingsCheckboxRow
checked={enterToSendConfigured ? enterToSend : !isMobile && !isExpandedInput} checked={enterToSendConfigured ? enterToSend : !isMobile && !isExpandedInput}
onChange={handleEnterToSendChange} onChange={handleEnterToSendChange}
label={t('chat.chatInput.actions.enterToSend')} label={t('settings.openchamber.visual.field.enterToSend')}
info={t('chat.chatInput.actions.enterToSendHint')} info={t('settings.openchamber.visual.field.enterToSendHint')}
ariaLabel={t('chat.chatInput.actions.enterToSend')} ariaLabel={t('settings.openchamber.visual.field.enterToSend')}
settingsItem="chat.enter-to-send" settingsItem="chat.enter-to-send"
/> />
)} )}
@@ -2134,7 +2134,6 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
} }
applyLoadedTextContent(content); applyLoadedTextContent(content);
}, },
maxBytes: MAX_CONTENT_POLL_BYTES,
}) })
: null; : null;
@@ -2165,7 +2164,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
if (contentPoller && latestStat.size <= MAX_CONTENT_POLL_BYTES) { if (contentPoller && latestStat.size <= MAX_CONTENT_POLL_BYTES) {
// Only an observed read retires the change; a dirty buffer or a // Only an observed read retires the change; a dirty buffer or a
// failed read leaves the baseline so the next tick retries. // 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) { if (observed && !cancelled) {
lastLoadedFileStatRef.current = latestStat; lastLoadedFileStatRef.current = latestStat;
} }
@@ -2,8 +2,6 @@ import { describe, expect, test } from 'bun:test';
import { createFileContentPoller } from './fileContentPoller'; import { createFileContentPoller } from './fileContentPoller';
const MAX_BYTES = 200_000;
const deferred = <T>() => { const deferred = <T>() => {
let resolve!: (value: T) => void; let resolve!: (value: T) => void;
const promise = new Promise<T>((done) => { resolve = done; }); const promise = new Promise<T>((done) => { resolve = done; });
@@ -23,11 +21,10 @@ describe('createFileContentPoller', () => {
getLoadedRevision: () => 0, getLoadedRevision: () => 0,
isDirty: () => false, isDirty: () => false,
applyContent: (content) => applied.push(content), applyContent: (content) => applied.push(content),
maxBytes: MAX_BYTES,
}); });
expect(await poller.poll(12)).toBe(true); expect(await poller.poll()).toBe(true);
expect(await poller.poll(12)).toBe(true); expect(await poller.poll()).toBe(true);
expect(reads).toBe(2); expect(reads).toBe(2);
expect(applied).toEqual([]); expect(applied).toEqual([]);
@@ -41,10 +38,9 @@ describe('createFileContentPoller', () => {
getLoadedRevision: () => 0, getLoadedRevision: () => 0,
isDirty: () => false, isDirty: () => false,
applyContent: (content) => applied.push(content), applyContent: (content) => applied.push(content),
maxBytes: MAX_BYTES,
}); });
expect(await poller.poll(6)).toBe(false); expect(await poller.poll()).toBe(false);
expect(applied).toEqual([]); expect(applied).toEqual([]);
}); });
@@ -59,10 +55,9 @@ describe('createFileContentPoller', () => {
getLoadedRevision: () => 0, getLoadedRevision: () => 0,
isDirty: () => true, isDirty: () => true,
applyContent: () => undefined, applyContent: () => undefined,
maxBytes: MAX_BYTES,
}); });
expect(await poller.poll(6)).toBe(false); expect(await poller.poll()).toBe(false);
expect(reads).toBe(0); expect(reads).toBe(0);
}); });
@@ -78,10 +73,9 @@ describe('createFileContentPoller', () => {
getLoadedRevision: () => 0, getLoadedRevision: () => 0,
isDirty: () => false, isDirty: () => false,
applyContent: (content) => applied.push(content), 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(reads).toBe(2);
expect(applied).toEqual(['abd']); expect(applied).toEqual(['abd']);
@@ -97,10 +91,9 @@ describe('createFileContentPoller', () => {
getLoadedRevision: () => 0, getLoadedRevision: () => 0,
isDirty: () => dirty, isDirty: () => dirty,
applyContent: (content) => applied.push(content), applyContent: (content) => applied.push(content),
maxBytes: MAX_BYTES,
}); });
const polling = poller.poll(20); const polling = poller.poll();
dirty = true; dirty = true;
read.resolve('external edit'); read.resolve('external edit');
await polling; await polling;
@@ -117,17 +110,16 @@ describe('createFileContentPoller', () => {
getLoadedRevision: () => 0, getLoadedRevision: () => 0,
isDirty: () => false, isDirty: () => false,
applyContent: (content) => applied.push(content), applyContent: (content) => applied.push(content),
maxBytes: MAX_BYTES,
}); });
await poller.poll(7); await poller.poll();
expect(applied).toEqual([]); expect(applied).toEqual([]);
expect(await poller.poll(7)).toBe(true); expect(await poller.poll()).toBe(true);
expect(applied).toEqual(['settled']); 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<string>(); const read = deferred<string>();
let reads = 0; let reads = 0;
const applied: string[] = []; const applied: string[] = [];
@@ -140,14 +132,12 @@ describe('createFileContentPoller', () => {
getLoadedRevision: () => 0, getLoadedRevision: () => 0,
isDirty: () => false, isDirty: () => false,
applyContent: (content) => applied.push(content), applyContent: (content) => applied.push(content),
maxBytes: MAX_BYTES,
}); });
const first = poller.poll(5); const pending = poller.poll();
await poller.poll(5);
poller.dispose(); poller.dispose();
read.resolve('after'); read.resolve('after');
await first; expect(await pending).toBe(false);
expect(reads).toBe(1); expect(reads).toBe(1);
expect(applied).toEqual([]); expect(applied).toEqual([]);
@@ -165,10 +155,9 @@ describe('createFileContentPoller', () => {
getLoadedRevision: () => loadedRevision, getLoadedRevision: () => loadedRevision,
isDirty: () => false, isDirty: () => false,
applyContent: (content) => applied.push(content), applyContent: (content) => applied.push(content),
maxBytes: MAX_BYTES,
}); });
const polling = poller.poll(20); const polling = poller.poll();
firstRead.resolve('external edit'); firstRead.resolve('external edit');
await Promise.resolve(); await Promise.resolve();
loadedRevision += 1; loadedRevision += 1;
@@ -179,22 +168,4 @@ describe('createFileContentPoller', () => {
expect(applied).toEqual([]); 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);
});
}); });
@@ -4,18 +4,17 @@ type FileContentPollerOptions = {
getLoadedRevision: () => number; getLoadedRevision: () => number;
isDirty: () => boolean; isDirty: () => boolean;
applyContent: (content: string) => void; 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) => { export const createFileContentPoller = (options: FileContentPollerOptions) => {
let active = true; let active = true;
let polling = false;
return { return {
/** Resolves true only when the poll observed the file's current content. */ /** Resolves true only when the poll observed the file's current content. */
poll: async (size: number): Promise<boolean> => { poll: async (): Promise<boolean> => {
if (!active || polling || options.isDirty() || size > options.maxBytes) return false; if (!active || options.isDirty()) return false;
polling = true;
const loadedContent = options.getLoadedContent(); const loadedContent = options.getLoadedContent();
const loadedRevision = options.getLoadedRevision(); const loadedRevision = options.getLoadedRevision();
try { try {
@@ -23,6 +22,7 @@ export const createFileContentPoller = (options: FileContentPollerOptions) => {
if (!active || options.isDirty() || loadedRevision !== options.getLoadedRevision()) return false; if (!active || options.isDirty() || loadedRevision !== options.getLoadedRevision()) return false;
if (content === loadedContent) return true; if (content === loadedContent) return true;
// A second read guards against applying a half-written file.
const confirmedContent = await options.readContent(); const confirmedContent = await options.readContent();
if (!active || options.isDirty() || confirmedContent !== content || loadedRevision !== options.getLoadedRevision()) { if (!active || options.isDirty() || confirmedContent !== content || loadedRevision !== options.getLoadedRevision()) {
return false; return false;
@@ -32,8 +32,6 @@ export const createFileContentPoller = (options: FileContentPollerOptions) => {
} catch { } catch {
// A failed read is not proof the file is unchanged; the next poll retries. // A failed read is not proof the file is unchanged; the next poll retries.
return false; return false;
} finally {
polling = false;
} }
}, },
dispose: () => { dispose: () => {
@@ -118,12 +118,9 @@ export const sendQueuedAutoSendPayload = (
payload.agentMentionName, payload.agentMentionName,
payload.additionalParts.length > 0 ? payload.additionalParts : undefined, payload.additionalParts.length > 0 ? payload.additionalParts : undefined,
resolved.variant, resolved.variant,
'normal', 'normal',
{ { target, historySubmissions: payload.historySubmissions },
target, );
historySubmissions: payload.historySubmissions,
},
);
}; };
const resolveSessionSendConfig = (sessionId: string) => { const resolveSessionSendConfig = (sessionId: string) => {
@@ -2012,6 +2012,8 @@ export const settingsDict = {
'settings.openchamber.visual.field.enableSpellcheckInTextInputs': 'Rechtschreibprüfung in Texteingaben aktivieren', 'settings.openchamber.visual.field.enableSpellcheckInTextInputs': 'Rechtschreibprüfung in Texteingaben aktivieren',
'settings.openchamber.visual.field.largeTextPaste': 'Großes Texteinfügen', '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.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.largeTextPasteAria': 'Verhalten bei großem Texteinfügen',
'settings.openchamber.visual.field.largeTextPasteOptionAria': 'Großes Texteinfügen: {option}', 'settings.openchamber.visual.field.largeTextPasteOptionAria': 'Großes Texteinfügen: {option}',
'settings.openchamber.visual.option.largeTextPaste.ask.label': 'Jedes Mal fragen', 'settings.openchamber.visual.option.largeTextPaste.ask.label': 'Jedes Mal fragen',
-2
View File
@@ -2105,8 +2105,6 @@ export const dict = {
'chat.chatInput.actions.linkGithubPr': 'GitHub-PR verknüpfen', 'chat.chatInput.actions.linkGithubPr': 'GitHub-PR verknüpfen',
'chat.chatInput.actions.modelAgentSettings': 'Modell- und Agenteneinstellungen', 'chat.chatInput.actions.modelAgentSettings': 'Modell- und Agenteneinstellungen',
'chat.chatInput.actions.sendMessageAria': 'Nachricht senden', '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.queueMessageAria': 'Nachricht in die Warteschlange stellen',
'chat.chatInput.actions.stopGeneratingAria': 'Generierung stoppen', 'chat.chatInput.actions.stopGeneratingAria': 'Generierung stoppen',
'chat.chatInput.focusMode.toggleAria': 'Fokusmodus umschalten', 'chat.chatInput.focusMode.toggleAria': 'Fokusmodus umschalten',
@@ -2097,6 +2097,8 @@ export const settingsDict = {
'settings.openchamber.visual.field.enableSpellcheckInTextInputs': 'Enable Spellcheck in Text Inputs', 'settings.openchamber.visual.field.enableSpellcheckInTextInputs': 'Enable Spellcheck in Text Inputs',
'settings.openchamber.visual.field.largeTextPaste': 'Large text paste', '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.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.largeTextPasteAria': 'Large text paste behavior',
'settings.openchamber.visual.field.largeTextPasteOptionAria': 'Large text paste: {option}', 'settings.openchamber.visual.field.largeTextPasteOptionAria': 'Large text paste: {option}',
'settings.openchamber.visual.option.largeTextPaste.ask.label': 'Ask each time', 'settings.openchamber.visual.option.largeTextPaste.ask.label': 'Ask each time',
-2
View File
@@ -2322,8 +2322,6 @@ export const dict = {
'chat.chatInput.actions.linkGithubPr': 'Link GitHub PR', 'chat.chatInput.actions.linkGithubPr': 'Link GitHub PR',
'chat.chatInput.actions.modelAgentSettings': 'Model and agent settings', 'chat.chatInput.actions.modelAgentSettings': 'Model and agent settings',
'chat.chatInput.actions.sendMessageAria': 'Send message', '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.queueMessageAria': 'Queue message',
'chat.chatInput.actions.stopGeneratingAria': 'Stop generating', 'chat.chatInput.actions.stopGeneratingAria': 'Stop generating',
'chat.chatInput.focusMode.toggleAria': 'Toggle focus mode', 'chat.chatInput.focusMode.toggleAria': 'Toggle focus mode',
@@ -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.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.inputHistoryLimitAria": "Prompts que recordar",
"settings.openchamber.visual.field.inputHistoryLimitUnit": "prompts", "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, ...linearIntegrationI18n.es,
'settings.page.integrations.title': 'Integraciones', 'settings.page.integrations.title': 'Integraciones',
'settings.page.integrations.description': 'Conecta GitHub y Linear para que OpenChamber pueda trabajar con tus issues y pull requests.', 'settings.page.integrations.description': 'Conecta GitHub y Linear para que OpenChamber pueda trabajar con tus issues y pull requests.',
-2
View File
@@ -2300,8 +2300,6 @@ export const dict: Record<I18nKey, string> = {
"chat.chatInput.actions.linkGithubPr": "Vincular PR de GitHub", "chat.chatInput.actions.linkGithubPr": "Vincular PR de GitHub",
"chat.chatInput.actions.modelAgentSettings": "Configuración del modelo y agente", "chat.chatInput.actions.modelAgentSettings": "Configuración del modelo y agente",
"chat.chatInput.actions.sendMessageAria": "Enviar mensaje", "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.queueMessageAria": "Poner mensaje en cola",
"chat.chatInput.actions.stopGeneratingAria": "Detener generación", "chat.chatInput.actions.stopGeneratingAria": "Detener generación",
"chat.chatInput.focusMode.toggleAria": "Activar o desactivar modo de enfoque", "chat.chatInput.focusMode.toggleAria": "Activar o desactivar modo de enfoque",
@@ -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.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.inputHistoryLimitAria': 'Prompts à mémoriser',
'settings.openchamber.visual.field.inputHistoryLimitUnit': 'prompts', '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, ...linearIntegrationI18n.fr,
'settings.page.integrations.title': 'Intégrations', '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.', 'settings.page.integrations.description': 'Connectez GitHub et Linear pour qu’OpenChamber puisse travailler avec vos issues et pull requests.',
-2
View File
@@ -2041,8 +2041,6 @@ export const dict = {
'chat.chatInput.actions.linkGithubPr': 'Lien GitHub PR', 'chat.chatInput.actions.linkGithubPr': 'Lien GitHub PR',
'chat.chatInput.actions.modelAgentSettings': 'Paramètres du modèle et de l\'agent', 'chat.chatInput.actions.modelAgentSettings': 'Paramètres du modèle et de l\'agent',
'chat.chatInput.actions.sendMessageAria': 'Envoyer un message', '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.queueMessageAria': 'Message de file d\'attente',
'chat.chatInput.actions.stopGeneratingAria': 'Arrêter de générer', 'chat.chatInput.actions.stopGeneratingAria': 'Arrêter de générer',
'chat.chatInput.focusMode.toggleAria': 'Basculer le mode de mise au point', 'chat.chatInput.focusMode.toggleAria': 'Basculer le mode de mise au point',
@@ -2246,6 +2246,8 @@ export const settingsDict = {
'settings.openchamber.visual.field.inputHistoryLimitDescription': 'この数を減らすと、履歴内の古いプロンプトはすぐに削除されます。', 'settings.openchamber.visual.field.inputHistoryLimitDescription': 'この数を減らすと、履歴内の古いプロンプトはすぐに削除されます。',
'settings.openchamber.visual.field.inputHistoryLimitAria': '記憶するプロンプト数', 'settings.openchamber.visual.field.inputHistoryLimitAria': '記憶するプロンプト数',
'settings.openchamber.visual.field.inputHistoryLimitUnit': '件', 'settings.openchamber.visual.field.inputHistoryLimitUnit': '件',
'settings.openchamber.visual.field.enterToSend': 'Enterで送信',
'settings.openchamber.visual.field.enterToSendHint': '変更すると、すべての環境でEnterとShift+Enterの動作を制御します。変更するまでは、各環境の既存の動作が維持されます。',
...linearIntegrationI18n.ja, ...linearIntegrationI18n.ja,
'settings.page.integrations.title': '連携', 'settings.page.integrations.title': '連携',
'settings.page.integrations.description': 'GitHub と Linear を接続すると、OpenChamber が Issue やプルリクエストを扱えるようになります。', 'settings.page.integrations.description': 'GitHub と Linear を接続すると、OpenChamber が Issue やプルリクエストを扱えるようになります。',
-2
View File
@@ -2318,8 +2318,6 @@ export const dict: Record<I18nKey, string> = {
'chat.chatInput.actions.linkGithubPr': 'GitHub PRをリンク', 'chat.chatInput.actions.linkGithubPr': 'GitHub PRをリンク',
'chat.chatInput.actions.modelAgentSettings': 'モデルとエージェント設定', 'chat.chatInput.actions.modelAgentSettings': 'モデルとエージェント設定',
'chat.chatInput.actions.sendMessageAria': 'メッセージを送信', 'chat.chatInput.actions.sendMessageAria': 'メッセージを送信',
'chat.chatInput.actions.enterToSend': 'Enterで送信',
'chat.chatInput.actions.enterToSendHint': '変更すると、すべての環境でEnterとShift+Enterの動作を制御します。変更するまでは、各環境の既存の動作が維持されます。',
'chat.chatInput.actions.queueMessageAria': 'メッセージをキュー', 'chat.chatInput.actions.queueMessageAria': 'メッセージをキュー',
'chat.chatInput.actions.stopGeneratingAria': '生成を停止', 'chat.chatInput.actions.stopGeneratingAria': '生成を停止',
'chat.chatInput.focusMode.toggleAria': 'フォーカスモードの切り替え', 'chat.chatInput.focusMode.toggleAria': 'フォーカスモードの切り替え',
@@ -2246,6 +2246,8 @@ export const settingsDict = {
'settings.openchamber.visual.field.inputHistoryLimitDescription': '이 숫자를 낮추면 기록에서 오래된 프롬프트가 바로 삭제됩니다.', 'settings.openchamber.visual.field.inputHistoryLimitDescription': '이 숫자를 낮추면 기록에서 오래된 프롬프트가 바로 삭제됩니다.',
'settings.openchamber.visual.field.inputHistoryLimitAria': '기억할 프롬프트 수', 'settings.openchamber.visual.field.inputHistoryLimitAria': '기억할 프롬프트 수',
'settings.openchamber.visual.field.inputHistoryLimitUnit': '개', 'settings.openchamber.visual.field.inputHistoryLimitUnit': '개',
'settings.openchamber.visual.field.enterToSend': 'Enter로 전송',
'settings.openchamber.visual.field.enterToSendHint': '변경하면 모든 환경에서 Enter와 Shift+Enter의 동작을 제어합니다. 변경하기 전에는 각 환경의 기존 동작이 유지됩니다.',
...linearIntegrationI18n.ko, ...linearIntegrationI18n.ko,
'settings.page.integrations.title': '통합', 'settings.page.integrations.title': '통합',
'settings.page.integrations.description': 'GitHub와 Linear를 연결하면 OpenChamber가 이슈와 풀 리퀘스트를 다룰 수 있습니다.', 'settings.page.integrations.description': 'GitHub와 Linear를 연결하면 OpenChamber가 이슈와 풀 리퀘스트를 다룰 수 있습니다.',
-2
View File
@@ -2322,8 +2322,6 @@ export const dict: Record<I18nKey, string> = {
'chat.chatInput.actions.linkGithubPr': 'GitHub PR 연결', 'chat.chatInput.actions.linkGithubPr': 'GitHub PR 연결',
'chat.chatInput.actions.modelAgentSettings': '모델 및 에이전트 설정', 'chat.chatInput.actions.modelAgentSettings': '모델 및 에이전트 설정',
'chat.chatInput.actions.sendMessageAria': '보내기 메시지', 'chat.chatInput.actions.sendMessageAria': '보내기 메시지',
'chat.chatInput.actions.enterToSend': 'Enter로 전송',
'chat.chatInput.actions.enterToSendHint': '변경하면 모든 환경에서 Enter와 Shift+Enter의 동작을 제어합니다. 변경하기 전에는 각 환경의 기존 동작이 유지됩니다.',
'chat.chatInput.actions.queueMessageAria': '메시지 대기열에 추가', 'chat.chatInput.actions.queueMessageAria': '메시지 대기열에 추가',
'chat.chatInput.actions.stopGeneratingAria': '생성 중지', 'chat.chatInput.actions.stopGeneratingAria': '생성 중지',
'chat.chatInput.focusMode.toggleAria': '집중 모드 전환', 'chat.chatInput.focusMode.toggleAria': '집중 모드 전환',
@@ -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.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.inputHistoryLimitAria': 'Liczba zapamiętywanych promptów',
'settings.openchamber.visual.field.inputHistoryLimitUnit': '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, ...linearIntegrationI18n.pl,
'settings.page.integrations.title': 'Integracje', '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.', 'settings.page.integrations.description': 'Połącz GitHub i Linear, aby OpenChamber mógł pracować z Twoimi issue i pull requestami.',
-2
View File
@@ -1255,8 +1255,6 @@ export const dict: Record<I18nKey, string> = {
'chat.chatInput.actions.modelAgentSettings': 'Model and agent settings', 'chat.chatInput.actions.modelAgentSettings': 'Model and agent settings',
'chat.chatInput.actions.queueMessageAria': 'Queue message', 'chat.chatInput.actions.queueMessageAria': 'Queue message',
'chat.chatInput.actions.sendMessageAria': 'Send 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.actions.stopGeneratingAria': 'Stop generating',
'chat.chatInput.branch': 'Gałąź', 'chat.chatInput.branch': 'Gałąź',
'chat.chatInput.draftPicker.projectTitle': 'Projekt', 'chat.chatInput.draftPicker.projectTitle': 'Projekt',
@@ -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.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.inputHistoryLimitAria": "Prompts para lembrar",
"settings.openchamber.visual.field.inputHistoryLimitUnit": "prompts", "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'], ...linearIntegrationI18n['pt-BR'],
'settings.page.integrations.title': 'Integrações', '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.', 'settings.page.integrations.description': 'Conecte o GitHub e o Linear para que o OpenChamber possa trabalhar com suas issues e pull requests.',
@@ -2300,8 +2300,6 @@ export const dict: Record<I18nKey, string> = {
"chat.chatInput.actions.linkGithubPr": "Vincular PR de GitHub", "chat.chatInput.actions.linkGithubPr": "Vincular PR de GitHub",
"chat.chatInput.actions.modelAgentSettings": "Configurações de modelo e agente", "chat.chatInput.actions.modelAgentSettings": "Configurações de modelo e agente",
"chat.chatInput.actions.sendMessageAria": "Enviar mensagem", "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.queueMessageAria": "Colocar mensagem na fila",
"chat.chatInput.actions.stopGeneratingAria": "Parar geração", "chat.chatInput.actions.stopGeneratingAria": "Parar geração",
"chat.chatInput.focusMode.toggleAria": "Ativar ou desativar modo de foco", "chat.chatInput.focusMode.toggleAria": "Ativar ou desativar modo de foco",
@@ -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.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.inputHistoryLimitAria': 'Hatırlanacak prompt sayısı',
'settings.openchamber.visual.field.inputHistoryLimitUnit': 'prompt', '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.actions.resetInputBarOffsetAria': 'Giriş çubuğu ofsetini sıfırla',
'settings.openchamber.visual.field.terminalQuickKeysAria': 'Terminal hızlı tuşları', 'settings.openchamber.visual.field.terminalQuickKeysAria': 'Terminal hızlı tuşları',
'settings.openchamber.visual.field.terminalQuickKeys': 'Terminal Hızlı Tuşları', 'settings.openchamber.visual.field.terminalQuickKeys': 'Terminal Hızlı Tuşları',
-2
View File
@@ -2263,8 +2263,6 @@ export const dict = {
'chat.chatInput.actions.linkGithubPr': 'GitHub PR\'yi bağla', 'chat.chatInput.actions.linkGithubPr': 'GitHub PR\'yi bağla',
'chat.chatInput.actions.modelAgentSettings': 'Model ve agent ayarları', 'chat.chatInput.actions.modelAgentSettings': 'Model ve agent ayarları',
'chat.chatInput.actions.sendMessageAria': 'Mesaj gönder', '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.queueMessageAria': 'Mesajı kuyruğa ekle',
'chat.chatInput.actions.stopGeneratingAria': 'Üretmeyi durdur', 'chat.chatInput.actions.stopGeneratingAria': 'Üretmeyi durdur',
'chat.chatInput.focusMode.toggleAria': 'Odak modunu aç/kapat', 'chat.chatInput.focusMode.toggleAria': 'Odak modunu aç/kapat',
@@ -2246,6 +2246,8 @@ export const settingsDict = {
"settings.openchamber.visual.field.inputHistoryLimitDescription": "Якщо зменшити це число, старіші промпти одразу буде видалено з історії.", "settings.openchamber.visual.field.inputHistoryLimitDescription": "Якщо зменшити це число, старіші промпти одразу буде видалено з історії.",
"settings.openchamber.visual.field.inputHistoryLimitAria": "Скільки промптів пам’ятати", "settings.openchamber.visual.field.inputHistoryLimitAria": "Скільки промптів пам’ятати",
"settings.openchamber.visual.field.inputHistoryLimitUnit": "промптів", "settings.openchamber.visual.field.inputHistoryLimitUnit": "промптів",
"settings.openchamber.visual.field.enterToSend": "Enter надсилає",
"settings.openchamber.visual.field.enterToSendHint": "Після зміни цей параметр керує поведінкою Enter і Shift+Enter на всіх поверхнях. До цього кожна поверхня зберігає свою поточну поведінку.",
...linearIntegrationI18n.uk, ...linearIntegrationI18n.uk,
'settings.page.integrations.title': 'Інтеграції', 'settings.page.integrations.title': 'Інтеграції',
'settings.page.integrations.description': 'Підключіть GitHub і Linear, щоб OpenChamber міг працювати з вашими задачами та pull request-ами.', 'settings.page.integrations.description': 'Підключіть GitHub і Linear, щоб OpenChamber міг працювати з вашими задачами та pull request-ами.',
-2
View File
@@ -2300,8 +2300,6 @@ export const dict: Record<I18nKey, string> = {
"chat.chatInput.actions.linkGithubPr": "Пов’язати GitHub PR", "chat.chatInput.actions.linkGithubPr": "Пов’язати GitHub PR",
"chat.chatInput.actions.modelAgentSettings": "Параметри моделі та агента", "chat.chatInput.actions.modelAgentSettings": "Параметри моделі та агента",
"chat.chatInput.actions.sendMessageAria": "Надіслати повідомлення", "chat.chatInput.actions.sendMessageAria": "Надіслати повідомлення",
'chat.chatInput.actions.enterToSend': 'Enter надсилає',
'chat.chatInput.actions.enterToSendHint': 'Після зміни цей параметр керує поведінкою Enter і Shift+Enter на всіх поверхнях. До цього кожна поверхня зберігає свою поточну поведінку.',
"chat.chatInput.actions.queueMessageAria": "Поставити повідомлення в чергу", "chat.chatInput.actions.queueMessageAria": "Поставити повідомлення в чергу",
"chat.chatInput.actions.stopGeneratingAria": "Припинити генерацію", "chat.chatInput.actions.stopGeneratingAria": "Припинити генерацію",
"chat.chatInput.focusMode.toggleAria": "Перемкнути режим фокусування", "chat.chatInput.focusMode.toggleAria": "Перемкнути режим фокусування",
@@ -2246,6 +2246,8 @@ export const settingsDict = {
'settings.openchamber.visual.field.inputHistoryLimitDescription': '调低这个数字会立即从历史记录中删除较早的提示词。', 'settings.openchamber.visual.field.inputHistoryLimitDescription': '调低这个数字会立即从历史记录中删除较早的提示词。',
'settings.openchamber.visual.field.inputHistoryLimitAria': '要记住的提示词数量', 'settings.openchamber.visual.field.inputHistoryLimitAria': '要记住的提示词数量',
'settings.openchamber.visual.field.inputHistoryLimitUnit': '条', 'settings.openchamber.visual.field.inputHistoryLimitUnit': '条',
'settings.openchamber.visual.field.enterToSend': 'Enter 发送',
'settings.openchamber.visual.field.enterToSendHint': '更改后,此设置会控制所有界面中的 Enter 和 Shift+Enter。更改前,各界面保持现有行为。',
...linearIntegrationI18n['zh-CN'], ...linearIntegrationI18n['zh-CN'],
'settings.page.integrations.title': '集成', 'settings.page.integrations.title': '集成',
'settings.page.integrations.description': '连接 GitHub 和 Linear,让 OpenChamber 可以处理你的 issue 和拉取请求。', 'settings.page.integrations.description': '连接 GitHub 和 Linear,让 OpenChamber 可以处理你的 issue 和拉取请求。',
@@ -2288,8 +2288,6 @@ export const dict: Record<I18nKey, string> = {
'chat.chatInput.actions.linkGithubPr': '关联 GitHub PR', 'chat.chatInput.actions.linkGithubPr': '关联 GitHub PR',
'chat.chatInput.actions.modelAgentSettings': '模型与智能体设置', 'chat.chatInput.actions.modelAgentSettings': '模型与智能体设置',
'chat.chatInput.actions.sendMessageAria': '发送消息', 'chat.chatInput.actions.sendMessageAria': '发送消息',
'chat.chatInput.actions.enterToSend': 'Enter 发送',
'chat.chatInput.actions.enterToSendHint': '更改后,此设置会控制所有界面中的 Enter 和 Shift+Enter。更改前,各界面保持现有行为。',
'chat.chatInput.actions.queueMessageAria': '将消息加入队列', 'chat.chatInput.actions.queueMessageAria': '将消息加入队列',
'chat.chatInput.actions.stopGeneratingAria': '停止生成', 'chat.chatInput.actions.stopGeneratingAria': '停止生成',
'chat.chatInput.focusMode.toggleAria': '切换专注模式', 'chat.chatInput.focusMode.toggleAria': '切换专注模式',
@@ -2246,6 +2246,8 @@ export const settingsDict = {
'settings.openchamber.visual.field.inputHistoryLimitDescription': '調低這個數字會立刻從歷史記錄移除較早的提示詞。', 'settings.openchamber.visual.field.inputHistoryLimitDescription': '調低這個數字會立刻從歷史記錄移除較早的提示詞。',
'settings.openchamber.visual.field.inputHistoryLimitAria': '要記住的提示詞數量', 'settings.openchamber.visual.field.inputHistoryLimitAria': '要記住的提示詞數量',
'settings.openchamber.visual.field.inputHistoryLimitUnit': '則', 'settings.openchamber.visual.field.inputHistoryLimitUnit': '則',
'settings.openchamber.visual.field.enterToSend': 'Enter 傳送',
'settings.openchamber.visual.field.enterToSendHint': '變更後,此設定會控制所有介面中的 Enter 與 Shift+Enter。變更前,各介面會維持現有行為。',
...linearIntegrationI18n['zh-TW'], ...linearIntegrationI18n['zh-TW'],
'settings.page.integrations.title': '整合', 'settings.page.integrations.title': '整合',
'settings.page.integrations.description': '連接 GitHub 和 Linear,讓 OpenChamber 可以處理你的 issue 和 pull request。', 'settings.page.integrations.description': '連接 GitHub 和 Linear,讓 OpenChamber 可以處理你的 issue 和 pull request。',
@@ -2292,8 +2292,6 @@ export const dict: Record<I18nKey, string> = {
'chat.chatInput.actions.linkGithubPr': '關聯 GitHub PR', 'chat.chatInput.actions.linkGithubPr': '關聯 GitHub PR',
'chat.chatInput.actions.modelAgentSettings': '模型與 Agent 設定', 'chat.chatInput.actions.modelAgentSettings': '模型與 Agent 設定',
'chat.chatInput.actions.sendMessageAria': '傳送訊息', 'chat.chatInput.actions.sendMessageAria': '傳送訊息',
'chat.chatInput.actions.enterToSend': 'Enter 傳送',
'chat.chatInput.actions.enterToSendHint': '變更後,此設定會控制所有介面中的 Enter 與 Shift+Enter。變更前,各介面會維持現有行為。',
'chat.chatInput.actions.queueMessageAria': '將訊息加入佇列', 'chat.chatInput.actions.queueMessageAria': '將訊息加入佇列',
'chat.chatInput.actions.stopGeneratingAria': '停止生成', 'chat.chatInput.actions.stopGeneratingAria': '停止生成',
'chat.chatInput.focusMode.toggleAria': '切換專注模式', 'chat.chatInput.focusMode.toggleAria': '切換專注模式',
+1 -1
View File
@@ -1,6 +1,6 @@
export type InputHistoryScope = 'global' | 'session'; 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 DEFAULT_INPUT_HISTORY_LIMIT = 40;
export const MIN_INPUT_HISTORY_LIMIT = 1; export const MIN_INPUT_HISTORY_LIMIT = 1;
export const MAX_INPUT_HISTORY_LIMIT = 100; export const MAX_INPUT_HISTORY_LIMIT = 100;
+2 -2
View File
@@ -1,9 +1,9 @@
import type { ContextPartMetadata } from '@/lib/messages/contextParts'; import type { ContextPartMetadata } from '@/lib/messages/contextParts';
import { createOpencodeClient, OpencodeClient } from "@opencode-ai/sdk/v2"; import { createOpencodeClient, OpencodeClient } from "@opencode-ai/sdk/v2";
import type { PermissionV2Request, PermissionV2Effect, PermissionV2Source } from "@opencode-ai/sdk/v2/client"; import type { PermissionV2Request, PermissionV2Effect, PermissionV2Source } from "@opencode-ai/sdk/v2/client";
import { z } from "zod";
import type { FilesAPI } from "../api/types"; import type { FilesAPI } from "../api/types";
import { getDesktopHomeDirectory } from "../desktop"; import { getDesktopHomeDirectory } from "../desktop";
import { z } from "zod";
import type { import type {
Session, Session,
Message, Message,
@@ -343,7 +343,7 @@ const getDesktopFilesApi = (): FilesAPI | null => {
}; };
// /api/fs/home parsing boundary. Older servers answer without chatsRoot; // /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 fsAbsolutePathSchema = z.string().trim().regex(/^(?:\/|[A-Za-z]:[\\/]|\\\\)/);
const fsHomeResponseSchema = z.object({ home: fsAbsolutePathSchema, chatsRoot: fsAbsolutePathSchema.optional() }); const fsHomeResponseSchema = z.object({ home: fsAbsolutePathSchema, chatsRoot: fsAbsolutePathSchema.optional() });
+11 -17
View File
@@ -21,9 +21,7 @@ import {
isInputHistoryLimit, isInputHistoryLimit,
isInputHistoryScope, isInputHistoryScope,
} from '@/lib/inputHistoryScope'; } from '@/lib/inputHistoryScope';
import { import { useInputHistoryStore } from '@/stores/useInputHistoryStore';
useInputHistoryStore,
} from '@/stores/useInputHistoryStore';
import { normalizeMobileKeyboardMode, setStoredMobileKeyboardMode } from '@/lib/mobileKeyboardMode'; import { normalizeMobileKeyboardMode, setStoredMobileKeyboardMode } from '@/lib/mobileKeyboardMode';
import { runtimeFetch } from '@/lib/runtime-fetch'; import { runtimeFetch } from '@/lib/runtime-fetch';
import { isCapacitorApp } from '@/lib/platform'; import { isCapacitorApp } from '@/lib/platform';
@@ -1081,14 +1079,11 @@ const applyDesktopUiPreferences = (settings: DesktopSettings) => {
store.setGitChangesViewMode(settings.gitChangesViewMode); store.setGitChangesViewMode(settings.gitChangesViewMode);
} }
} }
switch (settings.toolJsonViewMode) { if (typeof settings.toolJsonViewMode === 'string'
case 'summary': && (settings.toolJsonViewMode === 'summary' || settings.toolJsonViewMode === 'formatted' || settings.toolJsonViewMode === 'raw')) {
case 'formatted': if (settings.toolJsonViewMode !== store.toolJsonViewMode) {
case 'raw': store.setToolJsonViewMode(settings.toolJsonViewMode);
if (settings.toolJsonViewMode !== store.toolJsonViewMode) { }
store.setToolJsonViewMode(settings.toolJsonViewMode);
}
break;
} }
if (typeof settings.directoryShowHidden === 'boolean') { if (typeof settings.directoryShowHidden === 'boolean') {
setDirectoryShowHidden(settings.directoryShowHidden, { persist: false }); setDirectoryShowHidden(settings.directoryShowHidden, { persist: false });
@@ -1677,12 +1672,11 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
) { ) {
result.gitChangesViewMode = candidate.gitChangesViewMode; result.gitChangesViewMode = candidate.gitChangesViewMode;
} }
switch (candidate.toolJsonViewMode) { if (
case 'summary': typeof candidate.toolJsonViewMode === 'string'
case 'formatted': && (candidate.toolJsonViewMode === 'summary' || candidate.toolJsonViewMode === 'formatted' || candidate.toolJsonViewMode === 'raw')
case 'raw': ) {
result.toolJsonViewMode = candidate.toolJsonViewMode; result.toolJsonViewMode = candidate.toolJsonViewMode;
break;
} }
if (typeof candidate.directoryShowHidden === 'boolean') { if (typeof candidate.directoryShowHidden === 'boolean') {
result.directoryShowHidden = candidate.directoryShowHidden; result.directoryShowHidden = candidate.directoryShowHidden;
+2 -1
View File
@@ -384,7 +384,8 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
{ {
id: 'chat.enter-to-send', id: 'chat.enter-to-send',
page: 'chat', 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'], keywords: ['enter', 'shift enter', 'send', 'newline'],
}, },
{ {
+1 -1
View File
@@ -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. 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. 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.
+1 -1
View File
@@ -1,8 +1,8 @@
import { createInputHistoryIdentity, createInputHistorySubmission, useInputHistoryStore } from './useInputHistoryStore';
import { create } from 'zustand'; import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware'; import { devtools, persist } from 'zustand/middleware';
import { z } from 'zod'; import { z } from 'zod';
import type { Event } from '@opencode-ai/sdk/v2'; import type { Event } from '@opencode-ai/sdk/v2';
import { createInputHistoryIdentity, createInputHistorySubmission, useInputHistoryStore } from './useInputHistoryStore';
import { createDeferredSafeJSONStorage } from './utils/safeStorage'; import { createDeferredSafeJSONStorage } from './utils/safeStorage';
import type { AttachedFile } from './types/sessionTypes'; import type { AttachedFile } from './types/sessionTypes';
import { contextPartMetadataSchema, type ContextPartMetadata } from '@/lib/messages/contextParts'; import { contextPartMetadataSchema, type ContextPartMetadata } from '@/lib/messages/contextParts';
@@ -117,9 +117,9 @@ describe('useInputHistoryStore', () => {
const identity = mod.createInputHistoryIdentity('runtime-a', '/repo', 'session-1'); const identity = mod.createInputHistoryIdentity('runtime-a', '/repo', 'session-1');
expect(identity).not.toBeNull(); 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(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.useInputHistoryStore.getState().entryLimit).toBe(DEFAULT_INPUT_HISTORY_LIMIT);
expect(mod.selectInputHistoryEntries(mod.useInputHistoryStore.getState(), identity!)).toEqual([]); expect(mod.selectInputHistoryEntries(mod.useInputHistoryStore.getState(), identity!)).toEqual([]);
}); });
@@ -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)) entries.slice(Math.max(0, entries.length - limit))
); );
@@ -273,7 +273,7 @@ const limitNamespaces = (
key, key,
{ {
touchedAt: namespace.touchedAt, touchedAt: namespace.touchedAt,
entries: cloneEntriesWithLimit(namespace.entries, entryLimit), entries: trimEntriesToLimit(namespace.entries, entryLimit),
}, },
] as const) ] as const)
.sort((left, right) => right[1].touchedAt - left[1].touchedAt) .sort((left, right) => right[1].touchedAt - left[1].touchedAt)
@@ -381,8 +381,7 @@ export const createInputHistoryIdentity = (
const getAttachmentReference = (attachment: AttachedFile): string | null => { const getAttachmentReference = (attachment: AttachedFile): string | null => {
if (attachment.source === 'vscode' && attachment.vscodeSource === 'file') { if (attachment.source === 'vscode' && attachment.vscodeSource === 'file') {
const normalizedPath = normalizePath(attachment.vscodePath ?? null); return normalizePath(attachment.vscodePath ?? null);
return normalizedPath;
} }
const candidate = attachment.dataUrl.trim(); const candidate = attachment.dataUrl.trim();
return candidate || null; return candidate || null;
@@ -484,7 +483,7 @@ const appendToNamespace = (
} }
return { return {
touchedAt, touchedAt,
entries: cloneEntriesWithLimit(entries, entryLimit), entries: trimEntriesToLimit(entries, entryLimit),
}; };
}; };
+1
View File
@@ -2256,6 +2256,7 @@ export const useUIStore = create<UIStore>()(
const trimmed = identifier?.trim() ?? ''; const trimmed = identifier?.trim() ?? '';
set({ linearIssueFocus: trimmed || null }); set({ linearIssueFocus: trimmed || null });
}, },
setInputBarOffset: (offset) => { setInputBarOffset: (offset) => {
set({ inputBarOffset: offset }); set({ inputBarOffset: offset });
}, },
+1 -1
View File
@@ -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. 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. 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. 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. 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. 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.
+3 -2
View File
@@ -69,9 +69,10 @@ export async function bootstrapGlobal(
sdk: OpencodeClient, sdk: OpencodeClient,
set: (patch: Partial<GlobalState>) => void, set: (patch: Partial<GlobalState>) => void,
) { ) {
// Sync chat classification needs the root before session lists load.
await warmChatsRootDirectory()
const results = await Promise.allSettled([ 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.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(() => sdk.global.config.get().then((x) => set({ config: unwrap(x, "global.config.get") }))),
retry(() => retry(() =>
@@ -36,6 +36,7 @@ export type SyncPerformanceCounters = {
questionChangeCallbacks: number questionChangeCallbacks: number
sessionMessageChangeCallbacks: number sessionMessageChangeCallbacks: number
sessionRenderableNotificationSkips: number sessionRenderableNotificationSkips: number
userMessageHistoryNotificationSkips: number
sessionMessageRecordNotificationSkips: number sessionMessageRecordNotificationSkips: number
materializationEnqueues: number materializationEnqueues: number
materializationEmptyAssistantEnqueues: number materializationEmptyAssistantEnqueues: number
@@ -78,6 +79,7 @@ const createCounters = (): SyncPerformanceCounters => ({
questionChangeCallbacks: 0, questionChangeCallbacks: 0,
sessionMessageChangeCallbacks: 0, sessionMessageChangeCallbacks: 0,
sessionRenderableNotificationSkips: 0, sessionRenderableNotificationSkips: 0,
userMessageHistoryNotificationSkips: 0,
sessionMessageRecordNotificationSkips: 0, sessionMessageRecordNotificationSkips: 0,
materializationEnqueues: 0, materializationEnqueues: 0,
materializationEmptyAssistantEnqueues: 0, materializationEmptyAssistantEnqueues: 0,
+7 -13
View File
@@ -99,14 +99,6 @@ import {
export type { AttachedFile } export type { AttachedFile }
function appendInputHistorySubmissions(
identity: ReturnType<typeof createInputHistoryIdentity>,
submissions: readonly InputHistorySubmission[],
): void {
if (!identity || submissions.length === 0) return
useInputHistoryStore.getState().appendSubmissions(identity, submissions)
}
type GoalCommand = { name: string; template?: string } type GoalCommand = { name: string; template?: string }
export function expandSlashCommandGoalObjective(content: string, commands: GoalCommand[]): string { export function expandSlashCommandGoalObjective(content: string, commands: GoalCommand[]): string {
@@ -1734,8 +1726,9 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
createdDraftSession.directory ?? '', createdDraftSession.directory ?? '',
createdDraftSession.sessionId, createdDraftSession.sessionId,
) )
const appendSubmissions = historyIdentity && options?.historySubmissions?.length const historySubmissions = options?.historySubmissions
? () => appendInputHistorySubmissions(historyIdentity, options.historySubmissions ?? []) const appendSubmissions = historyIdentity && historySubmissions?.length
? () => useInputHistoryStore.getState().appendSubmissions(historyIdentity, historySubmissions)
: undefined : undefined
notifyMessageSent(createdDraftSession.sessionId) notifyMessageSent(createdDraftSession.sessionId)
@@ -1860,13 +1853,14 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
const partsWithPinnedContext = prefixParts.length > 0 const partsWithPinnedContext = prefixParts.length > 0
? [...prefixParts, ...(additionalParts || [])] ? [...prefixParts, ...(additionalParts || [])]
: additionalParts : additionalParts
const currentHistoryIdentity = createInputHistoryIdentity( const historyIdentity = createInputHistoryIdentity(
capturedRuntimeKey, capturedRuntimeKey,
currentSessionDirectory ?? '', currentSessionDirectory ?? '',
targetSessionId || '', targetSessionId || '',
) )
const appendSubmissions = currentHistoryIdentity && options?.historySubmissions?.length const historySubmissions = options?.historySubmissions
? () => appendInputHistorySubmissions(currentHistoryIdentity, options.historySubmissions ?? []) const appendSubmissions = historyIdentity && historySubmissions?.length
? () => useInputHistoryStore.getState().appendSubmissions(historyIdentity, historySubmissions)
: undefined : undefined
const messageRoute = await routeMessage({ const messageRoute = await routeMessage({
+46
View File
@@ -86,6 +86,7 @@ import { formatMessage, useI18nStore } from "@/lib/i18n"
import { sessionEvents } from "@/lib/sessionEvents" import { sessionEvents } from "@/lib/sessionEvents"
import { listGlobalSessionPages } from "@/stores/globalSessions" import { listGlobalSessionPages } from "@/stores/globalSessions"
import { areRequestArraysReferentiallyEqual, collectScopedBlockingRequests } from "./scoped-blocking-requests" import { areRequestArraysReferentiallyEqual, collectScopedBlockingRequests } from "./scoped-blocking-requests"
import { EMPTY_USER_MESSAGE_HISTORY_SNAPSHOT, buildUserMessageHistorySnapshot, type TranscriptPrompt, type UserMessageHistorySnapshot } from "./user-message-history"
import { import {
EMPTY_SESSION_MESSAGE_LOAD_STATE, EMPTY_SESSION_MESSAGE_LOAD_STATE,
SessionMessageLoader, SessionMessageLoader,
@@ -3432,6 +3433,51 @@ export function useSessionRenderable(sessionID: string, directory?: string): boo
return React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot) 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<UserMessageHistorySnapshot>(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. * Get messages for a session in the old {info, parts}[] format.
* Uses visible messages (filtered by revert state). * Uses visible messages (filtered by revert state).
@@ -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<State>): Pick<State, 'session' | 'message' | 'part'> => ({
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']);
});
});
@@ -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<State, 'session' | 'message' | 'part'>,
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 };
};
-1
View File
@@ -66,7 +66,6 @@ The webview build emits each worker as one self-contained file. VS Code webviews
- `bridge-settings-runtime.ts` - `bridge-settings-runtime.ts`
- Settings read/write and OpenCode skills discovery via API for bridge consumers. - 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` - `bridge-system-runtime.ts`
- System/editor/provider/quota/notification/update-check message handlers. - System/editor/provider/quota/notification/update-check message handlers.
@@ -5,7 +5,6 @@ import * as path from 'path';
import * as vscode from 'vscode'; import * as vscode from 'vscode';
import { BUILT_IN_SKILL_LOCATION, type DiscoveredSkill, type SkillScope, type SkillSource } from './opencodeConfig'; import { BUILT_IN_SKILL_LOCATION, type DiscoveredSkill, type SkillScope, type SkillSource } from './opencodeConfig';
import type { BridgeContext } from './bridge'; import type { BridgeContext } from './bridge';
import { enterSettingsSchema } from './settings-changes';
const SETTINGS_KEY = 'openchamber.settings'; const SETTINGS_KEY = 'openchamber.settings';
const OPENCHAMBER_SHARED_SETTINGS_PATH = path.join(os.homedir(), '.config', 'openchamber', 'settings.json'); const OPENCHAMBER_SHARED_SETTINGS_PATH = path.join(os.homedir(), '.config', 'openchamber', 'settings.json');
@@ -293,14 +292,6 @@ export const readSettings = (ctx?: BridgeContext): Record<string, unknown> => {
export const persistSettings = async (changes: Record<string, unknown>, ctx?: BridgeContext): Promise<Record<string, unknown>> => { export const persistSettings = async (changes: Record<string, unknown>, ctx?: BridgeContext): Promise<Record<string, unknown>> => {
const current = readSettings(ctx); const current = readSettings(ctx);
const restChanges = stripDerived({ ...(changes || {}) }); 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<string>(); const keysToClear = new Set<string>();
@@ -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({});
});
});
-8
View File
@@ -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),
});
+1 -2
View File
@@ -313,8 +313,7 @@ function getCurrentUsername() {
// when the user has lingering enabled. Detect it so `startup enable` can warn // 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 // that the service may otherwise stop on logout. Returns null when the state
// cannot be determined (no username, loginctl unavailable, or odd output). // cannot be determined (no username, loginctl unavailable, or odd output).
function getUserLingerEnabled(username) { function getUserLingerEnabled(user) {
const user = typeof username === 'string' && username.length > 0 ? username : getCurrentUsername();
if (!user) { if (!user) {
return null; return null;
} }
+1 -1
View File
@@ -1320,7 +1320,7 @@ const resolveMemoryProjectId = createMemoryProjectResolver({
return sanitizeProjects(settings?.projects || []).map((project) => project.path); return sanitizeProjects(settings?.projects || []).map((project) => project.path);
}, },
resolvePrimaryWorktreeRoot, 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])],
}); });
/** /**
+6 -6
View File
@@ -526,9 +526,12 @@ export const registerFsRoutes = (app, dependencies) => {
openchamberUserConfigRoot, openchamberUserConfigRoot,
managedChatsRoot, managedChatsRoot,
} = dependencies; } = dependencies;
const managedRoots = [openchamberUserConfigRoot, managedChatsRoot] // Chat worktrees may live outside every project workspace; both managed
.filter((root) => typeof root === 'string' && root.trim().length > 0) // roots stay valid filesystem targets.
.map((root) => path.resolve(root)); const chatsRoot = typeof managedChatsRoot === 'string' && managedChatsRoot.trim()
? path.resolve(managedChatsRoot.trim())
: path.join(openchamberUserConfigRoot, 'chats');
const managedRoots = [path.resolve(openchamberUserConfigRoot), chatsRoot];
const realpathCache = createRealpathCache({ const realpathCache = createRealpathCache({
realpath: fsPromises.realpath.bind(fsPromises), realpath: fsPromises.realpath.bind(fsPromises),
}); });
@@ -707,9 +710,6 @@ export const registerFsRoutes = (app, dependencies) => {
if (!home || typeof home !== 'string' || home.length === 0) { if (!home || typeof home !== 'string' || home.length === 0) {
return res.status(500).json({ error: 'Failed to resolve home directory' }); 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 }); return res.json({ home, chatsRoot });
} catch (error) { } catch (error) {
console.error('Failed to resolve home directory:', error); console.error('Failed to resolve home directory:', error);
@@ -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; export const DEFAULT_INPUT_HISTORY_LIMIT = 40;
const MIN_INPUT_HISTORY_LIMIT = 1; const MIN_INPUT_HISTORY_LIMIT = 1;
const MAX_INPUT_HISTORY_LIMIT = 100; const MAX_INPUT_HISTORY_LIMIT = 100;
@@ -705,12 +705,11 @@ export const createSettingsHelpers = (dependencies) => {
result.gitChangesViewMode = mode; result.gitChangesViewMode = mode;
} }
} }
switch (candidate.toolJsonViewMode) { if (typeof candidate.toolJsonViewMode === 'string') {
case 'summary': const mode = candidate.toolJsonViewMode.trim();
case 'formatted': if (mode === 'summary' || mode === 'formatted' || mode === 'raw') {
case 'raw': result.toolJsonViewMode = mode;
result.toolJsonViewMode = candidate.toolJsonViewMode; }
break;
} }
if (typeof candidate.directoryShowHidden === 'boolean') { if (typeof candidate.directoryShowHidden === 'boolean') {
result.directoryShowHidden = candidate.directoryShowHidden; result.directoryShowHidden = candidate.directoryShowHidden;
@@ -121,7 +121,7 @@ describe('settings helpers', () => {
inputHistoryLimit: 40, inputHistoryLimit: 40,
}); });
expect(helpers.formatSettingsResponse({})).toMatchObject({ expect(helpers.formatSettingsResponse({})).toMatchObject({
inputHistoryScope: 'global', inputHistoryScope: 'session',
inputHistoryLimit: DEFAULT_INPUT_HISTORY_LIMIT, inputHistoryLimit: DEFAULT_INPUT_HISTORY_LIMIT,
}); });
} finally { } finally {
@@ -265,14 +265,14 @@ describe('settings helpers', () => {
expect(helpers.sanitizeSettingsUpdate({ inputHistoryScope: 'workspace' })).toEqual({}); 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(); const helpers = createTestHelpers();
expect(helpers.formatSettingsResponse({ inputHistoryScope: 'session' })).toMatchObject({ expect(helpers.formatSettingsResponse({ inputHistoryScope: 'global' })).toMatchObject({
inputHistoryScope: 'session', inputHistoryScope: 'global',
}); });
expect(helpers.formatSettingsResponse({})).toMatchObject({ expect(helpers.formatSettingsResponse({})).toMatchObject({
inputHistoryScope: 'global', inputHistoryScope: 'session',
}); });
}); });