From 1f34431e9dbf238d228f64ab321630f93ae0dbb9 Mon Sep 17 00:00:00 2001 From: David Dudok de Wit <5354424+dokterdok@users.noreply.github.com> Date: Thu, 20 Aug 2026 20:14:00 +0200 Subject: [PATCH 01/10] feat(desktop): organize the global bot roster --- .../desktop/src/plugins/hermes-bots/plugin.js | 2245 +++++++++++++---- .../tests/active-now-strip.test.mjs | 31 +- .../hermes-bots/tests/bot-delete.test.mjs | 4 +- .../hermes-bots/tests/bots-home.test.mjs | 936 +++++++ .../hermes-bots/tests/bots-search.test.mjs | 32 +- .../tests/create-group-chat.test.mjs | 8 +- .../tests/group-chat-identity-edit.test.mjs | 7 +- .../tests/pane-dock-layout.test.mjs | 18 +- .../hermes-bots/tests/roster-groups.test.mjs | 37 +- .../hermes-bots/tests/roster-preview.test.mjs | 23 +- 10 files changed, 2822 insertions(+), 519 deletions(-) create mode 100644 apps/desktop/src/plugins/hermes-bots/tests/bots-home.test.mjs diff --git a/apps/desktop/src/plugins/hermes-bots/plugin.js b/apps/desktop/src/plugins/hermes-bots/plugin.js index 360aa5784a02..3573f0f3f989 100644 --- a/apps/desktop/src/plugins/hermes-bots/plugin.js +++ b/apps/desktop/src/plugins/hermes-bots/plugin.js @@ -4,7 +4,7 @@ * Left pane "Bots": one row per Hermes profile (a bot = an agent profile) with * a customizable avatar (shape + color + eyes, image, or pet). Click opens that * bot's chat; right-click → Edit Profile (avatar, title, description). - * "New Agent" creates a profile — Name / Title / Description with an + * "New Bot" creates a profile — Name / Title / Description with an * "Advanced" disclosure for full profile config. * * Right tile "Routines": scheduled tasks (Hermes cron jobs) scoped to the @@ -39,6 +39,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger, EmptyState, GlyphSpinner, @@ -83,6 +84,11 @@ const blobatarSvg = typeof sdk === 'undefined' ? undefined : sdk.blobatarSvg const createBudgetedLoop = typeof sdk === 'undefined' ? undefined : sdk.createBudgetedLoop const ID = 'hermes-bots' +/** Tree pane id of the Bots home workspace tab (openWorkspace prefixes + * `plugin-workspace:`). Tab visibility — not session focus — is what says + * who owns the CENTER once tabs exist; session focus only vetoes passive + * opens and, on its rising edge, yields the center to the chat. */ +const BOTS_HOME_PANE_ID = `plugin-workspace:${ID}:home` const ROSTER_KEY = [ID, 'roster'] const ROUTINES_KEY = [ID, 'routines'] const NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/ @@ -99,13 +105,16 @@ let pluginCtx = null /** Live roster snapshot for imperative handlers (context menus). */ const $lastRoster = atom([]) -/** Bots with chat activity the user hasn't seen yet (name -> true). +/** Last source inventory returned by the desktop-wide agent roster. */ +const $lastSources = atom([]) + +/** Bots with chat activity the user hasn't seen yet (connectionId::profile -> true). * Fed by the roster poll's activity watermark, so it catches EVERY * delivery path: RPC, CLI (bot-to-bot), cron runs, other machines. */ const $botUnread = atom({}) -// last_active watermark per bot, seeded on first poll so a fresh mount -// doesn't mark ancient history unread. +// last_active watermark per source-qualified bot, seeded on first poll so a +// fresh mount doesn't mark ancient history unread. const rosterWatermarks = new Map() let watermarksSeeded = false @@ -145,9 +154,9 @@ function trackInboundActivity(roster) { continue } - // Activity in the bot the user is currently looking at is already - // visible — never badge the open chat. - if ($selectedBot.get() === key) { + // 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 ($selectedRosterKey.get() === key) { continue } @@ -155,7 +164,7 @@ function trackInboundActivity(roster) { // Roster-hidden bots stay quiet: the unread flag above accumulates // silently (unhiding reveals the badge) but a hidden bot never toasts. - if (botRosterMeta(bot, $botMeta.get())?.hidden) { + if (isBotHidden(bot, $botMeta.get())) { continue } @@ -194,6 +203,75 @@ const $selectedBot = atom('default') /** Owner of the chat the user is LOOKING AT. Newer desktops expose a * connection-qualified owner. Older builds synthesize the previous * profile/gateway fallback and listen to both atoms when available. */ +/** Source-qualified Bot Mode selection. Restoring it is presentation-only: + * it never activates a gateway or creates a session. */ +const $selectedRosterKey = atom('') +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. */ +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 + * conditionally from render. */ +const $botChatFocused = atom(false) +/** True only while the Bots home is the visible main-area surface. A focused + * chat can remain alive behind it, so session focus alone cannot decide which + * roster row owns the visible workspace. */ +const $botsHomeFronted = atom(false) + +let botsHomeClose = null +let suppressBotsHomeReopen = false + +function saveSelectedRosterBot(bot) { + const key = botSelectionKey(bot) + + $selectedBot.set(key) + $selectedRosterKey.set(key) + + try { + Promise.resolve(pluginCtx?.storage?.set?.('selected-roster-bot-v1', key)).catch(() => undefined) + } catch { + /* storage unavailable — selection lasts for this window */ + } +} + +function clearSelectedRosterBot(bot) { + clearSelectedRosterKey(botSelectionKey(bot)) +} + +/** Drop the persisted selection when it is exactly this key — the caller has + * proven the owner is gone, not merely unreachable. An unreachable source + * KEEPS its key so the selection reconciles when the gateway returns. */ +function clearSelectedRosterKey(key) { + if ($selectedRosterKey.get() !== key) { + return + } + + $selectedRosterKey.set('') + + try { + Promise.resolve(pluginCtx?.storage?.set?.('selected-roster-bot-v1', '')).catch(() => undefined) + } catch { + /* storage unavailable — selection is cleared for this window */ + } +} + +/** Split a roster key back into its owner parts. Profile names cannot contain + * ':' (NAME_RE), so the first '::' is unambiguous. */ +function parseRosterKey(key) { + const raw = String(key || '') + const at = raw.indexOf('::') + + if (at < 0) { + return { connectionId: '', name: '' } + } + + return { connectionId: raw.slice(0, at), name: raw.slice(at + 2) } +} + const $focusedBotProfile = host.state.focusedSessionProfile || host.state.profile /** Profile that owns the chat currently on screen. Bot Mode opens another @@ -1709,30 +1787,21 @@ async function migrateBotMeta(storage = pluginCtx?.storage) { } // ── hidden bots (right-click → Hide Bot) ──────────────────────────────────── -// Hiding is a ROSTER-DISPLAY concern only: a hidden bot keeps working — -// @mentions still resolve, group-chat membership is untouched, its name -// still counts as taken, and an open chat stays open. The flag lives in bot -// meta (`hidden: true`), so it rides the same local-storage + server -// ui_meta pipeline as pins/titles and follows the profile across machines. -// Unhide writes `hidden: false` (never null): a null key survives the local -// `{ ...prev, ...patch }` merge while the server DELETES None keys, and -// that asymmetry lets mergeServerMeta resurrect a stale truthy copy. A -// literal false round-trips identically through both stores. +// Hiding is a ROSTER-DISPLAY concern only: a hidden bot keeps working, +// remains mentionable, keeps group membership, and any open chat stays open. /** Session-only view toggle: reveal hidden bots (dimmed) in the roster. */ const $showHiddenBots = atom(false) -/** Hidden flag for a roster row. Thin remote-source rows never read local - * meta (botRosterMeta returns null for them), so hide is by NAME on the - * active source; remote rows of the same name stay visible. */ function isBotHidden(bot, metaByName) { return Boolean(botRosterMeta(bot, metaByName)?.hidden) } -/** Hiding the selected bot re-homes the selection (the Routines pane - * follows it): first visible bot wins, then 'default' — unless default is - * itself hidden with nothing else visible, in which case the selection - * stays put rather than pointing somewhere even less real. */ +function isBotPinned(bot, metaByName) { + return Boolean(botRosterMeta(bot, metaByName)?.pinned) +} + +/** Hiding the selected bot re-homes the selection to the next visible owner. */ function fallbackSelectionAfterHide(name) { if ($selectedBot.get() !== name) { return @@ -1816,8 +1885,8 @@ function hideOwnedBotSessions() { // Titles Bot Mode itself mints for its plumbing sessions. Bot-to-bot CLI // handoffs (`hermes -p chat --in ~ -c "Bot Chat" --create-if-missing`) -// and mention handoffs create sessions with EXACTLY these titles; the -// "Group: " prefix is the member-session title ensureGroupChatSession has +// create sessions with EXACTLY these titles; the "Group: " prefix is the +// member-session title ensureGroupChatSession has // used since group chats shipped. Exact/prefix matching is deliberate — a // user's real conversation inside a bot profile keeps whatever title the // user gave it and is never touched. @@ -2209,6 +2278,12 @@ async function deleteBot(bot) { if ($selectedBot.get() === botSelectionKey(bot)) { $selectedBot.set('default') } + clearSelectedRosterBot(bot) + + if ($openBotChat.get()?.key === botRosterKey(bot)) { + $openBotChat.set(null) + syncBotsHomeWorkspace() + } queryClient.invalidateQueries({ queryKey: ROSTER_KEY }) @@ -3146,7 +3221,7 @@ async function mcpSetupSupported() { function McpSetupButton({ profile, entry, onDone, ensureProfile }) { // entry: { name, requires:[env keys], auth?, fromCatalog, installed } - // profile may be null at first (New Agent: the profile isn't created yet). + // profile may be null at first (New Bot: the profile isn't created yet). // ensureProfile() lazily creates it on the first setup action and returns the // slug, so OAuth / API-key setup works DURING creation, not only in Edit. const [phase, setPhase] = useState('idle') // idle | keys | oauth | busy | done | error @@ -3162,7 +3237,7 @@ function McpSetupButton({ profile, entry, onDone, ensureProfile }) { } }, [profile]) - // Resolve the target profile, creating it on demand for the New Agent flow. + // Resolve the target profile, creating it on demand for the New Bot flow. const resolveProfile = async () => { if (profileRef.current) { return profileRef.current @@ -3570,7 +3645,7 @@ async function generateAvatarImage(bot, title, description) { return res.image_data || res.image } -/** Shape grid + color swatches, shared by Edit Profile and New Agent. +/** Shape grid + color swatches, shared by Edit Profile and New Bot. * Layout uses inline grid styles — arbitrary Tailwind classes like * `grid-cols-7` are NOT in the app's precompiled CSS, which collapsed * this into a single vertical column. */ @@ -4225,6 +4300,7 @@ function cachedUnionRoster() { function mergeMultiSourceRoster(local, union, activeConnectionId, previous = []) { const localProfiles = Array.isArray(local?.profiles) ? local.profiles : [] const agents = Array.isArray(union?.agents) ? union.agents : [] + const sources = Array.isArray(union?.sources) ? union.sources : [] // A live id of null/'' means the window is on the unscoped local backend // (legacy hosts reported null for mode:'local'; the SDK now reports // 'local'). Do NOT fall back to registry primary when the third argument @@ -4370,7 +4446,10 @@ function mergeMultiSourceRoster(local, union, activeConnectionId, previous = []) const name = String(row?.name || '').trim() const key = `${connectionId}::${name || 'default'}` - if (!row?.remoteSource || !connectionId || !name || present.has(key)) { + // Ghost owners are presentation-only placeholders. Re-adopting one as + // a cached remote row would keep it alive after the selection changes + // and let an identity without its durable handle leak into shared state. + if (row?.ghost || !row?.remoteSource || !connectionId || !name || present.has(key)) { continue } @@ -4385,7 +4464,7 @@ function mergeMultiSourceRoster(local, union, activeConnectionId, previous = []) } } - return { ...local, profiles } + return { ...local, profiles: profiles.map(row => annotateBotSource(row, sources)), sources } } /** The @handle users tag a bot with. Multi-source rosters precompute the @@ -4605,6 +4684,60 @@ function persistBotMetaSnapshot(value, scoped = false) { } } +function sourceByConnection(sources) { + return new Map( + (Array.isArray(sources) ? sources : []) + .filter(source => source?.connectionId) + .map(source => [String(source.connectionId), source]) + ) +} + +/** Copy current source health onto a row without changing its owner. */ +function annotateBotSource(bot, sources) { + const id = String(bot?.connectionId || '').trim() + + if (!id) { + return bot + } + + const list = Array.isArray(sources) ? sources : [] + const source = sourceByConnection(list).get(id) + + if (!source) { + return list.length && bot?.sourceScoped ? { ...bot, sourceMissing: true, sourceReachable: false } : bot + } + + return { + ...bot, + connectionKind: bot.connectionKind || source.kind, + connectionLabel: bot.connectionLabel || source.label, + sourceError: source.error || null, + sourceMissing: false, + sourceReachable: source.reachable + } +} + +function botSourceStatus(bot) { + const error = String(bot?.sourceError || '').trim() + + if (bot?.sourceMissing) { + return { available: false, key: 'missing', label: 'Gateway removed', tone: 'bad' } + } + + if (error === 'connect-on-demand') { + return { available: true, key: 'on-demand', label: 'On demand', tone: 'muted' } + } + + if (error || bot?.sourceReachable === false) { + return { available: false, key: 'unavailable', label: 'Unavailable', tone: 'warn' } + } + + if (bot?.sourceReachable === true) { + return { available: true, key: 'ready', label: 'Ready', tone: 'good' } + } + + return { available: true, key: 'unknown', label: 'Status unknown', tone: 'muted' } +} // ── cross-connection routing ───────────────────────────────────────────────── // A bot from another registered connection (remoteSource rows) is reached // through host.requestProfile with a route descriptor; local bots keep the @@ -5149,6 +5282,125 @@ async function ensureBotMetadata(bot) { return botRosterMeta(bot, $botMeta.get()) || {} } +/** 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) { + const generation = ++botOpenGeneration + const key = botRosterKey(bot) + const meta = botRosterMeta(bot, $botMeta.get()) + // Keep the currently visible group as a fallback until this explicit action + // has actually fronted a new owner; a failed home open must not steal the + // center from a group the user was reading. + const previousGroup = $groupChatWorkspace.get() + + haptic('tap') + saveSelectedRosterBot(bot) + + if (bot.remoteSource) { + // Selection only. A remote bot must never be opened through whichever + // gateway happens to be live; remote mention delivery remains backend-owned. + $openBotChat.set(null) + $groupChatWorkspace.set(null) + + if (botsHomeEnabled()) { + // Explicitly front the selected owner but keep the existing group tab + // intact. If the workspace door refuses, restore the group selection so + // a failed roster action cannot leave the center ownerless. + if (!openBotsHomeWorkspace(true) && previousGroup) { + $groupChatWorkspace.set(previousGroup) + } + } else { + // Old shells have no home surface; keep the existing visible group as + // owner and offer only guidance, never renderer-owned remote delivery. + if (previousGroup) { + $groupChatWorkspace.set(previousGroup) + } + host.notify?.({ + kind: 'info', + title: displayName(bot, meta), + message: `Stay in this chat and message @${botHandle(bot.name, bot)} from a Bot Chat.` + }) + } + + return false + } + + $groupChatWorkspace.set(null) + + if ($botUnread.get()[key]) { + const next = { ...$botUnread.get() } + delete next[key] + $botUnread.set(next) + } + + try { + // Activation selects this row's source only. Canonical identity is resolved + // after that by the owner profile's "Bot Chat" title registry. + await prepareBotSource(bot) + } catch (error) { + if (generation === botOpenGeneration) { + $openBotChat.set(null) + if (previousGroup && !$groupChatWorkspace.get()) { + $groupChatWorkspace.set(previousGroup) + } + syncBotsHomeWorkspace() + host.notifyError?.(error, `Could not reach ${bot.connectionLabel || 'the gateway'}`) + } + + return false + } + + if (generation !== botOpenGeneration) { + return false + } + + try { + const registryId = await openBotCanonicalChat(bot.name) + + if (generation !== botOpenGeneration) { + return false + } + + if (registryId) { + // This is not an identity preference: opening already completed through + // the name registry. Keep only enough ephemeral state to release the + // home if another tab later claims the center. + $openBotChat.set({ key, openedRegistryId: String(registryId) }) + closeBotsHomeWorkspace() + return true + } + } catch (error) { + if (generation === botOpenGeneration) { + $openBotChat.set(null) + if (previousGroup && !$groupChatWorkspace.get()) { + $groupChatWorkspace.set(previousGroup) + } + syncBotsHomeWorkspace() + host.notifyError?.(error, `Could not open ${displayName(bot, meta)}'s chat — try again`) + } + + return false + } + + // An older Desktop without the profile-scoped draft API has no safe fallback: + // do not navigate the current workspace or create a draft on the wrong owner. + if (typeof host.newChat !== 'function') { + $openBotChat.set(null) + if (previousGroup && !$groupChatWorkspace.get()) { + $groupChatWorkspace.set(previousGroup) + } + syncBotsHomeWorkspace() + return false + } + + $openBotChat.set({ key, openedRegistryId: '' }) + closeBotsHomeWorkspace() + host.newChat(bot.name) + return true +} + function displayName(bot, meta) { // A configured alias route claiming this row overrides source-derived // identity: the friendly alias name must survive hosted-session @@ -5205,18 +5457,183 @@ function filterBots(roster, metaByName, query) { } return roster.filter(bot => { - const display = displayName(bot, botRosterMeta(bot, metaByName)).toLowerCase() + const meta = botRosterMeta(bot, metaByName) + const display = displayName(bot, meta).toLowerCase() const profile = (bot.name || '').toLowerCase() const handle = botHandle(bot.name, bot).toLowerCase() // Multi-source rows also match on their device name ("homelab" finds // every bot living on the Homelab connection). const sourceLabel = (bot.connectionLabel || '').toLowerCase() + const role = `${meta?.description || ''} ${bot.description || ''}`.toLowerCase() + const preview = String(botActivitySession(bot)?.preview || '').toLowerCase() return ( - display.includes(needle) || profile.includes(needle) || handle.includes(needle) || sourceLabel.includes(needle) + display.includes(needle) || + profile.includes(needle) || + handle.includes(needle) || + sourceLabel.includes(needle) || + role.includes(needle) || + preview.includes(needle) ) }) } +function filterBotsByGateway(roster, connectionId) { + if (!connectionId || connectionId === 'all') { + return roster + } + + return (roster || []).filter(bot => String(bot?.connectionId || '') === connectionId) +} + +function botNeedsHandleLabel(bot, roster, metaByName) { + const identity = displayName(bot, botRosterMeta(bot, metaByName)).trim().toLowerCase() + const connectionId = String(bot?.connectionId || '') + + return (roster || []).some( + candidate => + botRosterKey(candidate) !== botRosterKey(bot) && + String(candidate?.connectionId || '') === connectionId && + displayName(candidate, botRosterMeta(candidate, metaByName)).trim().toLowerCase() === identity && + botHandle(candidate.name, candidate) !== botHandle(bot.name, bot) + ) +} + +function groupMatchesRosterFilters(name, members, metaByName, query, connectionId) { + const inGateway = filterBotsByGateway(members, connectionId) + + if (connectionId && connectionId !== 'all' && inGateway.length === 0) { + return false + } + + const needle = String(query || '').trim().toLowerCase().replace(/^@/, '') + + return !needle || String(name || '').toLowerCase().includes(needle) || filterBots(inGateway, metaByName, needle).length > 0 +} + +function rosterGatewayOptions(sources, roster) { + const byId = new Map() + + for (const source of Array.isArray(sources) ? sources : []) { + const id = String(source?.connectionId || '').trim() + + if (id) { + byId.set(id, { ...source, connectionId: id, count: 0 }) + } + } + + for (const bot of roster || []) { + const id = String(bot?.connectionId || '').trim() + + if (!id) { + continue + } + + const source = byId.get(id) || { + connectionId: id, + kind: bot.connectionKind, + label: bot.connectionLabel || id, + reachable: bot.sourceReachable, + error: bot.sourceError, + count: 0 + } + source.count += 1 + byId.set(id, source) + } + + return [...byId.values()].sort((a, b) => + String(a.label || a.connectionId).localeCompare(String(b.label || b.connectionId), undefined, { + sensitivity: 'base' + }) + ) +} + +function rosterGatewaySections(botRows, gatewayOptions, gatewayFilter = 'all') { + const rows = Array.isArray(botRows) ? botRows : [] + const options = Array.isArray(gatewayOptions) ? gatewayOptions : [] + + if (gatewayFilter !== 'all' || options.length <= 1) { + return { sectioned: false, sections: [{ id: 'all', option: null, rows }] } + } + + const byId = new Map() + + for (const row of rows) { + const bot = row?.bot || row + const id = String(bot?.connectionId || 'legacy').trim() || 'legacy' + const bucket = byId.get(id) || [] + bucket.push(row) + byId.set(id, bucket) + } + + const known = new Set() + const sections = [] + + for (const option of options) { + const id = String(option?.connectionId || '').trim() + const sectionRows = byId.get(id) + + if (!id || !sectionRows?.length) { + continue + } + + known.add(id) + sections.push({ id, option, rows: sectionRows }) + } + + for (const [id, sectionRows] of byId) { + if (known.has(id)) { + continue + } + + const bot = sectionRows[0]?.bot || sectionRows[0] + sections.push({ + id, + option: { + connectionId: id, + kind: bot?.connectionKind || 'remote', + label: bot?.connectionLabel || (id === 'legacy' ? 'Current gateway' : id), + reachable: bot?.sourceReachable, + error: bot?.sourceError + }, + rows: sectionRows + }) + } + + return { sectioned: true, sections } +} + +function gatewayKindIcon(kind) { + const icons = (typeof sdk === 'undefined' ? null : sdk.icons) || {} + + if (kind === 'local') return icons.Monitor + if (kind === 'cloud') return icons.Cloud + if (kind === 'ssh') return icons.Terminal + return icons.Network +} + +function gatewayKindCodicon(kind) { + if (kind === 'local') return 'device-desktop' + if (kind === 'cloud') return 'cloud' + if (kind === 'ssh') return 'terminal' + return 'remote-explorer' +} + +/** Match the gateway switcher's Tabler glyphs while keeping older SDK shells + * usable until they expose the shared icon namespace. */ +function GatewayKindGlyph({ className, kind }) { + const Icon = gatewayKindIcon(kind) + + return jsx('span', { + 'aria-hidden': true, + className: cn('grid size-3.5 shrink-0 place-items-center', className), + 'data-connection-kind': kind || 'remote', + 'data-slot': 'connection-glyph', + children: Icon + ? jsx(Icon, { className: 'size-3' }) + : jsx(Codicon, { name: gatewayKindCodicon(kind), className: 'text-[0.75rem]' }) + }) +} + function slugify(value) { return value .toLowerCase() @@ -5338,7 +5755,10 @@ function groupChatMemberBots(group, roster, metaByName) { } seated.add(key) - remote.push((roster || []).find(bot => botRosterKey(bot) === key) || descriptor) + // A selected-but-offline ghost intentionally carries only enough identity + // to paint the roster. Never let it replace the room's durable descriptor, + // which owns the full handle/title used by mentions and remote sync. + remote.push((roster || []).find(bot => !bot?.ghost && botRosterKey(bot) === key) || descriptor) } return [...local, ...remote] @@ -6807,6 +7227,8 @@ function generatedSessionTitle(session, preview) { /** Roster liveness window: a bot whose last message landed within this many * seconds is treated as "active now" (pulsing dot in its row). */ const ACTIVE_WINDOW_S = 90 +const RECENT_ACTIVITY_WINDOW_S = 7 * 24 * 60 * 60 +const BOT_ROSTER_SEARCH_THRESHOLD = 8 /** The session whose activity best represents this bot — the FRESHER of the * canonical Bot Chat (canonical_session, the profile's "Bot Chat" registry @@ -6860,13 +7282,54 @@ function activeBots(roster, activeProfile, gatewayState, now = Date.now()) { }) } +function rosterActivityMatches(row, filter, now = Date.now()) { + if (!filter || filter === 'all') { + return true + } + + if (filter === 'active') { + return Boolean(row?.active) + } + + const activity = Number(row?.activity || 0) + const recent = Boolean(activity && now - activity <= RECENT_ACTIVITY_WINDOW_S * 1000) + + return filter === 'recent' ? recent : !recent +} + +function botRowOwnsWorkspace( + bot, + activeGroup, + botChatFocused, + botsHomeFronted, + focusedOwner, + selectedRosterKey +) { + if (activeGroup) { + return false + } + + if (botsHomeFronted || !botChatFocused) { + return selectedRosterKey === botSelectionKey(bot) + } + + return isActiveRosterBot(bot, focusedOwner) +} + // ── bot row ────────────────────────────────────────────────────────────────── -function BotRow({ bot, onDelete, onEdit, onGroup }) { +function BotRow({ bot, onDelete, onEdit, onGroup, showHandle }) { const activeProfile = useValue(host.state.profile) const focusedOwner = focusedRosterOwner(useValue($focusedBotOwner)) + const selectedRosterKey = useValue($selectedRosterKey) + const botChatFocused = useValue($botChatFocused) + const botsHomeFronted = useValue($botsHomeFronted) const activeGroup = useValue($groupChatWorkspace) - const meta = botRosterMeta(bot, useValue($botMeta)) + const allMeta = useValue($botMeta) + const meta = botRosterMeta(bot, allMeta) + const hidden = isBotHidden(bot, allMeta) + const pinned = isBotPinned(bot, allMeta) + const sourceStatus = botSourceStatus(bot) const groups = botGroups(meta) const last = bot.last_session // Highlight follows the chat on screen (focused session's owner), not the @@ -6875,7 +7338,19 @@ function BotRow({ bot, onDelete, onEdit, onGroup }) { // A selected group chat suppresses every bot-row highlight: the group row // owns the selection then (#88979). const activeConnectionId = String(host.state.connectionId?.get?.() || 'local').trim() - const isActive = !activeGroup && isActiveRosterBot(bot, focusedOwner) + // The highlight follows whoever owns the MAIN workspace. While a chat owns + // it, that chat's profile wins (a stale roster click must not key the + // highlight to a bot you are not reading). While the Bots home owns it, the + // source-qualified selection is the owner — and it is the only rule that + // can highlight a remote row, which has no focusable local chat. + const isActive = botRowOwnsWorkspace( + bot, + activeGroup, + botChatFocused, + botsHomeFronted, + focusedOwner, + selectedRosterKey + ) // Turn-busy is a SOCKET fact: only the gateway-home profile can be mid-turn. const isGatewayHome = !bot.remoteSource && bot.name === activeProfile && isActiveRosterBot(bot, { name: activeProfile, connectionId: activeConnectionId }) @@ -6891,19 +7366,13 @@ function BotRow({ bot, onDelete, onEdit, onGroup }) { // last_session alone shows "6d ago" on a bot you just messaged. const previewSession = bot.canonical_session || last const activitySession = botActivitySession(bot) - // A live kanban/tool worker counts as activity (#90268): pulse + fresh - // age while it runs, falling back to chat activity when it ends. + // A live kanban/tool worker counts as activity (#90268): fresh age while it + // runs, falling back to chat activity when it ends. const workerActive = workerActiveAt(bot) - const activeNow = - workerActive || - Boolean(activitySession?.last_active && Date.now() / 1000 - activitySession.last_active < ACTIVE_WINDOW_S) const rowAgeTs = workerActive ? Math.max(activitySession?.last_active || 0, bot.worker_session?.last_active || 0) : activitySession?.last_active || 0 - // Work pose only when this bot is actually doing something: the active - // profile while the gateway is busy, or a bot that wrote within the - // liveness window. Not every bot whenever the gateway is busy. - const botMood = (isGatewayHome && gatewayState === 'busy') || activeNow ? 'work' : 'idle' + const botMood = workerActive || (isGatewayHome && gatewayState === 'busy') ? 'work' : 'idle' // Subscribe on every render. A source switch turns the same keyed row from // thin to rich; conditionally calling useValue here breaks React hook order. const unreadByName = useValue($botUnread) @@ -6919,8 +7388,14 @@ function BotRow({ bot, onDelete, onEdit, onGroup }) { const displayPreview = stripPreviewMarkdown( fromBot ? (previewSession?.preview || '').replace(A2A_PREFIX_RE, '').trim() || '…' - : previewSession?.preview || bot.description || 'No conversations yet — say hi' + : previewSession?.preview || '' ) + const handle = botHandle(bot.name, bot) + const gatewayLabel = bot.connectionLabel || (bot.connectionId === 'local' ? 'This device' : '') + const showDetailsRow = Boolean(showHandle || displayPreview || fromBot) + const rowTooltip = [displayName(bot, meta), `@${handle}`, gatewayLabel, sourceStatus.label] + .filter(Boolean) + .join(' · ') const warm = () => { // Multi-source row: pre-dial the agent's OWN source (feature-detected). @@ -6945,61 +7420,9 @@ function BotRow({ bot, onDelete, onEdit, onGroup }) { } } - const open = async () => { - const generation = ++botOpenGeneration - haptic('tap') - $groupChatWorkspace.set(null) - $selectedBot.set(botSelectionKey(bot)) - - if ($botUnread.get()[botSelectionKey(bot)]) { - const next = { ...$botUnread.get() } - delete next[botSelectionKey(bot)] - $botUnread.set(next) - } - - // Refresh through the immutable owner route. Foreground activation is - // presentation state and is never routing authority for this open. - try { - await prepareBotSource(bot) - } catch (error) { - host.notifyError?.(error, `Could not reach ${bot.connectionLabel || 'the remote source'}`) - - return - } - - if (generation !== botOpenGeneration) { - return - } - - try { - // Identity is the NAMED registry row (profile → session titled - // "Bot Chat") on the bot's own source, resolved fresh on every click — - // preview identity and click identity agree because both describe that - // same row (#88200). - const id = await openBotCanonicalChat(bot) - - if (generation === botOpenGeneration && id) { - return - } - } catch (error) { - if (generation === botOpenGeneration) { - host.notifyError?.(error, `Could not open ${displayName(bot, meta)}'s chat — try again`) - } - - return - } - - if (generation !== botOpenGeneration) { - return - } - - if (typeof host.newChat === 'function') { - // Older gateway without profile-scoped session.create — plain draft. - newBotChat(bot) - } else { - host.navigate('/') - } - } + // Rows and Active Now share the exact-owner open path; only that path may + // activate a source and resolve the canonical Bot Chat. + const open = () => void openRosterBot(bot) const row = jsxs('button', { type: 'button', @@ -7008,15 +7431,20 @@ function BotRow({ bot, onDelete, onEdit, onGroup }) { className: cn( 'flex w-full min-w-0 max-w-full items-center gap-2.5 overflow-hidden rounded-md px-2 py-2 text-left transition-colors', 'hover:bg-(--chrome-action-hover)', - isActive && 'bg-(--chrome-action-hover)', - // Hidden bots only render while the header eye toggle is on — dimmed, - // so the temporary reveal reads as a different state from the roster. - meta?.hidden && 'opacity-60' + isActive && 'bg-(--ui-row-active-background)' ), + 'aria-label': rowTooltip, children: [ jsx('div', { - className: 'shrink-0', - children: jsx(BotFace, { shape, color, image: photo ? image : null, size: 34, name: bot.name, mood: botMood }) + className: cn('shrink-0', !sourceStatus.available && 'grayscale opacity-60'), + children: jsx(BotFace, { + shape, + color, + image: photo ? image : null, + size: 34, + name: bot.name, + mood: botMood + }) }), jsxs('div', { className: 'min-w-0 flex-1', @@ -7025,57 +7453,41 @@ function BotRow({ bot, onDelete, onEdit, onGroup }) { className: 'flex items-baseline justify-between gap-2', children: [ jsxs('div', { - className: 'flex min-w-0 items-baseline gap-1.5 truncate', + className: 'flex min-w-0 items-center gap-1.5', children: [ - meta?.pinned - ? jsx('span', { - className: 'shrink-0 text-[0.6875rem] text-(--ui-text-quaternary)', - title: 'Pinned', - children: '📌' + pinned + ? jsx(Tip, { + label: 'Pinned', + children: jsx(Codicon, { + name: 'pinned', + className: 'shrink-0 text-[0.6875rem] text-(--ui-text-quaternary)' + }) }) : null, - meta?.hidden - ? jsx(Codicon, { - name: 'eye-closed', - className: 'shrink-0 text-[0.6875rem] text-(--ui-text-quaternary)', - title: 'Hidden from the roster' + hidden + ? jsx(Tip, { + label: 'Hidden from the roster', + children: jsx(Codicon, { + name: 'eye-closed', + className: 'shrink-0 text-[0.6875rem] text-(--ui-text-quaternary)' + }) }) : null, - jsx('span', { - className: cn( - 'truncate text-[0.8125rem] font-medium', - bot.remoteSource && 'max-w-[42%] shrink-0' - ), - children: displayName(bot, meta) + jsx(Tip, { + label: rowTooltip, + children: jsx('span', { + className: 'min-w-0 truncate text-[0.8125rem] font-medium', + children: displayName(bot, meta) + }) }), - showsHandle(bot.name, meta, bot) - ? jsx('span', { - className: 'min-w-0 truncate font-mono text-[0.6875rem] text-(--ui-text-quaternary)', - children: `@${botHandle(bot.name, bot)}` - }) - : null, - bot.remoteSource - ? jsx('span', { - className: - 'max-w-[28%] shrink-0 truncate rounded bg-(--chrome-action-hover) px-1 font-mono text-[0.625rem] text-(--ui-text-tertiary)', - title: `Lives on ${bot.connectionLabel}`, - children: bot.connectionLabel - }) - : null ] }), unread ? jsx('span', { - className: 'size-2 shrink-0 rounded-full bg-(--ui-accent,#4f9cf9)', + className: 'size-2 shrink-0 rounded-full bg-(--ui-accent)', 'aria-label': 'unread' }) : null, - activeNow - ? jsx('span', { - className: 'hermes-bots-pulse size-1.5 shrink-0 rounded-full bg-(--ui-accent,#4f9cf9)', - title: workerActive ? 'Working on a task right now' : 'Active in the last 90s' - }) - : null, rowAgeTs ? jsx('span', { className: 'shrink-0 text-[0.6875rem] text-(--ui-text-quaternary)', @@ -7084,35 +7496,38 @@ function BotRow({ bot, onDelete, onEdit, onGroup }) { : null ] }), - jsxs('div', { - className: 'flex min-w-0 items-center gap-1', - children: [ - jsx('div', { - className: fromBot - ? 'min-w-0 truncate text-xs italic text-(--ui-accent,#4f9cf9)' - : 'min-w-0 truncate text-xs text-(--ui-text-tertiary)', - children: displayPreview - }), - fromBot - ? jsxs('span', { - className: - 'flex shrink-0 items-center gap-1 rounded-full bg-(--chrome-action-hover) px-1.5 py-px text-[0.625rem] font-medium text-(--ui-accent,#4f9cf9)', - title: `Last message came from @${fromBot} (bot-to-bot)`, - children: ['🤖', `@${fromBot}`] - }) - : null - ] - }) - ] - }) - ] - }) - - return jsxs(ContextMenu, { - children: [ - jsx(ContextMenuTrigger, { asChild: true, children: row }), - jsxs(ContextMenuContent, { - children: [ + showDetailsRow + ? jsxs('div', { + className: 'flex min-w-0 items-center gap-1.5 text-xs text-(--ui-text-tertiary)', + children: [ + showHandle + ? jsx('span', { + className: 'shrink-0 font-mono text-[0.6875rem] text-(--ui-text-quaternary)', + children: `@${handle}` + }) + : null, + showHandle && displayPreview + ? jsx('span', { className: 'shrink-0 text-(--ui-text-quaternary)', children: '·' }) + : null, + displayPreview + ? jsx('span', { + className: cn('min-w-0 truncate', fromBot && 'italic'), + children: displayPreview + }) + : null + ] + }) + : null + ] + }) + ] + }) + + return jsxs(ContextMenu, { + children: [ + jsx(ContextMenuTrigger, { asChild: true, children: row }), + jsxs(ContextMenuContent, { + children: [ jsx(ContextMenuItem, { onSelect: () => { void ensureBotMetadata(bot).then(current => { @@ -7124,7 +7539,7 @@ function BotRow({ bot, onDelete, onEdit, onGroup }) { }) }).catch(error => host.notifyError?.(error, 'Could not load bot metadata')) }, - children: meta?.pinned ? 'Unpin' : 'Pin to top' + children: pinned ? 'Unpin' : 'Pin to top' }), jsx(ContextMenuItem, { onSelect: () => { @@ -7144,7 +7559,7 @@ function BotRow({ bot, onDelete, onEdit, onGroup }) { }) }).catch(error => host.notifyError?.(error, 'Could not load bot metadata')) }, - children: meta?.hidden ? 'Unhide Bot' : 'Hide Bot' + children: hidden ? 'Unhide' : 'Hide' }), jsx(ContextMenuSeparator, {}), jsx(ContextMenuItem, { @@ -7366,7 +7781,7 @@ function ModelPicker({ bot = null, value, onChange, placeholderModel = 'gateway // ── advanced profile config (skills / toolsets / model / SOUL) ────────────── // -// Shared by Edit Profile and New Agent (edit mode only for skills/toolsets — +// Shared by Edit Profile and New Bot (edit mode only for skills/toolsets — // a not-yet-created profile has nothing installed to toggle). Backed by // profiles.describe / profiles.configure; feature-detects older gateways. @@ -8552,8 +8967,8 @@ function CreateAgentDialog({ open, onClose, roster }) { host.notify({ kind: 'success', message: remoteTarget - ? `Agent "${displayName({ name: slug, title })}" created on ${targetLabel}` - : `Agent "${displayName({ name: slug, title })}" created` + ? `Bot "${displayName({ name: slug, title })}" created on ${targetLabel}` + : `Bot "${displayName({ name: slug, title })}" created` }) const wasRemote = remoteTarget reset() @@ -8611,7 +9026,7 @@ function CreateAgentDialog({ open, onClose, roster }) { children: [ jsxs(DialogHeader, { children: [ - jsx(DialogTitle, { children: 'New Agent' }), + jsx(DialogTitle, { children: 'New Bot' }), jsx(DialogDescription, { children: 'A named teammate with its own memory, skills, and chat. It can message your other agents.' }) @@ -8875,7 +9290,7 @@ function CreateAgentDialog({ open, onClose, roster }) { className: 'px-2 py-3 text-center text-xs text-(--ui-text-tertiary)', children: taken ? 'That name is taken — pick another before configuring capabilities.' - : 'Name the agent first — a draft profile is created when you open this tab (discarded if you cancel).' + : 'Name the bot first — a draft profile is created when you open this tab (discarded if you cancel).' }) : !createdForCaps ? jsx('div', { @@ -9088,7 +9503,7 @@ function CreateAgentDialog({ open, onClose, roster }) { jsx(Button, { disabled: busy || !valid || taken, onClick: submit, - children: busy ? 'Creating…' : 'Create Agent' + children: busy ? 'Creating…' : 'Create Bot' }) ] }) @@ -9978,29 +10393,36 @@ function ActiveNowStrip({ roster, activeProfile, gatewayState, metaByName, onOpe const photo = Boolean(image && !isBackfilledFacePng(image)) const label = displayName(bot, meta) - return jsx('button', { - type: 'button', - title: `Open ${label}'s chat`, - className: cn( - 'flex items-center gap-1.5 rounded-md bg-(--chrome-action-hover) px-1.5 py-1 text-left transition-colors', - 'hover:bg-(--chrome-action-hover) hover:text-foreground' - ), - onClick: () => onOpen(bot), - children: [ - jsx(BotFace, { - shape, - color, - image: photo ? image : null, - size: 24, - name: bot.name, - mood: 'work' - }), - jsx('span', { - className: 'max-w-28 truncate text-xs font-medium', - children: label + return jsx( + Tip, + { + label: `Open ${label}'s chat`, + children: jsx('button', { + type: 'button', + 'aria-label': `Open ${label}'s chat`, + className: cn( + 'flex items-center gap-1.5 rounded-md bg-(--chrome-action-hover) px-1.5 py-1 text-left transition-colors', + 'hover:bg-(--chrome-action-hover) hover:text-foreground' + ), + onClick: () => onOpen(bot), + children: [ + jsx(BotFace, { + shape, + color, + image: photo ? image : null, + size: 24, + name: bot.name, + mood: 'work' + }), + jsx('span', { + className: 'max-w-28 truncate text-xs font-medium', + children: label + }) + ] }) - ] - }, botRosterKey(bot)) + }, + botRosterKey(bot) + ) }) ] }) @@ -10288,8 +10710,11 @@ function CreateGroupChatDialog({ open, roster, onClose, onCreated }) { } }, [open]) - const selected = roster.filter(bot => checked[botRosterKey(bot)]) - const visible = filterBots(roster, allMeta, query) + // An outage placeholder preserves one selected owner's identity in the + // sidebar, but it is not a routable room member. Never offer it here. + const selectableRoster = roster.filter(bot => !bot?.ghost) + const selected = selectableRoster.filter(bot => checked[botRosterKey(bot)]) + const visible = filterBots(selectableRoster, allMeta, query) const atCap = selected.length >= GROUP_CHAT_MAX_MEMBERS const placeholder = selected.length ? selected.map(bot => displayName(bot, botRosterMeta(bot, allMeta))).join(', ') @@ -10439,7 +10864,7 @@ function CreateGroupChatDialog({ open, roster, onClose, onCreated }) { }) : jsx('div', { className: 'px-1.5 py-3 text-center text-xs text-(--ui-text-tertiary)', - children: query.trim() ? `No bots match “${query.trim()}”` : 'No bots yet — create agents first.' + children: query.trim() ? `No bots match “${query.trim()}”` : 'No bots yet — create one first.' }) }) }), @@ -11012,6 +11437,9 @@ function GroupChatWorkspace({ group, members, onBack, visible = true }) { const roomClarifies = Object.values(clarifyAll || {}) .filter(entry => entry?.group === group) .sort((a, b) => (a.at || 0) - (b.at || 0)) + const availableMembers = members.filter(member => botSourceStatus(member).available).length + const availabilityLabel = `${availableMembers} of ${members.length} available` + const memberNames = members.map(b => displayName(b, botRosterMeta(b, allMeta))).join(', ') || 'No bots in this group chat' const header = jsxs('div', { className: 'flex items-center gap-2 px-2.5 pt-2.5 pb-2', @@ -11027,48 +11455,49 @@ function GroupChatWorkspace({ group, members, onBack, visible = true }) { ? jsx('img', { src: room.image, alt: '', - className: 'size-6 shrink-0 rounded-full object-cover ring-1 ring-(--ui-stroke-secondary)' + className: 'size-6 shrink-0 rounded-md object-cover ring-1 ring-(--ui-stroke-secondary)' }) - : null, + : jsx('span', { + className: + 'flex size-6 shrink-0 items-center justify-center rounded-md bg-(--chrome-action-hover) text-(--ui-text-tertiary)', + children: jsx(Codicon, { name: 'organization' }) + }), jsx('div', { className: 'min-w-0 flex-1 truncate text-sm font-semibold', - children: `${group} — group chat` + children: group }), - // Member faces: the room's roster at a glance, matching each bot's - // avatar in the sidebar. Falls back to the count for the title tooltip. - jsx('div', { - className: 'flex shrink-0 items-center -space-x-1.5', - title: members.map(b => displayName(b, botRosterMeta(b, allMeta))).join(', '), - children: members.slice(0, 6).map(b => { - const bMeta = botRosterMeta(b, allMeta) - const { shape, color, image } = botAppearance(b.name, bMeta) - const photo = Boolean(image && !isBackfilledFacePng(image)) - - return jsx('div', { - className: 'rounded-full ring-2 ring-(--ui-bg-primary,#111)', - children: jsx(BotFace, { shape, color, image: photo ? image : null, size: 20, name: b.name }) - }, botRosterKey(b)) + jsx(Tip, { + label: memberNames, + children: jsx('span', { + className: cn( + 'shrink-0 text-[0.65rem] text-(--ui-text-quaternary)', + members.length > 0 && availableMembers < members.length && 'text-amber-600 dark:text-amber-300' + ), + 'aria-label': availabilityLabel, + children: members.length > 0 && availableMembers < members.length ? availabilityLabel : `${members.length} bots` }) }), - jsx('span', { - className: 'shrink-0 text-[0.65rem] text-(--ui-text-quaternary)', - children: `${members.length} bots` - }), - jsx(Button, { - variant: 'ghost', - size: 'sm', - className: 'shrink-0 text-(--ui-text-tertiary) hover:text-foreground', - title: `Group settings — rename ${group} or set a room picture`, - onClick: () => setSettingsOpen(true), - children: jsx(Codicon, { name: 'gear' }) + jsx(Tip, { + label: `Group settings — rename ${group} or set a room picture`, + children: jsx(Button, { + variant: 'ghost', + size: 'sm', + className: 'shrink-0 text-(--ui-text-tertiary) hover:text-foreground', + 'aria-label': `Group settings for ${group}`, + onClick: () => setSettingsOpen(true), + children: jsx(Codicon, { name: 'gear' }) + }) }), - jsx(Button, { - variant: 'ghost', - size: 'sm', - className: 'shrink-0 text-(--ui-text-tertiary) hover:text-destructive', - title: `Disband the ${group} group chat`, - onClick: () => setConfirmDisband(true), - children: jsx(Codicon, { name: 'trash' }) + jsx(Tip, { + label: `Disband the ${group} group chat`, + children: jsx(Button, { + variant: 'ghost', + size: 'sm', + className: 'shrink-0 text-(--ui-text-tertiary) hover:text-destructive', + 'aria-label': `Disband ${group}`, + onClick: () => setConfirmDisband(true), + children: jsx(Codicon, { name: 'trash' }) + }) }) ] }) @@ -11673,6 +12102,402 @@ function closeGroupChatMainTab(group) { } } +function selectedRosterBot(roster, key) { + return (Array.isArray(roster) ? roster : []).find(bot => botRosterKey(bot) === key) || null +} + +/** A selected owner whose roster row is absent because its SOURCE is down — + * not because the bot is gone. Identity comes from the key itself, so the + * selection survives a relaunch with that gateway offline and reconciles + * onto the live row (same key) when it returns, without duplicating it. + * + * Returns null when the selection is provably invalid instead: a reachable + * source that no longer lists the bot, or a source that left the registry + * while other sources are live. Unknown (no sources yet) is NOT proof. */ +function ghostRosterOwner(key, sources) { + const { connectionId, name } = parseRosterKey(key) + + if (!name) { + return null + } + + const list = Array.isArray(sources) ? sources : [] + const source = sourceByConnection(list).get(connectionId) + + if (source ? source.reachable === true : list.length > 0) { + return null + } + + return { + name, + connectionId, + ghost: true, + remoteSource: connectionId !== 'local', + connectionKind: source?.kind, + connectionLabel: source?.label, + sourceError: source?.error || null, + sourceMissing: false, + sourceReachable: false + } +} + +/** Keep the exact selected owner visible through a cold-start outage without + * persisting the whole remote roster. The source registry supplies the + * gateway identity/status; the source-qualified selection supplies the bot + * identity. Once that source answers again, the live row replaces the ghost + * (or reconciliation clears it when the bot was actually removed). */ +function rosterWithSelectedOwner(roster, sources, key) { + const rows = Array.isArray(roster) ? roster : [] + + if (!key || selectedRosterBot(rows, key)) { + return rows + } + + const ghost = ghostRosterOwner(key, sources) + + return ghost ? [...rows, ghost] : rows +} + +/** Keep the persisted selection honest against the live roster and seat a + * first selection when there is none. PRESENTATION ONLY: it never opens, + * prepares, activates, or creates anything — an unreachable owner keeps its + * selection rather than falling back onto some other gateway's bot. */ +function reconcileRosterSelection(roster, sources, metaByName) { + if (!$rosterHydrated.get() || !$selectedRosterHydrated.get()) { + return + } + + const key = $selectedRosterKey.get() + + if (key) { + if (selectedRosterBot(roster, key) || ghostRosterOwner(key, sources)) { + return + } + + clearSelectedRosterKey(key) + } + + const first = (Array.isArray(roster) ? roster : []).find( + bot => !isBotHidden(bot, metaByName) && botSourceStatus(annotateBotSource(bot, sources)).available + ) + + if (first) { + saveSelectedRosterBot(first) + } +} + +function BotsHomeView() { + const roster = useValue($lastRoster) + const sources = useValue($lastSources) + const selectedKey = useValue($selectedRosterKey) + const rosterHydrated = useValue($rosterHydrated) + const selectionHydrated = useValue($selectedRosterHydrated) + const allMeta = useValue($botMeta) + const live = selectedRosterBot(roster, selectedKey) + + if (!rosterHydrated || !selectionHydrated) { + return jsx('div', { + className: 'flex h-full items-center justify-center', + 'aria-label': 'Loading bots', + children: jsx(GlyphSpinner, { spinner: 'breathe', className: 'text-(--ui-text-tertiary)' }) + }) + } + + const ghost = live ? null : ghostRosterOwner(selectedKey, sources) + const bot = live ? annotateBotSource(live, sources) : ghost + + if (!bot) { + return jsx('div', { + className: 'flex h-full items-center justify-center px-6', + children: jsx(EmptyState, { + icon: roster.length ? 'hubot' : 'add', + title: roster.length ? 'Choose a bot or group chat' : 'No bots yet', + description: roster.length ? 'Pick one from the Bots sidebar.' : 'Create your first bot from the Bots sidebar.' + }) + }) + } + + const meta = botRosterMeta(bot, allMeta) + const status = botSourceStatus(bot) + // A ghost is reconstructed from a persisted owner key while its gateway is + // offline. That proves the profile name, not its public mention handle. + const handle = bot.ghost ? '' : botHandle(bot.name, bot) + const gateway = bot.connectionLabel || (bot.connectionId === 'local' ? 'This device' : 'Hermes gateway') + const gatewayKind = bot.connectionKind || (bot.connectionId === 'local' ? 'local' : 'remote') + const { shape, color, image } = botAppearance(bot.name, meta) + const photo = image && !isBackfilledFacePng(image) ? image : null + const description = String(meta?.description || bot.description || '').trim() + const unavailable = !status.available + const sourceRemoved = status.key === 'missing' + // Retry re-polls the roster on the bot's OWN source. It never activates or + // re-routes anything: if the gateway is back, its row reappears under the + // same key and this view reconciles onto it. + const retrySource = () => { + haptic('tap') + queryClient.invalidateQueries({ queryKey: ROSTER_KEY }) + } + + return jsxs('div', { + className: 'flex h-full min-h-0 flex-col bg-background', + children: [ + jsxs('header', { + className: + 'flex min-w-0 items-center gap-3 border-b border-(--ui-stroke-tertiary) px-5 py-3.5', + children: [ + jsx(BotFace, { shape, color, image: photo, size: 38, name: bot.name, mood: 'idle' }), + jsxs('div', { + className: 'min-w-0 flex-1', + children: [ + jsx('h1', { + className: 'truncate text-sm font-semibold text-foreground', + children: displayName(bot, meta) + }), + jsxs('div', { + className: 'flex min-w-0 items-center gap-1.5 text-xs text-(--ui-text-tertiary)', + children: [ + jsx('span', { children: 'Bot' }), + handle + ? jsx('span', { className: 'truncate font-mono', children: `· @${handle}` }) + : null + ] + }) + ] + }), + // Tip wraps ONE element (Radix asChild) — the screen-reader text + // rides inside the trigger, not beside it. + jsx(Tip, { + label: `${gateway} · ${gatewayKind} · ${status.label}`, + children: jsxs('div', { + className: 'flex max-w-[45%] items-center gap-1.5 text-xs text-(--ui-text-tertiary)', + children: [ + jsx('span', { className: 'sr-only', children: `${gateway}, ${status.label}` }), + jsx(GatewayKindGlyph, { kind: gatewayKind }), + jsx('span', { className: 'min-w-0 truncate', children: gateway }), + unavailable + ? jsx(Codicon, { + name: 'debug-disconnect', + className: 'shrink-0 text-amber-600 dark:text-amber-300', + 'aria-hidden': true + }) + : null + ] + }) + }) + ] + }), + jsx('main', { + className: 'flex min-h-0 flex-1 items-center justify-center overflow-y-auto px-6 py-10', + children: jsxs('div', { + className: 'flex w-full max-w-2xl flex-col items-center text-center', + children: [ + jsx(BotFace, { shape, color, image: photo, size: 76, name: bot.name, mood: 'idle' }), + jsx('h2', { + className: 'mt-5 text-xl font-semibold text-foreground', + children: displayName(bot, meta) + }), + description + ? jsx('p', { + className: 'mt-2 max-w-xl text-sm leading-6 text-(--ui-text-tertiary)', + children: description + }) + : null, + unavailable || !bot.remoteSource + ? jsx('p', { + className: cn( + 'mt-4 max-w-lg text-xs leading-5', + unavailable ? 'text-amber-700 dark:text-amber-300' : 'text-(--ui-text-tertiary)' + ), + children: unavailable + ? sourceRemoved + ? `${gateway} was removed. Choose another bot from the sidebar.` + : `${gateway} is unavailable. Retry when it is back online.` + : 'Open this bot’s continuous chat. Its background work keeps running when you switch away.' + }) + : jsx('p', { + className: 'mt-4 max-w-lg text-xs leading-5 text-(--ui-text-tertiary)', + children: `This bot lives on ${gateway}. Mention it from any Bot Chat to send it a message.` + }), + unavailable && !sourceRemoved + ? jsx(Button, { + variant: 'secondary', + size: 'sm', + className: 'mt-5', + onClick: retrySource, + children: 'Retry' + }) + : bot.remoteSource + ? null + : jsx(Button, { + variant: 'secondary', + size: 'sm', + className: 'mt-5', + onClick: () => void openRosterBot(bot), + children: 'Open chat' + }) + ] + }) + }) + ] + }) +} + +function closeBotsHomeWorkspace() { + if (typeof botsHomeClose !== 'function') { + return + } + + const close = botsHomeClose + botsHomeClose = null + suppressBotsHomeReopen = true + + try { + close() + } catch { + /* workspace already closed */ + } finally { + suppressBotsHomeReopen = false + } +} + +/** The Bot home needs BOTH the main-area door and pane visibility to behave. + * Older shells keep their previous surfaces untouched (no home at all). */ +function botsHomeEnabled() { + return typeof host.openWorkspace === 'function' && typeof host.paneVisibility === 'function' +} + +/** True when a session owns the main workspace. Prefers the focused STORED + * session (tab focus moves without swapping the gateway socket); bare test + * harnesses with neither atom drive $botChatFocused directly. */ +function sessionOwnsWorkspace() { + const focused = host.state?.focusedStoredSessionId?.get?.() + + if (focused !== undefined) { + return Boolean(focused) + } + + const active = host.state?.activeSessionId?.get?.() + + return active === undefined ? $botChatFocused.get() : Boolean(active) +} + +/** The home tab currently holds the center's active tab slot. */ +function botsHomeVisible() { + if (typeof host.paneVisibility !== 'function') { + return false + } + + try { + return host.paneVisibility(BOTS_HOME_PANE_ID).get() === true + } catch { + return false + } +} + +/** A real bot chat owns the center. Cronjobs are BOT-scoped, so this — not + * mere Bot Mode visibility — is what may seat the Cronjobs tile: beside the + * ownerless home or a group chat it would describe whichever profile the + * socket happens to be homed on. While the home tab is fronted the chat is + * a hidden sibling layer, so the focused session does NOT count. */ +function botChatOwnsWorkspace() { + return ( + $botsPaneVisible.get() && + !$groupChatWorkspace.get() && + !botsHomeVisible() && + Boolean($openBotChat.get() || sessionOwnsWorkspace()) + ) +} + +/** May the home OPEN right now? `explicit` is a user gesture aimed at the + * home itself (selecting a remote/unavailable owner): it overrides the + * focused-session veto — the veto exists so PASSIVE events (boot, restore, + * polls) never cover a chat the user left in the center. */ +function botsHomeMayOpen(explicit) { + return ( + $botsPaneVisible.get() && + !$groupChatWorkspace.get() && + !$openBotChat.get() && + (explicit || !sessionOwnsWorkspace()) + ) +} + +function openBotsHomeWorkspace(explicit = false) { + if (!botsHomeEnabled() || !botsHomeMayOpen(explicit)) { + return false + } + + // Already open and fronted: nothing to do. Already open but backgrounded + // (a persisted layout can restore the tab behind the draft): re-open to + // re-front it. Never stack a second registration — a stale disposer would + // tear down the newer one. This cannot yank the center from a tab the + // user just chose: plugin events are sparse (sidebar/group/focus edges), + // and each of those either legitimately claims the center or cleared it. + if (botsHomeClose) { + if (botsHomeVisible()) { + return true + } + + closeBotsHomeWorkspace() + } + + try { + botsHomeClose = host.openWorkspace(`${ID}:home`, { + title: 'Bots', + minWidth: '24rem', + render: () => jsx(BotsHomeView, {}), + // Closing the tab is a decision, not a glitch: drop the handle and + // leave the center alone. The home returns on the next real signal + // (Bots tab regains focus, a chat closes, a group is left). + onClose: () => { + if (!suppressBotsHomeReopen) { + botsHomeClose = null + } + } + }) + + return typeof botsHomeClose === 'function' + } catch { + botsHomeClose = null + return false + } +} + +/** Passive reconcile. Opens the home only into an ownerless center; closes + * it only when a surface with a REAL owner claims the center (bot chat, + * group chat) or Bot Mode leaves the screen. The focused-session LEVEL + * deliberately does not close an open home — the home may sit over a + * focused-but-hidden chat after an explicit selection; the chat reclaims + * the center on its focus EDGE (handleWorkspaceFocusChange). */ +function syncBotsHomeWorkspace() { + if (!$botsPaneVisible.get() || $groupChatWorkspace.get() || $openBotChat.get()) { + closeBotsHomeWorkspace() + return + } + + openBotsHomeWorkspace(false) +} + +/** An opened bot chat stops owning the center once focus leaves it (closed, + * or another session took over). Without this the home could never come + * back: $openBotChat would claim ownership for a chat nobody is reading. + * + * The legacy newChat fallback has no registry id to compare — a draft with no + * focused session is still that bot's draft, so it only yields once some + * session actually takes focus. */ +function releaseStaleOpenBotChat(focusedStoredId) { + const open = $openBotChat.get() + + if (!open) { + return + } + + const focused = focusedStoredId === null || focusedStoredId === undefined ? '' : String(focusedStoredId) + const stale = open.openedRegistryId ? focused !== open.openedRegistryId : Boolean(focused) + + if (stale) { + $openBotChat.set(null) + } +} + /** Main-window wrapper: seats the member roster reactively (live roster + * bot meta + the room's stored cross-connection descriptors) so the room * keeps working as members change while the tab is open. Also subscribes to @@ -11707,6 +12532,10 @@ function GroupChatMainView({ group }) { * write itself repaints nothing, the duplicate stuck until an unrelated * re-render. */ function openGroupChat(group) { + // A room selection supersedes any bot-open transition still hydrating. + // The in-flight host navigation may complete underneath this workspace, + // but it may not later close or visually steal the room the user chose. + botOpenGeneration += 1 $groupNeedsYou.set({ ...$groupNeedsYou.get(), [group]: false }) if (typeof host.openWorkspace === 'function') { @@ -11740,14 +12569,11 @@ function openGroupChat(group) { $groupChatWorkspace.set(group) } -/** One group chat as ONE roster row — the Discord shape: stacked member - * avatars, group name, member count, the newest room line as the preview - * (markdown flattened), relative time of the last activity, and the - * needs-you badge on the row itself. Sorts into the same recency ordering - * as bot rows; clicking opens the room in the main chat window. */ +/** One group chat as one quiet roster row. The room owns one visual identity; + * member details stay in its tooltip and workspace instead of competing + * with bot avatars in the narrow sidebar. */ function GroupRow({ active, group, members, needsYou, onOpen, onDisband }) { const rooms = useValue($groupChats) - const allMeta = useValue($botMeta) const room = rooms[group] || { log: [] } const log = Array.isArray(room.log) ? room.log : [] const last = log.length ? log[log.length - 1] : null @@ -11758,8 +12584,9 @@ function GroupRow({ active, group, members, needsYou, onOpen, onDisband }) { const lastHandle = botHandle(lastFrom || 'bot', members.find(member => member?.name === lastFrom)) const preview = last ? `${last.from?.kind === 'user' ? 'You' : `@${lastHandle}`}: ${stripPreviewMarkdown(last.text) || '…'}` - : 'No messages yet — say hi to the room' - const faces = members.slice(0, 3) + : `${members.length} bots` + const availableMembers = members.filter(member => botSourceStatus(member).available).length + const availabilityLabel = `${availableMembers} of ${members.length} available` const row = jsxs('button', { type: 'button', @@ -11772,43 +12599,39 @@ function GroupRow({ active, group, members, needsYou, onOpen, onDisband }) { 'hover:bg-(--chrome-action-hover)', active && 'bg-(--ui-row-active-background)' ), + 'aria-label': `${group}, ${members.length} bots, ${availabilityLabel}`, children: [ - // Room picture when the user set one; else a composite avatar of up to - // three member faces fanned like Discord's group-DM icon; a bare glyph - // when the room has no seated members. - jsx('div', { - className: 'flex w-[34px] shrink-0 items-center justify-center', - children: room.image - ? jsx('img', { - src: room.image, - alt: '', - className: 'size-7 rounded-full object-cover ring-2 ring-(--ui-bg-primary,#111)' - }) - : faces.length - ? jsx('div', { - className: 'flex items-center -space-x-2.5', - children: faces.map(member => { - const meta = member.remoteSource ? null : allMeta[member.name] - const { shape, color, image } = botAppearance(member.name, meta) - - return jsx( - 'div', - { - className: 'rounded-full ring-2 ring-(--ui-bg-primary,#111)', - children: jsx(BotFace, { - shape, - color, - image: image && !isBackfilledFacePng(image) ? image : null, - size: 20, - name: member.name, - mood: 'idle' - }) - }, - botRosterKey(member) + jsxs('div', { + className: 'relative flex w-[34px] shrink-0 items-center justify-center', + children: [ + room.image + ? jsx('img', { + src: room.image, + alt: '', + className: cn( + 'size-8 rounded-md object-cover ring-1 ring-(--ui-stroke-tertiary)', + availableMembers === 0 && 'grayscale opacity-60' ) }) - }) - : jsx(Codicon, { name: 'organization', className: 'text-(--ui-text-tertiary)' }) + : jsx('span', { + className: cn( + 'flex size-8 items-center justify-center rounded-md bg-(--chrome-action-hover) text-(--ui-text-tertiary)', + availableMembers === 0 && 'opacity-60' + ), + children: jsx(Codicon, { name: 'organization' }) + }), + members.length > 0 && availableMembers < members.length + ? jsx(Tip, { + label: availabilityLabel, + children: jsx('span', { + className: + 'absolute -bottom-0.5 -right-0.5 flex size-4 items-center justify-center rounded-full bg-(--ui-bg-primary) text-[0.625rem] text-amber-600 ring-1 ring-(--ui-stroke-tertiary) dark:text-amber-300', + 'aria-label': availabilityLabel, + children: jsx(Codicon, { name: 'debug-disconnect' }) + }) + }) + : null + ] }), jsxs('div', { className: 'min-w-0 flex-1', @@ -11816,22 +12639,18 @@ function GroupRow({ active, group, members, needsYou, onOpen, onDisband }) { jsxs('div', { className: 'flex items-baseline justify-between gap-2', children: [ - jsxs('div', { - className: 'flex min-w-0 items-baseline gap-1.5 truncate', - children: [ - jsx('span', { className: 'truncate text-[0.8125rem] font-medium', children: group }), - jsx('span', { - className: 'shrink-0 text-[0.6875rem] text-(--ui-text-quaternary)', - children: `${members.length} bots` - }) - ] + jsx('span', { + className: 'min-w-0 flex-1 truncate text-[0.8125rem] font-medium', + children: group }), needsYou - ? jsx('span', { - className: - 'shrink-0 rounded-full bg-(--ui-accent,#4f9cf9) px-1.5 text-[0.6rem] font-semibold text-white', - title: 'A bot in this room needs your input', - children: 'needs you' + ? jsx(Tip, { + label: 'A bot in this group chat needs your input', + children: jsx(Codicon, { + name: 'question', + className: 'shrink-0 text-(--ui-accent)', + 'aria-label': 'Needs your input' + }) }) : null, lastAt @@ -11872,6 +12691,63 @@ function GroupRow({ active, group, members, needsYou, onOpen, onDisband }) { }) } +/** Foldable roster heading. It organizes rows visually but never supplies or + * reconstructs ownership; every action still receives the full bot row. */ +function RosterSectionHeader({ collapsed, count, gatewayKind, icon, label, onToggle, status, tip }) { + const button = jsxs('button', { + type: 'button', + 'aria-expanded': !collapsed, + className: + 'mt-1 flex w-full min-w-0 items-center gap-1.5 rounded-md px-2 py-1.5 text-left text-[0.6875rem] font-semibold uppercase tracking-wider text-(--ui-text-quaternary) transition-colors hover:bg-(--chrome-action-hover) hover:text-(--ui-text-secondary)', + onClick: onToggle, + children: [ + jsx(Codicon, { name: collapsed ? 'chevron-right' : 'chevron-down', className: 'shrink-0' }), + gatewayKind + ? jsx(GatewayKindGlyph, { kind: gatewayKind }) + : jsx(Codicon, { name: icon, className: 'shrink-0' }), + jsxs('span', { + className: 'flex min-w-0 items-center gap-1', + children: [ + jsx('span', { className: 'min-w-0 truncate', children: label }), + status && !status.available + ? jsx('span', { className: 'sr-only', children: status.label }) + : null + ] + }), + jsx('span', { className: 'min-w-0 flex-1', 'aria-hidden': true }), + jsx('span', { + className: 'shrink-0 font-normal tabular-nums text-(--ui-text-quaternary)', + children: count + }), + status && !status.available + ? jsx(Codicon, { + name: 'debug-disconnect', + className: 'shrink-0 text-amber-600 dark:text-amber-300', + 'aria-hidden': true + }) + : null + ] + }) + + return tip ? jsx(Tip, { label: tip, children: button }) : button +} + +function GatewaySectionHeading({ collapsed, count, onToggle, option }) { + const status = botSourceStatus({ sourceError: option?.error, sourceReachable: option?.reachable }) + const label = option?.label || option?.connectionId || 'Current gateway' + const kind = option?.kind || 'remote' + + return jsx(RosterSectionHeader, { + collapsed, + count, + gatewayKind: kind, + label, + onToggle, + status, + tip: `${label} · ${kind} · ${status.label}` + }) +} + function BotsPane() { const { data, error, isLoading, refetch } = useRoster() const gatewayState = useValue(host.state.gateway) @@ -11884,6 +12760,11 @@ function BotsPane() { const [deletingGroup, setDeletingGroup] = useState(null) const [grouping, setGrouping] = useState(null) const [query, setQuery] = useState('') + const [rowKindFilter, setRowKindFilter] = useState('all') + const [activityFilter, setActivityFilter] = useState('all') + const [gatewayFilter, setGatewayFilter] = useState('all') + const [collapsedRosterSections, setCollapsedRosterSections] = useState(() => new Set()) + const hiddenSectionRef = useRef(null) const activityToasts = useValue($activityToasts) const groupChatName = useValue($groupChatWorkspace) // Main-tab ownership is a module Map; this rev subscription makes the @@ -11893,6 +12774,10 @@ function BotsPane() { useValue($groupMainTabsRev) const groupNeedsYou = useValue($groupNeedsYou) const groupRooms = useValue($groupChats) + const rememberedSources = useValue($lastSources) + const rosterHydrated = useValue($rosterHydrated) + const selectionHydrated = useValue($selectedRosterHydrated) + const selectedRosterKey = useValue($selectedRosterKey) // The socket opening (boot, SSH reconnect, sleep/wake) is the signal to // retry immediately instead of waiting out the poll interval. @@ -11912,17 +12797,19 @@ function BotsPane() { return Math.max(created, lastMsg) } - // Pinned bots (right-click → Pin) float to the top as a group; within the - // pinned group and within the unpinned group, recency still rules. A - // plain boolean flag in bot-meta (rides ui_meta to every machine). - const isPinned = bot => Boolean(botRosterMeta(bot, allMeta)?.pinned) + // Pin is a source-qualified Desktop preference, not gateway profile state. + const isPinned = bot => isBotPinned(bot, allMeta) // Resilience (@wesleysimplicio, #13): a failed refresh must not erase a // roster the user already had — mixed local+cloud gateways and remotes // waking from sleep fail transiently. Render the last good snapshot with // a notice; the full error card is reserved for "never had a roster". const live = Array.isArray(data?.profiles) ? data.profiles : null const source = live ?? (error ? $lastRoster.get() : []) - const roster = source.slice().sort((a, b) => { + const sourceSnapshot = Array.isArray(data?.sources) ? data.sources : rememberedSources + const sourceWithSelectedOwner = selectionHydrated && rosterHydrated + ? rosterWithSelectedOwner(source, sourceSnapshot, selectedRosterKey) + : source + const roster = sourceWithSelectedOwner.slice().sort((a, b) => { const pa = isPinned(a) ? 1 : 0 const pb = isPinned(b) ? 1 : 0 @@ -11932,34 +12819,68 @@ function BotsPane() { return activityOf(b) - activityOf(a) }) + // React Query can briefly report neither loading nor data while the plugin + // and the persisted connection registry hydrate. Keep that transition in a + // neutral loading state instead of flashing the first-run "No bots" copy. + const initialRosterLoading = !data && !error && roster.length === 0 + const activeRosterKeys = new Set(activeBots(roster, activeProfile, gatewayState).map(botRosterKey)) + const gatewayOptions = rosterGatewayOptions(sourceSnapshot, roster) + const selectedGateway = gatewayOptions.find(option => option.connectionId === gatewayFilter) + const gatewayFilterExists = gatewayFilter === 'all' || Boolean(selectedGateway) + + useEffect(() => { + if (!gatewayFilterExists) { + setGatewayFilter('all') + } + }, [gatewayFilterExists]) + const activeSourceRoster = roster.filter(bot => !bot.remoteSource) - // Hidden bots (right-click → Hide Bot) drop out of the roster list unless - // the header eye toggle reveals them. Display-only: every other consumer - // (mentions, group chats, name-collision checks, merge/avatar/activity - // sweeps) keeps the FULL roster. - const showHidden = useValue($showHiddenBots) - const unreadByName = useValue($botUnread) + // Hidden rows remain fully alive and recoverable at the bottom. Every + // non-display consumer continues to receive the complete roster. + const hiddenExpanded = useValue($showHiddenBots) const hiddenBots = roster.filter(bot => isBotHidden(bot, allMeta)) - const hiddenUnread = hiddenBots.some(bot => unreadByName[botSelectionKey(bot)]) - const visibleRoster = showHidden ? roster : roster.filter(bot => !isBotHidden(bot, allMeta)) - const filteredRoster = filterBots(visibleRoster, allMeta, query) - // Group chats are first-class roster rows (Discord-style): one standalone - // row per room, competing in the SAME recency ordering as bot rows — a - // group's activity is its newest room-log line. Pinned bots still lead; - // groups and unpinned bots interleave by recency below them. - const needle = query.trim().toLowerCase() - const groupRows = groupChatNames(allMeta, groupRooms) - .filter(name => !needle || name.toLowerCase().includes(needle)) - .map(name => ({ + const visibleRoster = roster.filter(bot => !isBotHidden(bot, allMeta)) + const gatewayRoster = filterBotsByGateway(visibleRoster, gatewayFilter) + const filteredRoster = filterBots(gatewayRoster, allMeta, query).filter(bot => + rosterActivityMatches( + { activity: activityOf(bot), active: activeRosterKeys.has(botRosterKey(bot)) }, + activityFilter + ) + ) + const filteredHiddenBots = filterBots(filterBotsByGateway(hiddenBots, gatewayFilter), allMeta, query).filter(bot => + rosterActivityMatches( + { activity: activityOf(bot), active: activeRosterKeys.has(botRosterKey(bot)) }, + activityFilter + ) + ) + const groupNames = groupChatNames(allMeta, groupRooms) + const groupRows = groupNames + .map(name => ({ name, members: groupChatMemberBots(name, roster, allMeta) })) + .filter(row => groupMatchesRosterFilters(row.name, row.members, allMeta, query, gatewayFilter)) + .map(row => ({ kind: 'group', - name, - members: groupChatMemberBots(name, roster, allMeta), - activity: groupLastActivity(groupRooms[name]) + name: row.name, + members: row.members, + pinned: Boolean(groupRooms[row.name]?.pinned), + activity: groupLastActivity(groupRooms[row.name]), + active: + Boolean( + groupLastActivity(groupRooms[row.name]) && + Date.now() - groupLastActivity(groupRooms[row.name]) <= ACTIVE_WINDOW_S * 1000 + ) || row.members.some(member => activeRosterKeys.has(botRosterKey(member))) })) - const rosterRows = [ - ...filteredRoster.map(bot => ({ kind: 'bot', bot, pinned: isPinned(bot), activity: activityOf(bot) })), - ...groupRows - ].sort((a, b) => { + .filter(row => rowKindFilter !== 'bots' && rosterActivityMatches(row, activityFilter)) + const botRows = + rowKindFilter === 'groups' + ? [] + : filteredRoster.map(bot => ({ + kind: 'bot', + bot, + pinned: isPinned(bot), + activity: activityOf(bot), + active: activeRosterKeys.has(botRosterKey(bot)) + })) + const sortRosterRows = rows => rows.slice().sort((a, b) => { const pa = a.pinned ? 1 : 0 const pb = b.pinned ? 1 : 0 @@ -11969,15 +12890,90 @@ function BotsPane() { return b.activity - a.activity }) + const rosterRows = sortRosterRows([...botRows, ...groupRows]) + const sortedGroupRows = sortRosterRows(groupRows) + const gatewaySections = rosterGatewaySections(botRows, gatewayOptions, gatewayFilter) + const showGatewaySections = gatewaySections.sectioned && botRows.length > 0 + const activeFilterCount = + (rowKindFilter === 'all' ? 0 : 1) + + (activityFilter === 'all' ? 0 : 1) + + (gatewayFilter === 'all' ? 0 : 1) + const hasRosterConstraint = Boolean(query.trim()) || activeFilterCount > 0 + const matchingHiddenBots = rowKindFilter === 'groups' ? [] : filteredHiddenBots + const showHiddenSection = hiddenBots.length > 0 && (!hasRosterConstraint || matchingHiddenBots.length > 0) + const showHiddenRows = hiddenExpanded || hasRosterConstraint + const rosterItemCount = roster.length + groupNames.length + const allBotsHidden = + !hasRosterConstraint && visibleRoster.length === 0 && groupNames.length === 0 && hiddenBots.length > 0 + const showRosterSearch = + gatewayOptions.length > 1 || rosterItemCount >= BOT_ROSTER_SEARCH_THRESHOLD || Boolean(query.trim()) + const showRosterFilters = + gatewayOptions.length > 1 || + groupNames.length > 0 || + rosterItemCount >= BOT_ROSTER_SEARCH_THRESHOLD || + activeFilterCount > 0 + const showRosterTools = showRosterSearch || showRosterFilters + const rosterSectionCollapsed = id => !hasRosterConstraint && collapsedRosterSections.has(id) + const hiddenGatewaySections = rosterGatewaySections( + matchingHiddenBots.map(bot => ({ kind: 'bot', bot })), + gatewayOptions, + gatewayFilter + ) + + const toggleRosterSection = id => { + setCollapsedRosterSections(previous => { + const next = new Set(previous) + + if (next.has(id)) { + next.delete(id) + } else { + next.add(id) + } + + return next + }) + } + + useEffect(() => { + if (!hiddenExpanded || hasRosterConstraint) { + return + } + + const frame = requestAnimationFrame(() => hiddenSectionRef.current?.scrollIntoView({ block: 'nearest' })) + + return () => cancelAnimationFrame(frame) + }, [hiddenExpanded, hasRosterConstraint]) if (live) { - $lastRoster.set(roster) + // Offline-owner ghosts belong only to this render. Shared roster state + // feeds merge caching, group membership, creation, and durable sync. + $lastRoster.set(roster.filter(row => !row?.ghost)) + if (Array.isArray(data?.sources)) { + $lastSources.set(data.sources) + } mergeServerMeta(activeSourceRoster, data?.fetchedAt || 0) pullServerAvatars(activeSourceRoster) - trackInboundActivity(activeSourceRoster) + trackInboundActivity(roster) backfillMessagingProtocol(activeSourceRoster) } + // The roster has ANSWERED once data or a terminal error exists — that, not + // row count, is what lets the home stop showing its loading state (an empty + // answer is a real answer; a pending one must not flash "No bots"). Keep the + // persisted-selection writes out of render: React may replay a render, but + // an abandoned render must never become a storage mutation. + useEffect(() => { + if (!data && !error) { + return + } + + $rosterHydrated.set(true) + + if (selectionHydrated) { + reconcileRosterSelection(roster, sourceSnapshot, allMeta) + } + }, [data, error, selectionHydrated, roster, sourceSnapshot, allMeta]) + const staleNotice = error && !live && roster.length ? 'Roster refresh failed — showing the last good list.' + (gatewayUp ? '' : ' Waiting for the gateway to reconnect…') : null @@ -11987,6 +12983,113 @@ function BotsPane() { return jsx(GroupChatWorkspace, { group: groupChatName, members: groupChatMembers }) } + const renderBotRow = (bot, keyPrefix = '') => + jsx( + BotRow, + { + bot, + onDelete: setDeleting, + onEdit: setEditing, + onGroup: setGrouping, + showHandle: botNeedsHandleLabel(bot, roster, allMeta) + }, + `${keyPrefix}${botRosterKey(bot)}` + ) + + const renderGroupRow = row => + jsx( + GroupRow, + { + active: groupChatName === row.name, + group: row.name, + members: row.members, + needsYou: Boolean(groupNeedsYou[row.name]), + onOpen: openGroupChat, + onDisband: setDeletingGroup + }, + `group:${row.name}` + ) + + const renderGatewaySection = section => { + const sectionId = `gateway:${section.id}` + const collapsed = rosterSectionCollapsed(sectionId) + + return jsxs( + 'div', + { + className: 'min-w-0', + children: [ + jsx(GatewaySectionHeading, { + collapsed, + count: section.rows.length, + onToggle: () => toggleRosterSection(sectionId), + option: section.option + }), + collapsed + ? null + : jsx('div', { + className: 'grid min-w-0 gap-0.5', + children: section.rows.map(row => renderBotRow(row.bot, `${section.id}:`)) + }) + ] + }, + sectionId + ) + } + + const renderGroupChatSection = () => { + const sectionId = 'group-chats' + const collapsed = rosterSectionCollapsed(sectionId) + + return jsxs( + 'div', + { + className: 'min-w-0', + children: [ + jsx(RosterSectionHeader, { + collapsed, + count: sortedGroupRows.length, + icon: 'organization', + label: 'Group chats', + onToggle: () => toggleRosterSection(sectionId), + tip: `${sortedGroupRows.length} global group chat${sortedGroupRows.length === 1 ? '' : 's'}` + }), + collapsed + ? null + : jsx('div', { + className: 'grid min-w-0 gap-0.5', + children: sortedGroupRows.map(renderGroupRow) + }) + ] + }, + sectionId + ) + } + + const renderHiddenGatewaySection = section => + jsxs( + 'div', + { + className: 'min-w-0', + children: [ + jsx('div', { + className: + 'flex min-w-0 items-center gap-1.5 px-2 py-1 text-[0.625rem] font-semibold uppercase tracking-wider text-(--ui-text-quaternary)', + children: [ + jsx(GatewayKindGlyph, { kind: section.option?.kind }), + jsx('span', { + className: 'min-w-0 flex-1 truncate', + children: section.option?.label || section.option?.connectionId || 'Current gateway' + }), + jsx('span', { className: 'shrink-0 font-normal tabular-nums', children: section.rows.length }) + ] + }), + ...section.rows.map(row => renderBotRow(row.bot, `hidden:${section.id}:`)) + ] + }, + `hidden-gateway:${section.id}` + ) + return jsxs('div', { className: 'flex h-full flex-col', children: [ @@ -12010,35 +13113,6 @@ function BotsPane() { children: jsx(Codicon, { name: activityToasts ? 'bell' : 'bell-slash' }) }) }), - // Eye toggle appears only once something is hidden — zero - // hidden bots means zero extra chrome. It stays visible while - // hidden rows are revealed, so Unhide is always reachable. - hiddenBots.length - ? jsx(Tip, { - label: showHidden - ? 'Hide hidden bots again' - : `Show ${hiddenBots.length} hidden bot${hiddenBots.length === 1 ? '' : 's'}`, - children: jsxs('button', { - type: 'button', - 'aria-label': showHidden ? 'Hide hidden bots' : 'Show hidden bots', - className: cn( - 'relative flex size-6 items-center justify-center rounded-md transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground', - showHidden ? 'text-foreground' : 'text-(--ui-text-tertiary)' - ), - onClick: () => $showHiddenBots.set(!showHidden), - children: [ - jsx(Codicon, { name: showHidden ? 'eye' : 'eye-closed' }), - hiddenUnread && !showHidden - ? jsx('span', { - className: - 'absolute right-0.5 top-0.5 size-1.5 rounded-full bg-(--ui-accent,#4f9cf9)', - 'aria-label': 'a hidden bot has unread activity' - }) - : null - ] - }) - }) - : null, jsxs(DropdownMenu, { children: [ jsx(Tip, { @@ -12047,7 +13121,7 @@ function BotsPane() { asChild: true, children: jsx('button', { type: 'button', - 'aria-label': 'New agent or group chat', + 'aria-label': 'New bot or group chat', className: 'flex size-6 items-center justify-center rounded-md text-(--ui-text-tertiary) transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground', children: jsx(Codicon, { name: 'add' }) @@ -12059,7 +13133,7 @@ function BotsPane() { children: [ jsxs(DropdownMenuItem, { onSelect: () => setCreateOpen(true), - children: [jsx(Codicon, { name: 'hubot', className: 'mr-1.5' }), 'New Agent'] + children: [jsx(Codicon, { name: 'hubot', className: 'mr-1.5' }), 'New Bot'] }), jsxs(DropdownMenuItem, { disabled: activeSourceRoster.length < 2, @@ -12079,67 +13153,147 @@ function BotsPane() { activeProfile, gatewayState, metaByName: allMeta, - onOpen: bot => { - const generation = ++botOpenGeneration - haptic('tap') - $selectedBot.set(botSelectionKey(bot)) - - if ($botUnread.get()[botSelectionKey(bot)]) { - const next = { ...$botUnread.get() } - delete next[botSelectionKey(bot)] - $botUnread.set(next) - } - - void (async () => { - try { - await prepareBotSource(bot) - } catch (error) { - host.notifyError?.(error, `Could not reach ${bot.connectionLabel || 'the remote source'}`) - - return - } - - if (generation !== botOpenGeneration) { - return - } - - try { - const id = await openBotCanonicalChat(bot) - - if (generation === botOpenGeneration && id) { - return - } - } catch (error) { - if (generation === botOpenGeneration) { - host.notifyError?.(error, `Could not open ${displayName(bot)}'s chat — try again`) - } - - return - } - - if (generation !== botOpenGeneration) { - return - } - - if (typeof host.newChat === 'function') { - newBotChat(bot) - } else { - host.navigate('/') - } - })() - } + // Keep the Active Now strip and sidebar rows on the same exact-owner + // route: source activation first, then canonical name-registry open. + onOpen: bot => void openRosterBot(bot) }), - roster.length + showRosterTools ? jsx('div', { - className: 'px-2.5 pb-1.5', - children: jsx(SearchField, { - 'aria-label': 'Search bots', - containerClassName: 'w-full', - inputClassName: 'w-full', - placeholder: 'Search bots…', - value: query, - onChange: setQuery - }) + className: 'flex min-w-0 items-center gap-1 px-2.5 pb-1.5', + children: [ + showRosterSearch + ? jsx(SearchField, { + 'aria-label': 'Search bots and group chats', + containerClassName: cn( + 'min-w-0 flex-1', + query ? 'opacity-100!' : 'opacity-50 focus-within:opacity-100' + ), + inputClassName: + 'w-full text-[0.75rem] placeholder:text-(--ui-text-tertiary)', + placeholder: 'Search bots and group chats…', + value: query, + onChange: setQuery + }) + : jsx('span', { className: 'min-w-0 flex-1' }), + showRosterFilters + ? jsxs(DropdownMenu, { + children: [ + jsx(Tip, { + label: activeFilterCount ? `Filters (${activeFilterCount} active)` : 'Filter roster', + children: jsx(DropdownMenuTrigger, { + asChild: true, + children: jsx('button', { + type: 'button', + 'aria-label': activeFilterCount ? `Filter roster, ${activeFilterCount} active` : 'Filter roster', + className: cn( + 'flex size-7 shrink-0 items-center justify-center rounded-md text-(--ui-text-tertiary) transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground', + activeFilterCount && 'text-(--ui-accent)' + ), + children: jsx(Codicon, { name: 'list-filter' }) + }) + }) + }), + jsxs(DropdownMenuContent, { + align: 'end', + children: [ + ...[ + ['all', 'Bots and group chats'], + ['bots', 'Bots only'], + ['groups', 'Group chats only'] + ].map(([value, label]) => + jsxs( + DropdownMenuItem, + { + onSelect: () => setRowKindFilter(value), + children: [ + jsx('span', { className: 'min-w-0 flex-1', children: label }), + rowKindFilter === value ? jsx(Codicon, { name: 'check' }) : null + ] + }, + `kind:${value}` + ) + ), + jsx(DropdownMenuSeparator, {}), + ...[ + ['all', 'Any activity'], + ['active', 'Active now'], + ['recent', 'Recently active'], + ['older', 'Older'] + ].map(([value, label]) => + jsxs( + DropdownMenuItem, + { + onSelect: () => setActivityFilter(value), + children: [ + jsx('span', { className: 'min-w-0 flex-1', children: label }), + activityFilter === value ? jsx(Codicon, { name: 'check' }) : null + ] + }, + `activity:${value}` + ) + ), + gatewayOptions.length > 1 ? jsx(DropdownMenuSeparator, {}) : null, + gatewayOptions.length > 1 + ? jsxs(DropdownMenuItem, { + onSelect: () => setGatewayFilter('all'), + children: [ + jsx(Codicon, { name: 'globe', className: 'mr-1.5' }), + jsx('span', { className: 'min-w-0 flex-1', children: 'All gateways' }), + gatewayFilter === 'all' ? jsx(Codicon, { name: 'check' }) : null + ] + }) + : null, + ...(gatewayOptions.length > 1 + ? gatewayOptions.map(option => { + const status = botSourceStatus({ + sourceError: option.error, + sourceReachable: option.reachable + }) + + return jsxs( + DropdownMenuItem, + { + onSelect: () => setGatewayFilter(option.connectionId), + children: [ + jsx(GatewayKindGlyph, { + kind: option.kind, + className: cn( + 'mr-1.5', + !status.available && 'text-amber-600 dark:text-amber-300' + ) + }), + jsx('span', { + className: 'min-w-0 flex-1 truncate', + children: option.label || option.connectionId + }), + jsx('span', { + className: 'text-[0.625rem] tabular-nums text-(--ui-text-quaternary)', + children: option.count + }), + gatewayFilter === option.connectionId ? jsx(Codicon, { name: 'check' }) : null + ] + }, + option.connectionId + ) + }) + : []), + activeFilterCount ? jsx(DropdownMenuSeparator, {}) : null, + activeFilterCount + ? jsx(DropdownMenuItem, { + onSelect: () => { + setRowKindFilter('all') + setActivityFilter('all') + setGatewayFilter('all') + }, + children: 'Clear filters' + }) + : null + ] + }) + ] + }) + : null + ] }) : null, staleNotice @@ -12148,7 +13302,7 @@ function BotsPane() { children: staleNotice }) : null, - isLoading && !roster.length + (isLoading || initialRosterLoading) && !roster.length ? jsx('div', { className: 'flex flex-1 items-center justify-center', children: jsx(GlyphSpinner, { spinner: 'breathe', className: 'text-(--ui-text-tertiary)' }) @@ -12174,56 +13328,104 @@ function BotsPane() { : roster.length === 0 ? jsx(EmptyState, { icon: 'hubot', - title: 'No agents yet', - description: 'Create your first teammate.' + title: 'No bots yet', + description: 'Create your first bot.' }) - : filteredRoster.length === 0 && rosterRows.length === 0 - ? jsx('div', { - 'aria-live': 'polite', - className: - 'flex flex-1 items-center justify-center px-4 text-center text-xs text-(--ui-text-tertiary)', - role: 'status', - children: query.trim() - ? `No bots match “${query.trim()}”` - : 'All bots are hidden — use the eye button above to show them.' + : allBotsHidden && !hiddenExpanded + ? jsxs('div', { + className: 'grid content-start gap-2 px-3 py-4 text-xs text-(--ui-text-tertiary)', + children: [ + jsxs('div', { + className: 'flex items-center gap-1.5 font-medium text-(--ui-text-secondary)', + children: [ + jsx(Codicon, { name: 'eye-closed', className: 'text-(--ui-text-quaternary)' }), + 'All bots are hidden' + ] + }), + jsx('p', { className: 'leading-relaxed', children: 'They keep working and retain their history.' }), + jsx(Button, { + variant: 'secondary', + size: 'sm', + className: 'justify-self-start', + onClick: () => $showHiddenBots.set(true), + children: 'Show hidden bots' + }) + ] }) - : jsx(ScrollArea, { - className: 'hermes-bots-roster min-h-0 flex-1', - children: jsx('div', { - className: 'grid w-full min-w-0 gap-0.5 px-1.5 pb-2', - // Flat, Discord-style list: bot rows and group rows - // interleaved by recency — no section headers. - children: rosterRows.map(row => - row.kind === 'group' - ? jsx( - GroupRow, - { - active: groupChatName === row.name, - group: row.name, - members: row.members, - needsYou: Boolean(groupNeedsYou[row.name]), - onOpen: openGroupChat, - onDisband: setDeletingGroup - }, - `group:${row.name}` - ) - : jsx( - BotRow, - { bot: row.bot, onDelete: setDeleting, onEdit: setEditing, onGroup: setGrouping }, - botRosterKey(row.bot) - ) - ) + : rosterRows.length === 0 && matchingHiddenBots.length === 0 + ? jsx('div', { + 'aria-live': 'polite', + className: + 'flex flex-1 items-center justify-center px-4 text-center text-xs text-(--ui-text-tertiary)', + role: 'status', + children: query.trim() + ? `No bots or group chats match “${query.trim()}”${selectedGateway ? ` on ${selectedGateway.label}` : ''}` + : selectedGateway + ? `No bots or group chats match these filters on ${selectedGateway.label}` + : 'No bots or group chats match these filters.' }) - }), - jsx('div', { - className: 'border-t border-(--ui-stroke-secondary) p-2', - children: jsxs(Button, { - className: 'w-full justify-center gap-1.5', - variant: 'secondary', - onClick: () => setCreateOpen(true), - children: [jsx(Codicon, { name: 'add' }), 'New Agent'] - }) - }), + : jsx(ScrollArea, { + className: 'hermes-bots-roster min-h-0 flex-1', + children: jsx('div', { + className: 'grid w-full min-w-0 gap-0.5 px-1.5 pb-2', + children: [ + ...(showGatewaySections + ? [ + sortedGroupRows.length ? renderGroupChatSection() : null, + ...gatewaySections.sections.map(renderGatewaySection) + ].filter(Boolean) + : rosterRows.map(row => + row.kind === 'group' ? renderGroupRow(row) : renderBotRow(row.bot) + )), + showHiddenSection + ? jsxs('div', { + ref: hiddenSectionRef, + className: 'mt-1 border-t border-(--ui-stroke-tertiary) pt-1', + children: [ + hasRosterConstraint + ? jsxs('div', { + className: + 'flex w-full items-center gap-1 px-2 py-1.5 text-[0.6875rem] font-medium text-(--ui-text-tertiary)', + children: [ + jsx(Codicon, { name: 'eye-closed' }), + jsx('span', { children: 'Hidden' }), + jsx('span', { + className: 'text-(--ui-text-quaternary)', + children: matchingHiddenBots.length + }) + ] + }) + : jsxs('button', { + type: 'button', + 'aria-expanded': hiddenExpanded, + className: + 'flex w-full items-center gap-1 rounded-md px-2 py-1.5 text-left text-[0.6875rem] font-medium text-(--ui-text-tertiary) transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground', + onClick: () => $showHiddenBots.set(!hiddenExpanded), + children: [ + jsx(Codicon, { name: hiddenExpanded ? 'chevron-down' : 'chevron-right' }), + jsx('span', { children: 'Hidden' }), + jsx('span', { + className: 'text-(--ui-text-quaternary)', + children: hiddenBots.length + }) + ] + }), + showHiddenRows + ? matchingHiddenBots.length + ? hiddenGatewaySections.sectioned + ? hiddenGatewaySections.sections.map(renderHiddenGatewaySection) + : matchingHiddenBots.map(bot => renderBotRow(bot, 'hidden:')) + : jsx('div', { + className: 'px-2 py-2 text-xs text-(--ui-text-quaternary)', + children: 'No hidden bots match these filters.' + }) + : null + ] + }) + : null + ] + }) + }), jsx(CreateAgentDialog, { open: createOpen, onClose: () => { @@ -12394,6 +13596,25 @@ export default { // one-version rollback. void migrateBotMeta(ctx.storage).catch(() => undefined) + // The last selected bot, source-qualified. Restoring it is PRESENTATION + // ONLY: it paints the Bots home and the roster highlight, and never + // activates a gateway, opens a chat, or creates a session. The hydrated + // flag must flip on every settle path — the home holds a loading state + // until it does, and a storage quirk must not strand it there. + try { + Promise.resolve(ctx.storage?.get?.('selected-roster-bot-v1')) + .then(value => { + if (typeof value === 'string' && value.trim()) { + $selectedRosterKey.set(value.trim()) + } + }) + .catch(() => undefined) + .finally(() => $selectedRosterHydrated.set(true)) + } catch { + /* no storage — this window starts with no restored selection */ + $selectedRosterHydrated.set(true) + } + // Bot Mode sessions are always hidden now — the old "hide Bot Chats" // pref is gone (its stored key is simply ignored). The reconciliation // sweep below hides any rows born visible under the old pref. @@ -12553,11 +13774,11 @@ export default { if (typeof host.paneVisibility === 'function') { // The contribution-scoped pane id (`register` prefixes `${ID}:`). - const $botsPaneVisible = host.paneVisibility(`${ID}:pane`) + const $sidebarVisible = host.paneVisibility(`${ID}:pane`) let unregisterRoutines = null - const syncRoutinesPane = visible => { - if (visible) { + const syncRoutinesPane = () => { + if (botChatOwnsWorkspace()) { unregisterRoutines ??= registerRoutinesPane() } else if (unregisterRoutines) { unregisterRoutines() @@ -12565,13 +13786,83 @@ export default { } } - const stopRoutinesSync = $botsPaneVisible.listen(syncRoutinesPane) - syncRoutinesPane($botsPaneVisible.get()) + // One recompute for both main-area surfaces: they answer the same + // question (who owns the center) from the same three signals. + const syncWorkspaceSurfaces = () => { + syncBotsHomeWorkspace() + syncRoutinesPane() + } + + const stopSidebarSync = $sidebarVisible.listen(visible => { + $botsPaneVisible.set(Boolean(visible)) + // A generic composer has no stored-session owner, so passive sync + // replaces it with the Bot home. A real restored chat keeps the + // center until the user explicitly selects a Bot owner. + syncWorkspaceSurfaces() + }) + const stopGroupSync = $groupChatWorkspace.listen(syncWorkspaceSurfaces) + // The home tab's visibility flips are the ONLY signal for two real + // transitions: layout hydration re-asserting a persisted active tab + // over the home after boot, and the user swapping between the home tab + // and a chat tab. React on the NEXT tick — the notification arrives + // mid-layout-mutation, and registering/unregistering panes from inside + // it would re-enter the tree store. + const scheduleSurfaceSync = () => { + try { + setTimeout(syncWorkspaceSurfaces, 0) + } catch { + syncWorkspaceSurfaces() + } + } + const homeVisibleStore = host.paneVisibility(BOTS_HOME_PANE_ID) + const stopHomeVisibleSync = homeVisibleStore.listen(visible => { + // Update selection ownership immediately; the deferred pass below may + // mutate registrations, but the visible row must never lag a frame. + $botsHomeFronted.set(Boolean(visible)) + scheduleSurfaceSync() + }) + // Tab focus moves without swapping the gateway socket, so the focused + // STORED session is the truth about session focus; older shells fall + // back to the active session id. A RISING edge means a session just + // claimed the center (opened or refocused): the home yields then — and + // only then, so an explicitly selected owner can hold the center over + // a focused-but-hidden chat without the next poll snatching it back. + const focusStore = host.state.focusedStoredSessionId || host.state.activeSessionId + const stopFocusSync = + typeof focusStore?.listen === 'function' + ? focusStore.listen(id => { + $botChatFocused.set(Boolean(id)) + releaseStaleOpenBotChat(id) + + if (id) { + closeBotsHomeWorkspace() + } + + syncWorkspaceSurfaces() + }) + : null + + $botsPaneVisible.set(Boolean($sidebarVisible.get())) + $botChatFocused.set(sessionOwnsWorkspace()) + $botsHomeFronted.set(Boolean(homeVisibleStore.get())) + // A persisted layout can boot directly into Bot Mode while restoring + // the generic Sessions workspace as the active sibling. Reconcile now, + // then once more after the layout mutation finishes: the deferred pass + // remains passive, so a real restored chat is never covered. + syncWorkspaceSurfaces() + scheduleSurfaceSync() if (typeof ctx.onDispose === 'function') { // The registration disposer is already tracked by ctx.register; only - // the listener needs explicit teardown or it survives plugin disable. - ctx.onDispose(stopRoutinesSync) + // the listeners need explicit teardown or they survive plugin disable. + ctx.onDispose(() => { + stopSidebarSync() + stopGroupSync() + stopHomeVisibleSync() + stopFocusSync?.() + $botsHomeFronted.set(false) + closeBotsHomeWorkspace() + }) } } else { registerRoutinesPane() @@ -12582,10 +13873,10 @@ export default { area: PALETTE_AREA, data: { id: `${ID}.new-agent`, - label: 'New Agent…', + label: 'New Bot…', keywords: ['bot', 'agent', 'profile', 'teammate', 'create'], run: () => { - host.notify({ kind: 'info', message: 'Open the Bots pane and hit “New Agent”.' }) + host.notify({ kind: 'info', message: 'Open the Bots pane and hit “New Bot”.' }) } } }) diff --git a/apps/desktop/src/plugins/hermes-bots/tests/active-now-strip.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/active-now-strip.test.mjs index 6d921aa75c01..6734e3fed7e4 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/active-now-strip.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/active-now-strip.test.mjs @@ -104,10 +104,10 @@ test('botActivitySession keeps last_session when it is the fresher one', () => { assert.equal(botActivitySession(bot).id, 'scratch') }) -test('botActivitySession degrades to whichever side exists (older gateways / no pin)', () => { +test('botActivitySession degrades to whichever session summary exists on an older gateway', () => { const botActivitySession = loadBotActivitySession() assert.equal(botActivitySession({ last_session: { id: 'only', last_active: 1 } }).id, 'only') - assert.equal(botActivitySession({ canonical_session: { id: 'pin', last_active: 1 } }).id, 'pin') + assert.equal(botActivitySession({ canonical_session: { id: 'canonical', last_active: 1 } }).id, 'canonical') assert.equal(botActivitySession({}), null) assert.equal(botActivitySession(null), null) }) @@ -164,7 +164,7 @@ test('activeBots ignores a finished worker outside the liveness window', () => { test('ActiveNowStrip renders above the roster, is a live region, and is click-accessible', () => { // Strip is placed between the pane header and the search field. const headerEnd = source.indexOf("children: 'Bots'") - const searchField = source.indexOf("placeholder: 'Search bots…'") + const searchField = source.indexOf("placeholder: 'Search bots and group chats…'") assert.ok(headerEnd >= 0 && searchField > headerEnd) const stripStart = source.indexOf('jsx(ActiveNowStrip') @@ -172,13 +172,22 @@ test('ActiveNowStrip renders above the roster, is a live region, and is click-ac // Live region announces membership changes politely. assert.match(source, /'aria-live': 'polite'/) - // Chips are real buttons (keyboard/click accessible), reuse BotFace, and - // open the canonical chat via the same path as roster rows. - assert.match(source, /jsx\('button', \{\s*type: 'button',\s*title: `Open \$\{label\}'s chat`/) - // The key rides as jsx()'s third argument — the ONLY form React treats as - // a list key; a `key:` prop leaves chips unkeyed (index identity). - assert.match(source, /\}, botRosterKey\(bot\)\)\s*\}\)\s*\]\s*\}\)\s*\}\s*\/\*\* Assign a bot to a group/s) + // Chips use the shared Tip component, remain keyboard/click accessible, + // and open the canonical chat via the same path as roster rows. + assert.match(source, /label: `Open \$\{label\}'s chat`/) + assert.match(source, /'aria-label': `Open \$\{label\}'s chat`/) + // The key rides as jsx()'s third argument so React keeps chip identity. + assert.match(source, /botRosterKey\(bot\)\s*\)\s*\}\)\s*\]\s*\}\)\s*\}\s*\/\*\* Assign a bot to a group/s) assert.match(source, /jsx\(BotFace,\s*\{[\s\S]*?mood: 'work'/) - assert.match(source, /await prepareBotSource\(bot\)/) - assert.match(source, /bot\.canonical_session \|\| last/) + // Chips share the exact-owner route with roster rows. That route activates the + // owner source, then resolves the profile's canonical name registry; no + // renderer pointer or preview-derived session id participates. + assert.match(source, /onOpen: bot => void openRosterBot\(bot\)/) + + const openStart = source.indexOf('async function openRosterBot(') + assert.ok(openStart >= 0) + const open = source.slice(openStart, openStart + 3200) + + assert.match(open, /await prepareBotSource\(bot\)/) + assert.match(open, /await openBotCanonicalChat\(bot\.name\)/) }) diff --git a/apps/desktop/src/plugins/hermes-bots/tests/bot-delete.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/bot-delete.test.mjs index 63c58f0d14a4..b7f699a1ef94 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/bot-delete.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/bot-delete.test.mjs @@ -204,13 +204,13 @@ test('unit: older desktop without host.deleteProfile falls back to the non-inter test('integration: a deleted bot is removed from plugin-local state and the roster is refreshed', async () => { const { context, invalidations, stored } = load() context.__delete.$botMeta.set({ researcher: { title: 'Research' }, writer: { title: 'Writer' } }) - context.__delete.$botUnread.set({ researcher: true, writer: true }) + context.__delete.$botUnread.set({ 'legacy::researcher': true, 'legacy::writer': true }) context.__delete.$selectedBot.set('researcher') await context.__delete.deleteBot({ name: 'researcher' }) assert.equal(context.__delete.$botMeta.get().researcher, undefined) - assert.equal(context.__delete.$botUnread.get().researcher, undefined) + assert.equal(context.__delete.$botUnread.get()['legacy::researcher'], undefined) assert.equal(context.__delete.$selectedBot.get(), 'default') assert.equal(stored.at(-1).key, 'bot-meta') assert.equal(stored.at(-1).value.researcher, undefined) diff --git a/apps/desktop/src/plugins/hermes-bots/tests/bots-home.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/bots-home.test.mjs new file mode 100644 index 000000000000..054491e38971 --- /dev/null +++ b/apps/desktop/src/plugins/hermes-bots/tests/bots-home.test.mjs @@ -0,0 +1,936 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' +import vm from 'node:vm' + +// Bot Mode's main workspace always has ONE clear owner: a bot chat, a group +// chat, or the Bots home. The home exists so the Bots tab never falls through +// to the ownerless Sessions composer. +// +// The invariant under test is #90149's: an existing resource carries its exact +// owner. Selecting or RESTORING a bot is presentation only — it must never +// activate a gateway, open a chat, create a session, or route a remote bot +// through whatever connection happens to be live. + +const pluginSource = readFileSync(new URL('../plugin.js', import.meta.url), 'utf8') + +function load({ focusedStoredSessionId = null, paneVisibility = true, openWorkspace = true } = {}) { + const values = new Map() + const atom = initial => { + const slot = { + get: () => values.get(slot), + set: value => { + values.set(slot, value) + for (const fn of slot.__listeners || []) { + fn(value) + } + }, + listen: fn => { + slot.__listeners = [...(slot.__listeners || []), fn] + return () => { + slot.__listeners = (slot.__listeners || []).filter(entry => entry !== fn) + } + } + } + values.set(slot, initial) + return slot + } + + const opened = [] + const closed = [] + const notifications = [] + const requests = [] + const invalidations = [] + const paneVisible = new Map() + const focused = atom(focusedStoredSessionId) + + const host = { + state: { + profile: { get: () => 'default', listen: () => undefined }, + gateway: { get: () => 'open', listen: () => undefined }, + focusedStoredSessionId: focused + }, + request: (method, params) => { + requests.push({ method, params }) + return Promise.resolve({}) + }, + notify: params => notifications.push(params), + notifyError: (error, fallback) => notifications.push({ kind: 'error', message: fallback, error }), + ensureAgent: async () => undefined, + activeConnectionId: () => 'local' + } + + if (openWorkspace) { + host.openWorkspace = (id, options) => { + const entry = { id, options, disposed: false } + opened.push(entry) + paneVisible.set(`plugin-workspace:${id}`, true) + + return () => { + entry.disposed = true + closed.push(entry) + paneVisible.set(`plugin-workspace:${id}`, false) + options.onClose?.() + } + } + } + + if (paneVisibility) { + host.paneVisibility = id => ({ + get: () => paneVisible.get(id) ?? false, + listen: () => () => undefined + }) + } + + const context = { + atom, + haptic: () => undefined, + PALETTE_AREA: 'palette', + COMPOSER_AREAS: { middleware: 'middleware' }, + document: { getElementById: () => null, createElement: () => ({}), head: { appendChild: () => undefined } }, + host, + queryClient: { invalidateQueries: params => invalidations.push(params) }, + navigator: { clipboard: { writeText: async () => undefined } }, + sdk: new Proxy({}, { get: () => undefined }) + } + + const source = 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(` +globalThis.__home = { + botRosterKey, + parseRosterKey, + ghostRosterOwner, + rosterWithSelectedOwner, + reconcileRosterSelection, + saveRosterPreference, + saveSelectedRosterBot, + clearSelectedRosterBot, + clearSelectedRosterKey, + releaseStaleOpenBotChat, + syncBotsHomeWorkspace, + openBotsHomeWorkspace, + closeBotsHomeWorkspace, + botsHomeMayOpen, + botsHomeVisible, + botChatOwnsWorkspace, + sessionOwnsWorkspace, + openRosterBot, + openGroupChat, + closeGroupChatMainTab, + prepareBotSource, + $botMeta, + $botsPaneVisible, + $botChatFocused, + $botsHomeFronted, + $groupChatWorkspace, + $lastRoster, + $lastSources, + $openBotChat, + $rosterHydrated, + $selectedBot, + $selectedRosterHydrated, + $selectedRosterKey, + setPluginCtx: value => { pluginCtx = value } +}; +`) + + vm.runInNewContext(source, context, { filename: 'plugin.js' }) + + return { ...context.__home, closed, focused, host, invalidations, notifications, opened, paneVisible, requests } +} + +/** Every door that would create, activate, or route something. A passive + * selection must touch none of them. */ +function assertNothingRouted(t, label) { + assert.deepEqual(t.requests, [], `${label}: no gateway RPC`) + assert.deepEqual(t.opened.filter(entry => entry.id !== 'hermes-bots:home'), [], `${label}: no chat surface opened`) +} + +// ── selection is source-qualified and presentation-only ───────────────────── + +test('selection persists the source-qualified key, not the bare profile name', () => { + const t = load() + const writes = [] + t.setPluginCtx({ storage: { set: (key, value) => writes.push({ key, value }) } }) + + t.saveSelectedRosterBot({ connectionId: 'work-vps', name: 'researcher', remoteSource: true }) + + assert.equal(t.$selectedRosterKey.get(), 'work-vps::researcher') + assert.deepEqual(writes.at(-1), { key: 'selected-roster-bot-v1', value: 'work-vps::researcher' }) + assertNothingRouted(t, 'saving a selection') +}) + +test('a remote selection never redirects the bare-name consumers at a local twin', () => { + const t = load() + t.setPluginCtx({ storage: { set: () => undefined } }) + + // Same profile name on two gateways — the exact-owner class from #90149. + t.saveSelectedRosterBot({ connectionId: 'local', name: 'default' }) + assert.equal(t.$selectedBot.get(), 'default') + + t.saveSelectedRosterBot({ connectionId: 'mac-mini', name: 'default', remoteSource: true }) + + assert.equal(t.$selectedRosterKey.get(), 'mac-mini::default') + assert.equal( + t.$selectedBot.get(), + 'default', + 'the Cronjobs/slash-guard name tracker keeps its LOCAL owner — a remote row must not claim it' + ) +}) + +test('clearing only fires for the exact selected owner', () => { + const t = load() + t.setPluginCtx({ storage: { set: () => undefined } }) + t.saveSelectedRosterBot({ connectionId: 'local', name: 'writer' }) + + t.clearSelectedRosterBot({ connectionId: 'mac-mini', name: 'writer' }) + assert.equal(t.$selectedRosterKey.get(), 'local::writer', 'a same-named bot elsewhere must not clear this one') + + t.clearSelectedRosterBot({ connectionId: 'local', name: 'writer' }) + assert.equal(t.$selectedRosterKey.get(), '') +}) + +test('roster keys round-trip through parseRosterKey', () => { + const t = load() + const parsed = key => ({ ...t.parseRosterKey(key) }) + + assert.deepEqual(parsed('work-vps::researcher'), { connectionId: 'work-vps', name: 'researcher' }) + assert.deepEqual(parsed('local::default'), { connectionId: 'local', name: 'default' }) + assert.deepEqual(parsed(''), { connectionId: '', name: '' }) + // A key always round-trips from the identity that produced it. + const bot = { connectionId: 'work-vps', name: 'researcher' } + assert.deepEqual(parsed(t.botRosterKey(bot)), { connectionId: 'work-vps', name: 'researcher' }) +}) + +// ── hydration ─────────────────────────────────────────────────────────────── + +test('hydration restores the stored key and flips the hydrated flag', async () => { + const t = load() + t.$selectedRosterHydrated.set(false) + + const ctx = { + storage: { get: key => (key === 'selected-roster-bot-v1' ? 'work-vps::researcher' : undefined), set: () => undefined }, + register: () => () => undefined, + onDispose: () => undefined + } + + // Only the selection hydrate is under test; run it the way register() does. + await Promise.resolve(ctx.storage.get('selected-roster-bot-v1')).then(value => { + if (typeof value === 'string' && value.trim()) { + t.$selectedRosterKey.set(value.trim()) + } + }) + t.$selectedRosterHydrated.set(true) + + assert.equal(t.$selectedRosterKey.get(), 'work-vps::researcher') + assert.equal(t.$selectedRosterHydrated.get(), true) + assertNothingRouted(t, 'restoring a selection') +}) + +test('source contract: every hydrate settle path flips the flag, and none of them opens anything', () => { + assert.match( + pluginSource, + /Promise\.resolve\(ctx\.storage\?\.get\?\.\('selected-roster-bot-v1'\)\)[\s\S]{0,400}?\.finally\(\(\) => \$selectedRosterHydrated\.set\(true\)\)/, + 'a storage quirk must not strand the home in its loading state' + ) + assert.match(pluginSource, /\} catch \{\s*\n\s*\/\* no storage[^\n]*\n\s*\$selectedRosterHydrated\.set\(true\)/) +}) + +// ── first selection + reconciliation ──────────────────────────────────────── + +test('the first selection picks a reachable visible bot and creates nothing', () => { + const t = load() + t.setPluginCtx({ storage: { set: () => undefined } }) + t.$rosterHydrated.set(true) + t.$selectedRosterHydrated.set(true) + + const sources = [{ connectionId: 'local', kind: 'local', label: 'This device', reachable: true }] + const roster = [{ connectionId: 'local', name: 'writer' }, { connectionId: 'local', name: 'coder' }] + + t.reconcileRosterSelection(roster, sources, {}) + + assert.equal(t.$selectedRosterKey.get(), 'local::writer') + assert.equal(t.$openBotChat.get(), null, 'selection is not an open') + assertNothingRouted(t, 'seating the first selection') +}) + +test('an unreachable bot is never auto-selected', () => { + const t = load() + t.setPluginCtx({ storage: { set: () => undefined } }) + t.$rosterHydrated.set(true) + t.$selectedRosterHydrated.set(true) + + const sources = [ + { connectionId: 'work-vps', kind: 'remote', label: 'Work', reachable: false }, + { connectionId: 'local', kind: 'local', label: 'This device', reachable: true } + ] + const roster = [ + { connectionId: 'work-vps', name: 'researcher', remoteSource: true, sourceScoped: true }, + { connectionId: 'local', name: 'writer' } + ] + + t.reconcileRosterSelection(roster, sources, {}) + + assert.equal(t.$selectedRosterKey.get(), 'local::writer') +}) + +test('nothing is selected before the roster has answered', () => { + const t = load() + t.setPluginCtx({ storage: { set: () => undefined } }) + t.$rosterHydrated.set(false) + t.$selectedRosterHydrated.set(true) + + t.reconcileRosterSelection([{ connectionId: 'local', name: 'writer' }], [], {}) + + assert.equal(t.$selectedRosterKey.get(), '', 'a pending roster must not seat a selection it may have to revoke') +}) + +test('a hidden bot is not auto-selected', () => { + const t = load() + t.setPluginCtx({ storage: { set: () => undefined } }) + t.$rosterHydrated.set(true) + t.$selectedRosterHydrated.set(true) + + const sources = [{ connectionId: 'local', kind: 'local', label: 'This device', reachable: true }] + const roster = [{ connectionId: 'local', name: 'hidden-one' }, { connectionId: 'local', name: 'writer' }] + + // Hidden is a source-qualified Desktop preference, so hide the exact row. + t.saveRosterPreference(roster[0], 'hidden', true) + + t.reconcileRosterSelection(roster, sources, {}) + + assert.equal( + t.$selectedRosterKey.get(), + 'local::writer', + 'the home must not open onto a bot the user removed from the roster' + ) +}) + +// ── offline owner survives; deleted owner does not ────────────────────────── + +test('an offline gateway keeps the selection and its identity (relaunch case)', () => { + const t = load() + t.setPluginCtx({ storage: { set: () => undefined } }) + t.$rosterHydrated.set(true) + t.$selectedRosterHydrated.set(true) + t.$selectedRosterKey.set('work-vps::researcher') + + // The gateway is registered but down, so it contributes no roster rows. + const sources = [ + { connectionId: 'work-vps', kind: 'remote', label: 'Work', reachable: false, error: 'ECONNREFUSED' }, + { connectionId: 'local', kind: 'local', label: 'This device', reachable: true } + ] + const roster = [{ connectionId: 'local', name: 'writer' }] + + t.reconcileRosterSelection(roster, sources, {}) + + assert.equal( + t.$selectedRosterKey.get(), + 'work-vps::researcher', + 'an unreachable owner is cached, not replaced — falling back would re-own the bot on another gateway' + ) + + const ghost = t.ghostRosterOwner('work-vps::researcher', sources) + assert.equal(ghost.name, 'researcher') + assert.equal(ghost.connectionId, 'work-vps') + assert.equal(ghost.connectionLabel, 'Work') + assert.equal(ghost.remoteSource, true) + assert.equal(ghost.sourceReachable, false) + assertNothingRouted(t, 'rendering an offline owner') +}) + +test('cold-start outage keeps the selected owner in the visible roster without caching every bot', () => { + const t = load() + const sources = [ + { connectionId: 'work-vps', kind: 'remote', label: 'Work', reachable: false, error: 'ECONNREFUSED' }, + { connectionId: 'local', kind: 'local', label: 'This device', reachable: true } + ] + const localOnly = [{ connectionId: 'local', name: 'writer' }] + + const restored = t.rosterWithSelectedOwner(localOnly, sources, 'work-vps::researcher') + + assert.equal(restored.length, 2, 'only the exact selected owner is restored, not an invented remote roster') + assert.equal(t.botRosterKey(restored[1]), 'work-vps::researcher') + assert.equal(restored[1].connectionLabel, 'Work') + assert.equal(restored[1].sourceReachable, false) + assert.equal(t.rosterWithSelectedOwner(restored, sources, 'work-vps::researcher').length, 2, 'never duplicated') +}) + +test('a reachable source never receives a ghost row for a deleted bot', () => { + const t = load() + const roster = [{ connectionId: 'local', name: 'writer' }] + const sources = [ + { connectionId: 'work-vps', kind: 'remote', label: 'Work', reachable: true }, + { connectionId: 'local', kind: 'local', label: 'This device', reachable: true } + ] + + assert.equal(t.rosterWithSelectedOwner(roster, sources, 'work-vps::deleted').length, 1) +}) + +test('a reachable source that no longer lists the bot clears the selection', () => { + const t = load() + t.setPluginCtx({ storage: { set: () => undefined } }) + t.$rosterHydrated.set(true) + t.$selectedRosterHydrated.set(true) + t.$selectedRosterKey.set('local::deleted-bot') + + const sources = [{ connectionId: 'local', kind: 'local', label: 'This device', reachable: true }] + const roster = [{ connectionId: 'local', name: 'writer' }] + + assert.equal(t.ghostRosterOwner('local::deleted-bot', sources), null, 'a live source answering without it is proof') + + t.reconcileRosterSelection(roster, sources, {}) + + assert.equal(t.$selectedRosterKey.get(), 'local::writer', 'the invalid selection is replaced, not kept') +}) + +test('an unknown source list is not proof of deletion', () => { + const t = load() + + // Sources have not hydrated yet — the owner must keep its identity. + assert.ok(t.ghostRosterOwner('work-vps::researcher', [])) + assert.equal(t.ghostRosterOwner('work-vps::researcher', [{ connectionId: 'local', reachable: true }]), null) +}) + +// ── explicit open: local routes, remote never does ────────────────────────── + +test('opening a remote bot selects it and routes nothing', async () => { + const t = load() + t.setPluginCtx({ storage: { set: () => undefined } }) + t.$botsPaneVisible.set(true) + + const bot = { + connectionId: 'work-vps', + connectionLabel: 'Work', + name: 'researcher', + remoteSource: true, + sourceScoped: true + } + + const result = await t.openRosterBot(bot) + + assert.equal(result, false, 'a remote row does not open a chat') + assert.equal(t.$selectedRosterKey.get(), 'work-vps::researcher') + assert.equal(t.$openBotChat.get(), null) + assertNothingRouted(t, 'clicking a remote row') +}) + +test('a remote owner fronts the Bots home without closing the group tab', async () => { + const t = load() + t.setPluginCtx({ storage: { set: () => undefined } }) + t.$botsPaneVisible.set(true) + t.openGroupChat('Launch room') + const groupEntry = t.opened.find(entry => entry.id === 'hermes-bots:group:launch-room') + + const result = await t.openRosterBot({ + connectionId: 'work-vps', + connectionLabel: 'Work', + name: 'researcher', + remoteSource: true + }) + + assert.equal(result, false) + assert.equal(t.botsHomeVisible(), true) + assert.equal(t.$groupChatWorkspace.get(), null) + assert.equal(groupEntry.disposed, false, 'explicit selection must not close an unrelated group tab') +}) + +test('a remote owner preserves a group when the Bots home cannot open', async () => { + const t = load() + t.setPluginCtx({ storage: { set: () => undefined } }) + t.$botsPaneVisible.set(true) + t.openGroupChat('Launch room') + const groupEntry = t.opened.find(entry => entry.id === 'hermes-bots:group:launch-room') + t.host.openWorkspace = () => { + throw new Error('workspace unavailable') + } + + const result = await t.openRosterBot({ + connectionId: 'work-vps', + connectionLabel: 'Work', + name: 'researcher', + remoteSource: true + }) + + assert.equal(result, false) + assert.equal(t.$groupChatWorkspace.get(), 'Launch room') + assert.equal(groupEntry.disposed, false) +}) + +test('a failed local open leaves no phantom owner in the center', async () => { + const t = load() + t.setPluginCtx({ storage: { set: () => undefined } }) + t.$botsPaneVisible.set(true) + t.$openBotChat.set({ key: 'local::writer', openedRegistryId: 'previous' }) + + // A source-scoped row on a desktop that cannot activate it: prepareBotSource + // refuses rather than letting the open fall through to the live gateway. + delete t.host.ensureAgent + const bot = { connectionId: 'work-vps', name: 'writer', sourceScoped: true } + + const result = await t.openRosterBot(bot) + + assert.equal(result, false) + assert.equal(t.$openBotChat.get(), null, 'a failed open must release the center back to the home') + assert.equal(t.notifications.at(-1).kind, 'error', 'and the failure is surfaced, not swallowed') + assertNothingRouted(t, 'a refused local open') +}) + +test('a missing profile-scoped draft API returns to the home without navigating', async () => { + const t = load() + t.setPluginCtx({ storage: { set: () => undefined } }) + t.$botsPaneVisible.set(true) + t.host.request = async method => { + if (method === 'session.list') return { sessions: [] } + if (method === 'session.create') return {} + return {} + } + + const result = await t.openRosterBot({ connectionId: 'local', name: 'writer' }) + + assert.equal(result, false) + assert.equal(t.$openBotChat.get(), null, 'no draft was opened without the owner-scoped API') + assert.ok(t.botsHomeVisible(), 'the owner home remains the visible recovery surface') +}) + +test('a bot chat opens from its canonical name-registry row without closing the prior group tab', async () => { + const t = load() + t.setPluginCtx({ storage: { set: () => undefined } }) + t.$botsPaneVisible.set(true) + t.host.openSession = async () => undefined + t.host.request = async method => { + if (method === 'session.list') { + return { sessions: [{ id: 'bot-chat', title: 'Bot Chat', message_count: 4 }] } + } + + return {} + } + + t.openGroupChat('Launch room') + const groupEntry = t.opened.find(entry => entry.id === 'hermes-bots:group:launch-room') + assert.ok(groupEntry) + assert.equal(t.$groupChatWorkspace.get(), 'Launch room') + + const result = await t.openRosterBot({ connectionId: 'local', name: 'writer' }) + + assert.equal(result, true) + assert.equal(t.$groupChatWorkspace.get(), null) + assert.equal(groupEntry.disposed, false, 'opening a canonical chat must not close an unrelated group tab') + assert.equal(t.$openBotChat.get()?.key, 'local::writer') + assert.equal(t.$openBotChat.get()?.openedRegistryId, 'bot-chat') +}) + +test('a failed canonical-chat open preserves the visible group owner', async () => { + const t = load() + t.setPluginCtx({ storage: { set: () => undefined } }) + t.$botsPaneVisible.set(true) + t.host.openSession = async () => { + throw new Error('gateway unavailable') + } + t.host.request = async method => { + if (method === 'session.list') { + return { sessions: [{ id: 'bot-chat', title: 'Bot Chat', message_count: 4 }] } + } + + return {} + } + + t.openGroupChat('Launch room') + const groupEntry = t.opened.find(entry => entry.id === 'hermes-bots:group:launch-room') + const result = await t.openRosterBot({ connectionId: 'local', name: 'writer' }) + + assert.equal(result, false) + assert.equal(t.$groupChatWorkspace.get(), 'Launch room') + assert.equal(groupEntry.disposed, false, 'a failed transition cannot retire the surface still on screen') + assert.equal(t.$openBotChat.get(), null) + assert.equal(t.notifications.at(-1).kind, 'error') +}) + +test('choosing a group prevents a stale canonical-chat open from closing it later', async () => { + const t = load() + t.setPluginCtx({ storage: { set: () => undefined } }) + t.$botsPaneVisible.set(true) + + let finishOpen + let markOpenStarted + const openStarted = new Promise(resolve => { + markOpenStarted = resolve + }) + t.host.openSession = () => + new Promise(resolve => { + finishOpen = resolve + markOpenStarted() + }) + t.host.request = async method => { + if (method === 'session.list') { + return { sessions: [{ id: 'bot-chat', title: 'Bot Chat', message_count: 4 }] } + } + + return {} + } + + const opening = t.openRosterBot({ connectionId: 'local', name: 'writer' }) + await openStarted + t.openGroupChat('Launch room') + const groupEntry = t.opened.find(entry => entry.id === 'hermes-bots:group:launch-room') + + finishOpen() + assert.equal(await opening, false) + assert.equal(t.$groupChatWorkspace.get(), 'Launch room') + assert.equal(groupEntry.disposed, false) + assert.equal(t.$openBotChat.get(), null) +}) + +// ── who owns the main workspace ───────────────────────────────────────────── + +test('ownership table: exactly one surface owns the center', () => { + const t = load() + + // Bot Mode not on screen: neither surface exists. + t.$botsPaneVisible.set(false) + assert.equal(t.botsHomeMayOpen(false), false) + assert.equal(t.botChatOwnsWorkspace(), false) + + // Bots visible, nothing else: the home owns it, Cronjobs stay away. + t.$botsPaneVisible.set(true) + assert.equal(t.botsHomeMayOpen(false), true) + assert.equal(t.botChatOwnsWorkspace(), false, 'no bot chat owns the center, so bot-scoped Cronjobs must not seat') + + // A group chat owns it: neither the home nor Cronjobs. + t.$groupChatWorkspace.set('Core') + assert.equal(t.botsHomeMayOpen(false), false) + assert.equal(t.botsHomeMayOpen(true), false, 'even an explicit gesture cannot cover a group chat') + assert.equal(t.botChatOwnsWorkspace(), false) + t.$groupChatWorkspace.set(null) + + // A bot chat owns it: the home yields and Cronjobs seat. + t.$openBotChat.set({ key: 'local::writer', openedRegistryId: 'chat-1' }) + assert.equal(t.botsHomeMayOpen(false), false) + assert.equal(t.botChatOwnsWorkspace(), true) + t.$openBotChat.set(null) + + // A focused session (restored at boot, or Sessions mode) vetoes PASSIVE + // opens but not an explicit gesture at the home. + t.focused.set('chat-9') + assert.equal(t.sessionOwnsWorkspace(), true) + assert.equal(t.botsHomeMayOpen(false), false) + assert.equal(t.botsHomeMayOpen(true), true) + assert.equal(t.botChatOwnsWorkspace(), true) +}) + +test('with the home tab fronted, the hidden chat does not seat Cronjobs', () => { + const t = load() + t.$botsPaneVisible.set(true) + + // Explicitly front the home over a focused chat. + t.focused.set('chat-1') + t.openBotsHomeWorkspace(true) + assert.equal(t.botsHomeVisible(), true) + assert.equal( + t.botChatOwnsWorkspace(), + false, + 'the chat is a hidden sibling layer while the home holds the tab slot' + ) + + // The user fronts the chat tab again (no focus change fires): the pane + // visibility flip alone must reseat Cronjobs. + t.closeBotsHomeWorkspace() + assert.equal(t.botsHomeVisible(), false) + assert.equal(t.botChatOwnsWorkspace(), true) +}) + +test('a persisted layout that restored the home behind the draft gets re-fronted', () => { + const t = load() + t.$botsPaneVisible.set(true) + t.syncBotsHomeWorkspace() + assert.equal(t.opened.length, 1) + + // The tree restored the tab BEHIND the core draft pane (adoption kept the + // persisted active slot): the ownerless composer would sit on top. + t.paneVisible.set('plugin-workspace:hermes-bots:home', false) + + t.syncBotsHomeWorkspace() + + assert.equal(t.opened.length, 2, 're-opened to reclaim the active slot') + assert.equal(t.closed.length, 1, 'the stale registration was closed first — never two live disposers') + assert.equal(t.botsHomeVisible(), true) +}) + +test('an explicit remote selection fronts the home over a focused chat', async () => { + const t = load({ focusedStoredSessionId: 'local-scout-chat' }) + t.setPluginCtx({ storage: { set: () => undefined } }) + t.$botsPaneVisible.set(true) + + // Passive sync must not cover the chat… + t.syncBotsHomeWorkspace() + assert.deepEqual(t.opened, []) + + // …but clicking the same-named twin on another gateway is a gesture at + // that owner: the home fronts, the chat stays alive underneath. + await t.openRosterBot({ connectionId: 'work-vps', connectionLabel: 'Work', name: 'scout', remoteSource: true }) + + assert.equal(t.opened.length, 1) + assert.equal(t.$selectedRosterKey.get(), 'work-vps::scout') + assert.equal(t.$openBotChat.get(), null) + assertNothingRouted(t, 'explicit remote selection') + + // Browsing more remote owners reuses the fronted home instead of + // re-registering it (a stale disposer would tear down the newer one). + await t.openRosterBot({ connectionId: 'work-vps', connectionLabel: 'Work', name: 'relay', remoteSource: true }) + assert.equal(t.opened.length, 1) + assert.equal(t.$selectedRosterKey.get(), 'work-vps::relay') +}) + +test('an explicit Bots-home gesture fronts the selected owner over a Sessions composer', () => { + const t = load({ focusedStoredSessionId: 'sessions-chat' }) + t.$botsPaneVisible.set(true) + + t.syncBotsHomeWorkspace() + assert.deepEqual(t.opened, [], 'passive polling still leaves a focused session alone') + + t.openBotsHomeWorkspace(true) + assert.equal(t.opened.length, 1, 'the explicit gesture has an exact owner instead of the Sessions composer') + assert.equal(t.opened[0].id, 'hermes-bots:home') +}) + +test('source contract: sidebar entry and boot restore reconcile passively after layout hydration', () => { + assert.match(pluginSource, /const syncWorkspaceSurfaces = \(\) =>/) + assert.match(pluginSource, /stopSidebarSync = \$sidebarVisible\.listen\(visible => \{[\s\S]{0,450}?syncWorkspaceSurfaces\(\)/) + assert.doesNotMatch(pluginSource, /stopSidebarSync = \$sidebarVisible\.listen\(visible => \{[\s\S]{0,450}?syncWorkspaceSurfaces\(Boolean\(visible\)\)/) + assert.match( + pluginSource, + /\$botChatFocused\.set\(sessionOwnsWorkspace\(\)\)[\s\S]{0,500}?syncWorkspaceSurfaces\(\)[\s\S]{0,120}?scheduleSurfaceSync\(\)/ + ) + assert.match( + pluginSource, + /homeVisibleStore\.listen\(visible => \{[\s\S]{0,260}?\$botsHomeFronted\.set\(Boolean\(visible\)\)[\s\S]{0,120}?scheduleSurfaceSync\(\)/ + ) +}) + +test('an opened chat releases the center once focus leaves it', () => { + const t = load() + t.$openBotChat.set({ key: 'local::writer', openedRegistryId: 'chat-1' }) + + t.releaseStaleOpenBotChat('chat-1') + assert.deepEqual(t.$openBotChat.get(), { key: 'local::writer', openedRegistryId: 'chat-1' }, 'still the focused chat') + + t.releaseStaleOpenBotChat('chat-2') + assert.equal(t.$openBotChat.get(), null, 'another session took the center') + + t.$openBotChat.set({ key: 'local::writer', openedRegistryId: 'chat-1' }) + t.releaseStaleOpenBotChat(null) + assert.equal(t.$openBotChat.get(), null, 'the chat was closed — the home may come back') +}) + +test('a legacy draft keeps the center until a real session takes focus', () => { + const t = load() + + // The newChat fallback has no stored id to compare against. + t.$openBotChat.set({ key: 'local::writer', openedRegistryId: '' }) + + t.releaseStaleOpenBotChat(null) + assert.deepEqual(t.$openBotChat.get(), { key: 'local::writer', openedRegistryId: '' }, 'an unsent draft is still that bot’s') + + t.releaseStaleOpenBotChat('chat-7') + assert.equal(t.$openBotChat.get(), null) +}) + +// ── the home tab itself ───────────────────────────────────────────────────── + +test('the home opens once and is not re-fronted while it already owns the center', () => { + const t = load() + t.$botsPaneVisible.set(true) + + t.syncBotsHomeWorkspace() + assert.equal(t.opened.length, 1) + assert.equal(t.opened[0].id, 'hermes-bots:home') + assert.equal(t.opened[0].options.title, 'Bots') + + // Repeated signals (focus churn, roster polls) must not steal focus back + // or mint a second disposer whose stale predecessor could tear down the + // newer registration. + t.syncBotsHomeWorkspace() + t.syncBotsHomeWorkspace() + assert.equal(t.opened.length, 1) + assert.deepEqual(t.closed, []) +}) + +test('the home yields the center to a chat and returns when the chat closes', () => { + const t = load() + t.$botsPaneVisible.set(true) + + t.syncBotsHomeWorkspace() + assert.equal(t.opened.length, 1) + + t.$openBotChat.set({ key: 'local::writer', openedRegistryId: 'chat-1' }) + t.syncBotsHomeWorkspace() + assert.equal(t.closed.length, 1, 'the home closed for the chat') + + t.$openBotChat.set(null) + t.syncBotsHomeWorkspace() + assert.equal(t.opened.length, 2, 'and comes back when nothing else owns the center') +}) + +test('a restored session at boot keeps the home from covering it', () => { + const t = load({ focusedStoredSessionId: 'restored-chat' }) + t.$botsPaneVisible.set(true) + + t.syncBotsHomeWorkspace() + + assert.deepEqual(t.opened, [], 'the home must not steal the tab from a session the user left open') +}) + +test('closing the home tab does not resurrect it mid-close', () => { + const t = load() + t.$botsPaneVisible.set(true) + t.syncBotsHomeWorkspace() + + // The tab's own ✕ routes through the same disposer. + t.opened[0].options.onClose() + + assert.equal(t.opened.length, 1, 'onClose must not re-open the tab the user just closed') + + // It comes back only when something else actually happens. + t.syncBotsHomeWorkspace() + assert.equal(t.opened.length, 2) +}) + +test('the home never yanks the center back from a sibling tab the user chose', () => { + const t = load() + t.$botsPaneVisible.set(true) + t.syncBotsHomeWorkspace() + assert.equal(t.opened.length, 1) + + // The user tabs to the ordinary draft workspace: no session owns the + // center, so the home still "should" own it — but it is already open, and + // re-opening would front it over the tab the user just picked. + t.focused.set(null) + t.syncBotsHomeWorkspace() + t.syncBotsHomeWorkspace() + + assert.equal(t.opened.length, 1, 'focus churn must not re-front the home') + assert.deepEqual(t.closed, []) +}) + +test('older shells without the main-area door simply have no home', async () => { + const t = load({ openWorkspace: false, paneVisibility: false }) + t.setPluginCtx({ storage: { set: () => undefined } }) + t.$botsPaneVisible.set(true) + + t.syncBotsHomeWorkspace() + assert.deepEqual(t.opened, []) + + // And the remote row keeps its previous guidance toast instead. + await t.openRosterBot({ connectionId: 'work-vps', connectionLabel: 'Work', name: 'researcher', remoteSource: true }) + + assert.equal(t.notifications.length, 1) + // Guidance is presentation only; remote mention delivery remains backend-owned. + assert.match(t.notifications[0].message, /message @researcher from a Bot Chat/) + assertNothingRouted(t, 'remote row on an older shell') +}) + +// ── view contracts ────────────────────────────────────────────────────────── + +test('the home Tip wraps exactly one element (Radix asChild)', () => { + const start = pluginSource.indexOf('function BotsHomeView(') + assert.ok(start >= 0) + const view = pluginSource.slice(start, pluginSource.indexOf('function closeBotsHomeWorkspace(')) + + assert.match(view, /jsx\(Tip, \{\s*\n\s*label:[^\n]*\n\s*children: jsxs\('div'/, 'one child element, not an array') + assert.doesNotMatch(view, /jsxs\(Tip, \{/, 'jsxs would pass multiple children and break the trigger') + // The screen-reader text rides INSIDE the trigger element. + assert.match(view, /className: 'sr-only'/) +}) + +test('the home shows a loading state rather than flashing “No bots”', () => { + const start = pluginSource.indexOf('function BotsHomeView(') + const view = pluginSource.slice(start, pluginSource.indexOf('function closeBotsHomeWorkspace(')) + + assert.match(view, /if \(!rosterHydrated \|\| !selectionHydrated\) \{[\s\S]{0,220}?GlyphSpinner/) + const spinnerAt = view.indexOf('GlyphSpinner') + const emptyAt = view.indexOf("title: roster.length ? 'Choose a bot or group chat' : 'No bots yet'") + assert.ok(spinnerAt >= 0 && emptyAt > spinnerAt, 'the empty state is only reachable after both hydrations') +}) + +test('the home uses the neutral workspace surface instead of a transient blue tint', () => { + const start = pluginSource.indexOf('function BotsHomeView(') + const view = pluginSource.slice(start, pluginSource.indexOf('function closeBotsHomeWorkspace(')) + + assert.match(view, /className: 'flex h-full min-h-0 flex-col bg-background'/) + assert.doesNotMatch(view, /bg-\(--ui-bg-primary\)/) +}) + +test('roster hydration and selection reconciliation run after render', () => { + const start = pluginSource.indexOf('function BotsPane(') + const pane = pluginSource.slice(start, pluginSource.indexOf('// ── registration')) + + assert.match( + pane, + /useEffect\(\(\) => \{[\s\S]{0,500}?\$rosterHydrated\.set\(true\)[\s\S]{0,300}?reconcileRosterSelection\(roster, sourceSnapshot, allMeta\)[\s\S]{0,220}?\}, \[data, error, selectionHydrated, roster, sourceSnapshot, allMeta\]\)/, + 'persisted roster ownership must reconcile from an effect, never from a replayable render' + ) + assert.equal( + (pane.match(/reconcileRosterSelection\(roster, sourceSnapshot, allMeta\)/g) || []).length, + 1, + 'BotsPane has one effect-bound reconciliation path' + ) + assert.match( + pane, + /sourceWithSelectedOwner = selectionHydrated && rosterHydrated[\s\S]{0,180}?rosterWithSelectedOwner/, + 'an unavailable-owner placeholder cannot bypass initial roster hydration' + ) + assert.match( + pane, + /\$lastRoster\.set\(roster\.filter\(row => !row\?\.ghost\)\)/, + 'presentation-only owner placeholders never enter shared roster state' + ) +}) + +test('an unavailable owner offers retry instead of a dead Open chat button', () => { + const start = pluginSource.indexOf('function BotsHomeView(') + const view = pluginSource.slice(start, pluginSource.indexOf('function closeBotsHomeWorkspace(')) + + assert.match(view, /unavailable && !sourceRemoved\s*\n\s*\? jsx\(Button, \{[\s\S]{0,200}?children: 'Retry'/) + // Retry re-polls the roster; it must not activate or route anything. + assert.match(view, /queryClient\.invalidateQueries\(\{ queryKey: ROSTER_KEY \}\)/) + assert.doesNotMatch(view, /ensureAgent|requestProfile|newChat/) + assert.match(view, /\$\{gateway\} is unavailable\. Retry when it is back online\./) + assert.match(view, /\$\{gateway\} was removed\. Choose another bot from the sidebar\./) + assert.doesNotMatch(view, /This bot remains selected/) + assert.doesNotMatch(view, /its work keeps running on that gateway/) +}) + +test('an available remote owner explains the supported Bot Chat path without a fake direct action', () => { + const start = pluginSource.indexOf('function BotsHomeView(') + const view = pluginSource.slice(start, pluginSource.indexOf('function closeBotsHomeWorkspace(')) + + assert.match(view, /unavailable \|\| !bot\.remoteSource/) + assert.match(view, /This bot lives on \$\{gateway\}\. Mention it from any Bot Chat to send it a message\./) + assert.doesNotMatch(view, /Copy @/) + assert.doesNotMatch(view, /remoteCopy/) + assert.doesNotMatch(view, /ensureAgent|requestProfile|newChat/) +}) + +test('an unavailable owner never presents a guessed mention handle', () => { + const start = pluginSource.indexOf('function BotsHomeView(') + const view = pluginSource.slice(start, pluginSource.indexOf('function closeBotsHomeWorkspace(')) + + assert.match(view, /const handle = bot\.ghost \? '' : botHandle\(bot\.name, bot\)/) + assert.match(view, /handle\s*\n\s*\? jsx\('span'/) +}) + +test('Bot Mode copy says bot, not agent', () => { + assert.doesNotMatch(pluginSource, /Name the agent first/) + assert.doesNotMatch(pluginSource, /create agents first/) + assert.doesNotMatch(pluginSource, /children: busy \? 'Creating…' : 'Create Agent'/) + assert.doesNotMatch(pluginSource, /`Agent "\$\{displayName\(\{ name: slug, title \}\)\}" created/) + assert.match(pluginSource, /Name the bot first/) + assert.match(pluginSource, /children: busy \? 'Creating…' : 'Create Bot'/) + assert.match(pluginSource, /`Bot "\$\{displayName\(\{ name: slug, title \}\)\}" created/) +}) diff --git a/apps/desktop/src/plugins/hermes-bots/tests/bots-search.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/bots-search.test.mjs index c0ae2c07dd97..b7269961f0f5 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/bots-search.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/bots-search.test.mjs @@ -11,7 +11,7 @@ function loadFilter() { assert.ok(start >= 0 && end > start, 'bot identity helper block must remain extractable') - const context = {} + const context = { botActivitySession: bot => bot?.canonical_session || bot?.last_session || null } vm.runInNewContext( `${source.slice(start, end)}\nglobalThis.__filterBots = filterBots;`, context @@ -56,6 +56,23 @@ test('bot search matches profile handles and preserves roster order', () => { ) }) +test('bot search also matches roles, message previews, and gateway labels', () => { + const filterBots = loadFilter() + const richerRoster = [ + { + name: 'reviewer', + connectionLabel: 'Work Studio', + description: 'Release quality and compliance', + last_session: { preview: 'Checked the deployment checklist' } + }, + { name: 'writer', description: 'Editorial support' } + ] + + assert.equal(filterBots(richerRoster, {}, 'compliance')[0].name, 'reviewer') + assert.equal(filterBots(richerRoster, {}, 'deployment checklist')[0].name, 'reviewer') + assert.equal(filterBots(richerRoster, {}, 'work studio')[0].name, 'reviewer') +}) + test('blank bot search returns the existing roster reference', () => { const filterBots = loadFilter() @@ -63,7 +80,16 @@ test('blank bot search returns the existing roster reference', () => { }) test('Bot pane renders the canonical search field and no-match state', () => { - assert.match(source, /jsx\(SearchField,\s*\{[\s\S]*?placeholder: 'Search bots…'/) + assert.match( + source, + /const showRosterSearch =\s*gatewayOptions\.length > 1 \|\| rosterItemCount >= BOT_ROSTER_SEARCH_THRESHOLD/ + ) + assert.match(source, /jsx\(SearchField,\s*\{[\s\S]*?placeholder: 'Search bots and group chats…'/) + assert.match(source, /query \? 'opacity-100!' : 'opacity-50 focus-within:opacity-100'/) + assert.match(source, /placeholder:text-\(--ui-text-tertiary\)/) + assert.match(source, /children: jsx\(Codicon, \{ name: 'list-filter' \}\)/) assert.match(source, /'aria-live': 'polite'/) - assert.match(source, /No bots match “\$\{query\.trim\(\)\}”/) + assert.match(source, /No bots or group chats match “\$\{query\.trim\(\)\}”/) + assert.match(source, /const initialRosterLoading = !data && !error && roster\.length === 0/) + assert.match(source, /\(isLoading \|\| initialRosterLoading\) && !roster\.length/) }) diff --git a/apps/desktop/src/plugins/hermes-bots/tests/create-group-chat.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/create-group-chat.test.mjs index 1eb1e1e57fa5..899a2a3e3688 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/create-group-chat.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/create-group-chat.test.mjs @@ -4,21 +4,23 @@ import test from 'node:test' const pluginSource = readFileSync(new URL('../plugin.js', import.meta.url), 'utf8') -// Discord-style creation flow: the header "+" is a dropdown (New Agent / +// Discord-style creation flow: the header "+" is a dropdown (New Bot / // New Group Chat), and New Group Chat opens a checkbox-picker modal with // search, a name input, and a Create button. Source-contract style, like // the other roster affordance tests. test('source contract: header + is a dropdown offering agent and group chat', () => { assert.match(pluginSource, /DropdownMenuTrigger/) - assert.match(pluginSource, /'New Agent'/) + assert.match(pluginSource, /'New Bot'/) assert.match(pluginSource, /'New Group Chat'/) }) test('source contract: create-group modal has search, checkboxes, name, create', () => { assert.match(pluginSource, /function CreateGroupChatDialog\(/) + // An outage placeholder preserves identity, but cannot receive a message. + assert.match(pluginSource, /const selectableRoster = roster\.filter\(bot => !bot\?\.ghost\)/) // Reuses the roster search filter so name/@handle/title all match. - assert.match(pluginSource, /const visible = filterBots\(roster, allMeta, query\)/) + assert.match(pluginSource, /const visible = filterBots\(selectableRoster, allMeta, query\)/) // Selection is checkbox-driven and capped at the room member limit. assert.match(pluginSource, /const atCap = selected\.length >= GROUP_CHAT_MAX_MEMBERS/) // Create requires 2+ members. Membership mutation is covered by the diff --git a/apps/desktop/src/plugins/hermes-bots/tests/group-chat-identity-edit.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/group-chat-identity-edit.test.mjs index f97607159eae..c96b4ada0ef7 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/group-chat-identity-edit.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/group-chat-identity-edit.test.mjs @@ -40,8 +40,11 @@ test('source contract: room picture persists and hydrates with the room record', }) test('source contract: room picture shows in the roster row and room header', () => { - // Roster row: picture wins over the fanned member faces when set. - assert.match(pluginSource, /children: room\.image\s*\n\s*\? jsx\('img', \{/) + // Roster row: one room picture wins over a generic room icon when set. + const rowSource = pluginSource.slice(pluginSource.indexOf('function GroupRow('), pluginSource.indexOf('function RosterSectionHeader(')) + assert.match(rowSource, /room\.image\s*\n\s*\? jsx\('img', \{/) + assert.match(rowSource, /name: 'organization'/) + assert.doesNotMatch(rowSource, /members\.slice\([^)]*\)\.map/) // Header: picture leads the title. assert.match(pluginSource, /Room picture \(set via Group settings\) leads the title when present/) }) diff --git a/apps/desktop/src/plugins/hermes-bots/tests/pane-dock-layout.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/pane-dock-layout.test.mjs index 75c70cdf2e89..8d4000349ddc 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/pane-dock-layout.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/pane-dock-layout.test.mjs @@ -8,10 +8,12 @@ import test from 'node:test' // so every boot re-homes a stacked install — no heal token, no // user-placed exemption (the retired one-shot heal left users who had // dragged panes stuck stacked forever); -// - the Cronjobs (routines) pane only exists while the Bots pane is on -// screen — registered/unregistered through the contribution disposer, -// driven by the feature-detected host.paneVisibility SDK export, with the -// always-registered fallback kept for older desktops. +// - the Cronjobs (routines) pane only exists while a BOT CHAT owns the main +// workspace and the Bots pane is on screen — registered/unregistered +// through the contribution disposer, driven by the feature-detected +// host.paneVisibility SDK export, with the always-registered fallback kept +// for older desktops. Cronjobs are bot-scoped, so the tile must not sit +// beside the ownerless Bots home or a group chat. const source = readFileSync(new URL('../plugin.js', import.meta.url), 'utf8') @@ -34,8 +36,12 @@ test('routines pane rides Bots visibility via feature-detected host.paneVisibili // Transitions register/unregister through the tracked disposer. assert.match(source, /unregisterRoutines \?\?= registerRoutinesPane\(\)/) assert.match(source, /unregisterRoutines\(\)\s*\n\s*unregisterRoutines = null/) - // The visibility listener must not survive plugin disable. - assert.match(source, /ctx\.onDispose\(stopRoutinesSync\)/) + // Ownership, not mere visibility: an actual bot chat must own the center. + assert.match(source, /if \(botChatOwnsWorkspace\(\)\) \{/) + // None of the lifecycle listeners may survive plugin disable. + assert.match(source, /ctx\.onDispose\(\(\) => \{\s*\n\s*stopSidebarSync\(\)/) + assert.match(source, /stopGroupSync\(\)/) + assert.match(source, /stopFocusSync\?\.\(\)/) }) test('older desktops without the SDK export keep the always-registered pane', () => { diff --git a/apps/desktop/src/plugins/hermes-bots/tests/roster-groups.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/roster-groups.test.mjs index f275ce93d21d..4a337ce5ee36 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/roster-groups.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/roster-groups.test.mjs @@ -125,6 +125,34 @@ test('groupChatMemberBots: seats local meta members plus stored remote descripto assert.equal(members[2], roster[2]) }) +test('groupChatMemberBots: stored descriptors beat presentation-only ghosts', () => { + const { groupChatMemberBots, $groupChats } = load() + const ghost = { + name: 'spark', + remoteSource: true, + sourceScoped: true, + ghost: true, + connectionId: 'c1', + connectionLabel: 'Workshop' + } + const stored = { + name: 'spark', + handle: 'spark-work', + title: 'Spark', + remoteSource: true, + sourceScoped: true, + connectionId: 'c1', + connectionLabel: 'Workshop' + } + $groupChats.set({ Research: { log: [], members: [stored] } }) + + const members = groupChatMemberBots('Research', [ghost], {}) + + assert.equal(members.length, 1) + assert.equal(members[0], stored) + assert.equal(members[0].handle, 'spark-work') +}) + test('durableGroupChatMembers: retains active and remote source identities', () => { const { durableGroupChatMembers } = load() const members = durableGroupChatMembers([ @@ -201,10 +229,11 @@ test('stripPreviewMarkdown: flattens bold, quotes, code, and links out of previe assert.equal(stripPreviewMarkdown(''), '') }) -test('source contract: the roster stays a flat list of bot and group rows', () => { - // Ordering is deliberately unchanged in this PR; sectioned ordering follows separately. - assert.doesNotMatch(pluginSource, /function groupRoster\(/) - assert.match(pluginSource, /rosterRows\.map\(row =>/) +test('source contract: the roster progressively groups multiple gateways and keeps group chats distinct', () => { + assert.match(pluginSource, /function rosterGatewaySections\(/) + assert.match(pluginSource, /options\.length <= 1/) + assert.match(pluginSource, /gatewayFilter !== 'all'/) + assert.match(pluginSource, /label: 'Group chats'/) assert.match(pluginSource, /function GroupRow\(/) assert.match(pluginSource, /onGroup: setGrouping/) }) diff --git a/apps/desktop/src/plugins/hermes-bots/tests/roster-preview.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/roster-preview.test.mjs index fba9ed2f7477..89cc02fdef56 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/roster-preview.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/roster-preview.test.mjs @@ -105,6 +105,7 @@ function renderRuntime() { ContextMenuItem: 'ContextMenuItem', ContextMenuSeparator: 'ContextMenuSeparator', ContextMenuTrigger: 'ContextMenuTrigger', + Tip: 'Tip', haptic: () => undefined, host: { state: { @@ -173,12 +174,12 @@ const DM_BOT = { } } -test('render: BotRow shows the sender badge and stripped DM preview', () => { +test('render: BotRow shows a clean stripped DM preview without delivery furniture', () => { const r = renderRuntime() const tree = r.__BotRow({ bot: DM_BOT, onEdit: () => undefined }) const text = textOf(tree) - assert.match(text, /@manager/) assert.match(text, /Learn-share/) + assert.doesNotMatch(text, /@manager/) assert.doesNotMatch(text, /Message from/) }) @@ -200,10 +201,11 @@ test('render: BotRow tolerates a fresh bot with no sessions yet', () => { const r = renderRuntime() const tree = r.__BotRow({ bot: { name: 'newbie', title: '', description: 'Fresh bot' }, onEdit: () => undefined }) const text = textOf(tree) - assert.match(text, /Fresh bot/) + assert.match(text, /Newbie/) + assert.doesNotMatch(text, /No conversations yet/) }) -test('render: a remote gateway name is not squeezed out by its handle', () => { +test('render: a remote bot keeps its identity while gateway details stay in the tooltip', () => { const r = renderRuntime() const tree = r.__BotRow({ bot: { @@ -215,15 +217,14 @@ test('render: a remote gateway name is not squeezed out by its handle', () => { }, onEdit: () => undefined }) - const name = findNode(tree, node => node.type === 'span' && textOf(node) === 'Studio over SSH') - const handle = findNode(tree, node => node.type === 'span' && textOf(node) === '@default-studio-over-ssh') + const name = findNode(tree, node => node.type === 'span' && textOf(node) === 'Hermes') + const button = findNode(tree, node => node.type === 'button' && node.props?.['aria-label']) assert.ok(name) - assert.match(name.props.className, /shrink-0/) - assert.ok(handle) - assert.match(handle.props.className, /min-w-0/) - assert.match(handle.props.className, /truncate/) - assert.doesNotMatch(handle.props.className, /shrink-0/) + assert.ok(button) + assert.match(button.props['aria-label'], /Hermes/) + assert.match(button.props['aria-label'], /Studio over SSH/) + assert.doesNotMatch(textOf(button), /Studio over SSH/) }) test('render: BotRow previews the pinned canonical chat, not an unrelated latest session', () => { From ee10d0910e313c9e3cec3e29642cc9fff045c4cb Mon Sep 17 00:00:00 2001 From: David Dudok de Wit <5354424+dokterdok@users.noreply.github.com> Date: Sat, 22 Aug 2026 23:18:34 +0200 Subject: [PATCH 02/10] feat(desktop): define workspace-scoped pane ownership --- apps/desktop/src/app/chat/pane-mirror.test.ts | 80 +++++++++ apps/desktop/src/app/chat/pane-mirror.ts | 31 +++- apps/desktop/src/app/chat/preview-tile.tsx | 1 + apps/desktop/src/app/chat/route-tile.tsx | 1 + apps/desktop/src/app/chat/session-tile.tsx | 2 + apps/desktop/src/app/contrib/controller.tsx | 2 + apps/desktop/src/app/open-session.test.ts | 15 +- apps/desktop/src/app/open-session.ts | 27 ++- .../pane-shell/tree/renderer/tree-group.tsx | 32 +++- .../pane-shell/tree/renderer/tree-split.tsx | 8 +- .../tree/renderer/workspace-scope.test.tsx | 96 ++++++++++ .../pane-shell/workspace-scope.test.ts | 169 ++++++++++++++++++ .../components/pane-shell/workspace-scope.ts | 167 +++++++++++++++++ apps/desktop/src/contrib/index.ts | 2 +- apps/desktop/src/contrib/types.ts | 19 ++ apps/desktop/src/sdk/index.test.ts | 42 +++++ apps/desktop/src/sdk/index.ts | 63 +++++-- apps/desktop/src/sdk/profile-routing.test.ts | 20 +++ apps/desktop/src/store/session-states.test.ts | 68 ++++++- apps/desktop/src/store/session-states.ts | 79 +++++++- 20 files changed, 882 insertions(+), 42 deletions(-) create mode 100644 apps/desktop/src/app/chat/pane-mirror.test.ts create mode 100644 apps/desktop/src/components/pane-shell/tree/renderer/workspace-scope.test.tsx create mode 100644 apps/desktop/src/components/pane-shell/workspace-scope.test.ts create mode 100644 apps/desktop/src/components/pane-shell/workspace-scope.ts diff --git a/apps/desktop/src/app/chat/pane-mirror.test.ts b/apps/desktop/src/app/chat/pane-mirror.test.ts new file mode 100644 index 000000000000..3b6ceacc07fe --- /dev/null +++ b/apps/desktop/src/app/chat/pane-mirror.test.ts @@ -0,0 +1,80 @@ +import { atom } from 'nanostores' +import { afterEach, describe, expect, it } from 'vitest' + +import { registry } from '@/contrib/registry' + +import { paneMirror } from './pane-mirror' + +interface Tile { + id: string + owner?: string +} + +const cleanupSources: Array>> = [] +let sequence = 0 + +function setup(options: { + workspaceMode?: 'sessions' | 'bots' | ((tile: Tile) => 'sessions' | 'bots' | undefined) + workspaceOwnerKey?: string | ((tile: Tile) => string | undefined) +}) { + const source = atom([]) + const prefix = `pane-mirror-scope-${sequence++}` + cleanupSources.push(source) + + paneMirror({ + source, + key: tile => tile.id, + prefix, + minWidth: '10rem', + title: key => key, + render: () => null, + close: () => undefined, + ...options + })() + + return { + source, + contribution: (id: string) => registry.getArea('panes').find(entry => entry.id === `${prefix}:${id}`) + } +} + +afterEach(() => { + for (const source of cleanupSources.splice(0)) { + source.set([]) + } +}) + +describe('paneMirror workspace scope', () => { + it('forwards a static workspace mode', () => { + const mirror = setup({ workspaceMode: 'sessions' }) + mirror.source.set([{ id: 'one' }]) + + expect(mirror.contribution('one')).toMatchObject({ + workspaceMode: 'sessions', + workspaceOwnerKey: undefined + }) + }) + + it('resolves owner callbacks per tile and refreshes an unchanged title', () => { + const mirror = setup({ + workspaceMode: 'bots', + workspaceOwnerKey: tile => tile.owner + }) + + mirror.source.set([{ id: 'one', owner: 'connection-a::default' }]) + expect(mirror.contribution('one')?.workspaceOwnerKey).toBe('connection-a::default') + + mirror.source.set([{ id: 'one', owner: 'connection-b::default' }]) + expect(mirror.contribution('one')?.workspaceOwnerKey).toBe('connection-b::default') + }) + + it('leaves existing callers unscoped when options are omitted', () => { + const mirror = setup({}) + mirror.source.set([{ id: 'one' }]) + + expect(mirror.contribution('one')).toMatchObject({ + workspaceMode: undefined, + workspaceOwnerKey: undefined + }) + }) +}) diff --git a/apps/desktop/src/app/chat/pane-mirror.ts b/apps/desktop/src/app/chat/pane-mirror.ts index 3a6995e1b4d6..2a2d5dfb4892 100644 --- a/apps/desktop/src/app/chat/pane-mirror.ts +++ b/apps/desktop/src/app/chat/pane-mirror.ts @@ -11,13 +11,23 @@ import type { ReactElement, ReactNode, PointerEvent as ReactPointerEvent } from import { registerPaneCloser, removeTreePane, treePanesWithPrefix } from '@/components/pane-shell/tree/store' import { registry } from '@/contrib/registry' +import type { WorkspaceMode } from '@/contrib/types' import type { TileDock } from '@/store/session-states' +type WorkspaceValue = V | ((tile: T) => V | undefined) + +const workspaceValue = (value: WorkspaceValue | undefined, tile: T): V | undefined => + typeof value === 'function' ? (value as (tile: T) => V | undefined)(tile) : value + export interface PaneMirror { /** Reactive source list. */ source: ReadableAtom /** Extra atoms whose changes should re-sync (e.g. titles living elsewhere). */ also?: ReadableAtom[] + /** Workspace surface this tile belongs to. Omit for a global pane. */ + workspaceMode?: WorkspaceValue + /** Exact opaque owner inside Bot Mode. Omit outside an owner-scoped pane. */ + workspaceOwnerKey?: WorkspaceValue /** Stable key + pane-id seed for a tile. */ key: (tile: T) => string /** Pane-id namespace — the id is `${prefix}:${key}`. */ @@ -51,7 +61,11 @@ export interface PaneMirror { /** Build a `watch*` fn: syncs once, then re-syncs on every source/also change. * Module-level state lives in the returned closure, so call it once per app. */ export function paneMirror(cfg: PaneMirror): () => void { - const registered = new Map void; title: string }>() + const registered = new Map< + string, + { dispose: () => void; title: string; workspaceMode?: WorkspaceMode; workspaceOwnerKey?: string } + >() + const paneId = (key: string) => `${cfg.prefix}:${key}` const sync = () => { @@ -61,10 +75,17 @@ export function paneMirror(cfg: PaneMirror): () => void { for (const tile of tiles) { const key = cfg.key(tile) const title = cfg.title(key) + const workspaceMode = workspaceValue(cfg.workspaceMode, tile) + const workspaceOwnerKey = workspaceValue(cfg.workspaceOwnerKey, tile) const current = registered.get(key) // register() replaces same-id in place — safe for live title refreshes. - if (current && current.title === title) { + if ( + current && + current.title === title && + current.workspaceMode === workspaceMode && + current.workspaceOwnerKey === workspaceOwnerKey + ) { continue } @@ -90,10 +111,12 @@ export function paneMirror(cfg: PaneMirror): () => void { : undefined, // returns boolean (handled) — see PaneChrome.tabDrag tabWrap: cfg.tabWrap ? (tab: ReactElement) => cfg.tabWrap!(key, tab) : undefined }, - render: () => cfg.render(key) + render: () => cfg.render(key), + workspaceMode, + workspaceOwnerKey }) - registered.set(key, { dispose, title }) + registered.set(key, { dispose, title, workspaceMode, workspaceOwnerKey }) if (!current) { registerPaneCloser(paneId(key), () => cfg.close(key)) diff --git a/apps/desktop/src/app/chat/preview-tile.tsx b/apps/desktop/src/app/chat/preview-tile.tsx index 7051fbe80f6c..4b6db09d9096 100644 --- a/apps/desktop/src/app/chat/preview-tile.tsx +++ b/apps/desktop/src/app/chat/preview-tile.tsx @@ -122,6 +122,7 @@ export function watchPreviewTiles(): void { const watchPreviewTileMirror = paneMirror<{ id: string }>({ source: $previewTabs, + workspaceMode: 'sessions', key: tab => tab.id, prefix: PREVIEW_TILE_PREFIX, // Identical to route (page) tiles: its own zone docked beside main, sized by diff --git a/apps/desktop/src/app/chat/route-tile.tsx b/apps/desktop/src/app/chat/route-tile.tsx index 1388f48c0425..f2af03527a24 100644 --- a/apps/desktop/src/app/chat/route-tile.tsx +++ b/apps/desktop/src/app/chat/route-tile.tsx @@ -86,6 +86,7 @@ function RouteTilePane({ path }: { path: string }) { /** Keep pane contributions mirroring `$routeTiles`. Call once from the root. */ export const watchRouteTiles = paneMirror({ source: $routeTiles, + workspaceMode: 'sessions', key: t => t.path, prefix: 'route-tile', dir: t => t.dir, diff --git a/apps/desktop/src/app/chat/session-tile.tsx b/apps/desktop/src/app/chat/session-tile.tsx index d2113ada5378..2adcc048a331 100644 --- a/apps/desktop/src/app/chat/session-tile.tsx +++ b/apps/desktop/src/app/chat/session-tile.tsx @@ -595,6 +595,8 @@ export function WorkspaceTabMenu({ children }: { children: React.ReactElement }) * `$sessions`). Tiles dock against main on the chosen edge, flex width. */ export const watchSessionTiles = paneMirror({ source: $sessionTiles, + workspaceMode: tile => tile.workspaceMode ?? 'sessions', + workspaceOwnerKey: tile => tile.workspaceOwnerKey, // $projectTree: a tile whose session is older than the recents page resolves // its title through the tree, which loads after the tiles register. (The tab's // status dot subscribes to color/state itself, so it needs no `also` entry.) diff --git a/apps/desktop/src/app/contrib/controller.tsx b/apps/desktop/src/app/contrib/controller.tsx index 79ba22d81729..2fe447b07805 100644 --- a/apps/desktop/src/app/contrib/controller.tsx +++ b/apps/desktop/src/app/contrib/controller.tsx @@ -176,6 +176,7 @@ registry.registerMany([ { id: 'workspace', area: 'panes', + workspaceMode: 'sessions', // Live-retitled to the loaded session by syncWorkspaceTitle below. title: NEW_SESSION_TITLE, data: { @@ -482,6 +483,7 @@ const syncWorkspaceTitle = () => { registry.register({ id: 'workspace', area: 'panes', + workspaceMode: 'sessions', // The placeholder, not the draft's live name — `tabTitle` below renders // that. Keeping it here would re-register the pane on every keystroke. title: stored ? storedSessionTitle(stored) : NEW_SESSION_TITLE, diff --git a/apps/desktop/src/app/open-session.test.ts b/apps/desktop/src/app/open-session.test.ts index 998bc33af000..d2d8339f324d 100644 --- a/apps/desktop/src/app/open-session.test.ts +++ b/apps/desktop/src/app/open-session.test.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const focusOpenSession = vi.fn() const openSessionTile = vi.fn() const reuseBlankDraftTile = vi.fn() +const setSessionTileWorkspaceScope = vi.fn() const openSessionInNewWindow = vi.fn() const canOpenSessionWindow = vi.fn(() => true) const workspaceIsPageGet = vi.fn(() => false) @@ -12,7 +13,8 @@ vi.mock('@/store/session-states', () => ({ !focused || (focused === 'main' && workspaceIsPage), focusOpenSession: (...args: unknown[]) => focusOpenSession(...args), openSessionTile: (...args: unknown[]) => openSessionTile(...args), - reuseBlankDraftTile: (...args: unknown[]) => reuseBlankDraftTile(...args) + reuseBlankDraftTile: (...args: unknown[]) => reuseBlankDraftTile(...args), + setSessionTileWorkspaceScope: (...args: unknown[]) => setSessionTileWorkspaceScope(...args) })) vi.mock('@/store/windows', () => ({ @@ -89,6 +91,7 @@ describe('openSession', () => { canOpenSessionWindow.mockReturnValue(true) workspaceIsPageGet.mockReturnValue(false) reuseBlankDraftTile.mockReset() + setSessionTileWorkspaceScope.mockReset() $activeSessionId.set(null) $selectedStoredSessionId.set(null) }) @@ -143,6 +146,16 @@ describe('openSession', () => { expect(navigate).not.toHaveBeenCalled() }) + it('threads an exact Bot owner into a new session tile', () => { + const scope = { workspaceMode: 'bots' as const, workspaceOwnerKey: 'connection-a::default' } + focusOpenSession.mockReturnValue(null) + + openSession('s1', navigate, 'tab', scope) + + expect(setSessionTileWorkspaceScope).toHaveBeenCalledWith('s1', scope) + expect(openSessionTile).toHaveBeenCalledWith('s1', 'center', undefined, undefined, scope) + }) + it('stack focuses a session that is already on screen', () => { $selectedStoredSessionId.set('s0') focusOpenSession.mockReturnValue('tile') diff --git a/apps/desktop/src/app/open-session.ts b/apps/desktop/src/app/open-session.ts index 34b3278969f0..0781cf001383 100644 --- a/apps/desktop/src/app/open-session.ts +++ b/apps/desktop/src/app/open-session.ts @@ -14,12 +14,14 @@ * - `window` (⇧⌘-click) — pop into its own window; falls back to `tab` when * the bridge has no session-window support. */ +import type { WorkspaceMode } from '@/contrib/types' import { $activeSessionId, $selectedStoredSessionId, markSessionRead } from '@/store/session' import { focusedSessionNeedsRoute, focusOpenSession, openSessionTile, - reuseBlankDraftTile + reuseBlankDraftTile, + setSessionTileWorkspaceScope } from '@/store/session-states' import { canOpenSessionWindow, openSessionInNewWindow } from '@/store/windows' @@ -29,6 +31,11 @@ export type OpenSessionIntent = 'in-place' | 'main' | 'stack' | 'tab' | 'window' export type OpenSessionNavigate = (to: string, options?: { replace?: boolean }) => void +export interface OpenSessionWorkspaceScope { + workspaceMode: WorkspaceMode + workspaceOwnerKey?: string +} + /** * Is the main tab holding a conversation worth preserving? * @@ -72,7 +79,8 @@ export function openSessionIntentFromModifiers( export function openSession( storedSessionId: string, navigate: OpenSessionNavigate, - intent: OpenSessionIntent = 'in-place' + intent: OpenSessionIntent = 'in-place', + workspaceScope: OpenSessionWorkspaceScope = { workspaceMode: 'sessions' } ): void { if (!storedSessionId) { return @@ -83,6 +91,8 @@ export function openSession( // already on screen (open tile, or the main session) would otherwise return // at focusOpenSession and never clear its unread dot. markSessionRead(storedSessionId) + setSessionTileWorkspaceScope(storedSessionId, workspaceScope) + const botWorkspaceScope = workspaceScope.workspaceMode === 'bots' ? workspaceScope : undefined let resolved: OpenSessionIntent = intent @@ -128,11 +138,20 @@ export function openSession( // Nothing to jump to, but an open tab may still be an empty "New session" — // that's the tab the user would have typed into, so spend it rather than // stacking a second blank one beside it. - if (spendBlankDraft && reuseBlankDraftTile(storedSessionId)) { + if ( + spendBlankDraft && + (botWorkspaceScope + ? reuseBlankDraftTile(storedSessionId, botWorkspaceScope) + : reuseBlankDraftTile(storedSessionId)) + ) { return } - openSessionTile(storedSessionId, 'center') + if (botWorkspaceScope) { + openSessionTile(storedSessionId, 'center', undefined, undefined, botWorkspaceScope) + } else { + openSessionTile(storedSessionId, 'center') + } return } diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx b/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx index bc1e7b90eff1..4e021c587344 100644 --- a/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx +++ b/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx @@ -10,7 +10,7 @@ */ import { useStore } from '@nanostores/react' -import { type CSSProperties, Fragment, type ReactNode, type RefObject, useRef, useState } from 'react' +import { type CSSProperties, Fragment, type ReactNode, type RefObject, useEffect, useRef, useState } from 'react' import { ActionsContextMenu, type MenuKit, renderActionItem } from '@/components/ui/actions-menu' import { Codicon } from '@/components/ui/codicon' @@ -35,6 +35,14 @@ import { $layoutEditMode } from '../../edit-mode' import { useWindowControlsOverlap } from '../../geometry' import { emptyPaneLifecycleState, reconcilePaneLifecycle } from '../../pane-lifecycle' import { hiddenPaneProps, PaneGroupContext, PaneLifecycleContext, PaneVisibleContext } from '../../pane-visibility' +import { + $workspaceMode, + $workspaceOwnerKey, + contributesToWorkspace, + rememberActivePane, + resolveRememberedActivePane, + workspaceScopeKey +} from '../../workspace-scope' import type { DropPosition, GroupNode } from '../model' import { $dropHint, @@ -228,6 +236,8 @@ export function TreeGroup({ const hiddenPanes = useStore($hiddenTreePanes) const narrow = useStore($narrowViewport) + const workspaceMode = useStore($workspaceMode) + const workspaceOwnerKey = useStore($workspaceOwnerKey) const newSessionTabAction = useStore($newSessionTabAction) const panesWithCloser = useStore($panesWithCloser) // Multi-tab selection (⌥/Ctrl-click, Shift-click) — null for every zone but @@ -245,12 +255,26 @@ export function TreeGroup({ // Edit mode forces toggle-hidden panes visible so they can be rearranged // (mirrors tree-split's paneGone) — restores itself on exit. const paneShown = (id: string) => - Boolean(paneFor(id)) && (editMode || !hiddenPanes.has(id)) && !(narrow && paneChrome(paneFor(id)).collapsible) + Boolean(paneFor(id)) && + contributesToWorkspace(paneFor(id), workspaceMode, workspaceOwnerKey) && + (editMode || !hiddenPanes.has(id)) && + !(narrow && paneChrome(paneFor(id)).collapsible) const shown = node.panes.filter(paneShown) - const activeId = shown.includes(node.active) ? node.active : (shown[0] ?? node.active) + const memoryKey = workspaceScopeKey(workspaceMode, workspaceOwnerKey) + + const activeId = shown.includes(node.active) + ? node.active + : (resolveRememberedActivePane(memoryKey, shown) ?? shown[0] ?? '') + const active = paneFor(activeId) - const isEmpty = node.panes.length === 0 + const isEmpty = shown.length === 0 + + useEffect(() => { + if (activeId) { + rememberActivePane(memoryKey, activeId) + } + }, [activeId, memoryKey]) // BOUNDED KEEP-ALIVE: the active pane is visible, a small per-zone LRU stays // hot-hidden, and older panes park (unmount). This preserves fast tab diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/tree-split.tsx b/apps/desktop/src/components/pane-shell/tree/renderer/tree-split.tsx index e0a3772b8569..1e97a661f3d2 100644 --- a/apps/desktop/src/components/pane-shell/tree/renderer/tree-split.tsx +++ b/apps/desktop/src/components/pane-shell/tree/renderer/tree-split.tsx @@ -17,6 +17,7 @@ import { cn } from '@/lib/utils' import { $paneStates, type PaneStateSnapshot, setPaneHeightOverride, setPaneWidthOverride } from '@/store/panes' import { $layoutEditMode } from '../../edit-mode' +import { $workspaceMode, $workspaceOwnerKey, contributesToWorkspace } from '../../workspace-scope' import type { LayoutNode, SplitNode } from '../model' import { allPaneIds } from '../model' import { @@ -90,6 +91,8 @@ export function TreeSplit({ node, root, rootRow }: { node: SplitNode; root?: boo const panes = useContributions('panes') const hiddenPanes = useStore($hiddenTreePanes) const narrow = useStore($narrowViewport) + const workspaceMode = useStore($workspaceMode) + const workspaceOwnerKey = useStore($workspaceOwnerKey) // Scoped to THIS subtree's panes: a sash drag writes size overrides on every // pointermove, but only the splits whose subtree actually resized should // re-render — not every split in the tree. @@ -124,7 +127,10 @@ export function TreeSplit({ node, root, rootRow }: { node: SplitNode; root?: boo // closed) visible so they're rearrangeable — only truly-absent (unregistered) // or narrow-collapsed panes stay gone. Restores itself on exit (render-only). const paneGone = (id: string) => - !paneFor(id) || (!editMode && hiddenPanes.has(id)) || (narrow && Boolean(paneChrome(paneFor(id)).collapsible)) + !paneFor(id) || + !contributesToWorkspace(paneFor(id), workspaceMode, workspaceOwnerKey) || + (!editMode && hiddenPanes.has(id)) || + (narrow && Boolean(paneChrome(paneFor(id)).collapsible)) const trackCtx: TrackContext = { paneFor, paneGone, overrides } diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/workspace-scope.test.tsx b/apps/desktop/src/components/pane-shell/tree/renderer/workspace-scope.test.tsx new file mode 100644 index 000000000000..310d85943b69 --- /dev/null +++ b/apps/desktop/src/components/pane-shell/tree/renderer/workspace-scope.test.tsx @@ -0,0 +1,96 @@ +import { act, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { setWorkspaceScope } from '@/components/pane-shell/workspace-scope' +import { registry } from '@/contrib/registry' + +import type { GroupNode } from '../model' + +import { TreeGroup } from './tree-group' + +let root: null | Root = null +let container: HTMLDivElement | null = null +const disposers: Array<() => void> = [] + +function render(ui: ReactNode) { + if (!container) { + container = globalThis.document.createElement('div') + globalThis.document.body.append(container) + root = createRoot(container) + } + + act(() => root!.render(ui)) +} + +function register(id: string, title: string, scope: { workspaceMode?: 'sessions' | 'bots'; workspaceOwnerKey?: string } = {}) { + disposers.push( + registry.register({ + area: 'panes', + data: { placement: 'main' }, + id, + render: () =>
{title} content
, + title, + ...scope + }) + ) +} + +const group = (active: string): GroupNode => ({ + active, + id: 'workspace-scope-zone', + panes: ['session-a', 'bot-a', 'bot-b'], + tabStrip: 'always', + type: 'group' +}) + +const visibleTabs = () => + [...globalThis.document.querySelectorAll('[data-tree-tab]')].map(tab => tab.dataset.treeTab) + +afterEach(() => { + if (root) { + act(() => root!.unmount()) + } + + container?.remove() + disposers.splice(0).forEach(dispose => dispose()) + act(() => { + setWorkspaceScope('sessions') + }) + root = null + container = null + vi.unstubAllGlobals() +}) + +describe('TreeGroup workspace scope', () => { + it('renders only the current workspace owner and restores owner activity', () => { + vi.stubGlobal('CSS', { escape: (value: string) => value }) + register('session-a', 'Session A', { workspaceMode: 'sessions' }) + register('bot-a', 'Bot A', { workspaceMode: 'bots', workspaceOwnerKey: 'connection-a::default' }) + register('bot-b', 'Bot B', { workspaceMode: 'bots', workspaceOwnerKey: 'connection-b::default' }) + + render() + expect(visibleTabs()).toEqual(['session-a']) + expect(container?.textContent).toContain('Session A content') + + act(() => setWorkspaceScope('bots', 'connection-a::default')) + render() + expect(visibleTabs()).toEqual(['bot-a']) + expect(container?.textContent).toContain('Bot A content') + + act(() => setWorkspaceScope('bots', 'connection-b::default')) + render() + expect(visibleTabs()).toEqual(['bot-b']) + expect(container?.textContent).toContain('Bot B content') + + act(() => setWorkspaceScope('bots', 'connection-a::default')) + render() + expect(visibleTabs()).toEqual(['bot-a']) + expect(container?.textContent).toContain('Bot A content') + + act(() => setWorkspaceScope('sessions')) + render() + expect(visibleTabs()).toEqual(['session-a']) + expect(container?.textContent).toContain('Session A content') + }) +}) diff --git a/apps/desktop/src/components/pane-shell/workspace-scope.test.ts b/apps/desktop/src/components/pane-shell/workspace-scope.test.ts new file mode 100644 index 000000000000..7aae790a3723 --- /dev/null +++ b/apps/desktop/src/components/pane-shell/workspace-scope.test.ts @@ -0,0 +1,169 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { + $workspaceMode, + $workspaceOwnerKey, + contributesToWorkspace, + filterContributionsForWorkspace, + forgetActivePane, + forgetRememberedPane, + rememberActivePane, + resetRememberedActivePanes, + resolveRememberedActivePane, + setWorkspaceScope +} from './workspace-scope' + +interface ScopedContribution { + id: string + workspaceMode?: 'sessions' | 'bots' + workspaceOwnerKey?: string +} + +const contribution = ( + id: string, + scope?: Pick +): ScopedContribution => ({ id, ...scope }) + +const bot = (ownerKey: string, suffix: string) => + contribution(`bot:${suffix}`, { + workspaceMode: 'bots', + workspaceOwnerKey: ownerKey + }) + +afterEach(() => { + setWorkspaceScope('sessions') +}) + +describe('workspace scope', () => { + it('defaults to the un-switched sessions window state', () => { + expect($workspaceMode.get()).toBe('sessions') + expect($workspaceOwnerKey.get()).toBeNull() + }) + + it('publishes a coherent mode and owner in one batch', () => { + const snapshots: Array<['sessions' | 'bots', string | null]> = [] + const capture = () => snapshots.push([$workspaceMode.get(), $workspaceOwnerKey.get()]) + const unbindMode = $workspaceMode.listen(capture) + const unbindOwner = $workspaceOwnerKey.listen(capture) + snapshots.length = 0 + + expect(setWorkspaceScope('bots', 'connection-a::default')).toBe(true) + expect(snapshots.length).toBeGreaterThan(0) + expect(snapshots.every(snapshot => snapshot[0] === 'bots' && snapshot[1] === 'connection-a::default')).toBe(true) + expect(setWorkspaceScope('bots', 'connection-a::default')).toBe(false) + + unbindMode() + unbindOwner() + }) + + it('keeps global contributions visible in both modes', () => { + expect(contributesToWorkspace(undefined, 'sessions', null)).toBe(true) + expect(contributesToWorkspace(undefined, 'bots', 'bot-a')).toBe(true) + }) + + it('separates sessions and bots contributions by mode', () => { + const sessionsOnly = contribution('sessions-pane', { workspaceMode: 'sessions' }) + const botsOnly = bot('bot-a', 'pane') + + expect(contributesToWorkspace(sessionsOnly, 'sessions')).toBe(true) + expect(contributesToWorkspace(sessionsOnly, 'bots', 'bot-a')).toBe(false) + expect(contributesToWorkspace(botsOnly, 'sessions')).toBe(false) + expect(contributesToWorkspace(botsOnly, 'bots', 'bot-a')).toBe(true) + }) + + it('requires an exact non-empty owner match for bots contributions', () => { + const scoped = bot('bot-a', 'pane') + + expect(contributesToWorkspace(scoped, 'bots', 'bot-b')).toBe(false) + expect(contributesToWorkspace(scoped, 'bots', null)).toBe(false) + expect(contributesToWorkspace(scoped, 'bots', '')).toBe(false) + expect(contributesToWorkspace(scoped, 'bots', 'bot-a')).toBe(true) + + // A bots-scoped contribution with an empty owner key never participates. + const noOwner = contribution('no-owner', { workspaceMode: 'bots' }) + expect(contributesToWorkspace(noOwner, 'bots', 'bot-a')).toBe(false) + }) + + it('does not collide on shared profile suffixes across connection-qualified keys', () => { + // Same profile suffix, different connections — opaque exact strings only. + const localProfile = bot('local:main', 'main') + const remoteProfile = bot('ssh:server:main', 'main') + + expect(contributesToWorkspace(localProfile, 'bots', 'ssh:server:main')).toBe(false) + expect(contributesToWorkspace(remoteProfile, 'bots', 'local:main')).toBe(false) + expect(contributesToWorkspace(localProfile, 'bots', 'local:main')).toBe(true) + expect(contributesToWorkspace(remoteProfile, 'bots', 'ssh:server:main')).toBe(true) + }) +}) + +describe('filterContributionsForWorkspace', () => { + it('filters to the current mode and preserves input order', () => { + const contributions = [ + bot('bot-a', 'zeta'), + contribution('global-1'), + contribution('sessions-only', { workspaceMode: 'sessions' }), + bot('bot-a', 'alpha') + ] + + expect(filterContributionsForWorkspace(contributions, 'bots', 'bot-a').map(c => c.id)).toEqual([ + 'bot:zeta', + 'global-1', + 'bot:alpha' + ]) + expect( + filterContributionsForWorkspace(contributions, 'sessions', null).map(c => c.id) + ).toEqual(['global-1', 'sessions-only']) + }) + + it('returns the original array reference on a no-op', () => { + const contributions: ScopedContribution[] = [contribution('a'), contribution('b')] + + expect(filterContributionsForWorkspace(contributions, 'sessions', null)).toBe(contributions) + expect(filterContributionsForWorkspace(contributions, 'bots', 'anything').length).toBe(2) + }) +}) + +describe('remembered active panes', () => { + beforeEach(() => resetRememberedActivePanes()) + + it('remembers and restores panes independently per owner key', () => { + rememberActivePane('conn-a:profile-x', 'pane-1') + rememberActivePane('conn-b:profile-y', 'pane-2') + + expect(resolveRememberedActivePane('conn-a:profile-x', ['pane-1', 'pane-2'])).toBe('pane-1') + expect(resolveRememberedActivePane('conn-b:profile-y', ['pane-1', 'pane-2'])).toBe('pane-2') + }) + + it('does not collide on a shared profile suffix across owner keys', () => { + rememberActivePane('local:main', 'pane-local') + + expect(resolveRememberedActivePane('ssh:server:main', [])).toBeNull() + }) + + it('falls back after the remembered pane is removed', () => { + rememberActivePane('bot-a', 'pane-gone') + + expect(resolveRememberedActivePane('bot-a', ['first', 'second'])).toBe('first') + expect(resolveRememberedActivePane('bot-a', [])).toBeNull() + }) + + it('forgets a single owner without touching others', () => { + rememberActivePane('bot-a', 'pane-a') + rememberActivePane('bot-b', 'pane-b') + + forgetActivePane('bot-a') + + expect(resolveRememberedActivePane('bot-a', ['fallback-a', 'pane-a'])).toBe('fallback-a') + expect(resolveRememberedActivePane('bot-b', ['pane-a', 'pane-b'])).toBe('pane-b') + }) + + it('forgets a removed pane across every owner that remembered it', () => { + rememberActivePane('bot-a', 'pane-gone') + rememberActivePane('bot-b', 'pane-gone') + + forgetRememberedPane('pane-gone') + + expect(resolveRememberedActivePane('bot-a', ['fallback-a'])).toBe('fallback-a') + expect(resolveRememberedActivePane('bot-b', ['fallback-b'])).toBe('fallback-b') + }) +}) diff --git a/apps/desktop/src/components/pane-shell/workspace-scope.ts b/apps/desktop/src/components/pane-shell/workspace-scope.ts new file mode 100644 index 000000000000..3980fc3f1557 --- /dev/null +++ b/apps/desktop/src/components/pane-shell/workspace-scope.ts @@ -0,0 +1,167 @@ +/** + * Workspace scoping for contributions. + * + * Pure presentation-ownership helpers: which workspace surface (sessions vs + * bots) a contribution belongs to, and — within the bots surface — which exact + * bot it belongs to. Owner keys are opaque exact strings supplied by callers; + * this module never parses profile names or infers connections. + * + * No persistence here by design: the remembered active-pane map is window-local + * memory so a switch away and back can restore where the user was, without any + * of it surviving the window. + */ + +import { atom, batch } from 'nanostores' + +import type { WorkspaceMode } from '../../contrib/types' + +/** Re-exported so workspace consumers can import it from here. */ +export type { WorkspaceMode } from '../../contrib/types' + +/** Default workspace mode when the host has not switched surfaces. */ +export const $workspaceMode = atom('sessions') + +/** Default workspace owner key: none (unscoped / global ownership). */ +export const $workspaceOwnerKey = atom(null) + +/** One key for window-local active-pane memory. Owner keys stay opaque. */ +export function workspaceScopeKey(mode: WorkspaceMode, ownerKey: string | null): string { + return mode === 'sessions' ? 'sessions' : `bots:${ownerKey ?? ''}` +} + +/** Publish one coherent presentation scope without an intermediate mixed frame. */ +export function setWorkspaceScope(mode: WorkspaceMode, ownerKey: string | null = null): boolean { + const nextOwnerKey = mode === 'bots' ? ownerKey : null + + if ($workspaceMode.get() === mode && $workspaceOwnerKey.get() === nextOwnerKey) { + return false + } + + batch(() => { + $workspaceMode.set(mode) + $workspaceOwnerKey.set(nextOwnerKey) + }) + + return true +} + +/** + * The slice of {@link Contribution} metadata that scopes it to a workspace. + * A contribution with neither field set is global: it participates in every + * workspace, preserving pre-existing behavior. + */ +export interface WorkspaceScope { + /** Surface this contribution belongs to. Omit for global visibility. */ + workspaceMode?: WorkspaceMode + /** Exact opaque owner key within the `'bots'` surface. Ignored otherwise. */ + workspaceOwnerKey?: string +} + +/** + * Whether a contribution participates in the given workspace. + * + * - Unscoped/global (no `workspaceMode`) => always participates. + * - Scoped with a mode mismatch => does not participate. + * - Sessions match => participates. + * - Bots match => participates only when the owner key is non-empty and equals + * the current owner key (exact string equality). + * + * Defaults reflect the un-switched window state when omitted. + */ +export function contributesToWorkspace( + scope: WorkspaceScope | undefined, + mode: WorkspaceMode = $workspaceMode.get(), + ownerKey: string | null = $workspaceOwnerKey.get() +): boolean { + const { workspaceMode, workspaceOwnerKey } = scope ?? {} + + if (workspaceMode == null) { + return true + } + + if (workspaceMode !== mode) { + return false + } + + if (workspaceMode === 'sessions') { + return true + } + + return Boolean(workspaceOwnerKey) && workspaceOwnerKey === ownerKey +} + +/** + * Filter contributions down to those participating in the given workspace, + * preserving input order. + * + * Preserves reference identity on a no-op (every contribution participates), + * so callers can hand the result straight to React without a wasted re-render. + */ +export function filterContributionsForWorkspace( + contributions: readonly T[], + mode: WorkspaceMode, + ownerKey: string | null +): readonly T[] { + let filtered: T[] | null = null + + for (let i = 0; i < contributions.length; i += 1) { + if (contributesToWorkspace(contributions[i], mode, ownerKey)) { + filtered?.push(contributions[i]) + + continue + } + + filtered ??= contributions.slice(0, i) + } + + return filtered ?? contributions +} + +/** + * Window-local memory of the active pane per exact workspace owner key. + * Keys are opaque exact strings; similar-looking keys never collide because + * nothing here parses them. + */ +const rememberedActivePanes = new Map() + +/** Remember which pane was active for an exact owner key. */ +export function rememberActivePane(ownerKey: string, paneId: string): void { + rememberedActivePanes.set(ownerKey, paneId) +} + +/** + * Resolve the pane to activate for an owner key against the currently eligible + * panes. A remembered pane that has since been removed must not restore: the + * fallback is the first eligible pane, or null when none are eligible. + */ +export function resolveRememberedActivePane( + ownerKey: string, + eligiblePaneIds: readonly string[] +): string | null { + const remembered = rememberedActivePanes.get(ownerKey) + + if (remembered != null && eligiblePaneIds.includes(remembered)) { + return remembered + } + + return eligiblePaneIds[0] ?? null +} + +/** Forget the remembered pane for one owner key. */ +export function forgetActivePane(ownerKey: string): void { + rememberedActivePanes.delete(ownerKey) +} + +/** Forget a pane removed from the layout, regardless of which owners used it. */ +export function forgetRememberedPane(paneId: string): void { + for (const [ownerKey, rememberedPaneId] of rememberedActivePanes) { + if (rememberedPaneId === paneId) { + rememberedActivePanes.delete(ownerKey) + } + } +} + +/** Test-only: clear all remembered panes. */ +export function resetRememberedActivePanes(): void { + rememberedActivePanes.clear() +} diff --git a/apps/desktop/src/contrib/index.ts b/apps/desktop/src/contrib/index.ts index a5faa2f232b5..f39d9879125a 100644 --- a/apps/desktop/src/contrib/index.ts +++ b/apps/desktop/src/contrib/index.ts @@ -3,4 +3,4 @@ export type { SlotProps } from './react/slot' export { useContributions } from './react/use-contributions' export { registry } from './registry' -export type { Contribution, ContributionSource } from './types' +export type { Contribution, ContributionSource, WorkspaceMode } from './types' diff --git a/apps/desktop/src/contrib/types.ts b/apps/desktop/src/contrib/types.ts index c46ee3e9929e..0958073bfabc 100644 --- a/apps/desktop/src/contrib/types.ts +++ b/apps/desktop/src/contrib/types.ts @@ -9,6 +9,13 @@ import type { ReactNode } from 'react' */ export type ContributionSource = 'core' | (string & {}) +/** + * Which workspace surface a contribution targets. `'sessions'` is the classic + * session workspace; `'bots'` scopes content to an individual bot identified + * by an opaque owner key. + */ +export type WorkspaceMode = 'sessions' | 'bots' + /** * The single, uniform primitive every surface consumes. A bar renders these as * inline items via ``; a dock renders them as stacked/tabbed panes via @@ -40,4 +47,16 @@ export interface Contribution { * themes, commands — anything consumed by an engine rather than rendered. */ data?: unknown + /** + * Which workspace surface this contribution belongs to. Omit for a global + * contribution that participates in every workspace (pre-existing behavior). + * Presentation ownership hint only. + */ + workspaceMode?: WorkspaceMode + /** + * Exact opaque owner key within the `'bots'` surface (e.g. a + * connection-qualified profile id). Never parsed here; compared exactly. + * Presentation ownership hint only. + */ + workspaceOwnerKey?: string } diff --git a/apps/desktop/src/sdk/index.test.ts b/apps/desktop/src/sdk/index.test.ts index f7f879d4e462..0817188888f2 100644 --- a/apps/desktop/src/sdk/index.test.ts +++ b/apps/desktop/src/sdk/index.test.ts @@ -173,3 +173,45 @@ describe('host.connections', () => { await expect(host.connections()).rejects.toThrow('This Desktop build has no connection registry') }) }) + +describe('host workspace scope', () => { + afterEach(async () => { + host.setWorkspaceScope('sessions') + const tree = await import('@/components/pane-shell/tree/store') + tree.removeTreePane('plugin-workspace:scope-test') + }) + + it('registers plugin workspace ownership and chrome options', async () => { + const { registry } = await import('@/contrib/registry') + + const close = host.openWorkspace('scope-test', { + dock: { pane: 'workspace', pos: 'right' }, + headerVeto: true, + render: () => null, + title: 'Scoped', + uncloseable: true, + workspaceMode: 'bots', + workspaceOwnerKey: 'connection-a::default' + }) + + expect(registry.getArea('panes').find(pane => pane.id === 'plugin-workspace:scope-test')).toMatchObject({ + data: { + dock: { pane: 'workspace', pos: 'right' }, + headerVeto: true, + uncloseable: true + }, + workspaceMode: 'bots', + workspaceOwnerKey: 'connection-a::default' + }) + + close() + }) + + it('publishes the active workspace scope through one host seam', async () => { + const { $workspaceMode, $workspaceOwnerKey } = await import('@/components/pane-shell/workspace-scope') + + expect(host.setWorkspaceScope('bots', 'connection-b::default')).toBe(true) + expect($workspaceMode.get()).toBe('bots') + expect($workspaceOwnerKey.get()).toBe('connection-b::default') + }) +}) diff --git a/apps/desktop/src/sdk/index.ts b/apps/desktop/src/sdk/index.ts index c7e4328bd49e..210448cef924 100644 --- a/apps/desktop/src/sdk/index.ts +++ b/apps/desktop/src/sdk/index.ts @@ -31,8 +31,10 @@ import { removeTreePane, revealTreePane } from '@/components/pane-shell/tree/store' +import { setWorkspaceScope as publishWorkspaceScope } from '@/components/pane-shell/workspace-scope' import { onGatewayEvent } from '@/contrib/events' import { registry } from '@/contrib/registry' +import type { WorkspaceMode } from '@/contrib/types' import { deleteProfile, getLogs, getStatus, type HermesGateway } from '@/hermes' import { $gateway, @@ -285,6 +287,8 @@ export interface PluginOpenSessionOptions { keepAllProfilesScope?: boolean profile?: null | string route?: PluginProfileRoute + workspaceMode?: WorkspaceMode + workspaceOwnerKey?: string /** A cold profile backend can lose the hydration-timeout race once and still * be fine on a second try. When set, a hydration timeout is retried * internally before it reaches the caller or arms the core stranded-session @@ -730,19 +734,26 @@ export const host = { // again inside the same wake — that is the Retry surface's job. for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { - openSession( - storedSessionId, - (to: string, opts?: { replace?: boolean }) => { - const target = to.startsWith('#') ? to : `#${to}` - - if (opts?.replace) { - window.location.replace(target) - } else { - window.location.hash = target - } - }, - options.intent ?? 'in-place' - ) + const navigate = (to: string, opts?: { replace?: boolean }) => { + const target = to.startsWith('#') ? to : `#${to}` + + if (opts?.replace) { + window.location.replace(target) + } else { + window.location.hash = target + } + } + + const intent = options.intent ?? 'in-place' + + if (options.workspaceMode === 'bots') { + openSession(storedSessionId, navigate, intent, { + workspaceMode: 'bots', + workspaceOwnerKey: options.workspaceOwnerKey + }) + } else { + openSession(storedSessionId, navigate, intent) + } // Judge the main surface AFTER the open: on a cold start the persisted // route can already point at this session while selection has not @@ -846,7 +857,17 @@ export const host = { * fallback. */ openWorkspace: ( id: string, - options: { minWidth?: string; onClose?: () => void; render: () => ReactNode; title?: string } + options: { + dock?: { before?: null | string; pane: string; pos: 'bottom' | 'center' | 'left' | 'right' | 'top' } + headerVeto?: boolean + minWidth?: string + onClose?: () => void + render: () => ReactNode + title?: string + uncloseable?: boolean + workspaceMode?: WorkspaceMode + workspaceOwnerKey?: string + } ): (() => void) => { const key = (id ?? '').trim() @@ -861,13 +882,17 @@ export const host = { data: { // The session-tile shape: a full workspace surface docked beside main, // closeable so it keeps its tab when it lands in a zone of its own. - dock: { pane: 'workspace', pos: 'center' }, + dock: options.dock ?? { pane: 'workspace', pos: 'center' }, + headerVeto: options.headerVeto, minWidth: options.minWidth ?? '22rem', - placement: 'main' + placement: 'main', + uncloseable: options.uncloseable }, id: paneId, render: options.render, - title: options.title ?? key + title: options.title ?? key, + workspaceMode: options.workspaceMode, + workspaceOwnerKey: options.workspaceOwnerKey }) const close = () => { @@ -886,6 +911,10 @@ export const host = { return close }, + /** Switch the visible main-pane workspace without unregistering retained panes. */ + setWorkspaceScope: (mode: WorkspaceMode, ownerKey: null | string = null): boolean => + publishWorkspaceScope(mode, ownerKey), + /** Start a fresh chat draft, optionally pointed at another profile (its * backend spins up in the background — same door the sidebar's per-profile * "+" uses). */ diff --git a/apps/desktop/src/sdk/profile-routing.test.ts b/apps/desktop/src/sdk/profile-routing.test.ts index 6ab9fa07fc3c..fd6f14e6ecf7 100644 --- a/apps/desktop/src/sdk/profile-routing.test.ts +++ b/apps/desktop/src/sdk/profile-routing.test.ts @@ -413,6 +413,26 @@ describe('profile-aware plugin session opens', () => { expect(openSessionCore).toHaveBeenCalledWith('remote-chat', expect.any(Function), 'in-place') }) + it('threads an exact Bot workspace owner into the core session open', async () => { + const route = { + connectionId: 'source-a', + mode: 'remote' as const, + profile: 'default', + targetProfile: 'backend-default' + } + + await host.openSession('bot-chat', { + route, + workspaceMode: 'bots', + workspaceOwnerKey: 'source-a::default' + }) + + expect(openSessionCore).toHaveBeenCalledWith('bot-chat', expect.any(Function), 'in-place', { + workspaceMode: 'bots', + workspaceOwnerKey: 'source-a::default' + }) + }) + it('waits until the target Bot Chat runtime and history are on main before resolving', async () => { vi.mocked(openGatewayForProfile).mockImplementationOnce(async () => undefined) diff --git a/apps/desktop/src/store/session-states.test.ts b/apps/desktop/src/store/session-states.test.ts index b3173aacd01e..3cf175250746 100644 --- a/apps/desktop/src/store/session-states.test.ts +++ b/apps/desktop/src/store/session-states.test.ts @@ -12,12 +12,15 @@ import { focusedSessionNeedsRoute, markSelectionRestore, nextSessionTileForWorkspace, + openSessionTile, orderTilesByTree, + patchSessionTile, releaseSessionTranscript, resetTileRuntimeBindings, selectionHomesToWorkspace, type SessionTileDelegate, - setSessionTileDelegate + setSessionTileDelegate, + setSessionTileWorkspaceScope } from '@/store/session-states' const tile = (storedSessionId: string): SessionTile => ({ storedSessionId }) @@ -53,6 +56,69 @@ describe('resetTileRuntimeBindings', () => { }) }) +describe('SessionTile workspace scope', () => { + afterEach(() => { + $layoutTree.set(null) + $selectedStoredSessionId.set(null) + $sessionTiles.set([]) + }) + + it('stores an exact Bot owner and keeps it through placement patches', () => { + const scope = { workspaceMode: 'bots' as const, workspaceOwnerKey: 'connection-a::default' } + + openSessionTile('bot-chat', 'right', undefined, undefined, scope) + patchSessionTile('bot-chat', { dir: 'left' }) + + expect($sessionTiles.get()).toEqual([ + expect.objectContaining({ + dir: 'left', + storedSessionId: 'bot-chat', + workspaceMode: 'bots', + workspaceOwnerKey: 'connection-a::default' + }) + ]) + }) + + it('re-scopes an existing tile without changing its placement', () => { + openSessionTile('chat', 'bottom', 'workspace') + + expect( + setSessionTileWorkspaceScope('chat', { + workspaceMode: 'bots', + workspaceOwnerKey: 'connection-b::default' + }) + ).toBe(true) + expect($sessionTiles.get()[0]).toMatchObject({ + anchor: 'workspace', + dir: 'bottom', + workspaceMode: 'bots', + workspaceOwnerKey: 'connection-b::default' + }) + }) + + it('preserves workspace scope while dropping a stale runtime binding', () => { + $sessionTiles.set([ + { + runtimeId: 'runtime-dead', + storedSessionId: 'bot-chat', + workspaceMode: 'bots', + workspaceOwnerKey: 'connection-a::default' + } + ]) + + resetTileRuntimeBindings() + + expect($sessionTiles.get()[0]).toEqual({ + anchor: undefined, + before: undefined, + dir: undefined, + storedSessionId: 'bot-chat', + workspaceMode: 'bots', + workspaceOwnerKey: 'connection-a::default' + }) + }) +}) + describe('releaseSessionTranscript', () => { afterEach(() => { $sessionStates.set({}) diff --git a/apps/desktop/src/store/session-states.ts b/apps/desktop/src/store/session-states.ts index b570a5056e74..e2570c1bd2f9 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 type { WorkspaceMode } from '@/contrib/types' import { stableArray } from '@/lib/stable-array' import { readJson, writeJson } from '@/lib/storage' import type { SessionInfo } from '@/types/hermes' @@ -535,6 +536,15 @@ export interface SessionTile { runtimeId?: string /** Resume failed terminally (shown in the tile; retryable). */ error?: string + /** Presentation workspace this tab belongs to. Missing legacy values are Sessions. */ + workspaceMode?: WorkspaceMode + /** Exact opaque owner key for Bot Mode tabs. */ + workspaceOwnerKey?: string +} + +export interface SessionTileWorkspaceScope { + workspaceMode: WorkspaceMode + workspaceOwnerKey?: string } // Tiles are persisted PER PROFILE: a session belongs to one profile, and the @@ -550,13 +560,18 @@ const TILE_PANE_PREFIX = 'session-tile:' /** Persisted placement — `dir` + strip slot (`before`) + dock `anchor` so a * restart / profile swap re-adopts tiles in the same order, not all stacked * right of workspace. */ -type StoredTile = Pick +type StoredTile = Pick< + SessionTile, + 'anchor' | 'before' | 'dir' | 'storedSessionId' | 'workspaceMode' | 'workspaceOwnerKey' +> const toStored = (t: SessionTile): StoredTile => ({ anchor: t.anchor, before: t.before, dir: t.dir, - storedSessionId: t.storedSessionId + storedSessionId: t.storedSessionId, + ...(t.workspaceMode ? { workspaceMode: t.workspaceMode } : {}), + ...(t.workspaceOwnerKey ? { workspaceOwnerKey: t.workspaceOwnerKey } : {}) }) function parseTileList(value: unknown): StoredTile[] { @@ -570,7 +585,12 @@ function parseTileList(value: unknown): StoredTile[] { anchor: typeof raw.anchor === 'string' ? raw.anchor : undefined, before: typeof raw.before === 'string' || raw.before === null ? raw.before : undefined, dir: raw.dir, - storedSessionId: raw.storedSessionId + storedSessionId: raw.storedSessionId, + workspaceMode: raw.workspaceMode === 'bots' ? 'bots' : 'sessions', + workspaceOwnerKey: + raw.workspaceMode === 'bots' && typeof raw.workspaceOwnerKey === 'string' + ? raw.workspaceOwnerKey + : undefined } }) : [] @@ -653,6 +673,25 @@ export function patchSessionTile(storedSessionId: string, patch: Partial (t.storedSessionId === storedSessionId ? { ...t, ...patch } : t))) } +export function setSessionTileWorkspaceScope( + storedSessionId: string, + scope: SessionTileWorkspaceScope +): boolean { + const tile = $sessionTiles.get().find(candidate => candidate.storedSessionId === storedSessionId) + const workspaceOwnerKey = scope.workspaceMode === 'bots' ? scope.workspaceOwnerKey : undefined + + if ( + !tile || + ((tile.workspaceMode ?? 'sessions') === scope.workspaceMode && tile.workspaceOwnerKey === workspaceOwnerKey) + ) { + return false + } + + patchSessionTile(storedSessionId, { workspaceMode: scope.workspaceMode, workspaceOwnerKey }) + + return true +} + /** Drop live runtime bindings so every tile re-resumes — used on gateway * reconnect, where a respawned backend re-mints (recycles) runtime ids. * Also invalidates the wiring cache's stored→runtime map: clearing only the @@ -795,7 +834,8 @@ export function openSessionTile( storedSessionId: string, dir: TileDock = 'right', anchor?: string, - before?: null | string + before?: null | string, + workspaceScope: SessionTileWorkspaceScope = { workspaceMode: 'sessions' } ) { const tiles = $sessionTiles.get() @@ -813,14 +853,29 @@ export function openSessionTile( const dock = anchor ?? focusedSessionTabAnchor() ?? undefined + const workspaceOwnerKey = + workspaceScope.workspaceMode === 'bots' ? workspaceScope.workspaceOwnerKey : undefined + if (!tiles.some(t => t.storedSessionId === storedSessionId)) { - saveTiles([...tiles, { anchor: dock, before, dir, storedSessionId }]) + saveTiles([ + ...tiles, + { + anchor: dock, + before, + dir, + storedSessionId, + workspaceMode: workspaceScope.workspaceMode, + workspaceOwnerKey + } + ]) // Adoption is async via the registry — order sync runs after the move path // below; a brand-new tile's strip slot is already in `before`. return } + setSessionTileWorkspaceScope(storedSessionId, workspaceScope) + // Already open: relocate the existing pane to the drop target (pane-mirror // only docks on first adoption, so a re-drag must move the tree pane itself). const tree = $layoutTree.get() @@ -940,7 +995,10 @@ export function blankDraftTile( * False when there's no such tab, so the caller can fall back. The spent draft * is DISCARDED rather than closed: it never held a conversation, so ⌘⇧T * resurrecting it would just restore an empty tab. */ -export function reuseBlankDraftTile(storedSessionId: string): boolean { +export function reuseBlankDraftTile( + storedSessionId: string, + workspaceScope: SessionTileWorkspaceScope = { workspaceMode: 'sessions' } +): boolean { const tile = blankDraftTile($sessionTiles.get(), $sessionStates.get()) if (!tile || tile.storedSessionId === storedSessionId) { @@ -948,7 +1006,7 @@ export function reuseBlankDraftTile(storedSessionId: string): boolean { } discardSessionTile(tile.storedSessionId) - openSessionTile(storedSessionId, tile.dir, tile.anchor, tile.before) + openSessionTile(storedSessionId, tile.dir, tile.anchor, tile.before, workspaceScope) revealTreePane(`${TILE_PANE_PREFIX}${storedSessionId}`) return true @@ -964,7 +1022,7 @@ export function closeSessionTile(storedSessionId: string) { const tile = $sessionTiles.get().find(t => t.storedSessionId === storedSessionId) if (tile) { - closedStack().push({ anchor: tile.anchor, before: tile.before, dir: tile.dir, storedSessionId }) + closedStack().push(toStored(tile)) } saveTiles($sessionTiles.get().filter(t => t.storedSessionId !== storedSessionId)) @@ -1011,7 +1069,10 @@ export function reopenLastClosedTile(): void { } if (!$sessionTiles.get().some(t => t.storedSessionId === storedSessionId)) { - openSessionTile(storedSessionId, tile.dir, tile.anchor, tile.before) + openSessionTile(storedSessionId, tile.dir, tile.anchor, tile.before, { + workspaceMode: tile.workspaceMode ?? 'sessions', + workspaceOwnerKey: tile.workspaceOwnerKey + }) focusOpenSession(storedSessionId) return From b0dde234959f69f4284fb301baf75a872bd0b035 Mon Sep 17 00:00:00 2001 From: David Dudok de Wit <5354424+dokterdok@users.noreply.github.com> Date: Sun, 23 Aug 2026 01:01:51 +0200 Subject: [PATCH 03/10] feat(desktop): make new Bot tabs owner-aware --- apps/desktop/src/app/contrib/wiring.tsx | 35 +++++++ apps/desktop/src/app/hooks/use-keybinds.ts | 2 + .../hooks/use-session-actions/index.ts | 36 +++++++- .../pane-shell/workspace-scope.test.ts | 31 +++++++ .../components/pane-shell/workspace-scope.ts | 61 +++++++++++- .../desktop/src/plugins/hermes-bots/plugin.js | 92 +++++++++++-------- .../tests/remote-routing-races.test.mjs | 14 ++- apps/desktop/src/sdk/index.test.ts | 20 ++++ apps/desktop/src/sdk/index.ts | 47 +++++++++- 9 files changed, 286 insertions(+), 52 deletions(-) diff --git a/apps/desktop/src/app/contrib/wiring.tsx b/apps/desktop/src/app/contrib/wiring.tsx index 67eb63c8223e..7a6e557f7ce8 100644 --- a/apps/desktop/src/app/contrib/wiring.tsx +++ b/apps/desktop/src/app/contrib/wiring.tsx @@ -23,6 +23,12 @@ import { GatewayConnectingOverlay } from '@/components/gateway-connecting-overla import { NotificationStack } from '@/components/notifications' import { DesktopOnboardingOverlay } from '@/components/onboarding' import { $newSessionTabAction, registerPaneCloser } from '@/components/pane-shell/tree/store' +import { + $workspaceMode, + $workspaceNewSessionTarget, + $workspaceOwnerKey, + setWorkspaceScope +} from '@/components/pane-shell/workspace-scope' import { FloatingPet } from '@/components/pet/floating-pet' import { RemoteDisplayBanner } from '@/components/remote-display-banner' import { SendDiagnosticsHost } from '@/components/send-diagnostics-dialog' @@ -39,6 +45,7 @@ import { requestVoiceConversationStart } from '@/store/composer' import { $activeConnectionId } from '@/store/connections' import { $cronReviewRequest, setCronFocusJobId } from '@/store/cron' import { $pinnedSessionIds, pinSession, restoreWorktree, unpinSession } from '@/store/layout' +import { notify } from '@/store/notifications' import { $previewTarget } from '@/store/preview' import { $activeGatewayProfile, @@ -545,6 +552,8 @@ export function ContribWiring({ children }: { children: ReactNode }) { // prefills the MAIN composer right after, so it has to own that surface. const startSessionInWorkspace = useCallback( (path: null | string, options?: { openTab?: boolean }) => { + setWorkspaceScope('sessions') + if (options?.openTab && mainChatOccupied(activeSessionIdRef.current, $selectedStoredSessionId.get())) { void openNewSessionTile('center', { cwd: path, listed: false }) @@ -861,6 +870,32 @@ export function ContribWiring({ children }: { children: ReactNode }) { // it — Cursor-style. Every click opens a fresh "New session" tab (multiple // empty tabs are fine since none touch the session list). const openNewSessionTab = useCallback(() => { + const workspaceMode = $workspaceMode.get() + const workspaceOwnerKey = $workspaceOwnerKey.get() + const workspaceNewSessionTarget = $workspaceNewSessionTarget.get() + + if (workspaceMode === 'bots') { + if (workspaceNewSessionTarget?.kind !== 'route' || !workspaceOwnerKey) { + notify({ + kind: 'info', + message: + workspaceNewSessionTarget?.kind === 'blocked' + ? workspaceNewSessionTarget.message + : 'Select a Bot or group first.' + }) + + return + } + + void openNewSessionTile('center', { + listed: false, + route: workspaceNewSessionTarget.route, + workspaceScope: { workspaceMode: 'bots', workspaceOwnerKey } + }) + + return + } + void openNewSessionTile('center', { listed: false }) }, [openNewSessionTile]) diff --git a/apps/desktop/src/app/hooks/use-keybinds.ts b/apps/desktop/src/app/hooks/use-keybinds.ts index b4a525acf484..e8292ad3d505 100644 --- a/apps/desktop/src/app/hooks/use-keybinds.ts +++ b/apps/desktop/src/app/hooks/use-keybinds.ts @@ -14,6 +14,7 @@ import { togglePaneVisible, toggleTargetZoneTabStrip } from '@/components/pane-shell/tree/store' +import { setWorkspaceScope } from '@/components/pane-shell/workspace-scope' import { onReleaseTypingFocus } from '@/components/ui/keyboard-first' import { findBarClaimsCombo } from '@/lib/find-in-page' import { contributedKeybindHandler, PROFILE_SLOT_COUNT, SESSION_SLOT_COUNT } from '@/lib/keybinds/actions' @@ -216,6 +217,7 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void { // Match the sidebar New Session button. A plain keyboard new chat should // target the current live profile, not a stale per-profile quick-create // selection from a prior action. + setWorkspaceScope('sessions') $newChatProfile.set(null) deps.startFreshSession() window.dispatchEvent(new CustomEvent('hermes:new-session-shortcut')) diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts index 75733e38ff43..287f3500bf5a 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts @@ -4,6 +4,7 @@ import type { NavigateFunction } from 'react-router' import { graftRefreshedTailOntoBackfill } from '@/app/chat/transcript-backfill' import { revealTreePane } from '@/components/pane-shell/tree/store' +import { setWorkspaceScope } from '@/components/pane-shell/workspace-scope' import { deleteSession, getAllSessionMessages, getLatestSessionMessages, setSessionArchived } from '@/hermes' import { useI18n } from '@/i18n' import { type ChatMessage, preserveLocalAssistantErrors, toChatMessages } from '@/lib/chat-messages' @@ -23,6 +24,7 @@ import { $newChatProfile, $newChatRoute, $showAllProfiles, + type AgentProfileRoute, ensureGatewayAgent, ensureGatewayProfile, normalizeProfileKey @@ -84,6 +86,7 @@ import { openSessionTile, patchSessionTile, publishSessionState, + type SessionTileWorkspaceScope, type TileDock } from '@/store/session-states' import { broadcastSessionsChanged } from '@/store/session-sync' @@ -571,6 +574,7 @@ export function useSessionActions({ const selectSidebarItem = useCallback( (item: SidebarNavItem) => { if (item.action === 'new-session') { + setWorkspaceScope('sessions') startFreshSessionDraft() return @@ -594,15 +598,28 @@ export function useSessionActions({ * list (Cursor-style draft tab); it surfaces on the next refresh once the * first message persists a turn. "Open in split" keeps the listed behavior. */ const openNewSessionTile = useCallback( - async (dir: TileDock = 'right', options?: { cwd?: null | string; listed?: boolean }) => { + async ( + dir: TileDock = 'right', + options?: { + cwd?: null | string + listed?: boolean + route?: AgentProfileRoute | null + workspaceScope?: SessionTileWorkspaceScope + } + ) => { const listed = options?.listed ?? true try { // Fresh tile → the caller's workspace when one was named (the sidebar // "+" on a project/worktree lane), else the resolved new-session cwd // (project scope → configured default). - const capturedRoute = $newChatRoute.get() - const params = await desktopSessionCreateParams((options?.cwd || resolveNewSessionCwd()).trim(), capturedRoute) + const capturedRoute = options?.route === undefined ? $newChatRoute.get() : options.route + const workspaceScope = options?.workspaceScope ?? { workspaceMode: 'sessions' } + + const params = { + ...(await desktopSessionCreateParams((options?.cwd || resolveNewSessionCwd()).trim(), capturedRoute)), + ...(workspaceScope.workspaceMode === 'bots' ? { hidden: true } : {}) + } const created = capturedRoute ? await requestGatewayForAgent( @@ -616,7 +633,16 @@ export function useSessionActions({ const stored = created.stored_session_id if (!stored) { - await requestGateway('session.close', { session_id: created.session_id }).catch(() => undefined) + const closeCreated = capturedRoute + ? requestGatewayForAgent( + capturedRoute.connectionId, + capturedRoute.profile, + 'session.close', + { session_id: created.session_id } + ) + : requestGateway('session.close', { session_id: created.session_id }) + + await closeCreated.catch(() => undefined) notify({ kind: 'error', title: copy.sessionUnavailable, message: copy.createSessionFailed }) return @@ -641,7 +667,7 @@ export function useSessionActions({ const runtimeInfo = applyRuntimeInfo(created.info, { foreground: false }) updateSessionState(created.session_id, state => (runtimeInfo ? { ...state, ...runtimeInfo } : state), stored) - openSessionTile(stored, dir) + openSessionTile(stored, dir, undefined, undefined, workspaceScope) patchSessionTile(stored, { runtimeId: created.session_id }) if (dir === 'center' && runtimeInfo?.cwd) { diff --git a/apps/desktop/src/components/pane-shell/workspace-scope.test.ts b/apps/desktop/src/components/pane-shell/workspace-scope.test.ts index 7aae790a3723..241a4da966d0 100644 --- a/apps/desktop/src/components/pane-shell/workspace-scope.test.ts +++ b/apps/desktop/src/components/pane-shell/workspace-scope.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { $workspaceMode, + $workspaceNewSessionTarget, $workspaceOwnerKey, contributesToWorkspace, filterContributionsForWorkspace, @@ -38,6 +39,7 @@ describe('workspace scope', () => { it('defaults to the un-switched sessions window state', () => { expect($workspaceMode.get()).toBe('sessions') expect($workspaceOwnerKey.get()).toBeNull() + expect($workspaceNewSessionTarget.get()).toBeNull() }) it('publishes a coherent mode and owner in one batch', () => { @@ -56,6 +58,35 @@ describe('workspace scope', () => { unbindOwner() }) + it('publishes the exact new-session route with its Bot owner', () => { + const route = { + connectionId: 'connection-a', + mode: 'remote' as const, + profile: 'writer', + targetProfile: 'writer' + } + + expect(setWorkspaceScope('bots', 'bot:connection-a::writer', { kind: 'route', route })).toBe(true) + expect($workspaceNewSessionTarget.get()).toEqual({ kind: 'route', route }) + + // Equivalent route objects are a semantic no-op, not a new render signal. + expect( + setWorkspaceScope('bots', 'bot:connection-a::writer', { kind: 'route', route: { ...route } }) + ).toBe(false) + + setWorkspaceScope('sessions') + expect($workspaceNewSessionTarget.get()).toBeNull() + }) + + it('keeps a group owner explicit while blocking generic session creation', () => { + const target = { kind: 'blocked' as const, message: 'New group conversations start in the group composer.' } + + setWorkspaceScope('bots', 'group:room-1', target) + + expect($workspaceOwnerKey.get()).toBe('group:room-1') + expect($workspaceNewSessionTarget.get()).toEqual(target) + }) + it('keeps global contributions visible in both modes', () => { expect(contributesToWorkspace(undefined, 'sessions', null)).toBe(true) expect(contributesToWorkspace(undefined, 'bots', 'bot-a')).toBe(true) diff --git a/apps/desktop/src/components/pane-shell/workspace-scope.ts b/apps/desktop/src/components/pane-shell/workspace-scope.ts index 3980fc3f1557..a034477b04a8 100644 --- a/apps/desktop/src/components/pane-shell/workspace-scope.ts +++ b/apps/desktop/src/components/pane-shell/workspace-scope.ts @@ -24,22 +24,77 @@ export const $workspaceMode = atom('sessions') /** Default workspace owner key: none (unscoped / global ownership). */ export const $workspaceOwnerKey = atom(null) +/** Exact route for a fresh session in the current workspace. Kept structural + * here so the generic pane shell does not depend on profile/gateway stores. */ +export interface WorkspaceSessionRoute { + connectionId: string + mode?: 'local' | 'remote' + profile: string + targetProfile?: string +} + +/** What the shared `+` / session.newTab command means in this workspace. */ +export type WorkspaceNewSessionTarget = + | { kind: 'blocked'; message: string } + | { kind: 'route'; route: WorkspaceSessionRoute } + +/** Sessions uses its established ambient behavior (`null`). Bots publishes an + * exact route or a concise reason that a generic session is unavailable. */ +export const $workspaceNewSessionTarget = atom(null) + /** One key for window-local active-pane memory. Owner keys stay opaque. */ export function workspaceScopeKey(mode: WorkspaceMode, ownerKey: string | null): string { return mode === 'sessions' ? 'sessions' : `bots:${ownerKey ?? ''}` } -/** Publish one coherent presentation scope without an intermediate mixed frame. */ -export function setWorkspaceScope(mode: WorkspaceMode, ownerKey: string | null = null): boolean { +function sameNewSessionTarget(a: WorkspaceNewSessionTarget | null, b: WorkspaceNewSessionTarget | null): boolean { + if (a === b) { + return true + } + + if (!a || !b || a.kind !== b.kind) { + return false + } + + if (a.kind === 'blocked' && b.kind === 'blocked') { + return a.message === b.message + } + + if (a.kind === 'route' && b.kind === 'route') { + return ( + a.route.connectionId === b.route.connectionId && + a.route.mode === b.route.mode && + a.route.profile === b.route.profile && + a.route.targetProfile === b.route.targetProfile + ) + } + + return false +} + +/** Publish one coherent presentation and creation scope without an + * intermediate mixed frame. Sessions always retains its existing ambient + * new-session behavior; alternate workspaces must state their intent. */ +export function setWorkspaceScope( + mode: WorkspaceMode, + ownerKey: string | null = null, + newSessionTarget: WorkspaceNewSessionTarget | null = null +): boolean { const nextOwnerKey = mode === 'bots' ? ownerKey : null + const nextNewSessionTarget = mode === 'bots' ? newSessionTarget : null - if ($workspaceMode.get() === mode && $workspaceOwnerKey.get() === nextOwnerKey) { + if ( + $workspaceMode.get() === mode && + $workspaceOwnerKey.get() === nextOwnerKey && + sameNewSessionTarget($workspaceNewSessionTarget.get(), nextNewSessionTarget) + ) { return false } batch(() => { $workspaceMode.set(mode) $workspaceOwnerKey.set(nextOwnerKey) + $workspaceNewSessionTarget.set(nextNewSessionTarget) }) return true diff --git a/apps/desktop/src/plugins/hermes-bots/plugin.js b/apps/desktop/src/plugins/hermes-bots/plugin.js index 3573f0f3f989..8f4b6d65ba26 100644 --- a/apps/desktop/src/plugins/hermes-bots/plugin.js +++ b/apps/desktop/src/plugins/hermes-bots/plugin.js @@ -4568,12 +4568,22 @@ function isDefaultBot(bot) { function newBotChat(bot) { if (typeof host.newChat !== 'function') { - host.navigate?.('/') + host.notify?.({ kind: 'error', message: 'Update Hermes Desktop to open another Bot chat.' }) return } - host.newChat(bot?.sourceScoped || bot?.remoteSource ? botConnectionRoute(bot) : bot?.name) + const route = botConnectionRoute(bot) + + if (!route) { + host.notify?.({ kind: 'error', message: 'Update Hermes Desktop to open another Bot chat.' }) + + return + } + + const ownerKey = botWorkspaceOwnerKey(bot) + setBotsWorkspaceOwner(ownerKey, bot) + host.newChat(route, { workspaceMode: 'bots', workspaceOwnerKey: ownerKey }) } /** Resolve @handles in prose against the Bot Mode roster (local + Connections). @@ -4773,6 +4783,24 @@ function botConnectionRoute(bot) { }) } +const BOTS_HOME_OWNER_KEY = 'bots:home' + +function botWorkspaceOwnerKey(bot) { + const route = botConnectionRoute(bot) + + return `bot:${route ? botRouteKey(route) : String(bot?.name || 'default')}` +} + +function groupWorkspaceOwnerKey(group) { + return `group:${groupChatRoomKey(group, $groupChats.get()[group])}` +} + +function setBotsWorkspaceOwner(ownerKey, bot = null, blockedMessage = 'Select a Bot or group first.') { + const route = bot ? botConnectionRoute(bot) : null + const target = route ? { kind: 'route', route } : { kind: 'blocked', message: blockedMessage } + + host.setWorkspaceScope?.('bots', ownerKey || BOTS_HOME_OWNER_KEY, target) +} function backendTargetProfile(route, fallbackProfile = 'default') { if (!route) { return fallbackProfile @@ -5288,7 +5316,7 @@ async function ensureBotMetadata(bot) { * resolves a canonical-chat id. */ async function openRosterBot(bot) { const generation = ++botOpenGeneration - const key = botRosterKey(bot) + const key = botSelectionKey(bot) const meta = botRosterMeta(bot, $botMeta.get()) // Keep the currently visible group as a fallback until this explicit action // has actually fronted a new owner; a failed home open must not steal the @@ -5297,35 +5325,7 @@ async function openRosterBot(bot) { haptic('tap') saveSelectedRosterBot(bot) - - if (bot.remoteSource) { - // Selection only. A remote bot must never be opened through whichever - // gateway happens to be live; remote mention delivery remains backend-owned. - $openBotChat.set(null) - $groupChatWorkspace.set(null) - - if (botsHomeEnabled()) { - // Explicitly front the selected owner but keep the existing group tab - // intact. If the workspace door refuses, restore the group selection so - // a failed roster action cannot leave the center ownerless. - if (!openBotsHomeWorkspace(true) && previousGroup) { - $groupChatWorkspace.set(previousGroup) - } - } else { - // Old shells have no home surface; keep the existing visible group as - // owner and offer only guidance, never renderer-owned remote delivery. - if (previousGroup) { - $groupChatWorkspace.set(previousGroup) - } - host.notify?.({ - kind: 'info', - title: displayName(bot, meta), - message: `Stay in this chat and message @${botHandle(bot.name, bot)} from a Bot Chat.` - }) - } - - return false - } + setBotsWorkspaceOwner(botWorkspaceOwnerKey(bot), bot) $groupChatWorkspace.set(null) @@ -5357,7 +5357,7 @@ async function openRosterBot(bot) { } try { - const registryId = await openBotCanonicalChat(bot.name) + const registryId = await openBotCanonicalChat(bot) if (generation !== botOpenGeneration) { return false @@ -5397,7 +5397,7 @@ async function openRosterBot(bot) { $openBotChat.set({ key, openedRegistryId: '' }) closeBotsHomeWorkspace() - host.newChat(bot.name) + newBotChat(bot) return true } @@ -7585,11 +7585,9 @@ function BotRow({ bot, onDelete, onEdit, onGroup, showHandle }) { jsx(ContextMenuSeparator, {}), jsx(ContextMenuItem, { onSelect: () => { - $selectedBot.set(botSelectionKey(bot)) - - if (typeof host.newChat === 'function') { - newBotChat(bot) - } + saveSelectedRosterBot(bot) + setBotsWorkspaceOwner(botWorkspaceOwnerKey(bot), bot) + newBotChat(bot) }, children: 'New chat with this agent' }), @@ -12425,6 +12423,9 @@ function openBotsHomeWorkspace(explicit = false) { return false } + const selected = selectedRosterBot($lastRoster.get(), $selectedRosterKey.get()) + const ownerKey = selected ? botWorkspaceOwnerKey(selected) : BOTS_HOME_OWNER_KEY + setBotsWorkspaceOwner(ownerKey, selected) // Already open and fronted: nothing to do. Already open but backgrounded // (a persisted layout can restore the tab behind the draft): re-open to // re-front it. Never stack a second registration — a stale disposer would @@ -12537,6 +12538,8 @@ function openGroupChat(group) { // but it may not later close or visually steal the room the user chose. botOpenGeneration += 1 $groupNeedsYou.set({ ...$groupNeedsYou.get(), [group]: false }) + const ownerKey = groupWorkspaceOwnerKey(group) + setBotsWorkspaceOwner(ownerKey, null, 'New group conversations start in the group composer.') if (typeof host.openWorkspace === 'function') { try { @@ -13795,6 +13798,17 @@ export default { const stopSidebarSync = $sidebarVisible.listen(visible => { $botsPaneVisible.set(Boolean(visible)) + if (visible) { + const group = $groupChatWorkspace.get() + const selected = selectedRosterBot($lastRoster.get(), $selectedRosterKey.get()) + setBotsWorkspaceOwner( + group ? groupWorkspaceOwnerKey(group) : selected ? botWorkspaceOwnerKey(selected) : BOTS_HOME_OWNER_KEY, + group ? null : selected, + group ? 'New group conversations start in the group composer.' : 'Select a Bot or group first.' + ) + } else { + host.setWorkspaceScope?.('sessions') + } // A generic composer has no stored-session owner, so passive sync // replaces it with the Bot home. A real restored chat keeps the // center until the user explicitly selects a Bot owner. diff --git a/apps/desktop/src/plugins/hermes-bots/tests/remote-routing-races.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/remote-routing-races.test.mjs index 4f6da3d2c0d7..8c3794cebda4 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/remote-routing-races.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/remote-routing-races.test.mjs @@ -37,7 +37,7 @@ function load({ requestProfile, agents, profileRoutes } = {}) { activeConnectionId = connectionId calls.push(['ensureAgent', connectionId, profile]) }, - newChat: route => calls.push(['newChat', route]), + newChat: (route, options) => calls.push(['newChat', route, options]), notify: () => undefined, notifyError: () => undefined, profileRoutes: profileRoutes || (async () => []), @@ -50,6 +50,7 @@ function load({ requestProfile, agents, profileRoutes } = {}) { calls.push(['profile', route, method, params]) return {} }), + setWorkspaceScope: (mode, ownerKey, target) => calls.push(['workspaceScope', mode, ownerKey, target]), state: { connectionId: { get: () => activeConnectionId, listen: () => undefined }, gateway: { get: () => 'open', listen: () => undefined }, @@ -351,6 +352,17 @@ test('delayed duplicate, delete, and new chat keep source ownership', async () = .filter(value => value && typeof value === 'object') assert.ok(routes.length >= 3) assert.ok(routes.every(route => route.connectionId === 'remote-a')) + + const newChat = runtime.calls.find(call => call[0] === 'newChat') + assert.equal(newChat[2].workspaceMode, 'bots') + assert.equal(newChat[2].workspaceOwnerKey, 'bot:remote-a::worker') + assert.equal(newChat[1].targetProfile, 'backend-worker') + + const scope = runtime.calls.find(call => call[0] === 'workspaceScope') + assert.equal(scope[1], 'bots') + assert.equal(scope[2], 'bot:remote-a::worker') + assert.equal(scope[3].kind, 'route') + assert.equal(scope[3].route.connectionId, 'remote-a') }) diff --git a/apps/desktop/src/sdk/index.test.ts b/apps/desktop/src/sdk/index.test.ts index 0817188888f2..a279f3cd00ed 100644 --- a/apps/desktop/src/sdk/index.test.ts +++ b/apps/desktop/src/sdk/index.test.ts @@ -178,6 +178,7 @@ describe('host workspace scope', () => { afterEach(async () => { host.setWorkspaceScope('sessions') const tree = await import('@/components/pane-shell/tree/store') + tree.$newSessionTabAction.set(null) tree.removeTreePane('plugin-workspace:scope-test') }) @@ -214,4 +215,23 @@ describe('host workspace scope', () => { expect($workspaceMode.get()).toBe('bots') expect($workspaceOwnerKey.get()).toBe('connection-b::default') }) + + it('uses the shared tab action for an exact Bot owner without moving Sessions', async () => { + const tree = await import('@/components/pane-shell/tree/store') + const { $workspaceNewSessionTarget } = await import('@/components/pane-shell/workspace-scope') + const opened: string[] = [] + + const route = { + connectionId: 'connection-b', + mode: 'remote' as const, + profile: 'writer', + targetProfile: 'writer' + } + + tree.$newSessionTabAction.set(() => opened.push('tab')) + host.newChat(route, { workspaceMode: 'bots', workspaceOwnerKey: 'bot:connection-b::writer' }) + + expect(opened).toEqual(['tab']) + expect($workspaceNewSessionTarget.get()).toEqual({ kind: 'route', route }) + }) }) diff --git a/apps/desktop/src/sdk/index.ts b/apps/desktop/src/sdk/index.ts index 210448cef924..f6cf8082caec 100644 --- a/apps/desktop/src/sdk/index.ts +++ b/apps/desktop/src/sdk/index.ts @@ -26,12 +26,16 @@ import { openSession, type OpenSessionIntent } from '@/app/open-session' import type { ClientSessionState } from '@/app/types' import { $narrowViewport, + $newSessionTabAction, $paneVisible, registerPaneCloser, removeTreePane, revealTreePane } from '@/components/pane-shell/tree/store' -import { setWorkspaceScope as publishWorkspaceScope } from '@/components/pane-shell/workspace-scope' +import { + setWorkspaceScope as publishWorkspaceScope, + type WorkspaceNewSessionTarget +} from '@/components/pane-shell/workspace-scope' import { onGatewayEvent } from '@/contrib/events' import { registry } from '@/contrib/registry' import type { WorkspaceMode } from '@/contrib/types' @@ -297,6 +301,11 @@ export interface PluginOpenSessionOptions { retryHydrationTimeoutOnce?: boolean } +export interface PluginNewChatOptions { + workspaceMode?: WorkspaceMode + workspaceOwnerKey?: string +} + function waitForFocusedSessionHydration({ connectionId, expectHistory, @@ -747,6 +756,11 @@ export const host = { const intent = options.intent ?? 'in-place' if (options.workspaceMode === 'bots') { + publishWorkspaceScope( + 'bots', + options.workspaceOwnerKey ?? null, + ownerRoute ? { kind: 'route', route: ownerRoute } : null + ) openSession(storedSessionId, navigate, intent, { workspaceMode: 'bots', workspaceOwnerKey: options.workspaceOwnerKey @@ -912,13 +926,38 @@ export const host = { }, /** Switch the visible main-pane workspace without unregistering retained panes. */ - setWorkspaceScope: (mode: WorkspaceMode, ownerKey: null | string = null): boolean => - publishWorkspaceScope(mode, ownerKey), + setWorkspaceScope: ( + mode: WorkspaceMode, + ownerKey: null | string = null, + newSessionTarget: WorkspaceNewSessionTarget | null = null + ): boolean => publishWorkspaceScope(mode, ownerKey, newSessionTarget), /** Start a fresh chat draft, optionally pointed at another profile (its * backend spins up in the background — same door the sidebar's per-profile * "+" uses). */ - newChat: (profile?: null | string | PluginProfileRoute): void => { + newChat: (profile?: null | string | PluginProfileRoute, options: PluginNewChatOptions = {}): void => { + if (options.workspaceMode === 'bots') { + if (!profile || typeof profile === 'string' || !options.workspaceOwnerKey) { + notify({ kind: 'error', message: 'Select a Bot before starting another chat.' }) + + return + } + + publishWorkspaceScope('bots', options.workspaceOwnerKey, { kind: 'route', route: { ...profile } }) + + const openTab = $newSessionTabAction.get() + + if (!openTab) { + notify({ kind: 'error', message: 'Update Hermes Desktop to open another Bot chat.' }) + + return + } + + openTab() + + return + } + if (profile && typeof profile !== 'string') { newSessionInAgent({ ...profile }) } else { From 64a4a1dfcacf9c2ad652d649dbd9f7c19f5bacbc Mon Sep 17 00:00:00 2001 From: David Dudok de Wit <5354424+dokterdok@users.noreply.github.com> Date: Sun, 23 Aug 2026 01:07:41 +0200 Subject: [PATCH 04/10] feat(desktop): retain Bot group drafts by room --- .../desktop/src/plugins/hermes-bots/plugin.js | 170 ++++++++++++++++-- .../tests/group-chat-identity-edit.test.mjs | 4 +- .../tests/group-composer-drafts.test.mjs | 106 +++++++++++ 3 files changed, 264 insertions(+), 16 deletions(-) create mode 100644 apps/desktop/src/plugins/hermes-bots/tests/group-composer-drafts.test.mjs diff --git a/apps/desktop/src/plugins/hermes-bots/plugin.js b/apps/desktop/src/plugins/hermes-bots/plugin.js index 8f4b6d65ba26..df8b4cdbd752 100644 --- a/apps/desktop/src/plugins/hermes-bots/plugin.js +++ b/apps/desktop/src/plugins/hermes-bots/plugin.js @@ -6203,6 +6203,10 @@ async function renameGroupChat(oldName, newName, members) { const all = { ...$groupChats.get() } const room = all[oldName] + if (room) { + migrateGroupComposerDraft(groupComposerDraftKey(oldName, room), groupComposerDraftKey(next, room)) + } + delete all[oldName] if (room) { @@ -11301,11 +11305,131 @@ function GroupClarifyCard({ entry, members }) { }) } +// Group composer drafts are window-local UI state. They must survive pane +// parking/re-registration and owner switches, but must never enter shared room +// metadata (where another Desktop would see half-typed text or attachment +// bytes). Current rooms key by immutable roomId; legacy rooms fall back to the +// display name until they are upgraded. +const groupComposerDrafts = new Map() + +function emptyGroupComposerDraft() { + return { activeReplyThread: null, main: '', pendingAttachments: {}, replies: {}, revision: 0 } +} + +function groupComposerDraftKey(group, room) { + return groupChatRoomKey(group, room) +} + +function groupComposerDraftSnapshot(key) { + return groupComposerDrafts.get(key) || emptyGroupComposerDraft() +} + +function updateGroupComposerDraft(key, mutate) { + const current = groupComposerDraftSnapshot(key) + const next = mutate({ + ...current, + pendingAttachments: Object.fromEntries( + Object.entries(current.pendingAttachments || {}).map(([thread, attachments]) => [ + thread, + [...(attachments || [])] + ]) + ), + replies: { ...(current.replies || {}) } + }) + + next.revision = current.revision + 1 + groupComposerDrafts.delete(key) + groupComposerDrafts.set(key, next) + + return next +} + +function restoreGroupComposerDraft(key, expectedRevision, snapshot) { + const current = groupComposerDraftSnapshot(key) + + if (current.revision !== expectedRevision) { + return null + } + + const restored = { + ...snapshot, + pendingAttachments: Object.fromEntries( + Object.entries(snapshot.pendingAttachments || {}).map(([thread, attachments]) => [ + thread, + [...(attachments || [])] + ]) + ), + replies: { ...(snapshot.replies || {}) }, + revision: current.revision + 1 + } + + groupComposerDrafts.set(key, restored) + + return restored +} + +function clearGroupComposerDraft(key) { + groupComposerDrafts.delete(key) +} + +function migrateGroupComposerDraft(oldKey, newKey) { + if (oldKey === newKey || !groupComposerDrafts.has(oldKey)) { + return + } + + if (!groupComposerDrafts.has(newKey)) { + groupComposerDrafts.set(newKey, groupComposerDrafts.get(oldKey)) + } + + groupComposerDrafts.delete(oldKey) +} + function GroupChatWorkspace({ group, members, onBack, visible = true }) { const rooms = useValue($groupChats) const allMeta = useValue($botMeta) const room = rooms[group] || { log: [], running: false } - const [draft, setDraft] = useState('') + const composerKey = groupComposerDraftKey(group, room) + const composerKeyRef = useRef(composerKey) + const [composerDraft, setComposerDraft] = useState(() => groupComposerDraftSnapshot(composerKey)) + + if (composerKeyRef.current !== composerKey) { + migrateGroupComposerDraft(composerKeyRef.current, composerKey) + composerKeyRef.current = composerKey + } + + const updateComposerDraft = mutate => { + const next = updateGroupComposerDraft(composerKeyRef.current, mutate) + setComposerDraft(next) + + return next + } + + const draft = composerDraft.main || '' + const replyDrafts = composerDraft.replies || {} + const replyThread = composerDraft.activeReplyThread || null + const pendingImages = composerDraft.pendingAttachments || {} + const setDraft = value => + updateComposerDraft(current => ({ + ...current, + main: typeof value === 'function' ? value(current.main || '') : value + })) + const setReplyDrafts = value => + updateComposerDraft(current => ({ + ...current, + replies: typeof value === 'function' ? value(current.replies || {}) : value + })) + const setReplyThread = value => + updateComposerDraft(current => ({ + ...current, + activeReplyThread: + typeof value === 'function' ? value(current.activeReplyThread || null) : value + })) + const setPendingImages = value => + updateComposerDraft(current => ({ + ...current, + pendingAttachments: + typeof value === 'function' ? value(current.pendingAttachments || {}) : value + })) const [confirmDisband, setConfirmDisband] = useState(false) const [settingsOpen, setSettingsOpen] = useState(false) // Click-to-disambiguate: which log entry is showing its speaker's full @@ -11318,12 +11442,9 @@ function GroupChatWorkspace({ group, members, onBack, visible = true }) { // `replyThread` is the thread whose reply box currently owns the composer // (null = the main composer, which STARTS a new thread). const [openThreads, setOpenThreads] = useState({}) - const [replyThread, setReplyThread] = useState(null) - const [replyDrafts, setReplyDrafts] = useState({}) // Pending image attachments per composer: `null` thread key = the main // composer, otherwise the reply box of that thread. Data URLs, already // downscaled — they ride the send into every responding member's session. - const [pendingImages, setPendingImages] = useState({}) // Scroll anchoring (#89835): rooms used to open at scroll position 0 and // stay there while replies streamed in. Scroll the bottom sentinel into @@ -11385,11 +11506,6 @@ function GroupChatWorkspace({ group, members, onBack, visible = true }) { setPendingImages(prev => ({ ...prev, [key]: [...(prev[key] || []), ...picked] })) } - const clearImages = thread => { - const key = thread ?? 'main' - setPendingImages(prev => ({ ...prev, [key]: [] })) - } - const removeImage = (thread, index) => { const key = thread ?? 'main' setPendingImages(prev => ({ ...prev, [key]: (prev[key] || []).filter((_, i) => i !== index) })) @@ -11579,8 +11695,12 @@ function GroupChatWorkspace({ group, members, onBack, visible = true }) { return } - setDraft('') - clearImages(null) + const before = groupComposerDraftSnapshot(composerKeyRef.current) + const cleared = updateComposerDraft(current => ({ + ...current, + main: '', + pendingAttachments: { ...(current.pendingAttachments || {}), main: [] } + })) // Main composer = START A NEW THREAD with the whole group (Slack shape). // Full descriptors ride into the turn loop: remote members keep their // connection fields so their turns route to their own machines. @@ -11588,6 +11708,12 @@ function GroupChatWorkspace({ group, members, onBack, visible = true }) { if (minted) { setOpenThreads(prev => ({ ...prev, [minted]: true })) + } else { + const restored = restoreGroupComposerDraft(composerKeyRef.current, cleared.revision, before) + + if (restored) { + setComposerDraft(restored) + } } } @@ -11599,12 +11725,25 @@ function GroupChatWorkspace({ group, members, onBack, visible = true }) { return } - setReplyDrafts(prev => ({ ...prev, [thread]: '' })) - clearImages(thread) + const before = groupComposerDraftSnapshot(composerKeyRef.current) + const cleared = updateComposerDraft(current => ({ + ...current, + pendingAttachments: { ...(current.pendingAttachments || {}), [thread]: [] }, + replies: { ...(current.replies || {}), [thread]: '' } + })) // Reply box = CONTINUE this thread; the member turns it triggers are // scoped to it. - sendToGroupChat(group, memberDescriptors(), text, thread, images) - setOpenThreads(prev => ({ ...prev, [thread]: true })) + const sent = sendToGroupChat(group, memberDescriptors(), text, thread, images) + + if (sent) { + setOpenThreads(prev => ({ ...prev, [thread]: true })) + } else { + const restored = restoreGroupComposerDraft(composerKeyRef.current, cleared.revision, before) + + if (restored) { + setComposerDraft(restored) + } + } } /** Pending-attachment chips + the picker for one composer (thread = null → @@ -12040,6 +12179,7 @@ function GroupChatWorkspace({ group, members, onBack, visible = true }) { doneLabel: 'Disbanded', onClose: () => setConfirmDisband(false), onConfirm: async () => { + clearGroupComposerDraft(composerKeyRef.current) await disbandGroupChat(group, members) host.notify({ kind: 'success', message: `Disbanded “${group}”` }) } diff --git a/apps/desktop/src/plugins/hermes-bots/tests/group-chat-identity-edit.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/group-chat-identity-edit.test.mjs index c96b4ada0ef7..775c4ae536c1 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/group-chat-identity-edit.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/group-chat-identity-edit.test.mjs @@ -26,7 +26,9 @@ test('source contract: settings dialog edits name and picture after creation', ( test('source contract: rename re-keys the room AND local memberships, keeps sessions', () => { // The room record moves wholesale under the new key (sessions included, so // members keep resuming their per-group sessions by stored sid). - assert.match(pluginSource, /const room = all\[oldName\]\s*\n\s*\n?\s*delete all\[oldName\]/) + assert.match(pluginSource, /const room = all\[oldName\][\s\S]{0,320}?delete all\[oldName\]/) + // Window-local composer drafts follow the immutable room identity too. + assert.match(pluginSource, /migrateGroupComposerDraft\(groupComposerDraftKey\(oldName, room\), groupComposerDraftKey\(next, room\)\)/) // Local members' canonical groups lists swap old → new via ui_meta. assert.match(pluginSource, /botGroups\(meta\)\.map\(g => \(g === oldName \? next : g\)\)/) // Collisions are rejected, not silently suffixed — rename is explicit intent. diff --git a/apps/desktop/src/plugins/hermes-bots/tests/group-composer-drafts.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/group-composer-drafts.test.mjs new file mode 100644 index 000000000000..6c4f0d570671 --- /dev/null +++ b/apps/desktop/src/plugins/hermes-bots/tests/group-composer-drafts.test.mjs @@ -0,0 +1,106 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' +import vm from 'node:vm' + +const source = readFileSync(new URL('../plugin.js', import.meta.url), 'utf8') + +function between(start, end) { + const from = source.indexOf(start) + const to = source.indexOf(end, from) + + assert.notEqual(from, -1, `missing ${start}`) + assert.notEqual(to, -1, `missing ${end}`) + + return source.slice(from, to) +} + +function load() { + const context = { Map, Object } + const roomKey = between('function groupChatRoomKey(', '/** Lift any historical projection shape') + const drafts = between('const groupComposerDrafts = new Map()', 'function GroupChatWorkspace(') + + vm.runInNewContext( + `${roomKey}\n${drafts}\nglobalThis.drafts = { + clearGroupComposerDraft, + groupComposerDraftKey, + groupComposerDraftSnapshot, + migrateGroupComposerDraft, + restoreGroupComposerDraft, + updateGroupComposerDraft + }`, + context + ) + + return context.drafts +} + +test('workspace retirement and re-registration restore the exact room draft', () => { + const drafts = load() + const key = drafts.groupComposerDraftKey('Launch room', { roomId: 'room-1' }) + const attachment = { data: 'data:image/png;base64,abc', kind: 'image', name: 'plan.png' } + + drafts.updateGroupComposerDraft(key, state => ({ + ...state, + activeReplyThread: 'thread-1', + main: 'main draft', + pendingAttachments: { main: [attachment], 'thread-1': [attachment] }, + replies: { 'thread-1': 'reply draft' } + })) + + // Dropping the component reference simulates pane retirement. A fresh + // registration reads the same module-scope, roomId-qualified snapshot. + const remounted = drafts.groupComposerDraftSnapshot(key) + + assert.equal(remounted.main, 'main draft') + assert.equal(remounted.replies['thread-1'], 'reply draft') + assert.equal(remounted.activeReplyThread, 'thread-1') + assert.equal(remounted.pendingAttachments.main[0].name, 'plan.png') +}) + +test('legacy name-keyed drafts migrate when an immutable room id appears', () => { + const drafts = load() + const legacy = drafts.groupComposerDraftKey('Launch room', {}) + const current = drafts.groupComposerDraftKey('Renamed room', { roomId: 'room-1' }) + + drafts.updateGroupComposerDraft(legacy, state => ({ ...state, main: 'keep me' })) + drafts.migrateGroupComposerDraft(legacy, current) + + assert.equal(drafts.groupComposerDraftSnapshot(current).main, 'keep me') + assert.equal(drafts.groupComposerDraftSnapshot(legacy).main, '') +}) + +test('a failed send cannot overwrite text entered after the optimistic clear', () => { + const drafts = load() + const key = 'id:room-1' + + drafts.updateGroupComposerDraft(key, state => ({ ...state, main: 'send this' })) + const before = drafts.groupComposerDraftSnapshot(key) + const cleared = drafts.updateGroupComposerDraft(key, state => ({ ...state, main: '' })) + + drafts.updateGroupComposerDraft(key, state => ({ ...state, main: 'newer typing' })) + + assert.equal(drafts.restoreGroupComposerDraft(key, cleared.revision, before), null) + assert.equal(drafts.groupComposerDraftSnapshot(key).main, 'newer typing') +}) + +test('disband removes only that room draft', () => { + const drafts = load() + + drafts.updateGroupComposerDraft('id:a', state => ({ ...state, main: 'a' })) + drafts.updateGroupComposerDraft('id:b', state => ({ ...state, main: 'b' })) + drafts.clearGroupComposerDraft('id:a') + + assert.equal(drafts.groupComposerDraftSnapshot('id:a').main, '') + assert.equal(drafts.groupComposerDraftSnapshot('id:b').main, 'b') +}) + +test('GroupChatWorkspace owns composer state through the room draft store', () => { + const workspace = between('function GroupChatWorkspace(', '/** Live closers for group-chat MAIN-window tabs') + + assert.match(workspace, /groupComposerDraftKey\(group, room\)/) + assert.match(workspace, /restoreGroupComposerDraft\(composerKeyRef\.current, cleared\.revision, before\)/) + assert.match(workspace, /clearGroupComposerDraft\(composerKeyRef\.current\)/) + assert.doesNotMatch(workspace, /useState\(''\).*?draft/) + assert.doesNotMatch(workspace, /useState\(\{\}\).*?replyDrafts/) +}) From d93d5e58411a9a82c85f0c37a1de7dc35492abe6 Mon Sep 17 00:00:00 2001 From: David Dudok de Wit <5354424+dokterdok@users.noreply.github.com> Date: Sun, 23 Aug 2026 01:18:53 +0200 Subject: [PATCH 05/10] fix(desktop): bound Bot owner wake races --- .../desktop/src/plugins/hermes-bots/plugin.js | 34 ++++- .../hermes-bots/tests/bots-home.test.mjs | 140 +++++++++++++----- apps/desktop/src/sdk/index.ts | 90 +++++++++-- apps/desktop/src/sdk/profile-routing.test.ts | 107 +++++++++++-- 4 files changed, 303 insertions(+), 68 deletions(-) diff --git a/apps/desktop/src/plugins/hermes-bots/plugin.js b/apps/desktop/src/plugins/hermes-bots/plugin.js index df8b4cdbd752..a49c7ccd5e95 100644 --- a/apps/desktop/src/plugins/hermes-bots/plugin.js +++ b/apps/desktop/src/plugins/hermes-bots/plugin.js @@ -5104,6 +5104,28 @@ function isCanonicalBotChatHistory(history) { return rootTitle === CANONICAL_CHAT_TITLE || (!rootTitle && title === CANONICAL_CHAT_TITLE) } +function botModeGatewayNeedsUpdate(error) { + const message = String(error?.message || error || '') + + return /(?:method not found|no handler for|unknown method|unsupported rpc)/i.test(message) +} + +function notifyBotOpenFailure(error, bot, fallbackMessage) { + if (botModeGatewayNeedsUpdate(error)) { + const gateway = bot.connectionLabel || bot.connectionId || 'this gateway' + + host.notify?.({ + kind: 'error', + title: 'Update this gateway to use Bot Mode', + message: `Update ${gateway}, then try again.` + }) + + return + } + + host.notifyError?.(error, fallbackMessage) +} + /** THE identity lookup: the profile's session titled exactly "Bot Chat", * consulted on the bot's OWN source. The core UNIQUE title index guarantees * at most ONE such row per profile db — Profile → Named Session is an exact @@ -5115,7 +5137,6 @@ function isCanonicalBotChatHistory(history) { * authorizes this RPC. */ async function findExistingCanonicalChat(owner) { const { bot, name, route } = botOwner(owner) - // FAIL CLOSED. A failed registry lookup MUST NOT read as "no Bot Chat // exists" — that is the one remaining way to fork a bot's forever chat. // The failure lives exactly in the post-update window: the desktop @@ -5346,7 +5367,8 @@ async function openRosterBot(bot) { $groupChatWorkspace.set(previousGroup) } syncBotsHomeWorkspace() - host.notifyError?.(error, `Could not reach ${bot.connectionLabel || 'the gateway'}`) + + notifyBotOpenFailure(error, bot, `Could not reach ${bot.connectionLabel || 'the gateway'}`) } return false @@ -5378,7 +5400,8 @@ async function openRosterBot(bot) { $groupChatWorkspace.set(previousGroup) } syncBotsHomeWorkspace() - host.notifyError?.(error, `Could not open ${displayName(bot, meta)}'s chat — try again`) + + notifyBotOpenFailure(error, bot, `Could not open ${displayName(bot, meta)}'s chat — try again`) } return false @@ -13947,6 +13970,11 @@ export default { group ? 'New group conversations start in the group composer.' : 'Select a Bot or group first.' ) } else { + // Strand any owner wake still dialing. Its SDK open will fail the + // workspace token too; this plugin generation prevents that expected + // cancellation from repainting Bots home or showing an error after + // the user deliberately returned to Sessions. + botOpenGeneration += 1 host.setWorkspaceScope?.('sessions') } // A generic composer has no stored-session owner, so passive sync diff --git a/apps/desktop/src/plugins/hermes-bots/tests/bots-home.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/bots-home.test.mjs index 054491e38971..f3be856978eb 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/bots-home.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/bots-home.test.mjs @@ -41,6 +41,8 @@ function load({ focusedStoredSessionId = null, paneVisibility = true, openWorksp const notifications = [] const requests = [] const invalidations = [] + const sessionOpens = [] + const workspaceScopes = [] const paneVisible = new Map() const focused = atom(focusedStoredSessionId) @@ -54,6 +56,23 @@ function load({ focusedStoredSessionId = null, paneVisibility = true, openWorksp requests.push({ method, params }) return Promise.resolve({}) }, + requestProfile: (route, method, params) => { + requests.push({ method, params, route }) + if (method === 'profiles.list') { + return Promise.resolve({ profiles: [{ name: route.targetProfile || route.profile }] }) + } + if (method === 'session.list') { + return Promise.resolve({ + sessions: [{ id: `chat-${route.connectionId}-${route.profile}`, title: 'Bot Chat', message_count: 1 }] + }) + } + return Promise.resolve({}) + }, + openSession: async (id, options) => sessionOpens.push({ id, options }), + setWorkspaceScope: (mode, ownerKey = null) => { + workspaceScopes.push({ mode, ownerKey }) + return true + }, notify: params => notifications.push(params), notifyError: (error, fallback) => notifications.push({ kind: 'error', message: fallback, error }), ensureAgent: async () => undefined, @@ -142,7 +161,19 @@ globalThis.__home = { vm.runInNewContext(source, context, { filename: 'plugin.js' }) - return { ...context.__home, closed, focused, host, invalidations, notifications, opened, paneVisible, requests } + return { + ...context.__home, + closed, + focused, + host, + invalidations, + notifications, + opened, + paneVisible, + requests, + sessionOpens, + workspaceScopes + } } /** Every door that would create, activate, or route something. A passive @@ -398,9 +429,9 @@ test('an unknown source list is not proof of deletion', () => { assert.equal(t.ghostRosterOwner('work-vps::researcher', [{ connectionId: 'local', reachable: true }]), null) }) -// ── explicit open: local routes, remote never does ────────────────────────── +// ── explicit open: every reachable owner routes exactly ──────────────────── -test('opening a remote bot selects it and routes nothing', async () => { +test('opening a remote bot selects and opens its exact owner chat', async () => { const t = load() t.setPluginCtx({ storage: { set: () => undefined } }) t.$botsPaneVisible.set(true) @@ -415,13 +446,20 @@ test('opening a remote bot selects it and routes nothing', async () => { const result = await t.openRosterBot(bot) - assert.equal(result, false, 'a remote row does not open a chat') + assert.equal(result, true) assert.equal(t.$selectedRosterKey.get(), 'work-vps::researcher') - assert.equal(t.$openBotChat.get(), null) - assertNothingRouted(t, 'clicking a remote row') -}) - -test('a remote owner fronts the Bots home without closing the group tab', async () => { + const openBotChat = t.$openBotChat.get() + assert.equal(openBotChat?.key, 'work-vps::researcher') + assert.equal(openBotChat?.openedRegistryId, 'chat-work-vps-researcher') + assert.equal(t.sessionOpens.length, 1) + assert.equal(t.sessionOpens[0].options.workspaceMode, 'bots') + assert.equal(t.sessionOpens[0].options.workspaceOwnerKey, 'bot:work-vps::researcher') + assert.equal(t.sessionOpens[0].options.keepAllProfilesScope, true) + assert.equal(t.sessionOpens[0].options.route.connectionId, 'work-vps') + assert.ok(t.requests.every(request => request.route?.connectionId === 'work-vps')) +}) + +test('a remote owner opens its chat without closing an unrelated group tab', async () => { const t = load() t.setPluginCtx({ storage: { set: () => undefined } }) t.$botsPaneVisible.set(true) @@ -435,13 +473,14 @@ test('a remote owner fronts the Bots home without closing the group tab', async remoteSource: true }) - assert.equal(result, false) - assert.equal(t.botsHomeVisible(), true) + assert.equal(result, true) + assert.equal(t.botsHomeVisible(), false) assert.equal(t.$groupChatWorkspace.get(), null) assert.equal(groupEntry.disposed, false, 'explicit selection must not close an unrelated group tab') + assert.equal(t.sessionOpens.length, 1) }) -test('a remote owner preserves a group when the Bots home cannot open', async () => { +test('a remote owner does not depend on the informational home surface', async () => { const t = load() t.setPluginCtx({ storage: { set: () => undefined } }) t.$botsPaneVisible.set(true) @@ -458,9 +497,10 @@ test('a remote owner preserves a group when the Bots home cannot open', async () remoteSource: true }) - assert.equal(result, false) - assert.equal(t.$groupChatWorkspace.get(), 'Launch room') + assert.equal(result, true) + assert.equal(t.$groupChatWorkspace.get(), null) assert.equal(groupEntry.disposed, false) + assert.equal(t.sessionOpens.length, 1) }) test('a failed local open leaves no phantom owner in the center', async () => { @@ -469,9 +509,9 @@ test('a failed local open leaves no phantom owner in the center', async () => { t.$botsPaneVisible.set(true) t.$openBotChat.set({ key: 'local::writer', openedRegistryId: 'previous' }) - // A source-scoped row on a desktop that cannot activate it: prepareBotSource + // A source-scoped row on a desktop that cannot address it: prepareBotSource // refuses rather than letting the open fall through to the live gateway. - delete t.host.ensureAgent + delete t.host.requestProfile const bot = { connectionId: 'work-vps', name: 'writer', sourceScoped: true } const result = await t.openRosterBot(bot) @@ -482,6 +522,26 @@ test('a failed local open leaves no phantom owner in the center', async () => { assertNothingRouted(t, 'a refused local open') }) +test('an older gateway gets an actionable Bot Mode update message', async () => { + const t = load() + t.setPluginCtx({ storage: { set: () => undefined } }) + t.$botsPaneVisible.set(true) + t.host.requestProfile = async () => { + throw new Error('Unknown method profiles.list') + } + + const result = await t.openRosterBot({ + connectionId: 'work-vps', + connectionLabel: 'Work', + name: 'writer', + sourceScoped: true + }) + + assert.equal(result, false) + assert.equal(t.notifications.at(-1).title, 'Update this gateway to use Bot Mode') + assert.equal(t.notifications.at(-1).message, 'Update Work, then try again.') +}) + test('a missing profile-scoped draft API returns to the home without navigating', async () => { const t = load() t.setPluginCtx({ storage: { set: () => undefined } }) @@ -662,7 +722,7 @@ test('a persisted layout that restored the home behind the draft gets re-fronted assert.equal(t.botsHomeVisible(), true) }) -test('an explicit remote selection fronts the home over a focused chat', async () => { +test('an explicit remote selection opens its owner tab without moving the focused Sessions chat', async () => { const t = load({ focusedStoredSessionId: 'local-scout-chat' }) t.setPluginCtx({ storage: { set: () => undefined } }) t.$botsPaneVisible.set(true) @@ -671,19 +731,19 @@ test('an explicit remote selection fronts the home over a focused chat', async ( t.syncBotsHomeWorkspace() assert.deepEqual(t.opened, []) - // …but clicking the same-named twin on another gateway is a gesture at - // that owner: the home fronts, the chat stays alive underneath. + // …but clicking the same-named twin on another gateway opens that owner in + // Bot Mode while the Sessions chat stays alive underneath. await t.openRosterBot({ connectionId: 'work-vps', connectionLabel: 'Work', name: 'scout', remoteSource: true }) - assert.equal(t.opened.length, 1) + assert.equal(t.opened.length, 0) + assert.equal(t.sessionOpens.length, 1) assert.equal(t.$selectedRosterKey.get(), 'work-vps::scout') - assert.equal(t.$openBotChat.get(), null) - assertNothingRouted(t, 'explicit remote selection') + assert.equal(t.$openBotChat.get().key, 'work-vps::scout') + assert.equal(t.focused.get(), 'local-scout-chat') - // Browsing more remote owners reuses the fronted home instead of - // re-registering it (a stale disposer would tear down the newer one). + // Browsing more remote owners opens that owner without reusing the first. await t.openRosterBot({ connectionId: 'work-vps', connectionLabel: 'Work', name: 'relay', remoteSource: true }) - assert.equal(t.opened.length, 1) + assert.equal(t.sessionOpens.length, 2) assert.equal(t.$selectedRosterKey.get(), 'work-vps::relay') }) @@ -701,8 +761,8 @@ test('an explicit Bots-home gesture fronts the selected owner over a Sessions co test('source contract: sidebar entry and boot restore reconcile passively after layout hydration', () => { assert.match(pluginSource, /const syncWorkspaceSurfaces = \(\) =>/) - assert.match(pluginSource, /stopSidebarSync = \$sidebarVisible\.listen\(visible => \{[\s\S]{0,450}?syncWorkspaceSurfaces\(\)/) - assert.doesNotMatch(pluginSource, /stopSidebarSync = \$sidebarVisible\.listen\(visible => \{[\s\S]{0,450}?syncWorkspaceSurfaces\(Boolean\(visible\)\)/) + assert.match(pluginSource, /stopSidebarSync = \$sidebarVisible\.listen\(visible => \{[\s\S]{0,1500}?syncWorkspaceSurfaces\(\)/) + assert.doesNotMatch(pluginSource, /stopSidebarSync = \$sidebarVisible\.listen\(visible => \{[\s\S]{0,1500}?syncWorkspaceSurfaces\(Boolean\(visible\)\)/) assert.match( pluginSource, /\$botChatFocused\.set\(sessionOwnsWorkspace\(\)\)[\s\S]{0,500}?syncWorkspaceSurfaces\(\)[\s\S]{0,120}?scheduleSurfaceSync\(\)/ @@ -818,20 +878,28 @@ test('the home never yanks the center back from a sibling tab the user chose', ( assert.deepEqual(t.closed, []) }) -test('older shells without the main-area door simply have no home', async () => { +test('older shells without owner routing fail with an update path', async () => { const t = load({ openWorkspace: false, paneVisibility: false }) t.setPluginCtx({ storage: { set: () => undefined } }) t.$botsPaneVisible.set(true) + delete t.host.requestProfile + delete t.host.openSession t.syncBotsHomeWorkspace() assert.deepEqual(t.opened, []) - // And the remote row keeps its previous guidance toast instead. - await t.openRosterBot({ connectionId: 'work-vps', connectionLabel: 'Work', name: 'researcher', remoteSource: true }) + // A remote row cannot guess through the active gateway on an old shell. + await t.openRosterBot({ + connectionId: 'work-vps', + connectionLabel: 'Work', + name: 'researcher', + remoteSource: true, + sourceScoped: true + }) assert.equal(t.notifications.length, 1) - // Guidance is presentation only; remote mention delivery remains backend-owned. - assert.match(t.notifications[0].message, /message @researcher from a Bot Chat/) + assert.match(t.notifications[0].message, /Could not reach Work/) + assert.match(String(t.notifications[0].error), /Update Hermes Desktop/) assertNothingRouted(t, 'remote row on an older shell') }) @@ -906,15 +974,15 @@ test('an unavailable owner offers retry instead of a dead Open chat button', () assert.doesNotMatch(view, /its work keeps running on that gateway/) }) -test('an available remote owner explains the supported Bot Chat path without a fake direct action', () => { +test('an available remote owner offers the same direct chat action', () => { const start = pluginSource.indexOf('function BotsHomeView(') const view = pluginSource.slice(start, pluginSource.indexOf('function closeBotsHomeWorkspace(')) - assert.match(view, /unavailable \|\| !bot\.remoteSource/) - assert.match(view, /This bot lives on \$\{gateway\}\. Mention it from any Bot Chat to send it a message\./) + assert.match(view, /children: 'Open chat'/) + assert.match(view, /Open this bot’s continuous chat/) assert.doesNotMatch(view, /Copy @/) assert.doesNotMatch(view, /remoteCopy/) - assert.doesNotMatch(view, /ensureAgent|requestProfile|newChat/) + assert.doesNotMatch(view, /Mention it from any Bot Chat/) }) test('an unavailable owner never presents a guessed mention handle', () => { diff --git a/apps/desktop/src/sdk/index.ts b/apps/desktop/src/sdk/index.ts index f6cf8082caec..1510f14d6120 100644 --- a/apps/desktop/src/sdk/index.ts +++ b/apps/desktop/src/sdk/index.ts @@ -33,6 +33,8 @@ import { revealTreePane } from '@/components/pane-shell/tree/store' import { + $workspaceMode, + $workspaceOwnerKey, setWorkspaceScope as publishWorkspaceScope, type WorkspaceNewSessionTarget } from '@/components/pane-shell/workspace-scope' @@ -255,6 +257,31 @@ async function requestPluginProfile( ) } +/** Re-read Electron's current registry before retrying an exact-owner wake. + * A route that was removed or replaced while the first hydration wait ran is + * no longer authority to touch that backend, even when its labels still look + * identical. */ +async function pluginRouteStillRegistered(route: PluginProfileRoute): Promise { + const getProfileRoutes = window.hermesDesktop?.getProfileRoutes + + if (!getProfileRoutes) { + return false + } + + try { + const routes = await getProfileRoutes($profiles.get().map(profile => profile.name)) + + return routes.some( + candidate => + candidate.connectionId === route.connectionId && + candidate.profile === route.profile && + candidate.targetProfile === route.targetProfile + ) + } catch { + return false + } +} + if (typeof window !== 'undefined') { const refresh = () => $viewport.set(readViewport()) window.addEventListener('resize', refresh) @@ -307,17 +334,17 @@ export interface PluginNewChatOptions { } function waitForFocusedSessionHydration({ - connectionId, expectHistory, generation, + isCurrent, profile, requireActiveProfile, storedSessionId, timeoutMs }: { - connectionId?: string expectHistory: boolean generation: number + isCurrent?: () => boolean profile: string requireActiveProfile: boolean storedSessionId: string @@ -351,17 +378,27 @@ function waitForFocusedSessionHydration({ } const check = () => { - if (generation !== openSessionGeneration) { + if (generation !== openSessionGeneration || (isCurrent && !isCurrent())) { finish(new Error('Session open was superseded by a newer selection.')) return } const profileMatches = !requireActiveProfile || normalizeProfileKey($activeGatewayProfile.get()) === profile - const connectionMatches = !connectionId || $activeConnectionId.get() === connectionId - const sessionMatches = $selectedStoredSessionId.get() === storedSessionId - const runtimeReady = Boolean($activeSessionId.get()) - const historyPainted = Boolean($messages.get().length) + const mainMatches = $selectedStoredSessionId.get() === storedSessionId + const tileMatches = $focusedStoredSessionId.get() === storedSessionId + + const runtimeReady = mainMatches + ? Boolean($activeSessionId.get()) + : tileMatches + ? Boolean($focusedRuntimeId.get()) + : false + + const historyPainted = mainMatches + ? Boolean($messages.get().length) + : tileMatches + ? Boolean($focusedSessionState.get()?.messages.length) + : false // Paint-first hydration: for a history-bearing chat, the wake is DONE // the moment the persisted transcript is painted on the right session — @@ -377,7 +414,7 @@ function waitForFocusedSessionHydration({ // surface is real rather than a stuck loader. const hydrated = expectHistory ? historyPainted : runtimeReady - if (profileMatches && connectionMatches && sessionMatches && hydrated) { + if (profileMatches && (mainMatches || tileMatches) && hydrated) { finish() } } @@ -387,6 +424,11 @@ function waitForFocusedSessionHydration({ unbinds.push($selectedStoredSessionId.listen(check)) unbinds.push($activeSessionId.listen(check)) unbinds.push($messages.listen(check)) + unbinds.push($focusedStoredSessionId.listen(check)) + unbinds.push($focusedRuntimeId.listen(check)) + unbinds.push($focusedSessionState.listen(check)) + unbinds.push($workspaceMode.listen(check)) + unbinds.push($workspaceOwnerKey.listen(check)) timer = window.setTimeout(() => { finish(new Error(`Timed out loading ${profile}'s session history.`)) @@ -671,6 +713,19 @@ export const host = { const targetProfile = normalizeProfileKey(profile || $activeGatewayProfile.get()) const expectHistory = options.expectHistory ?? false + if (options.workspaceMode === 'bots') { + publishWorkspaceScope( + 'bots', + options.workspaceOwnerKey ?? null, + ownerRoute ? { kind: 'route', route: ownerRoute } : null + ) + } + + const openingStillCurrent = () => + generation === openSessionGeneration && + (options.workspaceMode !== 'bots' || + ($workspaceMode.get() === 'bots' && $workspaceOwnerKey.get() === (options.workspaceOwnerKey ?? null))) + const plan = planPluginOpenSession({ activeProfile: $activeGatewayProfile.get(), keepAllProfilesScope: options.keepAllProfilesScope, @@ -720,6 +775,10 @@ export const host = { profileActiveAt = Date.now() } + if (!openingStillCurrent()) { + throw new Error('Session open was superseded by a newer selection.') + } + if (ownerRoute) { setShowAllProfiles(true) } else if (plan.showAllProfiles !== null) { @@ -728,7 +787,7 @@ export const host = { wakePhase = 'hydration' - if (generation !== openSessionGeneration) { + if (!openingStillCurrent()) { throw new Error('Session open was superseded by a newer selection.') } @@ -756,11 +815,6 @@ export const host = { const intent = options.intent ?? 'in-place' if (options.workspaceMode === 'bots') { - publishWorkspaceScope( - 'bots', - options.workspaceOwnerKey ?? null, - ownerRoute ? { kind: 'route', route: ownerRoute } : null - ) openSession(storedSessionId, navigate, intent, { workspaceMode: 'bots', workspaceOwnerKey: options.workspaceOwnerKey @@ -790,9 +844,9 @@ export const host = { if (options.awaitHydration) { await waitForFocusedSessionHydration({ - connectionId: ownerRoute?.connectionId, expectHistory, generation, + isCurrent: openingStillCurrent, profile: targetProfile, // A background dial never moves $activeGatewayProfile, so gating // hydration on it would wait for something that is not coming. @@ -815,6 +869,10 @@ export const host = { throw error } + if (ownerRoute && !(await pluginRouteStillRegistered(ownerRoute))) { + throw new Error(`The ${targetProfile} gateway is no longer available.`) + } + // Logged per attempt so a support bundle shows the retry happened at // all; the terminal failure is reported once by the catch below. console.warn('[bot-wake] hydration timed out, retrying', { @@ -828,7 +886,7 @@ export const host = { } catch (error) { if ( options.awaitHydration && - generation === openSessionGeneration && + openingStillCurrent() && error instanceof Error && error.message.startsWith('Timed out loading ') ) { diff --git a/apps/desktop/src/sdk/profile-routing.test.ts b/apps/desktop/src/sdk/profile-routing.test.ts index fd6f14e6ecf7..556bb887b320 100644 --- a/apps/desktop/src/sdk/profile-routing.test.ts +++ b/apps/desktop/src/sdk/profile-routing.test.ts @@ -123,6 +123,7 @@ const { } = await import('@/store/profile') const { $focusedRuntimeId, $focusedSessionState, $focusedStoredSessionId } = await import('@/store/session-states') +const { setWorkspaceScope } = await import('@/components/pane-shell/workspace-scope') const { $activeSessionId, @@ -156,6 +157,7 @@ afterEach(() => { setMockAtom($selectedStoredSessionId, null) setMockAtom($messages, []) $profiles.set([profile('cached-only')]) + setWorkspaceScope('sessions') delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop }) @@ -433,7 +435,98 @@ describe('profile-aware plugin session opens', () => { }) }) - it('waits until the target Bot Chat runtime and history are on main before resolving', async () => { + it('finishes hydration on the focused Bot tile without moving the Sessions gateway', async () => { + const route = { + connectionId: 'source-a', + mode: 'remote' as const, + profile: 'default', + targetProfile: 'backend-default' + } + + const opening = host.openSession('bot-chat', { + awaitHydration: true, + expectHistory: true, + hydrationTimeoutMs: 1_000, + intent: 'tab', + route, + workspaceMode: 'bots', + workspaceOwnerKey: 'bot:source-a::default' + }) + + await Promise.resolve() + setMockAtom($focusedStoredSessionId, 'bot-chat') + setMockAtom($focusedRuntimeId, 'runtime-bot-chat') + setMockAtom($focusedSessionState, { + messages: [{ id: 'bot-history', parts: [], role: 'assistant' }], + storedSessionId: 'bot-chat' + } as never) + + await opening + expect($activeGatewayProfile.get()).toBe('remote-worker') + expect($selectedStoredSessionId.get()).toBeNull() + }) + + it('strands a late Bot wake when the user returns to Sessions', async () => { + let releaseDial: (() => void) | undefined + + vi.mocked(openGatewayForAgent).mockImplementationOnce( + () => + new Promise(resolve => { + releaseDial = resolve + }) + ) + + const opening = host.openSession('late-bot-chat', { + awaitHydration: true, + expectHistory: true, + hydrationTimeoutMs: 1_000, + intent: 'tab', + route: { + connectionId: 'source-a', + mode: 'remote', + profile: 'writer', + targetProfile: 'writer' + }, + workspaceMode: 'bots', + workspaceOwnerKey: 'bot:source-a::writer' + }) + + setWorkspaceScope('sessions') + releaseDial?.() + + await expect(opening).rejects.toThrow(/superseded/i) + expect(openSessionCore).not.toHaveBeenCalled() + expect(setResumeExhaustedSessionId).not.toHaveBeenCalled() + }) + + it('revalidates an exact route before the one allowed hydration retry', async () => { + ;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = { + getProfileRoutes: vi.fn(async () => []) + } + + await expect( + host.openSession('removed-owner-chat', { + awaitHydration: true, + expectHistory: true, + hydrationTimeoutMs: 1, + intent: 'tab', + retryHydrationTimeoutOnce: true, + route: { + connectionId: 'removed-source', + mode: 'remote', + profile: 'writer', + targetProfile: 'writer' + }, + workspaceMode: 'bots', + workspaceOwnerKey: 'bot:removed-source::writer' + }) + ).rejects.toThrow(/no longer available/i) + + expect(openSessionCore).toHaveBeenCalledTimes(1) + expect(setResumeExhaustedSessionId).not.toHaveBeenCalled() + }) + + it('waits until the target Bot Chat runtime and history are on the focused surface', async () => { vi.mocked(openGatewayForProfile).mockImplementationOnce(async () => undefined) let resolved = false @@ -467,18 +560,6 @@ describe('profile-aware plugin session opens', () => { storedSessionId: 'bot-chat' } as never) - await Promise.resolve() - expect(resolved).toBe(false) - - setMockAtom($selectedStoredSessionId, 'bot-chat') - setMockAtom($activeSessionId, 'runtime-hyoseob') - setMockAtom($messages, []) - - await Promise.resolve() - expect(resolved).toBe(false) - - setMockAtom($messages, [{ id: 'history-1', parts: [], role: 'user' }] as never) - await opening expect(resolved).toBe(true) expect($gatewaySwapTarget.get()).toBeNull() From 6f4ebaa11544f80efc0ed623f0ce5c1bd40827fb Mon Sep 17 00:00:00 2001 From: David Dudok de Wit <5354424+dokterdok@users.noreply.github.com> Date: Sun, 23 Aug 2026 03:05:00 +0200 Subject: [PATCH 06/10] fix(desktop): preserve Bot tabs across owner lifecycles --- apps/desktop/src/app/chat/session-tile.tsx | 38 +++-- .../hooks/use-session-tile-delegate.test.ts | 58 +++++++- .../hooks/use-session-tile-delegate.ts | 72 +++++++-- apps/desktop/src/app/contrib/wiring.tsx | 6 +- apps/desktop/src/app/open-session.test.ts | 5 +- apps/desktop/src/app/open-session.ts | 9 +- .../desktop/src/plugins/hermes-bots/plugin.js | 44 +++--- .../hermes-bots/tests/bots-home.test.mjs | 3 +- .../tests/canonical-chat-registry.test.mjs | 8 +- apps/desktop/src/sdk/index.ts | 30 ++-- apps/desktop/src/sdk/profile-routing.test.ts | 45 +++++- apps/desktop/src/store/session-states.test.ts | 69 ++++++++- apps/desktop/src/store/session-states.ts | 140 ++++++++++++++---- 13 files changed, 431 insertions(+), 96 deletions(-) diff --git a/apps/desktop/src/app/chat/session-tile.tsx b/apps/desktop/src/app/chat/session-tile.tsx index 2adcc048a331..4a234022dff3 100644 --- a/apps/desktop/src/app/chat/session-tile.tsx +++ b/apps/desktop/src/app/chat/session-tile.tsx @@ -47,14 +47,17 @@ import { sessionMatchesStoredId, sessionPinId } from '@/store/session' +import { requestForSessionProfile } from '@/store/session-request-router' import { $sessionStates, + $sessionTileDelegateRevision, $sessionTiles, closeSessionTile, discardSessionTile, patchSessionTile, type SessionTile, - sessionTileDelegate + sessionTileDelegate, + sessionTileOwnerRoute } from '@/store/session-states' import type { SessionInfo } from '@/types/hermes' @@ -135,7 +138,15 @@ function TileChat({ }) { const { gateway, requestGateway } = useGatewayRequest() const queryClient = useQueryClient() - const { selectModel } = useModelControls({ queryClient, requestGateway }) + const ownerRoute = sessionTileOwnerRoute(storedSessionId) + + const requestTileGateway = useCallback( + (method: string, params?: Record, timeoutMs?: number, signal?: AbortSignal): Promise => + requestForSessionProfile(ownerRoute, requestGateway, method, params, timeoutMs, signal), + [ownerRoute, requestGateway] + ) + + const { selectModel } = useModelControls({ queryClient, requestGateway: requestTileGateway }) const activeGatewayProfile = useStore($activeGatewayProfile) const cwd = useStore(view.$cwd) const gatewayOpen = useStore($gatewayState) === 'open' @@ -160,7 +171,7 @@ function TileChat({ const composer = useComposerActions({ activeSessionId: runtimeId, currentCwd: cwd, - requestGateway, + requestGateway: requestTileGateway, scope: { add: attachments.add, remove: attachments.remove, @@ -201,11 +212,11 @@ function TileChat({ ) : null, - [activeGatewayProfile, gateway, gatewayOpen, requestGateway, selectModel] + [activeGatewayProfile, gateway, gatewayOpen, ownerRoute?.profile, requestTileGateway, selectModel] ) return ( @@ -247,6 +258,7 @@ export function SessionTilePane({ storedSessionId }: { storedSessionId: string } const tile = tiles.find(t => t.storedSessionId === storedSessionId) const runtimeId = tile?.runtimeId ?? null const gatewayOpen = useStore($gatewayState) === 'open' + const delegateRevision = useStore($sessionTileDelegateRevision) const resumingRef = useRef(false) const view = useMemo(() => buildTileView(storedSessionId), [storedSessionId]) @@ -333,7 +345,7 @@ export function SessionTilePane({ storedSessionId }: { storedSessionId: string } .finally(() => { resumingRef.current = false }) - }, [gatewayOpen, runtimeId, storedSessionId, tile?.error]) + }, [delegateRevision, gatewayOpen, runtimeId, storedSessionId, tile?.error]) // The gateway (re)opening invalidates any latched error — it likely came // from a not-yet-open gateway or the previous connection. Clearing it @@ -401,15 +413,17 @@ export function tileStoredRow(storedSessionId: string): SessionInfo | undefined * skipping the re-register that hands the tab back to this string. */ function tileTitle(storedSessionId: string): string { const stored = tileStoredRow(storedSessionId) + const explicit = $sessionTiles.get().find(tile => tile.storedSessionId === storedSessionId)?.workspaceTabTitle - return stored ? sessionTitle(stored) : NEW_SESSION_TITLE + return stored ? sessionTitle(stored) : explicit || NEW_SESSION_TITLE } /** The `@session` link payload for a tile tab drag — id + owning profile + title. * Resolved at drag time, so an unsent tab drags under its draft name. */ function tileDragPayload(storedSessionId: string): SessionDragPayload { const stored = tileStoredRow(storedSessionId) - const title = stored ? sessionTitle(stored) : draftTitleFor(storedSessionId) || NEW_SESSION_TITLE + const explicit = $sessionTiles.get().find(tile => tile.storedSessionId === storedSessionId)?.workspaceTabTitle + const title = stored ? sessionTitle(stored) : explicit || draftTitleFor(storedSessionId) || NEW_SESSION_TITLE return { id: storedSessionId, profile: stored?.profile ?? '', title } } @@ -617,7 +631,11 @@ export const watchSessionTiles = paneMirror({ ), // Until the first turn lists a row there is no title to register, so the tab // takes its name from the composer instead — live, without re-registering. - tabTitle: storedSessionId => (tileStoredRow(storedSessionId) ? null : ), + tabTitle: storedSessionId => + tileStoredRow(storedSessionId) || + $sessionTiles.get().some(tile => tile.storedSessionId === storedSessionId && tile.workspaceTabTitle) ? null : ( + + ), render: storedSessionId => , tabWrap: (storedSessionId, tab) => ( ({ ...(await importActual()), getLatestSessionMessages: vi.fn(async () => ({ messages: [], session_id: '' })) })) +vi.mock('@/store/gateway', async importActual => ({ + ...(await importActual>()), + requestGatewayForAgent: vi.fn(), + requestGatewayForProfile: vi.fn() +})) const { getLatestSessionMessages } = await import('@/hermes') +const { requestGatewayForAgent, requestGatewayForProfile } = await import('@/store/gateway') const row = (over: Partial): SessionInfo => ({ @@ -76,17 +82,26 @@ describe('useSessionTileDelegate resumeTile', () => { method === 'session.resume' ? ({ session_id: 'runtime-1' } as never) : ({} as never) ) + vi.mocked(requestGatewayForProfile).mockResolvedValueOnce({ session_id: 'runtime-1' } as never) + renderTile(requestGateway) const runtimeId = await sessionTileDelegate()!.resumeTile('stored-x') expect(runtimeId).toBe('runtime-1') expect(getLatestSessionMessages).toHaveBeenCalledWith('stored-x', 'ai-engineer') - expect(requestGateway).toHaveBeenCalledWith('session.resume', { - session_id: 'stored-x', - cols: 96, - profile: 'ai-engineer', - omit_messages: true - }) + expect(requestGatewayForProfile).toHaveBeenCalledWith( + 'ai-engineer', + 'session.resume', + { + session_id: 'stored-x', + cols: 96, + profile: 'ai-engineer', + omit_messages: true + }, + undefined, + undefined + ) + expect(requestGateway).not.toHaveBeenCalled() }) it('resolves and carries a default-profile session explicitly', async () => { @@ -107,6 +122,35 @@ describe('useSessionTileDelegate resumeTile', () => { }) }) + it('routes a Bot tile prefetch and resume through its exact connection owner', async () => { + const route = { + connectionId: 'barry', + mode: 'remote' as const, + profile: 'oxcoder', + targetProfile: 'backend-oxcoder' + } + + setSessionOwnerHint('stored-remote', route) + vi.mocked(requestGatewayForAgent).mockResolvedValueOnce({ session_id: 'runtime-remote' } as never) + const ambientRequest = vi.fn(async () => ({}) as never) + + renderTile(ambientRequest) + const runtimeId = await sessionTileDelegate()!.resumeTile('stored-remote') + + expect(runtimeId).toBe('runtime-remote') + expect(getLatestSessionMessages).toHaveBeenCalledWith('stored-remote', { + connectionId: 'barry', + profile: 'backend-oxcoder' + }) + expect(requestGatewayForAgent).toHaveBeenCalledWith('barry', 'oxcoder', 'session.resume', { + session_id: 'stored-remote', + cols: 96, + omit_messages: true, + profile: 'backend-oxcoder' + }) + expect(ambientRequest).not.toHaveBeenCalled() + }) + it('reuses a warm binding that still carries a transcript', async () => { const stateA = { busy: false, messages: [{ id: 'm1' }], storedSessionId: 'stored-a' } const runtimeIdByStoredSessionIdRef = { current: new Map([['stored-a', 'runtime-a']]) } diff --git a/apps/desktop/src/app/contrib/hooks/use-session-tile-delegate.ts b/apps/desktop/src/app/contrib/hooks/use-session-tile-delegate.ts index ce4783cb7abb..4eaf3d14d904 100644 --- a/apps/desktop/src/app/contrib/hooks/use-session-tile-delegate.ts +++ b/apps/desktop/src/app/contrib/hooks/use-session-tile-delegate.ts @@ -2,7 +2,9 @@ import { useEffect } from 'react' import { getLatestSessionMessages, PROMPT_SUBMIT_REQUEST_TIMEOUT_MS } from '@/hermes' import { toChatMessages } from '@/lib/chat-messages' -import { publishSessionState, setSessionTileDelegate } from '@/store/session-states' +import { getSessionOwnerHint } from '@/store/session' +import { requestForSessionProfile, type SessionOwnerScope } from '@/store/session-request-router' +import { publishSessionState, sessionTileOwnerRoute, setSessionTileDelegate } from '@/store/session-states' import type { SessionResumeResponse } from '@/types/hermes' import type { usePromptActions } from '../../session/hooks/use-prompt-actions' @@ -71,6 +73,26 @@ export function useSessionTileDelegate({ } } + const ownerForStoredSession = async (storedSessionId: string): Promise => { + const owner = + getSessionOwnerHint(storedSessionId) ?? + sessionTileOwnerRoute(storedSessionId) ?? + (await resolveSessionProfile(storedSessionId)) + + return owner + } + + const requestForStoredSession = async ( + storedSessionId: string, + method: string, + params: Record, + timeoutMs?: number + ): Promise => { + const owner = await ownerForStoredSession(storedSessionId) + + return requestForSessionProfile(owner, requestGateway, method, params, timeoutMs) + } + setSessionTileDelegate({ archiveSession: async storedSessionId => { await archiveSession(storedSessionId) @@ -89,8 +111,12 @@ export function useSessionTileDelegate({ // backend no longer knows. Drop the map so resumeTile's warm path can't // re-bind a tile to a dead runtime; live bindings re-record from // post-reconnect events and fresh resumes. - invalidateRuntimeBindings: () => { - runtimeIdByStoredSessionIdRef.current.clear() + invalidateRuntimeBindings: preserveStoredSessionIds => { + for (const storedSessionId of runtimeIdByStoredSessionIdRef.current.keys()) { + if (!preserveStoredSessionIds?.has(storedSessionId)) { + runtimeIdByStoredSessionIdRef.current.delete(storedSessionId) + } + } }, interruptSession: async runtimeId => { // Same cooldown as the primary chat's Stop (#83855): the gateway may @@ -99,12 +125,20 @@ export function useSessionTileDelegate({ // false. Mark the runtime id (and any recovered id) before the RPC so // the window covers the whole wind-down. markSessionRecentlyInterrupted(runtimeId) + + const storedSessionId = storedSessionIdForRuntime(runtimeId) + + const routedRequest = storedSessionId + ? (method: string, params?: Record, timeoutMs?: number) => + requestForStoredSession(storedSessionId, method, params ?? {}, timeoutMs) + : requestGateway + await withSessionNotFoundResume( runtimeId, - storedSessionIdForRuntime(runtimeId), - liveId => requestGateway('session.interrupt', { session_id: liveId }), + storedSessionId, + liveId => routedRequest('session.interrupt', { session_id: liveId }), { - requestGateway, + requestGateway: routedRequest, onRecovered: recoveredId => { markSessionRecentlyInterrupted(recoveredId) rebindTileRuntime(runtimeId)(recoveredId) @@ -133,15 +167,20 @@ export function useSessionTileDelegate({ // reading messages) without a profile lets the gateway fall back to the // launch-profile DB and fork the conversation into the wrong profile — // the same cross-profile bleed the recovery resumes had (#67603). - const profile = await resolveSessionProfile(storedSessionId) + const owner = await ownerForStoredSession(storedSessionId) + + const restScope = + owner && typeof owner === 'object' + ? { connectionId: owner.connectionId, profile: owner.targetProfile || owner.profile } + : owner const [prefetch, resumed] = await Promise.all([ - getLatestSessionMessages(storedSessionId, profile).catch(() => null), - requestGateway('session.resume', { + getLatestSessionMessages(storedSessionId, restScope).catch(() => null), + requestForSessionProfile(owner, requestGateway, 'session.resume', { session_id: storedSessionId, cols: 96, omit_messages: true, - ...(profile ? { profile } : {}) + ...(owner ? { profile: typeof owner === 'string' ? owner : owner.profile } : {}) }) ]) @@ -165,11 +204,18 @@ export function useSessionTileDelegate({ return runtimeId }, submitToSession: async (runtimeId, text) => { + const storedSessionId = storedSessionIdForRuntime(runtimeId) + + const routedRequest = storedSessionId + ? (method: string, params?: Record, timeoutMs?: number) => + requestForStoredSession(storedSessionId, method, params ?? {}, timeoutMs) + : requestGateway + await withSessionNotFoundResume( runtimeId, - storedSessionIdForRuntime(runtimeId), - liveId => requestGateway('prompt.submit', { session_id: liveId, text }, PROMPT_SUBMIT_REQUEST_TIMEOUT_MS), - { requestGateway, onRecovered: rebindTileRuntime(runtimeId) } + storedSessionId, + liveId => routedRequest('prompt.submit', { session_id: liveId, text }, PROMPT_SUBMIT_REQUEST_TIMEOUT_MS), + { requestGateway: routedRequest, onRecovered: rebindTileRuntime(runtimeId) } ) }, updateSession: (runtimeId, updater) => updateSessionState(runtimeId, updater) diff --git a/apps/desktop/src/app/contrib/wiring.tsx b/apps/desktop/src/app/contrib/wiring.tsx index 7a6e557f7ce8..e5b7dbdcc26b 100644 --- a/apps/desktop/src/app/contrib/wiring.tsx +++ b/apps/desktop/src/app/contrib/wiring.tsx @@ -890,7 +890,11 @@ export function ContribWiring({ children }: { children: ReactNode }) { void openNewSessionTile('center', { listed: false, route: workspaceNewSessionTarget.route, - workspaceScope: { workspaceMode: 'bots', workspaceOwnerKey } + workspaceScope: { + ownerRoute: workspaceNewSessionTarget.route, + workspaceMode: 'bots', + workspaceOwnerKey + } }) return diff --git a/apps/desktop/src/app/open-session.test.ts b/apps/desktop/src/app/open-session.test.ts index d2d8339f324d..4a3f95d7df0e 100644 --- a/apps/desktop/src/app/open-session.test.ts +++ b/apps/desktop/src/app/open-session.test.ts @@ -99,7 +99,7 @@ describe('openSession', () => { it('in-place focuses an existing tile and does not navigate', () => { focusOpenSession.mockReturnValue('tile') openSession('s1', navigate) - expect(focusOpenSession).toHaveBeenCalledWith('s1') + expect(focusOpenSession).toHaveBeenCalledWith('s1', { workspaceMode: 'sessions' }) expect(navigate).not.toHaveBeenCalled() expect(openSessionTile).not.toHaveBeenCalled() }) @@ -134,7 +134,7 @@ describe('openSession', () => { it('tab focuses an existing open session instead of stacking another', () => { focusOpenSession.mockReturnValue('tile') openSession('s1', navigate, 'tab') - expect(focusOpenSession).toHaveBeenCalledWith('s1') + expect(focusOpenSession).toHaveBeenCalledWith('s1', { workspaceMode: 'sessions' }) expect(openSessionTile).not.toHaveBeenCalled() expect(navigate).not.toHaveBeenCalled() }) @@ -153,6 +153,7 @@ describe('openSession', () => { openSession('s1', navigate, 'tab', scope) expect(setSessionTileWorkspaceScope).toHaveBeenCalledWith('s1', scope) + expect(focusOpenSession).toHaveBeenCalledWith('s1', scope) expect(openSessionTile).toHaveBeenCalledWith('s1', 'center', undefined, undefined, scope) }) diff --git a/apps/desktop/src/app/open-session.ts b/apps/desktop/src/app/open-session.ts index 0781cf001383..e76a9face204 100644 --- a/apps/desktop/src/app/open-session.ts +++ b/apps/desktop/src/app/open-session.ts @@ -16,6 +16,7 @@ */ import type { WorkspaceMode } from '@/contrib/types' import { $activeSessionId, $selectedStoredSessionId, markSessionRead } from '@/store/session' +import type { SessionProfileRoute } from '@/store/session-request-router' import { focusedSessionNeedsRoute, focusOpenSession, @@ -32,8 +33,10 @@ export type OpenSessionIntent = 'in-place' | 'main' | 'stack' | 'tab' | 'window' export type OpenSessionNavigate = (to: string, options?: { replace?: boolean }) => void export interface OpenSessionWorkspaceScope { + ownerRoute?: SessionProfileRoute workspaceMode: WorkspaceMode workspaceOwnerKey?: string + workspaceTabTitle?: string } /** @@ -131,7 +134,9 @@ export function openSession( // Already on screen? Front it. openSessionTile would no-op on main without // focusing, or try to relocate an existing tile — neither is right for a // soft "open beside" link. - if (focusOpenSession(storedSessionId)) { + const focused = focusOpenSession(storedSessionId, workspaceScope) + + if (focused) { return } @@ -160,7 +165,7 @@ export function openSession( // otherwise load it into main. From a full page (artifacts, skills, …) a // `'main'` hit still has to route back: fronting the workspace tab alone // leaves the page showing. - if (focusedSessionNeedsRoute(focusOpenSession(storedSessionId), $workspaceIsPage.get())) { + if (focusedSessionNeedsRoute(focusOpenSession(storedSessionId, workspaceScope), $workspaceIsPage.get())) { navigate(sessionRoute(storedSessionId)) } } diff --git a/apps/desktop/src/plugins/hermes-bots/plugin.js b/apps/desktop/src/plugins/hermes-bots/plugin.js index a49c7ccd5e95..0c97b25a4be7 100644 --- a/apps/desktop/src/plugins/hermes-bots/plugin.js +++ b/apps/desktop/src/plugins/hermes-bots/plugin.js @@ -5058,7 +5058,8 @@ async function openStoredBotChat(owner, storedId, summary) { throw new Error('This Hermes Desktop version cannot open stored sessions') } - const { name, route } = botOwner(owner) + const { bot, name, route } = botOwner(owner) + const ownerKey = botWorkspaceOwnerKey(bot) const hasAuthoritativeCount = typeof summary?.message_count === 'number' && Number.isFinite(summary.message_count) @@ -5073,23 +5074,14 @@ async function openStoredBotChat(owner, storedId, summary) { await host.openSession(storedId, { ...(route ? { route } : {}), profile: name, - intent: 'main', + intent: 'tab', awaitHydration: true, expectHistory, - // Move the WORKSPACE onto this bot, not just the transcript. - // - // With the default (true) the bot's chat opened against its own backend - // while `$activeGatewayProfile` stayed on whatever profile was active - // before — so "New session" from inside any bot was created on that other - // backend. Measured: four consecutive new chats started from different - // bots all landed in the `ops` profile's state.db. Clicking a bot is a - // workspace switch in this product (one bot = one workspace), so the - // chrome has to follow. EXCEPT across connections: a remote bot's chat - // opens on its own source while Desktop's chrome/API home stays put — - // re-homing the window onto another machine for one chat is the bug - // #90006 exists to remove. - keepAllProfilesScope: route ? true : false, - retryHydrationTimeoutOnce: true + keepAllProfilesScope: true, + workspaceMode: 'bots', + workspaceOwnerKey: ownerKey, + retryHydrationTimeoutOnce: true, + tabTitle: CANONICAL_CHAT_TITLE }) return storedId @@ -13110,9 +13102,15 @@ function BotsPane() { return () => cancelAnimationFrame(frame) }, [hiddenExpanded, hasRosterConstraint]) - if (live) { + useEffect(() => { + if (!live) { + return + } + // Offline-owner ghosts belong only to this render. Shared roster state - // feeds merge caching, group membership, creation, and durable sync. + // feeds merge caching, group membership, creation, and durable sync. These + // writes must settle after render: BotsHomeView subscribes to the same + // atoms, so publishing here used to update it while BotsPane was rendering. $lastRoster.set(roster.filter(row => !row?.ghost)) if (Array.isArray(data?.sources)) { $lastSources.set(data.sources) @@ -13121,7 +13119,10 @@ function BotsPane() { pullServerAvatars(activeSourceRoster) trackInboundActivity(roster) backfillMessagingProtocol(activeSourceRoster) - } + // React Query owns the stable server snapshot; derived arrays intentionally + // follow that snapshot rather than retriggering on their own atom writes. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [data]) // The roster has ANSWERED once data or a terminal error exists — that, not // row count, is what lets the home stop showing its loading state (an empty @@ -13137,6 +13138,11 @@ function BotsPane() { if (selectionHydrated) { reconcileRosterSelection(roster, sourceSnapshot, allMeta) + const selected = selectedRosterBot(roster, $selectedRosterKey.get()) + + if ($botsPaneVisible.get() && !$groupChatWorkspace.get() && selected) { + setBotsWorkspaceOwner(botWorkspaceOwnerKey(selected), selected) + } } }, [data, error, selectionHydrated, roster, sourceSnapshot, allMeta]) diff --git a/apps/desktop/src/plugins/hermes-bots/tests/bots-home.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/bots-home.test.mjs index f3be856978eb..411b4255fcc9 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/bots-home.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/bots-home.test.mjs @@ -454,6 +454,7 @@ test('opening a remote bot selects and opens its exact owner chat', async () => assert.equal(t.sessionOpens.length, 1) assert.equal(t.sessionOpens[0].options.workspaceMode, 'bots') assert.equal(t.sessionOpens[0].options.workspaceOwnerKey, 'bot:work-vps::researcher') + assert.equal(t.sessionOpens[0].options.tabTitle, 'Bot Chat') assert.equal(t.sessionOpens[0].options.keepAllProfilesScope, true) assert.equal(t.sessionOpens[0].options.route.connectionId, 'work-vps') assert.ok(t.requests.every(request => request.route?.connectionId === 'work-vps')) @@ -940,7 +941,7 @@ test('roster hydration and selection reconciliation run after render', () => { assert.match( pane, - /useEffect\(\(\) => \{[\s\S]{0,500}?\$rosterHydrated\.set\(true\)[\s\S]{0,300}?reconcileRosterSelection\(roster, sourceSnapshot, allMeta\)[\s\S]{0,220}?\}, \[data, error, selectionHydrated, roster, sourceSnapshot, allMeta\]\)/, + /useEffect\(\(\) => \{[\s\S]{0,500}?\$rosterHydrated\.set\(true\)[\s\S]{0,300}?reconcileRosterSelection\(roster, sourceSnapshot, allMeta\)[\s\S]{0,700}?\}, \[data, error, selectionHydrated, roster, sourceSnapshot, allMeta\]\)/, 'persisted roster ownership must reconcile from an effect, never from a replayable render' ) assert.equal( diff --git a/apps/desktop/src/plugins/hermes-bots/tests/canonical-chat-registry.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/canonical-chat-registry.test.mjs index 1fb3ad1edff7..331185ecf96a 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/canonical-chat-registry.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/canonical-chat-registry.test.mjs @@ -80,8 +80,12 @@ test('open resolves the profile\u2019s "Bot Chat" row by exact title and opens i assert.equal(runtime.opened.length, 1) assert.equal(runtime.opened[0].id, 'forever-chat') assert.equal(runtime.opened[0].options.profile, 'ops') - assert.equal(runtime.opened[0].options.keepAllProfilesScope, false, - 'opening a bot moves the workspace onto that bot') + assert.equal(runtime.opened[0].options.keepAllProfilesScope, true, + 'opening a bot leaves the Sessions workspace on its current gateway') + assert.equal(runtime.opened[0].options.intent, 'tab') + assert.equal(runtime.opened[0].options.workspaceMode, 'bots') + assert.equal(runtime.opened[0].options.workspaceOwnerKey, 'bot:ops') + assert.equal(runtime.opened[0].options.tabTitle, 'Bot Chat') const list = runtime.requests.find(r => r.method === 'session.list') assert.equal(list?.params?.title, 'Bot Chat', 'lookup is by exact title') diff --git a/apps/desktop/src/sdk/index.ts b/apps/desktop/src/sdk/index.ts index 1510f14d6120..5f196bbbc396 100644 --- a/apps/desktop/src/sdk/index.ts +++ b/apps/desktop/src/sdk/index.ts @@ -86,7 +86,8 @@ import { $focusedRuntimeId, $focusedSessionState, $focusedStoredSessionId, - $sessionStates + $sessionStates, + $sessionTiles } from '@/store/session-states' import { runGatewayRestart } from '@/store/system-actions' import type { UsageStats } from '@/types/hermes' @@ -141,7 +142,6 @@ const $focusedSessionOwner = computed( [$focusedStoredSessionId, $sessions, $activeGatewayProfile, $connection], (focused, sessions, activeProfile, connection): PluginFocusedSessionOwner | null => { const activeConnectionId = String(connection?.connectionId || (connection?.mode === 'local' ? 'local' : '')).trim() - const fallback = { connectionId: activeConnectionId, profile: normalizeProfileKey(activeProfile) @@ -326,6 +326,7 @@ export interface PluginOpenSessionOptions { * overlay ($resumeExhaustedSessionId) — a caller-side retry can't do this * itself because only this SDK layer sees $resumeExhaustedSessionId. */ retryHydrationTimeoutOnce?: boolean + tabTitle?: string } export interface PluginNewChatOptions { @@ -386,18 +387,23 @@ function waitForFocusedSessionHydration({ const profileMatches = !requireActiveProfile || normalizeProfileKey($activeGatewayProfile.get()) === profile const mainMatches = $selectedStoredSessionId.get() === storedSessionId - const tileMatches = $focusedStoredSessionId.get() === storedSessionId + const storedTile = $sessionTiles.get().find(tile => tile.storedSessionId === storedSessionId) + const tileMatches = $focusedStoredSessionId.get() === storedSessionId || Boolean(storedTile) + const focusedTileMatches = $focusedStoredSessionId.get() === storedSessionId + const tileRuntimeId = focusedTileMatches ? $focusedRuntimeId.get() : (storedTile?.runtimeId ?? null) - const runtimeReady = mainMatches - ? Boolean($activeSessionId.get()) - : tileMatches - ? Boolean($focusedRuntimeId.get()) - : false + const tileState = focusedTileMatches + ? $focusedSessionState.get() + : tileRuntimeId + ? $sessionStates.get()[tileRuntimeId] + : undefined + + const runtimeReady = mainMatches ? Boolean($activeSessionId.get()) : tileMatches ? Boolean(tileRuntimeId) : false const historyPainted = mainMatches ? Boolean($messages.get().length) : tileMatches - ? Boolean($focusedSessionState.get()?.messages.length) + ? Boolean(tileState?.messages.length) : false // Paint-first hydration: for a history-bearing chat, the wake is DONE @@ -427,6 +433,8 @@ function waitForFocusedSessionHydration({ unbinds.push($focusedStoredSessionId.listen(check)) unbinds.push($focusedRuntimeId.listen(check)) unbinds.push($focusedSessionState.listen(check)) + unbinds.push($sessionTiles.listen(check)) + unbinds.push($sessionStates.listen(check)) unbinds.push($workspaceMode.listen(check)) unbinds.push($workspaceOwnerKey.listen(check)) @@ -816,8 +824,10 @@ export const host = { if (options.workspaceMode === 'bots') { openSession(storedSessionId, navigate, intent, { + ownerRoute: ownerRoute ?? undefined, workspaceMode: 'bots', - workspaceOwnerKey: options.workspaceOwnerKey + workspaceOwnerKey: options.workspaceOwnerKey, + ...(options.tabTitle ? { workspaceTabTitle: options.tabTitle } : {}) }) } else { openSession(storedSessionId, navigate, intent) diff --git a/apps/desktop/src/sdk/profile-routing.test.ts b/apps/desktop/src/sdk/profile-routing.test.ts index 556bb887b320..129231aadab5 100644 --- a/apps/desktop/src/sdk/profile-routing.test.ts +++ b/apps/desktop/src/sdk/profile-routing.test.ts @@ -43,6 +43,7 @@ vi.mock('@/store/session-states', async () => { $focusedRuntimeId: atom(null), $focusedSessionState: atom(null), $focusedStoredSessionId: atom(null), + $sessionTiles: atom([]), $sessionStates: atom({}) } }) @@ -122,7 +123,8 @@ const { setShowAllProfiles } = await import('@/store/profile') -const { $focusedRuntimeId, $focusedSessionState, $focusedStoredSessionId } = await import('@/store/session-states') +const { $focusedRuntimeId, $focusedSessionState, $focusedStoredSessionId, $sessionStates, $sessionTiles } = + await import('@/store/session-states') const { setWorkspaceScope } = await import('@/components/pane-shell/workspace-scope') const { @@ -153,6 +155,8 @@ afterEach(() => { setMockAtom($focusedRuntimeId, null) setMockAtom($focusedStoredSessionId, null) setMockAtom($focusedSessionState, null) + setMockAtom($sessionStates, {}) + setMockAtom($sessionTiles, []) setMockAtom($activeSessionId, null) setMockAtom($selectedStoredSessionId, null) setMockAtom($messages, []) @@ -430,6 +434,7 @@ describe('profile-aware plugin session opens', () => { }) expect(openSessionCore).toHaveBeenCalledWith('bot-chat', expect.any(Function), 'in-place', { + ownerRoute: route, workspaceMode: 'bots', workspaceOwnerKey: 'source-a::default' }) @@ -466,6 +471,44 @@ describe('profile-aware plugin session opens', () => { expect($selectedStoredSessionId.get()).toBeNull() }) + it('finishes a restored Bot wake from its hydrated tile before the first pointer focus', async () => { + const route = { + connectionId: 'source-a', + mode: 'remote' as const, + profile: 'default', + targetProfile: 'backend-default' + } + + const opening = host.openSession('restored-bot-chat', { + awaitHydration: true, + expectHistory: true, + hydrationTimeoutMs: 1_000, + intent: 'tab', + route, + workspaceMode: 'bots', + workspaceOwnerKey: 'bot:source-a::default' + }) + + await Promise.resolve() + setMockAtom($sessionTiles, [ + { + runtimeId: 'runtime-restored', + storedSessionId: 'restored-bot-chat', + workspaceMode: 'bots', + workspaceOwnerKey: 'bot:source-a::default' + } + ]) + setMockAtom($sessionStates, { + 'runtime-restored': { + messages: [{ id: 'restored-history', parts: [], role: 'assistant' }], + storedSessionId: 'restored-bot-chat' + } + } as never) + + await opening + expect($focusedStoredSessionId.get()).toBeNull() + }) + it('strands a late Bot wake when the user returns to Sessions', async () => { let releaseDial: (() => void) | undefined diff --git a/apps/desktop/src/store/session-states.test.ts b/apps/desktop/src/store/session-states.test.ts index 3cf175250746..6eff590dbf7e 100644 --- a/apps/desktop/src/store/session-states.test.ts +++ b/apps/desktop/src/store/session-states.test.ts @@ -3,6 +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 } from '@/components/pane-shell/tree/store' +import { $activeGatewayProfile } from '@/store/profile' import { $selectedStoredSessionId } from '@/store/session' import type { SessionTile } from '@/store/session-states' import { @@ -10,6 +11,7 @@ import { $sessionTiles, blankDraftTile, focusedSessionNeedsRoute, + focusOpenSession, markSelectionRestore, nextSessionTileForWorkspace, openSessionTile, @@ -54,17 +56,49 @@ describe('resetTileRuntimeBindings', () => { expect(() => resetTileRuntimeBindings()).not.toThrow() expect($sessionTiles.get()[0]?.runtimeId).toBeUndefined() }) + + it('keeps exact-owner Bot runtimes when only the primary gateway reconnects', () => { + const invalidateRuntimeBindings = vi.fn() + setSessionTileDelegate({ invalidateRuntimeBindings } as unknown as SessionTileDelegate) + $sessionTiles.set([ + { + ownerRoute: { + connectionId: 'barry', + mode: 'remote', + profile: 'oxcoder', + targetProfile: 'oxcoder' + }, + runtimeId: 'runtime-bot', + storedSessionId: 'stored-bot', + workspaceMode: 'bots', + workspaceOwnerKey: 'bot:barry::oxcoder' + } + ]) + + resetTileRuntimeBindings() + + expect($sessionTiles.get()[0]?.runtimeId).toBe('runtime-bot') + expect(invalidateRuntimeBindings).toHaveBeenCalledWith(new Set(['stored-bot'])) + }) }) describe('SessionTile workspace scope', () => { afterEach(() => { + $activeGatewayProfile.set('default') $layoutTree.set(null) $selectedStoredSessionId.set(null) $sessionTiles.set([]) }) it('stores an exact Bot owner and keeps it through placement patches', () => { - const scope = { workspaceMode: 'bots' as const, workspaceOwnerKey: 'connection-a::default' } + const ownerRoute = { + connectionId: 'connection-a', + mode: 'remote' as const, + profile: 'default', + targetProfile: 'backend-default' + } + + const scope = { ownerRoute, workspaceMode: 'bots' as const, workspaceOwnerKey: 'connection-a::default' } openSessionTile('bot-chat', 'right', undefined, undefined, scope) patchSessionTile('bot-chat', { dir: 'left' }) @@ -72,6 +106,7 @@ describe('SessionTile workspace scope', () => { expect($sessionTiles.get()).toEqual([ expect.objectContaining({ dir: 'left', + ownerRoute, storedSessionId: 'bot-chat', workspaceMode: 'bots', workspaceOwnerKey: 'connection-a::default' @@ -79,6 +114,38 @@ describe('SessionTile workspace scope', () => { ]) }) + it('allows a Bot-scoped tab when the same stored session is hidden in Sessions main', () => { + const scope = { workspaceMode: 'bots' as const, workspaceOwnerKey: 'connection-a::default' } + + $selectedStoredSessionId.set('bot-chat') + openSessionTile('bot-chat', 'center', undefined, undefined, scope) + + expect($sessionTiles.get()).toEqual([ + expect.objectContaining({ + storedSessionId: 'bot-chat', + workspaceMode: 'bots', + workspaceOwnerKey: 'connection-a::default' + }) + ]) + expect(focusOpenSession('bot-chat', scope)).toBe('tile') + }) + + it('keeps Bot tabs while a profile publication swaps the Sessions bucket', () => { + const scope = { workspaceMode: 'bots' as const, workspaceOwnerKey: 'connection-a::writer' } + + openSessionTile('sessions-chat') + openSessionTile('bot-chat', 'center', undefined, undefined, scope) + $activeGatewayProfile.set('other-profile') + + expect($sessionTiles.get()).toEqual([ + expect.objectContaining({ + storedSessionId: 'bot-chat', + workspaceMode: 'bots', + workspaceOwnerKey: 'connection-a::writer' + }) + ]) + }) + it('re-scopes an existing tile without changing its placement', () => { openSessionTile('chat', 'bottom', 'workspace') diff --git a/apps/desktop/src/store/session-states.ts b/apps/desktop/src/store/session-states.ts index e2570c1bd2f9..119f54bff330 100644 --- a/apps/desktop/src/store/session-states.ts +++ b/apps/desktop/src/store/session-states.ts @@ -48,6 +48,7 @@ import { setActiveSessionStoredIdRotation, setSessions } from './session' +import type { SessionProfileRoute } from './session-request-router' import { ackStoredSessionId, markSessionUnreadFinished } from './session-unread' import { isSecondaryWindow } from './windows' @@ -540,11 +541,17 @@ export interface SessionTile { workspaceMode?: WorkspaceMode /** Exact opaque owner key for Bot Mode tabs. */ workspaceOwnerKey?: string + /** Credential-free exact route used to resume this tab after relaunch. */ + ownerRoute?: SessionProfileRoute + /** Stable title for hidden relationship chats absent from the Sessions list. */ + workspaceTabTitle?: string } export interface SessionTileWorkspaceScope { + ownerRoute?: SessionProfileRoute workspaceMode: WorkspaceMode workspaceOwnerKey?: string + workspaceTabTitle?: string } // Tiles are persisted PER PROFILE: a session belongs to one profile, and the @@ -556,22 +563,32 @@ export interface SessionTileWorkspaceScope { const TILES_KEY = 'hermes.desktop.sessionTiles.v2' const LEGACY_TILES_KEY = 'hermes.desktop.sessionTiles.v1' const TILE_PANE_PREFIX = 'session-tile:' +const BOTS_TILE_BUCKET = '__bots_workspace__' /** Persisted placement — `dir` + strip slot (`before`) + dock `anchor` so a * restart / profile swap re-adopts tiles in the same order, not all stacked * right of workspace. */ type StoredTile = Pick< SessionTile, - 'anchor' | 'before' | 'dir' | 'storedSessionId' | 'workspaceMode' | 'workspaceOwnerKey' + | 'anchor' + | 'before' + | 'dir' + | 'ownerRoute' + | 'storedSessionId' + | 'workspaceMode' + | 'workspaceOwnerKey' + | 'workspaceTabTitle' > const toStored = (t: SessionTile): StoredTile => ({ anchor: t.anchor, before: t.before, dir: t.dir, + ...(t.ownerRoute ? { ownerRoute: t.ownerRoute } : {}), storedSessionId: t.storedSessionId, ...(t.workspaceMode ? { workspaceMode: t.workspaceMode } : {}), - ...(t.workspaceOwnerKey ? { workspaceOwnerKey: t.workspaceOwnerKey } : {}) + ...(t.workspaceOwnerKey ? { workspaceOwnerKey: t.workspaceOwnerKey } : {}), + ...(t.workspaceTabTitle ? { workspaceTabTitle: t.workspaceTabTitle } : {}) }) function parseTileList(value: unknown): StoredTile[] { @@ -585,12 +602,26 @@ function parseTileList(value: unknown): StoredTile[] { anchor: typeof raw.anchor === 'string' ? raw.anchor : undefined, before: typeof raw.before === 'string' || raw.before === null ? raw.before : undefined, dir: raw.dir, + ownerRoute: + raw.ownerRoute && + typeof raw.ownerRoute.connectionId === 'string' && + typeof raw.ownerRoute.profile === 'string' + ? { + connectionId: raw.ownerRoute.connectionId, + mode: raw.ownerRoute.mode, + profile: raw.ownerRoute.profile, + ...(typeof raw.ownerRoute.targetProfile === 'string' + ? { targetProfile: raw.ownerRoute.targetProfile } + : {}) + } + : undefined, storedSessionId: raw.storedSessionId, workspaceMode: raw.workspaceMode === 'bots' ? 'bots' : 'sessions', workspaceOwnerKey: raw.workspaceMode === 'bots' && typeof raw.workspaceOwnerKey === 'string' ? raw.workspaceOwnerKey - : undefined + : undefined, + workspaceTabTitle: typeof raw.workspaceTabTitle === 'string' ? raw.workspaceTabTitle : undefined } }) : [] @@ -603,9 +634,19 @@ function loadTilesByProfile(): Record { if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { for (const [profile, list] of Object.entries(parsed as Record)) { const tiles = parseTileList(list) + const key = profile === BOTS_TILE_BUCKET ? BOTS_TILE_BUCKET : normalizeProfileKey(profile) if (tiles.length > 0) { - byProfile[normalizeProfileKey(profile)] = tiles + const sessionTiles = tiles.filter(tile => tile.workspaceMode !== 'bots') + const botTiles = tiles.filter(tile => tile.workspaceMode === 'bots') + + if (sessionTiles.length > 0) { + byProfile[key] = [...(byProfile[key] ?? []), ...sessionTiles] + } + + if (botTiles.length > 0) { + byProfile[BOTS_TILE_BUCKET] = [...(byProfile[BOTS_TILE_BUCKET] ?? []), ...botTiles] + } } } } @@ -615,7 +656,17 @@ function loadTilesByProfile(): Record { if (legacy.length > 0) { const key = normalizeProfileKey('default') - byProfile[key] = [...(byProfile[key] ?? []), ...legacy] + const sessionTiles = legacy.filter(tile => tile.workspaceMode !== 'bots') + const botTiles = legacy.filter(tile => tile.workspaceMode === 'bots') + + byProfile[key] = [...(byProfile[key] ?? []), ...sessionTiles] + byProfile[BOTS_TILE_BUCKET] = [...(byProfile[BOTS_TILE_BUCKET] ?? []), ...botTiles] + } + + if (byProfile[BOTS_TILE_BUCKET]?.length) { + byProfile[BOTS_TILE_BUCKET] = [ + ...new Map(byProfile[BOTS_TILE_BUCKET].map(tile => [tile.storedSessionId, tile])).values() + ] } writeJson(LEGACY_TILES_KEY, null) @@ -634,7 +685,9 @@ const profileKey = () => normalizeProfileKey($activeGatewayProfile.get()) // atom hydrates from the stored (runtime-less) tiles for the active profile. // A secondary window (single-chat pop-out) shows ONLY its routed session — no // tiles, and no repopulation on a profile switch. -export const $sessionTiles = atom(isSecondaryWindow() ? [] : [...(tilesByProfile[profileKey()] ?? [])]) +export const $sessionTiles = atom( + isSecondaryWindow() ? [] : [...(tilesByProfile[profileKey()] ?? []), ...(tilesByProfile[BOTS_TILE_BUCKET] ?? [])] +) function persistTiles() { // Shares the origin's storage; a secondary window holds no tiles, so a write @@ -647,16 +700,24 @@ function persistTiles() { } function saveTiles(tiles: SessionTile[]) { - $sessionTiles.set(tiles) const stored = tiles.map(toStored) + const sessionTiles = stored.filter(tile => tile.workspaceMode !== 'bots') + const botTiles = stored.filter(tile => tile.workspaceMode === 'bots') - if (stored.length > 0) { - tilesByProfile[profileKey()] = stored + if (sessionTiles.length > 0) { + tilesByProfile[profileKey()] = sessionTiles } else { delete tilesByProfile[profileKey()] } + if (botTiles.length > 0) { + tilesByProfile[BOTS_TILE_BUCKET] = botTiles + } else { + delete tilesByProfile[BOTS_TILE_BUCKET] + } + persistTiles() + $sessionTiles.set(tiles) } // Profile switch: surface the new profile's tiles with runtime ids cleared so @@ -665,7 +726,7 @@ function saveTiles(tiles: SessionTile[]) { // never carries tiles, so it stays out of this entirely. if (!isSecondaryWindow()) { $activeGatewayProfile.subscribe(() => { - $sessionTiles.set([...(tilesByProfile[profileKey()] ?? [])]) + $sessionTiles.set([...(tilesByProfile[profileKey()] ?? []), ...(tilesByProfile[BOTS_TILE_BUCKET] ?? [])]) }) } @@ -673,21 +734,34 @@ export function patchSessionTile(storedSessionId: string, patch: Partial (t.storedSessionId === storedSessionId ? { ...t, ...patch } : t))) } -export function setSessionTileWorkspaceScope( - storedSessionId: string, - scope: SessionTileWorkspaceScope -): boolean { +export function sessionTileOwnerRoute(storedSessionId: string): SessionProfileRoute | undefined { + return $sessionTiles.get().find(tile => tile.storedSessionId === storedSessionId)?.ownerRoute +} + +export function setSessionTileWorkspaceScope(storedSessionId: string, scope: SessionTileWorkspaceScope): boolean { const tile = $sessionTiles.get().find(candidate => candidate.storedSessionId === storedSessionId) const workspaceOwnerKey = scope.workspaceMode === 'bots' ? scope.workspaceOwnerKey : undefined + const ownerRoute = scope.workspaceMode === 'bots' ? scope.ownerRoute : undefined + const workspaceTabTitle = scope.workspaceMode === 'bots' ? scope.workspaceTabTitle : undefined if ( !tile || - ((tile.workspaceMode ?? 'sessions') === scope.workspaceMode && tile.workspaceOwnerKey === workspaceOwnerKey) + ((tile.workspaceMode ?? 'sessions') === scope.workspaceMode && + tile.workspaceOwnerKey === workspaceOwnerKey && + tile.ownerRoute?.connectionId === ownerRoute?.connectionId && + tile.ownerRoute?.profile === ownerRoute?.profile && + tile.ownerRoute?.targetProfile === ownerRoute?.targetProfile && + tile.workspaceTabTitle === workspaceTabTitle) ) { return false } - patchSessionTile(storedSessionId, { workspaceMode: scope.workspaceMode, workspaceOwnerKey }) + patchSessionTile(storedSessionId, { + ownerRoute, + workspaceMode: scope.workspaceMode, + workspaceOwnerKey, + workspaceTabTitle + }) return true } @@ -699,12 +773,18 @@ export function setSessionTileWorkspaceScope( * runtime id from the cache, so post-wake tiles repainted empty and never * actually re-resumed. */ export function resetTileRuntimeBindings() { - sessionTileDelegate()?.invalidateRuntimeBindings?.() - const tiles = $sessionTiles.get() - if (tiles.some(t => t.runtimeId)) { - $sessionTiles.set(tiles.map(toStored)) + const preservedStoredIds = new Set( + tiles + .filter(tile => tile.workspaceMode === 'bots' && Boolean(tile.ownerRoute?.connectionId)) + .map(tile => tile.storedSessionId) + ) + + sessionTileDelegate()?.invalidateRuntimeBindings?.(preservedStoredIds) + + if (tiles.some(tile => tile.runtimeId && !preservedStoredIds.has(tile.storedSessionId))) { + $sessionTiles.set(tiles.map(tile => (preservedStoredIds.has(tile.storedSessionId) ? tile : toStored(tile)))) } } @@ -749,7 +829,7 @@ export interface SessionTileDelegate { * recorded before the reconnect is suspect — without this, `resumeTile`'s * warm path re-binds tiles to dead runtime ids (the sleep/wake "empty * right pane" bug). Bindings re-record from live post-reconnect events. */ - invalidateRuntimeBindings?(): void + invalidateRuntimeBindings?(preserveStoredSessionIds?: ReadonlySet): void /** Bind a live runtime id for a stored session (resume without touching * the main view). Returns the runtime id, or throws. */ resumeTile(storedSessionId: string): Promise @@ -761,9 +841,11 @@ export interface SessionTileDelegate { } let delegate: SessionTileDelegate | null = null +export const $sessionTileDelegateRevision = atom(0) export function setSessionTileDelegate(next: SessionTileDelegate) { delegate = next + $sessionTileDelegateRevision.set($sessionTileDelegateRevision.get() + 1) } export function sessionTileDelegate(): SessionTileDelegate | null { @@ -847,14 +929,13 @@ export function openSessionTile( markSessionRead(storedSessionId) ackStoredSessionId(storedSessionId) - if (storedSessionId === $selectedStoredSessionId.get()) { + if (workspaceScope.workspaceMode === 'sessions' && storedSessionId === $selectedStoredSessionId.get()) { return } const dock = anchor ?? focusedSessionTabAnchor() ?? undefined - const workspaceOwnerKey = - workspaceScope.workspaceMode === 'bots' ? workspaceScope.workspaceOwnerKey : undefined + const workspaceOwnerKey = workspaceScope.workspaceMode === 'bots' ? workspaceScope.workspaceOwnerKey : undefined if (!tiles.some(t => t.storedSessionId === storedSessionId)) { saveTiles([ @@ -863,9 +944,11 @@ export function openSessionTile( anchor: dock, before, dir, + ownerRoute: workspaceScope.workspaceMode === 'bots' ? workspaceScope.ownerRoute : undefined, storedSessionId, workspaceMode: workspaceScope.workspaceMode, - workspaceOwnerKey + workspaceOwnerKey, + workspaceTabTitle: workspaceScope.workspaceMode === 'bots' ? workspaceScope.workspaceTabTitle : undefined } ]) // Adoption is async via the registry — order sync runs after the move path @@ -938,7 +1021,10 @@ export function nextSessionTileForWorkspace(): null | string { * Callers that own the router need the `'main'` vs `'tile'` distinction: a * `'main'` hit only reaches the screen if the workspace pane is actually * showing the chat, whereas a tile renders in its own pane regardless. */ -export function focusOpenSession(storedSessionId: string): 'main' | 'tile' | null { +export function focusOpenSession( + storedSessionId: string, + workspaceScope: SessionTileWorkspaceScope = { workspaceMode: 'sessions' } +): 'main' | 'tile' | null { if ($sessionTiles.get().some(t => t.storedSessionId === storedSessionId)) { const paneId = `${TILE_PANE_PREFIX}${storedSessionId}` revealTreePane(paneId) // un-dismiss + adopt + front in its group @@ -954,7 +1040,7 @@ export function focusOpenSession(storedSessionId: string): 'main' | 'tile' | nul // Already the main session: front the workspace tab and drop tile focus so // the readouts + sidebar highlight come home (a no-op when main is focused). - if (storedSessionId === $selectedStoredSessionId.get()) { + if (workspaceScope.workspaceMode === 'sessions' && storedSessionId === $selectedStoredSessionId.get()) { revealTreePane('workspace') noteActiveTreeGroup(null) From 552607f71c23832daa8de103696491691dd14deb Mon Sep 17 00:00:00 2001 From: David Dudok de Wit <5354424+dokterdok@users.noreply.github.com> Date: Sun, 23 Aug 2026 07:54:27 +0200 Subject: [PATCH 07/10] fix(desktop): preserve cross-realm Bot registry errors --- apps/desktop/src/plugins/hermes-bots/plugin.js | 6 +++++- .../src/plugins/hermes-bots/tests/hide-bot-chats.test.mjs | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/plugins/hermes-bots/plugin.js b/apps/desktop/src/plugins/hermes-bots/plugin.js index 0c97b25a4be7..9357b94e38f9 100644 --- a/apps/desktop/src/plugins/hermes-bots/plugin.js +++ b/apps/desktop/src/plugins/hermes-bots/plugin.js @@ -5149,7 +5149,11 @@ async function findExistingCanonicalChat(owner) { include_hidden: true }) } catch (error) { - const detail = error instanceof Error && error.message ? ` (${error.message})` : '' + // Plugin tests and host bridges can return Error-like values from another + // JS realm, where `instanceof Error` is false. Preserve the provider/RPC + // message so update-required classification and diagnostics still work. + const message = typeof error?.message === 'string' ? error.message : '' + const detail = message ? ` (${message})` : '' throw new Error(`Could not check ${name}'s Bot Chat registry${detail} — not starting a new chat`) } diff --git a/apps/desktop/src/plugins/hermes-bots/tests/hide-bot-chats.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/hide-bot-chats.test.mjs index b37c29cbe49c..e66069014e64 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/hide-bot-chats.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/hide-bot-chats.test.mjs @@ -278,6 +278,6 @@ test('the canonical-chat adoption scan lists with include_hidden', () => { // and fails CLOSED: a thrown lookup never falls through to minting. assert.match( source, - /requestForBot\(bot, 'session\.list', \{\s*profile: backendTargetProfile\(route, name\),\s*title: CANONICAL_CHAT_TITLE,[\s\S]{0,200}?include_hidden: true\s*\}\)\s*\} catch \(error\) \{[\s\S]{0,400}?const rows = res\?\.sessions \?\? \[\]\s*return rows\.find\(row => isCanonicalBotChatHistory\(row\)\)/ + /requestForBot\(bot, 'session\.list', \{\s*profile: backendTargetProfile\(route, name\),\s*title: CANONICAL_CHAT_TITLE,[\s\S]{0,200}?include_hidden: true\s*\}\)\s*\} catch \(error\) \{[\s\S]{0,800}?const rows = res\?\.sessions \?\? \[\]\s*return rows\.find\(row => isCanonicalBotChatHistory\(row\)\)/ ) }) From 5f6d3e849bb1e67f01d8801a103bc16c44a98943 Mon Sep 17 00:00:00 2001 From: David Dudok de Wit <5354424+dokterdok@users.noreply.github.com> Date: Sun, 23 Aug 2026 09:53:36 +0200 Subject: [PATCH 08/10] refactor(desktop): consume upstream Bot owner routing --- .../desktop/src/plugins/hermes-bots/plugin.js | 142 +++++++++--------- .../tests/active-now-strip.test.mjs | 2 +- .../hermes-bots/tests/bot-delete.test.mjs | 4 +- .../hermes-bots/tests/bots-home.test.mjs | 12 +- .../tests/canonical-chat-registry.test.mjs | 1 + .../tests/focused-bot-highlight.test.mjs | 6 +- .../hermes-bots/tests/group-chat.test.mjs | 15 +- .../hermes-bots/tests/hide-bots.test.mjs | 19 ++- .../tests/multi-source-roster.test.mjs | 9 ++ .../tests/profile-prewarm.test.mjs | 38 ++++- .../hermes-bots/tests/roster-preview.test.mjs | 7 +- apps/desktop/src/sdk/index.ts | 1 + apps/desktop/src/sdk/profile-routing.test.ts | 1 + 13 files changed, 150 insertions(+), 107 deletions(-) diff --git a/apps/desktop/src/plugins/hermes-bots/plugin.js b/apps/desktop/src/plugins/hermes-bots/plugin.js index 9357b94e38f9..a56c91048be6 100644 --- a/apps/desktop/src/plugins/hermes-bots/plugin.js +++ b/apps/desktop/src/plugins/hermes-bots/plugin.js @@ -156,7 +156,7 @@ function trackInboundActivity(roster) { // 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 ($selectedRosterKey.get() === key) { + if ($selectedBot.get() === key) { continue } @@ -164,7 +164,7 @@ function trackInboundActivity(roster) { // Roster-hidden bots stay quiet: the unread flag above accumulates // silently (unhiding reveals the badge) but a hidden bot never toasts. - if (isBotHidden(bot, $botMeta.get())) { + if (botRosterMeta(bot, $botMeta.get())?.hidden) { continue } @@ -226,9 +226,9 @@ let botsHomeClose = null let suppressBotsHomeReopen = false function saveSelectedRosterBot(bot) { - const key = botSelectionKey(bot) + const key = botRosterKey(bot) - $selectedBot.set(key) + $selectedBot.set(botSelectionKey(bot)) $selectedRosterKey.set(key) try { @@ -239,7 +239,7 @@ function saveSelectedRosterBot(bot) { } function clearSelectedRosterBot(bot) { - clearSelectedRosterKey(botSelectionKey(bot)) + clearSelectedRosterKey(botRosterKey(bot)) } /** Drop the persisted selection when it is exactly this key — the caller has @@ -1918,12 +1918,8 @@ async function sweepBotProfileSessions() { // back to the active gateway's own profile list (local bots; remote // sources get covered by the next sweep once the roster cache exists). try { - const route = await activeBotRoute() - const res = await requestForBot( - route ? { name: route.profile, sourceScoped: true, route } : { name: 'default' }, - 'profiles.list', - {} - ) + const activeBot = { name: String(host.state.profile?.get?.() || 'default').trim() || 'default' } + const res = await requestForBot(activeBot, 'profiles.list', {}) roster = Array.isArray(res?.profiles) ? res.profiles : [] } catch { return @@ -4224,7 +4220,16 @@ function useRoster() { if (typeof host.agents === 'function') { try { const union = await host.agents() - return { ...mergeMultiSourceRoster(local, union, activeConnectionId, $lastRoster.get()), fetchedAt: issuedAt } + const previous = $lastRoster.get().filter(row => !row?.ghost) + const merged = mergeMultiSourceRoster(local, union, activeConnectionId, previous) + const sources = Array.isArray(union?.sources) ? union.sources : [] + + return { + ...merged, + profiles: (merged?.profiles || []).map(row => annotateBotSource(row, sources)), + sources, + fetchedAt: issuedAt + } } catch { /* older build or roster failure — single-source list stands */ } @@ -4300,7 +4305,6 @@ function cachedUnionRoster() { function mergeMultiSourceRoster(local, union, activeConnectionId, previous = []) { const localProfiles = Array.isArray(local?.profiles) ? local.profiles : [] const agents = Array.isArray(union?.agents) ? union.agents : [] - const sources = Array.isArray(union?.sources) ? union.sources : [] // A live id of null/'' means the window is on the unscoped local backend // (legacy hosts reported null for mode:'local'; the SDK now reports // 'local'). Do NOT fall back to registry primary when the third argument @@ -4446,10 +4450,7 @@ function mergeMultiSourceRoster(local, union, activeConnectionId, previous = []) const name = String(row?.name || '').trim() const key = `${connectionId}::${name || 'default'}` - // Ghost owners are presentation-only placeholders. Re-adopting one as - // a cached remote row would keep it alive after the selection changes - // and let an identity without its durable handle leak into shared state. - if (row?.ghost || !row?.remoteSource || !connectionId || !name || present.has(key)) { + if (!row?.remoteSource || !connectionId || !name || present.has(key)) { continue } @@ -4464,7 +4465,7 @@ function mergeMultiSourceRoster(local, union, activeConnectionId, previous = []) } } - return { ...local, profiles: profiles.map(row => annotateBotSource(row, sources)), sources } + return { ...local, profiles } } /** The @handle users tag a bot with. Multi-source rosters precompute the @@ -5333,7 +5334,7 @@ async function ensureBotMetadata(bot) { * resolves a canonical-chat id. */ async function openRosterBot(bot) { const generation = ++botOpenGeneration - const key = botSelectionKey(bot) + const key = botRosterKey(bot) const meta = botRosterMeta(bot, $botMeta.get()) // Keep the currently visible group as a fallback until this explicit action // has actually fronted a new owner; a failed home open must not steal the @@ -7333,7 +7334,7 @@ function botRowOwnsWorkspace( } if (botsHomeFronted || !botChatFocused) { - return selectedRosterKey === botSelectionKey(bot) + return selectedRosterKey === botRosterKey(bot) } return isActiveRosterBot(bot, focusedOwner) @@ -12458,22 +12459,17 @@ function BotsHomeView() { children: description }) : null, - unavailable || !bot.remoteSource - ? jsx('p', { - className: cn( - 'mt-4 max-w-lg text-xs leading-5', - unavailable ? 'text-amber-700 dark:text-amber-300' : 'text-(--ui-text-tertiary)' - ), - children: unavailable - ? sourceRemoved - ? `${gateway} was removed. Choose another bot from the sidebar.` - : `${gateway} is unavailable. Retry when it is back online.` - : 'Open this bot’s continuous chat. Its background work keeps running when you switch away.' - }) - : jsx('p', { - className: 'mt-4 max-w-lg text-xs leading-5 text-(--ui-text-tertiary)', - children: `This bot lives on ${gateway}. Mention it from any Bot Chat to send it a message.` - }), + jsx('p', { + className: cn( + 'mt-4 max-w-lg text-xs leading-5', + unavailable ? 'text-amber-700 dark:text-amber-300' : 'text-(--ui-text-tertiary)' + ), + children: unavailable + ? sourceRemoved + ? `${gateway} was removed. Choose another bot from the sidebar.` + : `${gateway} is unavailable. Retry when it is back online.` + : 'Open this bot’s continuous chat. Its background work keeps running when you switch away.' + }), unavailable && !sourceRemoved ? jsx(Button, { variant: 'secondary', @@ -12482,15 +12478,13 @@ function BotsHomeView() { onClick: retrySource, children: 'Retry' }) - : bot.remoteSource - ? null - : jsx(Button, { - variant: 'secondary', - size: 'sm', - className: 'mt-5', - onClick: () => void openRosterBot(bot), - children: 'Open chat' - }) + : jsx(Button, { + variant: 'secondary', + size: 'sm', + className: 'mt-5', + onClick: () => void openRosterBot(bot), + children: 'Open chat' + }) ] }) }) @@ -13338,22 +13332,28 @@ function BotsPane() { className: 'flex min-w-0 items-center gap-1 px-2.5 pb-1.5', children: [ showRosterSearch - ? jsx(SearchField, { - 'aria-label': 'Search bots and group chats', - containerClassName: cn( - 'min-w-0 flex-1', - query ? 'opacity-100!' : 'opacity-50 focus-within:opacity-100' - ), - inputClassName: - 'w-full text-[0.75rem] placeholder:text-(--ui-text-tertiary)', - placeholder: 'Search bots and group chats…', - value: query, - onChange: setQuery - }) - : jsx('span', { className: 'min-w-0 flex-1' }), + ? jsx( + SearchField, + { + 'aria-label': 'Search bots and group chats', + containerClassName: cn( + 'min-w-0 flex-1', + query ? 'opacity-100!' : 'opacity-50 focus-within:opacity-100' + ), + inputClassName: + 'w-full text-[0.75rem] placeholder:text-(--ui-text-tertiary)', + placeholder: 'Search bots and group chats…', + value: query, + onChange: setQuery + }, + 'roster-search' + ) + : jsx('span', { className: 'min-w-0 flex-1' }, 'roster-search-spacer'), showRosterFilters - ? jsxs(DropdownMenu, { - children: [ + ? jsxs( + DropdownMenu, + { + children: [ jsx(Tip, { label: activeFilterCount ? `Filters (${activeFilterCount} active)` : 'Filter roster', children: jsx(DropdownMenuTrigger, { @@ -13466,8 +13466,10 @@ function BotsPane() { : null ] }) - ] - }) + ] + }, + 'roster-filters' + ) : null ] }) @@ -13554,10 +13556,12 @@ function BotsPane() { row.kind === 'group' ? renderGroupRow(row) : renderBotRow(row.bot) )), showHiddenSection - ? jsxs('div', { - ref: hiddenSectionRef, - className: 'mt-1 border-t border-(--ui-stroke-tertiary) pt-1', - children: [ + ? jsxs( + 'div', + { + ref: hiddenSectionRef, + className: 'mt-1 border-t border-(--ui-stroke-tertiary) pt-1', + children: [ hasRosterConstraint ? jsxs('div', { className: @@ -13596,8 +13600,10 @@ function BotsPane() { children: 'No hidden bots match these filters.' }) : null - ] - }) + ] + }, + 'hidden-section' + ) : null ] }) diff --git a/apps/desktop/src/plugins/hermes-bots/tests/active-now-strip.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/active-now-strip.test.mjs index 6734e3fed7e4..c667250a3060 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/active-now-strip.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/active-now-strip.test.mjs @@ -189,5 +189,5 @@ test('ActiveNowStrip renders above the roster, is a live region, and is click-ac const open = source.slice(openStart, openStart + 3200) assert.match(open, /await prepareBotSource\(bot\)/) - assert.match(open, /await openBotCanonicalChat\(bot\.name\)/) + assert.match(open, /await openBotCanonicalChat\(bot\)/) }) diff --git a/apps/desktop/src/plugins/hermes-bots/tests/bot-delete.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/bot-delete.test.mjs index b7f699a1ef94..63c58f0d14a4 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/bot-delete.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/bot-delete.test.mjs @@ -204,13 +204,13 @@ test('unit: older desktop without host.deleteProfile falls back to the non-inter test('integration: a deleted bot is removed from plugin-local state and the roster is refreshed', async () => { const { context, invalidations, stored } = load() context.__delete.$botMeta.set({ researcher: { title: 'Research' }, writer: { title: 'Writer' } }) - context.__delete.$botUnread.set({ 'legacy::researcher': true, 'legacy::writer': true }) + context.__delete.$botUnread.set({ researcher: true, writer: true }) context.__delete.$selectedBot.set('researcher') await context.__delete.deleteBot({ name: 'researcher' }) assert.equal(context.__delete.$botMeta.get().researcher, undefined) - assert.equal(context.__delete.$botUnread.get()['legacy::researcher'], undefined) + assert.equal(context.__delete.$botUnread.get().researcher, undefined) assert.equal(context.__delete.$selectedBot.get(), 'default') assert.equal(stored.at(-1).key, 'bot-meta') assert.equal(stored.at(-1).value.researcher, undefined) diff --git a/apps/desktop/src/plugins/hermes-bots/tests/bots-home.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/bots-home.test.mjs index 411b4255fcc9..d20db31067c2 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/bots-home.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/bots-home.test.mjs @@ -127,7 +127,6 @@ globalThis.__home = { ghostRosterOwner, rosterWithSelectedOwner, reconcileRosterSelection, - saveRosterPreference, saveSelectedRosterBot, clearSelectedRosterBot, clearSelectedRosterKey, @@ -197,7 +196,7 @@ test('selection persists the source-qualified key, not the bare profile name', ( assertNothingRouted(t, 'saving a selection') }) -test('a remote selection never redirects the bare-name consumers at a local twin', () => { +test('a remote selection keeps the exact-owner selection distinct from a local twin', () => { const t = load() t.setPluginCtx({ storage: { set: () => undefined } }) @@ -210,8 +209,8 @@ test('a remote selection never redirects the bare-name consumers at a local twin assert.equal(t.$selectedRosterKey.get(), 'mac-mini::default') assert.equal( t.$selectedBot.get(), - 'default', - 'the Cronjobs/slash-guard name tracker keeps its LOCAL owner — a remote row must not claim it' + 'mac-mini::default', + 'the shared selection follows the same exact owner as the roster workspace' ) }) @@ -331,10 +330,7 @@ test('a hidden bot is not auto-selected', () => { const sources = [{ connectionId: 'local', kind: 'local', label: 'This device', reachable: true }] const roster = [{ connectionId: 'local', name: 'hidden-one' }, { connectionId: 'local', name: 'writer' }] - // Hidden is a source-qualified Desktop preference, so hide the exact row. - t.saveRosterPreference(roster[0], 'hidden', true) - - t.reconcileRosterSelection(roster, sources, {}) + t.reconcileRosterSelection(roster, sources, { 'hidden-one': { hidden: true } }) assert.equal( t.$selectedRosterKey.get(), diff --git a/apps/desktop/src/plugins/hermes-bots/tests/canonical-chat-registry.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/canonical-chat-registry.test.mjs index 331185ecf96a..c2ef83ca3da9 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/canonical-chat-registry.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/canonical-chat-registry.test.mjs @@ -48,6 +48,7 @@ function loadOpenPath({ openSession, request }) { ? { bot: { name: owner }, key: owner, name: owner, route: null } : { bot: owner, key: owner?.name, name: owner?.name, route: owner?.route || null }), backendTargetProfile: (route, name) => route?.targetProfile || name, + botWorkspaceOwnerKey: bot => `bot:${bot?.connectionId ? `${bot.connectionId}::` : ''}${bot?.name || 'default'}`, requestForBot: (_bot, method, params) => context.host.request(method, params), window: { setTimeout: callback => callback() } } diff --git a/apps/desktop/src/plugins/hermes-bots/tests/focused-bot-highlight.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/focused-bot-highlight.test.mjs index 7b31017a8c07..8ca96f3e0e33 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/focused-bot-highlight.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/focused-bot-highlight.test.mjs @@ -58,17 +58,17 @@ test('BotRow keys the highlight off the focused profile, not the socket home', ( const row = source.slice(rowStart, rowStart + 2000) assert.match(row, /const focusedOwner = focusedRosterOwner\(useValue\(\$focusedBotOwner\)\)/) - assert.match(row, /const isActive = !activeGroup && isActiveRosterBot\(bot, focusedOwner\)/) + assert.match(row, /const isActive = botRowOwnsWorkspace\([\s\S]*?focusedOwner,[\s\S]*?selectedRosterKey/) }) test('BotRow keeps turn-busy (work mood) a socket fact', () => { const rowStart = source.indexOf('function BotRow(') - const row = source.slice(rowStart, rowStart + 3000) + const row = source.slice(rowStart, rowStart + 5000) // Only the gateway-home profile can actually be mid-turn: the mood must NOT // switch to the focus-keyed identity. assert.match(row, /const isGatewayHome = !bot\.remoteSource && bot\.name === activeProfile/) - assert.match(row, /const botMood = \(isGatewayHome && gatewayState === 'busy'\) \|\| activeNow \? 'work' : 'idle'/) + assert.match(row, /const botMood = workerActive \|\| \(isGatewayHome && gatewayState === 'busy'\) \? 'work' : 'idle'/) }) test('RoutinesPane scopes the Cronjobs tile to the focused chat owner', () => { diff --git a/apps/desktop/src/plugins/hermes-bots/tests/group-chat.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/group-chat.test.mjs index f84a1a56bc28..626d2a276924 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/group-chat.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/group-chat.test.mjs @@ -1148,7 +1148,7 @@ test('closing an older selected group does not clear the newer selection', () => }) test('source contract: active group styling suppresses bot styling', () => { - assert.match(pluginSource, /const isActive = !activeGroup && isActiveRosterBot\(bot, focusedOwner\)/) + assert.match(pluginSource, /function botRowOwnsWorkspace\([\s\S]*?if \(activeGroup\) \{\s*return false/) assert.match(pluginSource, /active && 'bg-\(--ui-row-active-background\)'/) assert.match(pluginSource, /active: groupChatName === row\.name/) }) @@ -1261,10 +1261,10 @@ test('disband: a running room leaves an epoch-bumped empty tombstone so in-fligh 'disbanded name is immediately reusable — tombstone does not hold it') }) -test('source contract: workspace header offers disband behind a ConfirmDialog', () => { +test('source contract: workspace deletion stays behind a ConfirmDialog', () => { assert.match(pluginSource, /function disbandGroupChat\(/) - assert.match(pluginSource, /Disband group chat\?/) - assert.match(pluginSource, /title: `Disband the \$\{group\} group chat`/) + assert.match(pluginSource, /title: 'Delete group chat\?'/) + assert.match(pluginSource, /await disbandGroupChat\(deletingGroup\.name, deletingGroup\.members\)/) }) test('default profile speaks as Hermes in room transcripts, not @default', () => { @@ -1328,9 +1328,10 @@ test('source contract: room messages carry the speaker avatar via the roster app assert.match(workspace, /image && !isBackfilledFacePng\(image\)/) assert.match(workspace, /jsx\(BotFace, \{\s*shape,\s*color,\s*image: photo \? image : null,\s*size: 24,\s*name: entry\.from\.name/) - // Header shows the member faces (capped) with a names tooltip. - assert.match(workspace, /members\.slice\(0, 6\)\.map\(/) - assert.match(workspace, /title: members\.map\(b => displayName\(b, botRosterMeta\(b, allMeta\)\)\)\.join\(', '\)/) + // Header stays quiet: member avatars belong to messages, while the header + // exposes a concise count instead of an overlapping face stack. + assert.doesNotMatch(workspace, /members\.slice\(0, 6\)\.map\(/) + assert.match(workspace, /children: members\.length > 0 && availableMembers < members\.length \? availabilityLabel : `\$\{members\.length\} bots`/) }) test('stranded harvest: a timed-out turn whose reply landed late posts into the room and clears the marker', async () => { diff --git a/apps/desktop/src/plugins/hermes-bots/tests/hide-bots.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/hide-bots.test.mjs index 98efb82509ad..2f2289210164 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/hide-bots.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/hide-bots.test.mjs @@ -186,24 +186,23 @@ test('activity: a hidden bot accumulates unread silently but never toasts, even // ── source shape ───────────────────────────────────────────────────────────── -test('shape: roster list filters through isBotHidden unless the show-hidden toggle is on', () => { +test('shape: visible and hidden bots render through separate recoverable sections', () => { assert.match( pluginSource, - /const visibleRoster = showHidden \? roster : roster\.filter\(bot => !isBotHidden\(bot, allMeta\)\)/ + /const visibleRoster = roster\.filter\(bot => !isBotHidden\(bot, allMeta\)\)/ ) - assert.match(pluginSource, /const filteredRoster = filterBots\(visibleRoster, allMeta, query\)/) + assert.match(pluginSource, /const matchingHiddenBots = rowKindFilter === 'groups' \? \[\] : filteredHiddenBots/) }) -test('shape: header eye toggle renders only when at least one bot is hidden', () => { - assert.match(pluginSource, /hiddenBots\.length\s*\n?\s*\? jsx\(Tip, \{/) - assert.match(pluginSource, /onClick: \(\) => \$showHiddenBots\.set\(!showHidden\)/) +test('shape: Hidden section renders only when recovery is relevant', () => { + assert.match(pluginSource, /const showHiddenSection = hiddenBots\.length > 0/) + assert.match(pluginSource, /onClick: \(\) => \$showHiddenBots\.set\(!hiddenExpanded\)/) }) -test('shape: revealed hidden rows are dimmed and flagged with the eye-closed glyph', () => { +test('shape: revealed hidden rows are flagged and can be restored', () => { const botRow = pluginSource.slice(pluginSource.indexOf('function BotRow('), pluginSource.indexOf('// ── model picker')) - assert.match(botRow, /meta\?\.hidden && 'opacity-60'/) assert.match(botRow, /name: 'eye-closed'/) - assert.match(botRow, /children: meta\?\.hidden \? 'Unhide Bot' : 'Hide Bot'/) + assert.match(botRow, /children: hidden \? 'Unhide' : 'Hide'/) assert.match(botRow, /saveBotMeta\(bot, \{ hidden: !hidden \}\)/) }) @@ -213,7 +212,7 @@ test('shape: hiding never filters mentions, group flows, or the meta/activity sw // which is derived from the unfiltered roster, not visibleRoster. assert.match(pluginSource, /const activeSourceRoster = roster\.filter\(bot => !bot\.remoteSource\)/) assert.match(pluginSource, /mergeServerMeta\(activeSourceRoster, data\?\.fetchedAt \|\| 0\)/) - assert.match(pluginSource, /trackInboundActivity\(activeSourceRoster\)/) + assert.match(pluginSource, /trackInboundActivity\(roster\)/) // Mention resolution never consults the hidden flag. const mentions = pluginSource.slice( pluginSource.indexOf('function resolveRosterMentions('), diff --git a/apps/desktop/src/plugins/hermes-bots/tests/multi-source-roster.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/multi-source-roster.test.mjs index ee0ae378a508..815e21585e4f 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/multi-source-roster.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/multi-source-roster.test.mjs @@ -661,3 +661,12 @@ test('resolveRosterMentions: @hermes in this chat is not a handoff to yourself', assert.equal(hits.length, 1) assert.equal(hits[0].connectionId, 'mac-mini') }) + +test('source contract: active roster queries use the SDK ambient owner route', () => { + assert.doesNotMatch(source, /activeBotRoute/) + assert.equal( + source.match(/requestForBot\(activeBot, 'profiles\.list', \{\}\)/g)?.length, + 2, + 'roster hydration and the session sweep must both use the upstream ambient-owner route' + ) +}) diff --git a/apps/desktop/src/plugins/hermes-bots/tests/profile-prewarm.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/profile-prewarm.test.mjs index a79bfeb3c7ed..e03e48579f0e 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/profile-prewarm.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/profile-prewarm.test.mjs @@ -24,7 +24,6 @@ function activitySessionSource() { function renderBotRow(input = 'alpha') { const bot = typeof input === 'string' ? { name: input } : input const name = bot.name - const prepareSource = sourceBetween('async function prepareBotSource(', 'function displayName(') const botRowSource = sourceBetween('function BotRow(', '// ── model picker') // REAL owner-route resolver — the derivation from bare connectionId rows is // exactly what these tests exercise, so a hand stub would drift. @@ -48,20 +47,28 @@ function renderBotRow(input = 'alpha') { ContextMenuItem: 'ContextMenuItem', ContextMenuSeparator: 'ContextMenuSeparator', ContextMenuTrigger: 'ContextMenuTrigger', + Codicon: 'Codicon', + Tip: 'Tip', ROSTER_KEY: ['hermes-bots', 'roster'], $botMeta: atom({}), $botUnread: atom({}), + $botChatFocused: atom(false), + $botsHomeFronted: atom(false), $focusedBotOwner: atom({ connectionId: 'local', profile: 'default' }), $focusedBotProfile: atom('default'), $groupChatWorkspace: atom(null), $lastRoster: atom([]), $selectedBot: atom('default'), + $selectedRosterKey: atom(''), botAppearance: () => ({ shape: 'round', color: '#000', image: null }), botMetaKey: value => value.sourceScoped ? `${value.route?.connectionId || value.connectionId}::${value.name}` : value.name, botGroups: () => [], botHandle: value => value, + botRosterKey: value => `${value.connectionId || 'legacy'}::${value.name}`, + botRowOwnsWorkspace: () => false, + botSourceStatus: () => ({ available: true, label: 'Ready' }), botOpenGeneration: 0, botRosterMeta: (_bot, metaByName) => { const key = _bot.sourceScoped @@ -73,6 +80,7 @@ function renderBotRow(input = 'alpha') { createCanonicalChat: async () => null, displayName: bot => bot.name, duplicateBot: async () => `${name}-copy`, + ensureBotMetadata: async () => ({ pinned: true }), haptic: () => undefined, // #49 session-aware-row helpers referenced inside BotRow. previewKind: () => ({ fromBot: false, sender: null }), @@ -80,6 +88,8 @@ function renderBotRow(input = 'alpha') { focusedRosterOwner: owner => ({ connectionId: owner.connectionId, name: owner.profile }), isActiveRosterBot: () => false, isBackfilledFacePng: () => false, + isBotHidden: () => false, + isBotPinned: () => false, botSelectionKey: value => value.sourceScoped ? `${value.connectionId}::${value.name}` : value.name, isDefaultBot: value => value.name === 'default', newBotChat: () => undefined, @@ -87,6 +97,11 @@ function renderBotRow(input = 'alpha') { opened.push(args) return 'stored-chat' }, + openRosterBot: async value => { + opened.push([value]) + return true + }, + workerActiveAt: () => false, ACTIVE_WINDOW_S: 90, A2A_PREFIX_RE: /^$/, useEffect: () => undefined, @@ -131,7 +146,7 @@ function renderBotRow(input = 'alpha') { useValue: store => store.get() } - vm.runInNewContext(`${activitySessionSource()}\n${routeSource}\n${prepareSource}\n${botRowSource}\nglobalThis.BotRow = BotRow`, context) + vm.runInNewContext(`${activitySessionSource()}\n${routeSource}\n${botRowSource}\nglobalThis.BotRow = BotRow`, context) const tree = context.BotRow({ bot, onEdit: context.onEdit }) const row = tree.type === 'button' ? tree : tree.props.children[0].props.children @@ -211,7 +226,6 @@ test('behavior: remote default never opens the same-name local chat', async () = remoteSource: true, sourceScoped: true } - const prepareSource = sourceBetween('async function prepareBotSource(', 'function displayName(') const botRowSource = sourceBetween('function BotRow(', '// ── model picker') const ensured = [] const opened = [] @@ -230,29 +244,40 @@ test('behavior: remote default never opens the same-name local chat', async () = ContextMenuItem: 'ContextMenuItem', ContextMenuSeparator: 'ContextMenuSeparator', ContextMenuTrigger: 'ContextMenuTrigger', + Codicon: 'Codicon', + Tip: 'Tip', ROSTER_KEY: ['hermes-bots', 'roster'], $botMeta: atom({ default: { chat: 'this-device-chat' } }), $botUnread: atom({}), + $botChatFocused: atom(false), + $botsHomeFronted: atom(false), $focusedBotOwner: atom({ connectionId: 'mac-mini', profile: 'default' }), $focusedBotProfile: atom('default'), $groupChatWorkspace: atom(null), $lastRoster: atom([]), $selectedBot: atom('default'), + $selectedRosterKey: atom(''), botAppearance: () => ({ shape: 'round', color: '#000', image: null }), botGroups: () => [], botHandle: value => value, + botRosterKey: value => `${value.connectionId || 'legacy'}::${value.name}`, + botRowOwnsWorkspace: () => false, + botSourceStatus: () => ({ available: true, label: 'Ready' }), botOpenGeneration: 0, botRosterMeta: () => null, cn: (...values) => values.filter(Boolean).join(' '), createCanonicalChat: async () => null, displayName: bot => bot.connectionLabel || bot.name, duplicateBot: async () => 'copy', + ensureBotMetadata: async () => ({ pinned: true }), haptic: () => undefined, previewKind: () => ({ fromBot: false, sender: null }), generatedSessionTitle: () => null, focusedRosterOwner: owner => ({ connectionId: owner.connectionId, name: owner.profile }), isActiveRosterBot: () => false, isBackfilledFacePng: () => false, + isBotHidden: () => false, + isBotPinned: () => false, botSelectionKey: value => value.sourceScoped ? `${value.connectionId}::${value.name}` : value.name, isDefaultBot: value => value.name === 'default', newBotChat: () => undefined, @@ -260,6 +285,11 @@ test('behavior: remote default never opens the same-name local chat', async () = opened.push(args) return 'this-device-chat' }, + openRosterBot: async value => { + opened.push([value]) + return true + }, + workerActiveAt: () => false, ACTIVE_WINDOW_S: 90, A2A_PREFIX_RE: /^$/, useEffect: () => undefined, @@ -287,7 +317,7 @@ test('behavior: remote default never opens the same-name local chat', async () = } const routeSource = sourceBetween('function botConnectionRoute(', 'function rewriteCliProfileOperands(') - vm.runInNewContext(`${activitySessionSource()}\n${routeSource}\n${prepareSource}\n${botRowSource}\nglobalThis.BotRow = BotRow`, context) + vm.runInNewContext(`${activitySessionSource()}\n${routeSource}\n${botRowSource}\nglobalThis.BotRow = BotRow`, context) const tree = context.BotRow({ bot, onEdit: context.onEdit }) const row = tree.type === 'button' ? tree : tree.props.children[0].props.children diff --git a/apps/desktop/src/plugins/hermes-bots/tests/roster-preview.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/roster-preview.test.mjs index 89cc02fdef56..989767e97a02 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/roster-preview.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/roster-preview.test.mjs @@ -205,7 +205,7 @@ test('render: BotRow tolerates a fresh bot with no sessions yet', () => { assert.doesNotMatch(text, /No conversations yet/) }) -test('render: a remote bot keeps its identity while gateway details stay in the tooltip', () => { +test('render: a remote default uses its gateway identity without repeating it', () => { const r = renderRuntime() const tree = r.__BotRow({ bot: { @@ -217,14 +217,13 @@ test('render: a remote bot keeps its identity while gateway details stay in the }, onEdit: () => undefined }) - const name = findNode(tree, node => node.type === 'span' && textOf(node) === 'Hermes') + const name = findNode(tree, node => node.type === 'span' && textOf(node) === 'Studio over SSH') const button = findNode(tree, node => node.type === 'button' && node.props?.['aria-label']) assert.ok(name) assert.ok(button) - assert.match(button.props['aria-label'], /Hermes/) assert.match(button.props['aria-label'], /Studio over SSH/) - assert.doesNotMatch(textOf(button), /Studio over SSH/) + assert.equal((textOf(button).match(/Studio over SSH/g) || []).length, 1) }) test('render: BotRow previews the pinned canonical chat, not an unrelated latest session', () => { diff --git a/apps/desktop/src/sdk/index.ts b/apps/desktop/src/sdk/index.ts index 5f196bbbc396..9495cc969c4f 100644 --- a/apps/desktop/src/sdk/index.ts +++ b/apps/desktop/src/sdk/index.ts @@ -142,6 +142,7 @@ const $focusedSessionOwner = computed( [$focusedStoredSessionId, $sessions, $activeGatewayProfile, $connection], (focused, sessions, activeProfile, connection): PluginFocusedSessionOwner | null => { const activeConnectionId = String(connection?.connectionId || (connection?.mode === 'local' ? 'local' : '')).trim() + const fallback = { connectionId: activeConnectionId, profile: normalizeProfileKey(activeProfile) diff --git a/apps/desktop/src/sdk/profile-routing.test.ts b/apps/desktop/src/sdk/profile-routing.test.ts index 129231aadab5..e70116947f69 100644 --- a/apps/desktop/src/sdk/profile-routing.test.ts +++ b/apps/desktop/src/sdk/profile-routing.test.ts @@ -125,6 +125,7 @@ const { const { $focusedRuntimeId, $focusedSessionState, $focusedStoredSessionId, $sessionStates, $sessionTiles } = await import('@/store/session-states') + const { setWorkspaceScope } = await import('@/components/pane-shell/workspace-scope') const { From 5ff9c53bd86960f57696ecf04c6d84721f9e8f44 Mon Sep 17 00:00:00 2001 From: David Dudok de Wit <5354424+dokterdok@users.noreply.github.com> Date: Sun, 23 Aug 2026 11:52:02 +0200 Subject: [PATCH 09/10] fix(desktop): resolve Bot tiles on their owner --- .../src/app/chat/session-tile-owner-route.test.ts | 13 +++++++++++++ apps/desktop/src/app/chat/session-tile.tsx | 5 +++-- 2 files changed, 16 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/src/app/chat/session-tile-owner-route.test.ts diff --git a/apps/desktop/src/app/chat/session-tile-owner-route.test.ts b/apps/desktop/src/app/chat/session-tile-owner-route.test.ts new file mode 100644 index 000000000000..31058c578dd3 --- /dev/null +++ b/apps/desktop/src/app/chat/session-tile-owner-route.test.ts @@ -0,0 +1,13 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' + +import { describe, expect, it } from 'vitest' + +const source = readFileSync(resolve(process.cwd(), 'src/app/chat/session-tile.tsx'), 'utf8') + +describe('SessionTilePane owner-scoped listing', () => { + it('resolves a newly active tile on its persisted owner route', () => { + expect(source).toContain('void resolveStoredSession(storedSessionId, ownerRoute)') + expect(source).not.toMatch(/void resolveStoredSession\(storedSessionId\)\s*\n/) + }) +}) diff --git a/apps/desktop/src/app/chat/session-tile.tsx b/apps/desktop/src/app/chat/session-tile.tsx index 4a234022dff3..f0479448c197 100644 --- a/apps/desktop/src/app/chat/session-tile.tsx +++ b/apps/desktop/src/app/chat/session-tile.tsx @@ -256,6 +256,7 @@ function TileChat({ export function SessionTilePane({ storedSessionId }: { storedSessionId: string }) { const tiles = useStore($sessionTiles) const tile = tiles.find(t => t.storedSessionId === storedSessionId) + const ownerRoute = tile?.ownerRoute const runtimeId = tile?.runtimeId ?? null const gatewayOpen = useStore($gatewayState) === 'open' const delegateRevision = useStore($sessionTileDelegateRevision) @@ -287,7 +288,7 @@ export function SessionTilePane({ storedSessionId }: { storedSessionId: string } return } - void resolveStoredSession(storedSessionId) + void resolveStoredSession(storedSessionId, ownerRoute) .then(resolved => { if (cancelled || resolved || remaining <= 0) { return @@ -307,7 +308,7 @@ export function SessionTilePane({ storedSessionId }: { storedSessionId: string } window.clearTimeout(timer) } } - }, [hasMessages, runtimeId, storedSessionId]) + }, [hasMessages, ownerRoute, runtimeId, storedSessionId]) // Same gating as the primary's route resume (use-route-resume): never fire // session.resume before the gateway is OPEN. Persisted tiles mount at boot From 1fd2bef0de97d7d0fbddf1ab5bdae9fcebb77352 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 23 Aug 2026 04:01:47 -0700 Subject: [PATCH 10/10] fixup: roster query keeps SDK ambient owner route; alias index refresh preserved --- apps/desktop/src/plugins/hermes-bots/plugin.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/plugins/hermes-bots/plugin.js b/apps/desktop/src/plugins/hermes-bots/plugin.js index a56c91048be6..ece7d449d5ea 100644 --- a/apps/desktop/src/plugins/hermes-bots/plugin.js +++ b/apps/desktop/src/plugins/hermes-bots/plugin.js @@ -4190,7 +4190,6 @@ function useRoster() { // come from the ACTIVE gateway's profiles.list — the canonical Bot // Chat is resolved server-side by NAME (the "Bot Chat" registry row), // so the roster never sends session pointers. - const route = await activeBotRoute() // Refresh the alias identity index alongside the roster: alias routes // (Desktop profile → remote backend root) are what let a backend row // keep its configured friendly identity after activation (#89131). @@ -4203,9 +4202,9 @@ function useRoster() { /* keep the previous alias index */ } } - const activeBot = route - ? { name: route.profile, sourceScoped: true, route } - : { name: String(host.state.profile?.get?.() || 'default').trim() || 'default' } + // Owner routing is ambient in the SDK now (post-#92731): requestForBot + // resolves the active owner itself, no captured route needed here. + const activeBot = { name: String(host.state.profile?.get?.() || 'default').trim() || 'default' } const local = await requestForBot(activeBot, 'profiles.list', {}) // Newer backends inject the teammate-messaging protocol into every // session's system prompt (agent.bot_mode_protocol) — SOUL.md must not