command hq: web frontend v1 — nav, grid, detail
Builds the Workspace/Panel screen the design doc's "Layout, v1" described but nothing had implemented yet: /command-hq and /command-hq/:sessionId, three always-mounted panels (officerdev/src/apps/CommandHQ/) — a flat category nav filtering the grid via ?category=, a commander grid reading GET /commanders, and a detail pane that is either an empty state, the new-commander composer, or the selected commander's resumed transcript. Follows the frontend route conventions in CLAUDE.md throughout: Workspace/Panel framework (locked, three fixed panels, no standalone screen), category and session both addressable in the URL rather than a channel, real <Link>s for every row and card, a route pair (/command-hq + /command-hq/:sessionId) the same shape as /chat's. The composer reuses the exact mechanism /chat already uses to create a commander — an ordinary turn with context: 'command-hq' via useChat/ EmbeddableChat — rather than the mobile-only POST /commanders/new HTTP route. Once the harness reports a real transcript id the URL is replaced onto it (useChat's own permalink writes /chat/<id>, which is the wrong screen here, so that's done by hand). fileAsCommander always files a new commander uncategorised — it has no way to know a category was selected in the composer — so if one was, a client-side PATCH sets it after the fact, the same race POST /commanders/new already resolves server-side for mobile, just resolved here on the client instead. The detail panel's transcript resume mirrors ChatPanelWrapper's fetch-then- mount pattern (GET /chat/sessions/:id first, then useChat with resumeSessionId + initialMessages + paginate) rather than reusing it directly — Command HQ's version is simpler (no dashboard/agent-panel concerns) and scopes rename/category/disarm through Command HQ's own PATCH route rather than /chat's, since a commander lives in a different directory than an ordinary chat session. Card status is the three-state read COMMS/command-hq/mobile-4.md proposed and the design record adopted: green running, amber armed-but-not-running (the watcherArmed && !running pair that makes a silently-dead watcher visible), grey neither. Registered in AppRegistry.tsx, re-exported from officerdev's barrel (route helpers + types, per CLAUDE.md's rule for anything an officer-web screen needs), a CORE_DOCK_ITEMS tile (not in DEFAULT_DOCK_PATHS — opt-in via the Dock settings screen, same as Jobs/Monitor/Activity), and a usePageTitle rule. bunx tsgo clean, suite at its 7 pre-existing baseline failures, none of them this feature's. NOT yet clicked through against a running server — the composer's redirect-on-real-id, the disarm button, the category-race PATCH have all typechecked and nothing more. That's the next real test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -20,6 +20,7 @@ import { appRegistryMetas as walletMetas } from '../apps/Wallet';
|
||||
import { appRegistryMetas as monitorMetas } from '../apps/SystemMonitor';
|
||||
import { appRegistryMetas as qrTransferMetas } from '../apps/QrTransfer';
|
||||
import { appRegistryMetas as davMetas } from '../apps/Dav';
|
||||
import { appRegistryMetas as commandHqMetas } from '../apps/CommandHQ';
|
||||
import type { QueryClient } from '@tanstack/react-query';
|
||||
import { globalQueryKey } from 'hooks/useGlobal';
|
||||
import { metasToRegistry } from './useAppRegistry';
|
||||
@@ -44,6 +45,7 @@ export const apps = [
|
||||
...monitorMetas,
|
||||
...qrTransferMetas,
|
||||
...davMetas,
|
||||
...commandHqMetas,
|
||||
...appStoreMetas,
|
||||
...pluginsMetas,
|
||||
];
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useSearchParams } from 'react-router';
|
||||
import { Plus, Radar } from 'lucide-react';
|
||||
import { toast } from '@/components/ui/sonner';
|
||||
import { errorText } from 'helpers/error-text';
|
||||
import { categoryFromParam, commandHqListPath } from './command-hq-routes';
|
||||
import { useCommandHqCategories } from './useCommandHq';
|
||||
import type { CommandHqCategoryFilter } from './types';
|
||||
|
||||
// Left panel of the /command-hq workspace — a flat category list, click to filter the grid. Deliberately
|
||||
// simple for v1: no nesting, no search, no drag-to-reorder — see `docs/command-hq.md` → "Layout, v1".
|
||||
// Entries are real `<Link>`s to `?category=…`, so back/forward and copy-paste work like any other filter.
|
||||
|
||||
type RowProps = {
|
||||
label: string;
|
||||
active: boolean;
|
||||
to: string;
|
||||
count?: number;
|
||||
};
|
||||
|
||||
const Row = ({ label, active, to, count }: RowProps) => (
|
||||
<Link
|
||||
to={to}
|
||||
className={`group relative flex items-center justify-between gap-2 rounded-lg px-3 py-2 text-left text-sm transition-colors ${
|
||||
active ? 'bg-primary/10 font-medium text-primary' : 'text-muted-foreground hover:bg-muted hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{active && <span className="absolute left-0 top-1/2 h-5 w-1 -translate-y-1/2 rounded-r-full bg-primary" />}
|
||||
<span className="truncate">{label}</span>
|
||||
{count !== undefined && <span className="text-xs text-muted-foreground">{count}</span>}
|
||||
</Link>
|
||||
);
|
||||
|
||||
export const CommandHqCategories = () => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const active: CommandHqCategoryFilter = categoryFromParam(searchParams.get('category'));
|
||||
const { categories, createCategory } = useCommandHqCategories();
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [name, setName] = useState('');
|
||||
|
||||
const submitNew = async () => {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) {
|
||||
setAdding(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await createCategory.mutateAsync(trimmed);
|
||||
setName('');
|
||||
setAdding(false);
|
||||
} catch (err) {
|
||||
toast.error(errorText(err, 'could not create category'));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-y-auto bg-muted/30">
|
||||
<div className="flex items-center gap-3 px-4 py-4">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-primary/10">
|
||||
<Radar className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold leading-tight">Command HQ</div>
|
||||
<div className="truncate text-xs text-muted-foreground">Watchers & long-running jobs</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="flex flex-col gap-0.5 px-2 pb-3">
|
||||
<Row label="All commanders" active={active === undefined} to={commandHqListPath(undefined)} />
|
||||
<Row label="Uncategorised" active={active === 'none'} to={commandHqListPath('none')} />
|
||||
</nav>
|
||||
|
||||
<div className="mt-2 flex items-center justify-between px-4 pb-1">
|
||||
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Categories</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAdding(true)}
|
||||
className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
title="New category"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav className="flex flex-col gap-0.5 px-2 pb-3">
|
||||
{categories.map((category) => (
|
||||
<Row
|
||||
key={category.id}
|
||||
label={category.name}
|
||||
active={active === category.id}
|
||||
to={commandHqListPath(category.id)}
|
||||
/>
|
||||
))}
|
||||
{adding && (
|
||||
<input
|
||||
autoFocus
|
||||
value={name}
|
||||
onChange={(ev) => setName(ev.target.value)}
|
||||
onBlur={submitNew}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === 'Enter') submitNew();
|
||||
if (ev.key === 'Escape') {
|
||||
setName('');
|
||||
setAdding(false);
|
||||
}
|
||||
}}
|
||||
placeholder="Category name"
|
||||
className="mx-1 rounded-lg border border-border bg-background px-2 py-1.5 text-sm outline-none focus:border-primary"
|
||||
/>
|
||||
)}
|
||||
{!adding && categories.length === 0 && (
|
||||
<div className="px-3 py-1 text-xs text-muted-foreground">No categories yet</div>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,251 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router';
|
||||
import { Radar, Unplug } from 'lucide-react';
|
||||
import { toast } from '@/components/ui/sonner';
|
||||
import { EditableTitle } from '@/components/EditableTitle';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { errorText } from 'helpers/error-text';
|
||||
import type { ClaudeSessionDetail } from 'state/useClaudeSessions';
|
||||
import { useChat, EmbeddableChat } from '../Chat';
|
||||
import type { ChatMessage } from '../Chat/types';
|
||||
import { categoryFromParam, commandHqSessionPath } from './command-hq-routes';
|
||||
import { useCommandHqCategories, useCommandHqCommander, useCommandHqCommanderActions } from './useCommandHq';
|
||||
import { formatCommanderCost } from './format';
|
||||
|
||||
// Right panel of the /command-hq workspace. Three states, chosen by the URL alone (no channel — panels
|
||||
// read the route directly, per `docs/navigation-audit.md`):
|
||||
//
|
||||
// no :sessionId → empty state, "select or start one"
|
||||
// :sessionId === 'new' → the composer that CREATES a commander (an ordinary chat turn with
|
||||
// `context: 'command-hq'`, per `docs/command-hq.md` → "How a commander is
|
||||
// created" — there is no separate "file this" step)
|
||||
// :sessionId is a real id → the commander's own transcript, resumed, plus a metadata bar
|
||||
|
||||
const CHAT_TAIL = 20;
|
||||
|
||||
function EmptyState() {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 text-center text-sm text-muted-foreground">
|
||||
<Radar className="h-8 w-8 opacity-40" />
|
||||
<p>Select a commander, or start a new one.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a brand-new commander. `useChat` mints the transcript id on the harness's first response
|
||||
* (`session:claude`) — once that lands, the URL is replaced onto the real id so a refresh resumes the
|
||||
* same conversation instead of starting a second one. `replaceUrl: false` because `useChat`'s own permalink
|
||||
* always writes `/chat/<id>` (see `writePermalink` in `useChat.ts`), which is the wrong screen here.
|
||||
*/
|
||||
function NewCommander({ category }: { category: number | 'none' | undefined }) {
|
||||
const navigate = useNavigate();
|
||||
const chat = useChat(undefined, undefined, { replaceUrl: false, context: 'command-hq' });
|
||||
const { setCategory } = useCommandHqCommanderActions(chat.sessionId ?? '');
|
||||
const [filed, setFiled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!chat.sessionId || filed) return;
|
||||
setFiled(true);
|
||||
// `fileAsCommander` (websocket.ts) always files with no category — it has no way to know one was
|
||||
// requested from here. The same race the `/commanders/new` HTTP route resolves server-side by always
|
||||
// running its UPDATE last; done client-side here for the same reason, since the web composer goes
|
||||
// through the websocket path instead.
|
||||
if (typeof category === 'number') void setCategory.mutateAsync(category).catch(() => {});
|
||||
navigate(commandHqSessionPath(chat.sessionId, category), { replace: true });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [chat.sessionId, filed]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-border bg-background/60 px-4 py-2 text-sm font-medium text-foreground/80">
|
||||
New commander
|
||||
</div>
|
||||
<EmbeddableChat chat={chat} sessionId={undefined} className="min-h-0 flex-1" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type MetaBarProps = {
|
||||
sessionId: string;
|
||||
title: string | undefined;
|
||||
isConnected: boolean;
|
||||
isGenerating: boolean;
|
||||
onDisconnect?: () => void;
|
||||
};
|
||||
|
||||
function MetaBar({ sessionId, title, isConnected, isGenerating, onDisconnect }: MetaBarProps) {
|
||||
const { categories } = useCommandHqCategories();
|
||||
const { commander } = useCommandHqCommander(sessionId);
|
||||
const { setCategory, rename, disarmWatcher } = useCommandHqCommanderActions(sessionId);
|
||||
|
||||
const commitRename = async (next: string) => {
|
||||
try {
|
||||
await rename.mutateAsync(next);
|
||||
} catch (err) {
|
||||
toast.error(errorText(err));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="shrink-0 border-b border-border bg-background/60">
|
||||
<div className="flex items-center px-4 py-2">
|
||||
<div className="flex-1 min-w-0 px-3 text-center">
|
||||
<EditableTitle
|
||||
value={title ?? 'Untitled commander'}
|
||||
onCommit={commitRename}
|
||||
ariaLabel="Commander title"
|
||||
hint="Click to rename this commander"
|
||||
className="block w-full cursor-text truncate text-sm font-medium text-foreground/80 transition-opacity hover:opacity-70"
|
||||
inputClassName="w-full border-b border-primary/40 bg-transparent text-center text-sm font-medium text-foreground/80 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
{!isConnected ? (
|
||||
<span className="inline-block h-2 w-2 rounded-full bg-destructive" />
|
||||
) : isGenerating ? (
|
||||
<span className="inline-block h-2 w-2 rounded-full bg-duck-orange animate-pulse" />
|
||||
) : (
|
||||
<span className="inline-block h-2 w-2 rounded-full bg-green-500" />
|
||||
)}
|
||||
{isConnected && onDisconnect && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDisconnect}
|
||||
title="End session — releases it so it can be resumed elsewhere"
|
||||
className="ml-1 rounded p-1 hover:bg-muted hover:text-destructive"
|
||||
>
|
||||
<Unplug className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 px-4 pb-2 text-xs text-muted-foreground">
|
||||
<select
|
||||
value={commander?.categoryId ?? ''}
|
||||
onChange={(ev) => setCategory.mutate(ev.target.value === '' ? null : Number(ev.target.value))}
|
||||
className="rounded border border-border bg-background px-1.5 py-0.5 text-xs"
|
||||
>
|
||||
<option value="">Uncategorised</option>
|
||||
{categories.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{commander && (
|
||||
<>
|
||||
<span>
|
||||
{commander.cost.turnCount} turn{commander.cost.turnCount === 1 ? '' : 's'} ·{' '}
|
||||
{formatCommanderCost(commander.cost.totalUsd)}
|
||||
</span>
|
||||
{commander.watcherArmed ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${commander.running ? 'bg-green-500' : 'bg-amber-500'}`} />
|
||||
watcher armed
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => disarmWatcher.mutate()}
|
||||
className="ml-1 underline decoration-dotted hover:text-foreground"
|
||||
>
|
||||
disarm
|
||||
</button>
|
||||
</span>
|
||||
) : commander.watcherStoppedAt ? (
|
||||
<span>watcher stopped</span>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A fetched transcript, ready to open on — mirrors `ChatPanelWrapper`'s `ResumeState`. */
|
||||
type ResumeState = {
|
||||
model: string | null;
|
||||
messages: ChatMessage[];
|
||||
total: number;
|
||||
offset: number;
|
||||
title: string | null;
|
||||
};
|
||||
|
||||
function ExistingCommander({ sessionId }: { sessionId: string }) {
|
||||
const client = useClient();
|
||||
const [resume, setResume] = useState<ResumeState | undefined>(undefined);
|
||||
const [resuming, setResuming] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setResuming(true);
|
||||
void (async () => {
|
||||
try {
|
||||
const detail = await client.get<ClaudeSessionDetail>(`/chat/sessions/${sessionId}?limit=${CHAT_TAIL}`);
|
||||
if (cancelled) return;
|
||||
setResume({
|
||||
// `officerModel`, not `detail.model` — see the same note in `ChatHistory/index.tsx`: the picker
|
||||
// offers CLI aliases, not the API model id the transcript records.
|
||||
model: detail.officerModel ?? null,
|
||||
messages: detail.messages as unknown as ChatMessage[],
|
||||
total: detail.total,
|
||||
offset: detail.offset,
|
||||
title: detail.title ?? null,
|
||||
});
|
||||
} catch (err) {
|
||||
if (!cancelled) toast.error(errorText(err, 'could not load this commander'));
|
||||
} finally {
|
||||
if (!cancelled) setResuming(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [sessionId]);
|
||||
|
||||
const chat = useChat(undefined, resume?.model ?? undefined, {
|
||||
replaceUrl: false,
|
||||
context: 'command-hq',
|
||||
...(resume
|
||||
? {
|
||||
resumeSessionId: sessionId,
|
||||
initialMessages: resume.messages,
|
||||
paginate: { sessionId, total: resume.total, initialOffset: resume.offset },
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
|
||||
if (resuming || !resume) {
|
||||
return <div className="flex h-full items-center justify-center text-sm text-muted-foreground">Loading…</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<MetaBar
|
||||
sessionId={sessionId}
|
||||
title={resume.title ?? undefined}
|
||||
isConnected={chat.isConnected}
|
||||
isGenerating={chat.isGenerating}
|
||||
onDisconnect={chat.disconnectSession}
|
||||
/>
|
||||
<EmbeddableChat
|
||||
key={sessionId}
|
||||
chat={chat}
|
||||
sessionId={undefined}
|
||||
panelId={`command-hq:${sessionId}`}
|
||||
className="min-h-0 flex-1"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const CommandHqDetail = () => {
|
||||
const { sessionId } = useParams<{ sessionId: string }>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const category = categoryFromParam(searchParams.get('category'));
|
||||
|
||||
if (!sessionId) return <EmptyState />;
|
||||
if (sessionId === 'new') return <NewCommander category={category} />;
|
||||
return <ExistingCommander key={sessionId} sessionId={sessionId} />;
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
import { Link, useParams, useSearchParams } from 'react-router';
|
||||
import { formatDistanceToNowStrict } from 'date-fns';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { categoryFromParam, commandHqNewPath, commandHqSessionPath } from './command-hq-routes';
|
||||
import { useCommandHqCommanders } from './useCommandHq';
|
||||
import { formatCommanderCost } from './format';
|
||||
import type { CommandHqCommander } from './types';
|
||||
|
||||
// Right-middle panel of the /command-hq workspace — one card per commander in the selected category.
|
||||
// Clicking a card opens the detail panel via the URL (`/command-hq/:sessionId`), never a channel — see
|
||||
// `docs/navigation-audit.md`.
|
||||
|
||||
/**
|
||||
* Three states, not two — the pair `COMMS/command-hq/mobile-4.md` proposed and the design record adopted:
|
||||
* green running, amber armed-but-not (the failure `watcherArmed && !running` exists to make visible), grey
|
||||
* neither. `running` alone cannot tell "never meant to watch" from "was watching and stopped"; `watcherArmed`
|
||||
* alone cannot tell "briefly between fire and re-arm" (normal) from "silently dead". Only the pair says it.
|
||||
*/
|
||||
function StatusDot({ running, watcherArmed }: { running: boolean; watcherArmed: boolean }) {
|
||||
const color = running ? 'bg-green-500' : watcherArmed ? 'bg-amber-500' : 'bg-muted-foreground/40';
|
||||
const title = running ? 'Running' : watcherArmed ? 'Armed, not currently running' : 'Not running';
|
||||
return <span className={`inline-block h-2 w-2 shrink-0 rounded-full ${color}`} title={title} />;
|
||||
}
|
||||
|
||||
type CardProps = { commander: CommandHqCommander; to: string; selected: boolean };
|
||||
|
||||
const Card = ({ commander, to, selected }: CardProps) => (
|
||||
<Link
|
||||
to={to}
|
||||
className={`flex flex-col gap-2 rounded-xl border bg-background p-4 text-left transition-colors hover:border-primary/40 hover:bg-muted/40 ${
|
||||
selected ? 'border-primary/60 ring-1 ring-primary/30' : 'border-border'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusDot running={commander.running} watcherArmed={commander.watcherArmed} />
|
||||
<span className="truncate text-sm font-medium">{commander.title}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>
|
||||
{commander.cost.turnCount} turn{commander.cost.turnCount === 1 ? '' : 's'} ·{' '}
|
||||
{formatCommanderCost(commander.cost.totalUsd)}
|
||||
</span>
|
||||
{commander.watcherLastFiredAt ? (
|
||||
<span title={commander.watcherLastFiredAt}>
|
||||
fired {formatDistanceToNowStrict(new Date(commander.watcherLastFiredAt), { addSuffix: true })}
|
||||
</span>
|
||||
) : commander.watcherArmed ? (
|
||||
<span>watching</span>
|
||||
) : null}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
|
||||
export const CommandHqGrid = () => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const category = categoryFromParam(searchParams.get('category'));
|
||||
const { sessionId } = useParams<{ sessionId: string }>();
|
||||
const { commanders, isLoading } = useCommandHqCommanders(category);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-y-auto">
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
||||
<span className="text-sm font-medium text-foreground/80">
|
||||
{commanders.length} commander{commanders.length === 1 ? '' : 's'}
|
||||
</span>
|
||||
<Link
|
||||
to={commandHqNewPath(category)}
|
||||
className="flex items-center gap-1.5 rounded-lg bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:opacity-90"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
New commander
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 p-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{commanders.map((commander) => (
|
||||
<Card
|
||||
key={commander.id}
|
||||
commander={commander}
|
||||
to={commandHqSessionPath(commander.id, category)}
|
||||
selected={commander.id === sessionId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!isLoading && commanders.length === 0 && (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 text-center text-sm text-muted-foreground">
|
||||
<p>No commanders here yet.</p>
|
||||
<Link
|
||||
to={commandHqNewPath(category)}
|
||||
className="rounded-lg bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:opacity-90"
|
||||
>
|
||||
Start one
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { CommandHqCategoryFilter } from './types';
|
||||
|
||||
/**
|
||||
* The Command HQ URL vocabulary — one place, mirroring `chat-routes.ts`'s reasoning: a link built in a
|
||||
* panel and one built anywhere else must not be free to disagree about the shape.
|
||||
*
|
||||
* Category is a FILTER on the grid, not a selection with its own page — `?category=<id>`, `?category=none`
|
||||
* for the uncategorised bucket, or omitted for all of them. Which commander is open is the path, exactly
|
||||
* like `/chat/<id>`: the same route renders nav + grid + detail together (see `CommandHqScreen`), so a
|
||||
* detail route here does not "destroy preview-on-list" the way `docs/navigation-audit.md` warns against —
|
||||
* there is no separate detail-only page to lose the grid to.
|
||||
*/
|
||||
|
||||
const categoryQuery = (category: CommandHqCategoryFilter): string =>
|
||||
category === undefined ? '' : `?category=${category}`;
|
||||
|
||||
/** The grid, filtered to one category or `'none'`. Omit for every commander. */
|
||||
export const commandHqListPath = (category?: CommandHqCategoryFilter): string =>
|
||||
`/command-hq${categoryQuery(category)}`;
|
||||
|
||||
/** The composer for a new commander. Category carries through so it lands filed where you started. */
|
||||
export const commandHqNewPath = (category?: CommandHqCategoryFilter): string =>
|
||||
`/command-hq/new${categoryQuery(category)}`;
|
||||
|
||||
/** An existing commander, deep-linkable — its own transcript id decides everything else. */
|
||||
export const commandHqSessionPath = (sessionId: string, category?: CommandHqCategoryFilter): string =>
|
||||
`/command-hq/${sessionId}${categoryQuery(category)}`;
|
||||
|
||||
/** Route param → filter value. `'none'` passes through; anything else is parsed as a category id. */
|
||||
export const categoryFromParam = (raw: string | null): CommandHqCategoryFilter => {
|
||||
if (raw === null) return undefined;
|
||||
if (raw === 'none') return 'none';
|
||||
const id = Number(raw);
|
||||
return Number.isFinite(id) ? id : undefined;
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
/** `$0`, `$0.0034`, `$1.20` — small enough that a flat 2-decimal format would round a real cost to nothing. */
|
||||
export function formatCommanderCost(usd: number): string {
|
||||
if (usd === 0) return '$0';
|
||||
if (usd < 0.01) return `$${usd.toFixed(4)}`;
|
||||
return `$${usd.toFixed(2)}`;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { AppRegistryMeta } from '../../AppRegistry';
|
||||
import { Radar, LayoutGrid, MessageSquare } from 'lucide-react';
|
||||
import { CommandHqCategories } from './CommandHqCategories';
|
||||
import { CommandHqGrid } from './CommandHqGrid';
|
||||
import { CommandHqDetail } from './CommandHqDetail';
|
||||
|
||||
export { CommandHqCategories, CommandHqGrid, CommandHqDetail };
|
||||
export { commandHqListPath, commandHqNewPath, commandHqSessionPath, categoryFromParam } from './command-hq-routes';
|
||||
export type {
|
||||
CommandHqCategory,
|
||||
CommandHqCategoryFilter,
|
||||
CommandHqCommander,
|
||||
CommandHqCommanderDetail,
|
||||
CommandHqCostSummary,
|
||||
CommandHqTurn,
|
||||
CommandHqFire,
|
||||
} from './types';
|
||||
|
||||
export const appRegistryMetas: AppRegistryMeta[] = [
|
||||
{
|
||||
key: 'command-hq-categories',
|
||||
name: 'Categories',
|
||||
icon: Radar,
|
||||
component: CommandHqCategories,
|
||||
availableOnPanel: false,
|
||||
},
|
||||
{
|
||||
key: 'command-hq-grid',
|
||||
name: 'Commanders',
|
||||
icon: LayoutGrid,
|
||||
component: CommandHqGrid,
|
||||
availableOnPanel: false,
|
||||
},
|
||||
{
|
||||
key: 'command-hq-detail',
|
||||
name: 'Commander',
|
||||
icon: MessageSquare,
|
||||
component: CommandHqDetail,
|
||||
availableOnPanel: false,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,67 @@
|
||||
// Mirrors the JSON shapes `src/servers/api/command-hq/router.ts` actually returns — hand-kept rather
|
||||
// than imported from officerdb, the same way every other frontend API type in this codebase is (dates
|
||||
// arrive as ISO strings over the wire, not `Date`, which is the one thing importing the server's own
|
||||
// type would get wrong silently).
|
||||
|
||||
export type CommandHqCategory = {
|
||||
id: number;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type CommandHqCostSummary = {
|
||||
turnCount: number;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalUsd: number;
|
||||
};
|
||||
|
||||
/** `category` query value: omitted (all), `'none'` (uncategorised), or a category id. */
|
||||
export type CommandHqCategoryFilter = number | 'none' | undefined;
|
||||
|
||||
/** One grid card — `ClaudeSessionSummary` plus what Command HQ knows about it. See `GET /commanders`. */
|
||||
export type CommandHqCommander = {
|
||||
id: string;
|
||||
title: string;
|
||||
cwd: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
messageCount: number;
|
||||
categoryId: number | null;
|
||||
tagIds: number[];
|
||||
running: boolean;
|
||||
watcherArmed: boolean;
|
||||
watcherArmedAt: string | null;
|
||||
watcherStoppedAt: string | null;
|
||||
watcherLastFiredAt: string | null;
|
||||
cost: CommandHqCostSummary;
|
||||
};
|
||||
|
||||
/** `GET /command-hq/commanders/:sessionId` — Command HQ's own metadata, not the transcript. */
|
||||
export type CommandHqCommanderDetail = {
|
||||
sessionId: string;
|
||||
categoryId: number | null;
|
||||
tagIds: number[];
|
||||
running: boolean;
|
||||
watcherArmed: boolean;
|
||||
watcherArmedAt: string | null;
|
||||
watcherStoppedAt: string | null;
|
||||
watcherLastFiredAt: string | null;
|
||||
cost: CommandHqCostSummary;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type CommandHqTurn = {
|
||||
id: number;
|
||||
fireId: number | null;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalUsd: number;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type CommandHqFire = {
|
||||
id: number;
|
||||
taskId: string;
|
||||
firedAt: string;
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import type { CommandHqCategory, CommandHqCategoryFilter, CommandHqCommander, CommandHqCommanderDetail } from './types';
|
||||
|
||||
const CATEGORIES_KEY = ['command-hq', 'categories'] as const;
|
||||
const commandersKey = (category: CommandHqCategoryFilter) => ['command-hq', 'commanders', category ?? 'all'] as const;
|
||||
const commanderKey = (sessionId: string) => ['command-hq', 'commander', sessionId] as const;
|
||||
|
||||
const EMPTY_CATEGORIES: CommandHqCategory[] = [];
|
||||
const EMPTY_COMMANDERS: CommandHqCommander[] = [];
|
||||
|
||||
/** The user's categories, flat — v1 has no nesting and no search, per `docs/command-hq.md` → "Layout, v1". */
|
||||
export function useCommandHqCategories() {
|
||||
const { get, post } = useClient();
|
||||
const qc = useQueryClient();
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: CATEGORIES_KEY,
|
||||
queryFn: () => get<{ categories: CommandHqCategory[] }>('/command-hq/categories').then((r) => r.categories),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const createCategory = useMutation({
|
||||
mutationFn: (name: string) => post<CommandHqCategory>('/command-hq/categories', { name }),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: CATEGORIES_KEY }),
|
||||
});
|
||||
|
||||
return { categories: data ?? EMPTY_CATEGORIES, isLoading, createCategory };
|
||||
}
|
||||
|
||||
/** The grid — every commander in `category`, or every commander at all when it is `undefined`. */
|
||||
export function useCommandHqCommanders(category: CommandHqCategoryFilter) {
|
||||
const { get } = useClient();
|
||||
|
||||
const { data, isLoading, refetch } = useQuery({
|
||||
queryKey: commandersKey(category),
|
||||
queryFn: () =>
|
||||
get<{ commanders: CommandHqCommander[] }>(
|
||||
`/command-hq/commanders${category === undefined ? '' : `?category=${category}`}`,
|
||||
).then((r) => r.commanders),
|
||||
// A watcher's `running`/cost/last-fired are live facts, not something a card should show stale for
|
||||
// minutes — short enough to feel current, long enough not to hammer the aggregate cost query per card.
|
||||
staleTime: 15_000,
|
||||
refetchInterval: 15_000,
|
||||
});
|
||||
|
||||
return { commanders: data ?? EMPTY_COMMANDERS, isLoading, refetch };
|
||||
}
|
||||
|
||||
/** One commander's own metadata — not the transcript, see the type's docstring. */
|
||||
export function useCommandHqCommander(sessionId: string | undefined) {
|
||||
const { get } = useClient();
|
||||
|
||||
const { data, isLoading, refetch } = useQuery({
|
||||
queryKey: commanderKey(sessionId ?? ''),
|
||||
queryFn: () => get<CommandHqCommanderDetail>(`/command-hq/commanders/${sessionId}`),
|
||||
enabled: !!sessionId,
|
||||
staleTime: 15_000,
|
||||
refetchInterval: 15_000,
|
||||
});
|
||||
|
||||
return { commander: data, isLoading, refetch };
|
||||
}
|
||||
|
||||
/**
|
||||
* Every mutation on a commander row — category, rename, disarm — invalidates the same two shapes: the
|
||||
* grid (whichever category it was showing) and this one commander's detail. One hook rather than three
|
||||
* near-identical ones, since every caller needs the same invalidation regardless of which field changed.
|
||||
*/
|
||||
export function useCommandHqCommanderActions(sessionId: string) {
|
||||
const { patch } = useClient();
|
||||
const qc = useQueryClient();
|
||||
|
||||
const invalidate = () => {
|
||||
qc.invalidateQueries({ queryKey: ['command-hq', 'commanders'] });
|
||||
qc.invalidateQueries({ queryKey: commanderKey(sessionId) });
|
||||
};
|
||||
|
||||
const setCategory = useMutation({
|
||||
mutationFn: (categoryId: number | null) => patch(`/command-hq/commanders/${sessionId}`, { categoryId }),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const rename = useMutation({
|
||||
mutationFn: (name: string) => patch(`/command-hq/commanders/${sessionId}`, { name }),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const disarmWatcher = useMutation({
|
||||
mutationFn: () => patch(`/command-hq/commanders/${sessionId}`, { watcherArmed: false }),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
return { setCategory, rename, disarmWatcher };
|
||||
}
|
||||
@@ -54,6 +54,17 @@ export type { SoulseekSectionId } from './apps/Soulseek/shared';
|
||||
export { DEFAULT_MONITOR_SCOPE, monitorScopePath, isMonitorScope } from './apps/SystemMonitor/shared';
|
||||
export type { MonitorScope } from './apps/SystemMonitor/shared';
|
||||
|
||||
// Same for /command-hq — category is a query-string filter, not a section, so the vocabulary carries it
|
||||
// through `?category=` on every path rather than a path segment.
|
||||
export { commandHqListPath, commandHqNewPath, commandHqSessionPath, categoryFromParam } from './apps/CommandHQ';
|
||||
export type {
|
||||
CommandHqCategory,
|
||||
CommandHqCategoryFilter,
|
||||
CommandHqCommander,
|
||||
CommandHqCommanderDetail,
|
||||
CommandHqCostSummary,
|
||||
} from './apps/CommandHQ';
|
||||
|
||||
// Same for /transmission.
|
||||
export {
|
||||
DEFAULT_TRANSMISSION_SECTION,
|
||||
|
||||
Reference in New Issue
Block a user