diff --git a/apps/desktop/src/store/session-states.test.ts b/apps/desktop/src/store/session-states.test.ts index f62f74aef4..8402274970 100644 --- a/apps/desktop/src/store/session-states.test.ts +++ b/apps/desktop/src/store/session-states.test.ts @@ -2,11 +2,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { ClientSessionState } from '@/app/types' import { findGroupOfPane, group, split } from '@/components/pane-shell/tree/model' -import { $layoutTree } from '@/components/pane-shell/tree/store' +import { $layoutTree, noteActiveTreeGroup } from '@/components/pane-shell/tree/store' +import { $workspaceMode, setWorkspaceScope } from '@/components/pane-shell/workspace-scope' import { $activeGatewayProfile } from '@/store/profile' import { $activeSessionId, $connection, $selectedStoredSessionId, setSessions } from '@/store/session' import type { SessionTile } from '@/store/session-states' import { + $focusedStoredSessionId, $sessionStates, $sessionTiles, blankDraftTile, @@ -505,6 +507,76 @@ describe('boot-restore selection homing (⌘R tab persistence)', () => { }) }) +describe('$focusedStoredSessionId in Bot Mode (#96062)', () => { + afterEach(() => { + $layoutTree.set(null) + $selectedStoredSessionId.set(null) + setWorkspaceScope('sessions') + }) + + it('a Bots-pane click keeps the main-zone bot tile focused instead of collapsing to a null selection edge', () => { + // Bot chats open as TILES and never set $selectedStoredSessionId. Clicking + // a roster row moves the interaction tracker to the sidebar group, whose + // active pane is chrome ('hermes-bots:pane'), not a session tile. The old + // derivation then fell back to the null primary selection and published a + // NULL "focused session" edge — which the Bots plugin read as "the chat + // lost the center", releasing its open claim and re-asserting the Bots + // home over the still-visible chat (the reported "jumps to the list"). + setWorkspaceScope('bots', 'bot:b') + $selectedStoredSessionId.set(null) + $layoutTree.set( + split('row', [ + group(['sessions', 'hermes-bots:pane'], { active: 'hermes-bots:pane', id: 'grp-sessions' }), + group(['workspace', tilePane('chat-b')], { active: tilePane('chat-b'), id: 'grp-main' }) + ]) + ) + noteActiveTreeGroup('grp-sessions') + + expect($focusedStoredSessionId.get()).toBe('chat-b') + }) + + it('the main-zone tile also answers while the tracker sits on the workspace tab itself', () => { + setWorkspaceScope('bots', 'bot:b') + $selectedStoredSessionId.set(null) + $layoutTree.set(group(['workspace', tilePane('chat-b')], { active: tilePane('chat-b'), id: 'grp-main' })) + noteActiveTreeGroup('grp-main') + + expect($focusedStoredSessionId.get()).toBe('chat-b') + }) + + it('a closed bot chat (no tile in main) still falls back to the selection', () => { + setWorkspaceScope('bots', 'bot:b') + $selectedStoredSessionId.set(null) + $layoutTree.set( + split('row', [ + group(['sessions', 'hermes-bots:pane'], { active: 'hermes-bots:pane', id: 'grp-sessions' }), + group(['workspace'], { active: 'workspace', id: 'grp-main' }) + ]) + ) + noteActiveTreeGroup('grp-sessions') + + // No tile owns the main zone — the chat was closed — so the null edge is + // genuine and must still surface (that is what lets the Bots home return). + expect($focusedStoredSessionId.get()).toBeNull() + }) + + it('sessions mode keeps collapsing to the primary selection (derivation gated to Bot Mode)', () => { + $selectedStoredSessionId.set('primary-1') + $layoutTree.set( + split('row', [ + group(['sessions'], { active: 'sessions', id: 'grp-sessions' }), + group(['workspace', tilePane('stacked')], { active: tilePane('stacked'), id: 'grp-main' }) + ]) + ) + noteActiveTreeGroup('grp-sessions') + + // The main-zone tile must NOT answer here: in sessions mode the sidebar + // highlight follows the primary selection exactly as it always has. + expect($workspaceMode.get()).toBe('sessions') + expect($focusedStoredSessionId.get()).toBe('primary-1') + }) +}) + describe('focusedSessionNeedsRoute', () => { it('routes when the session is not on screen', () => { expect(focusedSessionNeedsRoute(null, false)).toBe(true) diff --git a/apps/desktop/src/store/session-states.ts b/apps/desktop/src/store/session-states.ts index 8f04088fb6..98143ffa8a 100644 --- a/apps/desktop/src/store/session-states.ts +++ b/apps/desktop/src/store/session-states.ts @@ -29,6 +29,7 @@ import { noteActiveTreeGroup, revealTreePane } from '@/components/pane-shell/tree/store' +import { $workspaceMode } from '@/components/pane-shell/workspace-scope' import type { WorkspaceMode } from '@/contrib/types' import { stableArray } from '@/lib/stable-array' import { readJson, writeJson } from '@/lib/storage' @@ -1590,11 +1591,36 @@ export function reopenLastClosedTile(): void { /** Stored id of the focused session (the interacted zone's tile, else the * primary's selection). Null on a fresh draft. */ export const $focusedStoredSessionId = computed( - [$activeTreeGroup, $layoutTree, $selectedStoredSessionId], - (groupId, tree, selected) => { + [$activeTreeGroup, $layoutTree, $selectedStoredSessionId, $workspaceMode], + (groupId, tree, selected, workspaceMode) => { const active = groupId && tree ? findGroup(tree, groupId)?.active : undefined - return active?.startsWith(TILE_PANE_PREFIX) ? active.slice(TILE_PANE_PREFIX.length) : selected + if (active?.startsWith(TILE_PANE_PREFIX)) { + return active.slice(TILE_PANE_PREFIX.length) + } + + // The interaction tracker can point at sidebar CHROME while a chat still + // holds the main zone's active tab — clicking a Bots-pane roster row moves + // it to the sidebar group, whose active pane ('hermes-bots:pane') is not a + // session tile. In sessions mode the primary selection answers, exactly as + // always. In Bot Mode that fallback alone publishes a NULL "focused" + // edge: bot chats open as TILES and never set $selectedStoredSessionId, + // so the selection is null while the chat is plainly on screen. The Bots + // plugin reads that null edge as "the chat lost the center", releases its + // open claim, and re-asserts the Bots home over the still-visible chat — + // the reported "clicking a bot chat jumps to the list" (#96062). Bot + // Mode's on-screen truth is the main zone's active TILE; only when the + // main zone holds no tile (chat closed) does the selection answer, so a + // genuine close still lets the home return. + if (workspaceMode === 'bots' && tree) { + const mainActive = findGroupOfPane(tree, 'workspace')?.active + + if (mainActive?.startsWith(TILE_PANE_PREFIX)) { + return mainActive.slice(TILE_PANE_PREFIX.length) + } + } + + return selected } )