fix(desktop): a bot row click always lands on the Bot Chat the row previews

A plain roster click fronted whatever bots-workspace tab the user last had
active for that bot (#96649). A '+' side thread persists in Local Storage
across restarts, so it won every click forever while the row kept previewing
the canonical Bot Chat (profiles.list canonical_session) — sidebar and center
described two different conversations; a message typed there landed in the
side thread and the row never moved. Support thread "[Bots] - Sessions is not
in sync again" (bundle 7dfff039), reproduced live on origin/main.

- roster-actions: the open-tab shortcut may front only the canonical chat
  (registry id or lineage tip, via a new onlyStoredIds allowlist on
  focusWorkspaceOwnerSessionTile); anything else resolves the registry and
  opens in place. Side tabs stay open beside it. "Open Bot Chat" in the row
  menu is the same action; the `canonical` option goes away.
- roster-actions: when the FOCUSED Bot Chat's canonical session advances on
  the gateway (cron bot-chat delivery, message_agent, group round, CLI turn —
  none reach this window's stream), re-open it in place so the transcript
  refreshes instead of waiting for an app restart (#99393 class).

Tests: the fronting-shortcut unit file and its e2e spec pinned the reversed
behavior; replaced by one unit file (5 tests) and one e2e spec that fails on
main and passes here. group-to-local-bot-handoff e2e still passes.
This commit is contained in:
Teknium
2026-09-02 02:58:39 -07:00
parent 254158f453
commit 6e7c7c7da9
12 changed files with 372 additions and 575 deletions

View File

@@ -1,227 +0,0 @@
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").
//
// UI note (post design-system rework): the canonical Bot Chat opens INTO the
// main workspace pane (`data-tree-tab="workspace"`), and a lone uncloseable
// workspace pane renders chromeless — its "Bot Chat" tab only exists once a
// second pane (e.g. a ⌘/Ctrl+T thread tile) shares the main zone. Assertions
// about the lone open therefore read the transcript, not a tab.
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 Bot Chat workspace tab may sit
* beside them). The strip itself auto-hides when the workspace pane is the
* only pane in the zone, so an empty result also covers "no strip at all". */
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-
* workspace path; without it, it would mint the chat into the 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 seeded forever-chat's first turn — visible only while the Bot Chat
// transcript is on screen. This is how a chromeless lone open is observed.
const seededTurn = page.getByText('Hello alpha', { exact: true }).filter({ visible: true })
// The first click on a bot with nothing open lands on its canonical chat.
// It fills the lone main workspace pane, which renders without a tab strip.
await openUntil(
() => alphaRow.click(),
() => expect(seededTurn.first()).toBeVisible({ timeout: 45_000 })
)
await settle(page, 15_000)
await snap(page, '01-first-click-opens-bot-chat')
// Start a fresh thread for Alpha (⌘/Ctrl+T). The thread tile joins the main
// zone beside the Bot Chat workspace pane, which mounts the tab strip — the
// "Bot Chat" tab exists now, and the close affordance with it.
await page.keyboard.press('Control+t')
await expect(botChatTab.first()).toBeVisible({ timeout: 15_000 })
await expect.poll(() => mainTabs(page), { timeout: 15_000 }).toHaveLength(1)
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 })
await expect(page.getByText(MOCK_REPLY).filter({ visible: true }).first()).toBeVisible({ timeout: 60_000 })
await snap(page, '02-new-thread-beside-bot-chat')
const threadTabs = await mainTabs(page)
expect(threadTabs).toHaveLength(1)
const [threadTab] = threadTabs
expect(threadTab).toMatch(/^session-tile:/)
// Close the Bot Chat. Its transcript leaves the screen; the thread stays.
await botChatTab.first().hover()
await botChatTab.first().getByRole('button', { name: 'Close' }).click({ force: true })
await expect(botChatTab).toHaveCount(0)
await expect(seededTurn).toHaveCount(0)
await snap(page, '03-bot-chat-closed-thread-stays')
// 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 workspace comes back to what the user left, and the
// closed Bot Chat STAYS closed. The regression this pins re-opened the
// canonical chat beside the thread on every switch — two panes in the main
// zone, which mounts the tab strip and puts the "Bot Chat" tab back on
// screen. Its absence (with the transcript present, so the click landed) is
// the observable "stays closed".
await alphaRow.click()
await expect(page.getByText(MOCK_REPLY).filter({ visible: true }).first()).toBeVisible({ timeout: 30_000 })
await page.waitForTimeout(3000)
await expect(botChatTab).toHaveCount(0)
await snap(page, '04-back-to-alpha-bot-chat-stays-closed')
// The explicit ask still opens the forever-chat: its seeded first turn is
// back on screen. (As the surviving main-workspace pane it may render
// chromeless, so the transcript — not a tab — is the assertion.)
await openUntil(
async () => {
await alphaRow.click({ button: 'right' })
await page.getByRole('menuitem', { name: 'Open Bot Chat' }).click()
},
() => expect(seededTurn.first()).toBeVisible({ timeout: 45_000 })
)
await snap(page, '05-explicit-open-bot-chat')
})

View File

@@ -0,0 +1,158 @@
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 previews the bot's canonical Bot Chat (the gateway resolves it by
// name on every roster poll). Clicking the row must land on THAT conversation.
// Before this fix a plain click fronted whatever bots-workspace tile the user
// last had open for that bot — a `+` side thread outlived every restart in
// Local Storage and won every click forever, while the row kept previewing the
// Bot Chat. The user saw the sidebar and the center describe two different
// conversations ("sessions not in sync"; support thread 1544460286084391043).
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()
}
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)
}
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
}
}
}
}
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-sync')
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 lands on the Bot Chat the row previews, not a side thread', 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 seededTurn = page.getByText('Hello alpha', { exact: true }).filter({ visible: true })
await openUntil(
() => alphaRow.click(),
() => expect(seededTurn.first()).toBeVisible({ timeout: 45_000 })
)
await settle(page, 15_000)
// A `+` side thread for alpha, with a real turn so it is a persisted tile.
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(MOCK_REPLY).filter({ visible: true }).first()).toBeVisible({ timeout: 60_000 })
// Leave alpha on the side thread, go to beta, come back via the row.
await betaRow.click()
await expect(page.getByText('Hello beta', { exact: true }).filter({ visible: true }).first()).toBeVisible({
timeout: 60_000
})
await settle(page)
await alphaRow.click()
// The row previews the Bot Chat; the click must front it.
await expect(seededTurn.first()).toBeVisible({ timeout: 45_000 })
// The side thread is still open beside it (scoped to alpha), not closed.
await expect
.poll(
() =>
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:')).length
),
{ timeout: 15_000 }
)
.toBe(1)
})

View File

@@ -73,7 +73,7 @@ test('local bot replaces an open group main workspace', async () => {
await expect(groupTab).toHaveCount(0)
await expect(groupComposer).toHaveCount(0)
// No "Waking up…" assertion: the mock backend can keep a bot's wake notice
// around indefinitely (see bot-mode-closed-chat-stays-closed's settle()),
// around indefinitely (see bot-mode-row-click-mirrors-registry's settle()),
// so its presence no longer distinguishes a stranded handoff. The splash
// and composer above are the proof the bot's chat took the workspace.
await expect(page.locator('[data-slot="composer-root"] [contenteditable="true"]').filter({ visible: true }).first()).toBeVisible()

View File

@@ -1,291 +0,0 @@
/**
* 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 — and
* with no record of a close anywhere (this 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 ask (the row menu's "Open Bot Chat").
*
* Ported from tests/bot-row-keeps-closed-chat.test.mjs, which drove a `vm`
* copy of plugin.js. Its two source-reading cases are dropped for real
* assertions: the menu's call site is now a render in bot-row.test.tsx, and
* the reclaim guard's text is asserted here as the claim-shape invariant the
* guard actually reads.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { RosterRow } from './types'
const { openBotCanonicalChat, prepareBotSource } = vi.hoisted(() => ({
openBotCanonicalChat: vi.fn(),
prepareBotSource: vi.fn()
}))
vi.mock('./canonical-chat', () => ({
CANONICAL_CHAT_TITLE: 'Bot Chat',
ensureBotMetadata: vi.fn(async () => ({})),
notifyBotOpenFailure: vi.fn(),
openBotCanonicalChat,
prepareBotSource,
PROFILE_SESSION_LIST_LIMIT: 200
}))
const { host } = await import('@hermes/plugin-sdk')
const { $openBotChat, $selectedBot } = await import('./bot-state')
const { openRosterBot } = await import('./roster-actions')
const bot = { connectionId: 'local', name: 'alpha' } as RosterRow
/** Swap in a focus API for one test, restoring whatever the SDK really has —
* including its absence, which is the older-shell case. */
function withFocusApi(impl: null | (() => null | string)) {
const had = Object.hasOwn(host, 'focusOpenWorkspaceSession')
const original = host.focusOpenWorkspaceSession
if (impl) {
host.focusOpenWorkspaceSession = impl
} else {
// @ts-expect-error — modelling a Desktop old enough to lack the verb.
delete host.focusOpenWorkspaceSession
}
return () => {
if (had) {
host.focusOpenWorkspaceSession = original
} else {
// @ts-expect-error — same.
delete host.focusOpenWorkspaceSession
}
}
}
beforeEach(() => {
vi.clearAllMocks()
prepareBotSource.mockResolvedValue(undefined)
openBotCanonicalChat.mockResolvedValue({ openedId: 'bot-chat', registryId: 'bot-chat' })
$openBotChat.set(null)
$selectedBot.set('')
})
describe('a row click returns to the tabs the bot already has open', () => {
it('fronts the remembered tab and resolves no canonical chat', async () => {
const focus = vi.fn(() => 'thread-2')
const restore = withFocusApi(focus)
try {
await expect(openRosterBot(bot)).resolves.toBe(true)
expect(focus).toHaveBeenCalledWith('bot:alpha', expect.any(Function))
expect(openBotCanonicalChat).not.toHaveBeenCalled()
// Open tabs need no source activation either — the bot is already live.
expect(prepareBotSource).not.toHaveBeenCalled()
} finally {
restore()
}
})
it('claims only the fronted tab, with no registry id', async () => {
const restore = withFocusApi(() => 'thread-2')
try {
await openRosterBot(bot)
expect($openBotChat.get()).toEqual({
key: 'local::alpha',
openedRegistryId: '',
openedSessionId: 'thread-2'
})
} finally {
restore()
}
})
})
describe('the canonical chat still opens when it is what was asked for', () => {
it('opens it when the bot has nothing open', async () => {
const restore = withFocusApi(() => null)
try {
await expect(openRosterBot(bot)).resolves.toBe(true)
expect(openBotCanonicalChat).toHaveBeenCalled()
expect($openBotChat.get()?.openedRegistryId).toBe('bot-chat')
} finally {
restore()
}
})
it('skips the open-tab shortcut on the explicit ask', async () => {
const focus = vi.fn(() => 'thread-2')
const restore = withFocusApi(focus)
try {
await expect(openRosterBot(bot, { canonical: true })).resolves.toBe(true)
expect(focus).not.toHaveBeenCalled()
expect($openBotChat.get()?.openedRegistryId).toBe('bot-chat')
} finally {
restore()
}
})
})
describe('the fronted-tab shortcut reconciles with the canonical registry (#90102)', () => {
// The stuck shape: a persisted "Bot Chat" tile names a session the
// registry no longer resolves to (superseded pointer-era row, re-minted
// canonical chat, stale finished session). The roster click must judge
// that tile against the server-resolved canonical_session and fall
// through to the authoritative registry open instead of fronting it.
const staleBot = {
connectionId: 'local',
name: 'alpha',
canonical_session: { id: 'bot-chat', resolved_id: 'bot-chat-tip' }
} as RosterRow
/** The probe openRosterBot hands the focus verb, captured. */
function captureProbe() {
let probe: ((tile: { storedSessionId: string; workspaceTabTitle?: string }) => boolean) | undefined
const focus = vi.fn((_key: string, isStaleTile?: typeof probe) => {
probe = isStaleTile
return null
})
return { focus, probe: () => probe }
}
it('classifies a canonical-titled tile at a foreign id as stale', async () => {
const { focus, probe } = captureProbe()
const restore = withFocusApi(focus as unknown as () => null | string)
try {
await openRosterBot(staleBot)
const isStale = probe()!
expect(isStale({ storedSessionId: 'old-finished-session', workspaceTabTitle: 'Bot Chat' })).toBe(true)
} finally {
restore()
}
})
it('keeps the tile that matches the registry row or its lineage tip', async () => {
const { focus, probe } = captureProbe()
const restore = withFocusApi(focus as unknown as () => null | string)
try {
await openRosterBot(staleBot)
const isStale = probe()!
expect(isStale({ storedSessionId: 'bot-chat', workspaceTabTitle: 'Bot Chat' })).toBe(false)
expect(isStale({ storedSessionId: 'bot-chat-tip', workspaceTabTitle: 'Bot Chat' })).toBe(false)
} finally {
restore()
}
})
it('never judges side-chat tabs — only canonical-titled tiles carry registry identity', async () => {
const { focus, probe } = captureProbe()
const restore = withFocusApi(focus as unknown as () => null | string)
try {
await openRosterBot(staleBot)
const isStale = probe()!
expect(isStale({ storedSessionId: 'scratch-thread', workspaceTabTitle: 'Group: writers' })).toBe(false)
expect(isStale({ storedSessionId: 'scratch-thread' })).toBe(false)
} finally {
restore()
}
})
it('an older gateway without canonical_session cannot judge — every tile survives', async () => {
const { focus, probe } = captureProbe()
const restore = withFocusApi(focus as unknown as () => null | string)
try {
await openRosterBot(bot) // no canonical_session on this row
const isStale = probe()!
expect(isStale({ storedSessionId: 'anything', workspaceTabTitle: 'Bot Chat' })).toBe(false)
} finally {
restore()
}
})
it('falls through to the authoritative canonical open when the stale tile was the only tab', async () => {
// The store discards the stale tile and reports null; the click must
// then resolve the registry — the backend-truth path — not give up.
const restore = withFocusApi(() => null)
try {
await expect(openRosterBot(staleBot)).resolves.toBe(true)
expect(openBotCanonicalChat).toHaveBeenCalled()
expect($openBotChat.get()?.openedRegistryId).toBe('bot-chat')
} finally {
restore()
}
})
})
describe('a shell that cannot report open tabs behaves as it did before', () => {
it('opens the canonical chat when the verb is missing', async () => {
const restore = withFocusApi(null)
try {
await expect(openRosterBot(bot)).resolves.toBe(true)
expect(openBotCanonicalChat).toHaveBeenCalled()
} finally {
restore()
}
})
it('opens the canonical chat when the verb throws', async () => {
const restore = withFocusApi(() => {
throw new Error('no tree yet')
})
try {
await expect(openRosterBot(bot)).resolves.toBe(true)
expect(openBotCanonicalChat).toHaveBeenCalled()
} finally {
restore()
}
})
})
describe('the claim a fronted tab records cannot resurrect the closed chat', () => {
// The reclaim listener re-resolves the canonical chat for a claim it owns,
// and guards on the registry id to avoid doing so for a fronted tab. That
// guard is only correct because a fronted-tab claim leaves the id empty
// while a real canonical open always fills it — the invariant asserted here.
it('leaves the registry id empty for a fronted tab', async () => {
const restore = withFocusApi(() => 'thread-2')
try {
await openRosterBot(bot)
expect($openBotChat.get()?.openedRegistryId).toBe('')
expect($openBotChat.get()?.openedSessionId).toBeTruthy()
} finally {
restore()
}
})
it('fills the registry id for a real canonical open', async () => {
const restore = withFocusApi(() => null)
try {
await openRosterBot(bot)
expect($openBotChat.get()?.openedRegistryId).toBeTruthy()
} finally {
restore()
}
})
})

View File

@@ -0,0 +1,128 @@
/**
* A bot row click lands on the bot's canonical Bot Chat — the session the row
* previews (`canonical_session`, resolved by name on every roster poll).
*
* The regression this pins: a plain click used to front whatever
* bots-workspace tab the user last had open for that bot. A `+` side thread
* persisted in Local Storage across restarts and won every click forever while
* the row kept previewing the Bot Chat — sidebar and center described two
* different conversations ("[Bots] - Sessions is not in sync again").
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { RosterRow } from './types'
const { openBotCanonicalChat, prepareBotSource } = vi.hoisted(() => ({
openBotCanonicalChat: vi.fn(),
prepareBotSource: vi.fn()
}))
vi.mock('./canonical-chat', () => ({
CANONICAL_CHAT_TITLE: 'Bot Chat',
ensureBotMetadata: vi.fn(async () => ({})),
notifyBotOpenFailure: vi.fn(),
openBotCanonicalChat,
prepareBotSource,
PROFILE_SESSION_LIST_LIMIT: 200
}))
const { host } = await import('@hermes/plugin-sdk')
const { $openBotChat, $selectedBot } = await import('./bot-state')
const { openRosterBot, trackInboundActivity } = await import('./roster-actions')
const { $selectedStoredSessionId } = await import('@/store/session')
const bot = { connectionId: 'local', name: 'alpha' } as RosterRow
beforeEach(() => {
vi.clearAllMocks()
prepareBotSource.mockResolvedValue(undefined)
openBotCanonicalChat.mockResolvedValue({ openedId: 'bot-chat-tip', registryId: 'bot-chat' })
$openBotChat.set(null)
$selectedBot.set('')
})
describe('a row click lands on the canonical chat, never a remembered side tab', () => {
const canonicalBot = {
...bot,
canonical_session: { id: 'bot-chat', resolved_id: 'bot-chat-tip' }
} as RosterRow
afterEach(() => {
// @ts-expect-error — restore the harness default (no focus verb).
delete host.focusOpenWorkspaceSession
})
it('fronts an open Bot Chat tab without a registry round-trip, side tabs excluded', async () => {
const focus = vi.fn((_key: string, _probe: unknown, only?: readonly string[]) =>
only?.includes('bot-chat-tip') ? 'bot-chat-tip' : null
)
host.focusOpenWorkspaceSession = focus as never
await expect(openRosterBot(canonicalBot)).resolves.toBe(true)
expect(focus).toHaveBeenCalledWith('bot:alpha', expect.any(Function), ['bot-chat', 'bot-chat-tip'])
expect(openBotCanonicalChat).not.toHaveBeenCalled()
expect($openBotChat.get()).toEqual({
key: 'local::alpha',
openedRegistryId: 'bot-chat',
openedSessionId: 'bot-chat-tip'
})
})
it('resolves the registry when only a side thread is open', async () => {
// The shell would happily front 'side-thread' — the allowlist excludes it.
host.focusOpenWorkspaceSession = vi.fn((_key: string, _probe: unknown, only?: readonly string[]) =>
only?.includes('side-thread') ? 'side-thread' : null
) as never
await expect(openRosterBot(canonicalBot)).resolves.toBe(true)
expect(openBotCanonicalChat).toHaveBeenCalledWith(canonicalBot, expect.any(Function))
expect($openBotChat.get()?.openedSessionId).toBe('bot-chat-tip')
})
it('a failed open records no claim', async () => {
openBotCanonicalChat.mockRejectedValueOnce(new Error('gateway away'))
await expect(openRosterBot(bot)).resolves.toBe(false)
expect($openBotChat.get()).toBeNull()
})
})
describe('the open Bot Chat follows its session on the gateway', () => {
// The roster poll is the only signal for turns that never reach this
// window's stream (cron bot-chat deliveries, message_agent, group rounds).
// When the FOCUSED chat's canonical session moves, it re-resolves so the pane
// repaints from the gateway instead of waiting for a restart (#99393).
const activeBot = (lastActive: number) =>
({
connectionId: 'local',
name: 'alpha',
canonical_session: { id: 'bot-chat', resolved_id: 'bot-chat-tip', last_active: lastActive }
}) as RosterRow
it('re-opens the focused Bot Chat when its canonical session advances', () => {
$selectedBot.set('alpha')
$selectedStoredSessionId.set('bot-chat-tip')
trackInboundActivity([activeBot(100)]) // seeds the watermark
trackInboundActivity([activeBot(200)])
expect(openBotCanonicalChat).toHaveBeenCalledTimes(1)
$selectedStoredSessionId.set(null)
})
it('leaves the center alone when the Bot Chat is not what is focused', () => {
$selectedBot.set('alpha')
$selectedStoredSessionId.set('some-group-room')
trackInboundActivity([activeBot(300)])
trackInboundActivity([activeBot(400)])
expect(openBotCanonicalChat).not.toHaveBeenCalled()
$selectedStoredSessionId.set(null)
})
})

View File

@@ -132,14 +132,14 @@ describe('the row delegates the open and claims no activation authority', () =>
})
})
describe('the menu carries the explicit ask for the forever-chat', () => {
it('opens the canonical chat, which a plain row click deliberately does not', async () => {
describe('the menu opens the same forever-chat a row click does', () => {
it('opens the canonical chat', async () => {
const bot = { name: 'alpha' } as RosterRow
fireEvent.contextMenu(renderRow(bot))
fireEvent.click(await screen.findByText('Open Bot Chat'))
expect(openRosterBot.mock.calls).toEqual([[bot, { canonical: true }]])
expect(openRosterBot.mock.calls).toEqual([[bot]])
})
})

View File

@@ -284,7 +284,7 @@ export function BotRow({ bot, onDelete, onEdit, onGroup, showHandle }: BotRowPro
<ContextMenu>
<ContextMenuTrigger asChild>{row}</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onSelect={() => void openRosterBot(bot, { canonical: true })}>
<ContextMenuItem onSelect={() => void openRosterBot(bot)}>
{b.bot.openBotChat}
</ContextMenuItem>
<ContextMenuSeparator />

View File

@@ -551,10 +551,10 @@ 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.
// A claim without a registry id is the legacy newChat draft
// fallback: re-resolving the canonical chat here would replace
// a draft the user is typing into. Its tile recovers on the next
// send like any tab.
if (!claim.openedRegistryId) {
return
}

View File

@@ -71,6 +71,8 @@ export function trackInboundActivity(roster: RosterRow[]) {
// Activity in the exact bot owner the user is currently looking at is
// already visible — never badge the open chat or its same-named twin.
if ($selectedBot.get() === key) {
refreshOpenBotChat(bot)
continue
}
@@ -105,29 +107,45 @@ export function trackInboundActivity(roster: RosterRow[]) {
}
}
/** 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.
/** The open Bot Chat's canonical session moved on the gateway (a cron
* `bot-chat:` delivery, a teammate's `message_agent`, a group round, a CLI
* turn) — none of those arrive on this window's live stream, so the pane
* kept painting a stale transcript until an app restart (#99393). Re-run the
* same registry open the row click uses: it fronts the chat in place and
* forceResume re-pulls the transcript. Only while that chat is the FOCUSED
* session — a group room or another tab owning the center must not be
* yanked away by background activity — and never mid-turn, when the
* activity is the turn itself, already streaming. */
function refreshOpenBotChat(bot: RosterRow) {
const canonicalIds = [bot.canonical_session?.id, bot.canonical_session?.resolved_id].filter(Boolean).map(String)
const focused = String(host.state.focusedStoredSessionId?.get?.() || '')
if (!focused || !canonicalIds.includes(focused) || host.state.busy.get()) {
return
}
const generation = getBotOpenGeneration()
void openBotCanonicalChat(bot, () => generation === getBotOpenGeneration()).catch(() => {
/* the next click or reclaim event re-resolves it */
})
}
/** Front the bot's canonical Bot Chat when it is ALREADY open as a tab —
* presentation only, no registry round-trip. Returns the fronted stored id,
* or null when the chat is not on screen (or this shell cannot tell) and the
* caller must resolve the registry.
*
* The open set is a Local Storage cache, and it must reconcile with backend
* truth before it wins (hermes-agent#90102): a persisted "Bot Chat" tile can
* name a session the registry no longer resolves to — a superseded row from
* the retired pointer design, a re-minted canonical chat, a stale finished
* session. Fronting it re-pinned the roster click to that stale (often
* hidden) session forever while the row's preview described the live one.
* The staleness probe compares each canonical-titled tile against the
* roster's server-resolved `canonical_session` (identity by NAME, resolved
* fresh on every profiles.list): a mismatch means the registry moved on, so
* the tile is discarded and the click falls through to the authoritative
* registry open. Side-chat tabs (any other title) carry no registry identity
* and are never judged; an older gateway without `canonical_session` cannot
* judge either — both keep the tile, the pre-#90102 behavior. */
function focusExistingBotTab(bot: RosterRow): null | string {
* Only the canonical chat qualifies: a tile whose stored id is the roster's
* server-resolved `canonical_session` (registry row or its lineage tip). An
* earlier version fronted whatever bots-workspace tab the user last had
* active — a `+` side thread persisted in Local Storage across restarts and
* won every click forever while the row kept previewing the Bot Chat, so
* sidebar and center described two different conversations ("[Bots] -
* Sessions is not in sync again"). Side tabs stay open; they never answer a
* click aimed at the bot. Canonical-titled tiles at a foreign id are stale
* (hermes-agent#90102) and are discarded. Without `canonical_session` (older
* gateway) nothing can be verified, so nothing is fronted. */
function focusExistingBotTab(bot: RosterRow): null | { registryId: string; storedSessionId: string } {
if (typeof host.focusOpenWorkspaceSession !== 'function') {
return null
}
@@ -135,31 +153,36 @@ function focusExistingBotTab(bot: RosterRow): null | string {
const canonical = bot?.canonical_session
const canonicalIds = [canonical?.id, canonical?.resolved_id].filter(Boolean).map(String)
if (canonicalIds.length === 0) {
return null
}
const isStaleTile = (tile: { storedSessionId: string; workspaceTabTitle?: string }) =>
canonicalIds.length > 0 &&
typeof tile.workspaceTabTitle === 'string' &&
tile.workspaceTabTitle === CANONICAL_CHAT_TITLE &&
!canonicalIds.includes(String(tile.storedSessionId))
try {
const focused = host.focusOpenWorkspaceSession(botWorkspaceOwnerKey(bot), isStaleTile)
const focused = host.focusOpenWorkspaceSession(botWorkspaceOwnerKey(bot), isStaleTile, canonicalIds)
return typeof focused === 'string' && focused ? focused : null
return typeof focused === 'string' && focused ? { registryId: String(canonical!.id), storedSessionId: 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
/** Select one exact roster owner and open its canonical Bot Chat — the same
* session the row previews. Resolution always goes through the owner
* profile's "Bot Chat" title registry: an already-open canonical tab is
* fronted (focusExistingBotTab), otherwise openBotCanonicalChat resolves and
* opens it in place; side tabs the user opened with `+` stay open beside it. A click never fronts a side tab: an
* earlier "return to the last open tab" shortcut left the center on a `+`
* thread (persisted in Local Storage across restarts) while the row kept
* previewing the Bot Chat — sidebar and center described two different
* conversations ("[Bots] - Sessions is not in sync again"). The workspace
* remembers only this transient opened-view observation; it never stores or
* resolves a canonical-chat id.
*
* `canonical`: the user asked for the forever-chat itself (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. */
export async function openRosterBot(bot: RosterRow, { canonical = false } = {}): Promise<boolean> {
* resolves a canonical-chat id. */
export async function openRosterBot(bot: RosterRow): Promise<boolean> {
const generation = bumpBotOpenGeneration()
const key = botRosterKey(bot)
const meta = botRosterMeta(bot, $botMeta.get())
@@ -213,18 +236,15 @@ export async function openRosterBot(bot: RosterRow, { canonical = false } = {}):
// a bot open deliberately leaves the gateway on the launch profile.
ackStoredSessionId(botCanonicalSessionId(bot), bot.name)
if (!canonical) {
const focused = focusExistingBotTab(bot)
const fronted = 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 no registry id is recorded, because none
// was resolved.
$openBotChat.set({ key, openedRegistryId: '', openedSessionId: focused })
if (fronted) {
// The canonical chat is on screen: no source activation, no registry
// round-trip. Both identities are recorded so the reclaim listener and
// the roster-activity refresh treat it exactly like a registry open.
$openBotChat.set({ key, openedRegistryId: fronted.registryId, openedSessionId: fronted.storedSessionId })
return true
}
return true
}
try {

View File

@@ -1217,8 +1217,9 @@ export const host = {
* caller falls through to its authoritative open path. */
focusOpenWorkspaceSession: (
workspaceOwnerKey: string,
isStaleTile?: (tile: { storedSessionId: string; workspaceTabTitle?: string }) => boolean
): null | string => focusWorkspaceOwnerSessionTile(workspaceOwnerKey, isStaleTile),
isStaleTile?: (tile: { storedSessionId: string; workspaceTabTitle?: string }) => boolean,
onlyStoredIds?: readonly string[]
): null | string => focusWorkspaceOwnerSessionTile(workspaceOwnerKey, isStaleTile, onlyStoredIds),
/** 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

View File

@@ -1557,7 +1557,8 @@ export function focusOpenSession(
* falls through to its authoritative open. No probe = the old behavior. */
export function focusWorkspaceOwnerSessionTile(
workspaceOwnerKey: string,
isStaleTile?: (tile: SessionTile) => boolean
isStaleTile?: (tile: SessionTile) => boolean,
onlyStoredIds?: readonly string[]
): null | string {
const allOwned = $sessionTiles
.get()
@@ -1582,6 +1583,13 @@ export function focusWorkspaceOwnerSessionTile(
owned = allOwned.filter(tile => !stale.includes(tile))
}
// `onlyStoredIds`: the sessions this call may front (Bot Mode passes the
// canonical chat's registry id + lineage tip). Other tabs in the owner's
// zone stay open; they are simply not what the caller asked for.
if (onlyStoredIds) {
owned = owned.filter(tile => onlyStoredIds.includes(tile.storedSessionId))
}
if (owned.length === 0) {
return null
}

View File

@@ -17,7 +17,7 @@ There is no new primitive to learn: a Bot **is** a Hermes profile — isolated c
The roster shows one row per agent profile: avatar, latest-message preview, and timestamp.
- **Click a Bot** to land in its chat — every Bot has a canonical, persistent **Bot Chat** conversation that is created (and pinned) the moment the Bot is born.
- **Click a Bot** to land in its chat — every Bot has a canonical, persistent **Bot Chat** conversation that is created (and pinned) the moment the Bot is born. A row click always opens that Bot Chat (the same conversation the row previews), even when you have other tabs open for the Bot; those tabs stay open beside it.
- **Active now** — a presence strip above the roster shows every Bot currently working: the gateway-busy profile plus any Bot that wrote within the last 90 seconds. Each chip opens that Bot's chat. The strip never reorders the roster and disappears when the fleet is idle.
- **Search** filters the roster as you type.
- **Hide a Bot** — right-click a row → **Hide Bot** to take a Bot you don't use out of the roster and the Active-now strip. Hiding is display-only: @mentions still resolve, group-chat memberships are untouched, and routines keep running. Once at least one Bot is hidden, an **eye toggle** appears in the pane header — click it to reveal hidden Bots dimmed in place, then right-click → **Unhide Bot** to bring one back. Hidden Bots never toast, but they accumulate unread activity silently and the eye badges a dot so you know something happened. Hidden state is saved in the Bot's profile metadata, so it follows the Bot to every desktop connected to that backend.