Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions apps/desktop/electron/hud-url.test.ts
Original file line number Diff line number Diff line change
@@ -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$/)
})
39 changes: 39 additions & 0 deletions apps/desktop/electron/hud-url.ts
Original file line number Diff line number Diff line change
@@ -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}`
}
61 changes: 47 additions & 14 deletions apps/desktop/electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -9218,7 +9227,7 @@ function broadcastHudState(open) {
}
}

function spawnHudWindow(sessionId) {
function spawnHudWindow(sessionId, profile) {
const win = new BrowserWindow({
...hudBounds(),
minWidth: 380,
Expand Down Expand Up @@ -9301,7 +9310,7 @@ function spawnHudWindow(sessionId) {
broadcastHudState(false)
})

loadWindowUrl(win, hudUrl(sessionId), 'HUD')
loadWindowUrl(win, hudUrl(sessionId, profile), 'HUD')

return win
}
Expand All @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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 }
})
Expand Down
31 changes: 23 additions & 8 deletions apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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))
}
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/global.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
81 changes: 81 additions & 0 deletions apps/desktop/src/store/hud.test.ts
Original file line number Diff line number Diff line change
@@ -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>): 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' })
})
})
15 changes: 14 additions & 1 deletion apps/desktop/src/store/hud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading