diff --git a/apps/desktop/src/app/contrib/hooks/remembered-session.test.ts b/apps/desktop/src/app/contrib/hooks/remembered-session.test.ts new file mode 100644 index 0000000000..47e3317d08 --- /dev/null +++ b/apps/desktop/src/app/contrib/hooks/remembered-session.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' + +import type { SessionInfo } from '@/types/hermes' + +import { resolveRememberedSessionId } from './remembered-session' + +const session = (overrides: Partial): SessionInfo => + ({ + ended_at: null, + id: 'session', + input_tokens: 0, + is_active: false, + last_active: 0, + message_count: 0, + model: null, + output_tokens: 0, + preview: null, + source: 'tui', + started_at: 0, + title: null, + tool_call_count: 0, + ...overrides + }) as SessionInfo + +describe('resolveRememberedSessionId', () => { + it('repairs a remembered delegate child to its parent', async () => { + await expect( + resolveRememberedSessionId('child', async () => + session({ id: 'child', parent_session_id: 'parent', source: 'subagent' }) + ) + ).resolves.toBe('parent') + }) + + it('clears an orphaned delegate child instead of reopening it', async () => { + await expect( + resolveRememberedSessionId('child', async () => session({ id: 'child', source: 'subagent' })) + ).resolves.toBeNull() + }) + + it('keeps normal sessions', async () => { + await expect( + resolveRememberedSessionId('normal', async () => session({ id: 'normal', source: 'tui' })) + ).resolves.toBe('normal') + }) + + it('keeps /branch children: parenthood is not the discriminator, source is', async () => { + await expect( + resolveRememberedSessionId('branch', async () => + session({ id: 'branch', parent_session_id: 'parent', source: 'tui' }) + ) + ).resolves.toBe('branch') + }) +}) diff --git a/apps/desktop/src/app/contrib/hooks/remembered-session.ts b/apps/desktop/src/app/contrib/hooks/remembered-session.ts new file mode 100644 index 0000000000..3b4a2ac7f6 --- /dev/null +++ b/apps/desktop/src/app/contrib/hooks/remembered-session.ts @@ -0,0 +1,24 @@ +import type { SessionInfo } from '@/types/hermes' + +/** + * Returns the session safe to remember/restore: a delegate child + * (`source === 'subagent'`) is replaced by its parent, everything else keeps + * its own id. Delegate children are deliberately omitted from the sidebar list + * (`_LISTABLE_CHILD_SQL`), so remembering one leaves the next cold start split + * between the highlighted parent and the invisible child the chat area shows + * (#56983). `/branch` children also carry `parent_session_id` but ARE + * user-facing — `source`, not parenthood, is the discriminator. + * + * Accepts metadata fetched directly by id (`getSession`): the by-id endpoint + * serves delegate children even though the list does not, so this repairs a + * stale child id without the sidebar list. `null` means "do not remember" + * (an orphaned delegate child whose parent is gone). + */ +export async function resolveRememberedSessionId( + id: string, + getSession: (id: string) => Promise +): Promise { + const session = await getSession(id) + + return session.source === 'subagent' ? session.parent_session_id ?? null : session.id +} diff --git a/apps/desktop/src/app/contrib/hooks/use-desktop-integrations.test.tsx b/apps/desktop/src/app/contrib/hooks/use-desktop-integrations.test.tsx index 9b27c09b14..7d08c86172 100644 --- a/apps/desktop/src/app/contrib/hooks/use-desktop-integrations.test.tsx +++ b/apps/desktop/src/app/contrib/hooks/use-desktop-integrations.test.tsx @@ -82,7 +82,12 @@ describe('useDesktopIntegrations', () => { onDeepLink: vi.fn(), signalDeepLinkReady: vi.fn(), onClosePreviewRequested: vi.fn(), - onOpenFolderRequested: vi.fn() + onOpenFolderRequested: vi.fn(), + // getSession() rides hermesDesktop.api; tests that exercise the + // remembered-session resolution stub this per-test. + api: vi.fn(async () => { + throw new Error('no api stub for this test') + }) } as unknown as Window['hermesDesktop'] }) @@ -265,6 +270,55 @@ describe('useDesktopIntegrations', () => { }) }) + describe('delegate subagent sessions', () => { + const stubGetSession = (row: Partial) => { + vi.mocked(desktopWindow.hermesDesktop!.api as ReturnType).mockImplementation( + async (request: { path?: string }) => { + if (request.path?.startsWith('/api/sessions/')) { + return session({ profile: 'default', ...row }) + } + + throw new Error(`unexpected api call: ${request.path}`) + } + ) + } + + it('repairs a remembered delegate child to its parent on restore', async () => { + // Written by an older build (or a list slice that served the child). + window.localStorage.setItem('hermes.desktop.lastSessionId.profile.default', 'delegate-child') + stubGetSession({ id: 'delegate-child', parent_session_id: 'parent-session', source: 'subagent' }) + + const sessions = [session({ id: 'parent-session', profile: 'default' })] + + render({ profileReady: true, sessions }) + + await waitFor(() => expect(navigate).toHaveBeenCalledWith('/parent-session', { replace: true })) + expect(window.localStorage.getItem('hermes.desktop.lastSessionId.profile.default')).toBe('parent-session') + }) + + it('remembers the parent, never the delegate child, when routed to one', () => { + // A messaging slice can serve the child row, so list membership alone + // must not make it rememberable. + const sessions = [ + session({ id: 'delegate-child', parent_session_id: 'parent-session', profile: 'default', source: 'subagent' }) + ] + + render({ locationPathname: '/delegate-child', profileReady: true, routedSessionId: 'delegate-child', sessions }) + + expect(window.localStorage.getItem('hermes.desktop.lastSessionId.profile.default')).toBe('parent-session') + expect(window.localStorage.getItem('hermes.desktop.lastRoute.profile.default')).toBe('/parent-session') + }) + + it('keeps remembering a /branch child: source, not parenthood, is the discriminator', () => { + const sessions = [session({ id: 'branch-child', parent_session_id: 'parent-session', profile: 'default', source: 'tui' })] + + render({ locationPathname: '/branch-child', profileReady: true, routedSessionId: 'branch-child', sessions }) + + expect(window.localStorage.getItem('hermes.desktop.lastSessionId.profile.default')).toBe('branch-child') + expect(window.localStorage.getItem('hermes.desktop.lastRoute.profile.default')).toBe('/branch-child') + }) + }) + describe('ownership validation', () => { it('refuses to restore a session route owned by another profile', () => { window.localStorage.setItem('hermes.desktop.lastRoute.profile.default', '/ai-session') diff --git a/apps/desktop/src/app/contrib/hooks/use-desktop-integrations.ts b/apps/desktop/src/app/contrib/hooks/use-desktop-integrations.ts index 56c1b813e7..e96b85b203 100644 --- a/apps/desktop/src/app/contrib/hooks/use-desktop-integrations.ts +++ b/apps/desktop/src/app/contrib/hooks/use-desktop-integrations.ts @@ -7,6 +7,7 @@ import { commandFocusedPreview } from '@/app/chat/right-rail/preview-nav' import { openSession } from '@/app/open-session' import { openConnectionDoneLink } from '@/components/assistant-ui/connector-tool' import { $diskPluginsScanPending } from '@/contrib/runtime-loader' +import { getSession } from '@/hermes' import { resolveDeepLinkAction } from '@/lib/deeplink-routes' import { pathFromHermesDeepLink, resolveHermesOpenPath } from '@/lib/hermes-open-target' import { storedSessionIdForNotification } from '@/lib/session-ids' @@ -28,6 +29,7 @@ import { getRememberedSessionId, resolveComposerSessionKey, sessionBelongsToProfile, + sessionMatchesStoredId, setRememberedRoute, setRememberedSessionId } from '@/store/session' @@ -41,7 +43,12 @@ import type { SessionInfo } from '@/types/hermes' import { requestComposerFocus, requestComposerInsert } from '../../chat/composer/focus' import { appViewForPath, isOverlayView, NEW_CHAT_ROUTE, routeSessionId, sessionRoute } from '../../routes' -type RememberedSession = Pick +import { resolveRememberedSessionId } from './remembered-session' + +type RememberedSession = Pick< + SessionInfo, + '_lineage_root_id' | 'id' | 'parent_session_id' | 'profile' | 'source' +> interface DesktopIntegrationsParams { activeProfile: string @@ -163,11 +170,22 @@ export function useDesktopIntegrations({ restoredRef.current = true + // A delegate child (source='subagent') is never a restorable + // destination: it is invisible in the sidebar, so resuming one leaves + // the app split between the highlighted parent and the child the chat + // area shows (#56983). `/branch` children also carry + // parent_session_id but ARE user-facing — source, not parenthood, is + // the discriminator. A listed row carries its source, so the guard is + // synchronous there; an unlisted id resolves by id below. + const rowFor = (id: string) => sessions.find(session => sessionMatchesStoredId(session, id)) + const restorableRouteSession = + routeSession && rowFor(routeSession)?.source !== 'subagent' ? routeSession : null + if ( route && route !== NEW_CHAT_ROUTE && !isOverlayView(appViewForPath(route)) && - (!routeSession || sessionBelongsToProfile(sessions, routeSession, activeProfile)) + (!routeSession || (restorableRouteSession && sessionBelongsToProfile(sessions, routeSession, activeProfile))) ) { // The user may have started typing on the fresh chat while the // backend was still coming up; the composer moves that draft onto @@ -184,16 +202,37 @@ export function useDesktopIntegrations({ setRememberedRoute(null, activeProfile) } - if (last && sessionBelongsToProfile(sessions, last, activeProfile)) { - announceNewSessionDraftKey(resolveComposerSessionKey(last, sessions)) - navigate(sessionRoute(last), { replace: true }) + if (last) { + // Fast path: a listed, non-delegate row restores directly, exactly + // as before — no by-id fetch on the common cold start. + if (rowFor(last)?.source !== 'subagent' && sessionBelongsToProfile(sessions, last, activeProfile)) { + announceNewSessionDraftKey(resolveComposerSessionKey(last, sessions)) + navigate(sessionRoute(last), { replace: true }) + + return + } + + // Unlisted (or a delegate row that reached a list slice): resolve + // the id directly — the by-id endpoint serves delegate children the + // list omits. A delegate child repairs to its parent, an orphan or + // foreign-profile id clears, and a fetch failure keeps the + // remembered value for the next launch instead of discarding it. + void resolveRememberedSessionId(last, getSession) + .then(remembered => { + if (!remembered || !sessionBelongsToProfile(sessions, remembered, activeProfile)) { + setRememberedSessionId(null, activeProfile) + + return + } + + announceNewSessionDraftKey(resolveComposerSessionKey(remembered, sessions)) + setRememberedSessionId(remembered, activeProfile) + navigate(sessionRoute(remembered), { replace: true }) + }) + .catch(() => undefined) return } - - if (last) { - setRememberedSessionId(null, activeProfile) - } } else { restoredRef.current = true } @@ -204,8 +243,22 @@ export function useDesktopIntegrations({ // Session-shaped routes require an explicit matching owner; unresolved and // wrong-profile rows must not replace known-safe navigation. if (routedSessionId && sessionBelongsToProfile(sessions, routedSessionId, activeProfile)) { - setRememberedSessionId(routedSessionId, activeProfile) - setRememberedRoute(locationPathname, activeProfile) + // A delegate child (source='subagent') is never itself a rememberable + // destination: it is invisible in the sidebar, so a restart would resume + // an orphan chat while the sidebar highlights its parent (#56983). + // `/branch` children also carry parent_session_id but ARE user-facing — + // source, not parenthood, is the discriminator. + const routedRow = sessions.find(session => sessionMatchesStoredId(session, routedSessionId)) + const rememberedSessionId = + routedRow?.source === 'subagent' ? routedRow.parent_session_id || null : routedSessionId + + if (rememberedSessionId) { + setRememberedSessionId(rememberedSessionId, activeProfile) + setRememberedRoute( + rememberedSessionId === routedSessionId ? locationPathname : sessionRoute(rememberedSessionId), + activeProfile + ) + } } else if (!routedSessionId && !isOverlayView(appViewForPath(locationPathname))) { setRememberedRoute(locationPathname, activeProfile) }