From 096c96e3619f89cb3aa7a7e94287a4ea495fd738 Mon Sep 17 00:00:00 2001 From: Teknium Date: Sat, 15 Aug 2026 00:40:14 -0700 Subject: [PATCH] feat: hide canonical Bot Chats from the global Sessions sidebar (#46) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bot Chats are plugin-owned forever-chats but were byte-identical to ordinary desktop sessions, so they cluttered the global Sessions sidebar. Core hermes-agent#86797 added a generic 'hidden' session flag (sidebar-hide, still fully resumable). This wires it up: - $hideBotChats pref (default ON), persisted via ctx.storage('hide-bot-chats'). - createCanonicalChat passes hidden:true on session.create when the pref is on (core defers it via pending_hidden until the row exists). - setHideBotChats(): flips + persists the pref and reconciles EXISTING canonical chats via the session.set_hidden RPC (bounded batch over $botMeta[*].chat). - Eye toggle in the Bots header (eye-closed = hidden) to flip it live. Feature-detected by omission: older gateways ignore the unknown hidden param / lack session.set_hidden, so Bot Chats simply stay visible — no breakage. Bot Chats keep source=desktop (no platform mis-tag). Requires the core flag (hermes-agent#86797, merged) + gateway restart to take effect. Tests: hidden passed when pref on / omitted when off / toggle reconciles. Full suite 52/52. --- plugin.js | 86 +++++++++++++++++--- tests/canonical-chat-creation.test.mjs | 1 + tests/canonical-chat-empty-recovery.test.mjs | 1 + tests/hide-bot-chats.test.mjs | 59 ++++++++++++++ 4 files changed, 137 insertions(+), 10 deletions(-) create mode 100644 tests/hide-bot-chats.test.mjs diff --git a/plugin.js b/plugin.js index baa95f3..84f8aa8 100644 --- a/plugin.js +++ b/plugin.js @@ -118,6 +118,14 @@ function trackInboundActivity(roster) { /** Last good cron list, same idea as the roster snapshot. */ const $lastJobs = atom([]) +/** User pref: hide canonical "Bot Chat" sessions from the global Sessions + * sidebar (they always remain in the Bots roster). Persisted via ctx.storage. + * Default ON — Bot Chats are plugin-owned forever-chats, not scratch sessions, + * so keeping them out of the shared recents list is the expected behavior. + * Backed by the core generic `hidden` session flag (session.create hidden:true + * / session.set_hidden); older gateways ignore it and Bot Chats stay visible. */ +const $hideBotChats = atom(true) + /** Bot the Routines tile is scoped to. Follows the live gateway profile * (the bot you're actually chatting with) and roster clicks. */ const $selectedBot = atom('default') @@ -167,6 +175,31 @@ function saveBotMeta(name, patch) { } } +/** Flip the "hide Bot Chats from the sidebar" pref, persist it, and reconcile + * every known canonical chat via the core session.set_hidden RPC so the change + * applies to already-created Bot Chats (not just future ones). Feature-detected: + * older gateways lack session.set_hidden and simply keep the chats visible. */ +async function setHideBotChats(hidden) { + $hideBotChats.set(hidden) + + try { + Promise.resolve(pluginCtx?.storage?.set?.('hide-bot-chats', hidden)).catch(() => undefined) + } catch { + /* storage unavailable — pref holds for this window only */ + } + + const meta = $botMeta.get() + const ids = Object.values(meta) + .map(m => m && m.chat) + .filter(Boolean) + + await Promise.all( + ids.map(sid => + Promise.resolve(host.request('session.set_hidden', { session_id: sid, hidden })).catch(() => undefined) + ) + ) +} + /** Fetch server-side avatars for roster rows flagged has_avatar when the * local cache doesn't already have an image for them. Fire-and-forget. */ const avatarFetchInflight = new Set() @@ -1532,7 +1565,11 @@ function createCanonicalChat(name) { const run = (async () => { const res = await host.request('session.create', { profile: name, - title: 'Bot Chat' + title: 'Bot Chat', + // Born hidden from the global sidebar when the pref is on. Core applies + // this via the generic `hidden` flag (deferred as pending_hidden until the + // row exists); older gateways ignore the unknown param and it stays visible. + ...($hideBotChats.get() ? { hidden: true } : {}) }) const sid = res?.stored_session_id const runtime = res?.session_id @@ -4257,6 +4294,7 @@ function BotsPane() { const [editing, setEditing] = useState(null) const [deleting, setDeleting] = useState(null) const [query, setQuery] = useState('') + const hideBotChats = useValue($hideBotChats) // The socket opening (boot, SSH reconnect, sleep/wake) is the signal to // retry immediately instead of waiting out the poll interval. @@ -4319,15 +4357,30 @@ function BotsPane() { className: 'text-[0.6875rem] font-semibold uppercase tracking-wider text-(--ui-text-quaternary)', children: 'Bots' }), - jsx(Tip, { - label: 'New Agent', - children: jsx('button', { - type: 'button', - className: - 'flex size-6 items-center justify-center rounded-md text-(--ui-text-tertiary) transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground', - onClick: () => setCreateOpen(true), - children: jsx(Codicon, { name: 'add' }) - }) + jsxs('div', { + className: 'flex items-center gap-0.5', + children: [ + jsx(Tip, { + label: hideBotChats ? 'Bot Chats hidden from Sessions — click to show' : 'Bot Chats shown in Sessions — click to hide', + children: jsx('button', { + type: 'button', + className: + 'flex size-6 items-center justify-center rounded-md text-(--ui-text-tertiary) transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground', + onClick: () => void setHideBotChats(!hideBotChats), + children: jsx(Codicon, { name: hideBotChats ? 'eye-closed' : 'eye' }) + }) + }), + jsx(Tip, { + label: 'New Agent', + children: jsx('button', { + type: 'button', + className: + 'flex size-6 items-center justify-center rounded-md text-(--ui-text-tertiary) transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground', + onClick: () => setCreateOpen(true), + children: jsx(Codicon, { name: 'add' }) + }) + }) + ] }) ] }), @@ -4487,6 +4540,19 @@ export default { /* no storage on this shell — defaults stay */ } + // Hydrate the "hide Bot Chats from the sidebar" pref (default ON). + try { + Promise.resolve(ctx.storage?.get?.('hide-bot-chats')) + .then(value => { + if (typeof value === 'boolean') { + $hideBotChats.set(value) + } + }) + .catch(() => undefined) + } catch { + /* no storage — default (hide) stays */ + } + // Routines follow the chat you're in: track the live gateway profile. host.state.profile.listen(profile => { if (profile && typeof profile === 'string') { diff --git a/tests/canonical-chat-creation.test.mjs b/tests/canonical-chat-creation.test.mjs index 30f9b3e..8d6d8d3 100644 --- a/tests/canonical-chat-creation.test.mjs +++ b/tests/canonical-chat-creation.test.mjs @@ -12,6 +12,7 @@ function loadCanonicalCreation({ openSession, request }) { const context = { host: { openSession, request }, saveBotMeta: (name, patch) => saved.push({ name, patch }), + $hideBotChats: { get: () => false }, window: { setTimeout: callback => callback() } } const section = source diff --git a/tests/canonical-chat-empty-recovery.test.mjs b/tests/canonical-chat-empty-recovery.test.mjs index ca0d83c..9415860 100644 --- a/tests/canonical-chat-empty-recovery.test.mjs +++ b/tests/canonical-chat-empty-recovery.test.mjs @@ -12,6 +12,7 @@ function loadCanonicalRecovery({ openSession, request }) { const context = { host: { openSession, request }, saveBotMeta: (name, patch) => saved.push({ name, patch }), + $hideBotChats: { get: () => false }, window: { setTimeout: callback => callback() } } const section = source diff --git a/tests/hide-bot-chats.test.mjs b/tests/hide-bot-chats.test.mjs new file mode 100644 index 0000000..4f5a2c4 --- /dev/null +++ b/tests/hide-bot-chats.test.mjs @@ -0,0 +1,59 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' +import vm from 'node:vm' + +// #46: canonical "Bot Chat" sessions can be hidden from the global Sessions +// sidebar via the core generic `hidden` session flag, while staying in the Bots +// roster. The plugin passes hidden:true on session.create when the pref is on, +// and setHideBotChats reconciles existing chats via session.set_hidden. + +const source = readFileSync(new URL('../plugin.js', import.meta.url), 'utf8') + +function loadCreate(hidePref) { + const start = source.indexOf('const canonicalCreations = new Map()') + const end = source.indexOf('function displayName(', start) + const created = [] + const context = { + host: { + openSession: async () => {}, + request: async (method, params) => { + if (method === 'session.create') { + created.push(params) + return { stored_session_id: 'sid-1', session_id: 'rt-1' } + } + return {} + } + }, + saveBotMeta: () => {}, + $hideBotChats: { get: () => hidePref }, + window: { setTimeout: cb => cb() } + } + const section = source.slice(start, end).concat('\nglobalThis.__c = { createCanonicalChat };\n') + vm.runInNewContext(section, context, { filename: 'c.js' }) + return { create: context.__c.createCanonicalChat, created } +} + +test('createCanonicalChat passes hidden:true when the pref is on', async () => { + const { create, created } = loadCreate(true) + await create('alpha') + assert.equal(created.length, 1) + assert.equal(created[0].hidden, true) + assert.equal(created[0].title, 'Bot Chat') +}) + +test('createCanonicalChat omits hidden when the pref is off', async () => { + const { create, created } = loadCreate(false) + await create('beta') + assert.equal(created.length, 1) + assert.ok(!('hidden' in created[0]), 'hidden should be omitted, not false') +}) + +test('setHideBotChats persists the pref and reconciles known chats via session.set_hidden', () => { + // Source-level: the toggle calls session.set_hidden for each known canonical + // chat id and persists the pref to storage. + const fn = source.slice(source.indexOf('async function setHideBotChats('), source.indexOf('const avatarFetchInflight')) + assert.match(fn, /storage\?\.set\?\.\('hide-bot-chats', hidden\)/) + assert.match(fn, /session\.set_hidden.*session_id: sid, hidden/) + assert.match(fn, /\.map\(m => m && m\.chat\)/) +})