fix: cross-client settings sync and sidebar session pagination
- Paginate /experimental/session via time.updated fallback so sidebar loads past the 200-item first page when server omits x-next-cursor. - VSCode extension persists all settings in shared ~/.config/openchamber/settings.json (not only opencodeBinary), matching Desktop and Web. Canonical read from disk with globalState fallback for eager migration of pre-existing users. - Desktop settings sync: guard the contextBridge read-only __OPENCHAMBER_HOME__ assignment that was silently throwing a TypeError and skipping the whole applySettings chain — leaving server-synced values (autoDeleteAfterDays, autoDeleteEnabled, sessionRetentionAction) stuck at local defaults. Also wait for Zustand persist hydration before applying server settings to avoid overwrite races.
This commit is contained in:
@@ -32,7 +32,15 @@ const persistToLocalStorage = (settings: DesktopSettings) => {
|
|||||||
}
|
}
|
||||||
if (settings.homeDirectory) {
|
if (settings.homeDirectory) {
|
||||||
localStorage.setItem('homeDirectory', settings.homeDirectory);
|
localStorage.setItem('homeDirectory', settings.homeDirectory);
|
||||||
window.__OPENCHAMBER_HOME__ = settings.homeDirectory;
|
// Electron's preload exposes __OPENCHAMBER_HOME__ as a read-only
|
||||||
|
// contextBridge property; assignment throws TypeError there. In VSCode
|
||||||
|
// webview and plain web runtime the property is writable. Swallow the
|
||||||
|
// error in Electron — preload already seeded the value correctly.
|
||||||
|
try {
|
||||||
|
window.__OPENCHAMBER_HOME__ = settings.homeDirectory;
|
||||||
|
} catch {
|
||||||
|
/* read-only contextBridge property — leave preload-seeded value */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (Array.isArray(settings.projects) && settings.projects.length > 0) {
|
if (Array.isArray(settings.projects) && settings.projects.length > 0) {
|
||||||
localStorage.setItem('projects', JSON.stringify(settings.projects));
|
localStorage.setItem('projects', JSON.stringify(settings.projects));
|
||||||
@@ -969,20 +977,47 @@ export const syncDesktopSettings = async (): Promise<void> => {
|
|||||||
|
|
||||||
const persistApi = getPersistApi();
|
const persistApi = getPersistApi();
|
||||||
|
|
||||||
const applySettings = (settings: DesktopSettings) => {
|
// Wait for Zustand persist hydration before applying server settings.
|
||||||
persistToLocalStorage(settings);
|
// Otherwise `set()`-calls race with hydration: we set X, then hydration
|
||||||
const apply = () => applyDesktopUiPreferences(settings);
|
// reads localStorage and overwrites back to the persisted value.
|
||||||
|
const waitForHydration = (): Promise<void> => {
|
||||||
|
if (!persistApi?.hasHydrated || persistApi.hasHydrated()) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
if (!persistApi.onFinishHydration) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
let settled = false;
|
||||||
|
const finish = () => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
const unsubscribe = persistApi.onFinishHydration!(() => {
|
||||||
|
unsubscribe?.();
|
||||||
|
finish();
|
||||||
|
});
|
||||||
|
// Guard: hydration may have flipped to true between the hasHydrated
|
||||||
|
// check and the onFinishHydration subscription — resolve immediately.
|
||||||
|
if (persistApi.hasHydrated?.()) finish();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
if (persistApi?.hasHydrated?.()) {
|
// Each step is wrapped in try/catch so a failure in one side-effect (e.g.
|
||||||
apply();
|
// a TypeError from writing to a contextBridge-protected global) doesn't
|
||||||
} else {
|
// prevent server settings from reaching the Zustand store.
|
||||||
apply();
|
const applySettings = async (settings: DesktopSettings) => {
|
||||||
if (persistApi?.onFinishHydration) {
|
try {
|
||||||
const unsubscribe = persistApi.onFinishHydration(() => {
|
persistToLocalStorage(settings);
|
||||||
unsubscribe?.();
|
} catch (error) {
|
||||||
apply();
|
console.warn('persistToLocalStorage failed:', error);
|
||||||
});
|
}
|
||||||
}
|
await waitForHydration();
|
||||||
|
try {
|
||||||
|
applyDesktopUiPreferences(settings);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('applyDesktopUiPreferences failed:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
@@ -993,7 +1028,7 @@ export const syncDesktopSettings = async (): Promise<void> => {
|
|||||||
try {
|
try {
|
||||||
const webSettings = await fetchWebSettings();
|
const webSettings = await fetchWebSettings();
|
||||||
if (webSettings) {
|
if (webSettings) {
|
||||||
applySettings(webSettings);
|
await applySettings(webSettings);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Failed to synchronise settings:', error);
|
console.warn('Failed to synchronise settings:', error);
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ export async function listGlobalSessionPages(
|
|||||||
},
|
},
|
||||||
): Promise<GlobalSessionRecord[]> {
|
): Promise<GlobalSessionRecord[]> {
|
||||||
const all: GlobalSessionRecord[] = [];
|
const all: GlobalSessionRecord[] = [];
|
||||||
|
const seenIds = new Set<string>();
|
||||||
let cursor: number | undefined;
|
let cursor: number | undefined;
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
@@ -76,23 +77,41 @@ export async function listGlobalSessionPages(
|
|||||||
() => apiClient.experimental.session.list({
|
() => apiClient.experimental.session.list({
|
||||||
archived: options.archived,
|
archived: options.archived,
|
||||||
limit: options.pageSize,
|
limit: options.pageSize,
|
||||||
...(cursor ? { cursor } : {}),
|
...(cursor !== undefined ? { cursor } : {}),
|
||||||
}),
|
}),
|
||||||
{ attempts: 3, delay: 500, retryIf: () => true },
|
{ attempts: 3, delay: 500, retryIf: () => true },
|
||||||
);
|
);
|
||||||
|
|
||||||
const payload = Array.isArray(response.data) ? (response.data as GlobalSessionRecord[]) : [];
|
const payload = Array.isArray(response.data) ? (response.data as GlobalSessionRecord[]) : [];
|
||||||
if (payload.length === 0) {
|
if (payload.length === 0) break;
|
||||||
break;
|
|
||||||
|
let appended = 0;
|
||||||
|
for (const session of payload) {
|
||||||
|
if (!session?.id || seenIds.has(session.id)) continue;
|
||||||
|
seenIds.add(session.id);
|
||||||
|
all.push(session);
|
||||||
|
appended += 1;
|
||||||
|
}
|
||||||
|
if (appended > 0) {
|
||||||
|
options.onPage?.(payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
all.push(...payload);
|
// Stop on partial page — nothing more to fetch.
|
||||||
options.onPage?.(payload);
|
if (payload.length < options.pageSize) break;
|
||||||
|
|
||||||
|
// Prefer server header; fall back to last session's `time.updated`
|
||||||
|
// (cursor semantics on server = "updated strictly before this timestamp").
|
||||||
|
const headerCursor = toNumber(readResponseHeader(response, "x-next-cursor"));
|
||||||
|
const lastUpdated = payload[payload.length - 1]?.time?.updated;
|
||||||
|
const nextCursor = headerCursor
|
||||||
|
?? (typeof lastUpdated === "number" && Number.isFinite(lastUpdated) ? lastUpdated : undefined);
|
||||||
|
|
||||||
|
if (nextCursor === undefined) break;
|
||||||
|
// Loop guard: cursor must move backwards in time.
|
||||||
|
if (cursor !== undefined && nextCursor >= cursor) break;
|
||||||
|
// Every id in this page already seen — stop to avoid spinning.
|
||||||
|
if (appended === 0) break;
|
||||||
|
|
||||||
const nextCursor = toNumber(readResponseHeader(response, "x-next-cursor"));
|
|
||||||
if (!nextCursor) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
cursor = nextCursor;
|
cursor = nextCursor;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -165,12 +165,19 @@ const writeSharedSettingsToDisk = async (changes: Record<string, unknown>): Prom
|
|||||||
await fs.promises.mkdir(path.dirname(OPENCHAMBER_SHARED_SETTINGS_PATH), { recursive: true });
|
await fs.promises.mkdir(path.dirname(OPENCHAMBER_SHARED_SETTINGS_PATH), { recursive: true });
|
||||||
const current = readSharedSettingsFromDisk();
|
const current = readSharedSettingsFromDisk();
|
||||||
const next: Record<string, unknown> = { ...current, ...changes };
|
const next: Record<string, unknown> = { ...current, ...changes };
|
||||||
await fs.promises.writeFile(OPENCHAMBER_SHARED_SETTINGS_PATH, JSON.stringify(next, null, 2), 'utf8');
|
// Atomic write: tmp file + rename. Readers never see a partial/truncated
|
||||||
|
// JSON that would fail to parse and silently get coerced to {}.
|
||||||
|
const tmp = `${OPENCHAMBER_SHARED_SETTINGS_PATH}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
await fs.promises.writeFile(tmp, JSON.stringify(next, null, 2), 'utf8');
|
||||||
|
await fs.promises.rename(tmp, OPENCHAMBER_SHARED_SETTINGS_PATH);
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Fields derived from runtime context — never persisted, always recomputed.
|
||||||
|
const DERIVED_FIELDS = new Set(['themeVariant', 'lastDirectory']);
|
||||||
|
|
||||||
const sanitizeMagicPromptOverrides = (input: unknown): Record<string, string> => {
|
const sanitizeMagicPromptOverrides = (input: unknown): Record<string, string> => {
|
||||||
if (!input || typeof input !== 'object' || Array.isArray(input)) {
|
if (!input || typeof input !== 'object' || Array.isArray(input)) {
|
||||||
return {};
|
return {};
|
||||||
@@ -207,12 +214,50 @@ const writeMagicPromptFile = async (state: { version: number; overrides: Record<
|
|||||||
await fs.promises.writeFile(OPENCHAMBER_MAGIC_PROMPTS_PATH, JSON.stringify(state, null, 2), 'utf8');
|
await fs.promises.writeFile(OPENCHAMBER_MAGIC_PROMPTS_PATH, JSON.stringify(state, null, 2), 'utf8');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const stripDerived = (source: Record<string, unknown>): Record<string, unknown> => {
|
||||||
|
const next: Record<string, unknown> = { ...source };
|
||||||
|
for (const key of DERIVED_FIELDS) {
|
||||||
|
delete next[key];
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
};
|
||||||
|
|
||||||
|
let eagerMigrationAttempted = false;
|
||||||
|
|
||||||
|
// Read the merged persisted settings: shared file is canonical (synced with
|
||||||
|
// Desktop and Web clients), globalState is kept as a migration fallback for
|
||||||
|
// users upgrading from the pre-shared-sync era. Disk wins on conflicts.
|
||||||
|
//
|
||||||
|
// On first read per process, if globalState has keys that are missing on
|
||||||
|
// disk, copy them to disk so other clients see them immediately — without
|
||||||
|
// waiting for the user to save again.
|
||||||
|
const readPersistedSettings = (ctx?: BridgeContext): Record<string, unknown> => {
|
||||||
|
const fromGlobalState = stripDerived(
|
||||||
|
ctx?.context?.globalState.get<Record<string, unknown>>(SETTINGS_KEY) || {},
|
||||||
|
);
|
||||||
|
const fromDisk = stripDerived(readSharedSettingsFromDisk());
|
||||||
|
|
||||||
|
if (!eagerMigrationAttempted) {
|
||||||
|
eagerMigrationAttempted = true;
|
||||||
|
const missingFromDisk: Record<string, unknown> = {};
|
||||||
|
for (const [key, value] of Object.entries(fromGlobalState)) {
|
||||||
|
if (!(key in fromDisk)) {
|
||||||
|
missingFromDisk[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Object.keys(missingFromDisk).length > 0) {
|
||||||
|
// Fire-and-forget; readers already have an in-memory merged view.
|
||||||
|
void writeSharedSettingsToDisk(missingFromDisk);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...fromGlobalState, ...fromDisk };
|
||||||
|
};
|
||||||
|
|
||||||
export const readSettings = (ctx?: BridgeContext): Record<string, unknown> => {
|
export const readSettings = (ctx?: BridgeContext): Record<string, unknown> => {
|
||||||
const stored = ctx?.context?.globalState.get<Record<string, unknown>>(SETTINGS_KEY) || {};
|
const persisted = readPersistedSettings(ctx);
|
||||||
const restStored = { ...stored };
|
const persistedOpencodeBinary =
|
||||||
delete (restStored as Record<string, unknown>).lastDirectory;
|
typeof persisted.opencodeBinary === 'string' ? String(persisted.opencodeBinary).trim() : '';
|
||||||
const shared = readSharedSettingsFromDisk();
|
|
||||||
const sharedOpencodeBinary = typeof shared.opencodeBinary === 'string' ? shared.opencodeBinary.trim() : '';
|
|
||||||
const workspaceFolder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || '';
|
const workspaceFolder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || '';
|
||||||
const themeVariant =
|
const themeVariant =
|
||||||
vscode.window.activeColorTheme.kind === vscode.ColorThemeKind.Light ||
|
vscode.window.activeColorTheme.kind === vscode.ColorThemeKind.Light ||
|
||||||
@@ -221,20 +266,16 @@ export const readSettings = (ctx?: BridgeContext): Record<string, unknown> => {
|
|||||||
: 'dark';
|
: 'dark';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
...persisted,
|
||||||
themeVariant,
|
themeVariant,
|
||||||
lastDirectory: workspaceFolder,
|
lastDirectory: workspaceFolder,
|
||||||
...restStored,
|
opencodeBinary: persistedOpencodeBinary || undefined,
|
||||||
opencodeBinary:
|
|
||||||
typeof restStored.opencodeBinary === 'string'
|
|
||||||
? String(restStored.opencodeBinary).trim()
|
|
||||||
: (sharedOpencodeBinary || undefined),
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const persistSettings = async (changes: Record<string, unknown>, ctx?: BridgeContext): Promise<Record<string, unknown>> => {
|
export const persistSettings = async (changes: Record<string, unknown>, ctx?: BridgeContext): Promise<Record<string, unknown>> => {
|
||||||
const current = readSettings(ctx);
|
const current = readSettings(ctx);
|
||||||
const restChanges = { ...(changes || {}) };
|
const restChanges = stripDerived({ ...(changes || {}) });
|
||||||
delete restChanges.lastDirectory;
|
|
||||||
|
|
||||||
const keysToClear = new Set<string>();
|
const keysToClear = new Set<string>();
|
||||||
|
|
||||||
@@ -256,19 +297,33 @@ export const persistSettings = async (changes: Record<string, unknown>, ctx?: Br
|
|||||||
delete restChanges.usageRefreshIntervalMs;
|
delete restChanges.usageRefreshIntervalMs;
|
||||||
}
|
}
|
||||||
|
|
||||||
const merged = { ...current, ...restChanges, lastDirectory: current.lastDirectory } as Record<string, unknown>;
|
if (typeof restChanges.opencodeBinary === 'string') {
|
||||||
|
restChanges.opencodeBinary = restChanges.opencodeBinary.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persistable state = current persisted (no derived fields) + sanitized changes.
|
||||||
|
const persistedCurrent = readPersistedSettings(ctx);
|
||||||
|
const persistable: Record<string, unknown> = { ...persistedCurrent, ...restChanges };
|
||||||
for (const key of keysToClear) {
|
for (const key of keysToClear) {
|
||||||
delete merged[key];
|
delete persistable[key];
|
||||||
}
|
|
||||||
await ctx?.context?.globalState.update(SETTINGS_KEY, merged);
|
|
||||||
|
|
||||||
if (keysToClear.has('opencodeBinary')) {
|
|
||||||
await writeSharedSettingsToDisk({ opencodeBinary: '' });
|
|
||||||
} else if (typeof restChanges.opencodeBinary === 'string') {
|
|
||||||
await writeSharedSettingsToDisk({ opencodeBinary: restChanges.opencodeBinary.trim() });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return merged;
|
// Write to the shared file (canonical, cross-client). Also mirror into
|
||||||
|
// globalState so older builds can still read recent values if a user
|
||||||
|
// downgrades the extension.
|
||||||
|
await writeSharedSettingsToDisk(persistable);
|
||||||
|
await ctx?.context?.globalState.update(SETTINGS_KEY, persistable);
|
||||||
|
|
||||||
|
// Return the same shape as readSettings (with derived fields re-applied).
|
||||||
|
return {
|
||||||
|
...persistable,
|
||||||
|
themeVariant: current.themeVariant,
|
||||||
|
lastDirectory: current.lastDirectory,
|
||||||
|
opencodeBinary:
|
||||||
|
typeof persistable.opencodeBinary === 'string' && persistable.opencodeBinary.length > 0
|
||||||
|
? persistable.opencodeBinary
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const readMagicPromptOverrides = (): { version: number; overrides: Record<string, string> } => {
|
export const readMagicPromptOverrides = (): { version: number; overrides: Record<string, string> } => {
|
||||||
|
|||||||
Reference in New Issue
Block a user