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:
Bohdan Triapitsyn
2026-04-22 15:43:15 +03:00
parent 81591430a1
commit dcd68baa09
3 changed files with 156 additions and 47 deletions
+50 -15
View File
@@ -32,7 +32,15 @@ const persistToLocalStorage = (settings: DesktopSettings) => {
}
if (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) {
localStorage.setItem('projects', JSON.stringify(settings.projects));
@@ -969,20 +977,47 @@ export const syncDesktopSettings = async (): Promise<void> => {
const persistApi = getPersistApi();
const applySettings = (settings: DesktopSettings) => {
persistToLocalStorage(settings);
const apply = () => applyDesktopUiPreferences(settings);
// Wait for Zustand persist hydration before applying server settings.
// Otherwise `set()`-calls race with hydration: we set X, then hydration
// 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?.()) {
apply();
} else {
apply();
if (persistApi?.onFinishHydration) {
const unsubscribe = persistApi.onFinishHydration(() => {
unsubscribe?.();
apply();
});
}
// Each step is wrapped in try/catch so a failure in one side-effect (e.g.
// a TypeError from writing to a contextBridge-protected global) doesn't
// prevent server settings from reaching the Zustand store.
const applySettings = async (settings: DesktopSettings) => {
try {
persistToLocalStorage(settings);
} catch (error) {
console.warn('persistToLocalStorage failed:', error);
}
await waitForHydration();
try {
applyDesktopUiPreferences(settings);
} catch (error) {
console.warn('applyDesktopUiPreferences failed:', error);
}
if (typeof window !== 'undefined') {
@@ -993,7 +1028,7 @@ export const syncDesktopSettings = async (): Promise<void> => {
try {
const webSettings = await fetchWebSettings();
if (webSettings) {
applySettings(webSettings);
await applySettings(webSettings);
}
} catch (error) {
console.warn('Failed to synchronise settings:', error);
+28 -9
View File
@@ -69,6 +69,7 @@ export async function listGlobalSessionPages(
},
): Promise<GlobalSessionRecord[]> {
const all: GlobalSessionRecord[] = [];
const seenIds = new Set<string>();
let cursor: number | undefined;
while (true) {
@@ -76,23 +77,41 @@ export async function listGlobalSessionPages(
() => apiClient.experimental.session.list({
archived: options.archived,
limit: options.pageSize,
...(cursor ? { cursor } : {}),
...(cursor !== undefined ? { cursor } : {}),
}),
{ attempts: 3, delay: 500, retryIf: () => true },
);
const payload = Array.isArray(response.data) ? (response.data as GlobalSessionRecord[]) : [];
if (payload.length === 0) {
break;
if (payload.length === 0) 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);
options.onPage?.(payload);
// Stop on partial page — nothing more to fetch.
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;
}