command hq: delete a commander, from the grid card and the detail bar
DELETE /command-hq/commanders/:sessionId — deletes the transcript itself via deleteClaudeSession (the same function DELETE /chat/sessions/:id calls, so a /clear chain goes with it, every part), then the command_hq_sessions row via deleteCommandHqSession (existing, exported, never previously called from a route — its docstring said as much: "no product path to this yet"). Deliberately real deletion, not an unfile-back-to-/chat path — there still isn't one of those, and this doesn't create one. Documented next to "The /chat <-> /command-hq relationship" so it doesn't read as contradicting the "a session never moves the other direction" rule directly above it. Frontend: both the grid card and the detail bar's meta row get an inline confirm (trash icon -> check/cancel), same shape as ChatHistory's own SessionRow delete. The grid card's delete control is a sibling of the card's <Link>, not nested inside it, per docs/navigation-audit.md — the card is a stretched full-cover Link (absolute inset-0, default stacking) with the delete control in its own stacking context (z-10) on top of it, so the card stays click-anywhere except where the trash icon sits. Deleting the commander currently open in the detail panel navigates back to the bare list rather than leaving it pointed at a 404. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+19
-9
@@ -12,14 +12,17 @@ their account predates that — see "A member could not reach it" under "Working
|
||||
**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.
|
||||
`usePageTitle` rule. **Now exercised against a running server** — a real commander created, categorised,
|
||||
armed as a watcher and observed running, its auto-generated name traced back to the actual process on this
|
||||
machine (see "Auto-naming" and the `>/dev/null` finding below). 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, disarm and
|
||||
delete all go through Command HQ's own routes, never `/chat`'s — the two are scoped to different
|
||||
directories. Delete asks for confirmation inline (grid card and detail bar both), same shape as `/chat`'s
|
||||
own session-row delete. 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
|
||||
@@ -34,7 +37,7 @@ to the caller's own `user_id` regardless. Router: `src/servers/api/command-hq/ro
|
||||
for exact request/response shapes; the table below is the map, not the full contract.
|
||||
|
||||
| Method | Path | Body | Notes |
|
||||
| ------ | ------------------------------------ | ------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| ------ | ------------------------------------ | ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| GET | `/categories` | — | `{ categories: [{id,name,createdAt}] }` |
|
||||
| POST | `/categories` | `{name}` | 409 on a duplicate name for this user |
|
||||
| PATCH | `/categories/:id` | `{name}` | rename |
|
||||
@@ -49,6 +52,7 @@ for exact request/response shapes; the table below is the map, not the full cont
|
||||
| GET | `/commanders/:sessionId/fires` | — | `{fires: [...]}` — every trigger, oldest first. The full history behind `watcherLastFiredAt` |
|
||||
| POST | `/commanders` | `{sessionId, categoryId?}` | files an EXISTING session; idempotent; 404 if the session isn't in the commanders directory. Not the creation path — see `/commanders/new` |
|
||||
| PATCH | `/commanders/:sessionId` | `{categoryId?, name?, watcherArmed?}` | `name` proxies to `renameClaudeSession`; `watcherArmed: false` disarms (arming has no route — needs a live task id) |
|
||||
| DELETE | `/commanders/:sessionId` | — | deletes the TRANSCRIPT (`/clear` chain and all) via `deleteClaudeSession`, then the row. Not reversible, and not the same thing `/purge` does — see "The `/chat` ↔ `/command-hq` relationship" below |
|
||||
| POST | `/purge` | — | sweeps rows whose transcript no longer resolves; returns `{removed}` |
|
||||
| POST | `/commanders/:sessionId/tags` | `{tagId}` | |
|
||||
| DELETE | `/commanders/:sessionId/tags/:tagId` | — | |
|
||||
@@ -346,6 +350,12 @@ an independent record of what sessions exist. Consequences the owner confirmed e
|
||||
- A session **never moves the other direction**. There is no unfile path, and none is planned — a commander
|
||||
is a commander for life, and (given commanders live in their own cwd, never a `/chat` cwd) the two spaces
|
||||
never actually overlap.
|
||||
- **`DELETE /commanders/:sessionId`, added 2026-08-18, is not an unfile path — it is real deletion**, and
|
||||
deliberately does not conflict with the bullet above: the commander does not become a `/chat` session, it
|
||||
stops existing. `deleteClaudeSession` (the SAME function `DELETE /chat/sessions/:id` calls) removes the
|
||||
transcript — a `/clear` chain and all — and `deleteCommandHqSession` removes the row. Once the confirm
|
||||
step lands, this is not recoverable: there is no undo, no trash, and the row disappearing is a side
|
||||
effect of the transcript being gone, not the other way around.
|
||||
|
||||
## Built on, mostly unchanged
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
listClaudeSessions,
|
||||
loadClaudeSessionById,
|
||||
renameClaudeSession,
|
||||
deleteClaudeSession,
|
||||
type ChatIdentity,
|
||||
type ClaudeSessionSummary,
|
||||
} from '../chat/claude-sessions';
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
getCommandHqSession,
|
||||
setCommandHqSessionCategory,
|
||||
purgeCommandHqSessions,
|
||||
deleteCommandHqSession,
|
||||
disarmCommandHqWatcher,
|
||||
getCommandHqCostSummary,
|
||||
listCommandHqTurns,
|
||||
@@ -446,6 +448,34 @@ commandHqRouter.patch('/commanders/:sessionId', async (ctx) => {
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /command-hq/commanders/:sessionId
|
||||
*
|
||||
* Deletes the commander outright: the transcript itself (via `deleteClaudeSession`, the same function
|
||||
* `DELETE /chat/sessions/:id` calls — a `/clear` chain goes with it, every part), then the
|
||||
* `command_hq_sessions` row. Unlike everywhere else in this file, this is NOT the "row disagrees with
|
||||
* disk, so hide it" pattern `purgeCommandHqSessions` exists for — it is the one place that makes the two
|
||||
* agree by deleting the thing itself, on purpose, because the owner asked to.
|
||||
*
|
||||
* A watcher still armed at the time of deletion is left as-is rather than disarmed first: the transcript
|
||||
* is gone either way, `armCommandHqWatcher`/`recordCommandHqWatcherFired` can no longer find a row to
|
||||
* update once this returns, and the running background process (if any) dies on its own the same way it
|
||||
* would if the harness process were killed out from under any other conversation — not a new failure
|
||||
* mode this route has to account for.
|
||||
*/
|
||||
commandHqRouter.delete('/commanders/:sessionId', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const who = await commandHqIdentity(user);
|
||||
const sessionId = ctx.req.param('sessionId');
|
||||
|
||||
const row = await getCommandHqSession(user.id, sessionId);
|
||||
if (!row) throw errors.NOT_FOUND('not a commander');
|
||||
|
||||
deleteClaudeSession(who, getCommandersDir(who.email), sessionId);
|
||||
await deleteCommandHqSession(user.id, sessionId);
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /command-hq/purge
|
||||
*
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router';
|
||||
import { Radar, Unplug } from 'lucide-react';
|
||||
import { Check, Radar, Trash2, Unplug, X } from 'lucide-react';
|
||||
import { toast } from '@/components/ui/sonner';
|
||||
import { EditableTitle } from '@/components/EditableTitle';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
@@ -8,7 +8,7 @@ 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 { categoryFromParam, commandHqListPath, commandHqSessionPath } from './command-hq-routes';
|
||||
import { useCommandHqCategories, useCommandHqCommander, useCommandHqCommanderActions } from './useCommandHq';
|
||||
import { formatCommanderCost, formatTokens } from './format';
|
||||
|
||||
@@ -75,9 +75,13 @@ type MetaBarProps = {
|
||||
};
|
||||
|
||||
function MetaBar({ sessionId, title, isConnected, isGenerating, onDisconnect }: MetaBarProps) {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const category = categoryFromParam(searchParams.get('category'));
|
||||
const { categories } = useCommandHqCategories();
|
||||
const { commander } = useCommandHqCommander(sessionId);
|
||||
const { setCategory, rename, disarmWatcher } = useCommandHqCommanderActions(sessionId);
|
||||
const { setCategory, rename, disarmWatcher, deleteCommander } = useCommandHqCommanderActions(sessionId);
|
||||
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
||||
|
||||
const commitRename = async (next: string) => {
|
||||
try {
|
||||
@@ -87,6 +91,16 @@ function MetaBar({ sessionId, title, isConnected, isGenerating, onDisconnect }:
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
setConfirmingDelete(false);
|
||||
try {
|
||||
await deleteCommander.mutateAsync();
|
||||
navigate(commandHqListPath(category));
|
||||
} catch (err) {
|
||||
toast.error(errorText(err, 'could not delete this commander'));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="shrink-0 border-b border-border bg-background/60">
|
||||
<div className="flex items-center px-4 py-2">
|
||||
@@ -118,6 +132,39 @@ function MetaBar({ sessionId, title, isConnected, isGenerating, onDisconnect }:
|
||||
<Unplug className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{confirmingDelete ? (
|
||||
<span className="ml-1 flex items-center gap-1">
|
||||
<span className="text-destructive">Delete?</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void confirmDelete()}
|
||||
title="Confirm delete — removes the conversation too, not just the Command HQ entry"
|
||||
aria-label="Confirm delete"
|
||||
className="rounded p-1 text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmingDelete(false)}
|
||||
title="Cancel"
|
||||
aria-label="Cancel delete"
|
||||
className="rounded p-1 opacity-50 hover:opacity-100"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmingDelete(true)}
|
||||
title="Delete commander"
|
||||
aria-label="Delete commander"
|
||||
className="ml-1 rounded p-1 hover:bg-muted hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { Link, useParams, useSearchParams } from 'react-router';
|
||||
import { useState } from 'react';
|
||||
import { Link, useNavigate, 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 { Check, Plus, Trash2, X } from 'lucide-react';
|
||||
import { toast } from '@/components/ui/sonner';
|
||||
import { errorText } from 'helpers/error-text';
|
||||
import { categoryFromParam, commandHqListPath, commandHqNewPath, commandHqSessionPath } from './command-hq-routes';
|
||||
import { useCommandHqCommanderActions, useCommandHqCommanders } from './useCommandHq';
|
||||
import { formatCommanderCost, formatTokens } from './format';
|
||||
import type { CommandHqCommander } from './types';
|
||||
import type { CommandHqCategoryFilter, 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
|
||||
@@ -22,16 +25,42 @@ function StatusDot({ running, watcherArmed }: { running: boolean; watcherArmed:
|
||||
return <span className={`inline-block h-2 w-2 shrink-0 rounded-full ${color}`} title={title} />;
|
||||
}
|
||||
|
||||
type CardProps = { commander: CommandHqCommander; to: string; selected: boolean };
|
||||
type CardProps = {
|
||||
commander: CommandHqCommander;
|
||||
to: string;
|
||||
selected: boolean;
|
||||
onDeleted: (sessionId: string) => void;
|
||||
};
|
||||
|
||||
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 ${
|
||||
/**
|
||||
* The delete control is a SIBLING of the card's `<Link>`, not a child of it — a `<button>` nested inside
|
||||
* an anchor is exactly the shape `docs/navigation-audit.md` says to avoid. The `<Link>` is stretched to
|
||||
* cover the whole card (`absolute inset-0`, no explicit `z-index`) so the card stays click-anywhere; this
|
||||
* control sits in its own stacking context (`z-10`) to intercept its own clicks first.
|
||||
*/
|
||||
const Card = ({ commander, to, selected, onDeleted }: CardProps) => {
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const { deleteCommander } = useCommandHqCommanderActions(commander.id);
|
||||
|
||||
const confirmDelete = async () => {
|
||||
setConfirming(false);
|
||||
try {
|
||||
await deleteCommander.mutateAsync();
|
||||
onDeleted(commander.id);
|
||||
} catch (err) {
|
||||
toast.error(errorText(err, 'could not delete this commander'));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group relative flex flex-col gap-2 rounded-xl border bg-background p-4 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">
|
||||
<Link to={to} className="absolute inset-0" aria-label={commander.title} />
|
||||
|
||||
<div className="flex items-center gap-2 pr-6">
|
||||
<StatusDot running={commander.running} watcherArmed={commander.watcherArmed} />
|
||||
<span className="truncate text-sm font-medium">{commander.title}</span>
|
||||
</div>
|
||||
@@ -49,14 +78,52 @@ const Card = ({ commander, to, selected }: CardProps) => (
|
||||
<span>watching</span>
|
||||
) : null}
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{confirming ? (
|
||||
<div className="absolute right-2 top-2 z-10 flex items-center gap-1 rounded-lg bg-background/95 p-0.5 shadow">
|
||||
<button
|
||||
onClick={() => void confirmDelete()}
|
||||
className="cursor-pointer rounded p-1 text-destructive hover:bg-destructive/10"
|
||||
title="Confirm delete — removes the conversation too, not just the Command HQ entry"
|
||||
aria-label={`Confirm deleting ${commander.title}`}
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirming(false)}
|
||||
className="cursor-pointer rounded p-1 opacity-50 hover:opacity-100"
|
||||
title="Cancel"
|
||||
aria-label="Cancel delete"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setConfirming(true)}
|
||||
className="absolute right-2 top-2 z-10 cursor-pointer rounded p-1 text-muted-foreground opacity-0 transition-opacity hover:text-destructive focus-visible:opacity-100 group-hover:opacity-100"
|
||||
title="Delete commander"
|
||||
aria-label={`Delete ${commander.title}`}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const CommandHqGrid = () => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const category = categoryFromParam(searchParams.get('category'));
|
||||
const category: CommandHqCategoryFilter = categoryFromParam(searchParams.get('category'));
|
||||
const { sessionId } = useParams<{ sessionId: string }>();
|
||||
const { commanders, isLoading } = useCommandHqCommanders(category);
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Deleting the one open in the detail panel leaves that panel pointed at a session that no longer
|
||||
// exists — back out to the bare list rather than let it try (and fail) to resolve a 404.
|
||||
const handleDeleted = (deletedId: string) => {
|
||||
if (deletedId === sessionId) navigate(commandHqListPath(category));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-y-auto">
|
||||
@@ -80,6 +147,7 @@ export const CommandHqGrid = () => {
|
||||
commander={commander}
|
||||
to={commandHqSessionPath(commander.id, category)}
|
||||
selected={commander.id === sessionId}
|
||||
onDeleted={handleDeleted}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -68,7 +68,7 @@ export function useCommandHqCommander(sessionId: string | undefined) {
|
||||
* near-identical ones, since every caller needs the same invalidation regardless of which field changed.
|
||||
*/
|
||||
export function useCommandHqCommanderActions(sessionId: string) {
|
||||
const { patch } = useClient();
|
||||
const { patch, delete: del } = useClient();
|
||||
const qc = useQueryClient();
|
||||
|
||||
const invalidate = () => {
|
||||
@@ -91,5 +91,14 @@ export function useCommandHqCommanderActions(sessionId: string) {
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
return { setCategory, rename, disarmWatcher };
|
||||
const deleteCommander = useMutation({
|
||||
mutationFn: () => del(`/command-hq/commanders/${sessionId}`),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['command-hq', 'commanders'] });
|
||||
// Removed, not invalidated — refetching a deleted commander's own detail query would just 404.
|
||||
qc.removeQueries({ queryKey: commanderKey(sessionId) });
|
||||
},
|
||||
});
|
||||
|
||||
return { setCategory, rename, disarmWatcher, deleteCommander };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user