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:
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user