perf: overhaul session loading, caching, and runtime isolation (#2360)

Improve OpenChamber responsiveness under large session workloads while fixing
cache, synchronization, and persistence correctness across runtimes, projects,
directories, and worktrees.

- prioritize selected and visible sessions during bootstrap and defer
  non-critical enrichment work
- reduce redundant message loading, event processing, store publication, and
  hidden sidebar work
- prevent stale session and message requests from overwriting newer
  authoritative state
- preserve existing data when authoritative fetches fail instead of treating
  failures as successful empty responses
- scope session materialization, messages, drafts, queues, todos, pins,
  permissions, folders, tabs, Git state, and pull request data by runtime and
  directory identity
- harden runtime switching, reconnect, cleanup, mutation reconciliation, and
  persisted-state ordering
- preserve live subagent Task linkage when metadata arrives after an older
  message request or while streaming parts are suspended
- coalesce overlapping tail refreshes without losing newer refresh demand
- improve cold-session loading by moving deferrable work out of the critical
  bootstrap path
- isolate URL authentication, mobile credentials, native secrets, and other
  runtime-owned state across endpoint changes
- bound long-lived caches and remove avoidable allocations from event and
  rendering hot paths
- limit virtualization to archive collections where it improves rendering
  without disrupting active sidebar layout
- stabilize session folders, pin ordering, expanded state, and persisted
  sidebar behavior
- open skill files through the same secure editor and outside-workspace grant
  flow used by file navigation, including worktree sessions
- expand regression coverage for stale completions, runtime collisions,
  reconnect behavior, persistence races, authoritative empty results, and
  subagent refresh ordering
- document the updated synchronization, cache ownership, performance, and
  runtime-isolation invariants
This commit is contained in:
Bohdan Triapitsyn
2026-07-21 20:52:20 +03:00
committed by GitHub
parent 485efc7117
commit 85400459e9
197 changed files with 10835 additions and 3400 deletions
@@ -0,0 +1,92 @@
import { beforeEach, describe, expect, test } from 'bun:test';
import {
clearChatDraft,
createChatDraftIdentity,
getChatDraftIdentityKey,
readChatDraft,
subscribeChatDraftDeletion,
writeChatDraft,
} from './chatDraftPersistence';
import { getDeferredSafeStorage } from '@/stores/utils/safeStorage';
const storage = getDeferredSafeStorage();
describe('chatDraftPersistence', () => {
beforeEach(() => {
storage.removeItem('openchamber.chatDrafts.v2');
});
test('isolates drafts by runtime, directory, and session', () => {
const first = createChatDraftIdentity('runtime-a', '/repo-a/', 'session-1')!;
const second = createChatDraftIdentity('runtime-b', '/repo-a', 'session-1')!;
const third = createChatDraftIdentity('runtime-a', '/repo-b', 'session-1')!;
writeChatDraft(first, 'first', ['file.ts']);
writeChatDraft(second, 'second', []);
writeChatDraft(third, 'third', []);
expect(readChatDraft(first)).toEqual({ text: 'first', confirmedMentions: new Set(['file.ts']) });
expect(readChatDraft(second).text).toBe('second');
expect(readChatDraft(third).text).toBe('third');
});
test('keeps new-session drafts separate from similarly named sessions', () => {
const newSession = createChatDraftIdentity('runtime-a', '/repo', null)!;
const namedSession = createChatDraftIdentity('runtime-a', '/repo', '__new__')!;
writeChatDraft(newSession, 'new session', []);
writeChatDraft(namedSession, 'named session', []);
expect(readChatDraft(newSession).text).toBe('new session');
expect(readChatDraft(namedSession).text).toBe('named session');
});
test('clears only the matching identity and notifies active composers', () => {
const deleted = createChatDraftIdentity('runtime-a', '/repo-a', 'session-1')!;
const retained = createChatDraftIdentity('runtime-a', '/repo-b', 'session-1')!;
const notifications: string[] = [];
const unsubscribe = subscribeChatDraftDeletion((identity) => notifications.push(identity.directory));
writeChatDraft(deleted, 'delete', []);
writeChatDraft(retained, 'retain', []);
clearChatDraft(deleted, true);
unsubscribe();
expect(readChatDraft(deleted).text).toBe('');
expect(readChatDraft(retained).text).toBe('retain');
expect(notifications).toEqual(['/repo-a']);
});
test('bounds persisted drafts by recency', () => {
for (let index = 0; index < 55; index += 1) {
const identity = createChatDraftIdentity('runtime-a', '/repo', `session-${index}`)!;
writeChatDraft(identity, `draft-${index}`, []);
}
const envelope = JSON.parse(storage.getItem('openchamber.chatDrafts.v2') ?? '{}') as { drafts?: object };
expect(Object.keys(envelope.drafts ?? {})).toHaveLength(50);
});
test('reuses a parsed envelope while the stored value is unchanged', () => {
const identity = createChatDraftIdentity('runtime-cache', '/repo', 'session-1')!;
const key = getChatDraftIdentityKey(identity);
storage.setItem('openchamber.chatDrafts.v2', JSON.stringify({
version: 2,
drafts: { [key]: { text: 'cached', confirmedMentions: [], touchedAt: 1 } },
}));
const originalParse = JSON.parse;
let parseCalls = 0;
JSON.parse = ((...args: Parameters<typeof JSON.parse>) => {
parseCalls += 1;
return originalParse(...args);
}) as typeof JSON.parse;
try {
expect(readChatDraft(identity).text).toBe('cached');
expect(readChatDraft(identity).text).toBe('cached');
expect(parseCalls).toBe(1);
} finally {
JSON.parse = originalParse;
}
});
});
+125
View File
@@ -0,0 +1,125 @@
import { normalizePath } from '@/lib/pathNormalization';
import { getDeferredSafeStorage } from '@/stores/utils/safeStorage';
import { countSyncPersistenceSerialization } from '@/sync/performance-diagnostics';
export type ChatDraftIdentity = {
runtimeKey: string;
directory: string;
sessionId: string | null;
};
export type ChatDraftSnapshot = {
text: string;
confirmedMentions: Set<string>;
};
type PersistedChatDraft = {
text: string;
confirmedMentions: string[];
touchedAt: number;
};
type PersistedChatDraftEnvelope = {
version: 2;
drafts: Record<string, PersistedChatDraft>;
};
const STORAGE_KEY = 'openchamber.chatDrafts.v2';
const MAX_DRAFTS = 50;
const storage = getDeferredSafeStorage();
const deletionListeners = new Set<(identity: ChatDraftIdentity) => void>();
let cachedRawEnvelope: string | null | undefined;
let cachedEnvelope: PersistedChatDraftEnvelope | undefined;
export const createChatDraftIdentity = (
runtimeKey: string,
directory: string | null | undefined,
sessionId: string | null,
): ChatDraftIdentity | null => {
const normalizedDirectory = normalizePath(directory);
if (!runtimeKey || !normalizedDirectory) return null;
return { runtimeKey, directory: normalizedDirectory, sessionId };
};
export const getChatDraftIdentityKey = (identity: ChatDraftIdentity): string =>
JSON.stringify([identity.runtimeKey, identity.directory, identity.sessionId]);
const readEnvelope = (): PersistedChatDraftEnvelope => {
const raw = storage.getItem(STORAGE_KEY);
if (raw === cachedRawEnvelope && cachedEnvelope) return cachedEnvelope;
try {
const parsed = JSON.parse(raw ?? '') as Partial<PersistedChatDraftEnvelope>;
if (parsed.version !== 2 || !parsed.drafts || typeof parsed.drafts !== 'object' || Array.isArray(parsed.drafts)) {
cachedRawEnvelope = raw;
cachedEnvelope = { version: 2, drafts: {} };
return cachedEnvelope;
}
const drafts: Record<string, PersistedChatDraft> = {};
for (const [key, value] of Object.entries(parsed.drafts)) {
if (!value || typeof value !== 'object') continue;
const draft = value as Partial<PersistedChatDraft>;
if (typeof draft.text !== 'string' || !Array.isArray(draft.confirmedMentions) || typeof draft.touchedAt !== 'number') continue;
drafts[key] = {
text: draft.text,
confirmedMentions: draft.confirmedMentions.filter((mention): mention is string => typeof mention === 'string'),
touchedAt: draft.touchedAt,
};
}
cachedRawEnvelope = raw;
cachedEnvelope = { version: 2, drafts };
return cachedEnvelope;
} catch {
storage.removeItem(STORAGE_KEY);
cachedRawEnvelope = null;
cachedEnvelope = { version: 2, drafts: {} };
return cachedEnvelope;
}
};
const writeEnvelope = (envelope: PersistedChatDraftEnvelope): void => {
const serialized = JSON.stringify(envelope);
cachedRawEnvelope = serialized;
cachedEnvelope = envelope;
countSyncPersistenceSerialization(serialized);
storage.setItem(STORAGE_KEY, serialized);
};
export const readChatDraft = (identity: ChatDraftIdentity | null): ChatDraftSnapshot => {
if (!identity) return { text: '', confirmedMentions: new Set() };
const persisted = readEnvelope().drafts[getChatDraftIdentityKey(identity)];
return persisted
? { text: persisted.text, confirmedMentions: new Set(persisted.confirmedMentions) }
: { text: '', confirmedMentions: new Set() };
};
export const writeChatDraft = (
identity: ChatDraftIdentity | null,
text: string,
confirmedMentions: Iterable<string>,
): void => {
if (!identity) return;
const envelope = readEnvelope();
const key = getChatDraftIdentityKey(identity);
const mentions = Array.from(new Set(confirmedMentions));
if (!text && mentions.length === 0) {
if (!(key in envelope.drafts)) return;
delete envelope.drafts[key];
} else {
envelope.drafts[key] = { text, confirmedMentions: mentions, touchedAt: Date.now() };
}
const retained = Object.entries(envelope.drafts)
.sort((left, right) => right[1].touchedAt - left[1].touchedAt)
.slice(0, MAX_DRAFTS);
writeEnvelope({ version: 2, drafts: Object.fromEntries(retained) });
};
export const clearChatDraft = (identity: ChatDraftIdentity, notify = false): void => {
writeChatDraft(identity, '', []);
if (notify) deletionListeners.forEach((listener) => listener(identity));
};
export const subscribeChatDraftDeletion = (listener: (identity: ChatDraftIdentity) => void): (() => void) => {
deletionListeners.add(listener);
return () => deletionListeners.delete(listener);
};
+19 -21
View File
@@ -1,5 +1,3 @@
import type {
GitStatus,
GitDiffResponse,
@@ -37,6 +35,7 @@ import type {
} from './api/types';
import { runtimeFetch } from './runtime-fetch';
import { getRuntimeUrlResolver } from './runtime-url';
import { getRuntimeKey } from './runtime-switch';
const API_BASE = '/api/git';
const GIT_STATUS_CACHE_TTL_MS = 1200;
@@ -48,24 +47,22 @@ const gitRepoCache = new Map<string, { value: boolean; expiresAt: number }>();
const gitRepoInFlight = new Map<string, Promise<boolean>>();
const normalizeDirectoryKey = (directory: string): string => directory.trim();
const getStatusCacheKey = (directory: string, mode?: 'light'): string =>
mode === 'light' ? `${normalizeDirectoryKey(directory)}::light` : normalizeDirectoryKey(directory);
const getDirectoryCacheKey = (runtimeKey: string, directory: string): string =>
JSON.stringify([runtimeKey, normalizeDirectoryKey(directory)]);
const getStatusCacheKey = (runtimeKey: string, directory: string, mode?: 'light'): string =>
JSON.stringify([runtimeKey, normalizeDirectoryKey(directory), mode ?? 'full']);
const getStatusCacheVersion = (directory: string): number =>
gitStatusCacheVersions.get(normalizeDirectoryKey(directory)) ?? 0;
const getStatusCacheVersion = (runtimeKey: string, directory: string): number =>
gitStatusCacheVersions.get(getDirectoryCacheKey(runtimeKey, directory)) ?? 0;
const invalidateGitStatusCache = (directory: string): void => {
const key = normalizeDirectoryKey(directory);
gitStatusCacheVersions.set(key, getStatusCacheVersion(directory) + 1);
for (const cacheKey of Array.from(gitStatusCache.keys())) {
if (cacheKey === key || cacheKey.startsWith(`${key}::`)) {
gitStatusCache.delete(cacheKey);
}
}
for (const cacheKey of Array.from(gitStatusInFlight.keys())) {
if (cacheKey === key || cacheKey.startsWith(`${key}::`)) {
gitStatusInFlight.delete(cacheKey);
}
const runtimeKey = getRuntimeKey();
const key = getDirectoryCacheKey(runtimeKey, directory);
gitStatusCacheVersions.set(key, getStatusCacheVersion(runtimeKey, directory) + 1);
for (const mode of [undefined, 'light'] as const) {
const statusKey = getStatusCacheKey(runtimeKey, directory, mode);
gitStatusCache.delete(statusKey);
gitStatusInFlight.delete(statusKey);
}
};
@@ -81,7 +78,7 @@ function buildUrl(
}
export async function checkIsGitRepository(directory: string): Promise<boolean> {
const key = normalizeDirectoryKey(directory);
const key = getDirectoryCacheKey(getRuntimeKey(), directory);
const now = Date.now();
const cached = gitRepoCache.get(key);
if (cached && cached.expiresAt > now) {
@@ -119,7 +116,8 @@ export async function checkIsGitRepository(directory: string): Promise<boolean>
export async function getGitStatus(directory: string, options?: { mode?: 'light' }): Promise<GitStatus> {
const mode = options?.mode;
const key = getStatusCacheKey(directory, mode);
const runtimeKey = getRuntimeKey();
const key = getStatusCacheKey(runtimeKey, directory, mode);
const now = Date.now();
const cached = gitStatusCache.get(key);
if (cached && cached.expiresAt > now) {
@@ -132,13 +130,13 @@ export async function getGitStatus(directory: string, options?: { mode?: 'light'
}
const task = (async () => {
const cacheVersion = getStatusCacheVersion(directory);
const cacheVersion = getStatusCacheVersion(runtimeKey, directory);
const response = await runtimeFetch(buildUrl(`${API_BASE}/status`, directory, mode ? { mode } : undefined));
if (!response.ok) {
throw new Error(`Failed to get git status: ${response.statusText}`);
}
const payload = await response.json() as GitStatus;
if (getStatusCacheVersion(directory) === cacheVersion) {
if (getStatusCacheVersion(runtimeKey, directory) === cacheVersion) {
gitStatusCache.set(key, {
value: payload,
expiresAt: Date.now() + GIT_STATUS_CACHE_TTL_MS,
+6
View File
@@ -1860,6 +1860,12 @@ export const dict = {
'chat.container.returnToParent.title': 'Return to parent session',
'chat.container.returnToParent.label': 'Parent',
'chat.container.readOnlySubagentPromptBanner': 'Subagent sessions cannot be prompted.',
'chat.container.sessionLoadError.title': 'Session could not be loaded',
'chat.container.sessionLoadError.description': 'Check the connection and try loading this session again.',
'chat.container.sessionLoadError.retry': 'Try again',
'sessions.sidebar.group.empty.loadingSessions': 'Loading sessions…',
'sessions.sidebar.group.empty.loadFailed': 'Could not refresh sessions.',
'sessions.sidebar.group.empty.retry': 'Try again',
'chat.unifiedControls.title': 'Controls',
'chat.unifiedControls.model.title': 'Model',
'chat.unifiedControls.model.noRecent': 'No recent models',
+6
View File
@@ -1838,6 +1838,12 @@ export const dict: Record<I18nKey, string> = {
"chat.container.returnToParent.title": "Volver a la sesión principal",
"chat.container.returnToParent.label": "Principal",
"chat.container.readOnlySubagentPromptBanner": "Las sesiones de subagentes no pueden recibir prompts.",
"chat.container.sessionLoadError.title": "No se pudo cargar la sesión",
"chat.container.sessionLoadError.description": "Comprueba la conexión e intenta cargar esta sesión de nuevo.",
"chat.container.sessionLoadError.retry": "Reintentar",
"sessions.sidebar.group.empty.loadingSessions": "Cargando sesiones…",
"sessions.sidebar.group.empty.loadFailed": "No se pudieron actualizar las sesiones.",
"sessions.sidebar.group.empty.retry": "Reintentar",
"chat.unifiedControls.title": "Controles",
"chat.unifiedControls.model.title": "Modelo",
"chat.unifiedControls.model.noRecent": "No hay modelos recientes",
+6
View File
@@ -1656,6 +1656,12 @@ export const dict = {
'chat.container.returnToParent.title': 'Retour à la session parents',
'chat.container.returnToParent.label': 'Mère',
'chat.container.readOnlySubagentPromptBanner': 'Les sessions de sous-agent ne peuvent pas être invitées.',
'chat.container.sessionLoadError.title': 'Impossible de charger la session',
'chat.container.sessionLoadError.description': 'Vérifiez la connexion et essayez de charger à nouveau cette session.',
'chat.container.sessionLoadError.retry': 'Réessayer',
'sessions.sidebar.group.empty.loadingSessions': 'Chargement des sessions…',
'sessions.sidebar.group.empty.loadFailed': 'Impossible dactualiser les sessions.',
'sessions.sidebar.group.empty.retry': 'Réessayer',
'chat.unifiedControls.title': 'Contrôles',
'chat.unifiedControls.model.title': 'Modèle',
'chat.unifiedControls.model.noRecent': 'Aucun modèle récent',
+6
View File
@@ -1856,6 +1856,12 @@ export const dict: Record<I18nKey, string> = {
'chat.container.returnToParent.title': '親セッションに戻る',
'chat.container.returnToParent.label': '親',
'chat.container.readOnlySubagentPromptBanner': 'サブエージェントセッションはプロンプトを受け付けません。',
'chat.container.sessionLoadError.title': 'セッションを読み込めませんでした',
'chat.container.sessionLoadError.description': '接続を確認して、このセッションをもう一度読み込んでください。',
'chat.container.sessionLoadError.retry': '再試行',
'sessions.sidebar.group.empty.loadingSessions': 'セッションを読み込んでいます…',
'sessions.sidebar.group.empty.loadFailed': 'セッションを更新できませんでした。',
'sessions.sidebar.group.empty.retry': '再試行',
'chat.unifiedControls.title': 'コントロール',
'chat.unifiedControls.model.title': 'モデル',
'chat.unifiedControls.model.noRecent': '最近のモデルはありません',
+6
View File
@@ -1862,6 +1862,12 @@ export const dict: Record<I18nKey, string> = {
'chat.container.returnToParent.title': '상위 세션으로 돌아가기',
'chat.container.returnToParent.label': '상위',
'chat.container.readOnlySubagentPromptBanner': '하위 에이전트 세션에는 프롬프트를 보낼 수 없습니다.',
'chat.container.sessionLoadError.title': '세션을 불러올 수 없습니다',
'chat.container.sessionLoadError.description': '연결을 확인한 후 이 세션을 다시 불러오세요.',
'chat.container.sessionLoadError.retry': '다시 시도',
'sessions.sidebar.group.empty.loadingSessions': '세션을 불러오는 중…',
'sessions.sidebar.group.empty.loadFailed': '세션을 새로 고칠 수 없습니다.',
'sessions.sidebar.group.empty.retry': '다시 시도',
'chat.unifiedControls.title': '컨트롤',
'chat.unifiedControls.model.title': '모델',
'chat.unifiedControls.model.noRecent': '최근 모델 없음',
+6
View File
@@ -749,6 +749,12 @@ export const dict: Record<I18nKey, string> = {
'chat.container.returnToParent.title': 'Powrót do sesji nadrzędnej',
'chat.container.returnToParent.label': 'Nadrzędna',
'chat.container.readOnlySubagentPromptBanner': 'Sesje podagentów nie mogą otrzymywać promptów.',
'chat.container.sessionLoadError.title': 'Nie udało się wczytać sesji',
'chat.container.sessionLoadError.description': 'Sprawdź połączenie i spróbuj ponownie wczytać tę sesję.',
'chat.container.sessionLoadError.retry': 'Spróbuj ponownie',
'sessions.sidebar.group.empty.loadingSessions': 'Wczytywanie sesji…',
'sessions.sidebar.group.empty.loadFailed': 'Nie udało się odświeżyć sesji.',
'sessions.sidebar.group.empty.retry': 'Spróbuj ponownie',
'chat.unifiedControls.title': 'Kontrolki',
'chat.unifiedControls.model.title': 'Model',
'chat.unifiedControls.model.noRecent': 'Brak ostatnich modeli',
@@ -1838,6 +1838,12 @@ export const dict: Record<I18nKey, string> = {
"chat.container.returnToParent.title": "Voltar para a sessão principal",
"chat.container.returnToParent.label": "Principal",
"chat.container.readOnlySubagentPromptBanner": "Sessões de subagente não podem receber prompts.",
"chat.container.sessionLoadError.title": "Não foi possível carregar a sessão",
"chat.container.sessionLoadError.description": "Verifique a conexão e tente carregar esta sessão novamente.",
"chat.container.sessionLoadError.retry": "Tentar novamente",
"sessions.sidebar.group.empty.loadingSessions": "Carregando sessões…",
"sessions.sidebar.group.empty.loadFailed": "Não foi possível atualizar as sessões.",
"sessions.sidebar.group.empty.retry": "Tentar novamente",
"chat.unifiedControls.title": "Controles",
"chat.unifiedControls.model.title": "Modelo",
"chat.unifiedControls.model.noRecent": "Não há modelos recentes",
+6
View File
@@ -1838,6 +1838,12 @@ export const dict: Record<I18nKey, string> = {
"chat.container.returnToParent.title": "Повернутися до батьківської сесії",
"chat.container.returnToParent.label": "Батьківська",
"chat.container.readOnlySubagentPromptBanner": "Сесії субагентів не можна запитувати.",
"chat.container.sessionLoadError.title": "Не вдалося завантажити сесію",
"chat.container.sessionLoadError.description": "Перевірте з’єднання та спробуйте завантажити цю сесію ще раз.",
"chat.container.sessionLoadError.retry": "Спробувати знову",
"sessions.sidebar.group.empty.loadingSessions": "Завантаження сесій…",
"sessions.sidebar.group.empty.loadFailed": "Не вдалося оновити сесії.",
"sessions.sidebar.group.empty.retry": "Спробувати знову",
"chat.unifiedControls.title": "Елементи керування",
"chat.unifiedControls.model.title": "Модель",
"chat.unifiedControls.model.noRecent": "Немає останніх моделей",
@@ -1826,6 +1826,12 @@ export const dict: Record<I18nKey, string> = {
'chat.container.returnToParent.title': '返回父会话',
'chat.container.returnToParent.label': '父级',
'chat.container.readOnlySubagentPromptBanner': '无法向子智能体会话发送提示。',
'chat.container.sessionLoadError.title': '无法加载会话',
'chat.container.sessionLoadError.description': '请检查连接,然后重新加载此会话。',
'chat.container.sessionLoadError.retry': '重试',
'sessions.sidebar.group.empty.loadingSessions': '正在加载会话…',
'sessions.sidebar.group.empty.loadFailed': '无法刷新会话。',
'sessions.sidebar.group.empty.retry': '重试',
'chat.unifiedControls.title': '控制',
'chat.unifiedControls.model.title': '模型',
'chat.unifiedControls.model.noRecent': '没有最近使用的模型',
@@ -1830,6 +1830,12 @@ export const dict: Record<I18nKey, string> = {
'chat.container.returnToParent.title': '返回父會話',
'chat.container.returnToParent.label': '父級',
'chat.container.readOnlySubagentPromptBanner': '無法向子 Agent 會話傳送提示。',
'chat.container.sessionLoadError.title': '無法載入工作階段',
'chat.container.sessionLoadError.description': '請檢查連線,然後重新載入此工作階段。',
'chat.container.sessionLoadError.retry': '再試一次',
'sessions.sidebar.group.empty.loadingSessions': '正在載入工作階段…',
'sessions.sidebar.group.empty.loadFailed': '無法重新整理工作階段。',
'sessions.sidebar.group.empty.retry': '再試一次',
'chat.unifiedControls.title': '控制',
'chat.unifiedControls.model.title': '模型',
'chat.unifiedControls.model.noRecent': '沒有最近使用的模型',
+14
View File
@@ -1,5 +1,6 @@
import { useUIStore } from '@/stores/useUIStore';
import { updateDesktopSettings } from '@/lib/persistence';
import { getRuntimeKey, subscribeRuntimeEndpointWillChange } from '@/lib/runtime-switch';
type ModelRef = { providerID: string; modelID: string };
type ModelPrefsPayload = {
@@ -75,9 +76,13 @@ export const startModelPrefsAutoSave = () => {
let timer: number | null = null;
let lastSent: ModelPrefsPayload | null = null;
let didSkipInitial = false;
let scheduledRuntimeKey: string | null = null;
const flush = () => {
timer = null;
const runtimeKey = scheduledRuntimeKey;
scheduledRuntimeKey = null;
if (!runtimeKey || runtimeKey !== getRuntimeKey()) return;
const payload = snapshotModelPrefs();
if (lastSent && modelPrefsEqual(lastSent, payload)) {
@@ -97,9 +102,17 @@ export const startModelPrefsAutoSave = () => {
if (timer !== null) {
window.clearTimeout(timer);
}
scheduledRuntimeKey = getRuntimeKey();
timer = window.setTimeout(flush, 1200);
};
const unsubscribeRuntime = subscribeRuntimeEndpointWillChange(() => {
if (timer !== null) window.clearTimeout(timer);
timer = null;
scheduledRuntimeKey = null;
lastSent = null;
});
const unsubscribe = useUIStore.subscribe((state, prevState) => {
const next = {
favoriteModels: state.favoriteModels,
@@ -125,6 +138,7 @@ export const startModelPrefsAutoSave = () => {
return () => {
unsubscribe();
unsubscribeRuntime();
if (timer !== null) {
window.clearTimeout(timer);
}
@@ -0,0 +1,18 @@
import { expect, mock, test } from "bun:test"
let runtimeKey = "runtime-a"
mock.module("@/lib/runtime-switch", () => ({ getRuntimeKey: () => runtimeKey }))
const { assertProviderCircuitClosed, recordProviderError, recordProviderSuccess } = await import("./provider-tracker")
test("isolates provider circuit state by runtime", () => {
for (let attempt = 0; attempt < 3; attempt += 1) recordProviderError("provider", 503)
expect(() => assertProviderCircuitClosed("provider")).toThrow()
runtimeKey = "runtime-b"
assertProviderCircuitClosed("provider")
runtimeKey = "runtime-a"
recordProviderSuccess("provider")
assertProviderCircuitClosed("provider")
})
@@ -8,6 +8,8 @@
* Inspired by HiveMind (arXiv:2604.17111) OS-inspired scheduling primitives.
*/
import { getRuntimeKey } from '@/lib/runtime-switch'
const DEFAULT_CIRCUIT_BREAK_THRESHOLD = 3
const DEFAULT_CIRCUIT_COOLDOWN_MS = 30_000
const DEFAULT_RETRY_BASE_DELAY_MS = 1000
@@ -15,6 +17,7 @@ const DEFAULT_RETRY_MAX_DELAY_MS = 32_000
const DEFAULT_RETRY_MAX_ATTEMPTS = 3
const PROVIDER_EVICTION_TTL_MS = 60 * 60 * 1000
const PROVIDER_EVICTION_INTERVAL_MS = 10 * 60 * 1000
const PROVIDER_MAX_ENTRIES = 200
const RETRYABLE_STATUS_CODES = new Set([429, 502, 503, 504])
@@ -27,15 +30,14 @@ type ProviderState = {
}
const providers = new Map<string, ProviderState>()
const providerKey = (providerID: string): string => JSON.stringify([getRuntimeKey(), providerID])
function evictStaleProviders(): void {
const now = Date.now()
for (const [providerID, state] of providers) {
if (
state.consecutiveErrors === 0 &&
now - state.lastErrorAt > PROVIDER_EVICTION_TTL_MS
) {
providers.delete(providerID)
for (const [key, state] of providers) {
const lastActivityAt = Math.max(state.lastErrorAt, state.circuitOpenAt)
if (now - lastActivityAt > PROVIDER_EVICTION_TTL_MS) {
providers.delete(key)
}
}
}
@@ -46,7 +48,8 @@ if (typeof setInterval !== 'undefined') {
}
function getOrCreateProvider(providerID: string): ProviderState {
let state = providers.get(providerID)
const key = providerKey(providerID)
let state = providers.get(key)
if (!state) {
state = {
consecutiveErrors: 0,
@@ -55,17 +58,19 @@ function getOrCreateProvider(providerID: string): ProviderState {
circuitOpenAt: 0,
circuitCooldownMs: DEFAULT_CIRCUIT_COOLDOWN_MS,
}
providers.set(providerID, state)
providers.set(key, state)
while (providers.size > PROVIDER_MAX_ENTRIES) {
const oldest = providers.keys().next().value
if (!oldest) break
providers.delete(oldest)
}
}
return state
}
export function recordProviderSuccess(providerID: string): void {
if (!providerID) return
const state = providers.get(providerID)
if (!state) return
state.consecutiveErrors = 0
state.lastErrorAt = 0
providers.delete(providerKey(providerID))
}
export function recordProviderError(providerID: string, status?: number): void {
@@ -91,7 +96,7 @@ function isCircuitBreakerStatus(status?: number): boolean {
}
function isCircuitOpen(providerID: string): boolean {
const state = providers.get(providerID)
const state = providers.get(providerKey(providerID))
if (!state?.circuitOpen) return false
const elapsed = Date.now() - state.circuitOpenAt
+84
View File
@@ -5,8 +5,10 @@ import { registerRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { startModelPrefsAutoSave } from '@/lib/modelPrefsAutoSave';
import { startAppearanceAutoSave } from '@/lib/appearanceAutoSave';
import { useUIStore } from '@/stores/useUIStore';
import { useMessageQueueStore } from '@/stores/messageQueueStore';
import {
applyPersistedHomeDirectoryToWindow,
getRuntimeSettingsMirrorStorageKey,
getSettingsSaveState,
invalidateSettingsCache,
subscribeToSettingsSaveState,
@@ -315,6 +317,88 @@ describe('updateDesktopSettings', () => {
expect(useUIStore.getState().terminalShell).toBe('bash');
});
test('isolates local settings mirrors and removes values omitted by the next runtime', async () => {
getWindow();
localStorage.clear();
switchRuntimeEndpoint({ apiBaseUrl: 'https://mirror-a.example', runtimeKey: 'mirror-a' });
registerSettingsApi(async () => ({}), async () => ({
settings: {
themeId: 'theme-a',
directoryShowHidden: true,
sttModel: 'model-a',
draftStartersCraftGoalAdded: true,
},
source: 'web',
}));
await syncDesktopSettings();
switchRuntimeEndpoint({ apiBaseUrl: 'https://mirror-b.example', runtimeKey: 'mirror-b' });
registerSettingsApi(async () => ({}), async () => ({
settings: { draftStartersCraftGoalAdded: true },
source: 'web',
}));
await syncDesktopSettings();
expect(localStorage.getItem('selectedThemeId')).toBeNull();
expect(localStorage.getItem('directoryTreeShowHidden')).toBeNull();
expect(localStorage.getItem('sttModel')).toBeNull();
expect(JSON.parse(localStorage.getItem(getRuntimeSettingsMirrorStorageKey('mirror-a')) ?? '{}')).toEqual({
themeId: 'theme-a',
directoryShowHidden: true,
sttModel: 'model-a',
});
expect(JSON.parse(localStorage.getItem(getRuntimeSettingsMirrorStorageKey('mirror-b')) ?? '{}')).toEqual({});
});
test('resets in-memory preferences omitted by an authoritative runtime snapshot', async () => {
getWindow();
switchRuntimeEndpoint({ apiBaseUrl: 'https://preferences-a.example', runtimeKey: 'preferences-a' });
registerSettingsApi(async () => ({}), async () => ({
settings: {
showReasoningTraces: false,
terminalShell: 'fish',
favoriteModels: [{ providerID: 'anthropic', modelID: 'claude-sonnet-4' }],
followUpBehavior: 'steer',
draftStarters: [{ type: 'command', name: 'runtime-a' }],
draftStartersCraftGoalAdded: true,
},
source: 'web',
}));
await syncDesktopSettings();
expect(useUIStore.getState().showReasoningTraces).toBe(false);
expect(useUIStore.getState().terminalShell).toBe('fish');
expect(useUIStore.getState().favoriteModels).toHaveLength(1);
expect(useUIStore.getState().globalDraftStarters).toEqual([{ type: 'command', name: 'runtime-a' }]);
expect(useMessageQueueStore.getState().followUpBehavior).toBe('steer');
switchRuntimeEndpoint({ apiBaseUrl: 'https://preferences-b.example', runtimeKey: 'preferences-b' });
registerSettingsApi(async () => ({}), async () => ({
settings: { draftStartersCraftGoalAdded: true },
source: 'web',
}));
await syncDesktopSettings();
expect(useUIStore.getState().showReasoningTraces).toBe(true);
expect(useUIStore.getState().terminalShell).toBe('auto');
expect(useUIStore.getState().favoriteModels).toEqual([]);
expect(useUIStore.getState().globalDraftStarters).toBeNull();
expect(useMessageQueueStore.getState().followUpBehavior).toBe('queue');
});
test('treats settings save responses as partial patches', async () => {
getWindow();
localStorage.setItem('selectedThemeId', 'existing-theme');
useUIStore.getState().setTerminalShell('fish');
registerSettingsSave(async () => ({ showReasoningTraces: false }));
await updateDesktopSettings({ showReasoningTraces: false });
expect(useUIStore.getState().showReasoningTraces).toBe(false);
expect(useUIStore.getState().terminalShell).toBe('fish');
expect(localStorage.getItem('selectedThemeId')).toBe('existing-theme');
});
test('applies model selector settings from server settings', async () => {
getWindow();
const settings = {
+190 -42
View File
@@ -2,7 +2,13 @@ import type { DesktopSettings } from '@/lib/desktop';
import { createProjectIdFromPath } from '@/lib/projectId';
import { useUIStore } from '@/stores/useUIStore';
import { isMonoFontOption, isUiFontOption } from '@/lib/fontOptions';
import { isFollowUpBehavior, normalizeFollowUpBehavior, useMessageQueueStore, type FollowUpBehavior } from '@/stores/messageQueueStore';
import {
DEFAULT_FOLLOW_UP_BEHAVIOR,
isFollowUpBehavior,
normalizeFollowUpBehavior,
useMessageQueueStore,
type FollowUpBehavior,
} from '@/stores/messageQueueStore';
import { setDirectoryShowHidden } from '@/lib/directoryShowHidden';
import { setFilesViewShowGitignored } from '@/lib/filesViewShowGitignored';
import { loadAppearancePreferences, applyAppearancePreferences } from '@/lib/appearancePersistence';
@@ -12,6 +18,8 @@ import { normalizeMobileKeyboardMode, setStoredMobileKeyboardMode } from '@/lib/
import { runtimeFetch } from '@/lib/runtime-fetch';
import { isTerminalShell } from '@/lib/terminalShell';
import { getRuntimeKey, subscribeRuntimeEndpointChanged, subscribeRuntimeEndpointWillChange } from '@/lib/runtime-switch';
import { DEFAULT_DARK_THEME_ID, DEFAULT_LIGHT_THEME_ID } from '@/lib/theme/themes';
import { DEFAULT_OPEN_IN_APP_ID } from '@/lib/openInApps';
export const applyPersistedHomeDirectoryToWindow = (homeDirectory: string): void => {
if (typeof window === 'undefined') {
@@ -28,32 +36,80 @@ export const applyPersistedHomeDirectoryToWindow = (homeDirectory: string): void
}
};
const SETTINGS_MIRROR_INDEX_KEY = 'openchamber.settingsMirror.v2.index';
const SETTINGS_MIRROR_KEY_PREFIX = 'openchamber.settingsMirror.v2:';
const MAX_SETTINGS_MIRROR_RUNTIMES = 5;
export const getRuntimeSettingsMirrorStorageKey = (runtimeKey: string): string =>
`${SETTINGS_MIRROR_KEY_PREFIX}${encodeURIComponent(runtimeKey)}`;
const setOrRemoveLocalStorage = (key: string, value: string | null): void => {
if (value === null) {
localStorage.removeItem(key);
} else {
localStorage.setItem(key, value);
}
};
const persistRuntimeSettingsMirror = (settings: DesktopSettings, runtimeKey: string): void => {
const mirror = {
themeId: settings.themeId,
themeVariant: settings.themeVariant,
lightThemeId: settings.lightThemeId,
darkThemeId: settings.darkThemeId,
useSystemTheme: settings.useSystemTheme,
lastDirectory: settings.lastDirectory,
homeDirectory: settings.homeDirectory,
projects: settings.projects,
activeProjectId: settings.activeProjectId,
pinnedDirectories: settings.pinnedDirectories,
gitmojiEnabled: settings.gitmojiEnabled,
directoryShowHidden: settings.directoryShowHidden,
filesViewShowGitignored: settings.filesViewShowGitignored,
openInAppId: settings.openInAppId,
pwaAppName: settings.pwaAppName,
mobileKeyboardMode: settings.mobileKeyboardMode,
openCodeUpdateToastDismissedVersion: settings.openCodeUpdateToastDismissedVersion,
dictationEnabled: settings.dictationEnabled,
sttProvider: settings.sttProvider,
sttServerUrl: settings.sttServerUrl,
sttModel: settings.sttModel,
sttLocalModel: settings.sttLocalModel,
sttLanguage: settings.sttLanguage,
};
localStorage.setItem(getRuntimeSettingsMirrorStorageKey(runtimeKey), JSON.stringify(mirror));
let previous: string[] = [];
try {
const parsed = JSON.parse(localStorage.getItem(SETTINGS_MIRROR_INDEX_KEY) ?? '[]') as unknown;
if (Array.isArray(parsed)) previous = parsed.filter((entry): entry is string => typeof entry === 'string');
} catch {
previous = [];
}
const runtimes = [runtimeKey, ...previous.filter((entry) => entry !== runtimeKey)].slice(0, MAX_SETTINGS_MIRROR_RUNTIMES);
for (const staleRuntime of previous) {
if (!runtimes.includes(staleRuntime)) localStorage.removeItem(getRuntimeSettingsMirrorStorageKey(staleRuntime));
}
localStorage.setItem(SETTINGS_MIRROR_INDEX_KEY, JSON.stringify(runtimes));
};
const persistToLocalStorage = (settings: DesktopSettings) => {
if (typeof window === 'undefined') {
return;
}
if (settings.themeId) {
localStorage.setItem('selectedThemeId', settings.themeId);
}
if (settings.themeVariant) {
localStorage.setItem('selectedThemeVariant', settings.themeVariant);
}
if (settings.lightThemeId) {
localStorage.setItem('lightThemeId', settings.lightThemeId);
}
if (settings.darkThemeId) {
localStorage.setItem('darkThemeId', settings.darkThemeId);
}
if (typeof settings.useSystemTheme === 'boolean') {
localStorage.setItem('useSystemTheme', String(settings.useSystemTheme));
}
if (settings.lastDirectory) {
localStorage.setItem('lastDirectory', settings.lastDirectory);
}
persistRuntimeSettingsMirror(settings, getRuntimeKey());
setOrRemoveLocalStorage('selectedThemeId', settings.themeId || null);
setOrRemoveLocalStorage('selectedThemeVariant', settings.themeVariant || null);
setOrRemoveLocalStorage('lightThemeId', settings.lightThemeId || null);
setOrRemoveLocalStorage('darkThemeId', settings.darkThemeId || null);
setOrRemoveLocalStorage('useSystemTheme', typeof settings.useSystemTheme === 'boolean' ? String(settings.useSystemTheme) : null);
setOrRemoveLocalStorage('lastDirectory', settings.lastDirectory || null);
if (settings.homeDirectory) {
localStorage.setItem('homeDirectory', settings.homeDirectory);
applyPersistedHomeDirectoryToWindow(settings.homeDirectory);
} else {
localStorage.removeItem('homeDirectory');
}
if (Array.isArray(settings.projects) && settings.projects.length > 0) {
localStorage.setItem('projects', JSON.stringify(settings.projects));
@@ -81,6 +137,8 @@ const persistToLocalStorage = (settings: DesktopSettings) => {
} else {
localStorage.removeItem('oc.sessions.projectCollapse');
}
} else {
localStorage.removeItem('oc.sessions.projectCollapse');
}
if (typeof settings.gitmojiEnabled === 'boolean') {
localStorage.setItem('gitmojiEnabled', String(settings.gitmojiEnabled));
@@ -89,13 +147,15 @@ const persistToLocalStorage = (settings: DesktopSettings) => {
}
if (typeof settings.directoryShowHidden === 'boolean') {
localStorage.setItem('directoryTreeShowHidden', settings.directoryShowHidden ? 'true' : 'false');
} else {
localStorage.removeItem('directoryTreeShowHidden');
}
if (typeof settings.filesViewShowGitignored === 'boolean') {
localStorage.setItem('filesViewShowGitignored', settings.filesViewShowGitignored ? 'true' : 'false');
} else {
localStorage.removeItem('filesViewShowGitignored');
}
if (typeof settings.openInAppId === 'string' && settings.openInAppId.length > 0) {
localStorage.setItem('openInAppId', settings.openInAppId);
}
setOrRemoveLocalStorage('openInAppId', typeof settings.openInAppId === 'string' && settings.openInAppId.length > 0 ? settings.openInAppId : null);
if (typeof settings.pwaAppName === 'string') {
const normalized = settings.pwaAppName.trim().replace(/\s+/g, ' ').slice(0, 64);
if (normalized.length > 0) {
@@ -103,10 +163,10 @@ const persistToLocalStorage = (settings: DesktopSettings) => {
} else {
localStorage.removeItem('openchamber.pwaName');
}
} else {
localStorage.removeItem('openchamber.pwaName');
}
if (typeof settings.mobileKeyboardMode === 'string') {
setStoredMobileKeyboardMode(settings.mobileKeyboardMode);
}
setStoredMobileKeyboardMode(settings.mobileKeyboardMode);
if (typeof settings.openCodeUpdateToastDismissedVersion === 'string') {
const version = settings.openCodeUpdateToastDismissedVersion.trim();
if (version) {
@@ -114,25 +174,23 @@ const persistToLocalStorage = (settings: DesktopSettings) => {
} else {
localStorage.removeItem('opencode-update-toast-dismissed-version');
}
} else {
localStorage.removeItem('opencode-update-toast-dismissed-version');
}
if (typeof settings.dictationEnabled === 'boolean') {
localStorage.setItem('dictationEnabled', String(settings.dictationEnabled));
} else {
localStorage.removeItem('dictationEnabled');
}
if (settings.sttProvider === 'local' || settings.sttProvider === 'openai-compatible') {
localStorage.setItem('sttProvider', settings.sttProvider);
} else {
localStorage.removeItem('sttProvider');
}
if (typeof settings.sttServerUrl === 'string') {
localStorage.setItem('sttServerUrl', settings.sttServerUrl);
}
if (typeof settings.sttModel === 'string') {
localStorage.setItem('sttModel', settings.sttModel);
}
if (typeof settings.sttLocalModel === 'string') {
localStorage.setItem('sttLocalModel', settings.sttLocalModel);
}
if (typeof settings.sttLanguage === 'string') {
localStorage.setItem('sttLanguage', settings.sttLanguage);
}
setOrRemoveLocalStorage('sttServerUrl', typeof settings.sttServerUrl === 'string' ? settings.sttServerUrl : null);
setOrRemoveLocalStorage('sttModel', typeof settings.sttModel === 'string' ? settings.sttModel : null);
setOrRemoveLocalStorage('sttLocalModel', typeof settings.sttLocalModel === 'string' ? settings.sttLocalModel : null);
setOrRemoveLocalStorage('sttLanguage', typeof settings.sttLanguage === 'string' ? settings.sttLanguage : null);
};
const dispatchSettingsSynced = (settings: DesktopSettings): void => {
@@ -457,6 +515,91 @@ const getPersistApi = (): PersistApi | undefined => {
const getRuntimeSettingsAPI = () => getRegisteredRuntimeAPIs()?.settings ?? null;
const materializeAuthoritativeUiSettings = (settings: DesktopSettings): DesktopSettings => {
const defaults = useUIStore.getInitialState();
return {
useSystemTheme: true,
lightThemeId: DEFAULT_LIGHT_THEME_ID,
darkThemeId: DEFAULT_DARK_THEME_ID,
openInAppId: DEFAULT_OPEN_IN_APP_ID,
showReasoningTraces: defaults.showReasoningTraces,
sessionRecapEnabled: defaults.sessionRecapEnabled,
sessionSuggestionEnabled: defaults.sessionSuggestionEnabled,
sessionGoalEnabled: defaults.sessionGoalEnabled,
sessionGoalDefaultBudgetEnabled: defaults.sessionGoalDefaultBudgetEnabled,
sessionGoalDefaultBudget: defaults.sessionGoalDefaultBudget,
collapsibleThinkingBlocks: defaults.collapsibleThinkingBlocks,
autoDeleteEnabled: defaults.autoDeleteEnabled,
autoDeleteAfterDays: defaults.autoDeleteAfterDays,
sessionRetentionAction: defaults.sessionRetentionAction,
followUpBehavior: DEFAULT_FOLLOW_UP_BEHAVIOR,
showDeletionDialog: defaults.showDeletionDialog,
nativeNotificationsEnabled: defaults.nativeNotificationsEnabled,
notificationMode: defaults.notificationMode,
notifyOnSubtasks: defaults.notifyOnSubtasks,
notifyOnCompletion: defaults.notifyOnCompletion,
notifyOnError: defaults.notifyOnError,
notifyOnQuestion: defaults.notifyOnQuestion,
notificationTemplates: defaults.notificationTemplates,
summarizeLastMessage: defaults.summarizeLastMessage,
summaryThreshold: defaults.summaryThreshold,
summaryLength: defaults.summaryLength,
maxLastMessageLength: defaults.maxLastMessageLength,
inputSpellcheckEnabled: defaults.inputSpellcheckEnabled,
showOpenCodeUpdateNotifications: defaults.showOpenCodeUpdateNotifications,
showToolFileIcons: defaults.showToolFileIcons,
codeBlockLineWrap: defaults.codeBlockLineWrap,
showTurnChangedFiles: defaults.showTurnChangedFiles,
showExpandedBashTools: defaults.showExpandedBashTools,
showExpandedEditTools: defaults.showExpandedEditTools,
timeFormatPreference: defaults.timeFormatPreference,
weekStartPreference: defaults.weekStartPreference,
desktopWindowControlsPosition: defaults.desktopWindowControlsPosition,
chatRenderMode: defaults.chatRenderMode,
activityRenderMode: defaults.activityRenderMode,
mermaidRenderingMode: defaults.mermaidRenderingMode,
userMessageRenderingMode: defaults.userMessageRenderingMode,
collapsibleUserMessages: defaults.collapsibleUserMessages,
messageStreamTransport: 'auto',
stickyUserHeader: defaults.stickyUserHeader,
promptNavigatorEnabled: defaults.promptNavigatorEnabled,
expandedEditorToolbar: defaults.expandedEditorToolbar,
wideChatLayoutEnabled: defaults.wideChatLayoutEnabled,
showSplitAssistantMessageActions: defaults.showSplitAssistantMessageActions,
reportUsage: defaults.reportUsage,
fontSize: defaults.fontSize,
terminalFontSize: defaults.terminalFontSize,
terminalShell: defaults.terminalShell,
terminalLoginShells: defaults.terminalLoginShells,
editorFontSize: defaults.editorFontSize,
uiFont: defaults.uiFont,
monoFont: defaults.monoFont,
padding: defaults.padding,
cornerRadius: defaults.cornerRadius,
inputBarOffset: defaults.inputBarOffset,
shortcutOverrides: defaults.shortcutOverrides,
mobileKeyboardMode: 'resize-content',
favoriteModels: defaults.favoriteModels,
hiddenModels: defaults.hiddenModels,
collapsedModelProviders: defaults.collapsedModelProviders,
recentModels: defaults.recentModels,
recentAgents: defaults.recentAgents,
recentEfforts: defaults.recentEfforts,
diffLayoutPreference: defaults.diffLayoutPreference,
gitChangesViewMode: defaults.gitChangesViewMode,
directoryShowHidden: true,
filesViewShowGitignored: false,
dictationEnabled: true,
sttProvider: 'local',
sttServerUrl: 'http://localhost:8001/v1',
sttModel: 'deepdml/faster-whisper-large-v3-turbo-ct2',
sttLocalModel: 'parakeet-tdt-0.6b-v2-int8',
sttLanguage: '',
...settings,
};
};
const applyDesktopUiPreferences = (settings: DesktopSettings) => {
const store = useUIStore.getState();
const configStore = typeof window !== 'undefined'
@@ -1215,6 +1358,9 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
if (typeof candidate.promptNavigatorEnabled === 'boolean') {
result.promptNavigatorEnabled = candidate.promptNavigatorEnabled;
}
if (typeof candidate.expandedEditorToolbar === 'boolean') {
result.expandedEditorToolbar = candidate.expandedEditorToolbar;
}
if (typeof candidate.wideChatLayoutEnabled === 'boolean') {
result.wideChatLayoutEnabled = candidate.wideChatLayoutEnabled;
}
@@ -1524,6 +1670,7 @@ export const syncDesktopSettings = async (): Promise<void> => {
const applySettings = async (settings: DesktopSettings) => {
if (!isSettingsRuntimeContextCurrent(context)) return;
const shouldPersistCraftGoalMigration = settings.draftStartersCraftGoalAdded !== true;
const authoritativeSettings = materializeAuthoritativeUiSettings(settings);
try {
persistToLocalStorage(settings);
} catch (error) {
@@ -1531,20 +1678,23 @@ export const syncDesktopSettings = async (): Promise<void> => {
}
await waitForHydration();
if (!isSettingsRuntimeContextCurrent(context)) return;
if (settings.draftStarters === undefined) {
useUIStore.setState({ globalDraftStarters: null });
}
try {
applyDesktopUiPreferences(settings);
applyDesktopUiPreferences(authoritativeSettings);
} catch (error) {
console.warn('applyDesktopUiPreferences failed:', error);
}
if (shouldPersistCraftGoalMigration) {
await updateDesktopSettings({
...(settings.draftStarters ? { draftStarters: settings.draftStarters } : {}),
...(authoritativeSettings.draftStarters ? { draftStarters: authoritativeSettings.draftStarters } : {}),
draftStartersCraftGoalAdded: true,
});
if (!isSettingsRuntimeContextCurrent(context)) return;
}
dispatchSettingsSynced(settings);
dispatchSettingsSynced(authoritativeSettings);
};
try {
@@ -1579,7 +1729,6 @@ async function _flushSettingsUpdate(): Promise<void> {
const updated = await runtimeSettings.save(changes);
if (!isSettingsRuntimeContextCurrent(context)) return;
if (updated) {
persistToLocalStorage(updated);
applyDesktopUiPreferences(updated);
dispatchSettingsSynced(updated);
_settingsCache = null;
@@ -1613,7 +1762,6 @@ async function _flushSettingsUpdate(): Promise<void> {
const updated = (await response.json().catch(() => null)) as DesktopSettings | null;
if (!isSettingsRuntimeContextCurrent(context)) return;
if (updated) {
persistToLocalStorage(updated);
applyDesktopUiPreferences(updated);
dispatchSettingsSynced(updated);
dispatchSettingsSaveState('saved');
+51
View File
@@ -5,6 +5,8 @@ import {
clearRuntimeUrlAuthToken,
getRuntimeBearerTokenSync,
refreshRuntimeUrlAuthToken,
refreshLocalRuntimeUrlAuthToken,
getLocalRuntimeUrlAuthTokenSync,
setRuntimeAuthCredentialProvider,
setRuntimeBearerToken,
setRuntimeExtraHeaders,
@@ -145,4 +147,53 @@ describe('runtime auth headers', () => {
clearRuntimeAuthCredentialProvider();
}
});
test('never reuses a local URL token for another origin', async () => {
const previousFetch = globalThis.fetch;
let fetchCount = 0;
try {
clearRuntimeUrlAuthToken();
globalThis.fetch = (async (input: RequestInfo | URL) => {
fetchCount += 1;
const origin = new URL(String(input)).origin;
return Response.json({ token: `${origin}-token`, expiresAt: Date.now() + 60_000 });
}) as typeof fetch;
const a = await refreshLocalRuntimeUrlAuthToken('http://127.0.0.1:3001');
const b = await refreshLocalRuntimeUrlAuthToken('http://127.0.0.1:3002');
expect(a).toBe('http://127.0.0.1:3001-token');
expect(b).toBe('http://127.0.0.1:3002-token');
expect(getLocalRuntimeUrlAuthTokenSync('http://127.0.0.1:3001')).toBe('');
expect(getLocalRuntimeUrlAuthTokenSync('http://127.0.0.1:3002')).toBe(b);
expect(fetchCount).toBe(2);
} finally {
globalThis.fetch = previousFetch;
clearRuntimeUrlAuthToken();
}
});
test('rejects a local mint that completes after switching origins', async () => {
const previousFetch = globalThis.fetch;
let resolveA!: (response: Response) => void;
try {
clearRuntimeUrlAuthToken();
globalThis.fetch = ((input: RequestInfo | URL) => {
const origin = new URL(String(input)).origin;
if (origin.endsWith(':3001')) return new Promise<Response>((resolve) => { resolveA = resolve; });
return Promise.resolve(Response.json({ token: 'token-b', expiresAt: Date.now() + 60_000 }));
}) as typeof fetch;
const requestA = refreshLocalRuntimeUrlAuthToken('http://127.0.0.1:3001');
const tokenB = await refreshLocalRuntimeUrlAuthToken('http://127.0.0.1:3002');
resolveA(Response.json({ token: 'token-a', expiresAt: Date.now() + 60_000 }));
expect(tokenB).toBe('token-b');
await expect(requestA).rejects.toThrow('stale');
expect(getLocalRuntimeUrlAuthTokenSync('http://127.0.0.1:3002')).toBe('token-b');
} finally {
globalThis.fetch = previousFetch;
clearRuntimeUrlAuthToken();
}
});
});
+63 -14
View File
@@ -14,7 +14,10 @@ let runtimeUrlAuthTokenExpiresAt = 0;
let runtimeUrlAuthRefreshPromise: Promise<string> | null = null;
let localRuntimeUrlAuthToken = '';
let localRuntimeUrlAuthTokenExpiresAt = 0;
let localRuntimeUrlAuthOrigin = '';
let localRuntimeUrlAuthRefreshPromise: Promise<string> | null = null;
let localRuntimeUrlAuthRefreshOrigin = '';
let localRuntimeUrlAuthGeneration = 0;
let runtimeAuthGeneration = 0;
const URL_AUTH_REFRESH_SKEW_MS = 10_000;
@@ -66,11 +69,27 @@ const buildAuthUrl = (apiBaseUrl: string | null | undefined, path: string): stri
}
};
const normalizeOrigin = (value: string): string => {
try {
return new URL(value).origin;
} catch {
return '';
}
};
const clearLocalRuntimeUrlAuthToken = (): void => {
localRuntimeUrlAuthToken = '';
localRuntimeUrlAuthTokenExpiresAt = 0;
localRuntimeUrlAuthOrigin = '';
localRuntimeUrlAuthRefreshPromise = null;
localRuntimeUrlAuthRefreshOrigin = '';
localRuntimeUrlAuthGeneration += 1;
};
export const clearRuntimeUrlAuthToken = (): void => {
runtimeUrlAuthToken = '';
runtimeUrlAuthTokenExpiresAt = 0;
localRuntimeUrlAuthToken = '';
localRuntimeUrlAuthTokenExpiresAt = 0;
clearLocalRuntimeUrlAuthToken();
};
const resetRuntimeAuthGeneration = (): void => {
@@ -135,15 +154,20 @@ export const setRuntimeUrlAuthToken = (token: string | null | undefined, expires
}
};
export const setLocalRuntimeUrlAuthToken = (token: string | null | undefined, expiresAt: number | null | undefined): void => {
export const setLocalRuntimeUrlAuthToken = (
token: string | null | undefined,
expiresAt: number | null | undefined,
localOrigin?: string | null,
): void => {
const normalized = normalizeBearerToken(token);
if (!normalized || typeof expiresAt !== 'number' || !Number.isFinite(expiresAt)) {
localRuntimeUrlAuthToken = '';
localRuntimeUrlAuthTokenExpiresAt = 0;
const origin = typeof localOrigin === 'string' ? normalizeOrigin(localOrigin) : '';
if (!normalized || typeof expiresAt !== 'number' || !Number.isFinite(expiresAt) || !origin) {
clearLocalRuntimeUrlAuthToken();
return;
}
localRuntimeUrlAuthToken = normalized;
localRuntimeUrlAuthTokenExpiresAt = expiresAt;
localRuntimeUrlAuthOrigin = origin;
};
const readValidRuntimeUrlAuthTokenSync = (): string => {
@@ -154,10 +178,13 @@ const readValidRuntimeUrlAuthTokenSync = (): string => {
return runtimeUrlAuthToken;
};
const readValidLocalRuntimeUrlAuthTokenSync = (): string => {
const readValidLocalRuntimeUrlAuthTokenSync = (localOrigin: string): string => {
const origin = normalizeOrigin(localOrigin);
if (!origin || localRuntimeUrlAuthOrigin !== origin) return '';
if (!localRuntimeUrlAuthToken || localRuntimeUrlAuthTokenExpiresAt <= Date.now() + URL_AUTH_REFRESH_SKEW_MS) {
localRuntimeUrlAuthToken = '';
localRuntimeUrlAuthTokenExpiresAt = 0;
localRuntimeUrlAuthOrigin = '';
return '';
}
return localRuntimeUrlAuthToken;
@@ -172,7 +199,7 @@ export const getRuntimeUrlAuthTokenSync = (): string => {
};
export const getLocalRuntimeUrlAuthTokenSync = (localOrigin?: string | null): string => {
const token = readValidLocalRuntimeUrlAuthTokenSync();
const token = localOrigin ? readValidLocalRuntimeUrlAuthTokenSync(localOrigin) : '';
if (!token && localOrigin && typeof window !== 'undefined') {
void refreshLocalRuntimeUrlAuthToken(localOrigin).catch(() => {});
}
@@ -242,15 +269,23 @@ const mintRuntimeUrlAuthToken = (apiBaseUrl?: string | null): Promise<string> =>
};
const mintLocalRuntimeUrlAuthToken = (localOrigin: string): Promise<string> => {
if (localRuntimeUrlAuthRefreshPromise) return localRuntimeUrlAuthRefreshPromise;
const origin = normalizeOrigin(localOrigin);
if (!origin) return Promise.reject(new Error('Local runtime URL auth origin was invalid'));
if (localRuntimeUrlAuthRefreshPromise && localRuntimeUrlAuthRefreshOrigin === origin) {
return localRuntimeUrlAuthRefreshPromise;
}
const generation = localRuntimeUrlAuthGeneration;
const refreshPromise = (async () => {
const response = await fetch(buildAuthUrl(localOrigin, '/auth/url-token'), {
const response = await fetch(buildAuthUrl(origin, '/auth/url-token'), {
method: 'POST',
credentials: 'include',
});
if (!response.ok) {
localRuntimeUrlAuthToken = '';
localRuntimeUrlAuthTokenExpiresAt = 0;
if (generation === localRuntimeUrlAuthGeneration && origin === localRuntimeUrlAuthRefreshOrigin) {
localRuntimeUrlAuthToken = '';
localRuntimeUrlAuthTokenExpiresAt = 0;
localRuntimeUrlAuthOrigin = '';
}
throw new Error(`Failed to mint local runtime URL auth token (${response.status})`);
}
const payload = await response.json().catch(() => null) as { token?: unknown; expiresAt?: unknown } | null;
@@ -259,16 +294,22 @@ const mintLocalRuntimeUrlAuthToken = (localOrigin: string): Promise<string> => {
if (!token || !Number.isFinite(expiresAt)) {
throw new Error('Local runtime URL auth token response was invalid');
}
if (generation !== localRuntimeUrlAuthGeneration || origin !== localRuntimeUrlAuthRefreshOrigin) {
throw new Error('Local runtime URL auth token response is stale');
}
localRuntimeUrlAuthToken = token;
localRuntimeUrlAuthTokenExpiresAt = expiresAt;
localRuntimeUrlAuthOrigin = origin;
return token;
})();
const trackedPromise = refreshPromise.finally(() => {
if (localRuntimeUrlAuthRefreshPromise === trackedPromise) {
localRuntimeUrlAuthRefreshPromise = null;
localRuntimeUrlAuthRefreshOrigin = '';
}
});
localRuntimeUrlAuthRefreshPromise = trackedPromise;
localRuntimeUrlAuthRefreshOrigin = origin;
return localRuntimeUrlAuthRefreshPromise;
};
@@ -281,9 +322,17 @@ export const refreshRuntimeUrlAuthToken = async (apiBaseUrl?: string | null): Pr
};
export const refreshLocalRuntimeUrlAuthToken = async (localOrigin: string): Promise<string> => {
const existing = readValidLocalRuntimeUrlAuthTokenSync();
const origin = normalizeOrigin(localOrigin);
if (!origin) throw new Error('Local runtime URL auth origin was invalid');
const existing = readValidLocalRuntimeUrlAuthTokenSync(origin);
if (existing) return existing;
return mintLocalRuntimeUrlAuthToken(localOrigin);
if (
(localRuntimeUrlAuthOrigin && localRuntimeUrlAuthOrigin !== origin)
|| (localRuntimeUrlAuthRefreshOrigin && localRuntimeUrlAuthRefreshOrigin !== origin)
) {
clearLocalRuntimeUrlAuthToken();
}
return mintLocalRuntimeUrlAuthToken(origin);
};
// ── Proactive URL auth token refresh ──────────────────────────────────────
+1 -1
View File
@@ -103,7 +103,7 @@ describe('createRuntimeUrlResolver', () => {
test('adds local URL auth token to desktop realtime proxy URL', () => {
setRuntimeExtraHeaders({ 'CF-Access-Client-Id': 'client-id' });
setRuntimeUrlAuthToken('remote-url-token', Date.now() + 60_000);
setLocalRuntimeUrlAuthToken('local-url-token', Date.now() + 60_000);
setLocalRuntimeUrlAuthToken('local-url-token', Date.now() + 60_000, 'http://127.0.0.1:57123');
try {
withWindow({
location: { origin: 'openchamber-ui://app', href: 'openchamber-ui://app/index.html' },
@@ -0,0 +1,33 @@
import { describe, expect, test } from 'bun:test';
import { themes } from '@/lib/theme/themes';
import { getResolvedShikiTheme, getThemeContentSignature } from './appThemeRegistry';
describe('appThemeRegistry', () => {
test('invalidates resolved themes when content changes under the same ID', () => {
const original = themes[0];
const changed = {
...original,
colors: {
...original.colors,
syntax: {
...original.colors.syntax,
base: {
...original.colors.syntax.base,
keyword: original.colors.syntax.base.string,
},
},
},
};
expect(getThemeContentSignature(changed)).not.toBe(getThemeContentSignature(original));
expect(getResolvedShikiTheme(changed)).not.toBe(getResolvedShikiTheme(original));
});
test('reuses resolved themes for identical content', () => {
const original = themes[0];
const clone = JSON.parse(JSON.stringify(original));
expect(getResolvedShikiTheme(clone)).toBe(getResolvedShikiTheme(original));
});
});
+17 -5
View File
@@ -35,8 +35,11 @@ function withStableStringId<T extends object>(value: T, id: string): T {
return value;
}
const MAX_RESOLVED_THEME_CACHE_ENTRIES = 40;
const resolvedThemeCache = new Map<string, ShikiThemeRegistrationResolvedLike>();
const registeredPierreThemes = new Set<string>();
const registeredPierreThemeSignatures = new Map<string, string>();
export const getThemeContentSignature = (theme: Theme): string => JSON.stringify(theme);
const toResolvedTheme = (raw: VSCodeTextMateTheme, id: string): ShikiThemeRegistrationResolvedLike => {
const bgRaw = raw.colors?.['editor.background'];
@@ -68,24 +71,33 @@ const buildTextMateTheme = (theme: Theme): VSCodeTextMateTheme => {
};
export const getResolvedShikiTheme = (theme: Theme): ShikiThemeRegistrationResolvedLike => {
const cached = resolvedThemeCache.get(theme.metadata.id);
const signature = getThemeContentSignature(theme);
const cached = resolvedThemeCache.get(signature);
if (cached) {
resolvedThemeCache.delete(signature);
resolvedThemeCache.set(signature, cached);
return cached;
}
const raw = buildTextMateTheme(theme);
const resolved = toResolvedTheme(raw, theme.metadata.id);
resolvedThemeCache.set(theme.metadata.id, resolved);
resolvedThemeCache.set(signature, resolved);
while (resolvedThemeCache.size > MAX_RESOLVED_THEME_CACHE_ENTRIES) {
const oldest = resolvedThemeCache.keys().next().value;
if (oldest === undefined) break;
resolvedThemeCache.delete(oldest);
}
return resolved;
};
export const ensurePierreThemeRegistered = (theme: Theme): void => {
const id = theme.metadata.id;
if (registeredPierreThemes.has(id)) {
const signature = getThemeContentSignature(theme);
if (registeredPierreThemeSignatures.get(id) === signature) {
return;
}
const resolved = getResolvedShikiTheme(theme);
registerCustomTheme(id, async () => resolved);
registeredPierreThemes.add(id);
registeredPierreThemeSignatures.set(id, signature);
};