fix(desktop): keep bot chat focused when clicking the Bots pane (#96062)

Clicking a bot row moved the layout interaction tracker to the sidebar
group, so $focusedStoredSessionId fell back to the primary selection —
which is null in Bot Mode, because bot chats open as tiles and never set
$selectedStoredSessionId. The Bots plugin reads that null 'focused'
edge as 'the chat lost the center', releases its open claim, and the
Bots home re-asserts over the still-visible chat: the UI jumps to the
list instead of staying in the chat.

$focusedStoredSessionId now answers from the main zone's active tile in
Bot Mode before falling back to the selection, so a chrome-sidebar
click no longer fabricates a null edge; a genuinely closed chat (no
tile in main) still surfaces null and the home returns as before.

Regression tests cover the sidebar-click case (red before the fix),
plus guards for the closed-chat and sessions-mode derivations.
This commit is contained in:
Finn763
2026-08-27 13:58:02 +08:00
committed by Teknium
parent db63afb9d7
commit 253b9d78c1
2 changed files with 102 additions and 4 deletions

View File

@@ -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)

View File

@@ -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
}
)