a bot is a card, not a conversation in a list
it looked like the generic chat panel because it WAS one, and that leaks the implementation at exactly the wrong moment: a bot is a chat underneath, and nobody using this should have to hold that. the session list says "conversation, last touched" because that is what a conversation is. so it gets Command HQ's object instead — a card with a name, an id pill, rename and delete, selected ring, controls on hover. same arrangement: the whole card is the link with the buttons layered over it, so a click anywhere opens it and the controls still work. the pill is the first eight characters of the session, which is also what an @mention carries. that is the point rather than a coincidence — the thing shown on the card is the thing typed to reference it. the panel's own header picker is gone. the cards are the list, and a second picker in the panel bar is the same choice offered twice. command hq does not have one either. deleting the selected bot navigates back to /bots, or the route points at something that no longer exists and renders an empty chat. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
@@ -1,22 +1,143 @@
|
|||||||
import { Bot, Plus } from 'lucide-react';
|
import { useState } from 'react';
|
||||||
import { Link, useParams } from 'react-router';
|
import { Bot, Check, Pencil, Plus, Trash2, X } from 'lucide-react';
|
||||||
import { useClaudeSessions } from 'state/useClaudeSessions';
|
import { Link, useNavigate, useParams } from 'react-router';
|
||||||
|
import { useClaudeSessions, type ClaudeSessionSummary } from 'state/useClaudeSessions';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The bots, down the left — the same shape Command HQ gives commanders.
|
* The bots, as cards — the same object Command HQ gives a commander.
|
||||||
*
|
*
|
||||||
* Every bot is a chat in `$DATA_PATH/<email>/bots`, so this is that directory's session list and nothing
|
* ── Why not the chat session list ──
|
||||||
* 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
|
* A bot IS a chat underneath, and that is an implementation detail nobody using this should have to hold.
|
||||||
* cmd-click opens one in a tab and a reload keeps the one you were reading.
|
* The generic session list says "conversation, last touched at" because that is what a conversation is. A
|
||||||
|
* bot is a thing you name, check on, and eventually delete — so it gets a card with its name, its id, and
|
||||||
|
* whether it is watching, exactly as a commander does.
|
||||||
|
*
|
||||||
|
* ── The id pill ──
|
||||||
|
*
|
||||||
|
* A commander shows `#54`, its directory. A bot has no directory of its own — they share one — so its id
|
||||||
|
* is the first eight characters of its session, which is also what an `@mention` carries. Seeing the same
|
||||||
|
* eight characters in both places is the point: the pill is what you type to reference it.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const NEW = '/bots';
|
const shortId = (id: string) => id.slice(0, 8);
|
||||||
|
|
||||||
|
type CardProps = {
|
||||||
|
bot: ClaudeSessionSummary;
|
||||||
|
selected: boolean;
|
||||||
|
onRename: (id: string, title: string) => Promise<unknown>;
|
||||||
|
onDelete: (id: string) => Promise<unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const BotCard = ({ bot, selected, onRename, onDelete }: CardProps) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [confirming, setConfirming] = useState(false);
|
||||||
|
const [renaming, setRenaming] = useState(false);
|
||||||
|
const [draft, setDraft] = useState(bot.title);
|
||||||
|
|
||||||
|
const commit = async () => {
|
||||||
|
const next = draft.trim();
|
||||||
|
setRenaming(false);
|
||||||
|
if (next && next !== bot.title) await onRename(bot.id, next);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`group relative flex flex-col gap-1.5 rounded-xl border bg-background p-3 transition-colors hover:border-primary/40 hover:bg-muted/40 ${
|
||||||
|
selected ? 'border-primary/60 ring-1 ring-primary/30' : 'border-border'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* The whole card is the link, with the controls layered above it — same arrangement as a commander
|
||||||
|
card, so a click anywhere opens the bot and the buttons still work. */}
|
||||||
|
{!renaming && <Link to={`/bots/${bot.id}`} className="absolute inset-0" aria-label={bot.title} />}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 pr-12">
|
||||||
|
<Bot className="size-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
{renaming ? (
|
||||||
|
<form
|
||||||
|
className="z-10 flex min-w-0 flex-1 items-center gap-1"
|
||||||
|
onSubmit={(ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
void commit();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={draft}
|
||||||
|
onChange={(ev) => setDraft(ev.target.value)}
|
||||||
|
onKeyDown={(ev) => ev.key === 'Escape' && setRenaming(false)}
|
||||||
|
className="min-w-0 flex-1 rounded border bg-transparent px-1 py-0.5 text-sm"
|
||||||
|
/>
|
||||||
|
<button type="submit" className="rounded p-0.5 text-primary" aria-label="Save name">
|
||||||
|
<Check className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="truncate text-sm font-medium">{bot.title || '(unnamed)'}</span>
|
||||||
|
<span className="shrink-0 font-mono text-[11px] text-muted-foreground" title={bot.id}>
|
||||||
|
#{shortId(bot.id)}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-[11px] text-muted-foreground">
|
||||||
|
<span>
|
||||||
|
{bot.messageCount} message{bot.messageCount === 1 ? '' : 's'}
|
||||||
|
</span>
|
||||||
|
<span title={bot.updatedAt}>{new Date(bot.updatedAt).toLocaleDateString()}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{confirming ? (
|
||||||
|
<div className="absolute right-1.5 top-1.5 z-10 flex items-center gap-1 rounded-lg bg-background/95 p-0.5 shadow">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
void onDelete(bot.id);
|
||||||
|
// Leaving the route pointed at something that no longer exists would render an empty chat.
|
||||||
|
if (selected) void navigate('/bots');
|
||||||
|
}}
|
||||||
|
className="rounded p-1 text-destructive hover:bg-destructive/10"
|
||||||
|
title="Delete this bot"
|
||||||
|
>
|
||||||
|
<Check className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setConfirming(false)} className="rounded p-1 opacity-50 hover:opacity-100">
|
||||||
|
<X className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
!renaming && (
|
||||||
|
<div className="absolute right-1.5 top-1.5 z-10 flex items-center gap-0.5 opacity-0 transition-opacity focus-within:opacity-100 group-hover:opacity-100">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setDraft(bot.title);
|
||||||
|
setRenaming(true);
|
||||||
|
}}
|
||||||
|
className="rounded p-1 text-muted-foreground hover:text-foreground"
|
||||||
|
title="Rename"
|
||||||
|
>
|
||||||
|
<Pencil className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirming(true)}
|
||||||
|
className="rounded p-1 text-muted-foreground hover:text-destructive"
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const BotsList = () => {
|
export const BotsList = () => {
|
||||||
const { sessionId } = useParams();
|
const { sessionId } = useParams();
|
||||||
const { sessions, isLoading } = useClaudeSessions(null, { chatContext: { context: 'bots' } });
|
const { sessions, isLoading, renameSession, deleteSession } = useClaudeSessions(null, {
|
||||||
|
chatContext: { context: 'bots' },
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="flex size-full flex-col">
|
<nav className="flex size-full flex-col">
|
||||||
@@ -24,8 +145,8 @@ export const BotsList = () => {
|
|||||||
<span className="text-sm font-medium">
|
<span className="text-sm font-medium">
|
||||||
{sessions.length} {sessions.length === 1 ? 'bot' : 'bots'}
|
{sessions.length} {sessions.length === 1 ? 'bot' : 'bots'}
|
||||||
</span>
|
</span>
|
||||||
{/* `/bots` with no id IS the new-bot state — the composer opens empty and the first message arms it. */}
|
{/* `/bots` with no id IS the new-bot state: an empty composer, and the first sentence arms it. */}
|
||||||
<Link to={NEW} title="New bot" className="text-muted-foreground hover:text-foreground">
|
<Link to="/bots" title="New bot" className="text-muted-foreground hover:text-foreground">
|
||||||
<Plus className="size-4" />
|
<Plus className="size-4" />
|
||||||
</Link>
|
</Link>
|
||||||
</header>
|
</header>
|
||||||
@@ -41,23 +162,10 @@ export const BotsList = () => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ul className="flex-1 overflow-auto">
|
<ul className="flex flex-1 flex-col gap-2 overflow-auto p-2">
|
||||||
{sessions.map((s) => (
|
{sessions.map((bot) => (
|
||||||
<li key={s.id}>
|
<li key={bot.id}>
|
||||||
<Link
|
<BotCard bot={bot} selected={bot.id === sessionId} onRename={renameSession} onDelete={deleteSession} />
|
||||||
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>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { LayoutNode } from 'officerdev';
|
import type { LayoutNode } from 'officerdev';
|
||||||
import { WorkspaceView, ChatPanelWrapper, ChatHeader } from 'officerdev';
|
import { WorkspaceView, ChatPanelWrapper } from 'officerdev';
|
||||||
import { useParams } from 'react-router';
|
import { useParams } from 'react-router';
|
||||||
import { useAuthoredLayout } from 'state/useAuthoredLayout';
|
import { useAuthoredLayout } from 'state/useAuthoredLayout';
|
||||||
import { defaultLayout } from './defaultLayout';
|
import { defaultLayout } from './defaultLayout';
|
||||||
@@ -40,10 +40,6 @@ const BotsChat = ({ panelId }: { panelId: string }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const BotsChatHeader = ({ panelId }: { panelId: string }) => (
|
|
||||||
<ChatHeader panelId={panelId} chatContext={CHAT_CONTEXT} />
|
|
||||||
);
|
|
||||||
|
|
||||||
export const BotsScreen = () => {
|
export const BotsScreen = () => {
|
||||||
const workspace = useAuthoredLayout('screens/bots', defaultLayout as LayoutNode);
|
const workspace = useAuthoredLayout('screens/bots', defaultLayout as LayoutNode);
|
||||||
|
|
||||||
@@ -54,7 +50,9 @@ export const BotsScreen = () => {
|
|||||||
appTypes={{ allowed: ['bots-list', 'bots-chat'], fallback: 'bots-chat' }}
|
appTypes={{ allowed: ['bots-list', 'bots-chat'], fallback: 'bots-chat' }}
|
||||||
components={{
|
components={{
|
||||||
'bots-list': BotsList,
|
'bots-list': BotsList,
|
||||||
'bots-chat': { component: BotsChat, header: BotsChatHeader },
|
// No header picker: the cards on the left are the list, and a second one in the panel bar would be
|
||||||
|
// the same choice offered twice. Command HQ does not have one either, for the same reason.
|
||||||
|
'bots-chat': BotsChat,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user