/bots looks like Command HQ, and stops opening the wrong chat

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 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
2026-08-26 07:21:28 +00:00
co-authored by Claude Opus 5
parent f119bbdf93
commit 20d60cd3be
5 changed files with 139 additions and 26 deletions
@@ -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/<email>/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/<email>/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 }) => (
<div className="flex h-full min-h-0 flex-col">
<ChatPanelWrapper panelId={panelId} chatContext={CHAT_CONTEXT} />
</div>
);
const BotsChat = ({ panelId }: { panelId: string }) => {
const { sessionId } = useParams();
return (
<div className="flex h-full min-h-0 flex-col">
{/* `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. */}
<ChatPanelWrapper panelId={panelId} chatContext={CHAT_CONTEXT} sessionId={sessionId ?? null} />
</div>
);
};
const BotsChatHeader = ({ panelId }: { panelId: string }) => (
<ChatHeader panelId={panelId} chatContext={CHAT_CONTEXT} />
@@ -40,8 +51,11 @@ export const BotsScreen = () => {
<WorkspaceView
workspace={workspace}
locked
appTypes={{ allowed: ['bots-chat'], fallback: 'bots-chat' }}
components={{ 'bots-chat': { component: BotsChat, header: BotsChatHeader } }}
appTypes={{ allowed: ['bots-list', 'bots-chat'], fallback: 'bots-chat' }}
components={{
'bots-list': BotsList,
'bots-chat': { component: BotsChat, header: BotsChatHeader },
}}
/>
);
};