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.