From dfa74b581532a4d893381200198ec2d934b64eb7 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 03:48:44 -0700 Subject: [PATCH] fix(desktop): boot session pop-out/watch windows against the session's owning profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `openSessionInNewWindow` → IPC `hermes:window:openSession` → `buildSessionWindowUrl` emitted no `profile`, so a secondary window (⇧⌘-click pop-out, subagent watch) was a full renderer that adopted the PRIMARY backend's profile and resolved the session id against the wrong store — blank/wrong session for any non-primary profile (#82768, #61286). The owning profile now rides the URL as `&profile=`, exactly the carry the HUD already does (buildHudWindowUrl / windowProfileOverride in use-gateway-boot); the renderer picks it with the same ladder openHud uses: the session's stamped owner wins, an unstamped/uncached id (a brand-new subagent child) inherits the profile the user is looking at. Diagnosis credit: @DomGrieco (#82794). Co-authored-by: DomGrieco <6556434+DomGrieco@users.noreply.github.com> --- apps/desktop/electron/main.ts | 16 +++++++++++---- apps/desktop/electron/session-windows.test.ts | 6 ++++++ apps/desktop/electron/session-windows.ts | 9 +++++++-- apps/desktop/src/global.d.ts | 5 ++++- apps/desktop/src/store/windows.test.ts | 20 ++++++++----------- apps/desktop/src/store/windows.ts | 15 +++++++++++++- 6 files changed, 51 insertions(+), 20 deletions(-) diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 792fff541f..8a6a53e65a 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -13053,7 +13053,11 @@ function focusWindow(win) { win.focus() } -function spawnSecondaryWindow({ sessionId, watch }: { sessionId?: string; watch?: boolean } = {}) { +function spawnSecondaryWindow({ + sessionId, + profile, + watch +}: { sessionId?: string; profile?: null | string; watch?: boolean } = {}) { const icon = getAppIconPath() const win = new BrowserWindow({ @@ -13115,6 +13119,7 @@ function spawnSecondaryWindow({ sessionId, watch }: { sessionId?: string; watch? win, buildSessionWindowUrl(sessionId, { devServer: DEV_SERVER, + profile, rendererIndexPath: DEV_SERVER ? undefined : resolveRendererIndex(), watch }), @@ -13125,8 +13130,8 @@ function spawnSecondaryWindow({ sessionId, watch }: { sessionId?: string; watch? } // Open (or focus) a standalone window for a single chat session. -function createSessionWindow(sessionId, { watch = false } = {}) { - return sessionWindows.openOrFocus(sessionId, () => spawnSecondaryWindow({ sessionId, watch })) +function createSessionWindow(sessionId, { profile = null, watch = false } = {}) { + return sessionWindows.openOrFocus(sessionId, () => spawnSecondaryWindow({ sessionId, profile, watch })) } // Popped-out in-app Browser: same webview + address bar as a docked Browser @@ -14613,7 +14618,10 @@ ipcMain.handle('hermes:window:openSession', async (_event, sessionId, opts) => { return { ok: false, error: 'invalid-session-id' } } - createSessionWindow(sessionId.trim(), { watch: opts?.watch === true }) + createSessionWindow(sessionId.trim(), { + profile: typeof opts?.profile === 'string' ? opts.profile : null, + watch: opts?.watch === true + }) return { ok: true } }) diff --git a/apps/desktop/electron/session-windows.test.ts b/apps/desktop/electron/session-windows.test.ts index c0a1178429..64a9d6fb12 100644 --- a/apps/desktop/electron/session-windows.test.ts +++ b/apps/desktop/electron/session-windows.test.ts @@ -68,6 +68,12 @@ test('buildSessionWindowUrl avoids a double slash when the dev server has a trai assert.equal(url, 'http://localhost:5173/?win=secondary#/abc123') }) +test('buildSessionWindowUrl carries the owning profile in the query before the hash (#82768)', () => { + const url = buildSessionWindowUrl('abc123', { devServer: 'http://localhost:5173', profile: 'work', watch: true }) + + assert.equal(url, 'http://localhost:5173/?win=secondary&watch=1&profile=work#/abc123') +}) + test('buildSessionWindowUrl encodes the session id in the hash route', () => { const url = buildSessionWindowUrl('a b/c', { devServer: 'http://localhost:5173' }) diff --git a/apps/desktop/electron/session-windows.ts b/apps/desktop/electron/session-windows.ts index 19be87ff1c..5fbd456a5b 100644 --- a/apps/desktop/electron/session-windows.ts +++ b/apps/desktop/electron/session-windows.ts @@ -64,8 +64,13 @@ function chatWindowWebPreferences(preloadPath: string) { // onboarding overlays and the global session sidebar. `watch=1` marks a // spectator window (e.g. a running subagent's session): the renderer resumes it // lazily so the gateway never builds an agent just to stream into it. -function buildSessionWindowUrl(sessionId: string, { devServer, rendererIndexPath, watch }: any = {}) { - const query = `?win=secondary${watch ? '&watch=1' : ''}` +// `profile` names the backend the window must boot against (same carry as the +// HUD's buildHudWindowUrl): without it a pop-out/watch window adopts the +// PRIMARY profile and resolves the session id against the wrong backend +// (#82768, #61286). Absent → unchanged primary adoption. +function buildSessionWindowUrl(sessionId: string, { devServer, profile, rendererIndexPath, watch }: any = {}) { + const profileKey = typeof profile === 'string' ? profile.trim() : '' + const query = `?win=secondary${watch ? '&watch=1' : ''}${profileKey ? `&profile=${encodeURIComponent(profileKey)}` : ''}` const route = `#/${encodeURIComponent(sessionId)}` if (devServer) { diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index ec39041d5c..8067308df7 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -52,7 +52,10 @@ declare global { // with an error code when the sessionId is empty/invalid. `watch` opens // a spectator window (lazy resume — no agent build) for live-streaming // a running subagent's session. - openSessionWindow: (sessionId: string, opts?: { watch?: boolean }) => Promise<{ ok: boolean; error?: string }> + openSessionWindow: ( + sessionId: string, + opts?: { profile?: null | string; watch?: boolean } + ) => Promise<{ ok: boolean; error?: string }> // Resume this session in the user's own terminal emulator (`hermes --tui // --resume `) — the external terminal, not the in-app pane. openSessionInTerminal: ( diff --git a/apps/desktop/src/store/windows.test.ts b/apps/desktop/src/store/windows.test.ts index 7c032a07f0..69402ae14b 100644 --- a/apps/desktop/src/store/windows.test.ts +++ b/apps/desktop/src/store/windows.test.ts @@ -1,5 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { $activeGatewayProfile } from './profile' +import { $sessions } from './session' import { canOpenBrowserWindow, canOpenNewWindow, @@ -88,23 +90,17 @@ describe('openSessionInNewWindow', () => { expect(notifyError).not.toHaveBeenCalled() }) - it('invokes the bridge with the session id', async () => { + it('carries the owning profile: stamped row wins, an unstamped child inherits the viewed profile (#82768)', async () => { const open = vi.fn().mockResolvedValue({ ok: true }) installBridge(open) + $activeGatewayProfile.set('work') + $sessions.set([{ id: 's1', profile: 'research' } as never]) await openSessionInNewWindow('s1') + await openSessionInNewWindow('child-not-listed-yet', { watch: true }) - expect(open).toHaveBeenCalledWith('s1', undefined) - expect(notifyError).not.toHaveBeenCalled() - }) - - it('forwards the watch flag for spectator (subagent) windows', async () => { - const open = vi.fn().mockResolvedValue({ ok: true }) - installBridge(open) - - await openSessionInNewWindow('s1', { watch: true }) - - expect(open).toHaveBeenCalledWith('s1', { watch: true }) + expect(open).toHaveBeenCalledWith('s1', { profile: 'research' }) + expect(open).toHaveBeenCalledWith('child-not-listed-yet', { profile: 'work', watch: true }) expect(notifyError).not.toHaveBeenCalled() }) diff --git a/apps/desktop/src/store/windows.ts b/apps/desktop/src/store/windows.ts index 8015893d88..77befcdb39 100644 --- a/apps/desktop/src/store/windows.ts +++ b/apps/desktop/src/store/windows.ts @@ -189,13 +189,26 @@ async function runWindowOpen(call: () => Promise, failMessage: // Open (or focus) a standalone OS window for a single chat session. No-ops // gracefully outside Electron so callers can wire it unconditionally. // `watch: true` opens a spectator window (lazy resume, live-mirror stream). +// The window is a full renderer that adopts the PRIMARY profile unless told +// otherwise, so the owning profile rides along (same ladder as openHud, +// #82285): the session's stamped owner wins, and an unstamped/uncached id — +// a brand-new subagent child — inherits the profile the user is looking at +// (#82768, #61286). export async function openSessionInNewWindow(sessionId: string, opts?: { watch?: boolean }): Promise { if (!sessionId || !canOpenSessionWindow()) { return } + // Lazy imports: `./profile` subscribes to the API client on load, so a + // static import here would drag it into every page that opens windows. + const [{ $activeGatewayProfile, normalizeProfileKey }, { $sessions, rememberedSessionProfile }] = await Promise.all([ + import('./profile'), + import('./session') + ]) + const profile = normalizeProfileKey(rememberedSessionProfile($sessions.get(), sessionId, $activeGatewayProfile.get())) + await runWindowOpen( - () => window.hermesDesktop.openSessionWindow(sessionId, opts), + () => window.hermesDesktop.openSessionWindow(sessionId, { ...opts, profile }), 'Could not open chat in a new window' ) }