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:
@@ -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 }
|
||||
})
|
||||
|
||||
@@ -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' })
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
5
apps/desktop/src/global.d.ts
vendored
5
apps/desktop/src/global.d.ts
vendored
@@ -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: (
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user