From a9be3a92c97504f615e176ce1c3de7ce793f68a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Tue, 18 Aug 2026 09:41:13 +0000 Subject: [PATCH] =?UTF-8?q?command=20hq:=20web=20frontend=20v1=20=E2=80=94?= =?UTF-8?q?=20nav,=20grid,=20detail?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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/, 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 --- docs/command-hq.md | 36 ++- src/apps/officer-web/App.tsx | 2 + .../Dashboard/CommandHQ/CommandHqScreen.tsx | 25 ++ .../Dashboard/CommandHQ/defaultLayout.ts | 14 + .../Screens/Dashboard/CommandHQ/index.tsx | 1 + .../Screens/Dashboard/Layout/Dock.tsx | 2 + .../officer-web/Screens/Dashboard/index.tsx | 1 + src/apps/officer-web/state/usePageTitle.ts | 1 + .../src/AppRegistry/AppRegistry.tsx | 2 + .../apps/CommandHQ/CommandHqCategories.tsx | 117 ++++++++ .../src/apps/CommandHQ/CommandHqDetail.tsx | 251 ++++++++++++++++++ .../src/apps/CommandHQ/CommandHqGrid.tsx | 99 +++++++ .../src/apps/CommandHQ/command-hq-routes.ts | 35 +++ .../officerdev/src/apps/CommandHQ/format.ts | 6 + .../officerdev/src/apps/CommandHQ/index.ts | 41 +++ .../officerdev/src/apps/CommandHQ/types.ts | 67 +++++ .../src/apps/CommandHQ/useCommandHq.ts | 95 +++++++ src/workspaces/officerdev/src/index.ts | 11 + 18 files changed, 796 insertions(+), 10 deletions(-) create mode 100644 src/apps/officer-web/Screens/Dashboard/CommandHQ/CommandHqScreen.tsx create mode 100644 src/apps/officer-web/Screens/Dashboard/CommandHQ/defaultLayout.ts create mode 100644 src/apps/officer-web/Screens/Dashboard/CommandHQ/index.tsx create mode 100644 src/workspaces/officerdev/src/apps/CommandHQ/CommandHqCategories.tsx create mode 100644 src/workspaces/officerdev/src/apps/CommandHQ/CommandHqDetail.tsx create mode 100644 src/workspaces/officerdev/src/apps/CommandHQ/CommandHqGrid.tsx create mode 100644 src/workspaces/officerdev/src/apps/CommandHQ/command-hq-routes.ts create mode 100644 src/workspaces/officerdev/src/apps/CommandHQ/format.ts create mode 100644 src/workspaces/officerdev/src/apps/CommandHQ/index.ts create mode 100644 src/workspaces/officerdev/src/apps/CommandHQ/types.ts create mode 100644 src/workspaces/officerdev/src/apps/CommandHQ/useCommandHq.ts diff --git a/docs/command-hq.md b/docs/command-hq.md index c4b1c428..6e5dd18a 100644 --- a/docs/command-hq.md +++ b/docs/command-hq.md @@ -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. diff --git a/src/apps/officer-web/App.tsx b/src/apps/officer-web/App.tsx index 809e24a0..2158cef6 100644 --- a/src/apps/officer-web/App.tsx +++ b/src/apps/officer-web/App.tsx @@ -82,6 +82,8 @@ export function App() { } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/src/apps/officer-web/Screens/Dashboard/CommandHQ/CommandHqScreen.tsx b/src/apps/officer-web/Screens/Dashboard/CommandHQ/CommandHqScreen.tsx new file mode 100644 index 00000000..4a9874eb --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/CommandHQ/CommandHqScreen.tsx @@ -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('screens/command-hq', defaultLayout); + + return ( +
+ +
+ ); +}; diff --git a/src/apps/officer-web/Screens/Dashboard/CommandHQ/defaultLayout.ts b/src/apps/officer-web/Screens/Dashboard/CommandHQ/defaultLayout.ts new file mode 100644 index 00000000..74fba76d --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/CommandHQ/defaultLayout.ts @@ -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 }, + ], +}; diff --git a/src/apps/officer-web/Screens/Dashboard/CommandHQ/index.tsx b/src/apps/officer-web/Screens/Dashboard/CommandHQ/index.tsx new file mode 100644 index 00000000..85a5b91c --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/CommandHQ/index.tsx @@ -0,0 +1 @@ +export * from './CommandHqScreen'; diff --git a/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx b/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx index ccaf691b..33ae6cc7 100644 --- a/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx @@ -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' }, diff --git a/src/apps/officer-web/Screens/Dashboard/index.tsx b/src/apps/officer-web/Screens/Dashboard/index.tsx index a0244019..52597f3e 100644 --- a/src/apps/officer-web/Screens/Dashboard/index.tsx +++ b/src/apps/officer-web/Screens/Dashboard/index.tsx @@ -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'; diff --git a/src/apps/officer-web/state/usePageTitle.ts b/src/apps/officer-web/state/usePageTitle.ts index 7a00eb16..a26b83b9 100644 --- a/src/apps/officer-web/state/usePageTitle.ts +++ b/src/apps/officer-web/state/usePageTitle.ts @@ -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' }, diff --git a/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx b/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx index a384ddb7..3d8f1061 100644 --- a/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx +++ b/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx @@ -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, ]; diff --git a/src/workspaces/officerdev/src/apps/CommandHQ/CommandHqCategories.tsx b/src/workspaces/officerdev/src/apps/CommandHQ/CommandHqCategories.tsx new file mode 100644 index 00000000..93285ded --- /dev/null +++ b/src/workspaces/officerdev/src/apps/CommandHQ/CommandHqCategories.tsx @@ -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 ``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) => ( + + {active && } + {label} + {count !== undefined && {count}} + +); + +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 ( +
+
+
+ +
+
+
Command HQ
+
Watchers & long-running jobs
+
+
+ + + +
+ Categories + +
+ + +
+ ); +}; diff --git a/src/workspaces/officerdev/src/apps/CommandHQ/CommandHqDetail.tsx b/src/workspaces/officerdev/src/apps/CommandHQ/CommandHqDetail.tsx new file mode 100644 index 00000000..7451f588 --- /dev/null +++ b/src/workspaces/officerdev/src/apps/CommandHQ/CommandHqDetail.tsx @@ -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 ( +
+ +

Select a commander, or start a new one.

+
+ ); +} + +/** + * 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/` (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 ( +
+
+ New commander +
+ +
+ ); +} + +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 ( +
+
+
+ +
+
+ {!isConnected ? ( + + ) : isGenerating ? ( + + ) : ( + + )} + {isConnected && onDisconnect && ( + + )} +
+
+ +
+ + + {commander && ( + <> + + {commander.cost.turnCount} turn{commander.cost.turnCount === 1 ? '' : 's'} ·{' '} + {formatCommanderCost(commander.cost.totalUsd)} + + {commander.watcherArmed ? ( + + + watcher armed + + + ) : commander.watcherStoppedAt ? ( + watcher stopped + ) : null} + + )} +
+
+ ); +} + +/** 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(undefined); + const [resuming, setResuming] = useState(true); + + useEffect(() => { + let cancelled = false; + setResuming(true); + void (async () => { + try { + const detail = await client.get(`/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
Loading…
; + } + + return ( +
+ + +
+ ); +} + +export const CommandHqDetail = () => { + const { sessionId } = useParams<{ sessionId: string }>(); + const [searchParams] = useSearchParams(); + const category = categoryFromParam(searchParams.get('category')); + + if (!sessionId) return ; + if (sessionId === 'new') return ; + return ; +}; diff --git a/src/workspaces/officerdev/src/apps/CommandHQ/CommandHqGrid.tsx b/src/workspaces/officerdev/src/apps/CommandHQ/CommandHqGrid.tsx new file mode 100644 index 00000000..6688ce6d --- /dev/null +++ b/src/workspaces/officerdev/src/apps/CommandHQ/CommandHqGrid.tsx @@ -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 ; +} + +type CardProps = { commander: CommandHqCommander; to: string; selected: boolean }; + +const Card = ({ commander, to, selected }: CardProps) => ( + +
+ + {commander.title} +
+
+ + {commander.cost.turnCount} turn{commander.cost.turnCount === 1 ? '' : 's'} ·{' '} + {formatCommanderCost(commander.cost.totalUsd)} + + {commander.watcherLastFiredAt ? ( + + fired {formatDistanceToNowStrict(new Date(commander.watcherLastFiredAt), { addSuffix: true })} + + ) : commander.watcherArmed ? ( + watching + ) : null} +
+ +); + +export const CommandHqGrid = () => { + const [searchParams] = useSearchParams(); + const category = categoryFromParam(searchParams.get('category')); + const { sessionId } = useParams<{ sessionId: string }>(); + const { commanders, isLoading } = useCommandHqCommanders(category); + + return ( +
+
+ + {commanders.length} commander{commanders.length === 1 ? '' : 's'} + + + + New commander + +
+ +
+ {commanders.map((commander) => ( + + ))} +
+ + {!isLoading && commanders.length === 0 && ( +
+

No commanders here yet.

+ + Start one + +
+ )} +
+ ); +}; diff --git a/src/workspaces/officerdev/src/apps/CommandHQ/command-hq-routes.ts b/src/workspaces/officerdev/src/apps/CommandHQ/command-hq-routes.ts new file mode 100644 index 00000000..3e504363 --- /dev/null +++ b/src/workspaces/officerdev/src/apps/CommandHQ/command-hq-routes.ts @@ -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=`, `?category=none` + * for the uncategorised bucket, or omitted for all of them. Which commander is open is the path, exactly + * like `/chat/`: 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; +}; diff --git a/src/workspaces/officerdev/src/apps/CommandHQ/format.ts b/src/workspaces/officerdev/src/apps/CommandHQ/format.ts new file mode 100644 index 00000000..93d4596b --- /dev/null +++ b/src/workspaces/officerdev/src/apps/CommandHQ/format.ts @@ -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)}`; +} diff --git a/src/workspaces/officerdev/src/apps/CommandHQ/index.ts b/src/workspaces/officerdev/src/apps/CommandHQ/index.ts new file mode 100644 index 00000000..a979a032 --- /dev/null +++ b/src/workspaces/officerdev/src/apps/CommandHQ/index.ts @@ -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, + }, +]; diff --git a/src/workspaces/officerdev/src/apps/CommandHQ/types.ts b/src/workspaces/officerdev/src/apps/CommandHQ/types.ts new file mode 100644 index 00000000..f2d8a1b0 --- /dev/null +++ b/src/workspaces/officerdev/src/apps/CommandHQ/types.ts @@ -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; +}; diff --git a/src/workspaces/officerdev/src/apps/CommandHQ/useCommandHq.ts b/src/workspaces/officerdev/src/apps/CommandHQ/useCommandHq.ts new file mode 100644 index 00000000..86aaaced --- /dev/null +++ b/src/workspaces/officerdev/src/apps/CommandHQ/useCommandHq.ts @@ -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('/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(`/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 }; +} diff --git a/src/workspaces/officerdev/src/index.ts b/src/workspaces/officerdev/src/index.ts index da83e7b6..0213c902 100644 --- a/src/workspaces/officerdev/src/index.ts +++ b/src/workspaces/officerdev/src/index.ts @@ -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,