fix(desktop): a bot row click returns to its open tabs instead of re-opening a closed Bot Chat

In Bot Mode every roster click resolved the bot's canonical "Bot Chat" by
name and opened it as a tab. Nothing records a tab close (the plugin keeps no
closed set; core's tile bucket only forgets), so a Bot Chat the user had
closed came back beside every newer thread on every bot switch — close it,
start a new thread, visit another bot, come back: two tabs again, forever.

A row click is now "go to this bot": when the bot's workspace already holds
tabs, the one the user last had active is fronted and no chat is resolved or
opened. The canonical chat is opened only when the bot has nothing open, or
on the explicit asks — a new "Open Bot Chat" row-menu item and the Bots home
"Open chat" button (`openRosterBot(bot, { canonical: true })`).

- session-states: `focusWorkspaceOwnerSessionTile(ownerKey)` fronts the
  owner's remembered-active tile (else its most recent) and reports it.
- sdk: `host.focusOpenWorkspaceSession(ownerKey)` exposes it to plugins;
  feature-detected in the plugin so older shells keep the canonical open.
- hermes-bots: `focusExistingBotTab` short-circuits `openRosterBot`; the
  claim it records carries only the fronted tab, and the session.reclaimed
  re-resume now skips such claims so it cannot resurrect the closed chat.

Tests: vitest for the core helper, a node test for the click path (open tabs
win, nothing open → canonical, explicit canonical, older shell, throwing
host), and an e2e that seeds two bots with real "Bot Chat" rows, closes one,
starts a thread, switches bots and back, and asserts the Bot Chat stays
closed until asked for explicitly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Zeus-Deus
2026-08-26 23:04:53 +02:00
committed by Teknium
parent 1acd5bb02b
commit 7c910793bf
6 changed files with 520 additions and 7 deletions
@@ -0,0 +1,208 @@
import fs from 'node:fs'
import path from 'node:path'
import {
buildAppEnv,
createSandbox,
launchDesktop,
type MockBackendFixture,
waitForAppReady,
writeEnvFile,
writeMockProviderConfig
} from './fixtures'
import { MOCK_REPLY, startMockServer } from './mock-server'
import { RealSessionBuilder } from './real-session-builder'
import { expect, test } from './test'
// A bot row click is "go to this bot", not "open its Bot Chat". Before the
// fix, every click resolved the canonical chat by name and opened it as a tab
// again — a Bot Chat the user had closed came back beside every newer thread
// on every bot switch, because nothing records a close (the plugin keeps no
// closed set; core's tile bucket only forgets). Now a bot whose workspace
// already holds tabs comes back to the one the user left; the forever-chat is
// re-opened only by the explicit asks (row menu "Open Bot Chat", Bots home
// "Open chat").
type Page = MockBackendFixture['page']
let fixture: MockBackendFixture | null = null
async function openBots(page: Page): Promise<void> {
const tab = page
.getByRole('button', { name: 'Bots', exact: true })
.or(page.getByRole('tab', { name: 'Bots', exact: true }))
.first()
await tab.click()
await expect(page.getByRole('button', { name: 'New bot or group chat' })).toBeVisible()
}
/** A bot's backend spawns on its first open; give the wake a real chance to
* clear before the next gesture races it. Tolerant: the mock backend can
* keep a tile's "Waking up…" notice around. */
async function settle(page: Page, timeout = 90_000): Promise<void> {
await page
.getByText(/Waking up/i)
.first()
.waitFor({ state: 'hidden', timeout })
.catch(() => undefined)
await page.waitForTimeout(500)
}
/** A first open right after a bot's backend spawned can strand on the
* profile socket (a separate, pre-existing reconnect race); a newer click
* supersedes it. Retry the gesture like a user would before giving up. */
async function openUntil(action: () => Promise<void>, expected: () => Promise<void>, attempts = 3): Promise<void> {
for (let attempt = 1; ; attempt += 1) {
await action()
try {
await expected()
return
} catch (error) {
if (attempt >= attempts) {
throw error
}
}
}
}
const SCREENSHOT_DIR = process.env.BOT_MODE_SCREENSHOT_DIR
async function snap(page: Page, name: string): Promise<void> {
if (SCREENSHOT_DIR) {
await page.screenshot({ path: `${SCREENSHOT_DIR}/${name}.png` })
}
}
/** The session tabs on the main strip (the Bots home tab may sit beside them). */
const mainTabs = (page: Page) =>
page.evaluate(() =>
[...document.querySelectorAll<HTMLElement>('[data-zone-tabstrip="grp-main"] [data-tree-tab]')]
.map(element => element.getAttribute('data-tree-tab') ?? '')
.filter(id => id.startsWith('session-tile:'))
)
/** Bots are profiles. Seeding one on disk before launch — with the mock
* provider so its own backend can answer, and a real, durable "Bot Chat"
* row (the plugin's canonical forever-chat, found by exact title) — keeps
* in-app creation and the intro turn it fires out of a scenario that is
* about the row click. With the row present, the click takes the open-as-
* tab path; without it, it would mint the chat into the workspace pane. */
async function seedBot(hermesHome: string, mockUrl: string, name: string): Promise<void> {
const dir = path.join(hermesHome, 'profiles', name)
fs.mkdirSync(dir, { recursive: true })
writeMockProviderConfig(dir, mockUrl)
writeEnvFile(dir)
const builder = await RealSessionBuilder.start(dir)
try {
await builder.createSession({ title: 'Bot Chat', turns: [`Hello ${name}`] })
} finally {
await builder.close()
}
}
test.beforeAll(async () => {
const mock = await startMockServer()
const sandbox = createSandbox('bots')
writeMockProviderConfig(sandbox.hermesHome, mock.url)
writeEnvFile(sandbox.hermesHome)
await seedBot(sandbox.hermesHome, mock.url, 'alpha')
await seedBot(sandbox.hermesHome, mock.url, 'beta')
const { app, page } = await launchDesktop(buildAppEnv(sandbox))
fixture = {
app,
page,
mock,
mockUrl: mock.url,
sandbox,
cleanup: async () => {
await app.close().catch(() => undefined)
await mock.close()
sandbox.cleanup()
}
}
await waitForAppReady(fixture, 120_000)
})
test.afterAll(async () => {
await fixture?.cleanup()
fixture = null
})
test('a bot row click returns to the open thread and does not re-open a closed Bot Chat', async () => {
test.setTimeout(300_000)
const page = fixture!.page
await openBots(page)
const alphaRow = page.getByRole('button', { name: /^alpha\b/i }).filter({ visible: true }).first()
const betaRow = page.getByRole('button', { name: /^beta\b/i }).filter({ visible: true }).first()
await expect(alphaRow).toBeVisible({ timeout: 30_000 })
await expect(betaRow).toBeVisible({ timeout: 30_000 })
const botChatTab = page.getByRole('tab', { name: /Bot Chat/ }).filter({ visible: true })
// The first click on a bot with nothing open lands on its canonical chat.
await openUntil(
() => alphaRow.click(),
() => expect(botChatTab.first()).toBeVisible({ timeout: 45_000 })
)
await settle(page, 15_000)
await snap(page, '01-first-click-opens-bot-chat')
// Close it, then start a fresh thread for Alpha (⌘/Ctrl+T — the strip's
// "+" leaves with the zone's last tab).
await botChatTab.first().hover()
await botChatTab.first().getByRole('button', { name: 'Close' }).click({ force: true })
await expect(botChatTab).toHaveCount(0)
await page.keyboard.press('Control+t')
const composer = page.locator('[data-slot="composer-root"] [contenteditable="true"]').filter({ visible: true }).first()
await expect(composer).toBeVisible({ timeout: 15_000 })
await composer.click()
await composer.fill('hello alpha thread')
await page.keyboard.press('Enter')
await expect(page.getByText('hello alpha thread').filter({ visible: true }).first()).toBeVisible({ timeout: 15_000 })
// The reply also becomes the tab's (clipped) title — match the visible copy.
await expect(page.getByText(MOCK_REPLY).filter({ visible: true }).first()).toBeVisible({ timeout: 60_000 })
await snap(page, '02-closed-bot-chat-new-thread')
const threadTabs = await mainTabs(page)
expect(threadTabs).toHaveLength(1)
const [threadTab] = threadTabs
expect(threadTab).toMatch(/^session-tile:/)
// Switch to Beta: Alpha's thread leaves the strip (scoped away, not closed).
await betaRow.click()
await expect(page.locator(`[data-zone-tabstrip="grp-main"] [data-tree-tab="${threadTab}"]`)).toHaveCount(0, {
timeout: 60_000
})
await settle(page)
// Back to Alpha: the thread is fronted, and the closed Bot Chat STAYS closed.
await alphaRow.click()
const threadTabLocator = page.locator(`[data-zone-tabstrip="grp-main"] [data-tree-tab="${threadTab}"]`)
await expect(threadTabLocator).toBeVisible({ timeout: 30_000 })
await expect(threadTabLocator).toHaveAttribute('aria-selected', 'true')
await page.waitForTimeout(3000)
await expect(botChatTab).toHaveCount(0)
expect(await mainTabs(page)).toEqual([threadTab])
await snap(page, '03-back-to-alpha-bot-chat-stays-closed')
// The explicit ask still opens the forever-chat, beside the thread.
await openUntil(
async () => {
await alphaRow.click({ button: 'right' })
await page.getByRole('menuitem', { name: 'Open Bot Chat' }).click()
},
() => expect(botChatTab.first()).toBeVisible({ timeout: 45_000 })
)
expect(await mainTabs(page)).toHaveLength(2)
expect(await mainTabs(page)).toContain(threadTab)
await snap(page, '04-explicit-open-bot-chat')
})
+70 -5
View File
@@ -308,8 +308,11 @@ const $selectedRosterHydrated = atom(false)
const $rosterHydrated = atom(false)
/** Mirrors host.paneVisibility('hermes-bots:pane') — wired in register(). */
const $botsPaneVisible = atom(false)
/** An explicit open landed: {key, openedRegistryId}. This transient view
* observation is empty only for the legacy newChat draft fallback. */
/** An explicit open landed: {key, openedRegistryId, openedSessionId}. The
* registry id is empty for the legacy newChat draft fallback and for a click
* that came back to the bot's already-open tabs (only openedSessionId set —
* no canonical chat was resolved). This transient view observation is what
* releases the home; it is never an identity preference. */
const $openBotChat = atom(null)
/** A session owns the main workspace. The roster highlight and the home /
* Cronjobs lifecycles all key off this rather than reading host.state
@@ -6063,11 +6066,39 @@ async function ensureBotMetadata(bot) {
return botRosterMeta(bot, $botMeta.get()) || {}
}
/** The tab this bot's workspace already has open, fronted — or null when it
* has none. A roster click consults this BEFORE the canonical registry so a
* bot with open tabs simply comes back to the one the user left. It is what
* lets a closed Bot Chat STAY closed: the click path used to re-open the
* forever-chat beside every newer thread on every bot switch, and nothing
* records a close (this plugin keeps no closed set; core's tile bucket only
* forgets), so the only honest signal is the open set itself. Feature-
* detected — older shells fall through to the canonical open. */
function focusExistingBotTab(bot) {
if (typeof host.focusOpenWorkspaceSession !== 'function') {
return null
}
try {
const focused = host.focusOpenWorkspaceSession(botWorkspaceOwnerKey(bot))
return typeof focused === 'string' && focused ? focused : null
} catch {
return null
}
}
/** Select one exact roster owner, then open its named canonical chat only when
* the current Desktop can route that owner without guessing. The workspace
* remembers only this transient opened-view observation; it never stores or
* resolves a canonical-chat id. */
async function openRosterBot(bot) {
* resolves a canonical-chat id.
*
* `canonical`: the user asked for the forever-chat itself (Bots home "Open
* chat", the row menu's "Open Bot Chat"). A plain row click is "go to this
* bot": when its workspace already holds tabs, the one the user last had
* active is fronted and no chat is resolved or opened — see
* focusExistingBotTab. */
async function openRosterBot(bot, { canonical = false } = {}) {
const generation = beginBotOpen()
const key = botRosterKey(bot)
const meta = botRosterMeta(bot, $botMeta.get())
@@ -6115,6 +6146,23 @@ async function openRosterBot(bot) {
$botUnread.set(next)
}
if (!canonical) {
const focused = focusExistingBotTab(bot)
if (focused) {
// Open tabs win: no source activation, no registry consult, no open.
// The claim carries only the fronted tab so the focus edge it fires
// keeps it (releaseStaleOpenBotChat) and the home yields.
// The handoff is complete the moment the tab fronts — release the
// bot-open guard (#95917) so passive home reconciliation resumes.
finishBotOpen(generation)
$openBotChat.set({ key, openedRegistryId: '', openedSessionId: focused })
closeBotsHomeWorkspace()
return true
}
}
try {
// Activation selects this row's source only. Canonical identity is resolved
// after that by the owner profile's "Bot Chat" title registry.
@@ -9231,6 +9279,13 @@ function BotRow({ bot, onDelete, onEdit, onGroup, showHandle }) {
children: 'Duplicate'
}),
jsx(ContextMenuSeparator, {}),
jsx(ContextMenuItem, {
// The explicit ask for the forever-chat: a plain row click only
// comes back to the tabs already open (a closed Bot Chat stays
// closed), so this is how it is re-opened on purpose.
onSelect: () => void openRosterBot(bot, { canonical: true }),
children: 'Open Bot Chat'
}),
jsx(ContextMenuItem, {
onSelect: () => {
saveSelectedRosterBot(bot)
@@ -14372,7 +14427,9 @@ function BotsHomeView() {
variant: 'secondary',
size: 'sm',
className: 'mt-5',
onClick: () => void openRosterBot(bot),
// The home's button names the continuous chat itself — an
// explicit ask, unlike a row click that returns to open tabs.
onClick: () => void openRosterBot(bot, { canonical: true }),
children: 'Open chat'
})
]
@@ -16060,6 +16117,14 @@ export default {
return
}
// A claim without a registry id is a fronted non-canonical tab
// (focusExistingBotTab / the draft fallback): re-resolving the
// canonical chat here would open the Bot Chat the user has
// closed. Its tile recovers on the next send like any tab.
if (!claim.openedRegistryId) {
return
}
const bot = selectedRosterBot($lastRoster.get(), $selectedRosterKey.get())
if (!bot) {
@@ -0,0 +1,144 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import vm from 'node:vm'
// A bot row click is "go to this bot", not "open its Bot Chat". Before this,
// every click resolved the canonical chat by name and opened it as a tab —
// with no record of a close anywhere (the plugin keeps no closed set; core's
// tile bucket only forgets), a Bot Chat the user closed came back beside every
// newer thread on every bot switch. Now a bot whose workspace already holds
// tabs comes back to the one the user left; the forever-chat is opened only
// when nothing is open, or on the explicit asks (row menu "Open Bot Chat",
// Bots home "Open chat").
const pluginSource = readFileSync(new URL('../plugin.js', import.meta.url), 'utf8')
const atom = initial => {
let value = initial
return { get: () => value, set: next => { value = typeof next === 'function' ? next(value) : next }, listen: () => () => {} }
}
const jsx = (type, props = {}) => ({ type, props })
function load({ focusHit = null, focusApi = true } = {}) {
const timeline = []
const host = {
state: {
profile: { get: () => 'default', listen: () => {} },
gateway: { get: () => 'open', listen: () => {} },
connectionId: { get: () => 'local', listen: () => {} }
},
request: async () => ({ profiles: [], sessions: [] }),
notify: () => {}, notifyError: error => timeline.push({ type: 'error', error }),
openSession: async (id, options) => timeline.push({ type: 'openSession', id, options }),
ensureAgent: async () => {}, requestProfile: async () => ({}),
activeConnectionId: () => 'local', warmAgent: () => {}, warmProfile: () => {},
newChat: () => timeline.push({ type: 'newChat' }), navigate: () => {},
setWorkspaceScope: (mode, ownerKey) => timeline.push({ type: 'scope', mode, ownerKey }),
openWorkspace: () => () => timeline.push({ type: 'homeClose' })
}
if (focusApi) {
host.focusOpenWorkspaceSession = ownerKey => {
timeline.push({ type: 'focus', ownerKey })
return focusHit
}
}
const ui = 'Button Checkbox Codicon ConfirmDialog ContextMenu ContextMenuContent ContextMenuItem ContextMenuSeparator ContextMenuTrigger CopyButton Dialog DialogContent DialogDescription DialogFooter DialogHeader DialogTitle DropdownMenu DropdownMenuContent DropdownMenuItem DropdownMenuTrigger EmptyState GlyphSpinner Input ScrollArea SearchField Select SelectContent SelectItem SelectTrigger SelectValue Switch Textarea Tip'.split(' ')
const context = {
atom, jsx, jsxs: jsx, cn: (...values) => values.filter(Boolean).join(' '), haptic: () => {},
useEffect: () => {}, useMemo: fn => fn(), useRef: value => ({ current: typeof value === 'function' ? value() : value }),
useState: value => [typeof value === 'function' ? value() : value, () => {}],
useValue: store => store?.get ? store.get() : store,
useQuery: () => ({ data: [], isLoading: false, isFetching: false, refetch: () => {} }),
...Object.fromEntries(ui.map(name => [name, name])),
PALETTE_AREA: 'palette', COMPOSER_AREAS: { middleware: 'middleware' },
profileColor: () => '#000', queryClient: { invalidateQueries: () => {} }, relativeTime: () => 'now',
document: { getElementById: () => null, createElement: () => ({}), head: { appendChild: () => {} } },
setTimeout, clearTimeout, console, Date, Math, JSON, Promise, Map, Set, URL, Error,
Array, Object, String, Boolean, Number, RegExp, host
}
const code = pluginSource
.replace(/^import\s+\*\s+as\s+sdk\s+from '@hermes\/plugin-sdk'\r?\n/m, '')
.replace(/^import\s+\{[\s\S]*?\}\s+from '@hermes\/plugin-sdk'\r?\n/m, '')
.replace(/^const \{ McpTab, ToolsetConfigPanel \} = sdk\r?\n/m, '')
.replace(/^import .* from 'react'\r?\n/m, '')
.replace(/^import .* from 'react\/jsx-runtime'\r?\n/m, '')
.replace('export default {', 'globalThis.plugin = {')
.concat('\nglobalThis.__h={openRosterBot,$openBotChat,$selectedRosterKey,$botsPaneVisible,openBotsHomeWorkspace};')
vm.runInNewContext(code, context, { filename: 'plugin.js' })
context.prepareBotSource = async bot => { timeline.push({ type: 'prepare', bot }) }
context.openBotCanonicalChat = async bot => {
timeline.push({ type: 'canonicalOpen', bot })
return { registryId: 'bot-chat', openedId: 'bot-chat' }
}
return { api: context.__h, timeline, host }
}
const bot = { connectionId: 'local', name: 'alpha', title: 'Alpha' }
const types = runtime => runtime.timeline.map(event => event.type)
test('a row click comes back to the tab the bot already has open — no canonical open', async () => {
const runtime = load({ focusHit: 'thread-2' })
assert.equal(await runtime.api.openRosterBot(bot), true)
assert.deepEqual(runtime.timeline.find(event => event.type === 'focus'), { type: 'focus', ownerKey: 'bot:alpha' })
assert.ok(!types(runtime).includes('canonicalOpen'), 'the closed Bot Chat must not be resolved or re-opened')
assert.ok(!types(runtime).includes('prepare'), 'open tabs need no source activation')
assert.ok(!types(runtime).includes('openSession'))
// The claim carries only the fronted tab: the focus edge it fires keeps the
// claim (releaseStaleOpenBotChat), the home yields, and no registry id is
// recorded because none was resolved.
assert.deepEqual({ ...runtime.api.$openBotChat.get() }, { key: 'local::alpha', openedRegistryId: '', openedSessionId: 'thread-2' })
assert.equal(runtime.api.$selectedRosterKey.get(), 'local::alpha')
})
test('a bot with nothing open still opens its canonical chat', async () => {
const runtime = load({ focusHit: null })
assert.equal(await runtime.api.openRosterBot(bot), true)
assert.deepEqual(types(runtime).filter(type => type !== 'scope'), ['focus', 'prepare', 'canonicalOpen'])
assert.equal(runtime.api.$openBotChat.get()?.openedRegistryId, 'bot-chat')
})
test('the explicit canonical ask skips the open-tab shortcut', async () => {
const runtime = load({ focusHit: 'thread-2' })
assert.equal(await runtime.api.openRosterBot(bot, { canonical: true }), true)
assert.ok(!types(runtime).includes('focus'))
assert.ok(types(runtime).includes('canonicalOpen'))
assert.equal(runtime.api.$openBotChat.get()?.openedRegistryId, 'bot-chat')
})
test('an older shell without the focus API opens the canonical chat as before', async () => {
const runtime = load({ focusApi: false, focusHit: 'thread-2' })
assert.equal(await runtime.api.openRosterBot(bot), true)
assert.deepEqual(types(runtime).filter(type => type !== 'scope'), ['prepare', 'canonicalOpen'])
})
test('a throwing focus API degrades to the canonical open', async () => {
const runtime = load()
runtime.host.focusOpenWorkspaceSession = () => { throw new Error('no tree yet') }
assert.equal(await runtime.api.openRosterBot(bot), true)
assert.ok(types(runtime).includes('canonicalOpen'))
})
test('the explicit asks pass canonical: true (row menu, Bots home)', () => {
const menu = pluginSource.slice(pluginSource.indexOf("children: 'Open Bot Chat'") - 400, pluginSource.indexOf("children: 'Open Bot Chat'"))
assert.match(menu, /openRosterBot\(bot, \{ canonical: true \}\)/)
const home = pluginSource.slice(pluginSource.indexOf("children: 'Open chat'") - 400, pluginSource.indexOf("children: 'Open chat'"))
assert.match(home, /openRosterBot\(bot, \{ canonical: true \}\)/)
// A plain row click and the Active Now strip stay "go to this bot".
assert.match(pluginSource, /const open = \(\) => void openRosterBot\(bot\)\n/)
assert.match(pluginSource, /onOpen: bot => void openRosterBot\(bot\)\n/)
})
test('a reclaim of a fronted non-canonical tab does not re-resolve the Bot Chat', () => {
const start = pluginSource.indexOf("host.onEvent('session.reclaimed'")
const block = pluginSource.slice(start, pluginSource.indexOf('stopSidebarSync', start))
const guard = block.indexOf('if (!claim.openedRegistryId)')
assert.ok(guard > 0, 'reclaim listener guards on the registry id')
assert.ok(guard < block.indexOf('openBotCanonicalChat(bot)'), 'the guard precedes the canonical re-open')
})
+10
View File
@@ -91,6 +91,7 @@ import {
$focusedStoredSessionId,
$sessionStates,
$sessionTiles,
focusWorkspaceOwnerSessionTile,
sessionTileDelegate
} from '@/store/session-states'
import { runGatewayRestart } from '@/store/system-actions'
@@ -1185,6 +1186,15 @@ export const host = {
window.location.hash = '#/'
},
/** Front the tab a Bot Mode owner already has open — the tile that owner's
* zone last had active, else its most recent — and return that stored id;
* `null` when the owner has nothing open. A roster click asks this before
* resolving the canonical chat, so the tabs the user left (and the ones
* they closed) are respected. Presentation only: no gateway activation,
* no session create. Feature-detect on older desktops. */
focusOpenWorkspaceSession: (workspaceOwnerKey: string): null | string =>
focusWorkspaceOwnerSessionTile(workspaceOwnerKey),
/** Reactive on-screen visibility of a contributed pane: true while it is in
* the layout tree, not dismissed/hidden, its zone un-minimized, AND holding
* its zone's active tab slot (a lone pane in its own zone counts). The
+59 -1
View File
@@ -3,7 +3,7 @@ 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, noteActiveTreeGroup } from '@/components/pane-shell/tree/store'
import { $workspaceMode, setWorkspaceScope } from '@/components/pane-shell/workspace-scope'
import { $workspaceMode, forgetActivePane, rememberActivePane, setWorkspaceScope, workspaceScopeKey } 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'
@@ -16,6 +16,7 @@ import {
closeAllOpenSessionTiles,
focusedSessionNeedsRoute,
focusOpenSession,
focusWorkspaceOwnerSessionTile,
foregroundSessionScopes,
isSessionRemote,
knownOwnerForSession,
@@ -331,6 +332,63 @@ describe('SessionTile workspace scope', () => {
})
})
describe('focusWorkspaceOwnerSessionTile', () => {
const botA = { workspaceMode: 'bots' as const, workspaceOwnerKey: 'bot:a' }
const botB = { workspaceMode: 'bots' as const, workspaceOwnerKey: 'bot:b' }
afterEach(() => {
forgetActivePane(workspaceScopeKey('bots', 'bot:a'))
$layoutTree.set(null)
$sessionTiles.set([])
})
it('reports null for an owner with no open tile — the caller opens something', () => {
openSessionTile('other-bot-chat', 'center', 'workspace', undefined, botB)
openSessionTile('sessions-chat')
expect(focusWorkspaceOwnerSessionTile('bot:a')).toBeNull()
})
it('fronts the tab the owner last had active and reports its stored id', () => {
openSessionTile('older-thread', 'center', 'workspace', undefined, botA)
openSessionTile('newer-thread', 'center', 'workspace', undefined, botA)
$layoutTree.set(
group(['workspace', tilePane('older-thread'), tilePane('newer-thread')], { active: 'workspace', id: 'main' })
)
rememberActivePane(workspaceScopeKey('bots', 'bot:a'), tilePane('older-thread'))
expect(focusWorkspaceOwnerSessionTile('bot:a')).toBe('older-thread')
expect(findGroupOfPane($layoutTree.get()!, tilePane('older-thread'))?.active).toBe(tilePane('older-thread'))
})
it('falls back to the most recently opened tab when nothing is remembered', () => {
openSessionTile('older-thread', 'center', 'workspace', undefined, botA)
openSessionTile('newer-thread', 'center', 'workspace', undefined, botA)
$layoutTree.set(
group(['workspace', tilePane('older-thread'), tilePane('newer-thread')], { active: 'workspace', id: 'main' })
)
expect(focusWorkspaceOwnerSessionTile('bot:a')).toBe('newer-thread')
expect(findGroupOfPane($layoutTree.get()!, tilePane('newer-thread'))?.active).toBe(tilePane('newer-thread'))
})
it('ignores a remembered tab that has since been closed', () => {
openSessionTile('closed-bot-chat', 'center', 'workspace', undefined, botA)
openSessionTile('thread', 'center', 'workspace', undefined, botA)
rememberActivePane(workspaceScopeKey('bots', 'bot:a'), tilePane('closed-bot-chat'))
$sessionTiles.set($sessionTiles.get().filter(t => t.storedSessionId !== 'closed-bot-chat'))
expect(focusWorkspaceOwnerSessionTile('bot:a')).toBe('thread')
})
it('never crosses owners: another bot\'s open tabs do not count', () => {
openSessionTile('other-bot-chat', 'center', 'workspace', undefined, botB)
expect(focusWorkspaceOwnerSessionTile('bot:a')).toBeNull()
expect($sessionTiles.get().map(t => t.storedSessionId)).toEqual(['other-bot-chat'])
})
})
describe('closeAllOpenSessionTiles persists Bot Mode Close All (#94137)', () => {
afterEach(() => {
$activeGatewayProfile.set('default')
+29 -1
View File
@@ -29,7 +29,7 @@ import {
noteActiveTreeGroup,
revealTreePane
} from '@/components/pane-shell/tree/store'
import { $workspaceMode } from '@/components/pane-shell/workspace-scope'
import { $workspaceMode, resolveRememberedActivePane, workspaceScopeKey } from '@/components/pane-shell/workspace-scope'
import type { WorkspaceMode } from '@/contrib/types'
import { stableArray } from '@/lib/stable-array'
import { readJson, writeJson } from '@/lib/storage'
@@ -1444,6 +1444,34 @@ export function focusOpenSession(
return null
}
/** Front the tab a Bot Mode owner already has open and report its stored id:
* the tile the zone last had active for `workspaceOwnerKey` (the same
* window-local memory the strip restores on a scope switch), else the most
* recently opened one. `null` when that owner has no open tile — the caller
* decides what to open then. A roster click consults this FIRST so a bot
* with open tabs comes back to the one the user left, instead of re-opening
* its canonical Bot Chat beside them: nothing records a tab close except the
* tile bucket forgetting it, so any open path that ignores the open set
* resurrects closed chats on every bot switch. */
export function focusWorkspaceOwnerSessionTile(workspaceOwnerKey: string): null | string {
const owned = $sessionTiles
.get()
.filter(tile => tile.workspaceMode === 'bots' && tile.workspaceOwnerKey === workspaceOwnerKey)
if (owned.length === 0) {
return null
}
// Most recent first, so the fallback (no remembered pane) is the newest tab.
const paneIds = owned.map(tile => `${TILE_PANE_PREFIX}${tile.storedSessionId}`).reverse()
const paneId = resolveRememberedActivePane(workspaceScopeKey('bots', workspaceOwnerKey), paneIds) ?? paneIds[0]
const storedSessionId = paneId.slice(TILE_PANE_PREFIX.length)
focusOpenSession(storedSessionId, { workspaceMode: 'bots', workspaceOwnerKey })
return storedSessionId
}
/** Does a sidebar click still need to navigate after `focusOpenSession`? A miss
* always does. A `'main'` hit does too while the workspace pane is showing a
* full page (artifacts, skills, …): fronting the workspace tab doesn't put the