diff --git a/apps/desktop/electron/hud-url.test.ts b/apps/desktop/electron/hud-url.test.ts new file mode 100644 index 0000000000000..9087668f52a03 --- /dev/null +++ b/apps/desktop/electron/hud-url.test.ts @@ -0,0 +1,45 @@ +import assert from 'node:assert/strict' + +import { test } from 'vitest' + +import { buildHudWindowUrl } from './hud-url' + +test('buildHudWindowUrl puts win=hud before the hash route (dev server)', () => { + const url = buildHudWindowUrl('abc123', { devServer: 'http://localhost:5173' }) + + assert.equal(url, 'http://localhost:5173/?win=hud#/abc123') + assert.ok(url.indexOf('?win=hud') < url.indexOf('#')) +}) + +test('buildHudWindowUrl carries the handoff profile in the query before the hash', () => { + const url = buildHudWindowUrl('sess-1', { devServer: 'http://localhost:5173', profile: 'work' }) + + assert.equal(url, 'http://localhost:5173/?win=hud&profile=work#/sess-1') + assert.ok(url.indexOf('profile=work') < url.indexOf('#')) +}) + +test('buildHudWindowUrl encodes the profile and the session id', () => { + const url = buildHudWindowUrl('a b/c', { devServer: 'http://localhost:5173', profile: 'my profile' }) + + assert.equal(url, 'http://localhost:5173/?win=hud&profile=my%20profile#/a%20b%2Fc') +}) + +test('buildHudWindowUrl avoids a double slash when the dev server has a trailing slash', () => { + const url = buildHudWindowUrl('abc', { devServer: 'http://localhost:5173/', profile: 'work' }) + + assert.equal(url, 'http://localhost:5173/?win=hud&profile=work#/abc') +}) + +test('buildHudWindowUrl omits an empty profile — no override, legacy boot', () => { + assert.equal( + buildHudWindowUrl('abc', { devServer: 'http://localhost:5173', profile: ' ' }), + 'http://localhost:5173/?win=hud#/abc' + ) + assert.equal(buildHudWindowUrl(null, { devServer: 'http://localhost:5173' }), 'http://localhost:5173/?win=hud#/') +}) + +test('buildHudWindowUrl builds a packaged file URL with the flags before the hash', () => { + const url = buildHudWindowUrl('abc', { profile: 'coder', rendererIndexPath: '/opt/app/index.html' }) + + assert.match(url, /^file:\/\/.*index\.html\?win=hud&profile=coder#\/abc$/) +}) diff --git a/apps/desktop/electron/hud-url.ts b/apps/desktop/electron/hud-url.ts new file mode 100644 index 0000000000000..6626f71e96493 --- /dev/null +++ b/apps/desktop/electron/hud-url.ts @@ -0,0 +1,39 @@ +// HUD mode's renderer URL. The pure, Electron-free piece lives here so it can +// be unit-tested (same split as session-windows.ts, and for the same reason: +// the contract below is invisible until it breaks at runtime). + +import { pathToFileURL } from 'node:url' + +/** + * Build the renderer URL for the HUD window. + * + * Same query-before-hash contract as `buildSessionWindowUrl`: `?win=hud` and + * the optional `profile=` MUST sit in the search string before the '#', or + * HashRouter swallows them as part of the route. + * + * The profile is what the HUD renderer adopts at boot. Session ids are scoped + * per profile, so without it a HUD opened on a non-primary conversation + * resolves the id against the primary backend, misses, and falls back to that + * backend's last session (#82285). Absent/blank means no override — ordinary + * single-profile boots are unchanged. + */ +export function buildHudWindowUrl( + sessionId: null | string | undefined, + { + devServer, + profile, + rendererIndexPath + }: { devServer?: null | string; profile?: null | string; rendererIndexPath?: string } = {} +): string { + const profileKey = typeof profile === 'string' ? profile.trim() : '' + const query = `?win=hud${profileKey ? `&profile=${encodeURIComponent(profileKey)}` : ''}` + const route = sessionId ? `#/${encodeURIComponent(sessionId)}` : '#/' + + if (devServer) { + const base = devServer.endsWith('/') ? devServer.slice(0, -1) : devServer + + return `${base}/${query}${route}` + } + + return `${pathToFileURL(rendererIndexPath!).toString()}${query}${route}` +} diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 5e84f722ce509..9f8d8e6eb7e8a 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -135,6 +135,7 @@ import { TEXT_PREVIEW_SOURCE_MAX_BYTES } from './hardening' import { cursorPointInWindow } from './hud-cursor' +import { buildHudWindowUrl } from './hud-url' import { createLinkTitleWindow, guardLinkTitleSession, readLinkTitleWindowTitle } from './link-title-window' import { ensureMainWindow } from './main-window-lifecycle' import { @@ -9059,6 +9060,12 @@ let hudRestoreMainWindow = false // the id and hands it over in the close broadcast. let hudSessionId = null +// The profile the live HUD renderer booted against (rides hudUrl's query +// string). A renderer adopts its backend once at boot, so a retarget onto a +// session from a DIFFERENT profile cannot be a same-window `goto` — the HUD +// must be respawned against the new profile's backend (see openHudWindow). +let hudProfile = null + // A wide, short bar parked near the bottom of the active display — the shape // of a game chat frame, and where one belongs. Defaults only: once the user // moves or resizes the HUD, hud-state.json wins (same pattern as the main @@ -9193,15 +9200,17 @@ function hudBounds() { } } -function hudUrl(sessionId) { - const query = '?win=hud' - const route = sessionId ? `#/${encodeURIComponent(sessionId)}` : '#/' - - if (DEV_SERVER) { - return `${DEV_SERVER.endsWith('/') ? DEV_SERVER.slice(0, -1) : DEV_SERVER}/${query}${route}` - } - - return `${pathToFileURL(resolveRendererIndex()).toString()}${query}${route}` +function hudUrl(sessionId, profile) { + // The profile rides the query string next to `win=hud` (BEFORE the '#', so + // HashRouter never sees it). The HUD renderer's gateway boot reads it and + // adopts that backend instead of the primary — without it, a HUD opened on a + // non-primary profile's conversation resolves the session id against the + // wrong backend and falls back to the default profile's last session. + return buildHudWindowUrl(sessionId, { + devServer: DEV_SERVER, + profile, + rendererIndexPath: DEV_SERVER ? undefined : resolveRendererIndex() + }) } // Tell every window whether the HUD is up, so a toggle in any of them reads @@ -9218,7 +9227,7 @@ function broadcastHudState(open) { } } -function spawnHudWindow(sessionId) { +function spawnHudWindow(sessionId, profile) { const win = new BrowserWindow({ ...hudBounds(), minWidth: 380, @@ -9301,7 +9310,7 @@ function spawnHudWindow(sessionId) { broadcastHudState(false) }) - loadWindowUrl(win, hudUrl(sessionId), 'HUD') + loadWindowUrl(win, hudUrl(sessionId, profile), 'HUD') return win } @@ -9319,8 +9328,28 @@ function restoreMainWindowFromHud() { } } -function openHudWindow(sessionId) { +function openHudWindow(sessionId, profile) { + const profileKey = typeof profile === 'string' && profile.trim() ? profile.trim() : null + if (hudWindow && !hudWindow.isDestroyed()) { + // Pointed at another PROFILE: the live renderer is bound to the old + // profile's backend, and a renderer adopts its backend exactly once at + // boot — an in-place goto would resolve the id against the wrong backend + // (the #82285 fallback). Respawn against the right one. + if (profileKey && hudProfile !== profileKey) { + const win = hudWindow + hudWindow = null + win.removeAllListeners('closed') + win.destroy() + + hudSessionId = sessionId || null + hudProfile = profileKey + hudWindow = spawnHudWindow(sessionId, profileKey) + broadcastHudState(true) + + return hudWindow + } + // Already up, but pointed somewhere else — switch it rather than just // raising it. Asking for HUD mode from another tab means "put THIS // conversation in the HUD", and a plain focus leaves the wrong one there. @@ -9339,7 +9368,8 @@ function openHudWindow(sessionId) { hudRestoreMainWindow = Boolean(mainWindow && !mainWindow.isDestroyed() && mainWindow.isVisible()) hudSessionId = sessionId || null - hudWindow = spawnHudWindow(sessionId) + hudProfile = profileKey + hudWindow = spawnHudWindow(sessionId, profileKey) broadcastHudState(true) return hudWindow @@ -10035,7 +10065,10 @@ ipcMain.on('hermes:pet-overlay:control', (_event, payload) => { // --- HUD mode (chrome-free floating chat) ----------------------------------- ipcMain.handle('hermes:hud:open', async (_event, request) => { - openHudWindow(typeof request?.sessionId === 'string' ? request.sessionId : null) + openHudWindow( + typeof request?.sessionId === 'string' ? request.sessionId : null, + typeof request?.profile === 'string' ? request.profile : null + ) return { ok: true } }) diff --git a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts index d1c926afc1aba..f844b513a6587 100644 --- a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts +++ b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts @@ -39,6 +39,7 @@ import { setSessionsLoading } from '@/store/session' import { $attentionSessionIds, $workingSessionIds, resetTileRuntimeBindings } from '@/store/session-states' +import { windowProfileOverride } from '@/store/windows' import type { RpcEvent } from '@/types/hermes' import { stashGatewaySurvivor, survivorIsStale, takeGatewaySurvivor } from './gateway-hmr-survivor' @@ -254,15 +255,24 @@ export function useGatewayBoot({ // resumes are no-op swaps and reconnects target the right backend. // Best-effort: a missing preference means "default". Shared by boot + soft // switch. + // + // Helper windows (the HUD) can carry an explicit profile override in their + // URL: the HUD is opened ON a conversation, and when that conversation + // belongs to a non-primary profile, adopting the primary here resolves the + // session id against the wrong backend — the HUD then falls back to the + // default profile's last session (#82285). The override wins over the + // stored preference; absent, behavior is unchanged. async function adoptPrimaryProfile() { + const override = windowProfileOverride() + try { - const pref = await desktop.profile?.get?.() - const profileKey = (pref?.profile ?? '').trim() || 'default' - $activeGatewayProfile.set(profileKey) - setPrimaryGateway(gateway, profileKey) - void ensureGatewayForProfile(profileKey) + const profileKey = override ?? ((await desktop.profile?.get?.())?.profile ?? '') + const key = normalizeProfileKey(profileKey) + $activeGatewayProfile.set(key) + setPrimaryGateway(gateway, key) + void ensureGatewayForProfile(key) } catch { - $activeGatewayProfile.set('default') + $activeGatewayProfile.set(normalizeProfileKey(override)) } } @@ -298,7 +308,9 @@ export function useGatewayBoot({ gateway.close() closeSecondaryGateways() - const conn = await desktop.getConnection() + // Same override rule as boot(): a profile-pinned helper window stays + // on its pinned profile's backend across a soft switch. + const conn = await desktop.getConnection(windowProfileOverride() ?? undefined) if (cancelled) { return @@ -488,7 +500,10 @@ export function useGatewayBoot({ async function boot() { try { - const conn = await desktop.getConnection() + // A profile-pinned helper window (the HUD) dials its target profile's + // backend directly — ensureBackend spawns/reuses it from the pool. + // Everything else keeps dialing the primary. + const conn = await desktop.getConnection(windowProfileOverride() ?? undefined) if (cancelled) { return diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index c03c37b1df0e4..6b0fc5dc05e19 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -67,7 +67,7 @@ declare global { // bar — so it mounts the real composer rather than a lookalike. Main // owns the window; `onChanged` keeps every window's toggle truthful. hud?: { - open: (request?: { sessionId?: null | string }) => Promise<{ ok: boolean }> + open: (request?: { sessionId?: null | string; profile?: null | string }) => Promise<{ ok: boolean }> close: () => Promise<{ ok: boolean }> setIgnoreMouse: (ignore: boolean) => void moveBy: (delta: { x: number; y: number }) => void diff --git a/apps/desktop/src/store/hud.test.ts b/apps/desktop/src/store/hud.test.ts new file mode 100644 index 0000000000000..8cd85c01573ab --- /dev/null +++ b/apps/desktop/src/store/hud.test.ts @@ -0,0 +1,81 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { $activeGatewayProfile } from '@/store/profile' +import { $sessions } from '@/store/session' +import type { SessionInfo } from '@/types/hermes' + +import { $hudActive, $hudSession, openHud } from './hud' + +const desktopWindow = window as unknown as { hermesDesktop?: Window['hermesDesktop'] } +const initialHermesDesktop = desktopWindow.hermesDesktop + +const open = vi.fn().mockResolvedValue({ ok: true }) + +function installBridge() { + desktopWindow.hermesDesktop = { + hud: { open } + } as unknown as Window['hermesDesktop'] +} + +function session(overrides: Partial): SessionInfo { + return { id: 's', title: '', created_at: '', updated_at: '', ...overrides } as SessionInfo +} + +beforeEach(() => { + open.mockClear() + installBridge() + $hudActive.set(false) + $hudSession.set(null) + $sessions.set([]) + $activeGatewayProfile.set('default') +}) + +afterEach(() => { + if (initialHermesDesktop) { + desktopWindow.hermesDesktop = initialHermesDesktop + } else { + delete desktopWindow.hermesDesktop + } +}) + +describe('openHud profile targeting (#82285)', () => { + it('carries the session-stamped profile when the target belongs to another profile', () => { + $sessions.set([session({ id: 'abc', profile: 'work' })]) + $activeGatewayProfile.set('default') + + openHud('abc') + + expect(open).toHaveBeenCalledWith({ sessionId: 'abc', profile: 'work' }) + }) + + it('falls back to the active gateway profile for an unstamped session', () => { + $sessions.set([session({ id: 'abc', profile: '' })]) + $activeGatewayProfile.set('work') + + openHud('abc') + + expect(open).toHaveBeenCalledWith({ sessionId: 'abc', profile: 'work' }) + }) + + it('uses the active gateway profile when opening without a session', () => { + $activeGatewayProfile.set('research') + + openHud() + + expect(open).toHaveBeenCalledWith({ sessionId: null, profile: 'research' }) + }) + + it('normalizes to default for single-profile users', () => { + openHud() + + expect(open).toHaveBeenCalledWith({ sessionId: null, profile: 'default' }) + }) + + it('uses the active profile when the target session is not in the cache', () => { + $activeGatewayProfile.set('work') + + openHud('unknown-session') + + expect(open).toHaveBeenCalledWith({ sessionId: 'unknown-session', profile: 'work' }) + }) +}) diff --git a/apps/desktop/src/store/hud.ts b/apps/desktop/src/store/hud.ts index 6135081508e7d..2f04dafdb7182 100644 --- a/apps/desktop/src/store/hud.ts +++ b/apps/desktop/src/store/hud.ts @@ -16,6 +16,8 @@ import { atom } from 'nanostores' import { requestComposerDraftSync } from '@/store/composer' +import { $activeGatewayProfile, normalizeProfileKey } from '@/store/profile' +import { $sessions, rememberedSessionProfile } from '@/store/session' import { isHudWindow } from '@/store/windows' /** Whether a HUD window is currently up. In the HUD's own renderer this is @@ -53,9 +55,20 @@ export function openHud(sessionId?: null | string): void { // a cross-window storage event that lands after it has already painted. requestComposerDraftSync('flush') + // Which backend the HUD must boot against. The HUD is a full renderer that + // adopts the PRIMARY backend's profile by default, so handing it a session + // from a non-primary profile without saying so resolves the id against the + // wrong backend — the lookup misses and the HUD falls back to the default + // profile's last session (#82285). Same ladder the remembered-navigation key + // uses: the session's stamped owner wins, and a fresh/unstamped/uncached + // target inherits the profile the user is looking at. + const profile = normalizeProfileKey( + rememberedSessionProfile($sessions.get(), sessionId ?? null, $activeGatewayProfile.get()) + ) + $hudActive.set(true) $hudSession.set(sessionId ?? null) - void api.open({ sessionId: sessionId ?? null }) + void api.open({ sessionId: sessionId ?? null, profile }) } /** Leave HUD mode. Callable from either window — main closes the child, the diff --git a/apps/desktop/src/store/windows.ts b/apps/desktop/src/store/windows.ts index 75d48097748a1..7f23c1cdea7cb 100644 --- a/apps/desktop/src/store/windows.ts +++ b/apps/desktop/src/store/windows.ts @@ -83,6 +83,22 @@ export function isWatchWindow(): boolean { // keystroke into N prompts, and a HUD is the last place to paint onboarding. export const isAuxiliaryWindow = (): boolean => isSecondaryWindow() || isHudWindow() +// The profile a helper window (the HUD) was asked to boot against, carried in +// the query string by the main process (see hudUrl). The HUD is a full app +// renderer that otherwise adopts the PRIMARY backend's profile — wrong the +// moment the conversation it was opened on belongs to another profile +// (#82285). Empty/absent means "no override": boot adopts the primary as +// before, so ordinary windows and single-profile users are untouched. +// Not cached: it is read a handful of times per boot and staying cache-free +// keeps it honest under test. +export function windowProfileOverride(): null | string { + try { + return new URLSearchParams(window.location.search).get('profile')?.trim() || null + } catch { + return null + } +} + // True when running inside the Electron desktop shell (the preload bridge is // present). The "open in new window" affordance is desktop-only. export function canOpenSessionWindow(): boolean {