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:
2026-08-18 09:41:13 +00:00
co-authored by Claude Opus 5
parent 7b7d8bbf36
commit a9be3a92c9
18 changed files with 796 additions and 10 deletions
+26 -10
View File
@@ -5,14 +5,27 @@ it, kept current as answers landed.
**Status:** data model, the full REST API including both creation paths (web and `POST
/commanders/new`), built and mounted 2026-08-18 (`bunx tsgo` clean, suite at its 7 pre-existing baseline
failures — none of them this feature's). **Needs `bun db:push`** before any route answers with real data;
without it every route throws a Postgres "relation does not exist" 500. A member also needs
`confineUserTree` re-run once if their account predates tonight — see "A member could not reach it" under
"Working directory". **Frontend not built yet on the web side.** The mobile side is further along —
`apps/commandhq` exists and typechecks against this router (see `COMMS/command-hq/mobile.md`) — and as of
this revision has what it asked for to create a commander, though it has not yet been exercised against a
running server on either side. Every claim in this file is checked against the code, not aspirational —
see "API reference" for what a client can call today.
failures — none of them this feature's). `bun db:push` and a restart of `officer` have both since run
against the dev machine, so the backend is live. A member still needs `confineUserTree` re-run once if
their account predates that — see "A member could not reach it" under "Working directory".
**Web frontend v1 built 2026-08-18**, same session: `/command-hq` and `/command-hq/:sessionId`, three
panels per "Layout, v1" below (`command-hq-categories`, `command-hq-grid`, `command-hq-detail`) under
`officerdev/src/apps/CommandHQ/`, registered in `AppRegistry.tsx`, a `CORE_DOCK_ITEMS` tile, and a
`usePageTitle` rule. **Not yet exercised against a running server** — typechecks and the suite is at
baseline, but nobody has clicked through it live. The composer creates a commander the same way `/chat`
always has (an ordinary turn with `context: 'command-hq'`); once the harness reports a transcript id the
URL is replaced onto it, and if a category was selected when composing, a `PATCH` sets it client-side —
`fileAsCommander` itself always files uncategorised, the same race `POST /commanders/new` resolves
server-side for mobile, resolved here the client-side equivalent. Rename, category and disarm all go
through Command HQ's own `PATCH` route, never `/chat`'s — the two are scoped to different directories. Tag
UI is still deliberately absent, per "Tags — infrastructure now, UI not specified" below.
The mobile side is further along on live exercise — `apps/commandhq` exists and typechecks against this
router (see `COMMS/command-hq/mobile.md`) — and as of this revision has what it asked for to create a
commander, though it has not yet been exercised against a running server on either side either. Every claim
in this file is checked against the code, not aspirational — see "API reference" for what a client can call
today.
## API reference — build against this
@@ -527,5 +540,8 @@ scope for tonight."_
## Still open
Nothing blocking as of this revision. `POST /command-hq/commanders/new` is built — see "How a commander is
created" — with `prompt` required, confirmed with the owner 2026-08-18. Neither path (web or mobile) has
been exercised against a running server yet; that is the next real test, not a design question.
created" — with `prompt` required, confirmed with the owner 2026-08-18. The web frontend v1 is built too
(see the status line at the top). **Neither client has actually been clicked through against a running
server yet** — that is the next real test, not a design question, and it is the thing most likely to turn
up something this file gets wrong: the composer's redirect-on-real-id, the watcher-disarm button, the
category-race PATCH, none of them have run once outside a typecheck.
+2
View File
@@ -82,6 +82,8 @@ export function App() {
<Route path="/contacts" element={<Dashboard.ContactsScreen />} />
<Route path="/soulseek" element={<Dashboard.SoulseekScreen />} />
<Route path="/soulseek/:section" element={<Dashboard.SoulseekScreen />} />
<Route path="/command-hq" element={<Dashboard.CommandHqScreen />} />
<Route path="/command-hq/:sessionId" element={<Dashboard.CommandHqScreen />} />
<Route path="/photos" element={<Dashboard.PhotosScreen />} />
<Route path="/photos/:section" element={<Dashboard.PhotosScreen />} />
<Route path="/app-store" element={<Dashboard.AppStoreScreen />} />
@@ -0,0 +1,25 @@
import type { LayoutNode } from 'officerdev';
import { WorkspaceView } from 'officerdev';
import { useDashboardState } from 'state/useDashboardState';
import { defaultLayout } from './defaultLayout';
// /command-hq: nav + grid + detail, all three always mounted (like /chat), selection carried by the URL
// — `?category=` for the nav filter, `:sessionId` for the open commander. See `docs/command-hq.md` →
// "Route, surface, placement" and "Layout, v1".
export const CommandHqScreen = () => {
const workspace = useDashboardState<LayoutNode>('screens/command-hq', defaultLayout);
return (
<div className="h-full w-full pt-2">
<WorkspaceView
workspace={workspace}
locked
appTypes={{
allowed: ['command-hq-categories', 'command-hq-grid', 'command-hq-detail'],
fallback: 'command-hq-detail',
}}
/>
</div>
);
};
@@ -0,0 +1,14 @@
import type { LayoutNode } from 'officerdev';
// Three columns, per `docs/command-hq.md` → "Layout, v1": category nav, commander grid, detail pane. The
// detail pane gets the most room because it is a live chat transcript once something is selected.
export const defaultLayout: LayoutNode = {
type: 'group',
id: 'command-hq-root',
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: 'command-hq-categories', appType: 'command-hq-categories' }, size: 16 },
{ node: { type: 'panel', id: 'command-hq-grid', appType: 'command-hq-grid' }, size: 34 },
{ node: { type: 'panel', id: 'command-hq-detail', appType: 'command-hq-detail' }, size: 50 },
],
};
@@ -0,0 +1 @@
export * from './CommandHqScreen';
@@ -154,6 +154,7 @@ import {
GitBranch,
Store,
Puzzle,
Radar,
} from 'lucide-react';
/**
@@ -171,6 +172,7 @@ export const CORE_DOCK_ITEMS: DockItem[] = [
{ label: 'Home', to: '/', icon: Home, color: '#f59e0b' },
{ label: 'Files', to: '/files', icon: FolderOpen, color: '#fbbf24' },
{ label: 'Chat', to: '/chat', icon: MessageCircle, color: '#60a5fa' },
{ label: 'Command HQ', to: '/command-hq', icon: Radar, color: '#f43f5e' },
{ label: 'Gitea', to: '/gitea', icon: GitBranch, color: '#609926' },
{ label: 'Editor', to: '/code-editor', icon: Code, color: '#a78bfa' },
{ label: 'Jobs', to: '/jobs', icon: Workflow, color: '#14b8a6' },
@@ -14,6 +14,7 @@ export * from './Files';
export * from './Calendar';
export * from './Contacts';
export * from './Soulseek';
export * from './CommandHQ';
export * from './Photos';
export * from './Jellyfin';
export * from './Transmission';
@@ -26,6 +26,7 @@ const RULES: TitleRule[] = [
{ match: (p) => p.startsWith('/photos'), title: 'Photos' },
{ match: (p) => p.startsWith('/jellyfin'), title: 'Video' },
{ match: (p) => p.startsWith('/soulseek'), title: 'Soulseek' },
{ match: (p) => p.startsWith('/command-hq'), title: 'Command HQ' },
{ match: (p) => p.startsWith('/transmission'), title: 'Transmission' },
{ match: (p) => p.startsWith('/gitea'), title: 'Gitea' },
{ match: (p) => p.startsWith('/invoices'), title: 'Invoices' },
@@ -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 &amp; 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 };
}
+11
View File
@@ -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,