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:
@@ -10,6 +10,7 @@ All notable changes to this project will be documented in this file.
|
||||
- **Session tabs (opt-in):** the web/desktop header can show your open sessions as browser-style tabs — turn them on in Settings → General → Navigation → Session tabs. Every session you open joins the strip, clicking a tab switches the whole workspace (chat, project, panels), and closing one (its × button, middle-click, or Alt+W — rebindable in Shortcuts) never touches the session itself. Tabs reorder by drag, scroll behind the header buttons when there are many and carry the sidebar's running/unread dot. Each tab has the full session menu — on the "..." button or right-click — plus Close other tabs; renaming works right in the tab.
|
||||
- **Keyboard shortcuts learned sequences** (thanks @ChangeHow): shortcuts can now be two steps — press Cmd/Ctrl+S, then a letter: P opens the draft's project picker, G its branch picker, L the session list. A held sequence shows no menu but forgives you: Escape or three quiet seconds cancel it, and typing into a text field never triggers one armed elsewhere. Dropdown menus and pickers now also answer Ctrl+N/Ctrl+P for down/up, the session switcher opens focused on your current session, and every tooltip and menu label shows the binding you actually have set, not the default.
|
||||
- **Keyboard shortcuts redesigned.** Defaults now follow one model: single chords for everyday actions, a two-step Cmd/Ctrl+K leader for open/go actions (K then P/G/L — project picker, branch picker, session list; T timeline, N prompt navigator, I services, H shortcut help, C theme), held Cmd/Ctrl+digit for header session tabs and held Cmd/Ctrl+Option+digit for context panel surfaces. Cmd/Ctrl+B now toggles the sidebar; when it's already open, the session-list shortcut jumps into its search. Rare actions moved into the command palette instead of carrying obscure default bindings, and custom bindings recorded under the old layout are reset once. Shortcuts also stopped requiring an English keyboard layout — bindings follow the physical key on non-Latin layouts (and Option-modified digits on macOS), including when recording custom ones.
|
||||
- Desktop: two windows on different projects no longer hijack each other — switching sessions in one window could make the other adopt its project and jump to the same session mid-typing (the shared settings file round-tripped the active project between windows). Notification clicks and openchamber:// session links now open in one window instead of switching every window.
|
||||
- **Session expiry is announced, not discovered.** When the OpenChamber login expires (a browser on the LAN, a paired device, a tunnel), a frosted banner appears under the header within seconds — before anything is clicked — saying the session expired, with a Log in button that opens the usual unlock screen. Work on screen stays visible and interactive; sending is paused until login instead of failing into a toast. A 401 is confirmed against the server first, so an expired model-provider key can't fake a logout, and returning to the app after a long absence re-checks the session once. If a conversation failed to load while logged out, it explains that and reloads itself right after login.
|
||||
- **Permission cards answer to the keyboard:** Alt+Enter allows once, Alt+Shift+Enter allows always, Alt+Backspace denies — the keys are printed on the buttons, and the newest pending card is the one that listens. The auto-accept toggle also got a shortcut (Cmd/Ctrl+K, A).
|
||||
- Sessions: Cmd/Ctrl+Alt+Left/Right steps back and forward through the sessions you opened in this window, browser-history style; with session tabs enabled it moves between neighbouring tabs instead. Cmd/Ctrl+K, R renames the current session right in the header.
|
||||
|
||||
@@ -1369,7 +1369,7 @@ const maybeShowNativeNotification = (rawInput) => {
|
||||
notification.on('click', () => {
|
||||
focusForegroundWindow();
|
||||
if (sessionId) {
|
||||
emitToAllWindows('openchamber:open-session', { sessionId, directory });
|
||||
emitToPrimaryWindow('openchamber:open-session', { sessionId, directory });
|
||||
}
|
||||
release();
|
||||
});
|
||||
@@ -1997,6 +1997,18 @@ const emitToAllWindows = (event, detail) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Session navigation must land in ONE window. Broadcasting it makes every
|
||||
// open window adopt the same session, hijacking whatever the other windows
|
||||
// were doing.
|
||||
const emitToPrimaryWindow = (event, detail) => {
|
||||
const windows = BrowserWindow.getAllWindows().filter((window) => !window.isDestroyed());
|
||||
if (windows.length === 0) return;
|
||||
const target = (state.mainWindow && !state.mainWindow.isDestroyed())
|
||||
? state.mainWindow
|
||||
: windows.find((window) => window.isFocused()) || windows.find((window) => window.isVisible()) || windows[0];
|
||||
emitToWindow(target, event, detail);
|
||||
};
|
||||
|
||||
const setTaskbarProgress = (value) => {
|
||||
if (process.platform !== 'win32') return;
|
||||
for (const browserWindow of BrowserWindow.getAllWindows()) {
|
||||
@@ -2278,7 +2290,7 @@ const dispatchDeepLink = (link) => {
|
||||
}
|
||||
|
||||
if (link.type === 'session' && link.value) {
|
||||
emitToAllWindows('openchamber:open-session', { sessionId: link.value });
|
||||
emitToPrimaryWindow('openchamber:open-session', { sessionId: link.value });
|
||||
return;
|
||||
}
|
||||
if (link.type === 'host' && link.value) {
|
||||
|
||||
@@ -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