/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:
@@ -109,6 +109,8 @@ export function App() {
|
|||||||
<Route path="/sendoff" element={<Dashboard.SendOffScreen />} />
|
<Route path="/sendoff" element={<Dashboard.SendOffScreen />} />
|
||||||
<Route path="/todos" element={<Dashboard.TodosScreen />} />
|
<Route path="/todos" element={<Dashboard.TodosScreen />} />
|
||||||
<Route path="/bots" element={<Dashboard.BotsScreen />} />
|
<Route path="/bots" element={<Dashboard.BotsScreen />} />
|
||||||
|
{/* Route pair: `/bots` is the new-bot state, `/bots/:sessionId` is one of them. */}
|
||||||
|
<Route path="/bots/:sessionId" element={<Dashboard.BotsScreen />} />
|
||||||
<Route path="/activity" element={<Dashboard.ActivityScreen />} />
|
<Route path="/activity" element={<Dashboard.ActivityScreen />} />
|
||||||
<Route path="/activity/:id" element={<Dashboard.ActivityScreen />} />
|
<Route path="/activity/:id" element={<Dashboard.ActivityScreen />} />
|
||||||
|
|
||||||
|
|||||||
@@ -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/<email>/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 (
|
||||||
|
<nav className="flex size-full flex-col">
|
||||||
|
<header className="flex items-center justify-between border-b px-3 py-2">
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{sessions.length} {sessions.length === 1 ? 'bot' : 'bots'}
|
||||||
|
</span>
|
||||||
|
{/* `/bots` with no id IS the new-bot state — the composer opens empty and the first message arms it. */}
|
||||||
|
<Link to={NEW} title="New bot" className="text-muted-foreground hover:text-foreground">
|
||||||
|
<Plus className="size-4" />
|
||||||
|
</Link>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="p-3 text-xs text-muted-foreground">Loading…</p>
|
||||||
|
) : sessions.length === 0 ? (
|
||||||
|
<div className="flex flex-1 flex-col items-center justify-center gap-2 p-4 text-center">
|
||||||
|
<Bot className="size-7 text-muted-foreground" />
|
||||||
|
<p className="text-sm text-muted-foreground">No bots yet.</p>
|
||||||
|
<p className="text-[11px] leading-relaxed text-muted-foreground">
|
||||||
|
Say what you want watched — “notify me when x.com is down” — and it arms itself.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="flex-1 overflow-auto">
|
||||||
|
{sessions.map((s) => (
|
||||||
|
<li key={s.id}>
|
||||||
|
<Link
|
||||||
|
to={`/bots/${s.id}`}
|
||||||
|
className={`flex items-start gap-2 border-b px-3 py-2 ${
|
||||||
|
s.id === sessionId ? 'bg-muted' : 'hover:bg-muted/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Bot className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block truncate text-sm">{s.title || '(unnamed)'}</span>
|
||||||
|
<span className="block truncate text-[11px] text-muted-foreground">
|
||||||
|
{new Date(s.updatedAt).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,33 +1,44 @@
|
|||||||
import type { LayoutNode } from 'officerdev';
|
import type { LayoutNode } from 'officerdev';
|
||||||
import { WorkspaceView, ChatPanelWrapper, ChatHeader } from 'officerdev';
|
import { WorkspaceView, ChatPanelWrapper, ChatHeader } from 'officerdev';
|
||||||
|
import { useParams } from 'react-router';
|
||||||
import { useAuthoredLayout } from 'state/useAuthoredLayout';
|
import { useAuthoredLayout } from 'state/useAuthoredLayout';
|
||||||
import { defaultLayout } from './defaultLayout';
|
import { defaultLayout } from './defaultLayout';
|
||||||
|
import { BotsList } from './BotsList';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `/bots` — one sentence arms a watch, and the conversation stays open to widen it.
|
* `/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
|
* A narrow list on the left, the conversation on the right, and the selection in the URL as a route pair:
|
||||||
* the owner says in a sentence, and the agent's job is to turn that into an armed background job without
|
* `/bots` is the new-bot state, `/bots/:sessionId` is one of them. That is the convention
|
||||||
* an interview. The instructions live in `$DATA_PATH/<email>/bots/CLAUDE.md`, written by the server on
|
* `docs/navigation-audit.md` sets and the one Command HQ already follows, so a bot is linkable and a
|
||||||
* every turn — see `api/bots/brief.ts`.
|
* 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
|
* 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
|
* `officerdev/chat` derives its context from the workspace and would land on none of it — it would list
|
||||||
* the prop for a screen that supplies its own chat panel — `ChatPanelWrapper` says so — and it carries the
|
* and resume the caller's HOME, which is exactly what this screen did on its first outing. `components` is
|
||||||
* header with it, so the picker lists bots rather than every chat on the machine.
|
* 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 CHAT_CONTEXT = { context: 'bots' } as const;
|
||||||
|
|
||||||
const BotsChat = ({ panelId }: { panelId: string }) => (
|
const BotsChat = ({ panelId }: { panelId: string }) => {
|
||||||
<div className="flex h-full min-h-0 flex-col">
|
const { sessionId } = useParams();
|
||||||
<ChatPanelWrapper panelId={panelId} chatContext={CHAT_CONTEXT} />
|
return (
|
||||||
</div>
|
<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 }) => (
|
const BotsChatHeader = ({ panelId }: { panelId: string }) => (
|
||||||
<ChatHeader panelId={panelId} chatContext={CHAT_CONTEXT} />
|
<ChatHeader panelId={panelId} chatContext={CHAT_CONTEXT} />
|
||||||
@@ -40,8 +51,11 @@ export const BotsScreen = () => {
|
|||||||
<WorkspaceView
|
<WorkspaceView
|
||||||
workspace={workspace}
|
workspace={workspace}
|
||||||
locked
|
locked
|
||||||
appTypes={{ allowed: ['bots-chat'], fallback: 'bots-chat' }}
|
appTypes={{ allowed: ['bots-list', 'bots-chat'], fallback: 'bots-chat' }}
|
||||||
components={{ 'bots-chat': { component: BotsChat, header: BotsChatHeader } }}
|
components={{
|
||||||
|
'bots-list': BotsList,
|
||||||
|
'bots-chat': { component: BotsChat, header: BotsChatHeader },
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import type { LayoutNode } from 'officerdev';
|
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
|
// No category pane. Commanders have one because a commander is a job somebody files; a bot is a sentence,
|
||||||
// the header's own picker is the list of bots. A second pane listing them would repeat the header and take
|
// and a filter over a handful of one-line watches would be furniture.
|
||||||
// width from the only thing on this screen anybody reads.
|
|
||||||
export const defaultLayout: LayoutNode = {
|
export const defaultLayout: LayoutNode = {
|
||||||
type: 'panel',
|
type: 'group',
|
||||||
id: 'bots-chat',
|
id: 'bots-root',
|
||||||
appType: 'bots-chat',
|
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 },
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -164,6 +164,18 @@ type ChatPanelWrapperProps = {
|
|||||||
* platform mounts, not a caller that could pass this.
|
* platform mounts, not a caller that could pass this.
|
||||||
*/
|
*/
|
||||||
chatContext?: { context: string; contextId?: string };
|
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.
|
* Called when a turn ends, with whether it ran any tools.
|
||||||
*
|
*
|
||||||
@@ -182,6 +194,7 @@ export const ChatPanelWrapper = ({
|
|||||||
panelId,
|
panelId,
|
||||||
promptPrefix,
|
promptPrefix,
|
||||||
chatContext: declared,
|
chatContext: declared,
|
||||||
|
sessionId: fromScreen,
|
||||||
onTurnComplete: alsoOnTurnComplete,
|
onTurnComplete: alsoOnTurnComplete,
|
||||||
}: ChatPanelWrapperProps) => {
|
}: ChatPanelWrapperProps) => {
|
||||||
const { workspace, cwd, root } = useWorkspace();
|
const { workspace, cwd, root } = useWorkspace();
|
||||||
@@ -261,11 +274,24 @@ export const ChatPanelWrapper = ({
|
|||||||
const { defaultCwd } = useChatPwds();
|
const { defaultCwd } = useChatPwds();
|
||||||
const relativeCwd = cwdToPath(cwd);
|
const relativeCwd = cwdToPath(cwd);
|
||||||
const absoluteCwd = relativeCwd === '/' ? null : defaultCwd ? `${defaultCwd.replace(/\/$/, '')}${relativeCwd}` : null;
|
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 =
|
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.
|
* Fetch the transcript BEFORE the chat mounts.
|
||||||
|
|||||||
Reference in New Issue
Block a user