diff --git a/.memory/bot-chat-activity.md b/.memory/bot-chat-activity.md index bc3838e1d..aeb99476c 100644 --- a/.memory/bot-chat-activity.md +++ b/.memory/bot-chat-activity.md @@ -12,7 +12,7 @@ Spec: docs/superpowers/specs/2026-10-14-bot-chat-activity-design.md (§17 lists - Queue/Steer: `pi.inbox` projected as `snapshot.queued` — the person's own inputs only, classified by durable submission requestId (`routine:`/`intro:`) plus the hidden flag, failing closed. Follow-ups show faded with Remove (`bots:withdraw` → `Harness.abortSubmission`: withdrawn | started | gone); steers are normal bubbles. - Advanced → Chat activity (`?page=chat-activity`, `bot-chat-activity-page.tsx`): saves on pick through a serial queue, per-level Reduce Motion still frames, Reduce Motion read live. - `BotAvatar` faces glance via `data-working` with a shared off-screen observer; list's green dot removed. Under Reduce Motion (setting or OS) a working face holds the glance's look-aside pose (`translateX(-2.6px)` on the eyes, no animation), so it still differs from idle. -- Onboarding: the Bots gallery tile (`BotsArt`: name pill, one exchange, routine chip) shows neither the list nor the Updates line, so it needed no redraw. +- Onboarding: the Bots gallery tile (`BotsArt`: name pill, one exchange, routine chip) shows neither the list nor the Updates line. C2 changed only its header (panel toggle instead of a back chevron). ## For slice D (phones) - Remote wire is unchanged by B. D adds `activity` (phase, app ≤ 40, startedAt only) to the session stream and `chatActivity` to detail/PATCH behind `bot-activity-v1`; reuse `botActivityDisplay`, `botElapsedLabel` and `nextBotAnnouncement` semantics and the §5 table as the shared fixture. @@ -30,3 +30,18 @@ Spec: docs/superpowers/specs/2026-10-14-bot-chat-activity-design.md (§17 lists - Queue: Remove focus moves before the withdraw resolves; the fade couples to `MessageBubble`'s private `bg-control` class (prefer a variant prop); the hidden flag is not durable (latent); a missing-id streak is never logged; no backoff on permanent inspect failure (1/s per live feed); an `openFeed` failure could orphan a retry timer. - Runtime: `deps.state` rejection at run end leaves `{preparing}` stuck (pre-existing fallback); interrupted-with-run and approval-flip lack projection-level tests; `onResumed` → projection wire (`bot-session-main.ts`) is untested end to end and unguarded inside serialize. - Chat activity page: `useReducedMotion` subscribe closure re-subscribes every render (hoist); Summary still-frame folded state not asserted; Pause under Reduce Motion freezes mid-loop. + +## Slice C2 (desktop) — what shipped +- Bots sidebar: `renderer/main/bots/bot-sidebar.tsx`, rendered by `ChatSidebar` on any Bots path (`isBotsPath`, `renderer/lib/bot-routes.ts`). Rows from `useBotRows` (`bot-sidebar-rows.ts`): `bots:live:summary` now carries `needsYou` and `lastReplyAt`; `bots:attention` (questions/approvals) and `bots:changed` re-read only the affected Bot's row, synced once per window by `useBotNotificationsSync` (`renderer/lib/bot-notifications-sync.ts`, mounted in `root-view.tsx`). Back to Aiden, Search and New Bot sit at the top of the sidebar body, not the title bar (window controls overlap it). Back goes to `lastNonBotRoute()` (recorded by `ChatLayout`). +- Pin, last-viewed and seen reply: `aiden-agent.bots-sidebar.v1` (`renderer/lib/bot-sidebar-preferences.ts`, hook `use-bot-sidebar-preferences.ts`, selector `useIsBotPinned`), with `since` so old replies are never unread. Unread is `lastReplyAt > (seenReplyAt[bot] ?? since)`: `seenReplyAt` is `botLastReplyAt` of the entries the chat showed (main's clock; a reply streaming when the person leaves is not in them yet), `lastViewed` (this Mac's clock) only picks the Bot `/bots` opens. Pruned only after a settled successful load. Not synced to phones. +- `/bots` (`BotsIndex` in `bots-view.tsx`) redirects (replace) to the last viewed Bot's chat, else the newest-activity Bot (summaries awaited up to 800 ms, then `bot.updatedAt`), showing `BotPageSkeleton` meanwhile; the starter carousel when there are none; the create flow suppresses the redirect. `bot-list.tsx` is gone. +- Bot panel: `BotPanelProvider` (`bot-panel-context.tsx`, keys `aiden-agent.bot-panel.*`) at the root; `BotWorkbench` (`bot-workbench.tsx`) on the C1 `WorkbenchPanel` (`id="bot-panel"`, optional `surface`, `tabMenu` and `widthBounds` props added to the shell; `BOT_PANEL_WIDTH_BOUNDS` 400–720, default 440, in `environment-panel-layout.ts`, so it sits inline beside the chat where the chat keeps 560px and floats only where the workspace panel would too), fixed tabs Activity (`bot-panel-activity.tsx`, `bot-activity-log.ts`; always the full log, independent of the Chat activity level), Files (`bot-panel-files.tsx`, replaced `bot-files-dialog.tsx`), Memory (`BotMemoryContent` shared with Profile → Memory), Routines (`BotRoutines`). Chat route only. +- `environment.toggle` → `environmentToggleTarget(pathname)`: Bot panel in a Bot chat, nothing elsewhere in Bots, workspace otherwise. `EnvironmentWorkbench` is suppressed (state kept, `data-state="covered"`) on Bots paths. +- One live subscription per Bot per window: a refcounted store behind `useBotLive` (`renderer/lib/use-bot-live.ts`); the chat holds it while shown and the Activity tab (memoised on entries/running) shares it. A consumer joining a failed store keeps the last view (flagged failed) while it retries. +- Turns: `walkBotTurns` (`renderer/main/bots/bot-turns.ts`) is the one stop/interrupted/carried-on state machine; `botTranscriptRows` and `botActivityLog` both build on it. +- One ••• menu: `bot-menu.tsx` (`BotMenuItems` with leading icons, `MemoryCardIcon` for Memory; `useBotMenuActions` reads the open chat from `router.state` when an item is chosen, so a Bot switch hands rows no new callback); closing it returns focus to the trigger unless it opened the panel. Deleting a Bot navigates to `/bots` only when that Bot's page is on screen. +- Bots on paired Macs: `RemoteBotsSection` rows in the sidebar ("On {Mac}"), filtered by `remoteBotGroupsMatching` before they are passed in. Search (`renderer/lib/search-fold.ts`) folds only Latin accents. +- "All Bots" stays only where no Bot chat exists to go back to: Profile's loading and not-found shells and the chat's not-found shell. +- Test harness: Bot renderer suites run with `NODE_ENV=test` (`renderer/main/bots/test-env.ts`, first import of `test-dom.ts`, which throws if the router's server build loaded anyway) so TanStack Router behaves as on a client and routed pages re-render; `mountWithBotRouter` takes `sidebar` and mounts inside `act`. Wrap test navigations, sleeps and emitted notifications in `act` (bot-sidebar, bot-chat-pane, bots-home and bot-panel print no act warnings). +- e2e: `tests/e2e/bots-sidebar-panel.spec.ts` (1280×800: the panel leaves the composer uncovered; the workspace surface is inert; `bots:attention` reaches a row through the app's own sync). +- Parked: workspace sidebar remount loses its local state after a Bots visit; batched `bots:live:summaries` IPC (N reads on mount); Profile→Chat pushes history (pre-existing); remote-only shows "Meet your first Bot"; the device mini player reconnects while suppressed (pre-existing); panel close returns focus to the toggle, not the composer (briefed); chip names are basenames (pre-existing); test debt: `lastNonBotRoute` module-state seam and edge cases, dead `deferred()` lines in the Files keep-on-refresh test, the render probe never uninstalls, Task 2 review minors 3, 4 and 6. diff --git a/.memory/bot-durable-runtime.md b/.memory/bot-durable-runtime.md index d2c5d8216..aef72364e 100644 --- a/.memory/bot-durable-runtime.md +++ b/.memory/bot-durable-runtime.md @@ -31,12 +31,12 @@ Plan: `docs/superpowers/plans/2026-10-07-bots-rework.md`. Spec: `docs/superpower - `main/services/bot-runtime/live-projection.ts`: one `conv.watch` feed per Bot, `(epoch, seq)` events, >100 pending collapses to a snapshot, `[SILENT]` turns hidden, connect cards re-resolved on every subscribe. IPC: `bots:live:subscribe|unsubscribe|summary` (`main/handlers/bot-live.ts`), push `bots:live:event`. - `renderer/lib/use-bot-live.ts` applies events and re-subscribes on a gap or epoch change. - Chat route is `/bots/$botId/chat` (no chat id). Tool approvals show as a card in the chat (`bots:pendingApprovals`, `bots:approval`, `bots:approval-settled`, answered with `bots:approve`). `renderer/main/bots/bot-chat-pane.tsx` renders the transcript, the interrupted card (Resume/Dismiss, access-changed copy) and the composer (`Composer` with a `placeholder` override). Sends use `bots:send` with a UUID per message; Steer/Queue map to `whenBusy`; Stop maps to `bots:stop`. `bots:openChat` is gone. A legacy `/chat/` redirects to the Bot route; `ChatPane` renders nothing while it redirects, and its former Bot-mode branches are removed (`MessageList` has no `botPresentation` prop). -- List previews and state come from `bots:live:summary`, invalidated on `bots:changed`. +- Sidebar previews and state come from `bots:live:summary`, re-read per Bot on `bots:changed` and `bots:attention` (`useBotNotificationsSync`). - Create flow: two steps (name and help, then `ConnectionChips`; Skip creates). The self-intro (`bots:introduce`) runs only for a Bot made here with a model. `sendBotIntro` itself refuses unless the Bot is idle and its conversation has no `pi.user`/`pi.assistant` entries, and its fixed `intro:` request id dedupes repeats. - Start Chat has one process-wide starter (`botStarter()`, keys in `bot-creation-keys.json`). Remote `POST /bots/from-preset` reaches it through `createBotPresetCreatorFor` (`main/services/bot-preset-store.ts`), passing only its own `createBot` (Remote audience), so Mac and phone taps share keys, the in-flight lane and the one self-intro. Crash-safe: the starter mints the Bot id and saves `preset:` → id before creating the Bot with exactly that id (`botApplicationService.createBot({ botId })`), so a crash before or after the create never makes a second Bot (a key whose Bot is missing is forgotten). - `bots:live:subscribe` tracks the newest wanted subscribe per document and Bot; one that finishes after an unsubscribe (chat left while opening) releases itself. Deleting a Bot closes its projection (subscribers marked closed). `useBotLive` exposes `failed`; the chat shows Try again instead of loading forever. - `bots:connections:setup` is handled by `BotConnectionSetupHost`, mounted in `root-view.tsx`, so a phone's request opens setup from any page. It is sent to one window only: the focused one, else the most recently focused (`bot-connection-setup-core.ts`). -- First run: `renderer/main/bots/bot-starter-carousel.tsx` is the empty state of the Bots list and the onboarding step's body (`renderer/components/onboarding-bots-step.tsx`, `bots:createFromPreset`). Onboarding progress is the persisted `lastSatisfiedStep` enum; `"bots"` sits between `provider` and `tour`. The step is offered only with the Bots capability and zero Bots (decided when onboarding loads); Start Chat and Skip both persist `"bots"`. Playwright e2e: `tests/e2e/bots-starter.spec.ts`; flows that only need the tour call `skipBotsOnboardingStep` in `tests/e2e/fixtures.ts`. +- First run: `renderer/main/bots/bot-starter-carousel.tsx` is the empty state of `/bots` (no Bots yet) and the onboarding step's body (`renderer/components/onboarding-bots-step.tsx`, `bots:createFromPreset`). Onboarding progress is the persisted `lastSatisfiedStep` enum; `"bots"` sits between `provider` and `tour`. The step is offered only with the Bots capability and zero Bots (decided when onboarding loads); Start Chat and Skip both persist `"bots"`. Playwright e2e: `tests/e2e/bots-starter.spec.ts`; flows that only need the tour call `skipBotsOnboardingStep` in `tests/e2e/fixtures.ts`. - Profile: `bot-routines.tsx` and `bot-routine-editor.tsx` (`bots:routines:*`, labels from the host), photo menu (`bots:photo:set|remove`). - Connections: `use-connection-setup.tsx` opens `PresetSetupDialog`; `main/services/bot-connection-setup.ts` `openConnectionSetup(pluginId)` broadcasts `bots:connections:setup` (for Remote "Finish on your Mac" once that route calls it). `bots:connections:dismiss` persists Not now. - Tests: `bot-chat-pane.test.tsx`, `bot-routines.test.tsx`, `bots-home.test.tsx`, `bot-create-flow.test.tsx`; the test harness mounts the chat route through `test-providers.tsx` (and `installBotTestIpc` can push notifications with `emitBotTestNotification`). @@ -77,7 +77,7 @@ Plan: `docs/superpowers/plans/2026-10-07-bots-rework.md`. Spec: `docs/superpower ## Chat follow-ups (PR #377 gap pass, 2026-10-07) - Failed turns: `projectBotTranscript` turns an `error` reply into a `failed_turn` entry (id `:failed`, after any text it wrote) with `retryText` = the turn's typed message (null for routines, the self-intro and photo-only turns) and the Mac-only `errorMessage`. Remote revision 25 carries `{type:"failed_turn", id, createdAt?, retryText?}` (fixture `botSessionFailedTurns`). Every client shows "I couldn't finish that reply."; only the newest entry offers Retry (`botRetryableFailedTurn`), which sends `retryText` as a new submission with a new request id / Idempotency-Key. - File chips: a confirmed `write_file`/`edit_file` result (`details.producedFile`) with no `location` argument becomes a `file` entry; `aiden.bot-shared-image` becomes a `shared_image` entry. Desktop only: Remote entries carry no file metadata, so phones show no chips. -- Desktop Files: `bots:files:list` / `bots:files:read` (`main/handlers/bot-files.ts`) read the Bot's folder (`resolveManagedWorkspace(botId).homePath`) read-only; `BotFilesDialog` opens from ••• Files or a file chip. Phones use `/bot-conversations/:chatId/files` (chatId from the Bot's canonical conversation). +- Desktop Files: `bots:files:list` / `bots:files:read` (`main/handlers/bot-files.ts`) read the Bot's folder (`resolveManagedWorkspace(botId).homePath`) read-only; the panel's Files tab (`bot-panel-files.tsx`, which replaced `BotFilesDialog` in C2) opens from ••• Files or a file chip. Phones use `/bot-conversations/:chatId/files` (chatId from the Bot's canonical conversation). - Desktop transcript rows come from `botTranscriptRows` (`renderer/main/bots/bot-transcript-rows.ts`): one Updates line per reply, chips, failed cards. Messages have Copy and Reply (right-click or hover/focus •••); Reply quotes into the composer. A Stop button sits under the question card. - Onboarding Start Chat opens `/bots/$botId/chat` once onboarding finishes (`OnboardingFlow onOpenBotChat`). - Bot store: no legacy avatar compatibility; a stored avatar that is not a current recipe reads as `DEFAULT_BOT_AVATAR`. The companion `bot-avatar-appearances.json` is no longer read or written. diff --git a/.memory/bots-rework-ui.md b/.memory/bots-rework-ui.md index 45e4abf8c..dc53e7561 100644 --- a/.memory/bots-rework-ui.md +++ b/.memory/bots-rework-ui.md @@ -5,33 +5,33 @@ Status: implemented on PR #377 (`feature/bot-ui-durable-sessions-2f4474`). Physi See `.memory/bot-chat-activity.md` for the status line, Chat activity and recaps. ## Screen map (desktop, iOS, Android) -- **List:** one list of Bots, with search and +. Row: avatar whose face glances while working, name, subtitle pill, last-message preview, relative time. A paused Bot shows "Paused — tap to resume". Tapping a row opens its chat. Long-press or context menu: Profile, Memory (phones: only when the Mac serves `bot-memory-v1`), Advanced, separator, Delete Bot (2026-10-14; phones label it Delete). Bots from a paired Mac show a small Mac badge. +- **List:** desktop = the Bots sidebar (`bot-sidebar.tsx`; `/bots` opens the most recent Bot's chat, there is no list page); phones = a list of Bots with search and +. Row: avatar whose face glances while working, name, last-message preview ("Thinking…" while working, "Waiting for you" when it needs an answer), unread dot. A paused Bot shows "Paused — tap to resume". Tapping a row opens its chat. Context menu on desktop (same order as ••• everywhere): Profile, Files, Memory, Routines, Advanced, Pin to top | Unpin, separator, Delete Bot; phones: Profile, Memory (only when the Mac serves `bot-memory-v1`), Advanced, separator, Delete (2026-10-14). Bots from a paired Mac show a small Mac badge on phones; on desktop they sit under "On {Mac}" in the sidebar. - **First run ("Meet your first Bot"):** a carousel of starter presets. Start chat creates the preset (idempotent, keyed `preset:`) and opens its chat. Create my own opens the create flow. - **Create (two steps):** name and "What should it help with?", then optional connect-now chips (Back + Create). The self-intro runs only on genuine creation, with a model configured. -- **Chat:** header with back, a name pill that opens Profile, and ••• (Profile, Files, Memory, Advanced, separator, Delete Bot). Phones show Memory only when the Mac serves `bot-memory-v1`. Model choice in New Bot and Advanced (2026-10-14): desktop uses `BotModelPicker`, iOS a Model row pushing `AidenBotModelListView`, Android `AidenModelPickerField`. iOS New Bot and Advanced share `AidenBotEditorDraft.photoModelNeed` (none / choose / unavailable); with no usable image model on the Mac a text-only Bot saves without one. Android New Bot mints its Create key through `AidenBotCreateAttempts` (reuse only for the same request after an uncertain failure) and refetches the catalog after a failed Create so a vanished model is named. Plain bubbles, file chips, A–E quick replies (up to 5 options), connect cards, a status line while it works (Chat activity: Quiet/Summary/Detailed), recaps for Summary/Detailed, faded Queued bubbles with Remove. Long-press gives Copy and Reply. Desktop chat mode hides workspace, git, permission, model and thinking pickers. +- **Chat:** header with a name pill that opens Profile, the panel toggle (desktop), and ••• (desktop: Profile, Files, Memory, Routines, Advanced, Pin to top | Unpin, separator, Delete Bot; phones have a back button and a shorter menu). Desktop has no "All Bots" back: the sidebar's Back to Aiden leaves Bots. Phones show Memory only when the Mac serves `bot-memory-v1`. Model choice in New Bot and Advanced (2026-10-14): desktop uses `BotModelPicker`, iOS a Model row pushing `AidenBotModelListView`, Android `AidenModelPickerField`. iOS New Bot and Advanced share `AidenBotEditorDraft.photoModelNeed` (none / choose / unavailable); with no usable image model on the Mac a text-only Bot saves without one. Android New Bot mints its Create key through `AidenBotCreateAttempts` (reuse only for the same request after an uncertain failure) and refetches the catalog after a failed Create so a vanished model is named. Plain bubbles, file chips, A–E quick replies (up to 5 options), connect cards, a status line while it works (Chat activity: Quiet/Summary/Detailed), recaps for Summary/Detailed, faded Queued bubbles with Remove. Long-press gives Copy and Reply. Desktop chat mode hides workspace, git, permission, model and thinking pickers. - **Profile:** photo (Choose photo, Generate on iOS only, Remove photo), inline name and subtitle, Character (colour and shape, Reset to default), Instructions full-screen editor, Routines, and ••• (Advanced, Delete Bot). - **Advanced:** model and image model (auto by default), Full or Custom access, opening greeting, a Chat activity row that opens its own page (`?page=chat-activity`: Quiet/Summary/Detailed and Follows the work / Always “Thinking”, saved on pick; desktop only until slice D), and the Telegram binding (desktop only). - **Delete:** a confirmation dialog with a destructive button. ## Desktop layout and save model (2026-10-09 redo) -- Every Bots screen uses the shared `ScrollArea` (drag-region toolbar, sidebar-collapsed inset, scroll-edge fade). Home: toolbar "Bots" + **New Bot**, content `mx-auto max-w-3xl px-5`, always-visible search field (icon + Clear) once Bots exist, skeleton rows while loading, red `Callout` + Try again on failure. Rows keep the context menu and add a hover/focus-revealed ••• (Profile, Memory, Advanced, separator, Delete Bot). "Delete Bot" is the label in every menu. +- Every Bots screen uses the shared `ScrollArea` (drag-region toolbar, sidebar-collapsed inset, scroll-edge fade). The Bot list lives in the Bots sidebar (`bot-sidebar.tsx`); there is no list page. `/bots` (`BotsIndex` in `bots-view.tsx`) waits for a settled, successful Bots load, then replace-navigates to the last-viewed Bot's chat (`lastViewedBotId`), else the newest-activity Bot's chat; with no Bots it shows the starter carousel with **New Bot** in the toolbar, while loading a skeleton, on failure a red `Callout` + Try again. Profile's back reads "Chat". "Delete Bot" is the label in every menu. - First run copy is sentence case: "Meet your first Bot", "Start chat" (quiet filled), "Create my own" (the one accent action; omitted in onboarding). - Profile, Instructions and Advanced share `bots/bot-page-shell.tsx` (`BotPageShell` = ScrollArea + `settings-responsive` column + `SettingsPage`; toolbar title is the Bot's name, back on the left). Groups/rows are `FieldSet`/`Field`. `/bots/$botId?page=advanced|instructions` opens a sub-page directly (route `validateSearch` = `parseBotPageSearch`); moving between Profile and sub-pages afterwards is local state (a search-only navigation stalls in the test router). - Scrollports carry `scrollRestorationId` (`ScrollArea` prop → `data-scroll-restoration-id`); without it TanStack scroll restoration copied the chat's offset onto the Profile's same-position scrollport. - Save model: **Profile auto-saves each control** (name/subtitle on blur or Return, character on click, photo at once). **Instructions and Advanced are drafts saved by one toolbar Save**; Back with unsaved edits opens a "Discard changes?" AlertDialog (`useDiscardChangesGuard`). Advanced's Save covers model, access and opening greeting together; Telegram Connect/Disconnect stay immediate actions (account links, like Settings connection rows). Instructions can't be saved empty (main requires them); the editor shows a count against `BOT_LIMITS.instructionsChars`. - Create flow: Back on step 2 uses the new shared `Dialog` prop `cancelKeepsOpen` (no `DialogPrimitive.Close`), keeping the draft. No inline Skip: footer is Back + Create. A new Bot gets Full access, which already covers every connected app, so chips are "connect now" shortcuts (`ConnectionChips` `connected`/`onConnect`; already-set-up mcp presets show a check), not a per-Bot selection. Composio chip reads "More apps (Composio)". - Chat: `ScrollArea` with `autoScrollToBottom` (follows only while at the bottom) + scroll-to-bottom button; transcript/footer use `aiden-dock-inset chat-content-column`, so Appearance → Chat width applies. Interrupted, failed, model error, approval and connect cards share `components/bots/bot-notice-card.tsx` (status icon fill, title, quiet text, trailing actions; approvals are elevated in the footer like the workspace approval card). `model_error` offers **Choose a model** (Advanced) and **Open Providers**; "Open Advanced" on an access-changed interruption now really opens Advanced. Loading is bubble skeletons. -- Routines group: inset-separated rows with chevrons, an "Add routine" row, skeleton/empty/error rows. Routine editor uses `FieldLabel`/`Input` (date, time)/`Select` (month day) and a red `Callout` for errors. Files dialog has a single Close, a friendly title (Bot's files / file name with its path as description), skeletons, Try again, and an empty state. +- Routines group: inset-separated rows with chevrons, an "Add routine" row, skeleton/empty/error rows. Routine editor uses `FieldLabel`/`Input` (date, time)/`Select` (month day) and a red `Callout` for errors. The Files tab of the Bot panel (`bot-panel-files.tsx`, formerly a dialog) has a tree, a file view with its path, skeletons, Try again, and an empty state. ## Key files -- Desktop: `renderer/main/bots-view.tsx` (route shell), `renderer/main/bots/*` (`bot-page-shell.tsx`, `bot-page-search.ts`, `bot-list.tsx`, `bot-starter-carousel.tsx`, `bot-create-flow.tsx`, `bot-model-choice.ts`, `bot-model-picker.tsx` (`BotModelPicker`), `bot-chat-pane.tsx`, `bot-chat-header.tsx`, `bot-profile.tsx`, `bot-advanced.tsx`, `bot-delete-dialog.tsx`, `bot-routines.tsx`, `bot-routine-editor.tsx`, `bot-character-card.tsx`, `bot-status-line.tsx`, `bot-activity-recap.tsx`, `bot-activity-steps.ts`, `bot-chat-activity-page.tsx`, `bot-session-state.ts`, `use-connection-setup.tsx`), `renderer/components/bots/connect-card.tsx`, `connection-chips.tsx` and `bot-notice-card.tsx`, shared helpers `renderer/shared/bot-activity.ts`, `main/services/bot-runtime/bot-activity-detail.ts`, `renderer/shared/bot-presets.ts`, `bot-routine-label.ts`, `bot-routine-schedule.ts`, `bot-connections.ts`, live hook `renderer/lib/use-bot-live.ts`. +- Desktop: `renderer/main/bots-view.tsx` (route shell), `renderer/main/bots/*` (`bot-sidebar.tsx`, `bot-sidebar-rows.ts`, `bot-menu.tsx`, `bot-workbench.tsx`, `bot-panel-context.tsx`, `bot-panel-activity.tsx`, `bot-panel-files.tsx`, `bot-activity-log.ts`, `bot-page-shell.tsx`, `bot-page-search.ts`, `bot-starter-carousel.tsx`, `bot-create-flow.tsx`, `bot-model-choice.ts`, `bot-model-picker.tsx` (`BotModelPicker`), `bot-chat-pane.tsx`, `bot-chat-header.tsx`, `bot-profile.tsx`, `bot-advanced.tsx`, `bot-delete-dialog.tsx`, `bot-routines.tsx`, `bot-routine-editor.tsx`, `bot-character-card.tsx`, `bot-status-line.tsx`, `bot-activity-recap.tsx`, `bot-activity-steps.ts`, `bot-chat-activity-page.tsx`, `bot-session-state.ts`, `use-connection-setup.tsx`), `renderer/components/bots/connect-card.tsx`, `connection-chips.tsx` and `bot-notice-card.tsx`, shared helpers `renderer/shared/bot-activity.ts`, `main/services/bot-runtime/bot-activity-detail.ts`, `renderer/shared/bot-presets.ts`, `bot-routine-label.ts`, `bot-routine-schedule.ts`, `bot-connections.ts`, live hook `renderer/lib/use-bot-live.ts`, `renderer/lib/bot-routes.ts`, `renderer/lib/bot-sidebar-preferences.ts`. - iOS: `ios/AidenOnTheGo/Features/Bots/*` (`AidenBotsHomeView`, `AidenBotPresetsView`, `AidenBotCreateView`, `AidenBotSessionChatView`, `AidenBotSessionModel`, `AidenBotProfileView`, `AidenBotAdvancedView` (also `AidenBotModelRow`, `AidenBotModelListView`, `aidenBotModelListSections`), `AidenBotRoutinesView`, `AidenBotCharacter`, `AidenBotImagePlaygroundView`), models `Models/AidenBotSession.swift` and `Models/AidenBotRoutine.swift`. - Android: `android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/*` (`AidenBotsHomeScreen`, `AidenBotPresetCarousel`, `AidenBotSessionScreen`, `AidenBotSessionController`, `AidenBotProfileScreen`, `AidenBotAdvancedScreen`, `AidenBotModelChoice.kt` (`aidenBotModelPickerProviders`, `aidenBotRecommendedModel`), `AidenBotRoutines`, `AidenBotDeleteDialog`, `AidenBotCharacterCard`, `AidenBotChatHeader`, `AidenBotCanonicalAvatarView`). - Remote host: `main/services/aiden-remote-protocol.ts` (revision and feature tokens), `main/services/aiden-remote-router.ts` (routes), `main/services/aiden-remote-bot-session.ts`. - Main: `main/services/bot-runtime/*`, `main/services/bot-connection-setup.ts`, `main/services/bot-connection-dismissals.ts`, `main/services/scheduled-bot-routines.ts`. ## Deleted -- Desktop: the Conversations section and `startConversation`, the `BotEditor` wizard, `BotAccessSummary`, `BotFaceStudio`, the LLM avatar generator (`bot-avatar-generator-core.ts`, `bots:suggestAvatar`, `cancelAvatarSuggestion`), archive UI, `bots:openChat`, `bot-updates.tsx` and `bot-reply-projection.ts` (2026-10-14). +- Desktop: the Conversations section and `startConversation`, the `BotEditor` wizard, `BotAccessSummary`, `BotFaceStudio`, the LLM avatar generator (`bot-avatar-generator-core.ts`, `bots:suggestAvatar`, `cancelAvatarSuggestion`), archive UI, `bots:openChat`, `bot-updates.tsx` and `bot-reply-projection.ts` (2026-10-14); `bot-list.tsx` and `bot-files-dialog.tsx` (2026-10-14, C2). - iOS: `Prototype/BotFirstPrototype.swift` and its snapshot test, `AidenBotEditorView`, `AidenBotCustomAccessFlowView`, Favorites, the compose chooser, the Full Access notice wall, the "Set Up Image Understanding" alert. - Android: `prototype/BotFirstPrototype.kt`, `AidenBotCustomAccessFlowScreen.kt`, `features/remote/AidenBotChatToolsView.kt`, `AidenBotImagePlaygroundView.kt`, Favorites, FAB chooser, Recent Chats, eye and accessory pickers. - Remote: `/bot-favorites`, `/chats/:chatId/capabilities` for Bots, `/bots/:id/archive|restore` (now refused). Delete is `DELETE /bots/{botId}`. diff --git a/docs/plans/README.md b/docs/plans/README.md index f83041a91..b8f85e728 100644 --- a/docs/plans/README.md +++ b/docs/plans/README.md @@ -13,7 +13,7 @@ Last refreshed 2026-10-01 against `main` at `d2197dfef` (0.51.0 plus later merge - [Bot memory, soul and proactivity](2026-10-09-bot-memory-soul-proactivity.md) — Merged in [PR #433](https://github.com/sambitcreate/aiden-agent/pull/433) after PR #428; ships in 0.56.0. Spec: [design](../superpowers/specs/2026-10-09-bot-memory-soul-proactivity-design.md). Hermes-style bounded per-Bot memory (`MEMORY.md`/`USER.md` under `/bots//memory/`, main-owned `bot_memory` tool, frozen prompt snapshot, compaction flush and steered summary, add-only background review every 10 person turns) with Profile → Memory on desktop, iOS and Android; routine results to phones and Telegram, catch-up after pause, approve/Not now routine proposals, per-routine notes and a daily check-in suggestion; `openingGreeting` removed. Persona stays in the Bot record (no `SOUL.md` file). Three parallel tracks (A memory core, B proactivity + Remote, C clients); Remote contract revision 27 is provisional and is claimed at merge (`bot-memory-v1`, `bot-proactive-v1`, device capability `bot:cards`). -- [Bot chat activity, Bots sidebar and side panel](../superpowers/specs/2026-10-14-bot-chat-activity-design.md) — Active (2026-10-14). Five stacked slices: A quick fixes ([plan](../superpowers/plans/2026-10-14-bot-chat-activity-a-quick-fixes.md): model choice in New Bot on desktop/iOS/Android, Advanced and Memory in every ••• menu, Advanced model control fix; [PR #475](https://github.com/sambitcreate/aiden-agent/pull/475)), B quiet status line + per-Bot Chat activity setting ([plan](../superpowers/plans/2026-10-14-bot-chat-activity-b-status-line.md); [PR #477](https://github.com/sambitcreate/aiden-agent/pull/477), stacked on #475), C1 workbench shell extraction ([plan](../superpowers/plans/2026-10-14-bot-chat-activity-c1-workbench-shell.md); [PR #476](https://github.com/sambitcreate/aiden-agent/pull/476), stacked on B), C2 Bots-only sidebar + Bot side panel (stacked on C1), D phones (Remote revision 30, `bot-activity-v1`). Plans are written before each slice starts. +- [Bot chat activity, Bots sidebar and side panel](../superpowers/specs/2026-10-14-bot-chat-activity-design.md) — Active (2026-10-14). Five stacked slices: A quick fixes ([plan](../superpowers/plans/2026-10-14-bot-chat-activity-a-quick-fixes.md): model choice in New Bot on desktop/iOS/Android, Advanced and Memory in every ••• menu, Advanced model control fix; [PR #475](https://github.com/sambitcreate/aiden-agent/pull/475)), B quiet status line + per-Bot Chat activity setting ([plan](../superpowers/plans/2026-10-14-bot-chat-activity-b-status-line.md); [PR #477](https://github.com/sambitcreate/aiden-agent/pull/477), stacked on #475), C1 workbench shell extraction ([plan](../superpowers/plans/2026-10-14-bot-chat-activity-c1-workbench-shell.md); [PR #476](https://github.com/sambitcreate/aiden-agent/pull/476), stacked on B), C2 Bots-only sidebar + Bot side panel ([plan](../superpowers/plans/2026-10-14-bot-chat-activity-c2-sidebar-panel.md); implemented, stacked on C1), D phones (Remote revision 30, `bot-activity-v1`). Plans are written before each slice starts. - [Bots rework (Grok-style Bots + Pi Durable sessions)](../superpowers/specs/2026-10-07-bots-rework-design.md) — Implemented on [PR #377](https://github.com/sambitcreate/aiden-agent/pull/377); physical-device acceptance pending. Plan: [implementation plan](../superpowers/plans/2026-10-07-bots-rework.md). Bots are single-thread chats on desktop, iOS and Android, each with one Pi Durable session on `@earendil-works/pi-durable` 1.0.3 (`/bots//session.sqlite`) and explicit Resume/Dismiss after an interruption. Also shipped: per-Bot routines, starter presets, consent-first connection cards and a permanent Delete that replaces Archive. Aiden Remote is at contract revision 25 (`bot-delete-v1`, `bot-durable-session-v1`, `bot-routines-v1`, `bot-connection-requests-v1`, `bot-presets-v1`). Known follow-ups are listed in `.memory/bots-rework-ui.md`. diff --git a/docs/superpowers/plans/2026-10-14-bot-chat-activity-c2-sidebar-panel.md b/docs/superpowers/plans/2026-10-14-bot-chat-activity-c2-sidebar-panel.md new file mode 100644 index 000000000..a43ba7348 --- /dev/null +++ b/docs/superpowers/plans/2026-10-14-bot-chat-activity-c2-sidebar-panel.md @@ -0,0 +1,4002 @@ +# Bot chat activity · Slice C2 (Bots sidebar and Bot side panel, desktop) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** On any `/bots` route the left sidebar lists only Bots (back, Search, New Bot, a pinned Bot, rows with working faces, "Needs you" and unread dots, Bots on paired Macs, profile and Connect apps), `/bots` opens the most recently used Bot's chat instead of a list page, and a Bot chat gets a side panel on the C1 `WorkbenchPanel` with Activity, Files, Memory and Routines tabs, opened from a header toggle, the `environment.toggle` shortcut, file chips and the ••• menu. + +**Architecture:** Pure helpers own the rules: `renderer/lib/bot-routes.ts` (which surface a path is on, where ‹ goes back to, what the toggle shortcut acts on) and `renderer/lib/bot-sidebar-preferences.ts` (the per-device pin and last-viewed record). Main adds `needsYou` and `lastReplyAt` to the existing `bots:live:summary` and broadcasts `bots:attention` when a question or approval appears or settles. The renderer adds a `BotPanelProvider` (open state, tab, file request, focus hand-off) at the root next to `EnvironmentPanelProvider`, a `BotWorkbench` that mounts the C1 `WorkbenchPanel` beside the Bot chat with fixed Bot tabs, one shared live subscription per Bot (`BotLiveProvider`) so the chat and the Activity tab never fight over `bots:live:subscribe`, one shared ••• menu (`BotMenuItems`), and a `BotSidebar` that `ChatSidebar` renders on Bots paths. The workspace Environment surfaces are suppressed on Bots paths, so their open state and tabs come back untouched. + +**Tech Stack:** Electron main (Node), React 19 + TanStack Router/Query, Radix menus via `renderer/components/ui`, lucide-react, `node:test` via `tsx --test`, Testing Library on happy-dom (`renderer/main/bots/test-dom.ts`), Playwright Electron e2e (`tests/e2e`). + +**Spec:** [`docs/superpowers/specs/2026-10-14-bot-chat-activity-design.md`](../specs/2026-10-14-bot-chat-activity-design.md): §2 decisions 5, 6, 10, 12; §4 slice C2; §8; §9; §10 (••• menu); §14; §15; §16. + +**Slice plans (linear stack A → B → C1 → C2):** A ([quick fixes](2026-10-14-bot-chat-activity-a-quick-fixes.md), implemented), B ([status line](2026-10-14-bot-chat-activity-b-status-line.md)), C1 ([workbench shell](2026-10-14-bot-chat-activity-c1-workbench-shell.md)), C2 (this), D (phones). + +## What this plan consumes from B and C1 (exact names) + +- **C1** (`renderer/components/workbench-panel.tsx`): `WorkbenchPanel(props: WorkbenchPanelProps)`, `interface WorkbenchTab { id; viewId; label; icon?; selected; closable?; title?; onRename?; editor?; wrap?; dataAttributes? }`, `interface WorkbenchView { id; render(context: { presented; selected; width }) }`, `useWorkbenchWidth(storageKey): WorkbenchWidthState` (`preferredWidth`, `setPreferredWidth`, `commitPreferredWidth`, `resizing`, `setResizing`). Layout: `resolveEnvironmentPanelLayout(preferredWidth, containerWidth): { width; inline }` in `renderer/lib/environment-panel-layout.ts`. `EnvironmentWorkbench({ suppressed })` keeps Quick View, the device mini player and its Escape handler off while suppressed. +- **B** (`renderer/shared/bot-live.ts`): `BotTranscriptToolCall { id; name; kind: BotToolPhase; label; detail?; app? }`; the `tool_result` entry's `status: BotToolResultStatus` (`"ok" | "failed" | "blocked"`); snapshots carry `activity` and `queued`. (`main/services/bot-runtime/live-projection.ts`): `BotLiveProjectionDeps.approvalPending?(botId)` and the inner `waitingOn(botId)` helper of `createBotLiveProjection`; `botApprovals.onChange` is already wired in `bot-session-main.ts`. (`renderer/main/bots/bot-activity-steps.ts`): `BotRecapStep`, `botStepLine(step) → { text; detail?; tone: "normal" | "warning" | "error"; active }`. (`renderer/components/bot-avatar.tsx`): `BotAvatar` prop `working?: boolean` (root gets `data-working`). `agent-thinking-shimmer` animates only under `data-reduce-motion="false"`. + +## Rulings on spec ambiguities + +1. **Where the panel lives.** §8 says "on `/bots/$botId`", and the toggle "sits in the Bot chat header". The panel mounts on the chat route `/bots/$botId/chat` only. Profile and its sub-pages stay full pages (Profile → Memory stays, §8). ••• Files/Memory/Routines from anywhere open that Bot's chat with the panel on that tab. `environment.toggle` toggles the Bot panel in a Bot chat, does nothing on other Bots pages, and keeps today's behaviour everywhere else. +2. **Remote Bots go in the Bots sidebar**, under an "On {Mac}" group below this Mac's Bots, as compact rows that open the host chat exactly as today. Why: the `/bots` main area no longer has a list, the sidebar is now where every Bot is listed, a remote Bot's state lives on that Mac (no panel, no pin, no unread, §13), and opening one navigates to `/host/…`, which is not a Bots path, so the normal sidebar returns there. Search filters them too. The accessible contract of `RemoteBotsSection` (headings, button names, disabled reasons, `aria-busy`) is unchanged. +3. **"Most recently used Bot"** = the Bot last viewed on this Mac (per-Bot last-viewed preference) when it still exists, otherwise the Bot with the newest activity (the sidebar's first row). The redirect replaces the history entry. +4. **Unread baseline.** The preference record stores `since`, the time it was first created. A Bot never viewed on this Mac is unread only if a reply arrived after `since`, so the first launch after updating does not light up every Bot. The open Bot never shows a dot. Viewing a Bot's chat marks it viewed. +5. **Pinned Bot** appears only as the hero, not also as a row. While searching, the hero is hidden and the pinned Bot is filtered like any row. Deleting the pinned Bot clears the pin. Pruning runs only on a successful, non-empty Bot list, so a transient empty read cannot drop the pin. +6. **Footer "profile photo".** Aiden's `Profile` has only a name, so the footer shows the name's initial in a neutral circle (a `UserRound` glyph when empty) and opens `/profile`. **Connect apps** opens Settings → Plugins (`/settings?section=mcp`), where connections are set up. +7. **Back and Search.** ‹ ("Back to Aiden") returns to the last chat-shell page outside Bots this session (default `/`). The Search button reveals a search field and focuses it; Escape clears it, a second Escape closes it. +8. **Chat header.** The "All Bots" back button leaves the Bot chat header (the sidebar now holds the list). Profile's back reads "Chat" and returns to that Bot's chat. +9. **"Memory updated"** opens the panel's Memory tab, like ••• Memory. +10. **Row line.** Running and not waiting: "Thinking…" in shimmer. Waiting on an approval or question: "Waiting for you" (no shimmer) plus the "Needs you" badge. Otherwise the paused / needs-model / unavailable status, else the last message preview. Rows show no time. +11. **Activity tab** lists every step unmerged (questions included), grouped by the message or routine that started the reply, newest reply first, steps oldest first within a reply. Replies without steps are left out. A step still "running" in a reply that is not the live one reads as failed, as in B's recap. Data comes from the live snapshot's transcript entries; no new IPC. +12. **Panel defaults.** First open lands on Activity. Open state, tab and width persist per device under `aiden-agent.bot-panel.*`, never under `aiden-agent.environment.*`. Tabs are fixed (`closable: false`) and there is no "+". +13. **Quick View** (`quick-view.toggle`) does nothing on Bots pages, since its surface is suppressed there. + +## Global Constraints + +- Contract change is **Desktop IPC only** (§4): `BotLiveSummary` gains `needsYou: boolean` and `lastReplyAt: number | null`; a new main → renderer notification `bots:attention` `{ botId }` is added to `NOTIFICATION_CHANNELS`. No Remote route, wire field, feature token or protocol revision changes. iOS, Android and `packages/cli` are untouched. +- Pin and last-viewed are desktop UI preferences in `localStorage` under `aiden-agent.bots-sidebar.v1` (`{ version: 1, since, pinnedBotId, lastViewed }`), never part of `BotDefinition`, never synced to phones (§13). One pinned Bot at most. +- Bot panel keys: `aiden-agent.bot-panel.open`, `aiden-agent.bot-panel.tab`, `aiden-agent.bot-panel.width`. The `aiden-agent.environment.*` and `aiden-agent.quick-view.open` keys are never written from a Bots page. +- DOM: the panel is `id="bot-panel"`, `aria-label="Bot panel"`, tab strip `aria-label="Bot panel views"`, tab ids `bot-panel--tab` / `-panel`, resize separator "Resize Bot panel", Close "Close Bot panel" with title `Close panel (${environment.toggle shortcut})`. The header toggle carries `data-bot-panel-toggle`, `aria-controls="bot-panel"`, `aria-pressed`, and reads "Show panel" / "Hide panel". +- ••• menu order everywhere on desktop (chat header, row hover, right-click, pinned hero): Profile, Files, Memory, Routines, Advanced, Pin to top | Unpin, separator, Delete Bot. +- Copy, exact: "Back to Aiden", "Search Bots", "New Bot", "Pinned Bot", "Your Bots", "Needs you", "Unread", "Thinking…", "Waiting for you", "No Bots yet", "No matching Bots", "Your Bots couldn’t be loaded", "Try again", "Connect apps", "Profile", "Activity", "Files", "Memory", "Routines", "No activity yet", "All files", "Pin to top", "Unpin", "Chat". +- Memory uses `MemoryCardIcon`. Never a brain icon or illustration. +- UI rules (AGENTS.md, `GUI.md`, `docs/design-guide.md`): shared `Button` primitives, semantic tokens only, status in soft fills (`Badge color="warning"`), no decorative borders or outlines on badges, rows or cards, every non-text control keeps a `focus-visible` ring, the search field gets no accent border on focus (use the shared `Input`). Before writing Tasks 7 and 10, read `docs/chatgpt-desktop-ui-inspiration.md` and `docs/chatgpt-ui-element-specimen.html` and adapt, not copy. +- Motion: faces and marks freeze under Reduce Motion and off-screen (B's `BotAvatar`); shimmer only via `agent-thinking-shimmer`. +- No network calls are added. +- Tests are behavioural (AGENTS.md): no `readFileSync`/`assert.match` on production source, no class-string or snapshot assertions. Every new test file goes in `package.json` `test:bots` **and** the `renderer-other` lane of `scripts/ci-test-registry.json`; deleted test files leave both. `npm run test:ci-policy` must pass after each task that touches registration. +- Keep `renderer/main/chat-layout.tsx`'s `(pathname.startsWith("/bots") && !params.chatId)` expression unchanged (an existing source-grep in `renderer/main/bots-view.test.tsx` reads it; do not add new greps). +- Known pre-existing failure, out of scope: `renderer/lib/inline-metadata-hierarchy.test.ts` ("secondary menu suffixes…") already fails on this branch because it greps `bots-view.tsx` for markup that moved in slice A. Do not "fix" it by adding markup. + +## Review Focus + +1. **Two views of one Bot's live stream** (the chat and the Activity tab, or a panel tab closed while the chat stays). Expected: one `bots:live:subscribe` per Bot per window; switching or closing a tab never unsubscribes, and the chat keeps updating. Test: Task 5, Step 1, "a Bot's chat and its Activity tab share one live subscription…". +2. **The pinned or last-viewed Bot is deleted** (from the menu, another window, or a phone). Expected: the hero disappears and the pin is cleared; `/bots` opens the next most recent Bot, never "Bot not found". Tests: Task 10, Step 1, "Pin to top shows the Bot large…"; Task 11, Step 1, "with nothing viewed yet, or the last one deleted…". +3. **A hand-edited, foreign or garbage `aiden-agent.bots-sidebar.v1` value** (not JSON, `version: 2`, wrong types, thousands of entries). Expected: the sidebar loads with no pin and nothing viewed, and nothing throws. Test: Task 2, Step 1, "a stored record that is missing, malformed or from a newer build…". +4. **The first launch after updating, with Bots that replied long ago.** Expected: no unread dots until a new reply arrives. Tests: Task 2, Step 1, "a Bot is unread only for a reply after it was last viewed…"; Task 10, Step 1, "a reply since the person last looked shows an unread dot…". +5. **`environment.toggle` pressed in a Bot chat** (and the workspace panel was open before). Expected: only the Bot panel toggles; the workspace panel's saved open state and open tabs are untouched, and leaving Bots shows it exactly as it was. Tests: Task 1, Step 1, "the toggle shortcut acts on the Bot panel…"; Task 7, Step 1, "Escape inside the Bot panel closes it…"; Task 12, Step 1 (e2e). + +--- + +## File map + +| File | Responsibility | +|---|---| +| Create `renderer/lib/bot-routes.ts` (+ `.test.ts`) | `isBotsPath`, `botChatRouteBotId`, `botRouteBotId`, `environmentToggleTarget`, `botChatPath`, `rememberRoute`, `lastNonBotRoute` | +| Create `renderer/lib/bot-sidebar-preferences.ts` (+ `.test.ts`) | Pure pin / last-viewed record: parse, serialize, update, prune, unread, last viewed | +| Create `renderer/main/bots/use-bot-sidebar-preferences.ts` | `useSyncExternalStore` hook over that record with stable actions | +| Modify `renderer/shared/bot-live.ts`, `main/services/bot-runtime/live-projection.ts` (+ test), `main/handlers/bot-live.test.ts`, `main/services/bot-runtime/bot-session-main.ts`, `renderer/preload-channels.ts` | `needsYou`, `lastReplyAt`, `botLastReplyAt`, `bots:attention` | +| Create `renderer/main/bots/bot-panel-context.tsx` | `BotPanelProvider`, `useBotPanel`, tab ids and storage keys | +| Modify `renderer/main/root-view.tsx`, `renderer/main/chat-layout.tsx`, `renderer/main/bots/test-providers.tsx` | Provider at the root; toggle routing; suppression and route memory on Bots paths; test harness | +| Modify `renderer/lib/use-bot-live.ts` | `BotLiveProvider`, `useSharedBotLive` | +| Create `renderer/main/bots/bot-activity-log.ts` (+ `.test.ts`) | Activity grouping and the "Today, 6:41 PM" label | +| Create `renderer/main/bots/bot-workbench.tsx`, `bot-panel-activity.tsx`, `bot-panel-files.tsx`; test `renderer/main/bots/bot-panel.test.tsx` | The Bot side panel and its tabs | +| Modify `renderer/main/bots/bot-memory-page.tsx` | Extract `BotMemoryContent` (page and compact panel share it) | +| Modify `renderer/main/bot-chat-route.tsx`, `renderer/main/bots/bot-chat-header.tsx` (+ test), `renderer/main/bots/bot-chat-pane.tsx` (+ test) | Workbench around the chat, `BotPanelToggle`, chips/menu/caption open the panel, shared menu, mark viewed | +| Delete `renderer/main/bots/bot-files-dialog.tsx` | Replaced by the Files tab | +| Create `renderer/main/bots/bot-menu.tsx` | `BotMenuItems`, `useBotMenuActions` | +| Create `renderer/main/bots/bot-sidebar-rows.ts`, `renderer/main/bots/bot-sidebar.tsx`; test `renderer/main/bots/bot-sidebar.test.tsx` | Rows, ordering, search, the Bots sidebar | +| Modify `renderer/components/chat-sidebar.tsx` | `ChatSidebar` renders `BotSidebar` on Bots paths | +| Modify `renderer/components/remote-bots-section.tsx`, `renderer/main/remote-bots.tsx`, `renderer/main/remote-new-chat-view.test.tsx` | Sidebar rows and search for Bots on paired Macs | +| Delete `renderer/main/bots/bot-list.tsx`, `renderer/main/bots/bot-list.test.tsx` | Behaviours migrated to `bot-sidebar.test.tsx` | +| Modify `renderer/main/bots-view.tsx`, `renderer/main/bots/bot-profile.tsx`, `renderer/main/bots/bots-home.test.tsx` | `/bots` index redirect and starter page; Profile's back | +| Create `tests/e2e/bots-sidebar-panel.spec.ts` | Sidebar → latest Bot → Files tab shows a produced file; toggle isolation | +| Modify `renderer/components/onboarding-art/control-art.tsx` | Bots tile header follows the new chat header | +| Modify `package.json`, `scripts/ci-test-registry.json`, the spec, `docs/plans/README.md`, `.memory/bot-chat-activity.md`, `.memory/bots-rework-ui.md` | Registration, docs, memory | + +--- +### Task 1: Bots route helpers and the ‹ back target + +**Files:** +- Create: `renderer/lib/bot-routes.ts` +- Test: `renderer/lib/bot-routes.test.ts` +- Modify: `renderer/main/bots-view.tsx:21-24` (`botChatPath` moves out; re-exported) +- Modify: `package.json` (`test:bots`), `scripts/ci-test-registry.json` (`renderer-other`) + +**Interfaces:** +- Consumes: nothing. +- Produces (all in `renderer/lib/bot-routes.ts`): + - `isBotsPath(pathname: string): boolean` + - `botChatRouteBotId(pathname: string): string | null` (`/bots/$botId/chat` only) + - `botRouteBotId(pathname: string): string | null` (`/bots/$botId` or `/bots/$botId/chat`) + - `type EnvironmentToggleTarget = "bot-panel" | "workspace" | "none"`; `environmentToggleTarget(pathname): EnvironmentToggleTarget` + - `botChatPath(botId): { to: "/bots/$botId/chat"; params: { botId: string } }` + - `rememberRoute(href: string): void`, `lastNonBotRoute(): string` (default `"/"`) + +- [ ] **Step 1: Write the failing tests** + +```ts +// renderer/lib/bot-routes.test.ts +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + botChatPath, + botChatRouteBotId, + botRouteBotId, + environmentToggleTarget, + isBotsPath, + lastNonBotRoute, + rememberRoute, +} from "./bot-routes"; + +test("Bots pages are /bots and everything under it, and nothing that only starts with the same letters", () => { + for (const path of ["/bots", "/bots/", "/bots/b1", "/bots/b1/chat"]) assert.equal(isBotsPath(path), true, path); + for (const path of ["/", "/botsy", "/chat/c1", "/host/h1/chat/c1", "/settings"]) assert.equal(isBotsPath(path), false, path); +}); + +test("a Bot page names its Bot, and only the chat route counts as the Bot's chat", () => { + assert.equal(botRouteBotId("/bots/9f0c/chat"), "9f0c"); + assert.equal(botRouteBotId("/bots/9f0c"), "9f0c"); + assert.equal(botRouteBotId("/bots"), null); + assert.equal(botChatRouteBotId("/bots/9f0c/chat"), "9f0c"); + assert.equal(botChatRouteBotId("/bots/9f0c"), null); + assert.equal(botChatRouteBotId("/bots/%E0%A4%A/chat"), null, "a malformed segment is no Bot"); + assert.deepEqual(botChatPath("9f0c"), { to: "/bots/$botId/chat", params: { botId: "9f0c" } }); +}); + +test("the toggle shortcut acts on the Bot panel in a Bot chat, on nothing elsewhere in Bots, and on the workspace panel outside Bots", () => { + assert.equal(environmentToggleTarget("/bots/9f0c/chat"), "bot-panel"); + assert.equal(environmentToggleTarget("/bots"), "none"); + assert.equal(environmentToggleTarget("/bots/9f0c"), "none"); + assert.equal(environmentToggleTarget("/chat/c1"), "workspace"); + assert.equal(environmentToggleTarget("/scheduled"), "workspace"); +}); + +test("‹ goes back to the last page outside Bots, never to a Bots page", () => { + assert.equal(lastNonBotRoute(), "/"); + rememberRoute("/chat/c1?draft=1"); + rememberRoute("/bots/9f0c/chat"); + rememberRoute("/bots"); + assert.equal(lastNonBotRoute(), "/chat/c1?draft=1"); + rememberRoute("/scheduled#top"); + assert.equal(lastNonBotRoute(), "/scheduled#top"); +}); +``` + +Register: append ` renderer/lib/bot-routes.test.ts` to the end of `package.json` `test:bots`, and in `scripts/ci-test-registry.json`'s `renderer-other` lane add `"renderer/lib/bot-routes.test.ts",` after `"renderer/main/bots/bot-memory-page.test.tsx",`. + +- [ ] **Step 2: Run them to verify they fail** + +Run: `npx tsx --test renderer/lib/bot-routes.test.ts` +Expected: FAIL, cannot find module `./bot-routes`. + +- [ ] **Step 3: Implement** + +```ts +// renderer/lib/bot-routes.ts +// Where the Bots surfaces live in the route tree, what the panel shortcut acts +// on there, and where ‹ in the Bots sidebar goes back to (spec §8, §9). + +const BOT_CHAT_ROUTE = /^\/bots\/([^/]+)\/chat\/?$/u; +const BOT_ROUTE = /^\/bots\/([^/]+)(?:\/chat)?\/?$/u; + +export function isBotsPath(pathname: string): boolean { + return pathname === "/bots" || pathname.startsWith("/bots/"); +} + +function decodedSegment(segment: string | undefined): string | null { + if (!segment) return null; + try { + return decodeURIComponent(segment); + } catch { + return null; + } +} + +/** The Bot whose chat is open, or null. */ +export function botChatRouteBotId(pathname: string): string | null { + return decodedSegment(BOT_CHAT_ROUTE.exec(pathname)?.[1]); +} + +/** The Bot a `/bots/$botId` or `/bots/$botId/chat` page belongs to, or null. */ +export function botRouteBotId(pathname: string): string | null { + return decodedSegment(BOT_ROUTE.exec(pathname)?.[1]); +} + +export type EnvironmentToggleTarget = "bot-panel" | "workspace" | "none"; + +/** What `environment.toggle` acts on: the Bot panel in a Bot chat, nothing on other Bots pages, else the workspace panel. */ +export function environmentToggleTarget(pathname: string): EnvironmentToggleTarget { + if (botChatRouteBotId(pathname) !== null) return "bot-panel"; + return isBotsPath(pathname) ? "none" : "workspace"; +} + +/** The Bot's one conversation, addressed by Bot id only. */ +export function botChatPath(botId: string): { to: "/bots/$botId/chat"; params: { botId: string } } { + return { to: "/bots/$botId/chat", params: { botId } }; +} + +let lastNonBotHref = "/"; + +/** Called on every chat-shell navigation. Bots pages are never a back target. */ +export function rememberRoute(href: string): void { + const pathname = href.split(/[?#]/u, 1)[0] ?? href; + if (!isBotsPath(pathname)) lastNonBotHref = href; +} + +/** Where ‹ in the Bots sidebar goes: the last page outside Bots this session, else the latest chat (`/`). */ +export function lastNonBotRoute(): string { + return lastNonBotHref; +} +``` + +In `renderer/main/bots-view.tsx`, delete the local `botChatPath` function (lines 21-24, with its doc comment) and add, after the other imports: + +```tsx +import { botChatPath } from "../lib/bot-routes"; + +export { botChatPath } from "../lib/bot-routes"; +``` + +- [ ] **Step 4: Run the tests** + +Run: `npx tsx --test renderer/lib/bot-routes.test.ts && npx tsc --noEmit && npm run test:ci-policy` +Expected: 4 tests PASS, no type errors, CI policy PASS. + +- [ ] **Step 5: Commit** + +```bash +git add renderer/lib/bot-routes.ts renderer/lib/bot-routes.test.ts renderer/main/bots-view.tsx package.json scripts/ci-test-registry.json +git commit -m "Bots: route helpers for the Bots sidebar and panel shortcut" +``` + +--- + +### Task 2: Per-device pin and last-viewed preferences + +**Files:** +- Create: `renderer/lib/bot-sidebar-preferences.ts`, `renderer/main/bots/use-bot-sidebar-preferences.ts` +- Test: `renderer/lib/bot-sidebar-preferences.test.ts` +- Modify: `package.json` (`test:bots`), `scripts/ci-test-registry.json` (`renderer-other`) + +**Interfaces:** +- Consumes: nothing. +- Produces: + - `renderer/lib/bot-sidebar-preferences.ts`: `BOT_SIDEBAR_PREFERENCES_KEY = "aiden-agent.bots-sidebar.v1"`, `BOT_LAST_VIEWED_LIMIT = 200`, `interface BotSidebarPreferences { since: number; pinnedBotId: string | null; lastViewed: Readonly> }`, `parseBotSidebarPreferences(raw: string | null, now: number)`, `serializeBotSidebarPreferences(preferences): string`, `withPinnedBot(preferences, botId: string | null)`, `withBotViewed(preferences, botId, at)`, `withoutMissingBots(preferences, liveIds: ReadonlySet)` (returns the same object when nothing changes), `isBotUnread(preferences, botId, lastReplyAt: number | null): boolean`, `lastViewedBotId(preferences, liveIds): string | null`. + - `renderer/main/bots/use-bot-sidebar-preferences.ts`: `useBotSidebarPreferences(): { preferences; pin(botId); unpin(); markViewed(botId); prune(liveIds) }`. The four actions are module-level and stable across renders (effects can depend on them without looping). + +- [ ] **Step 1: Write the failing tests** + +```ts +// renderer/lib/bot-sidebar-preferences.test.ts +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + BOT_LAST_VIEWED_LIMIT, + isBotUnread, + lastViewedBotId, + parseBotSidebarPreferences, + serializeBotSidebarPreferences, + withBotViewed, + withoutMissingBots, + withPinnedBot, +} from "./bot-sidebar-preferences"; + +const NOW = 1_800_000_000_000; + +test("a stored record that is missing, malformed or from a newer build reads as no pin and nothing viewed", () => { + for (const raw of [null, "", "not json", "[]", "42", JSON.stringify({ version: 2, pinnedBotId: "b1" })]) { + assert.deepEqual(parseBotSidebarPreferences(raw, NOW), { since: NOW, pinnedBotId: null, lastViewed: {} }, String(raw)); + } + const odd = parseBotSidebarPreferences( + JSON.stringify({ version: 1, since: -5, pinnedBotId: 7, lastViewed: { b1: 10, b2: "soon", b3: Number.NaN, "": 4, b4: -1 } }), + NOW, + ); + assert.deepEqual(odd, { since: NOW, pinnedBotId: null, lastViewed: { b1: 10 } }); +}); + +test("a record round-trips, and only the most recent views are kept", () => { + const stored = { since: 5, pinnedBotId: "b1", lastViewed: { b1: 10, b2: 20 } }; + assert.deepEqual(parseBotSidebarPreferences(serializeBotSidebarPreferences(stored), NOW), stored); + const many = Object.fromEntries(Array.from({ length: BOT_LAST_VIEWED_LIMIT + 50 }, (_, index) => [`b${index}`, index + 1])); + const trimmed = parseBotSidebarPreferences(JSON.stringify({ version: 1, since: 1, pinnedBotId: null, lastViewed: many }), NOW); + assert.equal(Object.keys(trimmed.lastViewed).length, BOT_LAST_VIEWED_LIMIT); + assert.equal(trimmed.lastViewed[`b${BOT_LAST_VIEWED_LIMIT + 49}`], BOT_LAST_VIEWED_LIMIT + 50, "the newest view survives"); + assert.equal(trimmed.lastViewed.b0, undefined, "the oldest view goes"); +}); + +test("a Bot is unread only for a reply after it was last viewed, or after the record began when it never was", () => { + const preferences = { since: 1_000, pinnedBotId: null, lastViewed: { seen: 5_000 } }; + assert.equal(isBotUnread(preferences, "seen", 6_000), true); + assert.equal(isBotUnread(preferences, "seen", 4_000), false); + assert.equal(isBotUnread(preferences, "never", 500), false, "an old reply from before the update is not news"); + assert.equal(isBotUnread(preferences, "never", 1_500), true); + assert.equal(isBotUnread(preferences, "seen", null), false); +}); + +test("viewing only moves forward, pinning replaces the one pin, and pruning drops deleted Bots", () => { + const start = { since: 1, pinnedBotId: null, lastViewed: { b1: 50 } }; + assert.equal(withBotViewed(start, "b1", 40), start, "an older view changes nothing"); + assert.equal(withBotViewed(start, "b1", 60).lastViewed.b1, 60); + const pinned = withPinnedBot(withPinnedBot(start, "b1"), "b2"); + assert.equal(pinned.pinnedBotId, "b2"); + assert.equal(withPinnedBot(pinned, "b2"), pinned); + const live = new Set(["b1"]); + const pruned = withoutMissingBots({ ...pinned, lastViewed: { b1: 50, gone: 70 } }, live); + assert.deepEqual(pruned, { since: 1, pinnedBotId: null, lastViewed: { b1: 50 } }); + assert.equal(withoutMissingBots(pruned, live), pruned, "nothing to prune keeps the same record"); +}); + +test("the last viewed Bot is the newest view among Bots that still exist", () => { + const preferences = { since: 1, pinnedBotId: null, lastViewed: { b1: 10, gone: 99, b2: 30 } }; + assert.equal(lastViewedBotId(preferences, new Set(["b1", "b2"])), "b2"); + assert.equal(lastViewedBotId(preferences, new Set(["b3"])), null); +}); +``` + +Register: append ` renderer/lib/bot-sidebar-preferences.test.ts` to `package.json` `test:bots`, and add `"renderer/lib/bot-sidebar-preferences.test.ts",` after `"renderer/lib/bot-routes.test.ts",` in the `renderer-other` lane. + +- [ ] **Step 2: Run them to verify they fail** + +Run: `npx tsx --test renderer/lib/bot-sidebar-preferences.test.ts` +Expected: FAIL, cannot find module `./bot-sidebar-preferences`. + +- [ ] **Step 3: Implement the pure record** + +```ts +// renderer/lib/bot-sidebar-preferences.ts +// The Bots sidebar's per-device preferences (spec §9): which Bot is pinned and +// when each Bot was last viewed on this Mac. Not part of BotDefinition, never +// synced to phones. `since` is when the record began, so Bots never viewed here +// are not all unread after an update. + +export const BOT_SIDEBAR_PREFERENCES_KEY = "aiden-agent.bots-sidebar.v1"; +export const BOT_LAST_VIEWED_LIMIT = 200; + +export interface BotSidebarPreferences { + since: number; + pinnedBotId: string | null; + lastViewed: Readonly>; +} + +function fresh(now: number): BotSidebarPreferences { + return { since: now, pinnedBotId: null, lastViewed: {} }; +} + +const validTime = (value: unknown): value is number => + typeof value === "number" && Number.isFinite(value) && value > 0; + +function newest(lastViewed: Record): Record { + const entries = Object.entries(lastViewed); + if (entries.length <= BOT_LAST_VIEWED_LIMIT) return lastViewed; + return Object.fromEntries(entries.sort((left, right) => right[1] - left[1]).slice(0, BOT_LAST_VIEWED_LIMIT)); +} + +export function parseBotSidebarPreferences(raw: string | null, now: number): BotSidebarPreferences { + if (raw === null) return fresh(now); + let value: unknown; + try { + value = JSON.parse(raw); + } catch { + return fresh(now); + } + if (!value || typeof value !== "object" || Array.isArray(value)) return fresh(now); + const record = value as Record; + if (record.version !== 1) return fresh(now); + const lastViewed: Record = {}; + if (record.lastViewed && typeof record.lastViewed === "object" && !Array.isArray(record.lastViewed)) { + for (const [botId, at] of Object.entries(record.lastViewed as Record)) { + if (botId && validTime(at)) lastViewed[botId] = at; + } + } + return { + since: validTime(record.since) ? record.since : now, + pinnedBotId: typeof record.pinnedBotId === "string" && record.pinnedBotId ? record.pinnedBotId : null, + lastViewed: newest(lastViewed), + }; +} + +export function serializeBotSidebarPreferences(preferences: BotSidebarPreferences): string { + return JSON.stringify({ + version: 1, + since: preferences.since, + pinnedBotId: preferences.pinnedBotId, + lastViewed: preferences.lastViewed, + }); +} + +export function withPinnedBot(preferences: BotSidebarPreferences, botId: string | null): BotSidebarPreferences { + return preferences.pinnedBotId === botId ? preferences : { ...preferences, pinnedBotId: botId }; +} + +export function withBotViewed(preferences: BotSidebarPreferences, botId: string, at: number): BotSidebarPreferences { + if (at <= (preferences.lastViewed[botId] ?? 0)) return preferences; + return { ...preferences, lastViewed: newest({ ...preferences.lastViewed, [botId]: at }) }; +} + +export function withoutMissingBots( + preferences: BotSidebarPreferences, + liveIds: ReadonlySet, +): BotSidebarPreferences { + const pinGone = preferences.pinnedBotId !== null && !liveIds.has(preferences.pinnedBotId); + const kept = Object.entries(preferences.lastViewed).filter(([botId]) => liveIds.has(botId)); + if (!pinGone && kept.length === Object.keys(preferences.lastViewed).length) return preferences; + return { + ...preferences, + pinnedBotId: pinGone ? null : preferences.pinnedBotId, + lastViewed: Object.fromEntries(kept), + }; +} + +export function isBotUnread(preferences: BotSidebarPreferences, botId: string, lastReplyAt: number | null): boolean { + return lastReplyAt !== null && lastReplyAt > (preferences.lastViewed[botId] ?? preferences.since); +} + +export function lastViewedBotId(preferences: BotSidebarPreferences, liveIds: ReadonlySet): string | null { + let best: string | null = null; + let bestAt = Number.NEGATIVE_INFINITY; + for (const [botId, at] of Object.entries(preferences.lastViewed)) { + if (liveIds.has(botId) && at > bestAt) { + best = botId; + bestAt = at; + } + } + return best; +} +``` + +- [ ] **Step 4: Implement the hook** + +```ts +// renderer/main/bots/use-bot-sidebar-preferences.ts +// One per-window view of the Bots sidebar preferences, kept in step across +// windows by the `storage` event. Storage can be missing or throw (a private +// profile, cleared site data): the sidebar then works for this session only. + +import * as React from "react"; +import { + BOT_SIDEBAR_PREFERENCES_KEY, + parseBotSidebarPreferences, + serializeBotSidebarPreferences, + withBotViewed, + withoutMissingBots, + withPinnedBot, + type BotSidebarPreferences, +} from "../../lib/bot-sidebar-preferences"; + +const listeners = new Set<() => void>(); +let cache: { raw: string | null; value: BotSidebarPreferences } | null = null; + +function readRaw(): string | null { + try { + return localStorage.getItem(BOT_SIDEBAR_PREFERENCES_KEY); + } catch { + return null; + } +} + +function snapshot(): BotSidebarPreferences { + const raw = readRaw(); + if (!cache || cache.raw !== raw) cache = { raw, value: parseBotSidebarPreferences(raw, Date.now()) }; + return cache.value; +} + +function update(change: (current: BotSidebarPreferences) => BotSidebarPreferences): void { + const current = snapshot(); + const next = change(current); + // An unchanged record is still written once, so `since` sticks across launches. + if (next === current && cache?.raw !== null) return; + try { + localStorage.setItem(BOT_SIDEBAR_PREFERENCES_KEY, serializeBotSidebarPreferences(next)); + } catch { + // Per-device convenience only. + } + cache = { raw: readRaw(), value: next }; + for (const listener of listeners) listener(); +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener); + const onStorage = (event: StorageEvent) => { + if (event.key === null || event.key === BOT_SIDEBAR_PREFERENCES_KEY) listener(); + }; + window.addEventListener("storage", onStorage); + return () => { + listeners.delete(listener); + window.removeEventListener("storage", onStorage); + }; +} + +const ACTIONS = { + pin: (botId: string) => update((current) => withPinnedBot(current, botId)), + unpin: () => update((current) => withPinnedBot(current, null)), + markViewed: (botId: string) => update((current) => withBotViewed(current, botId, Date.now())), + prune: (liveIds: ReadonlySet) => update((current) => withoutMissingBots(current, liveIds)), +} as const; + +export function useBotSidebarPreferences() { + const preferences = React.useSyncExternalStore(subscribe, snapshot, snapshot); + return { preferences, ...ACTIONS }; +} +``` + +- [ ] **Step 5: Run the tests** + +Run: `npx tsx --test renderer/lib/bot-sidebar-preferences.test.ts && npx tsc --noEmit && npm run test:ci-policy` +Expected: 5 tests PASS, no type errors, CI policy PASS. (The hook is exercised by Task 10's sidebar tests.) + +- [ ] **Step 6: Commit** + +```bash +git add renderer/lib/bot-sidebar-preferences.ts renderer/lib/bot-sidebar-preferences.test.ts renderer/main/bots/use-bot-sidebar-preferences.ts package.json scripts/ci-test-registry.json +git commit -m "Bots: per-device pin and last-viewed preferences for the Bots sidebar" +``` + +--- + +### Task 3: List summaries say "needs you" and when the last reply arrived + +**Files:** +- Modify: `renderer/shared/bot-live.ts` (`BotLiveSummary`, new `botLastReplyAt`) +- Modify: `main/services/bot-runtime/live-projection.ts` (`summary`, about lines 729-741) +- Modify: `main/services/bot-runtime/bot-session-main.ts` (next to the `botQuestions.onChange` block) +- Modify: `renderer/preload-channels.ts` (`NOTIFICATION_CHANNELS`) +- Test: `main/services/bot-runtime/live-projection.test.ts`, `main/handlers/bot-live.test.ts` + +**Interfaces:** +- Consumes: B's `approvalPending` dep and the `waitingOn(botId)` helper inside `createBotLiveProjection` (B Task 3, Step 3). If B landed it under another name, use the same expression inline: `(deps.question?.(botId) ?? null) !== null || deps.approvalPending?.(botId) === true`. +- Produces: + - `BotLiveSummary` = `{ botId; preview: string | null; updatedAt: number | null; state: BotSessionState; needsYou: boolean; lastReplyAt: number | null }`. + - `botLastReplyAt(entries: readonly BotTranscriptEntry[]): number | null` in `renderer/shared/bot-live.ts`. + - Notification `bots:attention` with payload `{ botId: string }`, broadcast whenever a Bot's question or approval appears or settles. + +- [ ] **Step 1: Write the failing tests** + +In `main/services/bot-runtime/live-projection.test.ts`, add `botLastReplyAt` to the `../../../renderer/shared/bot-live.js` import, then add: + +```ts +test("the last reply time ignores the person's newer messages and replies with no text", () => { + assert.equal(botLastReplyAt([]), null); + assert.equal( + botLastReplyAt([ + { id: "a1", type: "assistant", text: "Here you go.", toolCalls: [], stopReason: "stop", at: 10 }, + { id: "a2", type: "assistant", text: " ", toolCalls: [], stopReason: "toolUse", at: 20 }, + { id: "u2", type: "user", text: "Thanks", imageCount: 0, at: 30 }, + ]), + 10, + ); +}); + +test("a list summary says when the last reply arrived and whether the Bot is waiting on the person", async () => { + let waiting = false; + const live = await start(createFauxModels([fauxAssistantMessage("Here is your meal plan.")]), { + projection: { approvalPending: () => waiting }, + }); + try { + const before = await live.projection.summary("bot:a"); + assert.equal(before.lastReplyAt, null); + assert.equal(before.needsYou, false); + const sent = await live.service.send("bot:a", { text: "Plan dinners", requestId: "desk-1" }); + await live.service.awaitReply("bot:a", sent.submissionId, new AbortController().signal); + const after = await live.projection.summary("bot:a"); + assert.equal(after.preview, "Here is your meal plan."); + assert.equal(typeof after.lastReplyAt, "number"); + assert.equal(after.lastReplyAt, after.updatedAt, "the newest message is the reply"); + waiting = true; + assert.equal((await live.projection.summary("bot:a")).needsYou, true); + } finally { + await live.close(); + } +}); +``` + +In `main/handlers/bot-live.test.ts`, change the fake's `summary` to `summary: async (botId) => ({ botId, preview: "hi", updatedAt: 1, state: { kind: "idle" }, needsYou: true, lastReplyAt: 1 }),` and the expected object in "the list summary comes from the projection" to: + +```ts + assert.deepEqual(await invoke("bots:live:summary", new FakeDocument("w"), "bot:a"), { + botId: "bot:a", + preview: "hi", + updatedAt: 1, + state: { kind: "idle" }, + needsYou: true, + lastReplyAt: 1, + }); +``` + +- [ ] **Step 2: Run them to verify they fail** + +Run: `npx tsx --test main/services/bot-runtime/live-projection.test.ts main/handlers/bot-live.test.ts` +Expected: FAIL. `botLastReplyAt` is not exported, and the fake's summary does not type-check against `BotLiveSummary`. + +- [ ] **Step 3: Implement** + +In `renderer/shared/bot-live.ts`, replace `BotLiveSummary` and add the helper below `botTranscriptPreview`: + +```ts +/** What a Bots sidebar row shows, read from the same projection. */ +export interface BotLiveSummary { + botId: string; + preview: string | null; + updatedAt: number | null; + state: BotSessionState; + /** An approval or an A–E question is waiting on the person ("Needs you"). */ + needsYou: boolean; + /** When the newest reply with visible text arrived; the sidebar's unread dot compares it with last-viewed. */ + lastReplyAt: number | null; +} + +/** When the newest reply with visible text arrived, or null when none has a time. */ +export function botLastReplyAt(entries: readonly BotTranscriptEntry[]): number | null { + for (let index = entries.length - 1; index >= 0; index -= 1) { + const entry = entries[index]!; + if (entry.type === "assistant" && entry.text.trim() && entry.at !== undefined) return entry.at; + } + return null; +} +``` + +In `main/services/bot-runtime/live-projection.ts`, import `botLastReplyAt` next to `botTranscriptPreview` from `../../../renderer/shared/bot-live.js`, and replace `summary` in the returned object with: + +```ts + async summary(botId) { + const needsYou = waitingOn(botId); + const feed = feeds.get(botId); + if (feed && !feed.ended) { + await feed.queue; + const preview = botTranscriptPreview(feed.entries); + return { + botId, + preview: preview?.text ?? null, + updatedAt: preview?.at ?? null, + state: feed.state, + needsYou, + lastReplyAt: botLastReplyAt(feed.entries), + }; + } + const state = await deps.state(botId); + if (state.kind === "unavailable") { + return { botId, preview: null, updatedAt: null, state, needsYou: false, lastReplyAt: null }; + } + const view = await (await deps.conversation(botId)).context(BACKGROUND_CONTEXT); + const entries = projectBotTranscript(view.entries); + const preview = botTranscriptPreview(entries); + return { + botId, + preview: preview?.text ?? null, + updatedAt: preview?.at ?? null, + state, + needsYou, + lastReplyAt: botLastReplyAt(entries), + }; + }, +``` + +In `main/services/bot-runtime/bot-session-main.ts`, directly after the `botQuestions.onChange((botId) => { … });` block, add: + +```ts +// "Needs you" in the Bots sidebar follows questions and approvals as they +// appear and settle; the renderer re-reads that Bot's summary. +function broadcastAttention(botId: string): void { + ipcMain.broadcast("bots:attention", { botId }); +} +botQuestions.onChange(broadcastAttention); +botApprovals.onChange(broadcastAttention); +``` + +In `renderer/preload-channels.ts`, after `"bots:approval-settled",` add: + +```ts + /** A Bot's question or approval appeared or settled: `{botId}`. The Bots sidebar re-reads its row. */ + "bots:attention", +``` + +- [ ] **Step 4: Run the tests** + +Run: +```bash +npx tsx --test main/services/bot-runtime/live-projection.test.ts main/handlers/bot-live.test.ts main/handlers/ipc-contract.test.ts +npx tsc --noEmit +npm run test:aiden-remote +``` +Expected: all PASS. `ipc-contract` confirms the new broadcast and the preload list agree. `test:aiden-remote` passes with no fixture change (the Remote wire does not use `summary`). + +- [ ] **Step 5: Commit** + +```bash +git add renderer/shared/bot-live.ts main/services/bot-runtime/live-projection.ts main/services/bot-runtime/live-projection.test.ts main/handlers/bot-live.test.ts main/services/bot-runtime/bot-session-main.ts renderer/preload-channels.ts +git commit -m "Bots: list summaries carry Needs you and the last reply time; bots:attention" +``` + +--- +### Task 4: Bot panel state, the toggle shortcut, and suppressing workspace surfaces on Bots pages + +This task answers three of C1's risks. **`toggleTools` appends a `new-tab` launcher**, so the Bot route never calls it: `environment.toggle` is routed by `environmentToggleTarget` to `BotPanelProvider.toggle`. **Open state is shared in `EnvironmentPanelProvider`**, so the Bot panel gets its own provider and keys, and the workspace surfaces are `suppressed` on every Bots path; their open state is kept, not changed, and comes back on leaving. **Quick View and the device mini player are not hidden on `/bots`**: `suppressed` hides both (they are gated on it inside `EnvironmentWorkbench`), and `quick-view.toggle` is a no-op on Bots paths. + +**Files:** +- Create: `renderer/main/bots/bot-panel-context.tsx` +- Test: `renderer/main/bots/bot-panel.test.tsx` +- Modify: `renderer/main/root-view.tsx` (imports; `RootView`; the `environment.toggle` and `quick-view.toggle` handlers at about lines 139-159) +- Modify: `renderer/main/chat-layout.tsx` (route memory; `suppressed`) +- Modify: `renderer/main/bots/test-providers.tsx` (provider in the harness; `sidebar` option; two more paths) +- Modify: `package.json` (`test:bots`), `scripts/ci-test-registry.json` (`renderer-other`) + +**Interfaces:** +- Consumes: `environmentToggleTarget`, `isBotsPath`, `rememberRoute` (Task 1). +- Produces (in `renderer/main/bots/bot-panel-context.tsx`): + - `BOT_PANEL_ID = "bot-panel"`, `BOT_PANEL_TABS = ["activity", "files", "memory", "routines"] as const`, `type BotPanelTab`, `BOT_PANEL_OPEN_KEY`, `BOT_PANEL_TAB_KEY`, `BOT_PANEL_WIDTH_KEY`, `parseBotPanelTab(value: string | null): BotPanelTab`. + - `interface BotPanelFileRequest { path: string; key: number }` (`key` is new on every request, so the same chip clicked twice re-selects its file). + - `interface BotPanelValue { open; tab; fileRequest: BotPanelFileRequest | null; focusRequest: number; show(tab, options?: { path?: string }): void; setTab(tab): void; toggle(): void; close(): void; consumeFocusRequest(): boolean }`. + - `BotPanelProvider`, `useBotPanel(): BotPanelValue` (throws outside the provider). + - Test harness: `mountWithBotRouter(children, { initialPath?, queryClient?, sidebar? })` wraps every route in `BotPanelProvider` and renders `sidebar` beside the routed page; `BOT_TEST_PATHS` adds `/profile` and `/scheduled`. + +- [ ] **Step 1: Write the failing tests** + +```tsx +// renderer/main/bots/bot-panel.test.tsx +import { installBotTestIpc } from "./test-dom"; +import assert from "node:assert/strict"; +import { afterEach, beforeEach, test } from "node:test"; +import * as React from "react"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { BOT_PANEL_OPEN_KEY, BOT_PANEL_TAB_KEY, BotPanelProvider, useBotPanel } from "./bot-panel-context"; + +beforeEach(() => { + installBotTestIpc(); + localStorage.clear(); +}); +afterEach(cleanup); + +function PanelProbe() { + const panel = useBotPanel(); + const request = panel.fileRequest ? `${panel.fileRequest.path}#${panel.fileRequest.key}` : "none"; + return ( + <> + {`${panel.open ? "open" : "closed"} ${panel.tab} ${request}`} + + + + + ); +} + +const panelState = () => screen.getByRole("status", { name: "Panel" }).textContent ?? ""; + +test("a file asked for twice is two requests, and the toggle closes and reopens on the same tab", () => { + render( + + + , + ); + assert.equal(panelState(), "closed activity none"); + fireEvent.click(screen.getByRole("button", { name: "Open groceries" })); + const first = panelState(); + assert.match(first, /^open files lists\/groceries\.md#\d+$/u); + fireEvent.click(screen.getByRole("button", { name: "Open groceries" })); + assert.notEqual(panelState(), first, "the same chip clicked again selects its file again"); + fireEvent.click(screen.getByRole("button", { name: "Toggle" })); + assert.match(panelState(), /^closed files /u); + fireEvent.click(screen.getByRole("button", { name: "Toggle" })); + assert.match(panelState(), /^open files /u); +}); + +test("the Bot panel comes back open on its last tab after a relaunch, under its own keys only", () => { + const first = render( + + + , + ); + fireEvent.click(screen.getByRole("button", { name: "Open memory" })); + first.unmount(); + render( + + + , + ); + assert.equal(panelState(), "open memory none"); + assert.equal(localStorage.getItem(BOT_PANEL_OPEN_KEY), "true"); + assert.equal(localStorage.getItem(BOT_PANEL_TAB_KEY), "memory"); + for (const key of [ + "aiden-agent.environment.open", + "aiden-agent.environment.open-tabs-v1", + "aiden-agent.environment.tab", + "aiden-agent.quick-view.open", + ]) { + assert.equal(localStorage.getItem(key), null, key); + } +}); + +test("a saved tab this build doesn't know, or a garbled open flag, opens closed on Activity", () => { + localStorage.setItem(BOT_PANEL_TAB_KEY, "browser"); + localStorage.setItem(BOT_PANEL_OPEN_KEY, "yes"); + render( + + + , + ); + assert.equal(panelState(), "closed activity none"); +}); +``` + +Register: append ` renderer/main/bots/bot-panel.test.tsx` to `package.json` `test:bots`; add `"renderer/main/bots/bot-panel.test.tsx",` after `"renderer/lib/bot-sidebar-preferences.test.ts",` in the `renderer-other` lane. + +- [ ] **Step 2: Run them to verify they fail** + +Run: `npx tsx --test renderer/main/bots/bot-panel.test.tsx` +Expected: FAIL, cannot find module `./bot-panel-context`. + +- [ ] **Step 3: Implement the provider** + +```tsx +// renderer/main/bots/bot-panel-context.tsx +// The Bot side panel's state (spec §8), kept apart from the workspace +// Environment panel: its own open flag, tab and width keys, so opening it on a +// Bot never opens or reshapes the workspace panel. Focus: opening asks the +// panel to focus its selected tab; closing with focus inside returns it to +// what was focused before, else the header toggle. + +import * as React from "react"; + +export const BOT_PANEL_ID = "bot-panel"; +export const BOT_PANEL_TABS = ["activity", "files", "memory", "routines"] as const; +export type BotPanelTab = (typeof BOT_PANEL_TABS)[number]; +export const BOT_PANEL_OPEN_KEY = "aiden-agent.bot-panel.open"; +export const BOT_PANEL_TAB_KEY = "aiden-agent.bot-panel.tab"; +export const BOT_PANEL_WIDTH_KEY = "aiden-agent.bot-panel.width"; + +export interface BotPanelFileRequest { + path: string; + /** New on every request, so the same file chip clicked twice selects its file again. */ + key: number; +} + +export interface BotPanelValue { + open: boolean; + tab: BotPanelTab; + fileRequest: BotPanelFileRequest | null; + /** Bumped whenever the person opens the panel or picks a tab from outside it. */ + focusRequest: number; + show(tab: BotPanelTab, options?: { path?: string }): void; + setTab(tab: BotPanelTab): void; + toggle(): void; + close(): void; + /** True once after a person-initiated open: the panel then focuses its selected tab. */ + consumeFocusRequest(): boolean; +} + +export function parseBotPanelTab(value: string | null): BotPanelTab { + return (BOT_PANEL_TABS as readonly string[]).includes(value ?? "") ? (value as BotPanelTab) : "activity"; +} + +function readStored(key: string): string | null { + try { + return localStorage.getItem(key); + } catch { + return null; + } +} + +function writeStored(key: string, value: string): void { + try { + localStorage.setItem(key, value); + } catch { + // Per-device convenience only. + } +} + +const BotPanelContext = React.createContext(null); + +interface PanelState { + open: boolean; + tab: BotPanelTab; + fileRequest: BotPanelFileRequest | null; +} + +export function BotPanelProvider({ children }: React.PropsWithChildren) { + const [state, setState] = React.useState(() => ({ + open: readStored(BOT_PANEL_OPEN_KEY) === "true", + tab: parseBotPanelTab(readStored(BOT_PANEL_TAB_KEY)), + fileRequest: null, + })); + const [focusRequest, setFocusRequest] = React.useState(0); + const openRef = React.useRef(state.open); + openRef.current = state.open; + const pendingFocus = React.useRef(false); + const returnFocus = React.useRef(null); + const fileKey = React.useRef(0); + + React.useEffect(() => writeStored(BOT_PANEL_OPEN_KEY, String(state.open)), [state.open]); + React.useEffect(() => writeStored(BOT_PANEL_TAB_KEY, state.tab), [state.tab]); + + const requestFocus = React.useCallback(() => { + if (!openRef.current) { + const active = document.activeElement; + returnFocus.current = active instanceof HTMLElement && !active.closest(`#${BOT_PANEL_ID}`) ? active : null; + } + pendingFocus.current = true; + setFocusRequest((value) => value + 1); + }, []); + + const close = React.useCallback(() => { + const active = document.activeElement; + if (active instanceof HTMLElement && active.closest(`#${BOT_PANEL_ID}`)) { + const stored = returnFocus.current; + const target = + stored?.isConnected && !stored.closest("[inert]") + ? stored + : (document.querySelector("[data-bot-panel-toggle]") ?? + document.querySelector("[data-app-focus-root]")); + requestAnimationFrame(() => target?.focus()); + } + pendingFocus.current = false; + setState((current) => (current.open ? { ...current, open: false } : current)); + }, []); + + const show = React.useCallback( + (tab: BotPanelTab, options?: { path?: string }) => { + requestFocus(); + const request = options?.path ? { path: options.path, key: (fileKey.current += 1) } : null; + setState((current) => ({ open: true, tab, fileRequest: request ?? current.fileRequest })); + }, + [requestFocus], + ); + + const toggle = React.useCallback(() => { + if (openRef.current) { + close(); + return; + } + requestFocus(); + setState((current) => ({ ...current, open: true })); + }, [close, requestFocus]); + + const setTab = React.useCallback( + (tab: BotPanelTab) => setState((current) => (current.tab === tab ? current : { ...current, tab })), + [], + ); + + const consumeFocusRequest = React.useCallback(() => { + const pending = pendingFocus.current; + pendingFocus.current = false; + return pending; + }, []); + + const value = React.useMemo( + () => ({ ...state, focusRequest, show, setTab, toggle, close, consumeFocusRequest }), + [state, focusRequest, show, setTab, toggle, close, consumeFocusRequest], + ); + return {children}; +} + +export function useBotPanel(): BotPanelValue { + const value = React.useContext(BotPanelContext); + if (!value) throw new Error("useBotPanel must be used inside BotPanelProvider."); + return value; +} +``` + +- [ ] **Step 4: Wire it into the app and the test harness** + +`renderer/main/root-view.tsx`: + +1. Add imports: + +```tsx +import { BotPanelProvider, useBotPanel } from "./bots/bot-panel-context"; +import { environmentToggleTarget, isBotsPath } from "../lib/bot-routes"; +``` + +2. In `RootView`, wrap `…` in `…` (inside `EnvironmentPanelProvider`), so `RootContent` can read it. +3. In `RootContent`, below `const environmentPanel = useEnvironmentPanel();`, add `const botPanel = useBotPanel();`. +4. Replace the two handlers: + +```tsx + useCommandHandler( + "environment.toggle", + () => { + // In a Bot chat the shortcut is the Bot panel's; the workspace panel stays as it is. + const target = environmentToggleTarget(pathname); + if (target === "bot-panel") { + botPanel.toggle(); + return; + } + if (target === "none") return; + if (environmentPanel.gitOperationBusy) { + toast.info("Wait for the current Git operation to finish before changing panels."); + return; + } + environmentPanel.toggleTools(); + }, + workspaceCommands.environment, + ); + useCommandHandler( + "quick-view.toggle", + () => { + // Quick View is suppressed on Bots pages; toggling it there would change hidden state. + if (isBotsPath(pathname)) return; + if (environmentPanel.gitOperationBusy) { + toast.info("Wait for the current Git operation to finish before changing panels."); + return; + } + environmentPanel.toggleQuickView(); + }, + workspaceCommands.environment, + ); +``` + +`renderer/main/chat-layout.tsx`: + +1. Add `import { isBotsPath, rememberRoute } from "../lib/bot-routes";`. +2. Below `const pathname = …`, add: + +```tsx + const href = useRouterState({ select: (state) => state.location.href }); + // ‹ in the Bots sidebar returns to the last page outside Bots (spec §9). + React.useEffect(() => rememberRoute(href), [href]); + // The workspace Environment, Quick View and device mini player are hidden, not + // closed, on Bots pages: the Bot has its own panel, and leaving restores them. + const botsSurface = isBotsPath(pathname); +``` + +3. Change `` to ``. Leave the `TerminalDrawer` condition exactly as it is. + +`renderer/main/bots/test-providers.tsx`: + +1. Add `import { BotPanelProvider } from "./bot-panel-context";`. +2. Add `"/profile"` and `"/scheduled"` to `BOT_TEST_PATHS` (after `"/"`). +3. Give both `mountWithBotRouter` and `createBotRouterHarness` the option type `{ initialPath?: string; queryClient?: QueryClient; sidebar?: React.ReactNode }`, and change the root route to: + +```tsx + // As in the app: the Bot panel's state outlives route changes, and a sidebar + // (when a test passes one) stays mounted beside the routed page. + const rootRoute = createRootRoute({ + component: () => ( + + {options.sidebar} + + + ), + }); +``` + +- [ ] **Step 5: Run the tests** + +Run: +```bash +npx tsx --test renderer/main/bots/bot-panel.test.tsx renderer/lib/command-system-core.test.ts +npm run test:bots +npx tsc --noEmit && npm run lint && npm run test:ci-policy +``` +Expected: the 3 provider tests PASS; every existing Bot suite still passes with the harness change; no type or lint errors. + +- [ ] **Step 6: Commit** + +```bash +git add renderer/main/bots/bot-panel-context.tsx renderer/main/bots/bot-panel.test.tsx renderer/main/root-view.tsx renderer/main/chat-layout.tsx renderer/main/bots/test-providers.tsx package.json scripts/ci-test-registry.json +git commit -m "Bots: Bot panel state apart from the workspace panel; route the toggle shortcut" +``` + +--- + +### Task 5: One live subscription per Bot, shared by the chat and the panel + +`bots:live:subscribe` keeps one subscription per window and Bot (`main/handlers/bot-live.ts` replaces the previous one), and `useBotLive`'s cleanup calls `bots:live:unsubscribe`. Two hooks for the same Bot would therefore freeze the chat when the second unmounts. `BotLiveProvider` subscribes once; consumers inside it share that view. + +**Files:** +- Modify: `renderer/lib/use-bot-live.ts` (append) +- Test: `renderer/main/bots/bot-panel.test.tsx` (append) + +**Interfaces:** +- Consumes: `useBotLive(botId)`, `BotLiveView` (existing). +- Produces: `BotLiveProvider({ botId, children })`; `useSharedBotLive(botId: string | undefined): BotLiveView` (the provider's view when the ids match, else its own `useBotLive`). + +- [ ] **Step 1: Write the failing test** + +In `bot-panel.test.tsx`, change the first import to `import { emitBotTestNotification, installBotTestIpc } from "./test-dom";`, add `waitFor` to the `@testing-library/react` import, and add `import { BotLiveProvider, useSharedBotLive } from "../../lib/use-bot-live";`. Then append: + +```tsx +function LiveProbe({ label }: { label: string }) { + const live = useSharedBotLive("bot-1"); + return

{live.snapshot ? `${live.snapshot.entries.length} entries` : "loading"}

; +} + +function SharedLive({ second }: { second: boolean }) { + return ( + + + {second ? : null} + + ); +} + +test("a Bot's chat and its Activity tab share one live subscription, and closing the tab never stops the chat's", async () => { + const calls = installBotTestIpc({ + "bots:live:subscribe": () => ({ + botId: "bot-1", + epoch: "e1", + seq: 0, + entries: [], + partial: null, + state: { kind: "idle" }, + question: null, + activity: null, + queued: [], + }), + "bots:live:unsubscribe": () => undefined, + }); + const view = render(); + await waitFor(() => assert.equal(screen.getByLabelText("Activity view").textContent, "0 entries")); + view.rerender(); + emitBotTestNotification("bots:live:event", { + botId: "bot-1", + epoch: "e1", + seq: 1, + type: "entry", + entry: { id: "u1", type: "user", text: "Plan dinners", imageCount: 0 }, + }); + await waitFor(() => assert.equal(screen.getByLabelText("Chat view").textContent, "1 entries")); + assert.equal(calls.filter((call) => call.channel === "bots:live:subscribe").length, 1); + assert.equal(calls.filter((call) => call.channel === "bots:live:unsubscribe").length, 0); +}); +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `npx tsx --test renderer/main/bots/bot-panel.test.tsx` +Expected: FAIL, `BotLiveProvider` is not exported. + +- [ ] **Step 3: Implement** + +Append to `renderer/lib/use-bot-live.ts`: + +```ts +const BotLiveContext = React.createContext<{ botId: string; view: BotLiveView } | null>(null); + +/** + * One live subscription for a Bot, shared by everything inside (the chat and + * the side panel). Main keeps one subscription per window and Bot, so two + * independent `useBotLive` calls would unsubscribe each other. + */ +export function BotLiveProvider({ botId, children }: React.PropsWithChildren<{ botId: string }>) { + const view = useBotLive(botId); + const value = React.useMemo(() => ({ botId, view }), [botId, view]); + return React.createElement(BotLiveContext.Provider, { value }, children); +} + +/** The shared view when a `BotLiveProvider` for this Bot is above, else a subscription of its own. */ +export function useSharedBotLive(botId: string | undefined): BotLiveView { + const shared = React.useContext(BotLiveContext); + const owned = shared !== null && shared.botId === botId; + const own = useBotLive(owned ? undefined : botId); + return owned ? shared.view : own; +} +``` + +In the same file, make `useBotLive`'s returned object stable so the memo above holds: replace `return { snapshot, failed, reload };` with `return React.useMemo(() => ({ snapshot, failed, reload }), [snapshot, failed, reload]);`. + +- [ ] **Step 4: Run the tests** + +Run: `npx tsx --test renderer/main/bots/bot-panel.test.tsx renderer/main/bots/bot-chat-pane.test.tsx && npx tsc --noEmit` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add renderer/lib/use-bot-live.ts renderer/main/bots/bot-panel.test.tsx +git commit -m "Bots: one live subscription per Bot, shared by the chat and the panel" +``` + +--- + +### Task 6: The Activity log + +**Files:** +- Create: `renderer/main/bots/bot-activity-log.ts` +- Test: `renderer/main/bots/bot-activity-log.test.ts` +- Modify: `package.json` (`test:bots`), `scripts/ci-test-registry.json` (`renderer-other`) + +**Interfaces:** +- Consumes: B's `BotTranscriptEntry` (tool calls with `kind`, `label`, `detail?`, `app?`; results with `status`), `BotRecapStep`. +- Produces: + - `interface BotActivityReply { id: string; title: string; routine: boolean; at?: number; steps: BotRecapStep[] }` + - `BOT_ACTIVITY_TITLE_CHARS = 60` + - `botActivityLog(entries: readonly BotTranscriptEntry[], options: { running: boolean }): BotActivityReply[]` (newest reply first; steps in the order they ran; replies with no steps left out) + - `formatBotActivityWhen(at: number | undefined, now: number): string` ("Today, 6:41 PM", "Yesterday, 9:05 AM", "Oct 6, 7:00 PM", "Earlier") + +- [ ] **Step 1: Write the failing tests** + +```ts +// renderer/main/bots/bot-activity-log.test.ts +import assert from "node:assert/strict"; +import { test } from "node:test"; +import type { BotTranscriptEntry, BotTranscriptToolCall } from "../../shared/bot-live"; +import { BOT_ACTIVITY_TITLE_CHARS, botActivityLog, formatBotActivityWhen } from "./bot-activity-log"; + +const call = (id: string, name: string, kind: BotTranscriptToolCall["kind"], label: string, detail?: string): BotTranscriptToolCall => ({ + id, + name, + kind, + label, + ...(detail ? { detail } : {}), +}); + +const result = (callId: string, status: "ok" | "failed" | "blocked"): BotTranscriptEntry => ({ + id: `${callId}:result`, + type: "tool_result", + toolCallId: callId, + toolName: "tool", + isError: status !== "ok", + status, +}); + +const working = (id: string, calls: BotTranscriptToolCall[], at?: number): BotTranscriptEntry => ({ + id, + type: "assistant", + text: "", + toolCalls: calls, + stopReason: "toolUse", + ...(at === undefined ? {} : { at }), +}); + +const answer = (id: string, text: string): BotTranscriptEntry => ({ id, type: "assistant", text, toolCalls: [], stopReason: "stop" }); + +const ENTRIES: BotTranscriptEntry[] = [ + { id: "r1", type: "routine", label: "Sunday plan", text: "Plan next week", at: 100 }, + working("a1", [call("c1", "read_file", "reading", "Read 1 file", "pantry.md")]), + result("c1", "ok"), + answer("a2", "Planned."), + { id: "u1", type: "user", text: "Ask me nothing", imageCount: 0, at: 150 }, + answer("a3", "Sure."), + { id: "u2", type: "user", text: "Plan dinners for this week", imageCount: 0, at: 200 }, + working("a4", [ + call("c2", "web_search", "searching", "Searched the web", "dairy-free dinners"), + call("c3", "run_command", "command", "Ran a command"), + ]), + result("c2", "ok"), + result("c3", "failed"), + working("a5", [call("c4", "edit_file", "editing", "Edited 1 file", "meal-plan.md")]), + result("c4", "blocked"), + answer("a6", "Here's your week."), +]; + +test("replies come newest first, each with its steps in the order they ran, and a reply with no steps is left out", () => { + const log = botActivityLog(ENTRIES, { running: false }); + assert.deepEqual(log.map((reply) => reply.title), ["Plan dinners for this week", "Sunday plan"]); + assert.deepEqual( + log[0]!.steps.map((step) => [step.label, step.detail ?? null, step.status]), + [ + ["Searched the web", "dairy-free dinners", "ok"], + ["Ran a command", null, "failed"], + ["Edited 1 file", "meal-plan.md", "blocked"], + ], + ); + assert.equal(log[0]!.at, 200); +}); + +test("a routine run carries the routine's name", () => { + const routine = botActivityLog(ENTRIES, { running: false }).find((reply) => reply.routine); + assert.equal(routine?.title, "Sunday plan"); + assert.equal(routine?.steps[0]?.detail, "pantry.md"); +}); + +test("a step still running reads as failed once the Bot stopped, but stays running in the live reply", () => { + const unfinished = [...ENTRIES.slice(0, 6), { id: "u3", type: "user", text: "Next", imageCount: 0 } as BotTranscriptEntry, working("a7", [call("c5", "grep", "reading", "Read 1 file")])]; + assert.equal(botActivityLog(unfinished, { running: true })[0]!.steps[0]!.status, "running"); + assert.equal(botActivityLog(unfinished, { running: false })[0]!.steps[0]!.status, "failed"); + const older = [...unfinished, { id: "u4", type: "user", text: "Again", imageCount: 0 } as BotTranscriptEntry]; + assert.equal(botActivityLog(older, { running: true }).find((reply) => reply.id === "u3")?.steps[0]?.status, "failed"); +}); + +test("a long message makes a short title, and a photo-only message reads as Photo", () => { + const long = "word ".repeat(40); + const log = botActivityLog( + [ + { id: "u1", type: "user", text: long, imageCount: 0 }, + working("a1", [call("c1", "grep", "reading", "Read 1 file")]), + { id: "u2", type: "user", text: "", imageCount: 1 }, + working("a2", [call("c2", "inspect_image", "photo", "Looked at your photo")]), + ], + { running: false }, + ); + assert.equal(log[0]!.title, "Photo"); + assert.ok([...log[1]!.title].length <= BOT_ACTIVITY_TITLE_CHARS); + assert.ok(log[1]!.title.endsWith("…")); +}); + +test("reply times read as today, yesterday, or a date, each with the time", () => { + const now = new Date(2026, 9, 14, 18, 0).getTime(); + assert.match(formatBotActivityWhen(new Date(2026, 9, 14, 9, 5).getTime(), now), /^Today, \S/u); + assert.match(formatBotActivityWhen(new Date(2026, 9, 13, 23, 0).getTime(), now), /^Yesterday, \S/u); + const older = new Date(2026, 9, 6, 19, 0); + const olderDay = new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric" }).format(older); + assert.ok(formatBotActivityWhen(older.getTime(), now).startsWith(`${olderDay}, `), "an older reply names its date"); + assert.equal(formatBotActivityWhen(undefined, now), "Earlier"); +}); +``` + +Register: append ` renderer/main/bots/bot-activity-log.test.ts` to `package.json` `test:bots`; add `"renderer/main/bots/bot-activity-log.test.ts",` after `"renderer/main/bots/bot-panel.test.tsx",` in `renderer-other`. + +- [ ] **Step 2: Run them to verify they fail** + +Run: `npx tsx --test renderer/main/bots/bot-activity-log.test.ts` +Expected: FAIL, cannot find module `./bot-activity-log`. + +- [ ] **Step 3: Implement** + +```ts +// renderer/main/bots/bot-activity-log.ts +// The side panel's Activity tab (spec §8): every step of every reply, newest +// reply first, grouped under the message or routine that started it. Built +// from the transcript tool calls slice B enriched; read-only. + +import type { BotTranscriptEntry } from "../../shared/bot-live"; +import type { BotRecapStep } from "./bot-activity-steps"; + +export interface BotActivityReply { + id: string; + /** The person's message, shortened, or the routine's name. */ + title: string; + routine: boolean; + at?: number; + steps: BotRecapStep[]; +} + +export const BOT_ACTIVITY_TITLE_CHARS = 60; + +type OpenReply = BotActivityReply & { byCall: Map }; + +function shortTitle(text: string, fallback: string): string { + const line = text.replace(/\s+/gu, " ").trim(); + if (!line) return fallback; + const points = [...line]; + return points.length > BOT_ACTIVITY_TITLE_CHARS + ? `${points.slice(0, BOT_ACTIVITY_TITLE_CHARS - 1).join("").trimEnd()}…` + : line; +} + +function openReply(id: string, title: string, routine: boolean, at: number | undefined): OpenReply { + return { id, title, routine, ...(at === undefined ? {} : { at }), steps: [], byCall: new Map() }; +} + +export function botActivityLog( + entries: readonly BotTranscriptEntry[], + { running }: { running: boolean }, +): BotActivityReply[] { + const replies: OpenReply[] = []; + let current: OpenReply | null = null; + for (const entry of entries) { + if (entry.type === "user") { + const photos = entry.imageCount === 1 ? "Photo" : `${entry.imageCount} photos`; + current = openReply(entry.id, shortTitle(entry.text, entry.imageCount > 0 ? photos : "Message"), false, entry.at); + replies.push(current); + } else if (entry.type === "question_answer") { + current = openReply(entry.id, shortTitle(entry.text, "Answer"), false, entry.at); + replies.push(current); + } else if (entry.type === "routine") { + current = openReply(entry.id, entry.label, true, entry.at); + replies.push(current); + } else if (entry.type === "assistant") { + if (!current) { + // A reply with no visible input (the self-intro) still gets a group. + current = openReply(entry.id, "Reply", false, entry.at); + replies.push(current); + } + for (const call of entry.toolCalls) { + const step: BotRecapStep = { + id: call.id, + kind: call.kind, + label: call.label, + ...(call.detail ? { detail: call.detail } : {}), + ...(call.app ? { app: call.app } : {}), + status: "running", + }; + current.steps.push(step); + current.byCall.set(call.id, step); + } + } else if (entry.type === "tool_result") { + const step = current?.byCall.get(entry.toolCallId); + if (step) step.status = entry.status; + } + } + const liveId = running ? replies[replies.length - 1]?.id : undefined; + return replies + .filter((reply) => reply.steps.length > 0) + .map(({ byCall: _byCall, ...reply }) => ({ + ...reply, + steps: reply.steps.map((step) => + step.status === "running" && reply.id !== liveId ? { ...step, status: "failed" as const } : step, + ), + })) + .reverse(); +} + +const clock = new Intl.DateTimeFormat(undefined, { hour: "numeric", minute: "2-digit" }); +const day = new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric" }); + +function startOfDay(timestamp: number): number { + const date = new Date(timestamp); + date.setHours(0, 0, 0, 0); + return date.getTime(); +} + +/** "Today, 6:41 PM", "Yesterday, 9:05 AM", "Oct 6, 7:00 PM"; "Earlier" when the time is unknown. */ +export function formatBotActivityWhen(at: number | undefined, now: number): string { + if (at === undefined) return "Earlier"; + const days = Math.round((startOfDay(now) - startOfDay(at)) / 86_400_000); + const time = clock.format(at); + if (days <= 0) return `Today, ${time}`; + if (days === 1) return `Yesterday, ${time}`; + return `${day.format(at)}, ${time}`; +} +``` + +- [ ] **Step 4: Run the tests** + +Run: `npx tsx --test renderer/main/bots/bot-activity-log.test.ts && npx tsc --noEmit && npm run test:ci-policy` +Expected: 5 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add renderer/main/bots/bot-activity-log.ts renderer/main/bots/bot-activity-log.test.ts package.json scripts/ci-test-registry.json +git commit -m "Bots: the Activity log, every step grouped by reply" +``` + +--- +### Task 7: The Bot side panel on the workbench shell + +This task answers C1's remaining risks. **Focus-on-open lives in workspace code**: `BotWorkbench` focuses the selected tab after a person-initiated open (`consumeFocusRequest`), keeps focus on the selected tab while arrow keys move through the strip, and `BotPanelProvider.close` returns focus to the opener or the header toggle. **The panel keeps a hard-coded `#environment-panel` id**: the Bot panel passes `id="bot-panel"` / `idPrefix="bot-panel"`, and its own Escape handler closes it (the workspace Escape handler is not installed while the workspace surfaces are suppressed on Bots pages). + +**Files:** +- Create: `renderer/main/bots/bot-workbench.tsx`, `renderer/main/bots/bot-panel-activity.tsx`, `renderer/main/bots/bot-panel-files.tsx` +- Modify: `renderer/main/bots/bot-memory-page.tsx` (extract `BotMemoryContent`; `compact` groups) +- Modify: `renderer/main/bot-chat-route.tsx`, `renderer/main/bots/bot-chat-header.tsx` (add `BotPanelToggle`), `renderer/main/bots/bot-chat-pane.tsx` (shared live; toggle in the header) +- Test: `renderer/main/bots/bot-panel.test.tsx` (append) + +**Interfaces:** +- Consumes: `WorkbenchPanel`, `WorkbenchTab`, `WorkbenchView`, `useWorkbenchWidth` (C1); `resolveEnvironmentPanelLayout`; `BotPanelProvider`/`useBotPanel`, `BOT_PANEL_*` (Task 4); `BotLiveProvider`, `useSharedBotLive` (Task 5); `botActivityLog`, `formatBotActivityWhen` (Task 6); `botStepLine`, `BotRecapStep` (B); `BotRoutines` (existing). +- Produces: + - `BotWorkbench({ botId, children })` (wraps a Bot chat). + - `BotPanelActivity({ botName, entries, running, loading, now? })`. + - `BotPanelFiles({ botId, botName, active, request }: { …; active: boolean; request: BotPanelFileRequest | null })`. + - `BotMemoryContent({ bot, compact? }: { bot: Pick; compact?: boolean })` exported from `bot-memory-page.tsx`; `BotMemoryPage` renders it inside `BotPageShell`. + - `BotPanelToggle()` exported from `bot-chat-header.tsx` ("Show panel" / "Hide panel", `data-bot-panel-toggle`). + +- [ ] **Step 1: Write the failing tests** + +Add `within` to the `@testing-library/react` import of `renderer/main/bots/bot-panel.test.tsx`, and add: + +```tsx +import type { BotMemoryView } from "../../shared/bot-memory"; +import type { BotTranscriptEntry } from "../../shared/bot-live"; +import { mountWithBotRouter } from "./test-providers"; +import { botFixture } from "./test-fixtures"; +``` + +Append: + +```tsx +function chatSnapshot(entries: BotTranscriptEntry[] = []) { + return { + botId: "bot-1", + epoch: "e1", + seq: 0, + entries, + partial: null, + state: { kind: "idle" }, + question: null, + activity: null, + queued: [], + }; +} + +const FILE_HANDLERS = { + "bots:files:list": () => ({ + entries: [ + { path: "lists", name: "lists", parentPath: "", depth: 0, kind: "directory" }, + { path: "lists/groceries.md", name: "groceries.md", parentPath: "lists", depth: 1, kind: "file" }, + ], + truncated: false, + skippedDirectories: 0, + }), + "bots:files:read": (_botId: unknown, path: unknown) => ({ path, content: "eggs\nmilk", size: 9, modifiedAt: 1, version: "v1" }), +}; + +async function mountBotChat(handlers: Record unknown> = {}) { + const calls = installBotTestIpc({ + "bots:get": () => botFixture(), + "bots:pendingApprovals": () => [], + "bots:live:subscribe": () => chatSnapshot(), + "bots:live:unsubscribe": () => undefined, + "bots:routines:list": () => [], + ...handlers, + }); + const mounted = await mountWithBotRouter(null, { initialPath: "/bots/bot-1/chat" }); + await screen.findByRole("button", { name: "Planner profile" }); + return { calls, ...mounted }; +} + +const botPanel = () => screen.getByRole("complementary", { name: "Bot panel", hidden: true }); +const panelTab = (name: string) => within(botPanel()).getByRole("tab", { name }); + +test("the panel toggle opens the Bot panel with only Bot tabs, focused on the selected tab, and Close gives focus back", async () => { + await mountBotChat(); + assert.equal(botPanel().getAttribute("data-state"), "closed"); + const toggle = screen.getByRole("button", { name: "Show panel" }); + toggle.focus(); + fireEvent.click(toggle); + assert.equal(botPanel().getAttribute("data-state"), "open"); + assert.deepEqual( + within(botPanel()).getAllByRole("tab").map((tab) => tab.textContent), + ["Activity", "Files", "Memory", "Routines"], + ); + assert.equal(within(botPanel()).queryByRole("button", { name: /^Close .+ tab$/u }), null, "Bot tabs are fixed"); + assert.equal(within(botPanel()).queryByRole("button", { name: "New workspace tab" }), null); + await waitFor(() => assert.equal(document.activeElement, panelTab("Activity"))); + fireEvent.keyDown(panelTab("Activity"), { key: "ArrowRight" }); + await waitFor(() => assert.equal(document.activeElement, panelTab("Files"))); + assert.equal(panelTab("Files").getAttribute("aria-selected"), "true"); + assert.equal(screen.getByRole("button", { name: "Hide panel" }).getAttribute("aria-pressed"), "true"); + fireEvent.click(within(botPanel()).getByRole("button", { name: "Close Bot panel" })); + assert.equal(botPanel().getAttribute("data-state"), "closed"); + await waitFor(() => assert.equal(document.activeElement, screen.getByRole("button", { name: "Show panel" }))); +}); + +test("Escape inside the Bot panel closes it and leaves the workspace panel's saved state alone", async () => { + localStorage.setItem("aiden-agent.environment.open", "true"); + localStorage.setItem("aiden-agent.environment.open-tabs-v1", JSON.stringify(["files"])); + await mountBotChat(); + fireEvent.click(screen.getByRole("button", { name: "Show panel" })); + fireEvent.click(panelTab("Routines")); + panelTab("Routines").focus(); + fireEvent.keyDown(panelTab("Routines"), { key: "Escape" }); + assert.equal(botPanel().getAttribute("data-state"), "closed"); + assert.equal(localStorage.getItem("aiden-agent.environment.open"), "true"); + assert.equal(localStorage.getItem("aiden-agent.environment.open-tabs-v1"), JSON.stringify(["files"])); + fireEvent.click(screen.getByRole("button", { name: "Show panel" })); + assert.equal(panelTab("Routines").getAttribute("aria-selected"), "true", "it reopens where it was"); +}); + +test("Activity lists every step newest reply first, names a routine run, and tells failed from blocked", async () => { + const today = new Date().setHours(12, 0, 0, 0); + const yesterday = new Date(today).setDate(new Date(today).getDate() - 1); + const { calls } = await mountBotChat({ + "bots:live:subscribe": () => + chatSnapshot([ + { id: "r1", type: "routine", label: "Sunday plan", text: "Plan next week", at: yesterday }, + { id: "a1", type: "assistant", text: "", toolCalls: [{ id: "c1", name: "read_file", kind: "reading", label: "Read 1 file", detail: "pantry.md" }], stopReason: "toolUse", at: yesterday }, + { id: "t1", type: "tool_result", toolCallId: "c1", toolName: "read_file", isError: false, status: "ok" }, + { id: "a2", type: "assistant", text: "Planned.", toolCalls: [], stopReason: "stop", at: yesterday }, + { id: "u1", type: "user", text: "Plan dinners for this week", imageCount: 0, at: today }, + { + id: "a3", + type: "assistant", + text: "", + toolCalls: [ + { id: "c2", name: "web_search", kind: "searching", label: "Searched the web", detail: "dairy-free dinners" }, + { id: "c3", name: "run_command", kind: "command", label: "Ran a command" }, + { id: "c4", name: "edit_file", kind: "editing", label: "Edited 1 file", detail: "meal-plan.md" }, + ], + stopReason: "toolUse", + at: today, + }, + { id: "t2", type: "tool_result", toolCallId: "c2", toolName: "web_search", isError: false, status: "ok" }, + { id: "t3", type: "tool_result", toolCallId: "c3", toolName: "run_command", isError: true, status: "failed" }, + { id: "t4", type: "tool_result", toolCallId: "c4", toolName: "edit_file", isError: true, status: "blocked" }, + { id: "a4", type: "assistant", text: "Here's your week.", toolCalls: [], stopReason: "stop", at: today }, + ]), + }); + fireEvent.click(screen.getByRole("button", { name: "Show panel" })); + const headings = await within(botPanel()).findAllByRole("heading"); + assert.match(headings[0]!.textContent ?? "", /^Today, .+ · Plan dinners for this week$/u); + assert.match(headings[1]!.textContent ?? "", /^Yesterday, .+ · Sunday plan \(routine\)$/u); + const steps = within(botPanel()).getAllByRole("listitem").map((item) => item.textContent); + assert.deepEqual(steps, [ + "Searched the web · dairy-free dinners", + "Couldn’t run a command", + "Blocked: edit a file · meal-plan.md", + "Read 1 file · pantry.md", + ]); + assert.equal(calls.filter((call) => call.channel === "bots:live:subscribe").length, 1, "the chat and Activity share one subscription"); +}); + +test("Files lists the Bot's folder and opens a file read-only, with All files to go back", async () => { + const { calls } = await mountBotChat(FILE_HANDLERS); + fireEvent.click(screen.getByRole("button", { name: "Show panel" })); + fireEvent.click(panelTab("Files")); + fireEvent.click(await within(botPanel()).findByRole("button", { name: "groceries.md" })); + assert.equal((await within(botPanel()).findByLabelText("Contents of lists/groceries.md")).textContent, "eggs\nmilk"); + assert.deepEqual(calls.find((call) => call.channel === "bots:files:read")?.args, ["bot-1", "lists/groceries.md"]); + fireEvent.click(within(botPanel()).getByRole("button", { name: "All files" })); + assert.ok(await within(botPanel()).findByRole("list", { name: "Files" })); +}); + +function memoryView(entries: { id: string; text: string }[]): BotMemoryView { + const store = (list: { id: string; text: string }[], limitChars: number) => ({ + entries: list, + usedChars: list.reduce((sum, entry) => sum + entry.text.length, 0), + limitChars, + overBudget: false, + }); + return { botId: "bot-1", revision: `rev-${entries.length}`, readable: true, user: store(entries, 1_375), memory: store([], 2_200), updatedAt: 1 }; +} + +test("Memory in the panel edits an entry exactly as Profile → Memory does", async () => { + const edits: unknown[] = []; + await mountBotChat({ + "bots:memory:get": () => memoryView([{ id: "u-short", text: "Prefers short answers." }]), + "bots:memory:edit": (input) => { + edits.push((input as { edit: unknown }).edit); + return { ok: true, view: memoryView([{ id: "u-short", text: "Prefers short, friendly answers." }]) }; + }, + }); + fireEvent.click(screen.getByRole("button", { name: "Show panel" })); + fireEvent.click(panelTab("Memory")); + assert.ok(await within(botPanel()).findByText("Prefers short answers.")); + fireEvent.keyDown(within(botPanel()).getByRole("button", { name: "Memory options" }), { key: "Enter" }); + fireEvent.click(await screen.findByRole("menuitem", { name: "Edit" })); + const dialog = await screen.findByRole("dialog", { name: "Edit memory" }); + fireEvent.change(within(dialog).getByRole("textbox", { name: "Memory" }), { target: { value: "Prefers short, friendly answers." } }); + fireEvent.click(within(dialog).getByRole("button", { name: "Save" })); + await waitFor(() => + assert.deepEqual(edits, [{ kind: "replace", target: "user", entryId: "u-short", text: "Prefers short, friendly answers." }]), + ); + assert.ok(await within(botPanel()).findByText("Prefers short, friendly answers.")); +}); + +test("Routines in the panel lists the Bot's routines and offers Add routine", async () => { + await mountBotChat({ + "bots:routines:list": () => [ + { + id: "task-1", + botId: "bot-1", + name: "Weekly meal prep", + prompt: "Plan this week's meals.", + schedule: { kind: "weekly", days: [0], time: "08:41" }, + timezone: "UTC", + label: "Every Sunday at 8:41 AM", + enabled: true, + updatedAt: 10, + }, + ], + }); + fireEvent.click(screen.getByRole("button", { name: "Show panel" })); + fireEvent.click(panelTab("Routines")); + const list = await within(botPanel()).findByRole("list", { name: "Routines" }); + assert.ok(within(list).getByText("Every Sunday at 8:41 AM")); + fireEvent.click(within(botPanel()).getByRole("button", { name: /Add routine/u })); + assert.ok(await screen.findByRole("dialog", { name: "Add routine" })); +}); +``` + +- [ ] **Step 2: Run them to verify they fail** + +Run: `npx tsx --test renderer/main/bots/bot-panel.test.tsx` +Expected: FAIL. There is no "Show panel" button. + +- [ ] **Step 3: Extract `BotMemoryContent`** + +In `renderer/main/bots/bot-memory-page.tsx`: + +1. Give `MemoryGroup` a `compact?: boolean` prop (add `compact = false,` to the destructuring and `compact?: boolean;` to its type), and change its `
` and `

` classes to: + +```tsx +
+

+``` + +2. Rename `export function BotMemoryPage({ bot, onClose }: …)` to `export function BotMemoryContent({ bot, compact = false }: { bot: Pick; compact?: boolean })`. Pass `compact={compact}` to both ``s. Replace its `return ({body}…);` with the same children in a fragment: + +```tsx + return ( + <> + {body} + {editing ? ( + setEditing(null)} + onView={onView} + /> + ) : null} + { + if (!erasingBusy) setErasing(open); + }} + title={`Erase everything ${bot.name} remembers?`} + description="This can’t be undone." + confirmLabel="Erase memory" + confirmVariant="destructive" + busy={erasingBusy} + keepOpenOnConfirm + onConfirm={erase} + /> + + ); +} +``` + +3. Add the page back as a thin shell: + +```tsx +/** Profile → Memory: the same content as the side panel's Memory tab, as a full page. */ +export function BotMemoryPage({ + bot, + onClose, +}: { + bot: Pick; + onClose(): void; +}) { + return ( + + + + ); +} +``` + +- [ ] **Step 4: Write the Activity and Files tabs** + +```tsx +// renderer/main/bots/bot-panel-activity.tsx +// Activity (spec §8): every step of every reply, newest reply first, grouped by +// the message or routine that started it. Red is a failure, amber a refusal, +// with B's step wording; the live step shimmers (only with motion allowed). + +import * as React from "react"; +import { EmptyState } from "../../components/ui"; +import { cn } from "../../lib/ui-utils"; +import type { BotTranscriptEntry } from "../../shared/bot-live"; +import { botActivityLog, formatBotActivityWhen, type BotActivityReply } from "./bot-activity-log"; +import { botStepLine, type BotRecapStep } from "./bot-activity-steps"; + +const TONE: Record<"normal" | "warning" | "error", string> = { + normal: "text-secondary", + warning: "text-support-warning", + error: "text-red", +}; + +function ActivityStep({ step }: { step: BotRecapStep }) { + const line = botStepLine(step); + return ( +
+ + {line.text} + {line.detail ? · {line.detail} : null} + +
+ ); +} + +function ActivityReply({ reply, now }: { reply: BotActivityReply; now: number }) { + const headingId = React.useId(); + const title = reply.routine ? `${reply.title} (routine)` : reply.title; + return ( +
+

+ {`${formatBotActivityWhen(reply.at, now)} · ${title}`} +

+
+ {reply.steps.map((step) => ( + + ))} +
+
+ ); +} + +export function BotPanelActivity({ + botName, + entries, + running, + loading, + now = Date.now(), +}: { + botName: string; + entries: readonly BotTranscriptEntry[]; + running: boolean; + loading: boolean; + now?: number; +}) { + const replies = React.useMemo(() => botActivityLog(entries, { running }), [entries, running]); + if (loading) { + return ( +
+ {[0, 1, 2].map((row) => ( + + ))} +
+ ); + } + if (replies.length === 0) { + return ( + + ); + } + return ( +
+ {replies.map((reply) => ( + + ))} +
+ ); +} +``` + +```tsx +// renderer/main/bots/bot-panel-files.tsx +// Files (spec §8): the files in the Bot's own folder, read-only, with a simple +// text viewer. Replaces the old Files dialog. A file chip in the chat opens +// this tab on that file through the panel's file request. + +import * as React from "react"; +import { ChevronLeft, File, Folder, RotateCcw } from "lucide-react"; +import { Button, Callout, EmptyState, Text } from "../../components/ui"; +import { botsApi } from "../../lib/ipc"; +import { userFacingErrorMessage } from "../../lib/ipc-error"; +import type { WorkspaceFileDocument, WorkspaceFileIndex } from "../../lib/types"; +import type { BotPanelFileRequest } from "./bot-panel-context"; + +type Load = { status: "loading" } | { status: "ready"; value: T } | { status: "failed"; message: string }; + +export function BotPanelFiles({ + botId, + botName, + active, + request, +}: { + botId: string; + botName: string; + /** The tab is visible: the list is read each time it comes into view. */ + active: boolean; + request: BotPanelFileRequest | null; +}) { + const [index, setIndex] = React.useState>({ status: "loading" }); + const [path, setPath] = React.useState(request?.path ?? null); + const [document, setDocument] = React.useState | null>(null); + const [attempt, setAttempt] = React.useState(0); + const requestedPath = request?.path ?? null; + const requestKey = request?.key ?? 0; + + React.useEffect(() => { + if (requestedPath !== null) setPath(requestedPath); + }, [requestedPath, requestKey]); + + React.useEffect(() => { + if (!active) return; + let current = true; + setIndex({ status: "loading" }); + botsApi.files.list(botId).then( + (value) => current && setIndex({ status: "ready", value }), + (error: unknown) => + current && + setIndex({ status: "failed", message: userFacingErrorMessage(error, `Aiden couldn’t load ${botName}’s files.`) }), + ); + return () => { + current = false; + }; + }, [active, botId, botName, attempt]); + + React.useEffect(() => { + if (path === null) { + setDocument(null); + return; + } + if (!active) return; + let current = true; + setDocument({ status: "loading" }); + botsApi.files.read(botId, path).then( + (value) => current && setDocument({ status: "ready", value }), + (error: unknown) => + current && setDocument({ status: "failed", message: userFacingErrorMessage(error, "Aiden couldn’t open that file.") }), + ); + return () => { + current = false; + }; + }, [active, botId, path, attempt]); + + const retry = (message: string) => ( + + + {message} + + + + ); + const loading = (label: string) => ( +
+ {[0, 1, 2].map((row) => ( +
+ ))} +
+ ); + + if (path !== null) { + return ( +
+
+ +
+ + {path.split("/").pop() || path} + + {document === null || document.status === "loading" ? ( + loading("Opening file") + ) : document.status === "failed" ? ( + retry(document.message) + ) : ( +
+            {document.value.content}
+          
+ )} +
+ ); + } + + const files = index.status === "ready" ? index.value.entries : []; + return ( +
+ + {`Files ${botName} made or saved while helping you.`} + + {index.status === "loading" ? ( + loading("Loading files") + ) : index.status === "failed" ? ( + retry(index.message) + ) : files.length === 0 ? ( + + ) : ( +
    + {files.map((entry) => ( +
  • + {entry.kind === "file" ? ( + + ) : ( + + + )} +
  • + ))} +
+ )} +
+ ); +} +``` + +- [ ] **Step 5: Write the workbench** + +```tsx +// renderer/main/bots/bot-workbench.tsx +// The Bot side panel (spec §8) on the C1 workbench shell, beside a Bot's chat: +// fixed Activity, Files, Memory and Routines tabs. A tab's body mounts the first +// time it is shown, then stays mounted like the workspace views. The panel has +// its own state, id, width key, focus hand-off and Escape, so the workspace +// Environment panel (suppressed on Bots pages) is never touched. + +import * as React from "react"; +import { CalendarClock, Files, ListChecks } from "lucide-react"; +import { MemoryCardIcon } from "../../components/memory-card-icon"; +import { WorkbenchPanel, useWorkbenchWidth, type WorkbenchTab, type WorkbenchView } from "../../components/workbench-panel"; +import { resolveEnvironmentPanelLayout } from "../../lib/environment-panel-layout"; +import { useBot } from "../../lib/queries"; +import { cn } from "../../lib/ui-utils"; +import { BotLiveProvider, useSharedBotLive } from "../../lib/use-bot-live"; +import { BotMemoryContent } from "./bot-memory-page"; +import { BotPanelActivity } from "./bot-panel-activity"; +import { BOT_PANEL_ID, BOT_PANEL_TABS, BOT_PANEL_WIDTH_KEY, useBotPanel, type BotPanelTab } from "./bot-panel-context"; +import { BotPanelFiles } from "./bot-panel-files"; +import { BotRoutines } from "./bot-routines"; + +const TAB_META: Record }> = { + activity: { label: "Activity", icon: ListChecks }, + files: { label: "Files", icon: Files }, + memory: { label: "Memory", icon: MemoryCardIcon }, + routines: { label: "Routines", icon: CalendarClock }, +}; + +export function BotWorkbench({ botId, children }: React.PropsWithChildren<{ botId: string }>) { + return ( + + {children} + + ); +} + +function BotWorkbenchLayout({ botId, children }: React.PropsWithChildren<{ botId: string }>) { + const panel = useBotPanel(); + const { open, tab, fileRequest, focusRequest, close, consumeFocusRequest, setTab } = panel; + const bot = useBot(botId).data ?? null; + const live = useSharedBotLive(botId); + const containerRef = React.useRef(null); + const activeTabRef = React.useRef(null); + const [containerWidth, setContainerWidth] = React.useState(() => window.innerWidth); + const panelWidth = useWorkbenchWidth(BOT_PANEL_WIDTH_KEY); + const { width, inline } = resolveEnvironmentPanelLayout(panelWidth.preferredWidth, containerWidth); + const [visited, setVisited] = React.useState>(() => new Set()); + + React.useLayoutEffect(() => { + const element = containerRef.current; + if (!element) return; + const measure = () => setContainerWidth(element.getBoundingClientRect().width); + measure(); + const observer = new ResizeObserver(measure); + observer.observe(element); + return () => observer.disconnect(); + }, []); + + React.useEffect(() => { + if (!open) return; + setVisited((current) => (current.has(tab) ? current : new Set([...current, tab]))); + }, [open, tab]); + + // A person-initiated open focuses the selected tab; a panel restored open at launch does not. + React.useEffect(() => { + if (!open) return; + const frame = requestAnimationFrame(() => { + if (consumeFocusRequest()) activeTabRef.current?.focus(); + }); + return () => cancelAnimationFrame(frame); + }, [open, tab, focusRequest, consumeFocusRequest]); + + // Arrow keys in the strip select a tab; focus follows the one tab stop. + React.useEffect(() => { + const active = document.activeElement; + if ( + active instanceof HTMLElement && + active.getAttribute("role") === "tab" && + active.closest(`#${BOT_PANEL_ID}`) && + active !== activeTabRef.current + ) { + activeTabRef.current?.focus(); + } + }, [tab]); + + React.useEffect(() => { + if (!open) return; + const onKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented || event.key !== "Escape") return; + if (document.querySelector('[data-slot="dialog-content"][data-state="open"]')) return; + const active = document.activeElement; + if (!(active instanceof HTMLElement) || !active.closest(`#${BOT_PANEL_ID}`)) return; + event.preventDefault(); + close(); + }; + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); + }, [open, close]); + + const tabs: WorkbenchTab[] = BOT_PANEL_TABS.map((id) => ({ + id, + viewId: id, + label: TAB_META[id].label, + icon: TAB_META[id].icon, + selected: tab === id, + closable: false, + })); + const running = live.snapshot?.state.kind === "running"; + const body = (id: BotPanelTab, active: boolean): React.ReactNode => { + if (!bot) return null; + switch (id) { + case "activity": + return ( + + ); + case "files": + return ; + case "memory": + return ; + case "routines": + return ; + } + }; + const views: WorkbenchView[] = BOT_PANEL_TABS.map((id) => ({ + id, + render: ({ presented, selected }) => + visited.has(id) ? ( +
{body(id, presented && selected)}
+ ) : null, + })); + + return ( +
+
{children}
+ + ); +} +``` + +- [ ] **Step 6: Mount it around the chat and add the toggle** + +`renderer/main/bot-chat-route.tsx`: + +```tsx +import { BotChatPane } from "./bots/bot-chat-pane"; +import { BotWorkbench } from "./bots/bot-workbench"; + +/** `/bots/$botId/chat`: one Bot's conversation, with its side panel beside it. */ +export function BotChatRoute({ botId }: { botId: string }) { + return ( + + + + ); +} +``` + +`renderer/main/bots/bot-chat-header.tsx`: change the lucide import to `import { ChevronLeft, Ellipsis, PanelRightClose, PanelRightOpen } from "lucide-react";`, add + +```tsx +import { useShortcutBinding, useShortcutLabel } from "../../lib/command-system"; +import { ariaKeyShortcut } from "../../shared/keybindings"; +import { BOT_PANEL_ID, useBotPanel } from "./bot-panel-context"; +``` + +and append: + +```tsx +/** Shows and hides the Bot side panel; `environment.toggle` does the same from the keyboard. */ +export function BotPanelToggle() { + const panel = useBotPanel(); + const shortcut = useShortcutLabel("environment.toggle"); + const binding = useShortcutBinding("environment.toggle"); + return ( + + ); +} +``` + +`renderer/main/bots/bot-chat-pane.tsx`: + +1. Change `import { useBotLive } from "../../lib/use-bot-live";` to `import { useSharedBotLive } from "../../lib/use-bot-live";` and `const live = useBotLive(botId);` to `const live = useSharedBotLive(botId);`. +2. Add `BotPanelToggle` to the `./bot-chat-header` import. +3. In `shell`'s `actions`, render the toggle before the ••• menu: + +```tsx + actions={ + current ? ( + <> + + void navigate({ to: "/bots/$botId", params: { botId: current.id } })} + onOpenFiles={() => setFiles({ open: true, path: null })} + onOpenMemory={openMemory} + onOpenAdvanced={openAdvanced} + onDelete={() => setDeleteOpen(true)} + /> + + ) : null + } +``` + +- [ ] **Step 7: Run the tests** + +Run: +```bash +npx tsx --test renderer/main/bots/bot-panel.test.tsx renderer/main/bots/bot-memory-page.test.tsx renderer/main/bots/bot-chat-pane.test.tsx renderer/main/bots/bot-chat-header.test.tsx renderer/components/workbench-panel.test.tsx +npx tsc --noEmit && npm run lint +``` +Expected: all PASS (Profile → Memory's suite proves the page still behaves the same). + +- [ ] **Step 8: Commit** + +```bash +git add renderer/main/bots/bot-workbench.tsx renderer/main/bots/bot-panel-activity.tsx renderer/main/bots/bot-panel-files.tsx renderer/main/bots/bot-memory-page.tsx renderer/main/bot-chat-route.tsx renderer/main/bots/bot-chat-header.tsx renderer/main/bots/bot-chat-pane.tsx renderer/main/bots/bot-panel.test.tsx +git commit -m "Bots: the Bot side panel with Activity, Files, Memory and Routines" +``` + +--- +### Task 8: File chips, ••• Files and "Memory updated" open the panel; the Files dialog goes + +**Files:** +- Modify: `renderer/main/bots/bot-chat-pane.tsx` +- Delete: `renderer/main/bots/bot-files-dialog.tsx` +- Test: `renderer/main/bots/bot-panel.test.tsx` (append), `renderer/main/bots/bot-chat-pane.test.tsx` + +**Interfaces:** +- Consumes: `useBotPanel().show(tab, { path? })` (Task 4); the panel (Task 7). +- Produces: nothing new. `BotChatPane` no longer imports or renders `BotFilesDialog`. + +- [ ] **Step 1: Write the failing tests** + +Append to `renderer/main/bots/bot-panel.test.tsx`: + +```tsx +test("a file the Bot wrote is a chip that opens the panel's Files tab on that file, again after going back", async () => { + await mountBotChat({ + ...FILE_HANDLERS, + "bots:live:subscribe": () => + chatSnapshot([ + { id: "u1", type: "user", text: "Write my grocery list", imageCount: 0 }, + { id: "f1", type: "file", path: "lists/groceries.md", operation: "written" }, + { id: "a1", type: "assistant", text: "Done.", toolCalls: [], stopReason: "stop" }, + ]), + }); + fireEvent.click(await screen.findByRole("button", { name: "Open groceries.md" })); + assert.equal(botPanel().getAttribute("data-state"), "open"); + assert.equal(panelTab("Files").getAttribute("aria-selected"), "true"); + assert.equal((await within(botPanel()).findByLabelText("Contents of lists/groceries.md")).textContent, "eggs\nmilk"); + fireEvent.click(within(botPanel()).getByRole("button", { name: "All files" })); + await within(botPanel()).findByRole("list", { name: "Files" }); + fireEvent.click(screen.getByRole("button", { name: "Open groceries.md" })); + assert.ok(await within(botPanel()).findByLabelText("Contents of lists/groceries.md"), "the same chip selects its file again"); + assert.equal(screen.queryByRole("dialog"), null, "no Files dialog any more"); +}); + +test("Files and Memory in the chat's ••• menu open the panel on that tab", async () => { + await mountBotChat({ ...FILE_HANDLERS, "bots:memory:get": () => memoryView([]) }); + fireEvent.keyDown(screen.getByRole("button", { name: "More for Planner" }), { key: "Enter" }); + fireEvent.click(await screen.findByRole("menuitem", { name: "Files" })); + assert.equal(panelTab("Files").getAttribute("aria-selected"), "true"); + assert.ok(await within(botPanel()).findByRole("button", { name: "groceries.md" })); + fireEvent.keyDown(screen.getByRole("button", { name: "More for Planner" }), { key: "Enter" }); + fireEvent.click(await screen.findByRole("menuitem", { name: "Memory" })); + assert.equal(panelTab("Memory").getAttribute("aria-selected"), "true"); +}); +``` + +In `renderer/main/bots/bot-chat-pane.test.tsx`: + +1. Change the `node:test` import to `import { afterEach, beforeEach, test } from "node:test";` and add `beforeEach(() => localStorage.clear());` under `afterEach(cleanup);`, so a panel opened by one test never carries into the next. +2. Delete the tests "Files in the ••• menu lists the Bot's folder and opens a file" and "a file the Bot wrote shows as a chip that opens that file", and the `FILE_HANDLERS` constant they used. Their behaviour is now covered by the two panel tests above and Task 7's Files test. +3. In "Memory updated is a quiet caption, outside Updates, that opens this Bot's Memory page", rename it to "Memory updated is a quiet caption, outside Updates, that opens the panel's Memory tab" and replace its last three lines (`fireEvent.click(captions[0]!);` onwards) with: + +```tsx + fireEvent.click(captions[0]!); + const panel = screen.getByRole("complementary", { name: "Bot panel" }); + assert.equal(within(panel).getByRole("tab", { name: "Memory" }).getAttribute("aria-selected"), "true"); + assert.equal(router.state.location.pathname, "/bots/bot-1/chat", "the chat stays open beside it"); +``` + +- [ ] **Step 2: Run them to verify they fail** + +Run: `npx tsx --test renderer/main/bots/bot-panel.test.tsx renderer/main/bots/bot-chat-pane.test.tsx` +Expected: FAIL. The chip still opens a dialog, and the caption navigates to Profile → Memory. + +- [ ] **Step 3: Implement** + +In `renderer/main/bots/bot-chat-pane.tsx`: + +1. Delete `import { BotFilesDialog } from "./bot-files-dialog";` and add `import { useBotPanel } from "./bot-panel-context";`. +2. Delete `const [files, setFiles] = React.useState<{ open: boolean; path: string | null }>({ open: false, path: null });` and add `const panel = useBotPanel();` in its place. +3. Replace `const openMemory = () => void navigate({ to: "/bots/$botId", params: { botId }, search: { page: "memory" } });` with: + +```tsx + // Memory, Files and Routines live in the side panel beside the chat (spec §8). + const openMemory = () => panel.show("memory"); +``` + +4. In `shell`, change `onOpenFiles={() => setFiles({ open: true, path: null })}` to `onOpenFiles={() => panel.show("files")}` and delete the whole `` element (leave `BotDeleteDialog`). +5. In the `TranscriptEntries` call, change `onOpenFile={(path) => setFiles({ open: true, path })}` to `onOpenFile={(path) => panel.show("files", { path })}`. + +Delete the file: + +```bash +git rm renderer/main/bots/bot-files-dialog.tsx +``` + +- [ ] **Step 4: Run the tests** + +Run: +```bash +npx tsx --test renderer/main/bots/bot-panel.test.tsx renderer/main/bots/bot-chat-pane.test.tsx +git grep -n "bot-files-dialog\|BotFilesDialog" -- renderer main tests +npx tsc --noEmit && npm run lint +``` +Expected: tests PASS; the grep prints nothing; no type or lint errors. + +- [ ] **Step 5: Commit** + +```bash +git add renderer/main/bots/bot-chat-pane.tsx renderer/main/bots/bot-panel.test.tsx renderer/main/bots/bot-chat-pane.test.tsx +git commit -m "Bots: file chips, Files and Memory open the side panel; the Files dialog is removed" +``` + +--- + +### Task 9: One ••• menu for every Bot surface, with Routines and Pin to top + +**Files:** +- Create: `renderer/main/bots/bot-menu.tsx` +- Modify: `renderer/main/bots/bot-chat-header.tsx` (`BotChatActions`), `renderer/main/bots/bot-chat-pane.tsx` +- Test: `renderer/main/bots/bot-chat-header.test.tsx`, `renderer/main/bots/bot-panel.test.tsx` (append) + +**Interfaces:** +- Consumes: `useBotPanel` (Task 4), `useBotSidebarPreferences` (Task 2), `botChatPath`, `botChatRouteBotId` (Task 1). +- Produces: + - `type BotMenuAction = "profile" | "files" | "memory" | "routines" | "advanced" | "pin" | "delete"` + - `BotMenuItems({ pinned, Item, Separator, onAction })` (the §10 order; "Pin to top" or "Unpin") + - `useBotMenuActions(onDelete: (bot: BotDefinition) => void): (bot: BotDefinition, action: BotMenuAction) => void`. Files/Memory/Routines open the panel on that tab and, when that Bot's chat is not the open page, navigate to it. + - `BotChatActions({ bot, pinned, onAction }: { bot: Pick; pinned: boolean; onAction(action: BotMenuAction): void })` + +- [ ] **Step 1: Write the failing tests** + +Replace `renderer/main/bots/bot-chat-header.test.tsx` with: + +```tsx +import "./test-dom"; +import assert from "node:assert/strict"; +import { afterEach, test } from "node:test"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { DEFAULT_BOT_AVATAR } from "../../shared/bots"; +import { BotChatActions, BotChatBackButton, BotChatNamePill } from "./bot-chat-header"; + +afterEach(cleanup); + +const bot = { id: "b1", name: "SLMob", avatar: { ...DEFAULT_BOT_AVATAR } }; + +function renderHeader(pinned = false) { + const calls: string[] = []; + render( + <> + calls.push("back")} /> + calls.push("profile")} /> + calls.push(action)} /> + , + ); + return calls; +} + +const openMenu = async () => { + fireEvent.keyDown(screen.getByRole("button", { name: "More for SLMob" }), { key: "Enter" }); + return (await screen.findAllByRole("menuitem")).map((item) => item.textContent); +}; + +test("the name pill opens the Bot's profile and back returns to all Bots", () => { + const calls = renderHeader(); + fireEvent.click(screen.getByRole("button", { name: "SLMob profile" })); + fireEvent.click(screen.getByRole("button", { name: "All Bots" })); + assert.deepEqual(calls, ["profile", "back"]); +}); + +test("the ••• menu offers Profile, Files, Memory, Routines, Advanced, Pin to top and Delete Bot, in that order", async () => { + const calls = renderHeader(); + assert.deepEqual(await openMenu(), ["Profile", "Files", "Memory", "Routines", "Advanced", "Pin to top", "Delete Bot"]); + fireEvent.click(screen.getByRole("menuitem", { name: "Routines" })); + assert.deepEqual(calls, ["routines"]); +}); + +test("a pinned Bot's menu offers Unpin instead", async () => { + const calls = renderHeader(true); + assert.ok((await openMenu()).includes("Unpin")); + fireEvent.click(screen.getByRole("menuitem", { name: "Unpin" })); + assert.deepEqual(calls, ["pin"]); +}); +``` + +Add `import { BOT_SIDEBAR_PREFERENCES_KEY, parseBotSidebarPreferences } from "../../lib/bot-sidebar-preferences";` to `bot-panel.test.tsx` and append: + +```tsx +test("Routines in the chat's ••• menu opens the panel on Routines, and Pin to top pins the Bot", async () => { + await mountBotChat(); + const more = () => screen.getByRole("button", { name: "More for Planner" }); + fireEvent.keyDown(more(), { key: "Enter" }); + fireEvent.click(await screen.findByRole("menuitem", { name: "Routines" })); + assert.equal(panelTab("Routines").getAttribute("aria-selected"), "true"); + fireEvent.keyDown(more(), { key: "Enter" }); + fireEvent.click(await screen.findByRole("menuitem", { name: "Pin to top" })); + assert.equal(parseBotSidebarPreferences(localStorage.getItem(BOT_SIDEBAR_PREFERENCES_KEY), 0).pinnedBotId, "bot-1"); + fireEvent.keyDown(more(), { key: "Enter" }); + assert.ok(await screen.findByRole("menuitem", { name: "Unpin" })); +}); +``` + +- [ ] **Step 2: Run them to verify they fail** + +Run: `npx tsx --test renderer/main/bots/bot-chat-header.test.tsx renderer/main/bots/bot-panel.test.tsx` +Expected: FAIL. `BotChatActions` has no `onAction`, and there is no Routines item. + +- [ ] **Step 3: Write the shared menu** + +```tsx +// renderer/main/bots/bot-menu.tsx +// The one Bot ••• menu (spec §10), for the chat header, sidebar rows (hover and +// right-click) and the pinned Bot: Profile, Files, Memory, Routines, Advanced, +// Pin to top/Unpin, then Delete Bot. Files, Memory and Routines open the side +// panel on that tab, beside that Bot's chat. + +import * as React from "react"; +import { useNavigate, useRouterState } from "@tanstack/react-router"; +import type { + ContextMenuItem, + ContextMenuSeparator, + DropdownMenuItem, + DropdownMenuSeparator, +} from "../../components/ui"; +import { botChatPath, botChatRouteBotId } from "../../lib/bot-routes"; +import type { BotDefinition } from "../../shared/bots"; +import { useBotPanel } from "./bot-panel-context"; +import { useBotSidebarPreferences } from "./use-bot-sidebar-preferences"; + +export type BotMenuAction = "profile" | "files" | "memory" | "routines" | "advanced" | "pin" | "delete"; + +export function BotMenuItems({ + pinned, + Item, + Separator, + onAction, +}: { + pinned: boolean; + Item: typeof DropdownMenuItem | typeof ContextMenuItem; + Separator: typeof DropdownMenuSeparator | typeof ContextMenuSeparator; + onAction(action: BotMenuAction): void; +}) { + return ( + <> + onAction("profile")}>Profile + onAction("files")}>Files + onAction("memory")}>Memory + onAction("routines")}>Routines + onAction("advanced")}>Advanced + onAction("pin")}>{pinned ? "Unpin" : "Pin to top"} + + onAction("delete")}> + Delete Bot + + + ); +} + +/** What each menu item does for a Bot. Delete is the caller's, because it owns the confirmation. */ +export function useBotMenuActions(onDelete: (bot: BotDefinition) => void) { + const navigate = useNavigate(); + const { show } = useBotPanel(); + const { preferences, pin, unpin } = useBotSidebarPreferences(); + const pathname = useRouterState({ select: (state) => state.location.pathname }); + const chatBotId = botChatRouteBotId(pathname); + const pinnedBotId = preferences.pinnedBotId; + return React.useCallback( + (bot: BotDefinition, action: BotMenuAction) => { + switch (action) { + case "profile": + void navigate({ to: "/bots/$botId", params: { botId: bot.id } }); + return; + case "advanced": + void navigate({ to: "/bots/$botId", params: { botId: bot.id }, search: { page: "advanced" } }); + return; + case "files": + case "memory": + case "routines": + show(action); + if (chatBotId !== bot.id) void navigate(botChatPath(bot.id)); + return; + case "pin": + if (pinnedBotId === bot.id) unpin(); + else pin(bot.id); + return; + case "delete": + onDelete(bot); + return; + } + }, + [chatBotId, navigate, onDelete, pin, pinnedBotId, show, unpin], + ); +} +``` + +- [ ] **Step 4: Use it in the chat header** + +In `renderer/main/bots/bot-chat-header.tsx`, add `import { BotMenuItems, type BotMenuAction } from "./bot-menu";` and replace `BotChatActions` with: + +```tsx +/** The action side of a Bot chat: the shared Bot ••• menu (spec §10). */ +export function BotChatActions({ + bot, + pinned, + onAction, +}: { + bot: Pick; + pinned: boolean; + onAction(action: BotMenuAction): void; +}) { + return ( + + + + + + + + + ); +} +``` + +In `renderer/main/bots/bot-chat-pane.tsx`, add + +```tsx +import { useBotMenuActions } from "./bot-menu"; +import { useBotSidebarPreferences } from "./use-bot-sidebar-preferences"; +``` + +below `const panel = useBotPanel();` add + +```tsx + const { preferences } = useBotSidebarPreferences(); + const menuAction = useBotMenuActions(() => setDeleteOpen(true)); +``` + +and replace the `` element in `shell` with: + +```tsx + menuAction(current, action)} + /> +``` + +Keep `openMemory` and `openAdvanced`: the "Memory updated" caption, the interrupted card and the model-error card still use them. + +- [ ] **Step 5: Run the tests** + +Run: `npx tsx --test renderer/main/bots/bot-chat-header.test.tsx renderer/main/bots/bot-panel.test.tsx renderer/main/bots/bot-chat-pane.test.tsx && npx tsc --noEmit && npm run lint` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add renderer/main/bots/bot-menu.tsx renderer/main/bots/bot-chat-header.tsx renderer/main/bots/bot-chat-header.test.tsx renderer/main/bots/bot-chat-pane.tsx renderer/main/bots/bot-panel.test.tsx +git commit -m "Bots: one ••• menu with Routines and Pin to top on every Bot surface" +``` + +--- +### Task 10: The Bots sidebar + +**Files:** +- Create: `renderer/main/bots/bot-sidebar-rows.ts`, `renderer/main/bots/bot-sidebar.tsx` +- Test: `renderer/main/bots/bot-sidebar.test.tsx` +- Modify: `renderer/components/chat-sidebar.tsx` (the exported `ChatSidebar` at about line 461) +- Modify: `renderer/components/remote-bots-section.tsx`, `renderer/main/remote-bots.tsx`, `renderer/main/remote-new-chat-view.test.tsx` +- Modify: `renderer/main/bots/bot-chat-pane.tsx` (mark viewed; no "All Bots" back), `renderer/main/bots/bot-chat-header.tsx` (delete `BotChatBackButton`), `renderer/main/bots/bot-chat-header.test.tsx` +- Modify: `package.json` (`test:bots`), `scripts/ci-test-registry.json` (`renderer-other`) + +Before writing the markup, read `docs/chatgpt-desktop-ui-inspiration.md` and `docs/chatgpt-ui-element-specimen.html` (sidebar rows, hover affordances, badges, focus) and `docs/design-guide.md`. + +**Interfaces:** +- Consumes: `botLiveSummary` fields (Task 3), `useBotSidebarPreferences`, `isBotUnread` (Task 2), `botChatPath`, `botRouteBotId`, `isBotsPath`, `lastNonBotRoute` (Task 1), `BotMenuItems`, `useBotMenuActions` (Task 9), `BotAvatar` `working` (B), `botRowStatusLabel` (existing). +- Produces: + - `bot-sidebar-rows.ts`: `interface BotSidebarRow { bot: BotDefinition; state?: BotSessionState; preview?: string; activityAt: number; needsYou: boolean; lastReplyAt: number | null }`, `BOT_WORKING_LINE = "Thinking…"`, `BOT_WAITING_LINE = "Waiting for you"`, `botSidebarRows(bots, summaries): BotSidebarRow[]` (newest activity first, then name), `matchesBotSearch(row, query): boolean`, `type BotRowLine = { kind: "working" | "waiting" | "status" | "preview"; text: string }`, `botRowLine(row): BotRowLine | null`, `botLiveSummaryKey(botId)`, `useBotRows(): { bots: UseQueryResult; rows: BotSidebarRow[]; summariesSettled: boolean }` (re-reads a row on `bots:attention`). + - `BotSidebar()`. + - `RemoteBotsSection({ groups, opening, onOpen, query? })`, `RemoteBots({ query? })`. + +- [ ] **Step 1: Write the failing tests** + +```tsx +// renderer/main/bots/bot-sidebar.test.tsx +import { emitBotTestNotification, installBotTestIpc } from "./test-dom"; +import assert from "node:assert/strict"; +import { afterEach, beforeEach, test } from "node:test"; +import { cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react"; +import { rememberRoute } from "../../lib/bot-routes"; +import { + BOT_SIDEBAR_PREFERENCES_KEY, + parseBotSidebarPreferences, + serializeBotSidebarPreferences, +} from "../../lib/bot-sidebar-preferences"; +import type { BotLiveSummary } from "../../shared/bot-live"; +import type { BotDefinition } from "../../shared/bots"; +import { BotSidebar } from "./bot-sidebar"; +import { mountWithBotRouter } from "./test-providers"; +import { botFixture } from "./test-fixtures"; + +beforeEach(() => localStorage.clear()); +afterEach(cleanup); + +const PLANNER = botFixture({ id: "bot-1", name: "Planner", description: "Plans trips" }); +const INBOX = botFixture({ id: "bot-2", name: "Inbox Helper", description: "Email" }); +const SCOUT = botFixture({ id: "bot-3", name: "Scout", description: "Finds places" }); + +function summary(botId: string, patch: Partial = {}): BotLiveSummary { + return { botId, preview: null, updatedAt: null, state: { kind: "idle" }, needsYou: false, lastReplyAt: null, ...patch }; +} + +function sidebarIpc( + bots: BotDefinition[], + summaries: Record = {}, + extra: Record unknown> = {}, +) { + return installBotTestIpc({ + "bots:list": () => bots, + "bots:live:summary": (botId) => summaries[botId as string] ?? summary(botId as string), + "bots:get": (botId) => bots.find((bot) => bot.id === botId) ?? null, + "bots:live:subscribe": (botId) => ({ + botId, + epoch: "e1", + seq: 0, + entries: [], + partial: null, + state: { kind: "idle" }, + question: null, + activity: null, + queued: [], + }), + "bots:live:unsubscribe": () => undefined, + "bots:pendingApprovals": () => [], + "profile:get": () => ({ name: "Sam" }), + ...extra, + }); +} + +async function mountSidebar(initialPath = "/bots") { + const mounted = await mountWithBotRouter(null, { initialPath, sidebar: }); + await screen.findByRole("navigation", { name: "Bots" }); + return mounted; +} + +const rowButton = (name: string) => + within(screen.getByRole("navigation", { name: "Bots" })).getByRole("button", { name: new RegExp(`^${name}`, "u") }); + +/** Row order by each row's face, whose title is the Bot's name. */ +const rowOrder = () => + within(screen.getByRole("list", { name: "Your Bots" })) + .getAllByRole("listitem") + .map((item) => item.querySelector("span[title]")?.getAttribute("title")); + +const noUnread = (name: string) => within(rowButton(name)).queryByRole("img", { name: "Unread" }) === null; + +test("rows show each Bot's last message, newest activity first, in plain words", async () => { + sidebarIpc([PLANNER, INBOX, SCOUT], { + "bot-1": summary("bot-1", { preview: "Lisbon in May", updatedAt: 100 }), + "bot-2": summary("bot-2", { preview: "Inbox is clear", updatedAt: 300 }), + "bot-3": summary("bot-3", { preview: "Found two cafés", updatedAt: 200 }), + }); + await mountSidebar(); + await waitFor(() => assert.deepEqual(rowOrder(), ["Inbox Helper", "Scout", "Planner"])); + assert.ok(within(rowButton("Planner")).getByText("Lisbon in May")); + assert.equal(screen.queryByRole("navigation", { name: "Primary" }), null, "only Bots in this sidebar"); + const text = document.body.textContent ?? ""; + for (const jargon of [/\bPi\b/u, /provider/iu, /workspace/iu, /capabilit/iu, /canonical/iu]) { + assert.doesNotMatch(text, jargon); + } +}); + +test("a working Bot's face works and its line reads Thinking…, and Needs you appears as soon as it asks", async () => { + let asking = false; + sidebarIpc([PLANNER, INBOX], {}, { + "bots:live:summary": (botId) => + botId === "bot-1" + ? summary("bot-1", { state: { kind: "running", submissionId: "s1" }, needsYou: asking }) + : summary("bot-2", { preview: "Inbox is clear" }), + }); + await mountSidebar(); + await waitFor(() => assert.ok(within(rowButton("Planner")).getByText("Thinking…"))); + assert.equal(within(rowButton("Planner")).getByTitle("Planner").hasAttribute("data-working"), true); + assert.equal(within(rowButton("Inbox Helper")).getByTitle("Inbox Helper").hasAttribute("data-working"), false); + asking = true; + emitBotTestNotification("bots:attention", { botId: "bot-1" }); + await waitFor(() => assert.ok(within(rowButton("Planner")).getByText("Needs you"))); + assert.ok(within(rowButton("Planner")).getByText("Waiting for you")); + assert.equal(within(rowButton("Inbox Helper")).queryByText("Needs you"), null); +}); + +test("a reply since the person last looked shows an unread dot, but not on the open Bot or on replies from before the sidebar existed", async () => { + localStorage.setItem( + BOT_SIDEBAR_PREFERENCES_KEY, + serializeBotSidebarPreferences({ since: 1_000, pinnedBotId: null, lastViewed: { "bot-2": 5_000 } }), + ); + sidebarIpc([PLANNER, INBOX, SCOUT], { + "bot-1": summary("bot-1", { lastReplyAt: 9_000, updatedAt: 9_000 }), + "bot-2": summary("bot-2", { lastReplyAt: 6_000, updatedAt: 6_000 }), + "bot-3": summary("bot-3", { lastReplyAt: 500, updatedAt: 500 }), + }); + const { router } = await mountSidebar("/bots/bot-1"); + await waitFor(() => assert.ok(within(rowButton("Inbox Helper")).getByRole("img", { name: "Unread" }))); + assert.ok(noUnread("Planner"), "the open Bot"); + assert.ok(noUnread("Scout"), "a reply from before the sidebar existed"); + assert.equal(rowButton("Planner").getAttribute("aria-current"), "page"); + + fireEvent.click(rowButton("Inbox Helper")); + await waitFor(() => assert.equal(router.state.location.pathname, "/bots/bot-2/chat")); + await screen.findByRole("button", { name: "Inbox Helper profile" }); + await router.navigate({ to: "/bots/$botId", params: { botId: "bot-1" } }); + await waitFor(() => assert.ok(noUnread("Inbox Helper"), "viewing its chat marked it read")); +}); + +test("a paused Bot says so, and one without a model says it needs one", async () => { + sidebarIpc([PLANNER, INBOX], { + "bot-1": summary("bot-1", { preview: "Reading your calendar", state: { kind: "interrupted", submissionId: "s1" } }), + "bot-2": summary("bot-2", { state: { kind: "needs_model" } }), + }); + await mountSidebar(); + await waitFor(() => assert.ok(within(rowButton("Planner")).getByText("Paused — tap to resume"))); + assert.equal(within(rowButton("Planner")).queryByText("Reading your calendar"), null); + assert.ok(within(rowButton("Inbox Helper")).getByText("Needs an AI model")); +}); + +test("tapping a row opens that Bot's chat, and its context menu and ••• offer the Bot menu", async () => { + sidebarIpc([PLANNER, INBOX]); + const { router } = await mountSidebar(); + fireEvent.click(await screen.findByRole("button", { name: /^Inbox Helper/u })); + await waitFor(() => assert.equal(router.state.location.pathname, "/bots/bot-2/chat")); + + fireEvent.contextMenu(rowButton("Planner")); + const items = await screen.findAllByRole("menuitem"); + assert.deepEqual(items.map((item) => item.textContent), ["Profile", "Files", "Memory", "Routines", "Advanced", "Pin to top", "Delete Bot"]); + fireEvent.click(screen.getByRole("menuitem", { name: "Advanced" })); + await waitFor(() => assert.equal(router.state.location.pathname, "/bots/bot-1")); + assert.equal((router.state.location.search as { page?: string }).page, "advanced"); + + fireEvent.keyDown(screen.getByRole("button", { name: "More for Inbox Helper" }), { key: "Enter" }); + fireEvent.click(await screen.findByRole("menuitem", { name: "Delete Bot" })); + const confirm = await screen.findByRole("alertdialog"); + assert.ok(within(confirm).getByRole("heading", { name: "Delete Inbox Helper?" })); +}); + +test("Memory in a row's menu opens that Bot's chat with the panel on Memory", async () => { + const empty = { entries: [], usedChars: 0, limitChars: 1_375, overBudget: false }; + sidebarIpc([PLANNER, INBOX], {}, { + "bots:memory:get": (botId) => ({ botId, revision: "r1", readable: true, user: empty, memory: { ...empty, limitChars: 2_200 }, updatedAt: 1 }), + }); + const { router } = await mountSidebar("/bots/bot-1/chat"); + fireEvent.keyDown(await screen.findByRole("button", { name: "More for Inbox Helper" }), { key: "Enter" }); + fireEvent.click(await screen.findByRole("menuitem", { name: "Memory" })); + await waitFor(() => assert.equal(router.state.location.pathname, "/bots/bot-2/chat")); + const panel = await screen.findByRole("complementary", { name: "Bot panel" }); + assert.equal(within(panel).getByRole("tab", { name: "Memory" }).getAttribute("aria-selected"), "true"); +}); + +test("Pin to top shows the Bot large above the rows, Unpin puts it back, and a deleted pinned Bot leaves no hero", async () => { + let bots = [PLANNER, INBOX]; + sidebarIpc([], {}, { "bots:list": () => bots }); + const { queryClient } = await mountSidebar(); + fireEvent.contextMenu(await screen.findByRole("button", { name: /^Inbox Helper/u })); + fireEvent.click(await screen.findByRole("menuitem", { name: "Pin to top" })); + const hero = await screen.findByRole("region", { name: "Pinned Bot" }); + assert.ok(within(hero).getByRole("button", { name: /^Inbox Helper/u })); + assert.deepEqual(rowOrder(), ["Planner"], "a pinned Bot is not listed twice"); + + fireEvent.keyDown(within(hero).getByRole("button", { name: "More for Inbox Helper" }), { key: "Enter" }); + fireEvent.click(await screen.findByRole("menuitem", { name: "Unpin" })); + await waitFor(() => assert.equal(screen.queryByRole("region", { name: "Pinned Bot" }), null)); + assert.deepEqual(rowOrder(), ["Inbox Helper", "Planner"]); + + fireEvent.contextMenu(rowButton("Inbox Helper")); + fireEvent.click(await screen.findByRole("menuitem", { name: "Pin to top" })); + await screen.findByRole("region", { name: "Pinned Bot" }); + bots = [PLANNER]; + await queryClient.invalidateQueries({ queryKey: ["bots"] }); + await waitFor(() => assert.equal(screen.queryByRole("region", { name: "Pinned Bot" }), null)); + assert.equal(parseBotSidebarPreferences(localStorage.getItem(BOT_SIDEBAR_PREFERENCES_KEY), 0).pinnedBotId, null); +}); + +test("search narrows rows by name, subtitle or last message, finds the pinned Bot, and Clear brings everything back", async () => { + localStorage.setItem( + BOT_SIDEBAR_PREFERENCES_KEY, + serializeBotSidebarPreferences({ since: 1, pinnedBotId: "bot-3", lastViewed: {} }), + ); + sidebarIpc([PLANNER, INBOX, SCOUT], { "bot-1": summary("bot-1", { preview: "Lisbon in May" }) }); + await mountSidebar(); + await screen.findByRole("region", { name: "Pinned Bot" }); + await waitFor(() => assert.ok(within(rowButton("Planner")).getByText("Lisbon in May"))); + fireEvent.click(screen.getByRole("button", { name: "Search Bots" })); + const search = screen.getByRole("searchbox", { name: "Search Bots" }); + await waitFor(() => assert.equal(document.activeElement, search)); + fireEvent.change(search, { target: { value: "email" } }); + assert.deepEqual(rowOrder(), ["Inbox Helper"]); + fireEvent.change(search, { target: { value: "lisbon" } }); + assert.deepEqual(rowOrder(), ["Planner"]); + fireEvent.change(search, { target: { value: "places" } }); + assert.equal(screen.queryByRole("region", { name: "Pinned Bot" }), null); + assert.deepEqual(rowOrder(), ["Scout"]); + fireEvent.change(search, { target: { value: "zebra" } }); + assert.ok(within(screen.getByRole("status")).getByText("No matching Bots")); + fireEvent.click(screen.getByRole("button", { name: "Clear search" })); + assert.deepEqual(rowOrder(), ["Inbox Helper", "Planner"]); + assert.ok(screen.getByRole("region", { name: "Pinned Bot" })); +}); + +test("while the Bots load the sidebar shows placeholders, and a failed load offers Try again", async () => { + let release!: (bots: BotDefinition[]) => void; + sidebarIpc([], {}, { "bots:list": () => new Promise((resolve) => (release = resolve)) }); + await mountSidebar(); + assert.ok(screen.getByRole("status", { name: "Loading Bots" })); + release([PLANNER]); + await screen.findByRole("button", { name: /^Planner/u }); + cleanup(); + + let fail = true; + sidebarIpc([], {}, { + "bots:list": () => { + if (fail) throw new Error("Bots are still starting"); + return [PLANNER]; + }, + }); + await mountSidebar(); + const alert = await screen.findByRole("alert"); + fail = false; + fireEvent.click(within(alert).getByRole("button", { name: "Try again" })); + await screen.findByRole("button", { name: /^Planner/u }); +}); + +test("‹ returns to the last page outside Bots, and the footer opens Profile and Connect apps", async () => { + rememberRoute("/scheduled"); + rememberRoute("/bots/bot-1/chat"); + sidebarIpc([PLANNER]); + const { router } = await mountSidebar(); + fireEvent.click(screen.getByRole("button", { name: "Back to Aiden" })); + await waitFor(() => assert.equal(router.state.location.pathname, "/scheduled")); + await waitFor(() => assert.ok(within(screen.getByRole("button", { name: "Profile" })).getByText("S"))); + fireEvent.click(screen.getByRole("button", { name: "Profile" })); + await waitFor(() => assert.equal(router.state.location.pathname, "/profile")); + fireEvent.click(screen.getByRole("button", { name: "Connect apps" })); + await waitFor(() => assert.equal(router.state.location.pathname, "/settings")); + assert.equal((router.state.location.search as { section?: string }).section, "mcp"); +}); +``` + +Append to `renderer/main/remote-new-chat-view.test.tsx`: + +```tsx +test("search in the Bots sidebar narrows paired Macs' Bots too, and hides a Mac with no match", () => { + const groups = [ + { host: studio, bots: [{ id: "bot-1", name: "Reviewer", purpose: "Reviews pull requests" }, { id: "bot-2", name: "Scribe" }] }, + { host: laptop, bots: [{ id: "bot-3", name: "Planner" }] }, + ]; + const searched = dom(renderToStaticMarkup()); + assert.ok(searched.button(/^Reviewer, on Studio$/u)); + assert.equal(searched.button(/^Scribe, on Studio$/u), undefined); + assert.doesNotMatch(searched.text, /On Laptop/u); + assert.equal(renderToStaticMarkup(), ""); +}); +``` + +Register: append ` renderer/main/bots/bot-sidebar.test.tsx` to `package.json` `test:bots`; add `"renderer/main/bots/bot-sidebar.test.tsx",` after `"renderer/main/bots/bot-activity-log.test.ts",` in `renderer-other`. + +- [ ] **Step 2: Run them to verify they fail** + +Run: `npx tsx --test renderer/main/bots/bot-sidebar.test.tsx renderer/main/remote-new-chat-view.test.tsx` +Expected: FAIL, cannot find module `./bot-sidebar`; `RemoteBotsSection` ignores `query`. + +- [ ] **Step 3: Write the rows model** + +```ts +// renderer/main/bots/bot-sidebar-rows.ts +// The Bots sidebar's rows (spec §9): one per Bot, newest activity first, read +// from the same live summaries main projects for the chat. A question or an +// approval appearing or settling (`bots:attention`) re-reads that Bot's row; +// state changes already re-read every row through `bots:changed`. + +import * as React from "react"; +import { useQueries, useQueryClient } from "@tanstack/react-query"; +import { botsApi, onNotification } from "../../lib/ipc"; +import { useBots } from "../../lib/queries"; +import type { BotLiveSummary } from "../../shared/bot-live"; +import type { BotDefinition } from "../../shared/bots"; +import { botRowStatusLabel, type BotSessionState } from "./bot-session-state"; + +export interface BotSidebarRow { + bot: BotDefinition; + state?: BotSessionState; + /** Last message preview, one line of plain text. */ + preview?: string; + /** When the Bot's chat last changed, or the Bot itself when it has no messages. */ + activityAt: number; + needsYou: boolean; + lastReplyAt: number | null; +} + +export const BOT_WORKING_LINE = "Thinking…"; +export const BOT_WAITING_LINE = "Waiting for you"; + +export const botLiveSummaryKey = (botId: string) => ["bot-live-summary", botId] as const; + +export function botSidebarRows( + bots: readonly BotDefinition[], + summaries: ReadonlyArray, +): BotSidebarRow[] { + return bots + .map((bot, index): BotSidebarRow => { + const summary = summaries[index]; + return { + bot, + ...(summary ? { state: summary.state } : {}), + ...(summary?.preview ? { preview: summary.preview } : {}), + activityAt: summary?.updatedAt ?? bot.updatedAt, + needsYou: summary?.needsYou ?? false, + lastReplyAt: summary?.lastReplyAt ?? null, + }; + }) + .sort((left, right) => right.activityAt - left.activityAt || left.bot.name.localeCompare(right.bot.name)); +} + +export function matchesBotSearch(row: BotSidebarRow, query: string): boolean { + const needle = query.trim().toLocaleLowerCase(); + if (!needle) return true; + return [row.bot.name, row.bot.description ?? "", row.preview ?? ""].some((value) => + value.toLocaleLowerCase().includes(needle), + ); +} + +export type BotRowLine = { kind: "working" | "waiting" | "status" | "preview"; text: string }; + +export function botRowLine(row: BotSidebarRow): BotRowLine | null { + if (row.needsYou) return { kind: "waiting", text: BOT_WAITING_LINE }; + if (row.state?.kind === "running") return { kind: "working", text: BOT_WORKING_LINE }; + const status = botRowStatusLabel(row.state); + if (status) return { kind: "status", text: status }; + return row.preview ? { kind: "preview", text: row.preview } : null; +} + +export function useBotRows() { + const queryClient = useQueryClient(); + const bots = useBots(); + const list = bots.data ?? []; + const summaries = useQueries({ + queries: list.map((bot) => ({ + queryKey: botLiveSummaryKey(bot.id), + queryFn: () => botsApi.liveSummary(bot.id), + retry: false, + })), + }); + React.useEffect( + () => + onNotification<{ botId?: string }>("bots:attention", ({ botId }) => { + void queryClient.invalidateQueries({ queryKey: botId ? botLiveSummaryKey(botId) : ["bot-live-summary"] }); + }), + [queryClient], + ); + const rows = botSidebarRows( + list, + summaries.map((result) => result.data), + ); + return { bots, rows, summariesSettled: summaries.every((result) => !result.isPending) }; +} +``` + +- [ ] **Step 4: Write the sidebar** + +```tsx +// renderer/main/bots/bot-sidebar.tsx +// The Bots-only sidebar (spec §9): ‹ back to Aiden, Search and New Bot on top; +// the pinned Bot, large, when one is pinned; one row per Bot, newest activity +// first, whose face glances while it works, with "Needs you" and an unread dot; +// Bots on paired Macs; the profile and Connect apps in the footer. + +import * as React from "react"; +import { useNavigate, useRouter, useRouterState } from "@tanstack/react-router"; +import { ChevronLeft, Ellipsis, Plug, Plus, RotateCcw, Search, UserRound, X } from "lucide-react"; +import { BotAvatar } from "../../components/bot-avatar"; +import { HistoryNavButtons } from "../../components/history-nav"; +import { + Badge, + Button, + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, + EmptyState, + Input, + Sidebar, + SidebarFooter, + SidebarListItem, + SplitView, +} from "../../components/ui"; +import { botChatPath, botRouteBotId, lastNonBotRoute } from "../../lib/bot-routes"; +import { isBotUnread } from "../../lib/bot-sidebar-preferences"; +import { useProfile } from "../../lib/queries"; +import { cn } from "../../lib/ui-utils"; +import type { BotDefinition } from "../../shared/bots"; +import { RemoteBots } from "../remote-bots"; +import { BotCreateFlow } from "./bot-create-flow"; +import { BotDeleteDialog } from "./bot-delete-dialog"; +import { BotMenuItems, useBotMenuActions, type BotMenuAction } from "./bot-menu"; +import { botRowLine, matchesBotSearch, useBotRows, type BotSidebarRow } from "./bot-sidebar-rows"; +import { useBotSidebarPreferences } from "./use-bot-sidebar-preferences"; + +const ROW_FOCUS = + "outline-none focus-visible:bg-list-selection focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-focus-ring"; + +interface RowProps { + row: BotSidebarRow; + selected: boolean; + unread: boolean; + pinned: boolean; + onOpen(bot: BotDefinition): void; + onAction(bot: BotDefinition, action: BotMenuAction): void; +} + +function RowMarkers({ row, unread }: { row: BotSidebarRow; unread: boolean }) { + return ( + <> + {row.needsYou ? ( + + Needs you + + ) : null} + {unread ? ( + + + ) : null} + + ); +} + +/** The row's right-click menu and its hover ••• button, with the same items. */ +function RowMenus({ + row, + pinned, + onAction, + moreClassName, + children, +}: Pick & { moreClassName: string; children: React.ReactElement }) { + const act = (action: BotMenuAction) => onAction(row.bot, action); + return ( + <> + + {children} + + + + + + + + + + + + + + ); +} + +function BotRow({ row, selected, unread, pinned, onOpen, onAction }: RowProps) { + const line = botRowLine(row); + return ( +
  • + + + +
  • + ); +} + +function PinnedBot({ row, selected, unread, onOpen, onAction }: Omit) { + return ( +
    + + + +
    + ); +} + +function RowsSkeleton() { + return ( +
    + {[0, 1, 2].map((row) => ( +
    + + + + + +
    + ))} + Loading Bots… +
    + ); +} + +function ProfileFace({ name }: { name: string }) { + const initial = [...name.trim()][0]?.toLocaleUpperCase(); + return ( + + ); +} + +export function BotSidebar() { + const navigate = useNavigate(); + const router = useRouter(); + const pathname = useRouterState({ select: (state) => state.location.pathname }); + const currentBotId = botRouteBotId(pathname); + const profile = useProfile(); + const { bots, rows } = useBotRows(); + const { preferences, prune } = useBotSidebarPreferences(); + const [query, setQuery] = React.useState(""); + const [searchOpen, setSearchOpen] = React.useState(false); + const searchRef = React.useRef(null); + const [creating, setCreating] = React.useState(false); + const [deleting, setDeleting] = React.useState(null); + const act = useBotMenuActions(setDeleting); + + React.useEffect(() => { + // Only a loaded, non-empty list prunes, so a transient empty read can't drop the pin. + if (bots.data && bots.data.length > 0) prune(new Set(bots.data.map((bot) => bot.id))); + }, [bots.data, prune]); + + React.useEffect(() => { + if (searchOpen) searchRef.current?.focus(); + }, [searchOpen]); + + const searching = query.trim().length > 0; + const pinnedBotId = preferences.pinnedBotId; + const pinnedRow = searching ? undefined : rows.find((row) => row.bot.id === pinnedBotId); + const visible = rows.filter((row) => row !== pinnedRow && matchesBotSearch(row, query)); + const openChat = (bot: BotDefinition) => void navigate(botChatPath(bot.id)); + const closeSearch = () => { + setQuery(""); + setSearchOpen(false); + }; + const rowProps = (row: BotSidebarRow) => ({ + row, + selected: row.bot.id === currentBotId, + unread: row.bot.id !== currentBotId && isBotUnread(preferences, row.bot.id, row.lastReplyAt), + onOpen: openChat, + onAction: act, + }); + + let list: React.ReactNode; + if (bots.isLoading) { + list = ; + } else if (bots.isError) { + list = ( +
    + + } title="Try again" onClick={() => void bots.refetch()} /> +
    + ); + } else if (rows.length === 0) { + list = ( + + ); + } else if (searching && visible.length === 0) { + list = ( + + ); + } else { + list = ( + <> + {pinnedRow ? : null} + {visible.length > 0 ? ( +
      + {visible.map((row) => ( + + ))} +
    + ) : null} + + ); + } + + return ( + <> + + + + + + + + +
    + } + footer={ + +
    + } + title={profile.data?.name?.trim() || "Profile"} + aria-label="Profile" + selected={pathname === "/profile"} + className="min-w-0 flex-1" + onClick={() => void navigate({ to: "/profile" })} + /> + +
    +
    + } + > + + + openChat(bot)} /> + {deleting ? ( + { + if (!open) setDeleting(null); + }} + /> + ) : null} + + ); +} +``` + +- [ ] **Step 5: Show it on Bots pages, and move paired Macs' Bots into it** + +`renderer/components/chat-sidebar.tsx`: + +1. Add imports: `import { isBotsPath } from "../lib/bot-routes";` and `import { BotSidebar } from "../main/bots/bot-sidebar";`. +2. Rename `export function ChatSidebar({ activeChatId, activeRemoteChat = null, titleReveal }: ChatSidebarProps) {` to `function WorkspaceChatSidebar({ activeChatId, activeRemoteChat = null, titleReveal }: ChatSidebarProps) {`. +3. Add above it: + +```tsx +/** On any Bots page the sidebar lists only Bots (spec §9); everywhere else it is the workspace outline. */ +export function ChatSidebar(props: ChatSidebarProps) { + const pathname = useRouterState({ select: (state) => state.location.pathname }); + const capabilities = useAppCapabilities(); + return capabilities.bots && isBotsPath(pathname) ? : ; +} +``` + +`renderer/components/remote-bots-section.tsx`: replace the component (keep the imports) with compact sidebar rows and a `query` filter. Headings, button names, disabled reasons and `aria-busy` are unchanged: + +```tsx +/** + * Bots that live on paired Macs, grouped by Mac, as rows in the Bots sidebar. + * Opening one opens its chat on that Mac; nothing about a remote Bot is stored + * or run on this one. `query` narrows them with the sidebar's search. + */ +export function RemoteBotsSection({ + groups, + opening, + onOpen, + query = "", +}: { + groups: readonly RemoteBotGroup[]; + /** `hostId/botId` of the Bot whose chat is being opened. */ + opening: string | null; + onOpen(hostId: string, botId: string): void; + query?: string; +}) { + const needle = query.trim().toLocaleLowerCase(); + const shown = groups + .map(({ host, bots }) => ({ + host, + bots: needle + ? bots.filter((bot) => [bot.name, bot.purpose ?? ""].some((value) => value.toLocaleLowerCase().includes(needle))) + : bots, + })) + .filter((group) => group.bots.length > 0); + if (shown.length === 0) return null; + return ( +
    + {shown.map(({ host, bots }) => ( +
    +
    + + + {`On ${host.label}`} + +
    + {host.disabledReason ? ( + + {host.disabledReason} + + ) : null} +
      + {bots.map((bot) => { + const busy = opening === `${host.id}/${bot.id}`; + return ( +
    • + +
    • + ); + })} +
    +
    + ))} +
    + ); +} +``` + +`renderer/main/remote-bots.tsx`: change the signature to `export function RemoteBots({ query = "" }: { query?: string } = {}) {` and the return to `return void open(hostId, botId)} />;`. + +- [ ] **Step 6: The chat marks its Bot viewed and loses the "All Bots" back** + +`renderer/main/bots/bot-chat-pane.tsx`: + +1. Change `const { preferences } = useBotSidebarPreferences();` to `const { preferences, markViewed } = useBotSidebarPreferences();`. +2. Below `const snapshot = live.snapshot;` (not above it: the effect reads `snapshot`) add: + +```tsx + const entryCount = snapshot?.entries.length ?? 0; + // Seeing the chat (and every message that arrives while it is open) clears its unread dot. + React.useEffect(() => { + markViewed(botId); + }, [botId, entryCount, markViewed]); +``` + +3. Delete `leading={}` from `shell`, and remove `BotChatBackButton` from the `./bot-chat-header` import. Keep `back` (the "Bot not found" state's "All Bots" button uses it). + +`renderer/main/bots/bot-chat-header.tsx`: delete `BotChatBackButton` and its doc comment, and drop `ChevronLeft` from the lucide import. + +`renderer/main/bots/bot-chat-header.test.tsx`: remove `BotChatBackButton` from the import and from `renderHeader`, and replace the first test with: + +```tsx +test("the name pill opens the Bot's profile", () => { + const calls = renderHeader(); + fireEvent.click(screen.getByRole("button", { name: "SLMob profile" })); + assert.deepEqual(calls, ["profile"]); +}); +``` + +- [ ] **Step 7: Run the tests** + +Run: +```bash +npx tsx --test renderer/main/bots/bot-sidebar.test.tsx renderer/main/remote-new-chat-view.test.tsx renderer/main/bots/bot-chat-header.test.tsx renderer/main/bots/bot-chat-pane.test.tsx renderer/main/bots/bot-panel.test.tsx +npx tsc --noEmit && npm run lint && npm run test:ci-policy +``` +Expected: 10 sidebar tests and the remote filter test PASS, the rest still PASS, no type or lint errors, CI policy PASS. + +- [ ] **Step 8: Commit** + +```bash +git add renderer/main/bots/bot-sidebar-rows.ts renderer/main/bots/bot-sidebar.tsx renderer/main/bots/bot-sidebar.test.tsx renderer/components/chat-sidebar.tsx renderer/components/remote-bots-section.tsx renderer/main/remote-bots.tsx renderer/main/remote-new-chat-view.test.tsx renderer/main/bots/bot-chat-pane.tsx renderer/main/bots/bot-chat-header.tsx renderer/main/bots/bot-chat-header.test.tsx package.json scripts/ci-test-registry.json +git commit -m "Bots: a Bots-only sidebar with pinned Bot, working faces, Needs you and unread" +``` + +--- +### Task 11: `/bots` opens the most recently used Bot; the list page goes + +**Files:** +- Modify: `renderer/main/bots-view.tsx` (`BotsHome` → `BotsIndex`; imports; Profile's back) +- Modify: `renderer/main/bots/bot-profile.tsx:211` (back label) +- Delete: `renderer/main/bots/bot-list.tsx`, `renderer/main/bots/bot-list.test.tsx` +- Test: `renderer/main/bots/bots-home.test.tsx` +- Modify: `package.json` (`test:bots`), `scripts/ci-test-registry.json` (`renderer-other`) + +**Interfaces:** +- Consumes: `useBotRows` (Task 10), `useBotSidebarPreferences` (Task 2), `lastViewedBotId` (Task 2), `botChatPath` (Task 1). +- Produces: `/bots` with Bots redirects (replace) to the last viewed Bot's chat, else the newest-activity Bot's chat. With no Bots it shows "Meet your first Bot" with New Bot in the toolbar. `BotProfile`'s back reads "Chat" and opens that Bot's chat. + +Where the list's behaviours went: row opens chat, context menu, •••, search, loading, error, plain language, paused and needs-model labels, and the working face are in `bot-sidebar.test.tsx` (Task 10). Row times are gone: sidebar rows show no time (ruling 10), so `formatBotRowTime` and its test are deleted, not moved. + +- [ ] **Step 1: Write the failing tests** + +In `renderer/main/bots/bots-home.test.tsx`: + +1. Change the `node:test` import to `import { afterEach, beforeEach, test } from "node:test";`, add `beforeEach(() => localStorage.clear());`, and add `import { BOT_SIDEBAR_PREFERENCES_KEY, serializeBotSidebarPreferences } from "../../lib/bot-sidebar-preferences";`. +2. Change `const idle = …` to `const idle = { botId: "bot-1", preview: null, updatedAt: null, state: { kind: "idle" }, needsYou: false, lastReplyAt: null };`. +3. Delete the four row tests ("tapping a Bot row opens its one chat directly", "the row's ••• menu opens the Bot's profile", "a Bot that needs a model says so in its row", "an interrupted Bot's row reads Paused"). They live in `bot-sidebar.test.tsx` now. +4. Add: + +```tsx +const PLANNER = botFixture(); +const INBOX = botFixture({ id: "bot-2", name: "Inbox" }); + +function twoBots(updatedAt: Record) { + installBotTestIpc({ + "bots:list": () => [PLANNER, INBOX], + "bots:live:summary": (botId) => ({ ...idle, botId, updatedAt: updatedAt[botId as string] ?? null }), + "bots:get": (botId) => [PLANNER, INBOX].find((bot) => bot.id === botId) ?? null, + "bots:live:subscribe": (botId) => ({ ...idle, botId, epoch: "e1", seq: 0, entries: [], partial: null, question: null, activity: null, queued: [] }), + "bots:pendingApprovals": () => [], + }); +} + +test("/bots opens the chat of the Bot the person last looked at", async () => { + localStorage.setItem( + BOT_SIDEBAR_PREFERENCES_KEY, + serializeBotSidebarPreferences({ since: 1, pinnedBotId: null, lastViewed: { "bot-2": 50 } }), + ); + twoBots({ "bot-1": 900, "bot-2": 100 }); + const { router } = await mountWithBotRouter(, { initialPath: "/bots" }); + await waitFor(() => assert.equal(router.state.location.pathname, "/bots/bot-2/chat")); + assert.equal(screen.queryByRole("heading", { name: "Meet your first Bot" }), null); +}); + +test("with nothing viewed yet, or the last one deleted, /bots opens the Bot with the newest activity", async () => { + twoBots({ "bot-1": 100, "bot-2": 900 }); + const first = await mountWithBotRouter(, { initialPath: "/bots" }); + await waitFor(() => assert.equal(first.router.state.location.pathname, "/bots/bot-2/chat")); + cleanup(); + localStorage.setItem( + BOT_SIDEBAR_PREFERENCES_KEY, + serializeBotSidebarPreferences({ since: 1, pinnedBotId: null, lastViewed: { "bot-gone": 999 } }), + ); + twoBots({ "bot-1": 900, "bot-2": 100 }); + const second = await mountWithBotRouter(, { initialPath: "/bots" }); + await waitFor(() => assert.equal(second.router.state.location.pathname, "/bots/bot-1/chat")); +}); +``` + +5. Add a test for Profile's back: + +```tsx +test("Profile's back returns to the Bot's chat", async () => { + twoBots({}); + const { router } = await mountWithBotRouter(, { initialPath: "/bots/bot-2" }); + fireEvent.click(await screen.findByRole("button", { name: "Chat" })); + await waitFor(() => assert.equal(router.state.location.pathname, "/bots/bot-2/chat")); +}); +``` + +The starter-carousel, "Create my own" and connection-setup tests stay as they are. + +- [ ] **Step 2: Run them to verify they fail** + +Run: `npx tsx --test renderer/main/bots/bots-home.test.tsx` +Expected: FAIL. `/bots` still renders the list page, and Profile's back reads "All Bots". + +- [ ] **Step 3: Implement** + +In `renderer/main/bots-view.tsx`: + +1. Replace the imports of `useQueries`, `botsApi`, `useBots`, `BotList`/`BotListRow` and `RemoteBots` with: + +```tsx +import { RotateCcw } from "lucide-react"; +import { lastViewedBotId } from "../lib/bot-sidebar-preferences"; +import { useBotRows } from "./bots/bot-sidebar-rows"; +import { useBotSidebarPreferences } from "./bots/use-bot-sidebar-preferences"; +``` + +(keep `Plus`; add `Callout` and `Text` to the `../components/ui` import.) + +2. Replace the whole `BotsHome` function with: + +```tsx +/** + * `/bots` with no Bot selected (spec §9): the Bot list lives in the sidebar, so + * this opens the chat of the Bot last viewed on this Mac, else the one with the + * newest activity. With no Bots it is "Meet your first Bot". + */ +function BotsIndex() { + const navigate = useNavigate(); + const { bots, rows, summariesSettled } = useBotRows(); + const { preferences } = useBotSidebarPreferences(); + const [creating, setCreating] = React.useState(false); + const openChat = (bot: BotDefinition) => void navigate(botChatPath(bot.id)); + const hasBots = (bots.data?.length ?? 0) > 0; + const target = React.useMemo(() => { + if (!bots.data || bots.data.length === 0) return null; + const viewed = lastViewedBotId(preferences, new Set(bots.data.map((bot) => bot.id))); + if (viewed) return viewed; + return summariesSettled ? (rows[0]?.bot.id ?? null) : null; + }, [bots.data, preferences, rows, summariesSettled]); + + React.useEffect(() => { + // While the create flow is open it finishes first and opens the new Bot itself. + if (target && !creating) void navigate({ ...botChatPath(target), replace: true }); + }, [creating, navigate, target]); + + if (hasBots && !creating) return
    ; + return ( + setCreating(true)}> + New Bot + + } + > +
    + {bots.isLoading ? ( + + ) : bots.isError ? ( + +
    + + Your Bots couldn’t be loaded + + + Check that Aiden finished starting, then try again. + +
    + +
    + ) : ( + setCreating(true)} onOpenChat={openChat} /> + )} +
    + openChat(bot)} /> +
    + ); +} +``` + +3. In `BotsView`, change `return ;` to `return ;`, and update its doc comment to "Routes `/bots` to the most recent Bot's chat (or the starter Bots), `/bots/$botId` to a Bot's Profile and `/bots/$botId/chat` to its chat." +4. In `BotPage`, pass `onBack={() => void navigate(botChatPath(current.id))}` to `BotProfile` instead of `onBack={allBots}`. Keep `allBots` for the loading and "Bot not found" shells. + +In `renderer/main/bots/bot-profile.tsx`, change `backLabel="All Bots"` to `backLabel="Chat"`. + +Delete the list: + +```bash +git rm renderer/main/bots/bot-list.tsx renderer/main/bots/bot-list.test.tsx +``` + +Remove ` renderer/main/bots/bot-list.test.tsx` from `package.json` `test:bots` and `"renderer/main/bots/bot-list.test.tsx",` from the `renderer-other` lane. + +- [ ] **Step 4: Run the tests** + +Run: +```bash +npx tsx --test renderer/main/bots/bots-home.test.tsx renderer/main/bots/bot-create-flow.test.tsx renderer/main/bots/bot-profile.test.tsx renderer/main/bots/bot-profile-photo.test.tsx renderer/main/bots/bot-memory-page.test.tsx +git grep -n "bot-list\|BotList\b\|formatBotRowTime" -- renderer main tests +npm run test:bots +npx tsc --noEmit && npm run lint && npm run test:ci-policy +``` +Expected: all PASS. The grep prints nothing. `bot-create-flow.test.tsx` still finds "New Bot" in the toolbar of the empty `/bots` page. + +- [ ] **Step 5: Commit** + +```bash +git add renderer/main/bots-view.tsx renderer/main/bots/bot-profile.tsx renderer/main/bots/bots-home.test.tsx package.json scripts/ci-test-registry.json +git commit -m "Bots: /bots opens the most recently used Bot; the list page is removed" +``` + +--- + +### Task 12: Electron check, onboarding art, docs and full verification + +**Files:** +- Create: `tests/e2e/bots-sidebar-panel.spec.ts` +- Modify: `renderer/components/onboarding-art/control-art.tsx` (`BotsArt` header, about lines 178-192; lucide import) +- Modify: `docs/superpowers/specs/2026-10-14-bot-chat-activity-design.md` (§8, §17), `docs/plans/README.md`, `.memory/bot-chat-activity.md`, `.memory/bots-rework-ui.md` + +**Interfaces:** +- Consumes: everything above. +- Produces: the record slice D starts from. + +- [ ] **Step 1: Write the e2e test** + +```ts +// tests/e2e/bots-sidebar-panel.spec.ts +import { botFixture } from "../../renderer/main/bots/test-fixtures"; +import { expect, finishLmStudioOnboarding, test } from "./fixtures"; + +// The e2e profile cannot open the real Bot store (no Keychain authority), so +// only the Bot IPC is substituted, as in bots-starter.spec.ts. The sidebar, +// routing, panel and workspace panel are the shipped renderer. +test("the Bots page lists only Bots, opens the latest one, and its panel shows a file it made without touching the workspace panel", async ({ + aiden, +}) => { + const { page } = aiden; + const planner = botFixture({ id: "bot-planner", name: "Planner", description: "Plans meals" }); + const inbox = botFixture({ id: "bot-inbox", name: "Inbox Helper", description: "Email" }); + await finishLmStudioOnboarding(page); + await page.locator("[data-environment-toggle]").click(); + await expect(page.locator("#environment-panel")).toHaveAttribute("data-state", "open"); + const workspaceTabs = await page.evaluate(() => localStorage.getItem("aiden-agent.environment.open-tabs-v1")); + + await aiden.app.evaluate(({ ipcMain }, bots) => { + const byId = new Map(bots.map((bot) => [bot.id, bot])); + const [plannerBot] = bots; + for (const channel of [ + "bots:list", + "bots:get", + "bots:live:subscribe", + "bots:live:summary", + "bots:getCanonicalPhoto", + "bots:pendingApprovals", + "bots:files:list", + "bots:files:read", + ]) ipcMain.removeHandler(channel); + ipcMain.handle("bots:list", () => bots); + ipcMain.handle("bots:get", (_event, botId: string) => byId.get(botId) ?? null); + ipcMain.handle("bots:live:summary", (_event, botId: string) => ({ + botId, + preview: botId === plannerBot!.id ? "Here’s your week." : "Inbox is clear.", + updatedAt: botId === plannerBot!.id ? 2_000 : 1_000, + state: { kind: "idle" }, + needsYou: false, + lastReplyAt: null, + })); + ipcMain.handle("bots:live:subscribe", (_event, botId: string) => ({ + botId, + epoch: "e1", + seq: 0, + partial: null, + state: { kind: "idle" }, + question: null, + activity: null, + queued: [], + entries: + botId === plannerBot!.id + ? [ + { id: "u1", type: "user", text: "Plan dinners", imageCount: 0 }, + { id: "f1", type: "file", path: "lists/groceries.md", operation: "written" }, + { id: "a1", type: "assistant", text: "Here’s your week.", toolCalls: [], stopReason: "stop" }, + ] + : [], + })); + ipcMain.handle("bots:getCanonicalPhoto", () => null); + ipcMain.handle("bots:pendingApprovals", () => []); + ipcMain.handle("bots:files:list", () => ({ + entries: [ + { path: "lists", name: "lists", parentPath: "", depth: 0, kind: "directory" }, + { path: "lists/groceries.md", name: "groceries.md", parentPath: "lists", depth: 1, kind: "file" }, + ], + truncated: false, + skippedDirectories: 0, + })); + ipcMain.handle("bots:files:read", (_event, _botId: string, path: string) => ({ + path, + content: "eggs\nmilk", + size: 9, + modifiedAt: 1, + version: "v1", + })); + }, [planner, inbox]); + + await page.getByRole("button", { name: "Bots", exact: true }).click(); + const botsNav = page.getByRole("navigation", { name: "Bots" }); + await expect(botsNav.getByRole("button", { name: /^Planner/u })).toBeVisible(); + await expect(botsNav.getByRole("button", { name: /^Inbox Helper/u })).toBeVisible(); + await expect(page.getByRole("navigation", { name: "Primary" })).toHaveCount(0); + await expect(page.getByPlaceholder("Ask Planner")).toBeVisible(); + await expect(page.locator("#environment-panel")).toHaveAttribute("data-state", "closed"); + + await page.getByRole("button", { name: "Open groceries.md" }).click(); + const panel = page.getByRole("complementary", { name: "Bot panel" }); + await expect(panel.getByRole("tab", { name: "Files" })).toHaveAttribute("aria-selected", "true"); + await expect(panel.getByLabel("Contents of lists/groceries.md")).toHaveText("eggs\nmilk"); + + const sendToggle = () => + aiden.app.evaluate(({ BrowserWindow }) => { + for (const window of BrowserWindow.getAllWindows()) { + window.webContents.send("app:command", { commandId: "environment.toggle" }); + } + }); + await sendToggle(); + await expect(page.locator("#bot-panel")).toHaveAttribute("data-state", "closed"); + await sendToggle(); + await expect(page.locator("#bot-panel")).toHaveAttribute("data-state", "open"); + expect(await page.evaluate(() => localStorage.getItem("aiden-agent.environment.open-tabs-v1"))).toBe(workspaceTabs); + + await page.getByRole("button", { name: "Back to Aiden" }).click(); + await expect(page.getByRole("navigation", { name: "Primary" })).toBeVisible(); + await expect(page.locator("#environment-panel")).toHaveAttribute("data-state", "open"); + expect(await page.evaluate(() => localStorage.getItem("aiden-agent.environment.open-tabs-v1"))).toBe(workspaceTabs); +}); +``` + +The spec is picked up by the e2e shard planner automatically (`scripts/ci-e2e-shards.mjs` discovers new specs). + +- [ ] **Step 2: Run the Electron specs** + +Run: +```bash +npm run type-check:e2e && npm run build +npx playwright test tests/e2e/bots-sidebar-panel.spec.ts tests/e2e/bots-starter.spec.ts tests/e2e/workspace-panel.spec.ts tests/e2e/environment-focus.spec.ts --config=playwright.config.ts --fail-on-flaky-tests +``` +Expected: all PASS. If a spec fails and then passes on rerun, it is still a bug: record it in the PR description (AGENTS.md). + +- [ ] **Step 3: Review the onboarding Bots tile** + +`BotsArt` (`renderer/components/onboarding-art/control-art.tsx`) draws a Bot chat header with a back chevron, which the chat no longer has; the panel toggle sits beside •••. The tile's copy ("Each Bot keeps one chat, remembers what matters to you, and can check in on a schedule.") stays accurate, so only the header changes. Replace the header block with: + +```tsx +
    + + + + + Meal Planner + + + + +
    +``` + +In the lucide import at the top of the file, remove `ChevronLeft` (no other art uses it) and add `PanelRightOpen`. + +Run: `npm run test:onboarding` +Expected: PASS (every tile keeps its art; no raster images). + +- [ ] **Step 4: Record the rulings in the spec** + +In §8, under **Opening it.**, add a sub-bullet: + +> - Implemented in C2 on the chat route `/bots/$botId/chat` (`renderer/main/bots/bot-workbench.tsx`), with its own state and keys (`aiden-agent.bot-panel.*`, `BotPanelProvider`). `environment.toggle` toggles it in a Bot chat and does nothing on other Bots pages. The workspace Environment, Quick View and device mini player are suppressed on every Bots path and come back unchanged. + +In §17, add: + +> - **Slice C2 rulings (2026-10-14):** +> - The Bot panel lives on the chat route only; Profile and its sub-pages stay full pages. +> - Bots on paired Macs are listed in the Bots sidebar under "On {Mac}", and search filters them. +> - "Most recently used" is the Bot last viewed on this Mac, else the newest activity. +> - Unread compares the last reply with last-viewed, or with when the preference record began for a Bot never viewed, so updating lights up nothing. +> - The pinned Bot shows only as the hero; deleting it clears the pin. +> - The footer shows the profile name's initial (Aiden has no profile photo) and Connect apps opens Settings → Plugins. +> - The chat header has no "All Bots" back; Profile's back reads "Chat". +> - "Memory updated" opens the panel's Memory tab. +> - A waiting Bot's row reads "Waiting for you"; a working one "Thinking…". +> - `bots:live:summary` gained `needsYou` and `lastReplyAt`; `bots:attention` refreshes a row when a question or approval changes. Desktop IPC only. + +- [ ] **Step 5: Update the plan index** + +In `docs/plans/README.md`, in the "Bot chat activity…" row, change `C2 Bots-only sidebar + Bot side panel (stacked on C1)` to `C2 Bots-only sidebar + Bot side panel (stacked on C1, [plan](../superpowers/plans/2026-10-14-bot-chat-activity-c2-sidebar-panel.md))`. + +- [ ] **Step 6: Project memory** + +Append to `.memory/bot-chat-activity.md`: + +```markdown +## Slice C2 (desktop) — what shipped +- Bots sidebar: `renderer/main/bots/bot-sidebar.tsx`, rendered by `ChatSidebar` on any Bots path (`isBotsPath`, `renderer/lib/bot-routes.ts`). Rows from `useBotRows` (`bot-sidebar-rows.ts`): `bots:live:summary` now carries `needsYou` and `lastReplyAt`; `bots:attention` (questions/approvals) re-reads a row. ‹ goes to `lastNonBotRoute()` (recorded by `ChatLayout`). +- Pin and last-viewed: `aiden-agent.bots-sidebar.v1` (`renderer/lib/bot-sidebar-preferences.ts`, hook `use-bot-sidebar-preferences.ts`), with `since` so old replies are never unread. Not synced to phones. +- `/bots` (`BotsIndex` in `bots-view.tsx`) redirects to the last viewed Bot's chat, else the newest; the starter carousel when there are none. `bot-list.tsx` is gone. +- Bot panel: `BotPanelProvider` (`bot-panel-context.tsx`, keys `aiden-agent.bot-panel.*`) at the root; `BotWorkbench` (`bot-workbench.tsx`) on the C1 `WorkbenchPanel`, `id="bot-panel"`, fixed tabs Activity (`bot-activity-log.ts`), Files (`bot-panel-files.tsx`, replaced `bot-files-dialog.tsx`), Memory (`BotMemoryContent` shared with Profile → Memory), Routines (`BotRoutines`). Chat route only. +- `environment.toggle` → `environmentToggleTarget(pathname)`: Bot panel in a Bot chat, nothing elsewhere in Bots, workspace otherwise. `EnvironmentWorkbench` is suppressed on Bots paths. +- One live subscription per Bot: `BotLiveProvider`/`useSharedBotLive` (`renderer/lib/use-bot-live.ts`). +- One ••• menu: `bot-menu.tsx` (`BotMenuItems`, `useBotMenuActions`). +- Bots on paired Macs: `RemoteBotsSection` rows in the sidebar, filtered by search. +``` + +In `.memory/bots-rework-ui.md`: +- Replace the List bullet with "List: the Bots sidebar (`bot-sidebar.tsx`); `/bots` opens the most recent Bot's chat." +- Replace the Files mention with "Files: the side panel's Files tab (`bot-panel-files.tsx`)." +- In Key files add `bot-sidebar.tsx`, `bot-sidebar-rows.ts`, `bot-menu.tsx`, `bot-workbench.tsx`, `bot-panel-context.tsx`, `bot-panel-activity.tsx`, `bot-panel-files.tsx`, `bot-activity-log.ts`, `renderer/lib/bot-routes.ts`, `renderer/lib/bot-sidebar-preferences.ts`. +- In Deleted add `bot-list.tsx`, `bot-files-dialog.tsx` (2026-10-14). + +- [ ] **Step 7: Run every affected suite** + +```bash +npx tsc --noEmit +npm run lint +npm run test:ci-policy +npm run test:bots +npm run test:bot-runtime +npm run test:aiden-remote +npm run test:onboarding +npx tsx --test main/handlers/ipc-contract.test.ts renderer/components/workbench-panel.test.tsx renderer/lib/command-system-core.test.ts +npm test +``` +Expected: all PASS, apart from the pre-existing `inline-metadata-hierarchy` failure noted in Global Constraints (confirm it fails the same way on the base commit). iOS and Android are unaffected: the Remote wire is unchanged, which `test:aiden-remote` confirms, so the mobile suites need no run for this slice. Confirm Step 2's Playwright run is green on the final head. + +In the running app (`npm run dev`), check by eye: the Bots sidebar in light and dark and two theme presets; a working Bot's face glances and its "Thinking…" shimmers, and both hold still with Reduce Motion on; every row, the hero, the ••• buttons, the panel tabs and the separator show a focus ring with the keyboard; the search field shows no accent border when focused. + +- [ ] **Step 8: Commit** + +```bash +git add tests/e2e/bots-sidebar-panel.spec.ts renderer/components/onboarding-art/control-art.tsx docs/superpowers/specs/2026-10-14-bot-chat-activity-design.md docs/plans/README.md .memory/bot-chat-activity.md .memory/bots-rework-ui.md +git commit -m "Bots: e2e for the sidebar and panel; onboarding tile, spec rulings, memory" +``` diff --git a/docs/superpowers/specs/2026-10-14-bot-chat-activity-design.md b/docs/superpowers/specs/2026-10-14-bot-chat-activity-design.md index 0c3d562a1..cdfc70751 100644 --- a/docs/superpowers/specs/2026-10-14-bot-chat-activity-design.md +++ b/docs/superpowers/specs/2026-10-14-bot-chat-activity-design.md @@ -67,8 +67,8 @@ These are the owner's decisions from 2026-10-14. - **Environment panel** (`renderer/components/environment-panel.tsx`, about 2,100 lines): - `EnvironmentWorkbench` wraps every route, including `/bots/*`. - Its tabs are tightly coupled to the workspace and the active chat. - - Bots use `bot-files-dialog.tsx` instead. -- **Sidebar:** `renderer/components/chat-sidebar.tsx` and `sidebar-primary-nav.tsx`. Bots are one nav item, and no Bot rows appear in the sidebar. + - (was) Bots use `bot-files-dialog.tsx` instead. C2 replaced it with the Bot panel's Files tab. +- **Sidebar:** `renderer/components/chat-sidebar.tsx` and `sidebar-primary-nav.tsx`. (was) Bots are one nav item, and no Bot rows appear in the sidebar. C2 gives Bots pages their own sidebar. - **••• menus:** - Chat header: Profile, Files, Delete Bot. - List rows: Profile, Delete Bot. @@ -242,6 +242,7 @@ chatActivity?: { level: "quiet" | "summary" | "detailed"; wording: "follows" | " - **Memory:** the Memory page's content in compact form. Edit and delete behave exactly as in Profile → Memory, which stays. - **Routines:** the Routines group's content, with Add routine. - **Opening it.** A panel toggle sits in the Bot chat header. Files, Memory and Routines in the ••• menu open the panel on that tab. + - Implemented in C2 on the chat route `/bots/$botId/chat` (`renderer/main/bots/bot-workbench.tsx`), with its own state and keys (`aiden-agent.bot-panel.*`, `BotPanelProvider`). `environment.toggle` toggles it in a Bot chat and does nothing on other Bots pages. The workspace Environment, Quick View and device mini player are suppressed on every Bots path and come back unchanged (the workspace panel reads `data-state="covered"` while Bots pages show). ## 9. Bots sidebar @@ -379,3 +380,33 @@ Every new test file is registered in the matching `package.json` script and `scr - The Chat activity page saves on pick through a serial queue (a failed save returns the radio to the saved choice with a toast). Each level has its own Reduce Motion still frame, and Reduce Motion is read live. - Faces glance through `data-working` on the avatar, with one shared off-screen observer. - The Bots onboarding tile needed no redraw: it shows neither the list nor the Updates line (it draws the name pill, one exchange and a routine chip). + +- **Slice C2 rulings (2026-10-14):** + - The Bot panel lives on the chat route only; Profile and its sub-pages stay full pages. + - Bots on paired Macs are listed in the Bots sidebar under "On {Mac}", and search filters them. + - "Most recently used" is the Bot last viewed on this Mac, else the newest activity (chat activity once the summaries answer, else the Bot's own `updatedAt` after 800 ms, so a summary that never answers cannot leave `/bots` blank). While the list loads, or a Bot is about to open, `/bots` shows a skeleton; an open create flow suppresses the redirect. + - Unread compares the Bot's last reply with the newest reply its chat showed on this Mac (`seenReplyAt`, both main's times), or with when the preference record began for a Bot whose chat showed none, so updating lights up nothing. Main times a reply from when it starts streaming, so a reply that started before the person left and finished after is still unread. `lastViewed` (this Mac's clock) only picks the Bot `/bots` opens. + - The pinned Bot shows only as the hero; deleting it clears the pin. + - The footer shows the profile name's initial (Aiden has no profile photo) and Connect apps opens Settings → Plugins. + - The chat header has no "All Bots" back; Profile's back reads "Chat". The exceptions keep "All Bots", because there is no Bot whose chat they could go back to: Profile's loading and "not found" shells, and the chat's "not found" shell. + - "Memory updated" opens the panel's Memory tab. + - A waiting Bot's row reads "Waiting for you"; a working one "Thinking…". + - `bots:live:summary` gained `needsYou` and `lastReplyAt`; `bots:attention` refreshes a row when a question or approval changes. Desktop IPC only. + - Deviations from the plan as written, all accepted: + - One refcounted live store per Bot per window (`useBotLive`) instead of a context-only provider, so every consumer (the chat, the panel tabs) shares the single `bots:live:subscribe`. The chat holds it while it is shown. + - C1's `WorkbenchPanel` gained two optional props: `surface` (the `data-environment-surface` name, default `"tools"`, so the Bot panel is not treated as the workspace surface by focus and Escape rules) and `tabMenu` (default true; the Bot panel hides the "Open tabs" menu because its tabs are fixed). + - Back to Aiden, Search and New Bot sit at the top of the sidebar body rather than in the title bar, which the window controls overlap. + - `bots:changed` and `bots:attention` are synced once per window (`useBotNotificationsSync`) and re-read only the Bot they name. + - The Activity tab always shows the full log. The Chat activity level governs only the in-chat recap, since opening the panel is an explicit ask. + - Bot renderer suites run with `NODE_ENV=test` (`renderer/main/bots/test-env.ts`, imported first by `test-dom.ts`) so TanStack Router uses its client behaviour and routed pages re-render; navigation tests are real. + - Final fix wave: the Bot panel has its own width range (400–720, default 440) so it sits beside the chat, reflowing it, wherever the chat keeps a 560px column, and floats only where the workspace panel would float too. Deleting a Bot that is not on screen stays on the current page. A consumer joining a failed live store keeps the last view while it retries. Search folds only Latin accents. + - Parked, not fixed in C2: + - The workspace sidebar's local state is lost when Bots pages remount it. + - A batched summaries IPC (one `bots:live:summary` read per Bot on mount today). + - Profile → Chat pushes a history entry (pre-existing). + - A list with only paired Macs' Bots still shows "Meet your first Bot" on `/bots`. + - The device mini player reconnects while the workspace surface is suppressed (pre-existing, shared with the paired-host path). + - Closing the Bot panel returns focus to the panel toggle, not the composer (as briefed). + - A file chip names only the file's basename (pre-existing). + - Test debt: the `lastNonBotRoute` test depends on pristine module state and lacks edge cases (trailing slash, `/chat/extra`, a query on a Bots path, `/botsy`); dead `deferred()` lines in the Files keep-on-refresh test; the render probe never uninstalls (each suite is its own process); Task 2 review minors 3, 4 and 6 on the preference store. + - The Bots onboarding tile keeps its content; only its header changed from a back chevron to the panel toggle. diff --git a/main/handlers/bot-live.test.ts b/main/handlers/bot-live.test.ts index a1c0321d4..ee730b100 100644 --- a/main/handlers/bot-live.test.ts +++ b/main/handlers/bot-live.test.ts @@ -51,7 +51,14 @@ function fakeProjection() { notifyResuming: () => undefined, cancelStopping: () => undefined, refreshActivity: async () => undefined, - summary: async (botId) => ({ botId, preview: "hi", updatedAt: 1, state: { kind: "idle" } }), + summary: async (botId) => ({ + botId, + preview: "hi", + updatedAt: 1, + state: { kind: "idle" }, + needsYou: true, + lastReplyAt: 1, + }), close: async () => undefined, }; return { @@ -130,6 +137,8 @@ test("the list summary comes from the projection", async () => { preview: "hi", updatedAt: 1, state: { kind: "idle" }, + needsYou: true, + lastReplyAt: 1, }); }); diff --git a/main/services/bot-runtime/bot-attention.test.ts b/main/services/bot-runtime/bot-attention.test.ts new file mode 100644 index 000000000..41a037af1 --- /dev/null +++ b/main/services/bot-runtime/bot-attention.test.ts @@ -0,0 +1,119 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createBotApprovals } from "./bot-approvals.js"; +import { wireBotAttention } from "./bot-attention.js"; +import { createBotQuestions } from "./bot-questions.js"; +import { + optionAnswer, + QUESTION_ARGS, +} from "./test-support/question-fixtures.js"; + +function setup() { + const questions = createBotQuestions(); + const approvals = createBotApprovals({ publish() {}, withdraw() {} }); + const sent: Array<{ channel: string; payload: unknown }> = []; + const stop = wireBotAttention({ + questions, + approvals, + broadcast: (channel, payload) => void sent.push({ channel, payload }), + }); + return { questions, approvals, sent, stop }; +} + +function ask( + questions: ReturnType, + botId: string, + waitId: string, + signal?: AbortSignal, +) { + return questions.request({ + botId, + waitId, + toolCallId: `call-${waitId}`, + questions: QUESTION_ARGS.questions.map((question) => ({ + ...question, + multiSelect: false, + })), + signal, + }); +} + +function approve( + approvals: ReturnType, + botId: string, + waitId: string, + signal?: AbortSignal, +) { + return approvals.request({ + botId, + waitId, + toolCallId: `call-${waitId}`, + toolName: "write_file", + summary: "Write secret-notes.txt", + signal, + }); +} + +test("a question appearing and being answered each tell the sidebar, with only the Bot id", async () => { + const { questions, sent } = setup(); + const answered = ask(questions, "bot-a", "w1"); + assert.deepEqual(sent, [ + { channel: "bots:attention", payload: { botId: "bot-a" } }, + ]); + assert.equal( + questions.answer("bot-a", "w1", optionAnswer("w1", "Blue")), + "answered", + ); + await answered; + assert.equal(sent.length, 2); + assert.deepEqual(sent[1], { + channel: "bots:attention", + payload: { botId: "bot-a" }, + }); + assert.equal( + JSON.stringify(sent).includes("banner"), + false, + "no question text is broadcast", + ); +}); + +test("a question that is aborted tells the sidebar again", async () => { + const { questions, sent } = setup(); + const controller = new AbortController(); + const withdrawn = ask(questions, "bot-a", "w1", controller.signal); + controller.abort(); + await assert.rejects(withdrawn); + assert.equal(sent.length, 2); + assert.deepEqual(sent[1]!.payload, { botId: "bot-a" }); +}); + +test("an approval being asked and decided each tell the sidebar, with only the Bot id", async () => { + const { approvals, sent } = setup(); + const decided = approve(approvals, "bot-a", "a1"); + assert.deepEqual(sent, [ + { channel: "bots:attention", payload: { botId: "bot-a" } }, + ]); + assert.equal(approvals.decide("a1", "allow"), true); + assert.equal(await decided, "allow"); + assert.equal(sent.length, 2); + assert.equal( + JSON.stringify(sent).includes("secret-notes"), + false, + "no approval text is broadcast", + ); +}); + +test("a second Bot's question carries its own id, and stopping the wiring silences it", async () => { + const { questions, sent, stop } = setup(); + const first = ask(questions, "bot-a", "w1"); + const second = ask(questions, "bot-b", "w2"); + assert.deepEqual( + sent.map((message) => message.payload), + [{ botId: "bot-a" }, { botId: "bot-b" }], + ); + stop(); + questions.answer("bot-a", "w1", optionAnswer("w1", "Blue")); + questions.answer("bot-b", "w2", optionAnswer("w2", "Red")); + await Promise.all([first, second]); + assert.equal(sent.length, 2, "nothing is broadcast after the unsubscribe"); +}); diff --git a/main/services/bot-runtime/bot-attention.ts b/main/services/bot-runtime/bot-attention.ts new file mode 100644 index 000000000..e4cde6a9f --- /dev/null +++ b/main/services/bot-runtime/bot-attention.ts @@ -0,0 +1,24 @@ +// "Needs you" in the Bots sidebar follows a Bot's questions and approvals as +// they appear and settle. This wires the stores' change hooks to one +// notification that carries only the Bot id: the renderer re-reads that Bot's +// summary, so no question or approval text ever travels on this channel. + +import type { BotApprovals } from "./bot-approvals.js"; +import type { BotQuestions } from "./bot-questions.js"; + +interface BotAttentionDeps { + questions: Pick; + approvals: Pick; + broadcast(channel: "bots:attention", payload: { botId: string }): void; +} + +/** Start broadcasting `bots:attention`; the returned function stops it. */ +export function wireBotAttention(deps: BotAttentionDeps): () => void { + const broadcastAttention = (botId: string): void => deps.broadcast("bots:attention", { botId }); + const stopQuestions = deps.questions.onChange(broadcastAttention); + const stopApprovals = deps.approvals.onChange(broadcastAttention); + return () => { + stopQuestions(); + stopApprovals(); + }; +} diff --git a/main/services/bot-runtime/bot-session-main.ts b/main/services/bot-runtime/bot-session-main.ts index e8317882b..1b6de0ebe 100644 --- a/main/services/bot-runtime/bot-session-main.ts +++ b/main/services/bot-runtime/bot-session-main.ts @@ -41,6 +41,7 @@ import { runtimeSupportsImages } from "../generation-runtime.js"; import { resolveBotModelRuntime } from "../model-runtime.js"; import { telegramBotBindingAuthority, telegramBotBindings } from "../telegram/telegram-bot-bindings.js"; import { botApprovals } from "./bot-approvals-main.js"; +import { wireBotAttention } from "./bot-attention.js"; import { botQuestions } from "./bot-questions-main.js"; import type { BotExtensionDeps } from "./bot-extension.js"; import { createBotRuntimeModels, type BotModelRuntime } from "./bot-models.js"; @@ -333,6 +334,14 @@ botApprovals.onChange((botId) => { ); }); +// "Needs you" in the Bots sidebar follows questions and approvals as they +// appear and settle; the renderer re-reads that Bot's summary. +wireBotAttention({ + questions: botQuestions, + approvals: botApprovals, + broadcast: (channel, payload) => ipcMain.broadcast(channel, payload), +}); + /** The process-wide live projection that renderer windows subscribe to. */ export function botLiveProjection(): BotLiveProjection { liveProjection ??= createBotLiveProjection({ diff --git a/main/services/bot-runtime/live-projection.test.ts b/main/services/bot-runtime/live-projection.test.ts index 2b90e76c2..b3051db91 100644 --- a/main/services/bot-runtime/live-projection.test.ts +++ b/main/services/bot-runtime/live-projection.test.ts @@ -7,9 +7,11 @@ import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context"; import { fauxAssistantMessage, fauxText, fauxThinking, fauxToolCall } from "@earendil-works/pi-ai/providers/faux"; import { applyBotLiveEvent, + botLastReplyAt, botRetryableFailedTurn, type BotLiveEvent, type BotLiveSnapshot, + type BotPendingQuestion, } from "../../../renderer/shared/bot-live.js"; import { botTranscriptRows } from "../../../renderer/main/bots/bot-transcript-rows.js"; import { createBotSessionService, type BotSessionRuntime, type BotSessionServiceDeps } from "./bot-session-service.js"; @@ -1217,3 +1219,107 @@ test("an id the session cannot find stays hidden and is retried", async () => { await live.close(); } }); + +test("the last reply time ignores the person's newer messages and replies with no text", () => { + assert.equal(botLastReplyAt([]), null); + assert.equal( + botLastReplyAt([ + { id: "a1", type: "assistant", text: "Here you go.", toolCalls: [], stopReason: "stop", at: 10 }, + { id: "a2", type: "assistant", text: " ", toolCalls: [], stopReason: "toolUse", at: 20 }, + { id: "u2", type: "user", text: "Thanks", imageCount: 0, at: 30 }, + ]), + 10, + ); +}); + +test("the last reply time falls back past a reply that has no time", () => { + assert.equal( + botLastReplyAt([ + { id: "a1", type: "assistant", text: "Older reply.", toolCalls: [], stopReason: "stop", at: 10 }, + { id: "a2", type: "assistant", text: "Newer reply with no time.", toolCalls: [], stopReason: "stop" }, + ]), + 10, + ); +}); + +test("a Bot held by another process is never reported as needing the person", async () => { + const live = await start(createFauxModels([fauxAssistantMessage("Hello.")]), { + projection: { + state: async () => ({ kind: "unavailable", reason: "held_by_live_process" }), + approvalPending: () => true, + }, + }); + try { + const summary = await live.projection.summary("bot:a"); + assert.equal(summary.needsYou, false); + assert.equal(summary.lastReplyAt, null); + } finally { + await live.close(); + } +}); + +test("a message the person sent after the last reply does not move the last reply time", async () => { + const live = await start(createFauxModels([fauxAssistantMessage("First answer."), slowAnswer()], { tokensPerSecond: 40 })); + try { + const first = await live.service.send("bot:a", { text: "One", requestId: "desk-1" }); + await live.service.awaitReply("bot:a", first.submissionId, new AbortController().signal); + const answered = await live.projection.summary("bot:a"); + await live.service.send("bot:a", { text: "Two", requestId: "desk-2" }); + const waiting = await live.projection.summary("bot:a"); + assert.equal(waiting.preview, "Two", "the newest message is the person's"); + assert.equal(waiting.lastReplyAt, answered.lastReplyAt); + assert.equal(typeof waiting.lastReplyAt, "number"); + } finally { + await live.close(); + } +}); + +test("a list summary says when the last reply arrived and whether the Bot is waiting on the person", async () => { + let waiting = false; + const live = await start(createFauxModels([fauxAssistantMessage("Here is your meal plan.")]), { + projection: { approvalPending: () => waiting }, + }); + try { + const before = await live.projection.summary("bot:a"); + assert.equal(before.lastReplyAt, null); + assert.equal(before.needsYou, false); + const sent = await live.service.send("bot:a", { text: "Plan dinners", requestId: "desk-1" }); + await live.service.awaitReply("bot:a", sent.submissionId, new AbortController().signal); + const after = await live.projection.summary("bot:a"); + assert.equal(after.preview, "Here is your meal plan."); + assert.equal(typeof after.lastReplyAt, "number"); + assert.equal(after.lastReplyAt, after.updatedAt, "the newest message is the reply"); + waiting = true; + assert.equal((await live.projection.summary("bot:a")).needsYou, true); + } finally { + await live.close(); + } +}); + +test("a list summary reads a waiting question and approval whether or not a chat is open", async () => { + let question: BotPendingQuestion | null = null; + let approval = false; + const live = await start(createFauxModels([fauxAssistantMessage("Hello.")]), { + projection: { question: () => question, approvalPending: () => approval }, + }); + try { + const closed = async () => (await live.projection.summary("bot:a")).needsYou; + assert.equal(await closed(), false); + question = { botId: "bot:a", waitId: "w1", toolCallId: "t1", questions: [] }; + assert.equal(await closed(), true, "a question waits with no chat open"); + question = null; + approval = true; + assert.equal(await closed(), true, "an approval waits with no chat open"); + approval = false; + const { unsubscribe } = await live.projection.subscribe("bot:a", { send() {} }); + try { + assert.equal(await closed(), false, "settled with a chat open"); + approval = true; + assert.equal(await closed(), true, "an approval waits with a chat open"); + } finally { + unsubscribe(); + } + } finally { + await live.close(); + } +}); diff --git a/main/services/bot-runtime/live-projection.ts b/main/services/bot-runtime/live-projection.ts index a78c1bc87..6df0c5ad3 100644 --- a/main/services/bot-runtime/live-projection.ts +++ b/main/services/bot-runtime/live-projection.ts @@ -27,6 +27,7 @@ import { BOT_SILENT_REPLY, botFailedTurnId, botQuestionAnswerText, + botLastReplyAt, botTranscriptPreview, type BotLiveActivity, type BotLiveEvent, @@ -1082,17 +1083,37 @@ export function createBotLiveProjection(deps: BotLiveProjectionDeps): BotLivePro }, async summary(botId) { + // Read right before each return, so a slow summary never reports a stale wait. It uses the + // same sources as the chat, so a row agrees with it whether or not a chat is open. + const needsYouNow = () => (deps.question?.(botId) ?? null) !== null || deps.approvalPending?.(botId) === true; const feed = feeds.get(botId); if (feed && !feed.ended) { await feed.queue; const preview = botTranscriptPreview(feed.entries); - return { botId, preview: preview?.text ?? null, updatedAt: preview?.at ?? null, state: feed.state }; + return { + botId, + preview: preview?.text ?? null, + updatedAt: preview?.at ?? null, + state: feed.state, + needsYou: needsYouNow(), + lastReplyAt: botLastReplyAt(feed.entries), + }; } const state = await deps.state(botId); - if (state.kind === "unavailable") return { botId, preview: null, updatedAt: null, state }; + if (state.kind === "unavailable") { + return { botId, preview: null, updatedAt: null, state, needsYou: false, lastReplyAt: null }; + } const view = await (await deps.conversation(botId)).context(BACKGROUND_CONTEXT); - const preview = botTranscriptPreview(projectBotTranscript(view.entries)); - return { botId, preview: preview?.text ?? null, updatedAt: preview?.at ?? null, state }; + const entries = projectBotTranscript(view.entries); + const preview = botTranscriptPreview(entries); + return { + botId, + preview: preview?.text ?? null, + updatedAt: preview?.at ?? null, + state, + needsYou: needsYouNow(), + lastReplyAt: botLastReplyAt(entries), + }; }, async close(botId) { diff --git a/package.json b/package.json index 6e494f8f1..c75783007 100644 --- a/package.json +++ b/package.json @@ -128,8 +128,8 @@ "pi-upgrade:advance": "node --import tsx scripts/pi-upgrade-advance.mjs", "pretest:bots": "npm run build:bot-inbox-writer && node scripts/build-bot-inbox-writer.mjs --test && node --test scripts/bot-inbox-writer.test.mjs && tsx --test main/services/bot-share-image-tool.test.ts main/services/bot-inbound-attachment-inbox.test.ts main/services/vision-analysis-tool.test.ts", "test:telegram": "tsx --test main/services/telegram/bot-reply-outbox.test.ts main/services/telegram/telegram-profile-mutation-fence.test.ts main/services/telegram/telegram-controls.test.ts main/services/telegram/telegram-inbound.test.ts main/services/telegram/telegram-outbound.test.ts main/services/telegram/telegram-queue.test.ts main/services/telegram/telegram-markdown.test.ts main/services/telegram/telegram-bot-api.test.ts main/services/telegram/telegram-turn.test.ts main/services/telegram/telegram-session.test.ts main/services/telegram/telegram-service-core.test.ts main/services/telegram/telegram-workspace-core.test.ts main/services/telegram/telegram-activity.test.ts main/services/telegram/telegram-profile-config.test.ts main/services/telegram/telegram-extension-registry.test.ts main/services/telegram/telegram-thread-store.test.ts main/services/telegram/telegram-ownership.test.ts main/services/telegram/telegram-agent-tools.test.ts main/services/telegram/telegram-bot-binding-store.test.ts main/services/telegram/telegram-bot-chat-lifecycle.test.ts main/services/telegram/telegram-bot-binding-reconciliation.test.ts main/services/telegram/telegram-bot-binding-validation.test.ts main/services/telegram/telegram-bot-binding-authority.test.ts renderer/lib/telegram-workspace-options.test.ts", - "test:bots": "tsx --test main/services/telegram/telegram-bot-chat-lifecycle.test.ts main/services/telegram/telegram-bot-binding-reconciliation.test.ts main/services/telegram/telegram-bot-binding-validation.test.ts main/services/telegram/telegram-bot-binding-authority.test.ts main/services/bot-avatar-store.test.ts main/services/bot-store-core.test.ts main/services/bot-chat-store.test.ts main/services/bot-mutation-gate.test.ts main/services/bot-inbox-projection.test.ts main/services/bot-system-prompt.test.ts main/services/bot-generation-preparation.test.ts main/services/bot-inbound-attachment-home.test.ts main/services/bot-file-tool-router.test.ts main/services/bot-tool-authority.test.ts main/services/bot-capability-store-core.test.ts main/services/bot-capability-store.test.ts main/services/bot-capability-state-checkpoint.test.ts main/services/bot-capability-keychain-anchor.test.ts main/services/bot-capability-secret-service-anchor.test.ts main/services/bot-capability-lease.test.ts main/services/bot-runtime-inventory-lease.test.ts main/services/bot-runtime-inventory-publication.test.ts main/services/bot-runtime-authority.test.ts main/services/bot-capability-catalog-core.test.ts main/services/bot-capability-bindings.test.ts main/services/bot-capability-key-store.test.ts main/services/bot-capability-migration-seal.test.ts main/services/bot-capability-incarnation-store.test.ts main/services/bot-capability-inventory-ports.test.ts main/services/bot-capability-production-shape.test.ts main/services/bot-mcp-inventory.test.ts main/services/bot-skill-inventory.test.ts main/services/bot-skill-content-watcher.test.ts main/services/bot-managed-workspace-core.test.ts main/services/bot-lifecycle-journal-core.test.ts main/services/bot-application-service.test.ts main/handlers/bot-params.test.ts main/handlers/bot-renderer-errors.test.ts main/handlers/bots.contract.test.ts renderer/main/bots-view.test.tsx renderer/main/bots/bot-character-card.test.tsx renderer/main/bots/bot-chat-header.test.tsx renderer/main/bots/bot-chat-mode.test.tsx renderer/main/bots/bot-delete-dialog.test.tsx renderer/main/bots/bot-list.test.tsx renderer/main/bots/bots-home.test.tsx renderer/main/bots/bot-create-flow.test.tsx renderer/main/bots/bot-profile.test.tsx renderer/main/bots/bot-profile-photo.test.tsx renderer/lib/model-picker-data.test.ts renderer/lib/command-system-core.test.ts renderer/shared/bot-capabilities.test.ts renderer/shared/bot-presets.test.ts renderer/shared/bot-connections.test.ts renderer/components/bots/bot-connection-ui.test.tsx main/services/bot-runtime-tools/suggest-connection.test.ts main/services/bot-connection-dismissals.test.ts main/handlers/bot-live.test.ts main/handlers/bot-questions.test.ts renderer/main/bots/bot-chat-pane.test.tsx renderer/main/bots/bot-routines.test.tsx main/services/bot-connection-setup-core.test.ts main/handlers/bot-files.test.ts renderer/main/bots/bot-transcript-rows.test.ts renderer/main/bots/bot-memory-page.test.tsx main/services/bot-routine-notes.test.ts main/services/bot-routine-proposals.test.ts main/services/bot-routine-notifications.test.ts renderer/main/bots/bot-model-choice.test.ts renderer/main/bots/bot-model-picker.test.tsx renderer/shared/bot-activity.test.ts renderer/main/bots/bot-status-line.test.tsx renderer/main/bots/bot-activity-recap.test.tsx renderer/main/bots/bot-chat-activity-page.test.tsx", - "test:bot-runtime": "tsx --test main/services/bot-runtime/profile-lock.test.ts main/services/bot-runtime/harness-host.test.ts main/services/bot-runtime/tool-adapter.test.ts main/services/bot-runtime/bot-extension.test.ts main/services/bot-runtime/bot-session-service.test.ts main/services/bot-runtime/bot-tool-assembly.test.ts main/services/bot-runtime/bot-delete.test.ts main/services/bot-runtime/live-projection.test.ts main/services/bot-runtime/bot-starter.test.ts main/services/bot-runtime/bot-question-tool.test.ts main/services/bot-memory/store.test.ts main/services/bot-memory/scan.test.ts main/services/bot-memory/tool.test.ts main/services/bot-memory/compaction.test.ts main/services/bot-memory/review.test.ts main/handlers/bot-memory.test.ts main/services/bot-runtime/bot-activity-detail.test.ts", + "test:bots": "tsx --test main/services/telegram/telegram-bot-chat-lifecycle.test.ts main/services/telegram/telegram-bot-binding-reconciliation.test.ts main/services/telegram/telegram-bot-binding-validation.test.ts main/services/telegram/telegram-bot-binding-authority.test.ts main/services/bot-avatar-store.test.ts main/services/bot-store-core.test.ts main/services/bot-chat-store.test.ts main/services/bot-mutation-gate.test.ts main/services/bot-inbox-projection.test.ts main/services/bot-system-prompt.test.ts main/services/bot-generation-preparation.test.ts main/services/bot-inbound-attachment-home.test.ts main/services/bot-file-tool-router.test.ts main/services/bot-tool-authority.test.ts main/services/bot-capability-store-core.test.ts main/services/bot-capability-store.test.ts main/services/bot-capability-state-checkpoint.test.ts main/services/bot-capability-keychain-anchor.test.ts main/services/bot-capability-secret-service-anchor.test.ts main/services/bot-capability-lease.test.ts main/services/bot-runtime-inventory-lease.test.ts main/services/bot-runtime-inventory-publication.test.ts main/services/bot-runtime-authority.test.ts main/services/bot-capability-catalog-core.test.ts main/services/bot-capability-bindings.test.ts main/services/bot-capability-key-store.test.ts main/services/bot-capability-migration-seal.test.ts main/services/bot-capability-incarnation-store.test.ts main/services/bot-capability-inventory-ports.test.ts main/services/bot-capability-production-shape.test.ts main/services/bot-mcp-inventory.test.ts main/services/bot-skill-inventory.test.ts main/services/bot-skill-content-watcher.test.ts main/services/bot-managed-workspace-core.test.ts main/services/bot-lifecycle-journal-core.test.ts main/services/bot-application-service.test.ts main/handlers/bot-params.test.ts main/handlers/bot-renderer-errors.test.ts main/handlers/bots.contract.test.ts renderer/main/bots-view.test.tsx renderer/main/bots/bot-character-card.test.tsx renderer/main/bots/bot-chat-header.test.tsx renderer/main/bots/bot-chat-mode.test.tsx renderer/main/bots/bot-delete-dialog.test.tsx renderer/main/bots/bots-home.test.tsx renderer/main/bots/bot-create-flow.test.tsx renderer/main/bots/bot-profile.test.tsx renderer/main/bots/bot-profile-photo.test.tsx renderer/lib/model-picker-data.test.ts renderer/lib/command-system-core.test.ts renderer/shared/bot-capabilities.test.ts renderer/shared/bot-presets.test.ts renderer/shared/bot-connections.test.ts renderer/components/bots/bot-connection-ui.test.tsx main/services/bot-runtime-tools/suggest-connection.test.ts main/services/bot-connection-dismissals.test.ts main/handlers/bot-live.test.ts main/handlers/bot-questions.test.ts renderer/main/bots/bot-chat-pane.test.tsx renderer/main/bots/bot-routines.test.tsx main/services/bot-connection-setup-core.test.ts main/handlers/bot-files.test.ts renderer/main/bots/bot-transcript-rows.test.ts renderer/main/bots/bot-memory-page.test.tsx main/services/bot-routine-notes.test.ts main/services/bot-routine-proposals.test.ts main/services/bot-routine-notifications.test.ts renderer/main/bots/bot-model-choice.test.ts renderer/main/bots/bot-model-picker.test.tsx renderer/shared/bot-activity.test.ts renderer/main/bots/bot-status-line.test.tsx renderer/main/bots/bot-activity-recap.test.tsx renderer/main/bots/bot-chat-activity-page.test.tsx renderer/lib/bot-routes.test.ts renderer/lib/bot-sidebar-preferences.test.ts renderer/lib/search-fold.test.ts renderer/main/bots/use-bot-sidebar-preferences.test.tsx renderer/main/bots/bot-panel.test.tsx renderer/main/bots/bot-activity-log.test.ts renderer/main/bots/bot-sidebar.test.tsx", + "test:bot-runtime": "tsx --test main/services/bot-runtime/profile-lock.test.ts main/services/bot-runtime/harness-host.test.ts main/services/bot-runtime/tool-adapter.test.ts main/services/bot-runtime/bot-extension.test.ts main/services/bot-runtime/bot-session-service.test.ts main/services/bot-runtime/bot-tool-assembly.test.ts main/services/bot-runtime/bot-delete.test.ts main/services/bot-runtime/live-projection.test.ts main/services/bot-runtime/bot-attention.test.ts main/services/bot-runtime/bot-starter.test.ts main/services/bot-runtime/bot-question-tool.test.ts main/services/bot-memory/store.test.ts main/services/bot-memory/scan.test.ts main/services/bot-memory/tool.test.ts main/services/bot-memory/compaction.test.ts main/services/bot-memory/review.test.ts main/handlers/bot-memory.test.ts main/services/bot-runtime/bot-activity-detail.test.ts", "test:bots:coverage": "node scripts/run-registered-tests-with-coverage.mjs test:bots", "test:voice": "tsx --test main/services/dictation-hold-settings.test.ts main/services/linux-dictation-portal.test.ts main/services/transcription-core.test.ts main/services/gemini-live-transcription-core.test.ts main/services/dictation-coordinator.test.ts main/services/dictation-paste.test.ts main/services/local-speech-protocol.test.ts main/services/local-speech-process-core.test.ts main/services/local-speech-worker-host.test.ts main/services/local-speech-lane.test.ts renderer/shared/voice-models.test.ts renderer/shared/gemini-usage-scope.test.ts renderer/components/settings/gemini-voice-setup.test.tsx renderer/lib/accessibility-permission-core.test.ts renderer/lib/accessibility-refresh.test.ts renderer/lib/dictation-operation-gate.test.ts renderer/lib/gemini-recorded-retry.test.ts renderer/lib/live-pcm-capture.test.ts renderer/lib/voice-recorder-core.test.ts renderer/lib/wav-audio.test.ts main/services/local-speech-idle-unload.test.ts renderer/shared/dictation-dictionary.test.ts renderer/shared/dictation-preferences.test.ts renderer/components/settings/dictation-dictionary-settings.test.tsx renderer/pill/pill-copied-notice.test.tsx main/services/local-speech-catalog.test.ts main/services/local-speech-downloads.test.ts renderer/shared/voice-language.test.ts renderer/shared/voice-preferences.test.ts main/services/local-speech-vad.test.ts main/services/local-speech-engine.test.ts main/services/local-speech-worker-core.test.ts renderer/shared/voice-provider.test.ts main/services/transcription-routing.test.ts main/services/local-speech-opus.test.ts main/services/voice-provider-resolution.test.ts renderer/components/settings/local-voice-settings.test.tsx renderer/components/settings/model-manager-view.test.tsx renderer/components/settings/voice-settings.test.tsx renderer/lib/slow-model-load.test.ts main/services/local-speech-model-state.test.ts renderer/pill/pill-app.test.tsx main/services/dictation-voice-check.test.ts", "test:tts": "tsx --test main/services/tts/authority-contract.test.ts renderer/components/settings/tts-settings.test.tsx main/services/tts/audio-store.test.ts renderer/lib/tts-client.test.ts renderer/lib/tts-player.test.ts main/services/tts/speech-text.test.ts main/services/tts/source.test.ts main/services/tts/gemini-wire.test.ts main/services/tts/service.test.ts main/services/tts/gemini-provider.loopback.test.ts renderer/components/message-bubble.test.tsx renderer/shared/tts.test.ts", diff --git a/renderer/components/chat-sidebar.tsx b/renderer/components/chat-sidebar.tsx index fb32eb776..f7a3bb521 100644 --- a/renderer/components/chat-sidebar.tsx +++ b/renderer/components/chat-sidebar.tsx @@ -82,6 +82,8 @@ import { ChatRowContextGlyphs } from "./chat-row-context"; import { sidebarPullRequestChatIds } from "../lib/chat-row-context"; import { RemoteConnectionPopover } from "./remote-connection-popover"; import { useAppCapabilities } from "../lib/app-capabilities"; +import { isBotsPath } from "../lib/bot-routes"; +import { BotSidebar } from "../main/bots/bot-sidebar"; import { localSidebarChats, localSidebarProjects, @@ -458,7 +460,14 @@ function GeneratedTitleReveal({ previousTitle, title }: { previousTitle: string; ); } -export function ChatSidebar({ activeChatId, activeRemoteChat = null, titleReveal }: ChatSidebarProps) { +/** On any Bots page the sidebar lists only Bots (spec §9); everywhere else it is the workspace outline. */ +export function ChatSidebar(props: ChatSidebarProps) { + const pathname = useRouterState({ select: (state) => state.location.pathname }); + const capabilities = useAppCapabilities(); + return capabilities.bots && isBotsPath(pathname) ? : ; +} + +function WorkspaceChatSidebar({ activeChatId, activeRemoteChat = null, titleReveal }: ChatSidebarProps) { const pathPreferences = useWorkspacePathPreferences(); const navigate = useNavigate(); const capabilities = useAppCapabilities(); diff --git a/renderer/components/environment-panel.tsx b/renderer/components/environment-panel.tsx index bb0cb5693..98b3d0c6c 100644 --- a/renderer/components/environment-panel.tsx +++ b/renderer/components/environment-panel.tsx @@ -1753,7 +1753,8 @@ export function EnvironmentWorkbench({ }: React.PropsWithChildren<{ /** * Hides the Environment and Quick View surfaces without changing whether - * they are open, for content that is not on this Mac (a paired host's chat). + * they are open, for content that is not on this Mac (a paired host's chat) + * and for Bots pages, which have their own panel. */ suppressed?: boolean; }>) { diff --git a/renderer/components/onboarding-art/control-art.tsx b/renderer/components/onboarding-art/control-art.tsx index ef97b7acb..0b8476d08 100644 --- a/renderer/components/onboarding-art/control-art.tsx +++ b/renderer/components/onboarding-art/control-art.tsx @@ -2,7 +2,6 @@ import { Bot, CalendarClock, Check, - ChevronLeft, ChevronRight, Cpu, Ellipsis, @@ -17,6 +16,7 @@ import { Moon, MousePointer2, OctagonAlert, + PanelRightOpen, Play, Search, Send, @@ -180,7 +180,6 @@ export function BotsArt() { <>
    - @@ -188,6 +187,7 @@ export function BotsArt() { Meal Planner +
    diff --git a/renderer/components/remote-bots-section.tsx b/renderer/components/remote-bots-section.tsx index bae00a7d4..d009932a0 100644 --- a/renderer/components/remote-bots-section.tsx +++ b/renderer/components/remote-bots-section.tsx @@ -1,11 +1,21 @@ import { Bot, Loader2 } from "lucide-react"; import type { RemoteBotGroup } from "../lib/hosts/new-chat-targets"; +import { matchesSearch } from "../lib/search-fold"; import { RemoteHostMarker } from "./sidebar-remote"; import { Text } from "./ui"; +/** The paired Macs' Bots that match the sidebar's search; a Mac with none is left out. */ +export function remoteBotGroupsMatching(groups: readonly RemoteBotGroup[], query: string): RemoteBotGroup[] { + return groups + .map(({ host, bots }) => ({ host, bots: bots.filter((bot) => matchesSearch([bot.name, bot.purpose ?? ""], query)) })) + .filter((group) => group.bots.length > 0); +} + /** - * Bots that live on paired Macs, grouped by Mac. Opening one opens its chat on - * that Mac; nothing about a remote Bot is stored or run on this one. + * Bots that live on paired Macs, grouped by Mac, as rows in the Bots sidebar. + * Opening one opens its chat on that Mac; nothing about a remote Bot is stored + * or run on this one. The sidebar narrows `groups` with its search first + * (`remoteBotGroupsMatching`). */ export function RemoteBotsSection({ groups, @@ -19,48 +29,45 @@ export function RemoteBotsSection({ }) { if (groups.length === 0) return null; return ( -
    +
    {groups.map(({ host, bots }) => (
    -
    +
    - + {`On ${host.label}`}
    {host.disabledReason ? ( - + {host.disabledReason} ) : null} -
    +
      {bots.map((bot) => { const busy = opening === `${host.id}/${bot.id}`; return ( - +
    • + +
    • ); })} -
    +
    ))}
    diff --git a/renderer/components/workbench-panel.tsx b/renderer/components/workbench-panel.tsx index 80ae099a9..34618c104 100644 --- a/renderer/components/workbench-panel.tsx +++ b/renderer/components/workbench-panel.tsx @@ -10,7 +10,11 @@ import { import { cn } from "../lib/ui-utils"; import { useShortcutBinding, useShortcutLabel } from "../lib/command-system"; import { ariaKeyShortcut } from "../shared/keybindings"; -import { resolveEnvironmentPanelResizeBounds, storedWorkbenchWidth } from "../lib/environment-panel-layout"; +import { + resolveEnvironmentPanelResizeBounds, + storedWorkbenchWidth, + type PanelWidthBounds, +} from "../lib/environment-panel-layout"; export interface WorkbenchWidthState { /** The person's preferred width; the rendered width is resolved against the container. */ @@ -24,9 +28,9 @@ export interface WorkbenchWidthState { } /** Preferred width and drag state for a workbench, persisted under `storageKey`. */ -export function useWorkbenchWidth(storageKey: string): WorkbenchWidthState { +export function useWorkbenchWidth(storageKey: string, bounds?: PanelWidthBounds): WorkbenchWidthState { const [preferredWidth, setPreferredWidth] = React.useState(() => - storedWorkbenchWidth(localStorage.getItem(storageKey)), + storedWorkbenchWidth(localStorage.getItem(storageKey), bounds), ); const [resizing, setResizing] = React.useState(false); const commitPreferredWidth = React.useCallback( @@ -76,6 +80,8 @@ export interface WorkbenchTabStripProps { onCloseTab: (tab: T) => void; /** The launcher hook point. Without it the strip has no "+" button. */ newTab?: { label: string; onOpen: () => void }; + /** The "Open tabs" list, for strips whose tabs can overflow. Defaults to shown. */ + tabMenu?: boolean; closeLabel: string; /** Shown as `${closeTitle} (${shortcut})` with the `environment.toggle` binding. */ closeTitle: string; @@ -91,6 +97,7 @@ export function WorkbenchTabStrip({ onSelectTab, onCloseTab, newTab, + tabMenu = true, closeLabel, closeTitle, onClose, @@ -119,7 +126,7 @@ export function WorkbenchTabStrip({ })}
    {newTab ? : null} - { if (tabMenuSelectionRef.current) { event.preventDefault(); tabMenuSelectionRef.current = false; requestAnimationFrame(() => activeTabRef.current?.focus()); } }}>{tabs.map((entry) => { tabMenuSelectionRef.current = true; onSelectTab(entry); }}>{entry.label})} + {tabMenu ? { if (tabMenuSelectionRef.current) { event.preventDefault(); tabMenuSelectionRef.current = false; requestAnimationFrame(() => activeTabRef.current?.focus()); } }}>{tabs.map((entry) => { tabMenuSelectionRef.current = true; onSelectTab(entry); }}>{entry.label})} : null} ); @@ -146,6 +153,11 @@ export interface WorkbenchView { export interface WorkbenchPanelProps extends Omit, "activeTabRef"> { id: string; + /** + * `data-environment-surface`: the workspace's focus and Escape rules act on + * "tools". Another workbench (the Bot panel) names its own surface. + */ + surface?: string; label: string; resizeLabel: string; open: boolean; @@ -154,6 +166,8 @@ export interface WorkbenchPanelProps inline: boolean; width: number; containerWidth: number; + /** The surface's own width range; the workspace Environment panel's by default. */ + widthBounds?: PanelWidthBounds; resizing: boolean; onResizingChange: (resizing: boolean) => void; /** Live width while dragging, already clamped. */ @@ -170,6 +184,7 @@ export interface WorkbenchPanelProps export function WorkbenchPanel({ id, + surface = "tools", label, resizeLabel, open, @@ -177,6 +192,7 @@ export function WorkbenchPanel({ inline, width, containerWidth, + widthBounds, resizing, onResizingChange, onWidthChange, @@ -193,6 +209,7 @@ export function WorkbenchPanel({ onSelectTab, onCloseTab, newTab, + tabMenu, closeLabel, closeTitle, onClose, @@ -204,7 +221,7 @@ export function WorkbenchPanel({ const widthRef = React.useRef(width); widthRef.current = width; - const resizeBounds = resolveEnvironmentPanelResizeBounds(containerWidth, inline); + const resizeBounds = resolveEnvironmentPanelResizeBounds(containerWidth, inline, widthBounds); const clampToResizeBounds = React.useCallback( (nextWidth: number) => Math.min(resizeBounds.max, Math.max(resizeBounds.min, nextWidth)), [resizeBounds.max, resizeBounds.min], @@ -261,7 +278,7 @@ export function WorkbenchPanel({