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:
+37
-27
@@ -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
|
**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
|
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
|
`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
|
`usePageTitle` rule. **Now exercised against a running server** — a real commander created, categorised,
|
||||||
baseline, but nobody has clicked through it live. The composer creates a commander the same way `/chat`
|
armed as a watcher and observed running, its auto-generated name traced back to the actual process on this
|
||||||
always has (an ordinary turn with `context: 'command-hq'`); once the harness reports a transcript id the
|
machine (see "Auto-naming" and the `>/dev/null` finding below). The composer creates a commander the same
|
||||||
URL is replaced onto it, and if a category was selected when composing, a `PATCH` sets it client-side —
|
way `/chat` always has (an ordinary turn with `context: 'command-hq'`); once the harness reports a
|
||||||
`fileAsCommander` itself always files uncategorised, the same race `POST /commanders/new` resolves
|
transcript id the URL is replaced onto it, and if a category was selected when composing, a `PATCH` sets it
|
||||||
server-side for mobile, resolved here the client-side equivalent. Rename, category and disarm all go
|
client-side — `fileAsCommander` itself always files uncategorised, the same race `POST /commanders/new`
|
||||||
through Command HQ's own `PATCH` route, never `/chat`'s — the two are scoped to different directories. Tag
|
resolves server-side for mobile, resolved here the client-side equivalent. Rename, category, disarm and
|
||||||
UI is still deliberately absent, per "Tags — infrastructure now, UI not specified" below.
|
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
|
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
|
router (see `COMMS/command-hq/mobile.md`) — and as of this revision has what it asked for to create a
|
||||||
@@ -33,25 +36,26 @@ Base path `/command-hq`, `core` permission (every signed-in account, no grant to
|
|||||||
to the caller's own `user_id` regardless. Router: `src/servers/api/command-hq/router.ts` — read it directly
|
to the caller's own `user_id` regardless. Router: `src/servers/api/command-hq/router.ts` — read it directly
|
||||||
for exact request/response shapes; the table below is the map, not the full contract.
|
for exact request/response shapes; the table below is the map, not the full contract.
|
||||||
|
|
||||||
| Method | Path | Body | Notes |
|
| Method | Path | Body | Notes |
|
||||||
| ------ | ------------------------------------ | ------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ------ | ------------------------------------ | ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| GET | `/categories` | — | `{ categories: [{id,name,createdAt}] }` |
|
| GET | `/categories` | — | `{ categories: [{id,name,createdAt}] }` |
|
||||||
| POST | `/categories` | `{name}` | 409 on a duplicate name for this user |
|
| POST | `/categories` | `{name}` | 409 on a duplicate name for this user |
|
||||||
| PATCH | `/categories/:id` | `{name}` | rename |
|
| PATCH | `/categories/:id` | `{name}` | rename |
|
||||||
| DELETE | `/categories/:id` | — | uncategorises its commanders, does not delete them |
|
| DELETE | `/categories/:id` | — | uncategorises its commanders, does not delete them |
|
||||||
| GET | `/tags` | — | schema exists; no UI planned for v1 |
|
| GET | `/tags` | — | schema exists; no UI planned for v1 |
|
||||||
| POST | `/tags` | `{name}` | |
|
| POST | `/tags` | `{name}` | |
|
||||||
| DELETE | `/tags/:id` | — | |
|
| DELETE | `/tags/:id` | — | |
|
||||||
| GET | `/commanders[?category=<id>\|none]` | — | omitted = all, `none` = uncategorised bucket. See below |
|
| GET | `/commanders[?category=<id>\|none]` | — | omitted = all, `none` = uncategorised bucket. See below |
|
||||||
| POST | `/commanders/new` | `{prompt, displayText?, categoryId?}` | creates a commander; `prompt` required. `displayText ?? prompt` titles it, same precedence as the websocket. `-> {sessionId, cwd}` — `sessionId` is Claude's real transcript id, not a placeholder |
|
| POST | `/commanders/new` | `{prompt, displayText?, categoryId?}` | creates a commander; `prompt` required. `displayText ?? prompt` titles it, same precedence as the websocket. `-> {sessionId, cwd}` — `sessionId` is Claude's real transcript id, not a placeholder |
|
||||||
| GET | `/commanders/:sessionId` | — | Command HQ's own metadata only — NOT the transcript, see below |
|
| GET | `/commanders/:sessionId` | — | Command HQ's own metadata only — NOT the transcript, see below |
|
||||||
| GET | `/commanders/:sessionId/turns` | — | `{turns: [...]}` — the raw per-turn cost ledger, oldest first. What "cost over time" plots from |
|
| GET | `/commanders/:sessionId/turns` | — | `{turns: [...]}` — the raw per-turn cost ledger, oldest first. What "cost over time" plots from |
|
||||||
| GET | `/commanders/:sessionId/fires` | — | `{fires: [...]}` — every trigger, oldest first. The full history behind `watcherLastFiredAt` |
|
| 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` |
|
| 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) |
|
| PATCH | `/commanders/:sessionId` | `{categoryId?, name?, watcherArmed?}` | `name` proxies to `renameClaudeSession`; `watcherArmed: false` disarms (arming has no route — needs a live task id) |
|
||||||
| POST | `/purge` | — | sweeps rows whose transcript no longer resolves; returns `{removed}` |
|
| 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 | `/commanders/:sessionId/tags` | `{tagId}` | |
|
| POST | `/purge` | — | sweeps rows whose transcript no longer resolves; returns `{removed}` |
|
||||||
| DELETE | `/commanders/:sessionId/tags/:tagId` | — | |
|
| POST | `/commanders/:sessionId/tags` | `{tagId}` | |
|
||||||
|
| DELETE | `/commanders/:sessionId/tags/:tagId` | — | |
|
||||||
|
|
||||||
**A commander's conversation is `GET /chat/sessions/:id`, unchanged.** This router deliberately does not
|
**A commander's conversation is `GET /chat/sessions/:id`, unchanged.** This router deliberately does not
|
||||||
duplicate it — `GET /commanders/:sessionId` returns Command HQ's own metadata: `{sessionId, categoryId,
|
duplicate it — `GET /commanders/:sessionId` returns Command HQ's own metadata: `{sessionId, categoryId,
|
||||||
@@ -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
|
- 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
|
is a commander for life, and (given commanders live in their own cwd, never a `/chat` cwd) the two spaces
|
||||||
never actually overlap.
|
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
|
## Built on, mostly unchanged
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
listClaudeSessions,
|
listClaudeSessions,
|
||||||
loadClaudeSessionById,
|
loadClaudeSessionById,
|
||||||
renameClaudeSession,
|
renameClaudeSession,
|
||||||
|
deleteClaudeSession,
|
||||||
type ChatIdentity,
|
type ChatIdentity,
|
||||||
type ClaudeSessionSummary,
|
type ClaudeSessionSummary,
|
||||||
} from '../chat/claude-sessions';
|
} from '../chat/claude-sessions';
|
||||||
@@ -24,6 +25,7 @@ import {
|
|||||||
getCommandHqSession,
|
getCommandHqSession,
|
||||||
setCommandHqSessionCategory,
|
setCommandHqSessionCategory,
|
||||||
purgeCommandHqSessions,
|
purgeCommandHqSessions,
|
||||||
|
deleteCommandHqSession,
|
||||||
disarmCommandHqWatcher,
|
disarmCommandHqWatcher,
|
||||||
getCommandHqCostSummary,
|
getCommandHqCostSummary,
|
||||||
listCommandHqTurns,
|
listCommandHqTurns,
|
||||||
@@ -446,6 +448,34 @@ commandHqRouter.patch('/commanders/:sessionId', async (ctx) => {
|
|||||||
return ctx.json({ ok: true });
|
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
|
* POST /command-hq/purge
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useNavigate, useParams, useSearchParams } from 'react-router';
|
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 { toast } from '@/components/ui/sonner';
|
||||||
import { EditableTitle } from '@/components/EditableTitle';
|
import { EditableTitle } from '@/components/EditableTitle';
|
||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
@@ -8,7 +8,7 @@ import { errorText } from 'helpers/error-text';
|
|||||||
import type { ClaudeSessionDetail } from 'state/useClaudeSessions';
|
import type { ClaudeSessionDetail } from 'state/useClaudeSessions';
|
||||||
import { useChat, EmbeddableChat } from '../Chat';
|
import { useChat, EmbeddableChat } from '../Chat';
|
||||||
import type { ChatMessage } from '../Chat/types';
|
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 { useCommandHqCategories, useCommandHqCommander, useCommandHqCommanderActions } from './useCommandHq';
|
||||||
import { formatCommanderCost, formatTokens } from './format';
|
import { formatCommanderCost, formatTokens } from './format';
|
||||||
|
|
||||||
@@ -75,9 +75,13 @@ type MetaBarProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function MetaBar({ sessionId, title, isConnected, isGenerating, onDisconnect }: 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 { categories } = useCommandHqCategories();
|
||||||
const { commander } = useCommandHqCommander(sessionId);
|
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) => {
|
const commitRename = async (next: string) => {
|
||||||
try {
|
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 (
|
return (
|
||||||
<div className="shrink-0 border-b border-border bg-background/60">
|
<div className="shrink-0 border-b border-border bg-background/60">
|
||||||
<div className="flex items-center px-4 py-2">
|
<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" />
|
<Unplug className="h-3.5 w-3.5" />
|
||||||
</button>
|
</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>
|
||||||
</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 { formatDistanceToNowStrict } from 'date-fns';
|
||||||
import { Plus } from 'lucide-react';
|
import { Check, Plus, Trash2, X } from 'lucide-react';
|
||||||
import { categoryFromParam, commandHqNewPath, commandHqSessionPath } from './command-hq-routes';
|
import { toast } from '@/components/ui/sonner';
|
||||||
import { useCommandHqCommanders } from './useCommandHq';
|
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 { 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.
|
// 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
|
// 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} />;
|
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
|
* The delete control is a SIBLING of the card's `<Link>`, not a child of it — a `<button>` nested inside
|
||||||
to={to}
|
* an anchor is exactly the shape `docs/navigation-audit.md` says to avoid. The `<Link>` is stretched 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 ${
|
* cover the whole card (`absolute inset-0`, no explicit `z-index`) so the card stays click-anywhere; this
|
||||||
selected ? 'border-primary/60 ring-1 ring-primary/30' : 'border-border'
|
* control sits in its own stacking context (`z-10`) to intercept its own clicks first.
|
||||||
}`}
|
*/
|
||||||
>
|
const Card = ({ commander, to, selected, onDeleted }: CardProps) => {
|
||||||
<div className="flex items-center gap-2">
|
const [confirming, setConfirming] = useState(false);
|
||||||
<StatusDot running={commander.running} watcherArmed={commander.watcherArmed} />
|
const { deleteCommander } = useCommandHqCommanderActions(commander.id);
|
||||||
<span className="truncate text-sm font-medium">{commander.title}</span>
|
|
||||||
</div>
|
const confirmDelete = async () => {
|
||||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
setConfirming(false);
|
||||||
<span>
|
try {
|
||||||
{commander.cost.turnCount} turn{commander.cost.turnCount === 1 ? '' : 's'} ·{' '}
|
await deleteCommander.mutateAsync();
|
||||||
{formatTokens(commander.cost.inputTokens + commander.cost.outputTokens)} tok ·{' '}
|
onDeleted(commander.id);
|
||||||
{formatCommanderCost(commander.cost.totalUsd)}
|
} catch (err) {
|
||||||
</span>
|
toast.error(errorText(err, 'could not delete this commander'));
|
||||||
{commander.watcherLastFiredAt ? (
|
}
|
||||||
<span title={commander.watcherLastFiredAt}>
|
};
|
||||||
fired {formatDistanceToNowStrict(new Date(commander.watcherLastFiredAt), { addSuffix: true })}
|
|
||||||
|
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>
|
</span>
|
||||||
) : commander.watcherArmed ? (
|
{commander.watcherLastFiredAt ? (
|
||||||
<span>watching</span>
|
<span title={commander.watcherLastFiredAt}>
|
||||||
) : null}
|
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>
|
</div>
|
||||||
</Link>
|
);
|
||||||
);
|
};
|
||||||
|
|
||||||
export const CommandHqGrid = () => {
|
export const CommandHqGrid = () => {
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const category = categoryFromParam(searchParams.get('category'));
|
const category: CommandHqCategoryFilter = categoryFromParam(searchParams.get('category'));
|
||||||
const { sessionId } = useParams<{ sessionId: string }>();
|
const { sessionId } = useParams<{ sessionId: string }>();
|
||||||
const { commanders, isLoading } = useCommandHqCommanders(category);
|
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 (
|
return (
|
||||||
<div className="flex h-full flex-col overflow-y-auto">
|
<div className="flex h-full flex-col overflow-y-auto">
|
||||||
@@ -80,6 +147,7 @@ export const CommandHqGrid = () => {
|
|||||||
commander={commander}
|
commander={commander}
|
||||||
to={commandHqSessionPath(commander.id, category)}
|
to={commandHqSessionPath(commander.id, category)}
|
||||||
selected={commander.id === sessionId}
|
selected={commander.id === sessionId}
|
||||||
|
onDeleted={handleDeleted}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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.
|
* near-identical ones, since every caller needs the same invalidation regardless of which field changed.
|
||||||
*/
|
*/
|
||||||
export function useCommandHqCommanderActions(sessionId: string) {
|
export function useCommandHqCommanderActions(sessionId: string) {
|
||||||
const { patch } = useClient();
|
const { patch, delete: del } = useClient();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
|
||||||
const invalidate = () => {
|
const invalidate = () => {
|
||||||
@@ -91,5 +91,14 @@ export function useCommandHqCommanderActions(sessionId: string) {
|
|||||||
onSuccess: invalidate,
|
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