fix(desktop): stop windows from adopting each other's active project
Every window shares one server settings document, and every PUT returns the merged whole, so one window's activeProjectId write was adopted by the other on its next unrelated settings save — its sidebar then auto-selected a session in that project and wrote the pointer back, converging both windows onto one session. settings-synced now carries an adoptWorkspace flag: only bootstrap-grade syncs (startup, runtime switch) may adopt the shared workspace pointers; reconcile responses keep the window's own active project while it exists. Notification clicks and session deep links also stopped broadcasting the session switch to every window.
This commit is contained in:
@@ -353,6 +353,7 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({
|
||||
const [activePasskeyAction, setActivePasskeyAction] = React.useState<'auth' | 'register' | null>(null);
|
||||
const passwordInputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const hasResyncedRef = React.useRef(skipAuth);
|
||||
const hasBootstrapResyncedRef = React.useRef(skipAuth);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
@@ -593,10 +594,18 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({
|
||||
}
|
||||
if (state === 'authenticated' && !hasResyncedRef.current) {
|
||||
hasResyncedRef.current = true;
|
||||
// First authentication of this page load is bootstrap: adopt the
|
||||
// persisted workspace pointers. A re-login after mid-session expiry is
|
||||
// not — this window already has its own workspace, and the shared
|
||||
// settings document may carry another window's pointers.
|
||||
const isBootstrapResync = !hasBootstrapResyncedRef.current;
|
||||
hasBootstrapResyncedRef.current = true;
|
||||
void (async () => {
|
||||
await initializeAppearancePreferences();
|
||||
await syncDesktopSettings();
|
||||
await applyPersistedDirectoryPreferences();
|
||||
await syncDesktopSettings({ adoptWorkspace: isBootstrapResync });
|
||||
if (isBootstrapResync) {
|
||||
await applyPersistedDirectoryPreferences();
|
||||
}
|
||||
})();
|
||||
}
|
||||
}, [skipAuth, state]);
|
||||
|
||||
@@ -11,7 +11,7 @@ import type { DesktopSettings } from '@/lib/desktop';
|
||||
import { isDesktopLocalOriginActive, isDesktopShell as detectDesktopShell, isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { setDesktopWindowTheme } from '@/lib/desktopNative';
|
||||
import { CSSVariableGenerator } from '@/lib/theme/cssGenerator';
|
||||
import { updateDesktopSettings } from '@/lib/persistence';
|
||||
import { type SettingsSyncedDetail, updateDesktopSettings } from '@/lib/persistence';
|
||||
import {
|
||||
themes,
|
||||
getThemeById,
|
||||
@@ -622,7 +622,7 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
|
||||
return;
|
||||
}
|
||||
const handleSettingsSynced = (event: Event) => {
|
||||
const detail = (event as CustomEvent<DesktopSettings>).detail;
|
||||
const detail = (event as CustomEvent<SettingsSyncedDetail>).detail?.settings;
|
||||
if (!detail) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -558,7 +558,7 @@ describe('updateDesktopSettings', () => {
|
||||
});
|
||||
const syncedSettings: SettingsPayload[] = [];
|
||||
const handleSettingsSynced = (event: Event) => {
|
||||
syncedSettings.push((event as CustomEvent<SettingsPayload>).detail);
|
||||
syncedSettings.push((event as CustomEvent<{ settings: SettingsPayload }>).detail.settings);
|
||||
};
|
||||
getWindow().addEventListener('openchamber:settings-synced', handleSettingsSynced);
|
||||
|
||||
@@ -584,7 +584,7 @@ describe('updateDesktopSettings', () => {
|
||||
invalidateSettingsCache();
|
||||
const syncedSettings: SettingsPayload[] = [];
|
||||
const handleSettingsSynced = (event: Event) => {
|
||||
syncedSettings.push((event as CustomEvent<SettingsPayload>).detail);
|
||||
syncedSettings.push((event as CustomEvent<{ settings: SettingsPayload }>).detail.settings);
|
||||
};
|
||||
getWindow().addEventListener('openchamber:settings-synced', handleSettingsSynced);
|
||||
|
||||
@@ -616,7 +616,7 @@ describe('updateDesktopSettings', () => {
|
||||
invalidateSettingsCache();
|
||||
const syncedSettings: SettingsPayload[] = [];
|
||||
const handleSettingsSynced = (event: Event) => {
|
||||
syncedSettings.push((event as CustomEvent<SettingsPayload>).detail);
|
||||
syncedSettings.push((event as CustomEvent<{ settings: SettingsPayload }>).detail.settings);
|
||||
};
|
||||
getWindow().addEventListener('openchamber:settings-synced', handleSettingsSynced);
|
||||
|
||||
@@ -647,7 +647,7 @@ describe('updateDesktopSettings', () => {
|
||||
invalidateSettingsCache();
|
||||
const syncedSettings: SettingsPayload[] = [];
|
||||
const handleSettingsSynced = (event: Event) => {
|
||||
syncedSettings.push((event as CustomEvent<SettingsPayload>).detail);
|
||||
syncedSettings.push((event as CustomEvent<{ settings: SettingsPayload }>).detail.settings);
|
||||
};
|
||||
getWindow().addEventListener('openchamber:settings-synced', handleSettingsSynced);
|
||||
|
||||
|
||||
@@ -199,11 +199,23 @@ const persistToLocalStorage = (settings: DesktopSettings) => {
|
||||
setOrRemoveLocalStorage('sttLanguage', typeof settings.sttLanguage === 'string' ? settings.sttLanguage : null);
|
||||
};
|
||||
|
||||
const dispatchSettingsSynced = (settings: DesktopSettings): void => {
|
||||
export interface SettingsSyncedDetail {
|
||||
settings: DesktopSettings;
|
||||
/** Whether listeners may adopt cross-window workspace pointers
|
||||
(activeProjectId / lastDirectory). True only for a bootstrap-grade sync:
|
||||
the settings document is shared by every window of this server, so a
|
||||
mid-session reconciliation adopting them would hijack this window's
|
||||
workspace with another window's choice. */
|
||||
adoptWorkspace: boolean;
|
||||
}
|
||||
|
||||
const dispatchSettingsSynced = (settings: DesktopSettings, adoptWorkspace: boolean): void => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent<DesktopSettings>('openchamber:settings-synced', { detail: settings }));
|
||||
window.dispatchEvent(new CustomEvent<SettingsSyncedDetail>('openchamber:settings-synced', {
|
||||
detail: { settings, adoptWorkspace },
|
||||
}));
|
||||
};
|
||||
|
||||
type SettingsSaveState = 'idle' | 'saving' | 'error';
|
||||
@@ -1841,7 +1853,8 @@ export const invalidateSettingsCache = (): void => {
|
||||
_settingsCache = null;
|
||||
};
|
||||
|
||||
export const syncDesktopSettings = async (): Promise<void> => {
|
||||
export const syncDesktopSettings = async (options?: { adoptWorkspace?: boolean }): Promise<void> => {
|
||||
const adoptWorkspace = options?.adoptWorkspace !== false;
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
@@ -1970,7 +1983,7 @@ export const syncDesktopSettings = async (): Promise<void> => {
|
||||
if (!isSettingsRuntimeContextCurrent(context)) return;
|
||||
}
|
||||
|
||||
dispatchSettingsSynced(authoritativeSettings);
|
||||
dispatchSettingsSynced(authoritativeSettings, adoptWorkspace);
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -2013,7 +2026,7 @@ async function _flushSettingsUpdate(): Promise<void> {
|
||||
if (updated) {
|
||||
const reconciled = _settingsMutationTracker.reconcile(updated, operation);
|
||||
applyDesktopUiPreferences(reconciled);
|
||||
dispatchSettingsSynced(reconciled);
|
||||
dispatchSettingsSynced(reconciled, false);
|
||||
_settingsCache = null;
|
||||
}
|
||||
dispatchSettingsSaveState(updated ? 'saved' : 'error');
|
||||
@@ -2047,7 +2060,7 @@ async function _flushSettingsUpdate(): Promise<void> {
|
||||
if (updated) {
|
||||
const reconciled = _settingsMutationTracker.reconcile(updated, operation);
|
||||
applyDesktopUiPreferences(reconciled);
|
||||
dispatchSettingsSynced(reconciled);
|
||||
dispatchSettingsSynced(reconciled, false);
|
||||
dispatchSettingsSaveState('saved');
|
||||
// Invalidate GET cache so next read sees the fresh data
|
||||
_settingsCache = null;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { create } from 'zustand';
|
||||
|
||||
import { fetchDesktopInstalledApps, isDesktopLocalOriginActive, isDesktopShell, type DesktopSettings, type InstalledDesktopAppInfo } from '@/lib/desktop';
|
||||
import { OPEN_IN_APPS, DEFAULT_OPEN_IN_APP_ID, OPEN_IN_ALWAYS_AVAILABLE_APP_IDS, getOpenInAppById, getPlatformOpenInApp, type OpenInApp } from '@/lib/openInApps';
|
||||
import { updateDesktopSettings } from '@/lib/persistence';
|
||||
import { type SettingsSyncedDetail, updateDesktopSettings } from '@/lib/persistence';
|
||||
|
||||
export type OpenInAppOption = OpenInApp & {
|
||||
iconDataUrl?: string;
|
||||
@@ -160,7 +160,7 @@ export const useOpenInAppsStore = create<OpenInAppsState>()((set, get) => ({
|
||||
void loadInstalledApps();
|
||||
|
||||
const settingsHandler = (event: Event) => {
|
||||
const detail = (event as CustomEvent<DesktopSettings>).detail;
|
||||
const detail = (event as CustomEvent<SettingsSyncedDetail>).detail?.settings;
|
||||
const nextId = detail
|
||||
&& typeof detail.openInAppId === 'string'
|
||||
&& detail.openInAppId.length > 0
|
||||
|
||||
@@ -18,6 +18,39 @@ describe("useProjectsStore settings synchronization", () => {
|
||||
expect(useProjectsStore.getState().activeProjectId).toBe(null)
|
||||
expect(useProjectsStore.getState().manualProjectOrder).toEqual([])
|
||||
})
|
||||
|
||||
test("a reconcile sync never adopts another window's active project", () => {
|
||||
// Ids are path-derived inside the store's sanitizer, so seed real ones by
|
||||
// bootstrapping once and reading them back.
|
||||
const raw = { projects: [{ path: "/repo-a" }, { path: "/repo-b" }] } as DesktopSettings
|
||||
useProjectsStore.getState().synchronizeFromSettings(raw)
|
||||
const [first, second] = useProjectsStore.getState().projects
|
||||
useProjectsStore.setState({ activeProjectId: first.id })
|
||||
|
||||
// The shared settings document carries window B's pointer; outside a
|
||||
// bootstrap this window keeps its own.
|
||||
useProjectsStore.getState().synchronizeFromSettings(
|
||||
{ ...raw, activeProjectId: second.id } as DesktopSettings,
|
||||
{ adoptActiveProject: false },
|
||||
)
|
||||
expect(useProjectsStore.getState().activeProjectId).toBe(first.id)
|
||||
|
||||
// Unless its own project vanished from the list — then the incoming
|
||||
// pointer is better than a dangling one.
|
||||
useProjectsStore.getState().synchronizeFromSettings(
|
||||
{ projects: [{ path: "/repo-b" }], activeProjectId: second.id } as DesktopSettings,
|
||||
{ adoptActiveProject: false },
|
||||
)
|
||||
expect(useProjectsStore.getState().activeProjectId).toBe(second.id)
|
||||
|
||||
// A bootstrap sync adopts as before.
|
||||
useProjectsStore.getState().synchronizeFromSettings(raw)
|
||||
useProjectsStore.setState({ activeProjectId: first.id })
|
||||
useProjectsStore.getState().synchronizeFromSettings(
|
||||
{ ...raw, activeProjectId: second.id } as DesktopSettings,
|
||||
)
|
||||
expect(useProjectsStore.getState().activeProjectId).toBe(second.id)
|
||||
})
|
||||
})
|
||||
|
||||
describe("useProjectsStore selection identity", () => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { opencodeClient } from '@/lib/opencode/client';
|
||||
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||
import type { ProjectEntry } from '@/lib/api/types';
|
||||
import type { DesktopSettings } from '@/lib/desktop';
|
||||
import { updateDesktopSettings } from '@/lib/persistence';
|
||||
import { type SettingsSyncedDetail, updateDesktopSettings } from '@/lib/persistence';
|
||||
import { createProjectIdFromPath } from '@/lib/projectId';
|
||||
import { getDeferredSafeStorage } from './utils/safeStorage';
|
||||
import { useDirectoryStore } from './useDirectoryStore';
|
||||
@@ -68,7 +68,7 @@ interface ProjectsStore {
|
||||
reorderProjects: (fromIndex: number, toIndex: number) => void;
|
||||
resetForRuntimeSwitch: () => void;
|
||||
validateProjectPath: (path: string) => ProjectPathValidationResult;
|
||||
synchronizeFromSettings: (settings: DesktopSettings) => void;
|
||||
synchronizeFromSettings: (settings: DesktopSettings, options?: { adoptActiveProject?: boolean }) => void;
|
||||
syncVSCodeWorkspaceFolders: (folders: VSCodeWorkspaceFolderConfig[], activePath?: string | null) => ProjectEntry | null;
|
||||
getActiveProject: () => ProjectEntry | null;
|
||||
}
|
||||
@@ -809,7 +809,7 @@ export const useProjectsStore = create<ProjectsStore>()(
|
||||
|
||||
const payload = (await response.json().catch(() => null)) as { settings?: DesktopSettings } | null;
|
||||
if (payload?.settings) {
|
||||
get().synchronizeFromSettings(payload.settings);
|
||||
get().synchronizeFromSettings(payload.settings, { adoptActiveProject: false });
|
||||
}
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
@@ -838,7 +838,7 @@ export const useProjectsStore = create<ProjectsStore>()(
|
||||
|
||||
const payload = (await response.json().catch(() => null)) as { settings?: DesktopSettings } | null;
|
||||
if (payload?.settings) {
|
||||
get().synchronizeFromSettings(payload.settings);
|
||||
get().synchronizeFromSettings(payload.settings, { adoptActiveProject: false });
|
||||
}
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
@@ -874,7 +874,7 @@ export const useProjectsStore = create<ProjectsStore>()(
|
||||
}
|
||||
|
||||
if (payload?.settings) {
|
||||
get().synchronizeFromSettings(payload.settings);
|
||||
get().synchronizeFromSettings(payload.settings, { adoptActiveProject: false });
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -924,32 +924,43 @@ export const useProjectsStore = create<ProjectsStore>()(
|
||||
set({ projects, activeProjectId: nextActiveProjectId, manualProjectOrder: [] });
|
||||
},
|
||||
|
||||
synchronizeFromSettings: (settings: DesktopSettings) => {
|
||||
synchronizeFromSettings: (settings: DesktopSettings, options?: { adoptActiveProject?: boolean }) => {
|
||||
if (isVSCodeProjectsRuntime) {
|
||||
return;
|
||||
}
|
||||
const adoptActiveProject = options?.adoptActiveProject !== false;
|
||||
const incomingProjects = sanitizeProjects(settings.projects ?? []);
|
||||
const incomingActive = typeof settings.activeProjectId === 'string' && settings.activeProjectId.trim()
|
||||
? settings.activeProjectId.trim()
|
||||
: null;
|
||||
|
||||
const current = get();
|
||||
const incomingIds = new Set(incomingProjects.map((p) => p.id));
|
||||
|
||||
// The settings document is shared by every window on this server, so
|
||||
// outside a bootstrap sync the incoming active pointer is just another
|
||||
// window's choice — the project LIST still reconciles, but this
|
||||
// window's active project stays its own while it remains valid.
|
||||
const nextActive = adoptActiveProject
|
||||
? incomingActive
|
||||
: (current.activeProjectId && incomingIds.has(current.activeProjectId)
|
||||
? current.activeProjectId
|
||||
: incomingActive);
|
||||
|
||||
const projectsChanged = JSON.stringify(current.projects) !== JSON.stringify(incomingProjects);
|
||||
const activeChanged = current.activeProjectId !== incomingActive;
|
||||
const activeChanged = current.activeProjectId !== nextActive;
|
||||
|
||||
if (!projectsChanged && !activeChanged) {
|
||||
return;
|
||||
}
|
||||
|
||||
const incomingIds = new Set(incomingProjects.map((p) => p.id));
|
||||
const cleanedOrder = get().manualProjectOrder.filter((id) => incomingIds.has(id));
|
||||
set({ projects: incomingProjects, activeProjectId: incomingActive, manualProjectOrder: cleanedOrder });
|
||||
cacheProjects(incomingProjects, incomingActive);
|
||||
set({ projects: incomingProjects, activeProjectId: nextActive, manualProjectOrder: cleanedOrder });
|
||||
cacheProjects(incomingProjects, nextActive);
|
||||
persistManualProjectOrder(cleanedOrder);
|
||||
|
||||
if (incomingActive) {
|
||||
const activeProject = incomingProjects.find((project) => project.id === incomingActive);
|
||||
if (activeChanged && nextActive) {
|
||||
const activeProject = incomingProjects.find((project) => project.id === nextActive);
|
||||
if (activeProject) {
|
||||
opencodeClient.setDirectory(activeProject.path);
|
||||
useDirectoryStore.getState().setDirectory(activeProject.path, { showOverlay: false });
|
||||
@@ -1005,9 +1016,11 @@ export const useProjectsStore = create<ProjectsStore>()(
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('openchamber:settings-synced', (event: Event) => {
|
||||
const detail = (event as CustomEvent<DesktopSettings>).detail;
|
||||
if (detail && typeof detail === 'object') {
|
||||
useProjectsStore.getState().synchronizeFromSettings(detail);
|
||||
const detail = (event as CustomEvent<SettingsSyncedDetail>).detail;
|
||||
if (detail && typeof detail === 'object' && detail.settings) {
|
||||
useProjectsStore.getState().synchronizeFromSettings(detail.settings, {
|
||||
adoptActiveProject: detail.adoptWorkspace,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user