fix(desktop): boot session pop-out/watch windows against the session's owning profile

`openSessionInNewWindow` → IPC `hermes🪟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>
This commit is contained in:
Teknium
2026-09-02 03:48:44 -07:00
parent 74b00d7a97
commit dfa74b5815
6 changed files with 51 additions and 20 deletions

View File

@@ -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 }
})

View File

@@ -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' })

View File

@@ -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) {

View File

@@ -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 <id>`) — the external terminal, not the in-app pane.
openSessionInTerminal: (

View File

@@ -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()
})

View File

@@ -189,13 +189,26 @@ async function runWindowOpen(call: () => Promise<WindowOpenResult>, 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<void> {
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'
)
}