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`.
### `OPENCHAMBER_CHATS_DIR`
Verschiebt die verwalteten Chat-Verzeichnisse, die OpenChamber für Chats ohne Projekt anlegt. Standard ist `~/.config/openchamber/chats`. Setze ein Verzeichnis, das der OpenCode-Server lesen kann, wenn OpenChamber und OpenCode unter verschiedenen Benutzern laufen. Bestehende Chats werden nicht verschoben.
### `OPENCHAMBER_COMPRESS_API`
Steuert die Komprimierung von API-Antworten. Verwende `true` oder `1`, um sie zu erzwingen, und `false` oder `0`, um sie zu deaktivieren.
@@ -25,6 +25,10 @@ Starts OpenChamber in headless mode when set to `true` or `1`. API routes stay a
Overrides the OpenChamber data directory. The default is `~/.config/openchamber`.
### `OPENCHAMBER_CHATS_DIR`
Moves the managed chat directories that OpenChamber creates for chats without a project. The default is `~/.config/openchamber/chats`. Set it to a directory the OpenCode server can read when OpenChamber and OpenCode run as different users. Existing chats are not moved.
### `OPENCHAMBER_COMPRESS_API`
Controls API response compression. Use `true` or `1` to force it on, `false` or `0` to force it off.
@@ -25,6 +25,10 @@ Inicia OpenChamber en modo headless cuando vale `true` o `1`. Las rutas API sigu
Cambia el directorio de datos de OpenChamber. Por defecto es `~/.config/openchamber`.
### `OPENCHAMBER_CHATS_DIR`
Cambia la ubicación de los directorios de chat gestionados que OpenChamber crea para chats sin proyecto. Por defecto es `~/.config/openchamber/chats`. Indica un directorio que el servidor OpenCode pueda leer cuando OpenChamber y OpenCode se ejecutan con usuarios distintos. Los chats existentes no se mueven.
### `OPENCHAMBER_COMPRESS_API`
Controla la compresión de respuestas de la API. `true` o `1` la activa; `false` o `0` la desactiva.
@@ -25,6 +25,10 @@ Démarre OpenChamber en mode headless lorsquil vaut `true` ou `1`. Les routes
Remplace le dossier de données OpenChamber. La valeur par défaut est `~/.config/openchamber`.
### `OPENCHAMBER_CHATS_DIR`
Déplace les dossiers de chat gérés qu'OpenChamber crée pour les chats sans projet. La valeur par défaut est `~/.config/openchamber/chats`. Indiquez un dossier lisible par le serveur OpenCode lorsque OpenChamber et OpenCode tournent sous des utilisateurs différents. Les chats existants ne sont pas déplacés.
### `OPENCHAMBER_COMPRESS_API`
Contrôle la compression des réponses API. Utilisez `true` ou `1` pour la forcer, `false` ou `0` pour la désactiver.
@@ -25,6 +25,10 @@ OpenChamber Web サーバーのバインドアドレスです。他のマシン
OpenChamber のデータディレクトリを上書きします。デフォルトは `~/.config/openchamber` です。
### `OPENCHAMBER_CHATS_DIR`
プロジェクトを持たないチャット用に OpenChamber が作成する管理チャットディレクトリの場所を変更します。デフォルトは `~/.config/openchamber/chats` です。OpenChamber と OpenCode を別のユーザーで実行している場合は、OpenCode サーバーが読み取れるディレクトリを指定してください。既存のチャットは移動されません。
### `OPENCHAMBER_COMPRESS_API`
API レスポンス圧縮を制御します。`true` または `1` で強制的に有効化し、`false` または `0` で強制的に無効化します。
@@ -25,6 +25,10 @@ OpenChamber 웹 서버가 바인딩할 주소입니다. 다른 컴퓨터에서
OpenChamber 데이터 디렉터리를 바꿉니다. 기본값은 `~/.config/openchamber`입니다.
### `OPENCHAMBER_CHATS_DIR`
프로젝트가 없는 채팅을 위해 OpenChamber가 만드는 관리형 채팅 디렉터리의 위치를 바꿉니다. 기본값은 `~/.config/openchamber/chats`입니다. OpenChamber와 OpenCode가 서로 다른 사용자로 실행될 때는 OpenCode 서버가 읽을 수 있는 디렉터리를 지정하세요. 기존 채팅은 이동되지 않습니다.
### `OPENCHAMBER_COMPRESS_API`
API 응답 압축을 제어합니다. `true` 또는 `1`은 켜고, `false` 또는 `0`은 끕니다.
@@ -25,6 +25,10 @@ Uruchamia OpenChamber w trybie headless, gdy ustawione na `true` lub `1`. Trasy
Nadpisuje katalog danych OpenChamber. Domyślnie jest to `~/.config/openchamber`.
### `OPENCHAMBER_CHATS_DIR`
Przenosi katalogi zarządzanych czatów, które OpenChamber tworzy dla czatów bez projektu. Domyślnie jest to `~/.config/openchamber/chats`. Ustaw katalog, który serwer OpenCode może odczytać, gdy OpenChamber i OpenCode działają jako różni użytkownicy. Istniejące czaty nie są przenoszone.
### `OPENCHAMBER_COMPRESS_API`
Steruje kompresją odpowiedzi API. `true` albo `1` ją włącza, `false` albo `0` wyłącza.
@@ -25,6 +25,10 @@ Inicia o OpenChamber em modo headless quando definido como `true` ou `1`. As rot
Altera o diretório de dados do OpenChamber. O padrão é `~/.config/openchamber`.
### `OPENCHAMBER_CHATS_DIR`
Move os diretórios de chat gerenciados que o OpenChamber cria para chats sem projeto. O padrão é `~/.config/openchamber/chats`. Defina um diretório que o servidor OpenCode consiga ler quando o OpenChamber e o OpenCode rodam com usuários diferentes. Os chats existentes não são movidos.
### `OPENCHAMBER_COMPRESS_API`
Controla a compressão das respostas da API. `true` ou `1` ativa; `false` ou `0` desativa.
@@ -25,6 +25,10 @@ Tarayıcı arayüzü için parola. Bunu localhost dışına bind ederken, tünel
OpenChamber veri dizinini geçersiz kılar. Varsayılan `~/.config/openchamber`.
### `OPENCHAMBER_CHATS_DIR`
OpenChamber'ın projesiz sohbetler için oluşturduğu yönetilen sohbet dizinlerinin yerini değiştirir. Varsayılan `~/.config/openchamber/chats`. OpenChamber ve OpenCode farklı kullanıcılarla çalışıyorsa OpenCode sunucusunun okuyabileceği bir dizin belirtin. Mevcut sohbetler taşınmaz.
### `OPENCHAMBER_COMPRESS_API`
API yanıt sıkıştırmasını kontrol eder. `true` ya da `1` ile zorla açın, `false` ya da `0` ile zorla kapatın.
@@ -25,6 +25,10 @@ OpenChamber читає ці змінні під час запуску. Для st
Перевизначає директорію даних OpenChamber. Типово це `~/.config/openchamber`.
### `OPENCHAMBER_CHATS_DIR`
Переносить директорії керованих чатів, які OpenChamber створює для чатів без проєкту. Типово це `~/.config/openchamber/chats`. Вкажіть директорію, яку може читати сервер OpenCode, якщо OpenChamber і OpenCode працюють від різних користувачів. Наявні чати не переносяться.
### `OPENCHAMBER_COMPRESS_API`
Керує стисненням API-відповідей. `true` або `1` вмикає, `false` або `0` вимикає.
@@ -25,6 +25,10 @@ OpenChamber web 服务器监听的地址。使用 `0.0.0.0` 可允许其他机
覆盖 OpenChamber 数据目录。默认是 `~/.config/openchamber`。
### `OPENCHAMBER_CHATS_DIR`
更改 OpenChamber 为无项目聊天创建的托管聊天目录的位置。默认是 `~/.config/openchamber/chats`。当 OpenChamber 和 OpenCode 以不同用户运行时,请设置为 OpenCode 服务器可读取的目录。现有聊天不会被移动。
### `OPENCHAMBER_COMPRESS_API`
控制 API 响应压缩。`true` 或 `1` 强制开启,`false` 或 `0` 强制关闭。
+13 -7
View File
@@ -175,7 +175,9 @@ import {
buildChatInputHistorySubmissions,
buildInputHistoryNavigatorIdentity,
mapInputHistoryEntriesToValues,
mergeSessionInputHistory,
} from './inputHistory';
import { useUserMessageHistory } from '@/sync/sync-context';
// Lazy like in ChatMessage: a static import would pull the @pierre/diffs and
// Shiki stacks into the eager startup graph for a dialog opened on demand.
@@ -198,7 +200,6 @@ const MAX_MOBILE_COMPOSER_LINES = 16;
*/
const MOBILE_COMPOSER_BOUND_GAP_PX = 4;
const EMPTY_QUEUE: QueuedMessage[] = [];
const EMPTY_INPUT_HISTORY_ENTRIES = Object.freeze([] as const);
const COMPACT_CHAT_PLACEHOLDER_MAX_WIDTH = 560;
const renameFileForAttachmentCitation = (file: File, filename: string): File => {
if (file.name === filename) {
@@ -908,13 +909,18 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
),
[activeRuntimeKey, currentDirectory, currentSessionDirectoryForSync, currentSessionId],
);
const inputHistoryEntries = useInputHistoryStore(React.useCallback((state) => {
const entries = selectInputHistoryEntries(state, inputHistoryIdentity);
return entries.length === 0 ? EMPTY_INPUT_HISTORY_ENTRIES : entries;
}, [inputHistoryIdentity]));
const inputHistoryEntries = useInputHistoryStore(React.useCallback(
(state) => selectInputHistoryEntries(state, inputHistoryIdentity),
[inputHistoryIdentity],
));
// Session scope also reads the visible transcript, so sessions older than
// the persisted history still recall their prompts.
const transcriptPrompts = useUserMessageHistory(currentSessionId ?? '');
const historyValues = React.useMemo(
() => mapInputHistoryEntriesToValues(inputHistoryEntries),
[inputHistoryEntries],
() => (inputHistoryScope === 'session'
? mergeSessionInputHistory(transcriptPrompts, inputHistoryEntries)
: mapInputHistoryEntriesToValues(inputHistoryEntries)),
[inputHistoryEntries, inputHistoryScope, transcriptPrompts],
);
const messageHistoryIdentity = React.useMemo(
() => buildInputHistoryNavigatorIdentity(inputHistoryScope, inputHistoryIdentity),
@@ -199,17 +199,22 @@ Prompt recall has two owners on purpose.
- `packages/ui/src/stores/useInputHistoryStore.ts` owns the persisted source of
truth. It keeps the runtime-scoped global bucket and the runtime + directory
+ session bucket, each capped by the configurable input-history limit. That
setting defaults to 40 entries.
setting defaults to 40 entries. Recall reads the current session's bucket by
default; the Chat setting can widen it to every project on the runtime.
- `state/useMessageHistory.ts` owns only keyboard traversal through whichever
bucket the composer was given. It stashes the current draft on entry and
restores it on the way back out.
- `ChatInput.tsx` owns the recalled-entry presentation. If the user edits a
recalled prompt, the UI may show an overlay state for "this came from
history", but that edit does not rewrite stored history.
bucket the composer was given. Moving away from a position stores the
composer's current text and attachments as an overlay for that position, so
the live draft and any edit made to a recalled prompt survive a round trip
through history. Overlays never rewrite stored history; sending resets them.
- `ChatInput.tsx` applies the recalled text and attachments to the composer and
places the caret.
Transcript visibility is not part of this contract anymore. Revert markers may
hide older user messages from the chat timeline, but they do not decide what
ArrowUp and ArrowDown can recall.
In session scope the composer merges two sources, oldest first: the visible
transcript's user prompts (`useUserMessageHistory` in `sync-context.tsx`), so
sessions that predate the persisted store still recall, and the persisted
session bucket, which adds attachments and keeps prompts a revert hid from the
timeline. A prompt present in both collapses to the persisted entry. Global
scope reads the persisted runtime bucket only.
## Mobile
@@ -234,7 +234,7 @@ export const ComposerEditor = React.forwardRef<ComposerEditorHandle, ComposerEdi
// A deferred Enter loses its modifiers in the re-dispatch.
if (event.key === 'Enter' && isDeferredSyntheticEvent(event)) {
const preserveShift = handlersRef.current.preserveDeferredEnterShift !== false;
restoreDeferredEnterModifiers(event, lastRealEnterModsRef.current, { preserveShift });
restoreDeferredEnterModifiers(event, lastRealEnterModsRef.current, preserveShift);
clearDeferredEnterModifiers();
}
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 }]) {
test(`untouched mobile deferred Shift does not submit: ${JSON.stringify(modifiers)}`, () => {
const event = { shiftKey: false, ctrlKey: false, metaKey: false };
restoreDeferredEnterModifiers(event, modifiers, { preserveShift: true });
restoreDeferredEnterModifiers(event, modifiers, true);
expect(shouldSubmitEnter(policy({ isMobile: true, ...event }))).toBe(false);
});
}
@@ -75,7 +75,7 @@ describe('deferred Enter modifiers', () => {
test('does not restore iOS auto-capitalization as Shift', () => {
const event = { shiftKey: false, ctrlKey: false, metaKey: false };
restoreDeferredEnterModifiers(event, { shiftKey: true, ctrlKey: false, metaKey: false }, { preserveShift: false });
restoreDeferredEnterModifiers(event, { shiftKey: true, ctrlKey: false, metaKey: false }, false);
expect(event).toEqual({ shiftKey: false, ctrlKey: false, metaKey: false });
});
@@ -28,16 +28,12 @@ export interface EnterModifierState {
metaKey: boolean;
}
interface DeferredEnterModifierOptions {
preserveShift?: boolean;
}
export const restoreDeferredEnterModifiers = (
event: EnterModifierState,
modifiers: EnterModifierState,
options: DeferredEnterModifierOptions = {},
preserveShift = true,
): void => {
if (options.preserveShift !== false && modifiers.shiftKey) {
if (preserveShift && modifiers.shiftKey) {
Object.defineProperty(event, 'shiftKey', { value: true });
}
if (modifiers.ctrlKey) Object.defineProperty(event, 'ctrlKey', { value: true });
@@ -7,6 +7,7 @@ import {
buildChatInputHistorySubmissions,
buildInputHistoryNavigatorIdentity,
mapInputHistoryEntriesToValues,
mergeSessionInputHistory,
} from './inputHistory';
const ATTACHMENT: AttachedFile = {
@@ -131,3 +132,43 @@ describe('buildInputHistoryNavigatorIdentity', () => {
})).toBe('session\nruntime-a\n/repo\nsession-1');
});
});
describe('mergeSessionInputHistory', () => {
const entry = (text: string, submittedAtMs: number): InputHistoryEntry => ({
text,
attachmentKeys: [],
restorableAttachments: [],
submittedAt: submittedAtMs * 1000,
});
test('recalls transcript prompts when nothing is persisted yet', () => {
const values = mergeSessionInputHistory(
[{ text: 'first', createdAt: 10 }, { text: 'second', createdAt: 20 }],
[],
);
expect(values.map((value) => value.text)).toEqual(['first', 'second']);
expect(values[0]?.attachments).toEqual([]);
});
test('interleaves persisted entries by time and collapses duplicates onto the persisted entry', () => {
const persisted: InputHistoryEntry = {
...entry('second', 20),
restorableAttachments: [{
key: 'server-file',
source: 'file-url',
filename: 'server.txt',
mimeType: 'text/plain',
size: 11,
reference: '/repo/server.txt',
}],
};
const values = mergeSessionInputHistory(
[{ text: 'first', createdAt: 10 }, { text: 'second', createdAt: 20 }, { text: 'fourth', createdAt: 40 }],
[persisted, entry('reverted', 30)],
);
expect(values.map((value) => value.text)).toEqual(['first', 'second', 'reverted', 'fourth']);
expect(values[1]?.attachments[0]?.filename).toBe('server.txt');
});
});
+25 -25
View File
@@ -8,6 +8,8 @@ import {
type InputHistorySubmission,
} from '@/stores/useInputHistoryStore';
import type { AttachedFile } from '@/stores/types/sessionTypes';
import type { TranscriptPrompt } from '@/sync/user-message-history';
import { toServerFileUrl } from './composer/attachments/filePaths';
type HistoryQueuedMessage = {
content: string;
@@ -22,30 +24,6 @@ type BuildHistorySubmissionsArgs = {
includeComposer: boolean;
};
const FILE_URI_PREFIX = 'file://';
const encodeFilePath = (filepath: string): string => {
let normalized = filepath.replace(/\\/g, '/');
if (/^[A-Za-z]:/.test(normalized)) {
normalized = `/${normalized}`;
}
return normalized
.split('/')
.map((segment, index) => {
if (index === 1 && /^[A-Za-z]:$/.test(segment)) return segment;
return encodeURIComponent(segment);
})
.join('/');
};
const toFileUrl = (filepath: string): string => {
const normalized = filepath.replace(/\\/g, '/').trim();
if (normalized.toLowerCase().startsWith(FILE_URI_PREFIX)) {
return normalized;
}
return `${FILE_URI_PREFIX}${encodeFilePath(normalized)}`;
};
export function buildChatInputHistorySubmissions({
inputMode,
queuedMessages,
@@ -85,7 +63,7 @@ function materializeHistoryAttachment(attachment: InputHistoryAttachment): Attac
return {
id: `history-${attachment.key}`,
file: new File([], attachment.filename, { type: attachment.mimeType }),
dataUrl: toFileUrl(attachment.reference),
dataUrl: toServerFileUrl(attachment.reference),
mimeType: attachment.mimeType,
filename: attachment.filename,
size: attachment.size,
@@ -109,6 +87,28 @@ export function mapInputHistoryEntriesToValues(
}));
}
/**
* Session-scoped recall: the visible transcript's prompts (so sessions older
* than the persisted store still recall) merged with the persisted bucket
* (attachments, and prompts a revert hid from the transcript), oldest first.
* A prompt present in both collapses to the persisted entry.
*/
export function mergeSessionInputHistory(
transcript: readonly TranscriptPrompt[],
entries: readonly InputHistoryEntry[],
): Array<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(
scope: InputHistoryScope,
identity: InputHistoryIdentity | null,
@@ -70,9 +70,7 @@ import {
import { isTerminalShell } from '@/lib/terminalShell';
import { subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch';
import { formatShortcutForDisplay } from '@/lib/shortcuts';
import {
useInputHistoryStore,
} from '@/stores/useInputHistoryStore';
import { useInputHistoryStore } from '@/stores/useInputHistoryStore';
interface Option<T extends string> {
id: T;
@@ -738,7 +736,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|| shouldShow('showToolFileIcons')
|| shouldShow('showTurnChangedFiles')
|| (!isMobile && shouldShow('inputSpellcheck'))
|| shouldShow('enterToSend')
|| shouldShow('enterToSend')
|| shouldShow('reasoning')
|| shouldShow('expandedTools');
// First behavior section under the page header should not draw a top border on Chat-only;
@@ -1512,7 +1510,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
/>
))}
</SettingsRadioGroup>
</SettingsControlGroup>
</SettingsControlGroup>
)}
<div className={SETTINGS_OPTION_STACK_CLASS}>
{shouldShow('autoSaveEnabled') && (
@@ -2150,9 +2148,9 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
<SettingsCheckboxRow
checked={enterToSendConfigured ? enterToSend : !isMobile && !isExpandedInput}
onChange={handleEnterToSendChange}
label={t('chat.chatInput.actions.enterToSend')}
info={t('chat.chatInput.actions.enterToSendHint')}
ariaLabel={t('chat.chatInput.actions.enterToSend')}
label={t('settings.openchamber.visual.field.enterToSend')}
info={t('settings.openchamber.visual.field.enterToSendHint')}
ariaLabel={t('settings.openchamber.visual.field.enterToSend')}
settingsItem="chat.enter-to-send"
/>
)}
@@ -2134,7 +2134,6 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
}
applyLoadedTextContent(content);
},
maxBytes: MAX_CONTENT_POLL_BYTES,
})
: null;
@@ -2165,7 +2164,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
if (contentPoller && latestStat.size <= MAX_CONTENT_POLL_BYTES) {
// Only an observed read retires the change; a dirty buffer or a
// failed read leaves the baseline so the next tick retries.
const observed = await contentPoller.poll(latestStat.size);
const observed = await contentPoller.poll();
if (observed && !cancelled) {
lastLoadedFileStatRef.current = latestStat;
}
@@ -2,8 +2,6 @@ import { describe, expect, test } from 'bun:test';
import { createFileContentPoller } from './fileContentPoller';
const MAX_BYTES = 200_000;
const deferred = <T>() => {
let resolve!: (value: T) => void;
const promise = new Promise<T>((done) => { resolve = done; });
@@ -23,11 +21,10 @@ describe('createFileContentPoller', () => {
getLoadedRevision: () => 0,
isDirty: () => false,
applyContent: (content) => applied.push(content),
maxBytes: MAX_BYTES,
});
expect(await poller.poll(12)).toBe(true);
expect(await poller.poll(12)).toBe(true);
expect(await poller.poll()).toBe(true);
expect(await poller.poll()).toBe(true);
expect(reads).toBe(2);
expect(applied).toEqual([]);
@@ -41,10 +38,9 @@ describe('createFileContentPoller', () => {
getLoadedRevision: () => 0,
isDirty: () => false,
applyContent: (content) => applied.push(content),
maxBytes: MAX_BYTES,
});
expect(await poller.poll(6)).toBe(false);
expect(await poller.poll()).toBe(false);
expect(applied).toEqual([]);
});
@@ -59,10 +55,9 @@ describe('createFileContentPoller', () => {
getLoadedRevision: () => 0,
isDirty: () => true,
applyContent: () => undefined,
maxBytes: MAX_BYTES,
});
expect(await poller.poll(6)).toBe(false);
expect(await poller.poll()).toBe(false);
expect(reads).toBe(0);
});
@@ -78,10 +73,9 @@ describe('createFileContentPoller', () => {
getLoadedRevision: () => 0,
isDirty: () => false,
applyContent: (content) => applied.push(content),
maxBytes: MAX_BYTES,
});
expect(await poller.poll(3)).toBe(true);
expect(await poller.poll()).toBe(true);
expect(reads).toBe(2);
expect(applied).toEqual(['abd']);
@@ -97,10 +91,9 @@ describe('createFileContentPoller', () => {
getLoadedRevision: () => 0,
isDirty: () => dirty,
applyContent: (content) => applied.push(content),
maxBytes: MAX_BYTES,
});
const polling = poller.poll(20);
const polling = poller.poll();
dirty = true;
read.resolve('external edit');
await polling;
@@ -117,17 +110,16 @@ describe('createFileContentPoller', () => {
getLoadedRevision: () => 0,
isDirty: () => false,
applyContent: (content) => applied.push(content),
maxBytes: MAX_BYTES,
});
await poller.poll(7);
await poller.poll();
expect(applied).toEqual([]);
expect(await poller.poll(7)).toBe(true);
expect(await poller.poll()).toBe(true);
expect(applied).toEqual(['settled']);
});
test('allows only one read in flight and ignores a disposed poll', async () => {
test('ignores a read that resolves after dispose', async () => {
const read = deferred<string>();
let reads = 0;
const applied: string[] = [];
@@ -140,14 +132,12 @@ describe('createFileContentPoller', () => {
getLoadedRevision: () => 0,
isDirty: () => false,
applyContent: (content) => applied.push(content),
maxBytes: MAX_BYTES,
});
const first = poller.poll(5);
await poller.poll(5);
const pending = poller.poll();
poller.dispose();
read.resolve('after');
await first;
expect(await pending).toBe(false);
expect(reads).toBe(1);
expect(applied).toEqual([]);
@@ -165,10 +155,9 @@ describe('createFileContentPoller', () => {
getLoadedRevision: () => loadedRevision,
isDirty: () => false,
applyContent: (content) => applied.push(content),
maxBytes: MAX_BYTES,
});
const polling = poller.poll(20);
const polling = poller.poll();
firstRead.resolve('external edit');
await Promise.resolve();
loadedRevision += 1;
@@ -179,22 +168,4 @@ describe('createFileContentPoller', () => {
expect(applied).toEqual([]);
});
test('does not read content above the polling byte limit', async () => {
let reads = 0;
const poller = createFileContentPoller({
readContent: async () => {
reads += 1;
return 'content';
},
getLoadedContent: () => 'before',
getLoadedRevision: () => 0,
isDirty: () => false,
applyContent: () => undefined,
maxBytes: MAX_BYTES,
});
expect(await poller.poll(MAX_BYTES + 1)).toBe(false);
expect(reads).toBe(0);
});
});
@@ -4,18 +4,17 @@ type FileContentPollerOptions = {
getLoadedRevision: () => number;
isDirty: () => boolean;
applyContent: (content: string) => void;
maxBytes: number;
};
// The caller serializes polls and decides which files qualify (text, within
// the byte limit); this owns only the read-compare-confirm-apply step.
export const createFileContentPoller = (options: FileContentPollerOptions) => {
let active = true;
let polling = false;
return {
/** Resolves true only when the poll observed the file's current content. */
poll: async (size: number): Promise<boolean> => {
if (!active || polling || options.isDirty() || size > options.maxBytes) return false;
polling = true;
poll: async (): Promise<boolean> => {
if (!active || options.isDirty()) return false;
const loadedContent = options.getLoadedContent();
const loadedRevision = options.getLoadedRevision();
try {
@@ -23,6 +22,7 @@ export const createFileContentPoller = (options: FileContentPollerOptions) => {
if (!active || options.isDirty() || loadedRevision !== options.getLoadedRevision()) return false;
if (content === loadedContent) return true;
// A second read guards against applying a half-written file.
const confirmedContent = await options.readContent();
if (!active || options.isDirty() || confirmedContent !== content || loadedRevision !== options.getLoadedRevision()) {
return false;
@@ -32,8 +32,6 @@ export const createFileContentPoller = (options: FileContentPollerOptions) => {
} catch {
// A failed read is not proof the file is unchanged; the next poll retries.
return false;
} finally {
polling = false;
}
},
dispose: () => {
@@ -118,12 +118,9 @@ export const sendQueuedAutoSendPayload = (
payload.agentMentionName,
payload.additionalParts.length > 0 ? payload.additionalParts : undefined,
resolved.variant,
'normal',
{
target,
historySubmissions: payload.historySubmissions,
},
);
'normal',
{ target, historySubmissions: payload.historySubmissions },
);
};
const resolveSessionSendConfig = (sessionId: string) => {
@@ -2012,6 +2012,8 @@ export const settingsDict = {
'settings.openchamber.visual.field.enableSpellcheckInTextInputs': 'Rechtschreibprüfung in Texteingaben aktivieren',
'settings.openchamber.visual.field.largeTextPaste': 'Großes Texteinfügen',
'settings.openchamber.visual.field.largeTextPasteHint': 'Beim Einfügen von mehr als etwa 2.000 Zeichen oder 25 Zeilen wählen, ob der Text als Datei angehängt, direkt eingefügt oder jedes Mal nachgefragt werden soll.',
'settings.openchamber.visual.field.enterToSend': 'Enter sendet',
'settings.openchamber.visual.field.enterToSendHint': 'Nach der Änderung steuern Enter und Shift+Enter das Verhalten auf jeder Oberfläche. Bis dahin behält jede Oberfläche ihr bestehendes Verhalten bei.',
'settings.openchamber.visual.field.largeTextPasteAria': 'Verhalten bei großem Texteinfügen',
'settings.openchamber.visual.field.largeTextPasteOptionAria': 'Großes Texteinfügen: {option}',
'settings.openchamber.visual.option.largeTextPaste.ask.label': 'Jedes Mal fragen',
-2
View File
@@ -2105,8 +2105,6 @@ export const dict = {
'chat.chatInput.actions.linkGithubPr': 'GitHub-PR verknüpfen',
'chat.chatInput.actions.modelAgentSettings': 'Modell- und Agenteneinstellungen',
'chat.chatInput.actions.sendMessageAria': 'Nachricht senden',
'chat.chatInput.actions.enterToSend': 'Enter sendet',
'chat.chatInput.actions.enterToSendHint': 'Nach der Änderung steuern Enter und Shift+Enter das Verhalten auf jeder Oberfläche. Bis dahin behält jede Oberfläche ihr bestehendes Verhalten bei.',
'chat.chatInput.actions.queueMessageAria': 'Nachricht in die Warteschlange stellen',
'chat.chatInput.actions.stopGeneratingAria': 'Generierung stoppen',
'chat.chatInput.focusMode.toggleAria': 'Fokusmodus umschalten',
@@ -2097,6 +2097,8 @@ export const settingsDict = {
'settings.openchamber.visual.field.enableSpellcheckInTextInputs': 'Enable Spellcheck in Text Inputs',
'settings.openchamber.visual.field.largeTextPaste': 'Large text paste',
'settings.openchamber.visual.field.largeTextPasteHint': 'When pasting more than about 2,000 characters or 25 lines, choose whether to attach the text as a file, paste it inline, or ask each time.',
'settings.openchamber.visual.field.enterToSend': 'Enter sends',
'settings.openchamber.visual.field.enterToSendHint': 'Once changed, this controls Enter and Shift+Enter on every surface. Until then, each surface keeps its existing behavior.',
'settings.openchamber.visual.field.largeTextPasteAria': 'Large text paste behavior',
'settings.openchamber.visual.field.largeTextPasteOptionAria': 'Large text paste: {option}',
'settings.openchamber.visual.option.largeTextPaste.ask.label': 'Ask each time',
-2
View File
@@ -2322,8 +2322,6 @@ export const dict = {
'chat.chatInput.actions.linkGithubPr': 'Link GitHub PR',
'chat.chatInput.actions.modelAgentSettings': 'Model and agent settings',
'chat.chatInput.actions.sendMessageAria': 'Send message',
'chat.chatInput.actions.enterToSend': 'Enter sends',
'chat.chatInput.actions.enterToSendHint': 'Once changed, this controls Enter and Shift+Enter on every surface. Until then, each surface keeps its existing behavior.',
'chat.chatInput.actions.queueMessageAria': 'Queue message',
'chat.chatInput.actions.stopGeneratingAria': 'Stop generating',
'chat.chatInput.focusMode.toggleAria': 'Toggle focus mode',
@@ -2246,6 +2246,8 @@ export const settingsDict = {
"settings.openchamber.visual.field.inputHistoryLimitDescription": "Bajar este número elimina de inmediato los prompts más antiguos de tu historial.",
"settings.openchamber.visual.field.inputHistoryLimitAria": "Prompts que recordar",
"settings.openchamber.visual.field.inputHistoryLimitUnit": "prompts",
"settings.openchamber.visual.field.enterToSend": "Enter envía",
"settings.openchamber.visual.field.enterToSendHint": "Después de cambiarlo, controla Enter y Shift+Enter en todas las superficies. Hasta entonces, cada superficie mantiene su comportamiento actual.",
...linearIntegrationI18n.es,
'settings.page.integrations.title': 'Integraciones',
'settings.page.integrations.description': 'Conecta GitHub y Linear para que OpenChamber pueda trabajar con tus issues y pull requests.',
-2
View File
@@ -2300,8 +2300,6 @@ export const dict: Record<I18nKey, string> = {
"chat.chatInput.actions.linkGithubPr": "Vincular PR de GitHub",
"chat.chatInput.actions.modelAgentSettings": "Configuración del modelo y agente",
"chat.chatInput.actions.sendMessageAria": "Enviar mensaje",
'chat.chatInput.actions.enterToSend': 'Enter envía',
'chat.chatInput.actions.enterToSendHint': 'Después de cambiarlo, controla Enter y Shift+Enter en todas las superficies. Hasta entonces, cada superficie mantiene su comportamiento actual.',
"chat.chatInput.actions.queueMessageAria": "Poner mensaje en cola",
"chat.chatInput.actions.stopGeneratingAria": "Detener generación",
"chat.chatInput.focusMode.toggleAria": "Activar o desactivar modo de enfoque",
@@ -2246,6 +2246,8 @@ export const settingsDict = {
'settings.openchamber.visual.field.inputHistoryLimitDescription': 'Réduire ce nombre supprime aussitôt les prompts les plus anciens de votre historique.',
'settings.openchamber.visual.field.inputHistoryLimitAria': 'Prompts à mémoriser',
'settings.openchamber.visual.field.inputHistoryLimitUnit': 'prompts',
'settings.openchamber.visual.field.enterToSend': 'Entrée envoie',
'settings.openchamber.visual.field.enterToSendHint': 'Après modification, ce réglage contrôle Entrée et Maj+Entrée sur toutes les surfaces. En attendant, chaque surface conserve son comportement actuel.',
...linearIntegrationI18n.fr,
'settings.page.integrations.title': 'Intégrations',
'settings.page.integrations.description': 'Connectez GitHub et Linear pour quOpenChamber 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.modelAgentSettings': 'Paramètres du modèle et de l\'agent',
'chat.chatInput.actions.sendMessageAria': 'Envoyer un message',
'chat.chatInput.actions.enterToSend': 'Entrée envoie',
'chat.chatInput.actions.enterToSendHint': 'Après modification, ce réglage contrôle Entrée et Maj+Entrée sur toutes les surfaces. En attendant, chaque surface conserve son comportement actuel.',
'chat.chatInput.actions.queueMessageAria': 'Message de file d\'attente',
'chat.chatInput.actions.stopGeneratingAria': 'Arrêter de générer',
'chat.chatInput.focusMode.toggleAria': 'Basculer le mode de mise au point',
@@ -2246,6 +2246,8 @@ export const settingsDict = {
'settings.openchamber.visual.field.inputHistoryLimitDescription': 'この数を減らすと、履歴内の古いプロンプトはすぐに削除されます。',
'settings.openchamber.visual.field.inputHistoryLimitAria': '記憶するプロンプト数',
'settings.openchamber.visual.field.inputHistoryLimitUnit': '件',
'settings.openchamber.visual.field.enterToSend': 'Enterで送信',
'settings.openchamber.visual.field.enterToSendHint': '変更すると、すべての環境でEnterとShift+Enterの動作を制御します。変更するまでは、各環境の既存の動作が維持されます。',
...linearIntegrationI18n.ja,
'settings.page.integrations.title': '連携',
'settings.page.integrations.description': 'GitHub と Linear を接続すると、OpenChamber が Issue やプルリクエストを扱えるようになります。',
-2
View File
@@ -2318,8 +2318,6 @@ export const dict: Record<I18nKey, string> = {
'chat.chatInput.actions.linkGithubPr': 'GitHub PRをリンク',
'chat.chatInput.actions.modelAgentSettings': 'モデルとエージェント設定',
'chat.chatInput.actions.sendMessageAria': 'メッセージを送信',
'chat.chatInput.actions.enterToSend': 'Enterで送信',
'chat.chatInput.actions.enterToSendHint': '変更すると、すべての環境でEnterとShift+Enterの動作を制御します。変更するまでは、各環境の既存の動作が維持されます。',
'chat.chatInput.actions.queueMessageAria': 'メッセージをキュー',
'chat.chatInput.actions.stopGeneratingAria': '生成を停止',
'chat.chatInput.focusMode.toggleAria': 'フォーカスモードの切り替え',
@@ -2246,6 +2246,8 @@ export const settingsDict = {
'settings.openchamber.visual.field.inputHistoryLimitDescription': '이 숫자를 낮추면 기록에서 오래된 프롬프트가 바로 삭제됩니다.',
'settings.openchamber.visual.field.inputHistoryLimitAria': '기억할 프롬프트 수',
'settings.openchamber.visual.field.inputHistoryLimitUnit': '개',
'settings.openchamber.visual.field.enterToSend': 'Enter로 전송',
'settings.openchamber.visual.field.enterToSendHint': '변경하면 모든 환경에서 Enter와 Shift+Enter의 동작을 제어합니다. 변경하기 전에는 각 환경의 기존 동작이 유지됩니다.',
...linearIntegrationI18n.ko,
'settings.page.integrations.title': '통합',
'settings.page.integrations.description': 'GitHub와 Linear를 연결하면 OpenChamber가 이슈와 풀 리퀘스트를 다룰 수 있습니다.',
-2
View File
@@ -2322,8 +2322,6 @@ export const dict: Record<I18nKey, string> = {
'chat.chatInput.actions.linkGithubPr': 'GitHub PR 연결',
'chat.chatInput.actions.modelAgentSettings': '모델 및 에이전트 설정',
'chat.chatInput.actions.sendMessageAria': '보내기 메시지',
'chat.chatInput.actions.enterToSend': 'Enter로 전송',
'chat.chatInput.actions.enterToSendHint': '변경하면 모든 환경에서 Enter와 Shift+Enter의 동작을 제어합니다. 변경하기 전에는 각 환경의 기존 동작이 유지됩니다.',
'chat.chatInput.actions.queueMessageAria': '메시지 대기열에 추가',
'chat.chatInput.actions.stopGeneratingAria': '생성 중지',
'chat.chatInput.focusMode.toggleAria': '집중 모드 전환',
@@ -2239,6 +2239,8 @@ export const settingsDict = {
'settings.openchamber.visual.field.inputHistoryLimitDescription': 'Zmniejszenie tej liczby od razu usuwa starsze prompty z historii.',
'settings.openchamber.visual.field.inputHistoryLimitAria': 'Liczba zapamiętywanych promptów',
'settings.openchamber.visual.field.inputHistoryLimitUnit': 'promptów',
'settings.openchamber.visual.field.enterToSend': 'Enter wysyła',
'settings.openchamber.visual.field.enterToSendHint': 'Po zmianie ustawienie steruje działaniem klawiszy Enter i Shift+Enter na każdej powierzchni. Do tego czasu każda powierzchnia zachowuje dotychczasowe działanie.',
...linearIntegrationI18n.pl,
'settings.page.integrations.title': 'Integracje',
'settings.page.integrations.description': 'Połącz GitHub i Linear, aby OpenChamber mógł pracować z Twoimi issue i pull requestami.',
-2
View File
@@ -1255,8 +1255,6 @@ export const dict: Record<I18nKey, string> = {
'chat.chatInput.actions.modelAgentSettings': 'Model and agent settings',
'chat.chatInput.actions.queueMessageAria': 'Queue message',
'chat.chatInput.actions.sendMessageAria': 'Send message',
'chat.chatInput.actions.enterToSend': 'Enter wysyła',
'chat.chatInput.actions.enterToSendHint': 'Po zmianie ustawienie steruje działaniem klawiszy Enter i Shift+Enter na każdej powierzchni. Do tego czasu każda powierzchnia zachowuje dotychczasowe działanie.',
'chat.chatInput.actions.stopGeneratingAria': 'Stop generating',
'chat.chatInput.branch': 'Gałąź',
'chat.chatInput.draftPicker.projectTitle': 'Projekt',
@@ -2246,6 +2246,8 @@ export const settingsDict = {
"settings.openchamber.visual.field.inputHistoryLimitDescription": "Reduzir esse número remove na hora os prompts mais antigos do seu histórico.",
"settings.openchamber.visual.field.inputHistoryLimitAria": "Prompts para lembrar",
"settings.openchamber.visual.field.inputHistoryLimitUnit": "prompts",
"settings.openchamber.visual.field.enterToSend": "Enter envia",
"settings.openchamber.visual.field.enterToSendHint": "Depois de alterada, esta opção controla Enter e Shift+Enter em todas as superfícies. Até lá, cada superfície mantém seu comportamento atual.",
...linearIntegrationI18n['pt-BR'],
'settings.page.integrations.title': 'Integrações',
'settings.page.integrations.description': 'Conecte o GitHub e o Linear para que o OpenChamber possa trabalhar com suas issues e pull requests.',
@@ -2300,8 +2300,6 @@ export const dict: Record<I18nKey, string> = {
"chat.chatInput.actions.linkGithubPr": "Vincular PR de GitHub",
"chat.chatInput.actions.modelAgentSettings": "Configurações de modelo e agente",
"chat.chatInput.actions.sendMessageAria": "Enviar mensagem",
'chat.chatInput.actions.enterToSend': 'Enter envia',
'chat.chatInput.actions.enterToSendHint': 'Depois de alterada, esta opção controla Enter e Shift+Enter em todas as superfícies. Até lá, cada superfície mantém seu comportamento atual.',
"chat.chatInput.actions.queueMessageAria": "Colocar mensagem na fila",
"chat.chatInput.actions.stopGeneratingAria": "Parar geração",
"chat.chatInput.focusMode.toggleAria": "Ativar ou desativar modo de foco",
@@ -1959,6 +1959,8 @@ export const settingsDict = {
'settings.openchamber.visual.field.inputHistoryLimitDescription': 'Bu sayıyı azaltmak, eski prompt\'ları geçmişinizden hemen siler.',
'settings.openchamber.visual.field.inputHistoryLimitAria': 'Hatırlanacak prompt sayısı',
'settings.openchamber.visual.field.inputHistoryLimitUnit': 'prompt',
'settings.openchamber.visual.field.enterToSend': 'Enter gönderir',
'settings.openchamber.visual.field.enterToSendHint': 'Değiştirildikten sonra Enter ve Shift+Enter davranışını tüm yüzeylerde kontrol eder. O zamana kadar her yüzey mevcut davranışını korur.',
'settings.openchamber.visual.actions.resetInputBarOffsetAria': 'Giriş çubuğu ofsetini sıfırla',
'settings.openchamber.visual.field.terminalQuickKeysAria': 'Terminal hızlı tuşları',
'settings.openchamber.visual.field.terminalQuickKeys': 'Terminal Hızlı Tuşları',
-2
View File
@@ -2263,8 +2263,6 @@ export const dict = {
'chat.chatInput.actions.linkGithubPr': 'GitHub PR\'yi bağla',
'chat.chatInput.actions.modelAgentSettings': 'Model ve agent ayarları',
'chat.chatInput.actions.sendMessageAria': 'Mesaj gönder',
'chat.chatInput.actions.enterToSend': 'Enter gönderir',
'chat.chatInput.actions.enterToSendHint': 'Değiştirildikten sonra Enter ve Shift+Enter davranışını tüm yüzeylerde kontrol eder. O zamana kadar her yüzey mevcut davranışını korur.',
'chat.chatInput.actions.queueMessageAria': 'Mesajı kuyruğa ekle',
'chat.chatInput.actions.stopGeneratingAria': 'Üretmeyi durdur',
'chat.chatInput.focusMode.toggleAria': 'Odak modunu aç/kapat',
@@ -2246,6 +2246,8 @@ export const settingsDict = {
"settings.openchamber.visual.field.inputHistoryLimitDescription": "Якщо зменшити це число, старіші промпти одразу буде видалено з історії.",
"settings.openchamber.visual.field.inputHistoryLimitAria": "Скільки промптів пам’ятати",
"settings.openchamber.visual.field.inputHistoryLimitUnit": "промптів",
"settings.openchamber.visual.field.enterToSend": "Enter надсилає",
"settings.openchamber.visual.field.enterToSendHint": "Після зміни цей параметр керує поведінкою Enter і Shift+Enter на всіх поверхнях. До цього кожна поверхня зберігає свою поточну поведінку.",
...linearIntegrationI18n.uk,
'settings.page.integrations.title': 'Інтеграції',
'settings.page.integrations.description': 'Підключіть GitHub і Linear, щоб OpenChamber міг працювати з вашими задачами та pull request-ами.',
-2
View File
@@ -2300,8 +2300,6 @@ export const dict: Record<I18nKey, string> = {
"chat.chatInput.actions.linkGithubPr": "Пов’язати GitHub PR",
"chat.chatInput.actions.modelAgentSettings": "Параметри моделі та агента",
"chat.chatInput.actions.sendMessageAria": "Надіслати повідомлення",
'chat.chatInput.actions.enterToSend': 'Enter надсилає',
'chat.chatInput.actions.enterToSendHint': 'Після зміни цей параметр керує поведінкою Enter і Shift+Enter на всіх поверхнях. До цього кожна поверхня зберігає свою поточну поведінку.',
"chat.chatInput.actions.queueMessageAria": "Поставити повідомлення в чергу",
"chat.chatInput.actions.stopGeneratingAria": "Припинити генерацію",
"chat.chatInput.focusMode.toggleAria": "Перемкнути режим фокусування",
@@ -2246,6 +2246,8 @@ export const settingsDict = {
'settings.openchamber.visual.field.inputHistoryLimitDescription': '调低这个数字会立即从历史记录中删除较早的提示词。',
'settings.openchamber.visual.field.inputHistoryLimitAria': '要记住的提示词数量',
'settings.openchamber.visual.field.inputHistoryLimitUnit': '条',
'settings.openchamber.visual.field.enterToSend': 'Enter 发送',
'settings.openchamber.visual.field.enterToSendHint': '更改后,此设置会控制所有界面中的 Enter 和 Shift+Enter。更改前,各界面保持现有行为。',
...linearIntegrationI18n['zh-CN'],
'settings.page.integrations.title': '集成',
'settings.page.integrations.description': '连接 GitHub 和 Linear,让 OpenChamber 可以处理你的 issue 和拉取请求。',
@@ -2288,8 +2288,6 @@ export const dict: Record<I18nKey, string> = {
'chat.chatInput.actions.linkGithubPr': '关联 GitHub PR',
'chat.chatInput.actions.modelAgentSettings': '模型与智能体设置',
'chat.chatInput.actions.sendMessageAria': '发送消息',
'chat.chatInput.actions.enterToSend': 'Enter 发送',
'chat.chatInput.actions.enterToSendHint': '更改后,此设置会控制所有界面中的 Enter 和 Shift+Enter。更改前,各界面保持现有行为。',
'chat.chatInput.actions.queueMessageAria': '将消息加入队列',
'chat.chatInput.actions.stopGeneratingAria': '停止生成',
'chat.chatInput.focusMode.toggleAria': '切换专注模式',
@@ -2246,6 +2246,8 @@ export const settingsDict = {
'settings.openchamber.visual.field.inputHistoryLimitDescription': '調低這個數字會立刻從歷史記錄移除較早的提示詞。',
'settings.openchamber.visual.field.inputHistoryLimitAria': '要記住的提示詞數量',
'settings.openchamber.visual.field.inputHistoryLimitUnit': '則',
'settings.openchamber.visual.field.enterToSend': 'Enter 傳送',
'settings.openchamber.visual.field.enterToSendHint': '變更後,此設定會控制所有介面中的 Enter 與 Shift+Enter。變更前,各介面會維持現有行為。',
...linearIntegrationI18n['zh-TW'],
'settings.page.integrations.title': '整合',
'settings.page.integrations.description': '連接 GitHub 和 Linear,讓 OpenChamber 可以處理你的 issue 和 pull request。',
@@ -2292,8 +2292,6 @@ export const dict: Record<I18nKey, string> = {
'chat.chatInput.actions.linkGithubPr': '關聯 GitHub PR',
'chat.chatInput.actions.modelAgentSettings': '模型與 Agent 設定',
'chat.chatInput.actions.sendMessageAria': '傳送訊息',
'chat.chatInput.actions.enterToSend': 'Enter 傳送',
'chat.chatInput.actions.enterToSendHint': '變更後,此設定會控制所有介面中的 Enter 與 Shift+Enter。變更前,各介面會維持現有行為。',
'chat.chatInput.actions.queueMessageAria': '將訊息加入佇列',
'chat.chatInput.actions.stopGeneratingAria': '停止生成',
'chat.chatInput.focusMode.toggleAria': '切換專注模式',
+1 -1
View File
@@ -1,6 +1,6 @@
export type InputHistoryScope = 'global' | 'session';
export const DEFAULT_INPUT_HISTORY_SCOPE: InputHistoryScope = 'global';
export const DEFAULT_INPUT_HISTORY_SCOPE: InputHistoryScope = 'session';
export const DEFAULT_INPUT_HISTORY_LIMIT = 40;
export const MIN_INPUT_HISTORY_LIMIT = 1;
export const MAX_INPUT_HISTORY_LIMIT = 100;
+2 -2
View File
@@ -1,9 +1,9 @@
import type { ContextPartMetadata } from '@/lib/messages/contextParts';
import { createOpencodeClient, OpencodeClient } from "@opencode-ai/sdk/v2";
import type { PermissionV2Request, PermissionV2Effect, PermissionV2Source } from "@opencode-ai/sdk/v2/client";
import { z } from "zod";
import type { FilesAPI } from "../api/types";
import { getDesktopHomeDirectory } from "../desktop";
import { z } from "zod";
import type {
Session,
Message,
@@ -343,7 +343,7 @@ const getDesktopFilesApi = (): FilesAPI | null => {
};
// /api/fs/home parsing boundary. Older servers answer without chatsRoot;
// Only a valid home response may use the legacy chats-root fallback.
// only a valid home response may use the legacy chats-root fallback.
const fsAbsolutePathSchema = z.string().trim().regex(/^(?:\/|[A-Za-z]:[\\/]|\\\\)/);
const fsHomeResponseSchema = z.object({ home: fsAbsolutePathSchema, chatsRoot: fsAbsolutePathSchema.optional() });
+11 -17
View File
@@ -21,9 +21,7 @@ import {
isInputHistoryLimit,
isInputHistoryScope,
} from '@/lib/inputHistoryScope';
import {
useInputHistoryStore,
} from '@/stores/useInputHistoryStore';
import { useInputHistoryStore } from '@/stores/useInputHistoryStore';
import { normalizeMobileKeyboardMode, setStoredMobileKeyboardMode } from '@/lib/mobileKeyboardMode';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { isCapacitorApp } from '@/lib/platform';
@@ -1081,14 +1079,11 @@ const applyDesktopUiPreferences = (settings: DesktopSettings) => {
store.setGitChangesViewMode(settings.gitChangesViewMode);
}
}
switch (settings.toolJsonViewMode) {
case 'summary':
case 'formatted':
case 'raw':
if (settings.toolJsonViewMode !== store.toolJsonViewMode) {
store.setToolJsonViewMode(settings.toolJsonViewMode);
}
break;
if (typeof settings.toolJsonViewMode === 'string'
&& (settings.toolJsonViewMode === 'summary' || settings.toolJsonViewMode === 'formatted' || settings.toolJsonViewMode === 'raw')) {
if (settings.toolJsonViewMode !== store.toolJsonViewMode) {
store.setToolJsonViewMode(settings.toolJsonViewMode);
}
}
if (typeof settings.directoryShowHidden === 'boolean') {
setDirectoryShowHidden(settings.directoryShowHidden, { persist: false });
@@ -1677,12 +1672,11 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
) {
result.gitChangesViewMode = candidate.gitChangesViewMode;
}
switch (candidate.toolJsonViewMode) {
case 'summary':
case 'formatted':
case 'raw':
result.toolJsonViewMode = candidate.toolJsonViewMode;
break;
if (
typeof candidate.toolJsonViewMode === 'string'
&& (candidate.toolJsonViewMode === 'summary' || candidate.toolJsonViewMode === 'formatted' || candidate.toolJsonViewMode === 'raw')
) {
result.toolJsonViewMode = candidate.toolJsonViewMode;
}
if (typeof candidate.directoryShowHidden === 'boolean') {
result.directoryShowHidden = candidate.directoryShowHidden;
+2 -1
View File
@@ -384,7 +384,8 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
{
id: 'chat.enter-to-send',
page: 'chat',
titleKey: 'chat.chatInput.actions.enterToSend',
titleKey: 'settings.openchamber.visual.field.enterToSend',
descriptionKey: 'settings.openchamber.visual.field.enterToSendHint',
keywords: ['enter', 'shift enter', 'send', 'newline'],
},
{
+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.
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.
+1 -1
View File
@@ -1,8 +1,8 @@
import { createInputHistoryIdentity, createInputHistorySubmission, useInputHistoryStore } from './useInputHistoryStore';
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
import { z } from 'zod';
import type { Event } from '@opencode-ai/sdk/v2';
import { createInputHistoryIdentity, createInputHistorySubmission, useInputHistoryStore } from './useInputHistoryStore';
import { createDeferredSafeJSONStorage } from './utils/safeStorage';
import type { AttachedFile } from './types/sessionTypes';
import { contextPartMetadataSchema, type ContextPartMetadata } from '@/lib/messages/contextParts';
@@ -117,9 +117,9 @@ describe('useInputHistoryStore', () => {
const identity = mod.createInputHistoryIdentity('runtime-a', '/repo', 'session-1');
expect(identity).not.toBeNull();
expect(DEFAULT_INPUT_HISTORY_SCOPE).toBe('global');
expect(DEFAULT_INPUT_HISTORY_SCOPE).toBe('session');
expect(DEFAULT_INPUT_HISTORY_LIMIT).toBe(40);
expect(mod.useInputHistoryStore.getState().scope).toBe('global');
expect(mod.useInputHistoryStore.getState().scope).toBe('session');
expect(mod.useInputHistoryStore.getState().entryLimit).toBe(DEFAULT_INPUT_HISTORY_LIMIT);
expect(mod.selectInputHistoryEntries(mod.useInputHistoryStore.getState(), identity!)).toEqual([]);
});
@@ -259,7 +259,7 @@ const readSnapshot = (): InputHistorySnapshot => {
});
};
const cloneEntriesWithLimit = (entries: readonly InputHistoryEntry[], limit: number): InputHistoryEntry[] => (
const trimEntriesToLimit = (entries: readonly InputHistoryEntry[], limit: number): InputHistoryEntry[] => (
entries.slice(Math.max(0, entries.length - limit))
);
@@ -273,7 +273,7 @@ const limitNamespaces = (
key,
{
touchedAt: namespace.touchedAt,
entries: cloneEntriesWithLimit(namespace.entries, entryLimit),
entries: trimEntriesToLimit(namespace.entries, entryLimit),
},
] as const)
.sort((left, right) => right[1].touchedAt - left[1].touchedAt)
@@ -381,8 +381,7 @@ export const createInputHistoryIdentity = (
const getAttachmentReference = (attachment: AttachedFile): string | null => {
if (attachment.source === 'vscode' && attachment.vscodeSource === 'file') {
const normalizedPath = normalizePath(attachment.vscodePath ?? null);
return normalizedPath;
return normalizePath(attachment.vscodePath ?? null);
}
const candidate = attachment.dataUrl.trim();
return candidate || null;
@@ -484,7 +483,7 @@ const appendToNamespace = (
}
return {
touchedAt,
entries: cloneEntriesWithLimit(entries, entryLimit),
entries: trimEntriesToLimit(entries, entryLimit),
};
};
+1
View File
@@ -2256,6 +2256,7 @@ export const useUIStore = create<UIStore>()(
const trimmed = identifier?.trim() ?? '';
set({ linearIssueFocus: trimmed || null });
},
setInputBarOffset: (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.
8. The ref-stable loader is disposed only after the current task when its provider unmounts. This lets React Strict Mode's development setup → cleanup → setup probe retain a usable loader for child effects, while real disposal still invalidates the preceding lifecycle's work.
9. Transcript arrays are chronological by `message.time.created`, with message ID used only as a deterministic equal-time tie-breaker. Message IDs are identity and reconciliation keys, not chronology: OpenCode's fixed-width sortable timestamp prefix rolls over, so a newer `msg_000...` can follow an older `msg_fff...`. Fetch, pagination, materialization, optimistic insertion, events, reconnect inspection, rendering, and revert/undo/redo must preserve this contract.
10. Transcript visibility and revert markers do not own prompt recall. The sync layer may hide reverted user messages from the visible transcript, but ArrowUp and ArrowDown recall come from the persisted input-history store, scoped by runtime and by runtime + normalized directory + session identity.
10. Session-scoped ArrowUp and ArrowDown recall merges the visible transcript's user prompts (`useUserMessageHistory`) with the persisted input-history bucket for runtime + normalized directory + session identity. Revert markers hide prompts from the transcript source only; the persisted bucket still recalls them. Global scope reads the persisted runtime bucket alone.
11. Part arrays preserve authoritative response/event order. Part IDs are identity keys and have the same rollover limitation; identity lookup/removal must not require a part array to be lexically ID-sorted.
Initial loads use smaller pages on constrained VS Code/mobile surfaces. Prefetch resolves only the initial renderable page; it does not eagerly download older history. The mounted chat timeline requests older pages when its viewport is underfilled or the user scrolls toward history, while mobile uses its explicit load-older action. Timeline caches, pending work, prepend snapshots, and stale checks use runtime + directory + session identity so equal session IDs in different worktrees cannot share lifecycle state. Older pages are fetched through the same loader and merged with optimistic records before publication. The same chronology contract applies in the VS Code webview because it consumes this shared loader and sync store; the extension bridge must transport OpenCode records without introducing its own ID-based ordering.
+3 -2
View File
@@ -69,9 +69,10 @@ export async function bootstrapGlobal(
sdk: OpencodeClient,
set: (patch: Partial<GlobalState>) => void,
) {
// Sync chat classification needs the root before session lists load.
await warmChatsRootDirectory()
const results = await Promise.allSettled([
// Sync chat classification needs the chats root before session lists load;
// it resolves alongside the other bootstrap calls, not ahead of them.
warmChatsRootDirectory(),
retry(() => sdk.path.get().then((x) => set({ path: unwrap(x, "path.get") }))),
retry(() => sdk.global.config.get().then((x) => set({ config: unwrap(x, "global.config.get") }))),
retry(() =>
@@ -36,6 +36,7 @@ export type SyncPerformanceCounters = {
questionChangeCallbacks: number
sessionMessageChangeCallbacks: number
sessionRenderableNotificationSkips: number
userMessageHistoryNotificationSkips: number
sessionMessageRecordNotificationSkips: number
materializationEnqueues: number
materializationEmptyAssistantEnqueues: number
@@ -78,6 +79,7 @@ const createCounters = (): SyncPerformanceCounters => ({
questionChangeCallbacks: 0,
sessionMessageChangeCallbacks: 0,
sessionRenderableNotificationSkips: 0,
userMessageHistoryNotificationSkips: 0,
sessionMessageRecordNotificationSkips: 0,
materializationEnqueues: 0,
materializationEmptyAssistantEnqueues: 0,
+7 -13
View File
@@ -99,14 +99,6 @@ import {
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 }
export function expandSlashCommandGoalObjective(content: string, commands: GoalCommand[]): string {
@@ -1734,8 +1726,9 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
createdDraftSession.directory ?? '',
createdDraftSession.sessionId,
)
const appendSubmissions = historyIdentity && options?.historySubmissions?.length
? () => appendInputHistorySubmissions(historyIdentity, options.historySubmissions ?? [])
const historySubmissions = options?.historySubmissions
const appendSubmissions = historyIdentity && historySubmissions?.length
? () => useInputHistoryStore.getState().appendSubmissions(historyIdentity, historySubmissions)
: undefined
notifyMessageSent(createdDraftSession.sessionId)
@@ -1860,13 +1853,14 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
const partsWithPinnedContext = prefixParts.length > 0
? [...prefixParts, ...(additionalParts || [])]
: additionalParts
const currentHistoryIdentity = createInputHistoryIdentity(
const historyIdentity = createInputHistoryIdentity(
capturedRuntimeKey,
currentSessionDirectory ?? '',
targetSessionId || '',
)
const appendSubmissions = currentHistoryIdentity && options?.historySubmissions?.length
? () => appendInputHistorySubmissions(currentHistoryIdentity, options.historySubmissions ?? [])
const historySubmissions = options?.historySubmissions
const appendSubmissions = historyIdentity && historySubmissions?.length
? () => useInputHistoryStore.getState().appendSubmissions(historyIdentity, historySubmissions)
: undefined
const messageRoute = await routeMessage({
+46
View File
@@ -86,6 +86,7 @@ import { formatMessage, useI18nStore } from "@/lib/i18n"
import { sessionEvents } from "@/lib/sessionEvents"
import { listGlobalSessionPages } from "@/stores/globalSessions"
import { areRequestArraysReferentiallyEqual, collectScopedBlockingRequests } from "./scoped-blocking-requests"
import { EMPTY_USER_MESSAGE_HISTORY_SNAPSHOT, buildUserMessageHistorySnapshot, type TranscriptPrompt, type UserMessageHistorySnapshot } from "./user-message-history"
import {
EMPTY_SESSION_MESSAGE_LOAD_STATE,
SessionMessageLoader,
@@ -3432,6 +3433,51 @@ export function useSessionRenderable(sessionID: string, directory?: string): boo
return React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
}
/**
* The user's prompts in the visible transcript of a session, oldest first.
* Session-scoped ArrowUp recall merges this with the persisted input history,
* so sessions that predate the persisted store still recall their prompts.
*/
export function useUserMessageHistory(sessionID: string, directory?: string): TranscriptPrompt[] {
const store = useDirectoryStore(directory)
const snapshotRef = useRef<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.
* 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`
- Settings read/write and OpenCode skills discovery via API for bridge consumers.
- `settings-changes.ts` validates Enter preferences independently before writes; invalid values are omitted without dropping unrelated changes.
- `bridge-system-runtime.ts`
- System/editor/provider/quota/notification/update-check message handlers.
@@ -5,7 +5,6 @@ import * as path from 'path';
import * as vscode from 'vscode';
import { BUILT_IN_SKILL_LOCATION, type DiscoveredSkill, type SkillScope, type SkillSource } from './opencodeConfig';
import type { BridgeContext } from './bridge';
import { enterSettingsSchema } from './settings-changes';
const SETTINGS_KEY = 'openchamber.settings';
const OPENCHAMBER_SHARED_SETTINGS_PATH = path.join(os.homedir(), '.config', 'openchamber', 'settings.json');
@@ -293,14 +292,6 @@ export const readSettings = (ctx?: BridgeContext): Record<string, unknown> => {
export const persistSettings = async (changes: Record<string, unknown>, ctx?: BridgeContext): Promise<Record<string, unknown>> => {
const current = readSettings(ctx);
const restChanges = stripDerived({ ...(changes || {}) });
const enterSettings = enterSettingsSchema.parse(restChanges);
for (const key of ['enterToSend', 'enterToSendConfigured'] as const) {
if (enterSettings[key] === undefined) {
delete restChanges[key];
} else {
restChanges[key] = enterSettings[key];
}
}
const keysToClear = new Set<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
// that the service may otherwise stop on logout. Returns null when the state
// cannot be determined (no username, loginctl unavailable, or odd output).
function getUserLingerEnabled(username) {
const user = typeof username === 'string' && username.length > 0 ? username : getCurrentUsername();
function getUserLingerEnabled(user) {
if (!user) {
return null;
}
+1 -1
View File
@@ -1320,7 +1320,7 @@ const resolveMemoryProjectId = createMemoryProjectResolver({
return sanitizeProjects(settings?.projects || []).map((project) => project.path);
},
resolvePrimaryWorktreeRoot,
managedProjectRoots: [path.join(OPENCHAMBER_USER_CONFIG_ROOT, 'chats'), OPENCHAMBER_CHATS_DIR],
managedProjectRoots: [...new Set([path.join(OPENCHAMBER_USER_CONFIG_ROOT, 'chats'), OPENCHAMBER_CHATS_DIR])],
});
/**
+6 -6
View File
@@ -526,9 +526,12 @@ export const registerFsRoutes = (app, dependencies) => {
openchamberUserConfigRoot,
managedChatsRoot,
} = dependencies;
const managedRoots = [openchamberUserConfigRoot, managedChatsRoot]
.filter((root) => typeof root === 'string' && root.trim().length > 0)
.map((root) => path.resolve(root));
// Chat worktrees may live outside every project workspace; both managed
// roots stay valid filesystem targets.
const chatsRoot = typeof managedChatsRoot === 'string' && managedChatsRoot.trim()
? path.resolve(managedChatsRoot.trim())
: path.join(openchamberUserConfigRoot, 'chats');
const managedRoots = [path.resolve(openchamberUserConfigRoot), chatsRoot];
const realpathCache = createRealpathCache({
realpath: fsPromises.realpath.bind(fsPromises),
});
@@ -707,9 +710,6 @@ export const registerFsRoutes = (app, dependencies) => {
if (!home || typeof home !== 'string' || home.length === 0) {
return res.status(500).json({ error: 'Failed to resolve home directory' });
}
const chatsRoot = managedChatsRoot && managedChatsRoot.trim()
? path.resolve(managedChatsRoot.trim())
: path.join(openchamberUserConfigRoot, 'chats');
return res.json({ home, chatsRoot });
} catch (error) {
console.error('Failed to resolve home directory:', error);
@@ -1,4 +1,4 @@
export const DEFAULT_INPUT_HISTORY_SCOPE = 'global';
export const DEFAULT_INPUT_HISTORY_SCOPE = 'session';
export const DEFAULT_INPUT_HISTORY_LIMIT = 40;
const MIN_INPUT_HISTORY_LIMIT = 1;
const MAX_INPUT_HISTORY_LIMIT = 100;
@@ -705,12 +705,11 @@ export const createSettingsHelpers = (dependencies) => {
result.gitChangesViewMode = mode;
}
}
switch (candidate.toolJsonViewMode) {
case 'summary':
case 'formatted':
case 'raw':
result.toolJsonViewMode = candidate.toolJsonViewMode;
break;
if (typeof candidate.toolJsonViewMode === 'string') {
const mode = candidate.toolJsonViewMode.trim();
if (mode === 'summary' || mode === 'formatted' || mode === 'raw') {
result.toolJsonViewMode = mode;
}
}
if (typeof candidate.directoryShowHidden === 'boolean') {
result.directoryShowHidden = candidate.directoryShowHidden;
@@ -121,7 +121,7 @@ describe('settings helpers', () => {
inputHistoryLimit: 40,
});
expect(helpers.formatSettingsResponse({})).toMatchObject({
inputHistoryScope: 'global',
inputHistoryScope: 'session',
inputHistoryLimit: DEFAULT_INPUT_HISTORY_LIMIT,
});
} finally {
@@ -265,14 +265,14 @@ describe('settings helpers', () => {
expect(helpers.sanitizeSettingsUpdate({ inputHistoryScope: 'workspace' })).toEqual({});
});
it('defaults inputHistoryScope to global in formatted settings responses', () => {
it('defaults inputHistoryScope to session in formatted settings responses', () => {
const helpers = createTestHelpers();
expect(helpers.formatSettingsResponse({ inputHistoryScope: 'session' })).toMatchObject({
inputHistoryScope: 'session',
expect(helpers.formatSettingsResponse({ inputHistoryScope: 'global' })).toMatchObject({
inputHistoryScope: 'global',
});
expect(helpers.formatSettingsResponse({})).toMatchObject({
inputHistoryScope: 'global',
inputHistoryScope: 'session',
});
});