diff --git a/apps/desktop/src/plugins/hermes-bots/plugin.js b/apps/desktop/src/plugins/hermes-bots/plugin.js index 636b7bd9dc..b1cc97b9a0 100644 --- a/apps/desktop/src/plugins/hermes-bots/plugin.js +++ b/apps/desktop/src/plugins/hermes-bots/plugin.js @@ -7289,14 +7289,23 @@ function useProfileSessions(botName, gatewayGeneration) { }) } -async function openProfileSession(botName, storedId, gatewayGeneration) { +async function openProfileSession(botName, session, gatewayGeneration) { const profile = String(botName || '') - const id = String(storedId || '') + const id = String(session?.id || '') if (!NAME_RE.test(profile) || !id || gatewayGeneration !== $sessionsGatewayGeneration.get()) return if (typeof host.openSession !== 'function') { throw new Error('This Hermes Desktop version cannot open stored sessions') } - await host.openSession(id, { profile }) + + // Same hydration contract as canonical Bot Chats (#89206): a bare open can + // focus a main surface whose runtime/transcript silently vanished, leaving a + // blank pane while the row preview still shows the conversation. Waiting on + // hydration lets the SDK issue the explicit resume when the surface is stale. + const hasAuthoritativeCount = + typeof session?.message_count === 'number' && Number.isFinite(session.message_count) + const expectHistory = hasAuthoritativeCount ? session.message_count > 0 : Boolean(session?.preview) + + await host.openSession(id, { profile, awaitHydration: true, expectHistory }) if (gatewayGeneration !== $sessionsGatewayGeneration.get()) return $botSelectedSessions.set({ ...$botSelectedSessions.get(), [profile]: id }) } @@ -7305,7 +7314,7 @@ function ProfileSessionRow({ session, botName, active, gatewayGeneration }) { return jsxs('button', { type: 'button', 'aria-current': active ? 'page' : undefined, - onClick: () => void openProfileSession(botName, session.id, gatewayGeneration).catch(err => host.notifyError(err, 'Could not open session')), + onClick: () => void openProfileSession(botName, session, gatewayGeneration).catch(err => host.notifyError(err, 'Could not open session')), className: cn( 'flex w-full flex-col gap-0.5 overflow-hidden rounded-md px-2 py-1.5 text-left transition-colors', 'hover:bg-(--chrome-action-hover)', diff --git a/apps/desktop/src/plugins/hermes-bots/tests/session-workspace.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/session-workspace.test.mjs index e3a91f80de..8d2d1c03b8 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/session-workspace.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/session-workspace.test.mjs @@ -88,15 +88,17 @@ test('sessions workspace: filtering searches title, preview, and source without test('sessions workspace: opening a stored row uses profile-aware navigation and records selection', async () => { const runtime = load({ profile: 'default' }) - await runtime.__sessions.openProfileSession('ops', 'stored-123', 0) - assert.deepEqual(plain(runtime.calls), [['openSession', 'stored-123', { profile: 'ops' }]]) + await runtime.__sessions.openProfileSession('ops', { id: 'stored-123', message_count: 4 }, 0) + assert.deepEqual(plain(runtime.calls), [ + ['openSession', 'stored-123', { profile: 'ops', awaitHydration: true, expectHistory: true }] + ]) assert.equal(runtime.__sessions.$botSelectedSessions.get().ops, 'stored-123') }) test('sessions workspace: malformed profile or session input is a no-op', async () => { const runtime = load() - await runtime.__sessions.openProfileSession('../ops', 'stored-123', 0) - await runtime.__sessions.openProfileSession('ops', '', 0) + await runtime.__sessions.openProfileSession('../ops', { id: 'stored-123' }, 0) + await runtime.__sessions.openProfileSession('ops', { id: '' }, 0) assert.deepEqual(runtime.calls, []) }) @@ -107,17 +109,25 @@ test('sessions workspace: a gateway lifecycle change clears selection and reject assert.equal(runtime.__sessions.$sessionsGatewayGeneration.get(), 1) assert.deepEqual(plain(runtime.__sessions.$botSelectedSessions.get()), {}) - await runtime.__sessions.openProfileSession('ops', 'stored-123', 0) + await runtime.__sessions.openProfileSession('ops', { id: 'stored-123' }, 0) assert.deepEqual(runtime.calls, []) }) +test('sessions workspace: an empty session with no preview does not demand history', async () => { + const runtime = load() + await runtime.__sessions.openProfileSession('ops', { id: 'stored-empty', message_count: 0 }, 0) + assert.deepEqual(plain(runtime.calls), [ + ['openSession', 'stored-empty', { profile: 'ops', awaitHydration: true, expectHistory: false }] + ]) +}) + test('sessions workspace: an in-flight open cannot restore selection after gateway replacement', async () => { let runtime runtime = load({ openSession: async () => runtime.__sessions.handleSessionsGatewayTransition() }) - await runtime.__sessions.openProfileSession('ops', 'stored-123', 0) + await runtime.__sessions.openProfileSession('ops', { id: 'stored-123', preview: 'last line' }, 0) assert.equal(runtime.calls.length, 1) assert.deepEqual(plain(runtime.__sessions.$botSelectedSessions.get()), {}) }) diff --git a/apps/desktop/src/sdk/index.ts b/apps/desktop/src/sdk/index.ts index a0569f2b43..a84afe5509 100644 --- a/apps/desktop/src/sdk/index.ts +++ b/apps/desktop/src/sdk/index.ts @@ -486,13 +486,6 @@ export const host = { $gatewaySwapTarget.set(targetProfile) } - const mainMessages = $messages.get() - - const needsExplicitResume = - options.awaitHydration && - $selectedStoredSessionId.get() === storedSessionId && - (!$activeSessionId.get() || (expectHistory && !mainMessages.length)) - try { openSession( storedSessionId, @@ -508,10 +501,22 @@ export const host = { options.intent ?? 'in-place' ) - if (needsExplicitResume) { - // Re-selecting a routed Bot Chat normally focuses the existing main - // surface without changing the URL. If that surface lost its runtime or - // transcript, the route effect otherwise gets no signal to re-resume it. + // Judge the main surface AFTER the open: on a cold start the persisted + // route can already point at this session while selection has not + // settled, so a pre-open "already selected" precondition skips the + // resume exactly when it is needed (#89206 — blank Bot Chat with the + // roster preview intact). The surface is healthy only when this stored + // session is selected, a runtime is bound, and the expected transcript + // is present; anything less gets an explicit sequenced resume request. + // The route-resume effect only honors the request while the route + // points at this session, and consumes it alongside any resume the + // navigation itself triggers, so a redundant request is a no-op. + const surfaceHealthy = + $selectedStoredSessionId.get() === storedSessionId && + Boolean($activeSessionId.get()) && + (!expectHistory || $messages.get().length > 0) + + if (options.awaitHydration && !surfaceHealthy) { requestSessionResume(storedSessionId) } diff --git a/apps/desktop/src/sdk/profile-routing.test.ts b/apps/desktop/src/sdk/profile-routing.test.ts index b172d4bc78..c3c37eed02 100644 --- a/apps/desktop/src/sdk/profile-routing.test.ts +++ b/apps/desktop/src/sdk/profile-routing.test.ts @@ -345,6 +345,39 @@ describe('profile-aware plugin session opens', () => { expect($gatewaySwapTarget.get()).toBeNull() }) + it('requests an explicit resume on a cold open where selection has not settled (#89206)', async () => { + vi.mocked(ensureGatewayProfile).mockImplementationOnce(async (target: null | string | undefined) => { + $activeGatewayProfile.set(target || 'default') + }) + + // Cold-start shape from the field: the persisted route already points at + // the bot's stored session, but no selection, no runtime, no transcript. + setMockAtom($selectedStoredSessionId, null) + setMockAtom($activeSessionId, null) + setMockAtom($messages, []) + + const opening = host.openSession('cold-bot-chat', { + profile: 'hyoseob', + awaitHydration: true, + expectHistory: true, + hydrationTimeoutMs: 1_000 + }) + + await Promise.resolve() + await Promise.resolve() + + // The old pre-open "already selected" precondition skipped this request, + // stranding the pane blank until timeout. It must fire now. + expect(requestSessionResume).toHaveBeenCalledWith('cold-bot-chat') + + setMockAtom($selectedStoredSessionId, 'cold-bot-chat') + setMockAtom($activeSessionId, 'runtime-cold') + setMockAtom($messages, [{ id: 'history-cold', parts: [], role: 'assistant' }] as never) + + await opening + expect($gatewaySwapTarget.get()).toBeNull() + }) + it('requests a fresh resume when the same main Bot Chat lost its transcript', async () => { $activeGatewayProfile.set('hyoseob') setMockAtom($selectedStoredSessionId, 'bot-chat')