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:
2026-08-18 10:46:31 +00:00
co-authored by Claude Opus 5
parent a335fad9a6
commit f77a41d26a
5 changed files with 228 additions and 64 deletions
+30
View File
@@ -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,41 +25,105 @@ 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 ${
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'} ·{' '}
{formatTokens(commander.cost.inputTokens + commander.cost.outputTokens)} tok ·{' '}
{formatCommanderCost(commander.cost.totalUsd)}
</span>
{commander.watcherLastFiredAt ? (
<span title={commander.watcherLastFiredAt}>
fired {formatDistanceToNowStrict(new Date(commander.watcherLastFiredAt), { addSuffix: true })}
/**
* 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'
}`}
>
<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>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>
{commander.cost.turnCount} turn{commander.cost.turnCount === 1 ? '' : 's'} ·{' '}
{formatTokens(commander.cost.inputTokens + commander.cost.outputTokens)} tok ·{' '}
{formatCommanderCost(commander.cost.totalUsd)}
</span>
) : commander.watcherArmed ? (
<span>watching</span>
) : null}
{commander.watcherLastFiredAt ? (
<span title={commander.watcherLastFiredAt}>
fired {formatDistanceToNowStrict(new Date(commander.watcherLastFiredAt), { addSuffix: true })}
</span>
) : commander.watcherArmed ? (
<span>watching</span>
) : null}
</div>
{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>
</Link>
);
);
};
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 };
}