From 20d60cd3bec7b865ac8109a5deb03b83d5738be0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Wed, 26 Aug 2026 07:21:28 +0000 Subject: [PATCH] /bots looks like Command HQ, and stops opening the wrong chat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TWO BUGS AND A LAYOUT. the first outing of /bots opened a months-old conversation about deleting proxy hosts. resume-newest called useClaudeSessions(absoluteCwd) with no context, and a screen that owns its own directory has cwd '~', so that join yields null and the hook listed the caller's HOME. the panel then resumed the newest thing there. same bug the header had, one function down; the declared context wins now. second: ChatPanelWrapper takes an optional sessionId, because a screen whose selection lives in the URL cannot also let usePanelSession decide — two sources would disagree and the URL would lose. null means "the screen chose a new chat", undefined means "no opinion", and collapsing them would make New snap back to the newest. the layout is Command HQ's: a narrow list left, the conversation right, and a route pair — /bots is the new-bot state, /bots/:sessionId is one of them. rows are real links, so cmd-click works and a reload keeps its place. no categories pane. a commander is a job somebody files and a filter earns its place; a bot is a sentence, and a filter over a handful of one-line watches would be furniture. no table either: every bot is a chat in bots/, so the list IS the session list for that directory. nothing to keep in step. Co-Authored-By: Claude Opus 5 Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 --- src/apps/officer-web/App.tsx | 2 + .../Screens/Dashboard/Bots/BotsList.tsx | 67 +++++++++++++++++++ .../Screens/Dashboard/Bots/BotsScreen.tsx | 46 ++++++++----- .../Screens/Dashboard/Bots/defaultLayout.ts | 18 +++-- .../src/apps/Chat/ChatPanelWrapper.tsx | 32 ++++++++- 5 files changed, 139 insertions(+), 26 deletions(-) create mode 100644 src/apps/officer-web/Screens/Dashboard/Bots/BotsList.tsx diff --git a/src/apps/officer-web/App.tsx b/src/apps/officer-web/App.tsx index e3cbd8d7..aecdbe56 100644 --- a/src/apps/officer-web/App.tsx +++ b/src/apps/officer-web/App.tsx @@ -109,6 +109,8 @@ export function App() { } /> } /> } /> + {/* Route pair: `/bots` is the new-bot state, `/bots/:sessionId` is one of them. */} + } /> } /> } /> diff --git a/src/apps/officer-web/Screens/Dashboard/Bots/BotsList.tsx b/src/apps/officer-web/Screens/Dashboard/Bots/BotsList.tsx new file mode 100644 index 00000000..6051ac9e --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Bots/BotsList.tsx @@ -0,0 +1,67 @@ +import { Bot, Plus } from 'lucide-react'; +import { Link, useParams } from 'react-router'; +import { useClaudeSessions } from 'state/useClaudeSessions'; + +/** + * The bots, down the left — the same shape Command HQ gives commanders. + * + * Every bot is a chat in `$DATA_PATH//bots`, so this is that directory's session list and nothing + * more. No table, no rows to keep in step: the list IS the conversations. + * + * Rows are real links to `/bots/:sessionId`, per `docs/navigation-audit.md` — a bot is addressable, so + * cmd-click opens one in a tab and a reload keeps the one you were reading. + */ + +const NEW = '/bots'; + +export const BotsList = () => { + const { sessionId } = useParams(); + const { sessions, isLoading } = useClaudeSessions(null, { chatContext: { context: 'bots' } }); + + return ( + + ); +}; diff --git a/src/apps/officer-web/Screens/Dashboard/Bots/BotsScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Bots/BotsScreen.tsx index 300986c6..9547fced 100644 --- a/src/apps/officer-web/Screens/Dashboard/Bots/BotsScreen.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Bots/BotsScreen.tsx @@ -1,33 +1,44 @@ import type { LayoutNode } from 'officerdev'; import { WorkspaceView, ChatPanelWrapper, ChatHeader } from 'officerdev'; +import { useParams } from 'react-router'; import { useAuthoredLayout } from 'state/useAuthoredLayout'; import { defaultLayout } from './defaultLayout'; +import { BotsList } from './BotsList'; /** * `/bots` — one sentence arms a watch, and the conversation stays open to widen it. * - * ── Why it is a chat and nothing else ── + * ── The same shape as Command HQ ── * - * A bot has no settings worth a form. What it watches, how often, and who hears about it are all things - * the owner says in a sentence, and the agent's job is to turn that into an armed background job without - * an interview. The instructions live in `$DATA_PATH//bots/CLAUDE.md`, written by the server on - * every turn — see `api/bots/brief.ts`. + * A narrow list on the left, the conversation on the right, and the selection in the URL as a route pair: + * `/bots` is the new-bot state, `/bots/:sessionId` is one of them. That is the convention + * `docs/navigation-audit.md` sets and the one Command HQ already follows, so a bot is linkable and a + * reload keeps the one you were reading. * - * ── Why the panel is supplied here rather than named in the registry ── + * ── Why the panels are supplied here rather than registered ── * * Every bot runs in one shared directory, which the server resolves from `context: 'bots'`. The registry's - * `officerdev/chat` derives its context from the workspace and would land on none. `components` is exactly - * the prop for a screen that supplies its own chat panel — `ChatPanelWrapper` says so — and it carries the - * header with it, so the picker lists bots rather than every chat on the machine. + * `officerdev/chat` derives its context from the workspace and would land on none of it — it would list + * and resume the caller's HOME, which is exactly what this screen did on its first outing. `components` is + * the prop for a screen that supplies its own chat panel, and it carries the header, so the picker lists + * bots rather than every chat on the machine. + * + * The instructions a bot runs under are `$DATA_PATH//bots/CLAUDE.md`, written by the server on + * every turn — `api/bots/brief.ts`. */ const CHAT_CONTEXT = { context: 'bots' } as const; -const BotsChat = ({ panelId }: { panelId: string }) => ( -
- -
-); +const BotsChat = ({ panelId }: { panelId: string }) => { + const { sessionId } = useParams(); + return ( +
+ {/* `sessionId ?? null` — null is "the screen chose a new chat", which is not the same as having no + opinion. On `/bots` with no id that is exactly right: an empty composer waiting for the sentence. */} + +
+ ); +}; const BotsChatHeader = ({ panelId }: { panelId: string }) => ( @@ -40,8 +51,11 @@ export const BotsScreen = () => { ); }; diff --git a/src/apps/officer-web/Screens/Dashboard/Bots/defaultLayout.ts b/src/apps/officer-web/Screens/Dashboard/Bots/defaultLayout.ts index 4dbb76cc..b0b146d4 100644 --- a/src/apps/officer-web/Screens/Dashboard/Bots/defaultLayout.ts +++ b/src/apps/officer-web/Screens/Dashboard/Bots/defaultLayout.ts @@ -1,12 +1,16 @@ import type { LayoutNode } from 'officerdev'; -// One panel: the conversation. +// The bots down the left, the conversation on the right — the same division Command HQ makes, and for the +// same reason: the list is names and the right-hand side is the only thing anybody reads. // -// A bot is armed, corrected and widened by talking to it — there is no form that would do it better, and -// the header's own picker is the list of bots. A second pane listing them would repeat the header and take -// width from the only thing on this screen anybody reads. +// No category pane. Commanders have one because a commander is a job somebody files; a bot is a sentence, +// and a filter over a handful of one-line watches would be furniture. export const defaultLayout: LayoutNode = { - type: 'panel', - id: 'bots-chat', - appType: 'bots-chat', + type: 'group', + id: 'bots-root', + direction: 'horizontal', + children: [ + { node: { type: 'panel', id: 'bots-list', appType: 'bots-list' }, size: 24 }, + { node: { type: 'panel', id: 'bots-chat', appType: 'bots-chat' }, size: 76 }, + ], }; diff --git a/src/workspaces/officerdev/src/apps/Chat/ChatPanelWrapper.tsx b/src/workspaces/officerdev/src/apps/Chat/ChatPanelWrapper.tsx index 13290d9a..93379ef6 100644 --- a/src/workspaces/officerdev/src/apps/Chat/ChatPanelWrapper.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/ChatPanelWrapper.tsx @@ -164,6 +164,18 @@ type ChatPanelWrapperProps = { * platform mounts, not a caller that could pass this. */ chatContext?: { context: string; contextId?: string }; + /** + * Which conversation to show, when the SCREEN decides rather than the panel. + * + * `usePanelSession` is right for a dashboard, where two chat panels side by side hold different + * conversations and each remembers its own. It is wrong for a screen whose selection lives in the URL — + * `/bots/:sessionId`, the same shape Command HQ uses — because then two sources would disagree about + * which one is open and the URL would lose. + * + * `null` means the screen has chosen a NEW chat, which is not the same as `undefined` meaning it has no + * opinion. Collapsing them would make New snap back to the newest. + */ + sessionId?: string | null; /** * Called when a turn ends, with whether it ran any tools. * @@ -182,6 +194,7 @@ export const ChatPanelWrapper = ({ panelId, promptPrefix, chatContext: declared, + sessionId: fromScreen, onTurnComplete: alsoOnTurnComplete, }: ChatPanelWrapperProps) => { const { workspace, cwd, root } = useWorkspace(); @@ -261,11 +274,24 @@ export const ChatPanelWrapper = ({ const { defaultCwd } = useChatPwds(); const relativeCwd = cwdToPath(cwd); const absoluteCwd = relativeCwd === '/' ? null : defaultCwd ? `${defaultCwd.replace(/\/$/, '')}${relativeCwd}` : null; - const { sessions, isLoading: sessionsLoading } = useClaudeSessions(absoluteCwd); + /* + The declared context wins over the derived path, and it has to. - const neverChose = panelSessionId === undefined; + A screen that owns its own directory — a plugin's chats, `/bots` — has a `cwd` of `~`, so the join + above yields null and this listed the caller's HOME instead. The panel then resumed the newest + conversation in the home directory, which on this machine meant `/bots` opening a months-old chat about + something else entirely. Reported the first time the screen was used. + */ + const { sessions, isLoading: sessionsLoading } = useClaudeSessions(absoluteCwd, { + chatContext: declared ? { context: declared.context, contextId: declared.contextId } : null, + }); + + // A screen that says which conversation is open wins over the panel's own memory of one. + const screenDecides = fromScreen !== undefined; + const neverChose = !screenDecides && panelSessionId === undefined; const selectedSessionId = - agentPanel.agent?.sessionKey ?? (neverChose ? sessions[0]?.id : (panelSessionId ?? undefined)); + agentPanel.agent?.sessionKey ?? + (screenDecides ? (fromScreen ?? undefined) : neverChose ? sessions[0]?.id : (panelSessionId ?? undefined)); /** * Fetch the transcript BEFORE the chat mounts.