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:
committed by
GitHub
parent
485efc7117
commit
85400459e9
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 d’actualiser 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',
|
||||
|
||||
@@ -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': '最近のモデルはありません',
|
||||
|
||||
@@ -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': '최근 모델 없음',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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': '沒有最近使用的模型',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 ──────────────────────────────────────
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user