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 }) => (
-
+ {/* `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.