Command HQ shows what plugins offer, and what has not been started
the UI half. a row per plugin that ships commanders, below the owner's own
categories and separated from them — because they are not the same kind of thing.
a category is the owner's filing and they may rename it; a plugin is where
something came from and they may not. mixed into one list, "av-club" reads as a
folder somebody made, and the first thing you do with a folder you made is rename
it.
offers sit ABOVE the grid rather than in it. a commander in the grid runs, has a
transcript and has cost money; an offer is a description on disk. drawn as the same
card with a badge, the two look interchangeable, and the first consequence is
somebody hunting for the transcript of a thing that has never run.
an offer STAYS after it is used. it is a template and there is no reason to have
only one — two folders watched for two formats is two commanders from one offer,
and hiding it would make the second look impossible.
the form is drawn from the template's own `inputs`. nothing in the component knows
what av-club wants: options become buttons, boolean becomes yes/no, everything else
is a field. the button names what is missing rather than marking it red.
TWO REAL BUGS FOUND BY WIRING IT UP, both in the list route:
`?category=plugin:av-club` reached `Number('plugin:av-club')` — NaN — and the query
compared `category_id = NaN`. that matches nothing and looks exactly like "this
plugin has no commanders", which is the worst way for a filter to fail. parsed
before the numeric branch now.
and the grid DROPS every row without a transcript. that was right when the only way
to get a row was to start a conversation; it is wrong now, because a commander from
a template arrives deliberately not running — so the thing somebody just created was
invisible, under a toast saying it worked. a row with a `plugin` and no session is
now projected from the row itself, with the zeroes that are true of it, and sorted
FIRST: it is the one that needs a decision. ordinary rows keep the old behaviour,
since a hand-made row with no transcript is a conversation that failed to start and
the design refuses to draw a tombstone for it.
four locale keys, all six languages, parity test green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
@@ -63,6 +63,8 @@ export type CommandHqSessionRow = {
|
||||
/** What the owner called it. Null for rows filed before the column existed — fall back to the transcript. */
|
||||
name: string | null;
|
||||
categoryId: number | null;
|
||||
plugin: string | null;
|
||||
pluginConfig: Record<string, string> | null;
|
||||
watcherArmed: boolean;
|
||||
watcherTaskId: string | null;
|
||||
watcherArmedAt: Date | null;
|
||||
@@ -78,6 +80,8 @@ const SESSION_ROW_COLUMNS = {
|
||||
referencePaths: commandHqSessions.referencePaths,
|
||||
name: commandHqSessions.name,
|
||||
categoryId: commandHqSessions.categoryId,
|
||||
plugin: commandHqSessions.plugin,
|
||||
pluginConfig: commandHqSessions.pluginConfig,
|
||||
watcherArmed: commandHqSessions.watcherArmed,
|
||||
watcherTaskId: commandHqSessions.watcherTaskId,
|
||||
watcherArmedAt: commandHqSessions.watcherArmedAt,
|
||||
@@ -166,9 +170,12 @@ export async function getCommandHqSessionById(userId: number, id: number): Promi
|
||||
export async function listCommandHqSessions(
|
||||
userId: number,
|
||||
categoryId?: number | null,
|
||||
/** Instead of a category: only commanders shipped by this plugin. The two never combine. */
|
||||
plugin?: string,
|
||||
): Promise<CommandHqSessionRow[]> {
|
||||
const where =
|
||||
categoryId === undefined
|
||||
const where = plugin
|
||||
? and(eq(commandHqSessions.userId, userId), eq(commandHqSessions.plugin, plugin))
|
||||
: categoryId === undefined
|
||||
? eq(commandHqSessions.userId, userId)
|
||||
: categoryId === null
|
||||
? and(eq(commandHqSessions.userId, userId), isCategoryNull())
|
||||
|
||||
@@ -203,6 +203,17 @@ type CommanderSummary = ClaudeSessionSummary & {
|
||||
watcherLastFiredAt: Date | null;
|
||||
/** The running total — every turn this commander has ever cost, summed. See `GET .../turns` for the raw series. */
|
||||
cost: CommandHqCostSummary;
|
||||
/** Where it came from, when a plugin's template made it. Null for one somebody wrote themselves. */
|
||||
plugin?: string | null;
|
||||
/** The owner's answers to that template's inputs, so the card can say what it is set to watch. */
|
||||
pluginConfig?: Record<string, string> | null;
|
||||
/**
|
||||
* Created, never opened — so there is no transcript behind it and its id is not a session id.
|
||||
*
|
||||
* Only ever true for a plugin's commander. A hand-made row without a transcript is one whose
|
||||
* conversation failed to start, and the design deliberately refuses to draw a tombstone for it.
|
||||
*/
|
||||
pending?: boolean;
|
||||
};
|
||||
|
||||
async function isRunning(sessionId: string): Promise<boolean> {
|
||||
@@ -224,7 +235,15 @@ commandHqRouter.get('/commanders', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const who = await commandHqIdentity(user);
|
||||
const categoryParam = ctx.req.query('category');
|
||||
const categoryId = categoryParam === undefined ? undefined : categoryParam === 'none' ? null : Number(categoryParam);
|
||||
|
||||
/*
|
||||
`plugin:<appName>` is a filter on provenance, not a category, and is parsed BEFORE the numeric branch.
|
||||
Without this it reached `Number('plugin:av-club')` — NaN — and the query compared `category_id = NaN`,
|
||||
which matches nothing and looks exactly like "this plugin has no commanders".
|
||||
*/
|
||||
const pluginFilter = categoryParam?.startsWith('plugin:') ? categoryParam.slice('plugin:'.length) : undefined;
|
||||
const categoryId =
|
||||
pluginFilter || categoryParam === undefined ? undefined : categoryParam === 'none' ? null : Number(categoryParam);
|
||||
|
||||
/*
|
||||
Each commander's transcript is loaded BY ID, not found by scanning one directory — a commander's own
|
||||
@@ -237,7 +256,7 @@ commandHqRouter.get('/commanders', async (ctx) => {
|
||||
reason: there is no transcript to summarise. Filtered out before the lookup rather than after, so
|
||||
`loadClaudeSessionSummaryById` is never asked about an id that cannot exist.
|
||||
*/
|
||||
const rows = await listCommandHqSessions(user.id, categoryId);
|
||||
const rows = await listCommandHqSessions(user.id, categoryId, pluginFilter);
|
||||
const withTranscript = rows.filter((row) => row.sessionId !== null);
|
||||
const summaries = withTranscript
|
||||
.map((row) => loadClaudeSessionSummaryById(who, row.sessionId!))
|
||||
@@ -260,6 +279,39 @@ commandHqRouter.get('/commanders', async (ctx) => {
|
||||
),
|
||||
]);
|
||||
|
||||
/*
|
||||
A commander CREATED but never opened has no transcript, and every branch above drops it.
|
||||
|
||||
That was right when the only way to get a row was to start a conversation. It is wrong now: a
|
||||
commander made from a plugin's template arrives deliberately not running, and dropping it means the
|
||||
thing somebody just created is invisible — with a toast saying it worked. So a row with a `plugin`
|
||||
and no session is projected from the ROW alone, with the zeroes that are actually true of it.
|
||||
|
||||
Ordinary commanders keep the old behaviour. A hand-made row with no transcript is a row whose
|
||||
conversation failed to start, and a tombstone for it is what the design deliberately refused.
|
||||
*/
|
||||
const pending = rows
|
||||
.filter((row) => row.sessionId === null && row.plugin)
|
||||
.map(
|
||||
(row) =>
|
||||
({
|
||||
id: `pending-${row.id}`,
|
||||
title: row.name ?? 'Commander',
|
||||
dirName: row.dirName,
|
||||
categoryId: row.categoryId,
|
||||
tagIds: [],
|
||||
running: false,
|
||||
watcherArmed: false,
|
||||
watcherArmedAt: null,
|
||||
watcherStoppedAt: null,
|
||||
watcherLastFiredAt: null,
|
||||
cost: { turnCount: 0, inputTokens: 0, outputTokens: 0, totalUsd: 0 },
|
||||
plugin: row.plugin,
|
||||
pluginConfig: row.pluginConfig,
|
||||
pending: true,
|
||||
}) as unknown as CommanderSummary,
|
||||
);
|
||||
|
||||
const commanders: CommanderSummary[] = filed
|
||||
.map((row) => ({
|
||||
...liveById.get(row.sessionId!)!,
|
||||
@@ -279,6 +331,10 @@ commandHqRouter.get('/commanders', async (ctx) => {
|
||||
// grid offered nothing to stop — which is the one thing this column exists to make possible. Keyed
|
||||
// on `dirName`, not the transcript id: the pid file lives in this commander's OWN `state/`, not
|
||||
// beside the transcript.
|
||||
// Provenance, so the UI can put a plugin's commanders in their own section rather than inferring
|
||||
// it from a category name somebody can rename.
|
||||
plugin: row.plugin,
|
||||
pluginConfig: row.pluginConfig,
|
||||
watcherArmed: row.watcherArmed || liveLoopPid(who.email, row.dirName) !== null,
|
||||
// Whether the LOOP is alive right now, separate from `running` (an agent turn in progress) and from
|
||||
// `watcherArmed` (which is also true for a row that says armed but whose loop has gone).
|
||||
@@ -290,7 +346,9 @@ commandHqRouter.get('/commanders', async (ctx) => {
|
||||
}))
|
||||
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||
|
||||
return ctx.json({ commanders });
|
||||
// Pending ones FIRST: a commander that was just created and needs a decision is the thing somebody came
|
||||
// to the screen for, and sorting it by `updatedAt` among running ones would bury it on a busy account.
|
||||
return ctx.json({ commanders: [...pending, ...commanders] });
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -465,6 +523,8 @@ commandHqRouter.get('/commanders/:sessionId', async (ctx) => {
|
||||
categoryId: row.categoryId,
|
||||
tagIds: tagsByRow.get(row.id) ?? [],
|
||||
running,
|
||||
plugin: row.plugin,
|
||||
pluginConfig: row.pluginConfig,
|
||||
watcherArmed: row.watcherArmed || liveLoopPid(who.email, row.dirName) !== null,
|
||||
watcherRunning: liveLoopPid(who.email, row.dirName) !== null,
|
||||
watcherArmedAt: row.watcherArmedAt,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Plus } from 'lucide-react';
|
||||
import { toast } from '@/components/ui/sonner';
|
||||
import { errorText } from 'helpers/error-text';
|
||||
import { categoryFromParam, commandHqListPath } from './command-hq-routes';
|
||||
import { usePluginCommanderTemplates } from './useCommandHq';
|
||||
import { useCommandHqCategories } from './useCommandHq';
|
||||
import type { CommandHqCategoryFilter } from './types';
|
||||
import { useTranslation } from '../../locales';
|
||||
@@ -36,6 +37,11 @@ export const CommandHqCategories = () => {
|
||||
const { t } = useTranslation();
|
||||
const [searchParams] = useSearchParams();
|
||||
const active: CommandHqCategoryFilter = categoryFromParam(searchParams.get('category'));
|
||||
|
||||
// Which plugins have anything to offer. Derived from the templates rather than from the installed
|
||||
// plugin list: a plugin that ships no commander should not get a row that is always empty.
|
||||
const { templates } = usePluginCommanderTemplates();
|
||||
const plugins = [...new Set(templates.map((template) => template.plugin))].sort();
|
||||
const { categories, createCategory } = useCommandHqCategories();
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [name, setName] = useState('');
|
||||
@@ -96,6 +102,34 @@ export const CommandHqCategories = () => {
|
||||
to={commandHqListPath(category.id)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/*
|
||||
One row per plugin that offers commanders, below the owner's own categories and separated from
|
||||
them.
|
||||
|
||||
Separated because they are not the same kind of thing. A category is the owner's filing and they
|
||||
can rename or delete it; a plugin is where something came from and they cannot. Mixing them into
|
||||
one list would invite the reasonable assumption that "av-club" is a folder they made, and the
|
||||
first thing somebody does with a folder they made is rename it.
|
||||
|
||||
Shown even when the plugin has no commanders yet — that is the whole point. The row is how you
|
||||
find out a plugin is offering one.
|
||||
*/}
|
||||
{plugins.length > 0 && (
|
||||
<div className="mt-2 border-t border-border pt-2">
|
||||
<span className="block px-2 pb-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground/70">
|
||||
{t('commandHq.fromPlugins')}
|
||||
</span>
|
||||
{plugins.map((plugin) => (
|
||||
<Row
|
||||
key={plugin}
|
||||
label={plugin}
|
||||
active={active === `plugin:${plugin}`}
|
||||
to={commandHqListPath(`plugin:${plugin}`)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{adding && (
|
||||
// No submit button — a form with exactly one text field still submits on Enter.
|
||||
<form onSubmit={submitNew}>
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
useCommandHqCommanderActions,
|
||||
useCreateCommander,
|
||||
} from './useCommandHq';
|
||||
import type { CommandHqCommanderCreated } from './types';
|
||||
import type { CommandHqCommanderCreated, CommandHqCategoryFilter } from './types';
|
||||
import { formatCommanderCost, formatTokens } from './format';
|
||||
import { NEW_COMMANDER_GUIDE } from './newCommanderGuide';
|
||||
import { useTranslation } from '../../locales';
|
||||
@@ -246,7 +246,7 @@ function NewCommanderSetup({
|
||||
category,
|
||||
onCreated,
|
||||
}: {
|
||||
category: number | 'none' | undefined;
|
||||
category: CommandHqCategoryFilter;
|
||||
onCreated: (commander: CommandHqCommanderCreated) => void;
|
||||
}) {
|
||||
/*
|
||||
@@ -335,7 +335,7 @@ function ActiveNewCommander({
|
||||
onStarted,
|
||||
}: {
|
||||
commander: CommandHqCommanderCreated;
|
||||
category: number | 'none' | undefined;
|
||||
category: CommandHqCategoryFilter;
|
||||
onStarted: (id: string) => void;
|
||||
}) {
|
||||
const { t, tUi, locale } = useTranslation();
|
||||
@@ -446,13 +446,7 @@ function ActiveNewCommander({
|
||||
* version had (a `messages.length === 0` latch that could not tell "never started" from "started, and the
|
||||
* socket dropped").
|
||||
*/
|
||||
function NewCommander({
|
||||
category,
|
||||
onStarted,
|
||||
}: {
|
||||
category: number | 'none' | undefined;
|
||||
onStarted: (id: string) => void;
|
||||
}) {
|
||||
function NewCommander({ category, onStarted }: { category: CommandHqCategoryFilter; onStarted: (id: string) => void }) {
|
||||
const [commander, setCommander] = useState<CommandHqCommanderCreated | null>(null);
|
||||
if (!commander) return <NewCommanderSetup category={category} onCreated={setCommander} />;
|
||||
return <ActiveNewCommander commander={commander} category={category} onStarted={onStarted} />;
|
||||
|
||||
@@ -5,6 +5,7 @@ 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 { PluginCommanderOffers } from './PluginCommanderOffers';
|
||||
import { useCommandHqCommanderActions, useCommandHqCommanders } from './useCommandHq';
|
||||
import { formatCommanderCost, formatTokens } from './format';
|
||||
import type { CommandHqCategoryFilter, CommandHqCommander } from './types';
|
||||
@@ -170,6 +171,12 @@ export const CommandHqGrid = () => {
|
||||
row.
|
||||
*/}
|
||||
<div className="flex flex-col gap-3 p-4">
|
||||
{/* Only on a plugin's own filter. On "All commanders" this would put every plugin's offers above
|
||||
a list that is mostly not theirs, which is a different screen pretending to be this one. */}
|
||||
{typeof category === 'string' && category.startsWith('plugin:') && (
|
||||
<PluginCommanderOffers plugin={category.slice('plugin:'.length)} />
|
||||
)}
|
||||
|
||||
{commanders.map((commander) => (
|
||||
<Card
|
||||
key={commander.id}
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import { useState } from 'react';
|
||||
import { Loader2, Package, Plus } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from 'officerdev';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { usePluginCommanderTemplates } from './useCommandHq';
|
||||
import type { PluginCommanderTemplate } from './types';
|
||||
|
||||
/**
|
||||
* What a plugin offers but has not been started.
|
||||
*
|
||||
* ── Offers sit ABOVE the grid, not inside it ──
|
||||
*
|
||||
* A commander in the grid is running, has a transcript and has cost something. An offer is a
|
||||
* description on disk. Drawing them as the same kind of card, distinguished by a badge, makes the two
|
||||
* look interchangeable — and the first consequence is somebody looking for the transcript of a thing
|
||||
* that has never run.
|
||||
*
|
||||
* ── An offer stays visible after it has been used ──
|
||||
*
|
||||
* Deliberately: a plugin's commander is a template, and there is no reason to have only one. Watching
|
||||
* two different folders for two different formats is two commanders from the same offer, and hiding
|
||||
* the offer once used would make the second one look impossible.
|
||||
*/
|
||||
|
||||
export const PluginCommanderOffers = ({ plugin }: { plugin: string }) => {
|
||||
const { t } = useTranslation();
|
||||
const { templates, createFromTemplate } = usePluginCommanderTemplates();
|
||||
const [configuring, setConfiguring] = useState<PluginCommanderTemplate | null>(null);
|
||||
|
||||
const offers = templates.filter((template) => template.plugin === plugin);
|
||||
if (offers.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="border-b border-border">
|
||||
<div className="flex items-center gap-2 px-4 pt-3 pb-1">
|
||||
<Package className="size-3.5 text-muted-foreground" />
|
||||
<span className="text-[10px] font-medium tracking-wide text-muted-foreground uppercase">
|
||||
{t('commandHq.available')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 p-3 pt-1">
|
||||
{offers.map((offer) => (
|
||||
<div key={offer.dirName} className="rounded-lg border border-border bg-card p-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">{offer.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{offer.description}</p>
|
||||
{offer.watches && <p className="pt-0.5 text-xs text-muted-foreground/70">{offer.watches}</p>}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={configuring?.dirName === offer.dirName ? 'secondary' : 'outline'}
|
||||
className="shrink-0"
|
||||
onClick={() => setConfiguring(configuring?.dirName === offer.dirName ? null : offer)}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
{t('commandHq.configureAndCreate')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{configuring?.dirName === offer.dirName && (
|
||||
<ConfigureForm
|
||||
template={offer}
|
||||
busy={createFromTemplate.isPending}
|
||||
onCancel={() => setConfiguring(null)}
|
||||
onSubmit={(name, config) => {
|
||||
createFromTemplate.mutate(
|
||||
{ plugin: offer.plugin, template: offer.dirName, name, config },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setConfiguring(null);
|
||||
// Named, because it is now in the grid below among things that look alike.
|
||||
toast.success(`${name} created — it is not running yet`);
|
||||
},
|
||||
onError: (err: unknown) =>
|
||||
toast.error(err instanceof Error ? err.message : 'could not create it'),
|
||||
},
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* The template's own inputs, drawn from its declaration.
|
||||
*
|
||||
* Nothing here knows what av-club's commander wants. `options` becomes a select, `boolean` becomes
|
||||
* yes/no, everything else is a text field — the same three shapes a task's inputs use, because they
|
||||
* are the same declaration.
|
||||
*/
|
||||
const ConfigureForm = ({
|
||||
template,
|
||||
busy,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
}: {
|
||||
template: PluginCommanderTemplate;
|
||||
busy: boolean;
|
||||
onCancel: () => void;
|
||||
onSubmit: (name: string, config: Record<string, string>) => void;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = useState(template.name);
|
||||
const [values, setValues] = useState<Record<string, string>>(() =>
|
||||
Object.fromEntries(Object.entries(template.inputs).map(([key, spec]) => [key, spec.default ?? ''])),
|
||||
);
|
||||
|
||||
const missing = Object.entries(template.inputs)
|
||||
.filter(([key, spec]) => spec.required && !values[key]?.trim())
|
||||
.map(([key]) => key);
|
||||
|
||||
return (
|
||||
<form
|
||||
className="mt-3 flex flex-col gap-3 border-t border-border pt-3"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (missing.length === 0 && name.trim()) onSubmit(name.trim(), values);
|
||||
}}
|
||||
>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">{t('commandHq.name')}</span>
|
||||
<Input value={name} onChange={(event) => setName(event.target.value)} className="text-sm" />
|
||||
</label>
|
||||
|
||||
{Object.entries(template.inputs).map(([key, spec]) => (
|
||||
<label key={key} className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{key.replace(/_/g, ' ')}
|
||||
{spec.required && <span className="text-destructive"> *</span>}
|
||||
</span>
|
||||
|
||||
{spec.options ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{spec.options.map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
onClick={() => setValues((v) => ({ ...v, [key]: option }))}
|
||||
className={`rounded-md px-2.5 py-1 text-xs transition-colors ${
|
||||
values[key] === option ? 'bg-primary/15 text-primary' : 'bg-muted/50 hover:bg-muted'
|
||||
}`}
|
||||
>
|
||||
{option}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : spec.type === 'boolean' ? (
|
||||
<div className="flex gap-1">
|
||||
{['false', 'true'].map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
onClick={() => setValues((v) => ({ ...v, [key]: option }))}
|
||||
className={`rounded-md px-2.5 py-1 text-xs transition-colors ${
|
||||
(values[key] || 'false') === option ? 'bg-primary/15 text-primary' : 'bg-muted/50 hover:bg-muted'
|
||||
}`}
|
||||
>
|
||||
{option === 'true' ? 'Yes' : 'No'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Input
|
||||
value={values[key] ?? ''}
|
||||
onChange={(event) => setValues((v) => ({ ...v, [key]: event.target.value }))}
|
||||
placeholder={spec.type === 'directory' ? '/home/you/folder' : ''}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
)}
|
||||
|
||||
{spec.description && <span className="text-[11px] text-muted-foreground/70">{spec.description}</span>}
|
||||
</label>
|
||||
))}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="submit" size="sm" disabled={busy || missing.length > 0 || !name.trim()}>
|
||||
{busy && <Loader2 className="size-3.5 animate-spin" />}
|
||||
{t('commandHq.configureAndCreate')}
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
{/* Named, so it is obvious WHICH field is stopping the button rather than hunting for a red mark. */}
|
||||
{missing.length > 0 && (
|
||||
<span className="text-xs text-muted-foreground">needs {missing.join(', ').replace(/_/g, ' ')}</span>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -40,6 +40,9 @@ export const commandHqSessionPath = (sessionId: string, category?: CommandHqCate
|
||||
export const categoryFromParam = (raw: string | null): CommandHqCategoryFilter => {
|
||||
if (raw === null) return undefined;
|
||||
if (raw === 'none') return 'none';
|
||||
// `plugin:av-club`. Checked before the numeric parse, since an appName never parses as a number and
|
||||
// would otherwise fall through to `undefined` and silently show every commander.
|
||||
if (raw.startsWith('plugin:') && raw.length > 'plugin:'.length) return raw as CommandHqCategoryFilter;
|
||||
const id = Number(raw);
|
||||
return Number.isFinite(id) ? id : undefined;
|
||||
};
|
||||
|
||||
@@ -26,8 +26,16 @@ export type CommandHqCostSummary = {
|
||||
totalUsd: number;
|
||||
};
|
||||
|
||||
/** `category` query value: omitted (all), `'none'` (uncategorised), or a category id. */
|
||||
export type CommandHqCategoryFilter = number | 'none' | undefined;
|
||||
/**
|
||||
* `category` query value: omitted (all), `'none'` (uncategorised), a category id, or `plugin:<appName>`.
|
||||
*
|
||||
* The plugin form is not a category and deliberately does not pretend to be one. A commander's category
|
||||
* is the owner's filing; which plugin shipped it is a fact about where it came from, and the two are
|
||||
* independent — a plugin's commander can also be filed under "Nightly" without either meaning changing.
|
||||
* One query parameter carries both because they are both "which subset of the grid", and that is what
|
||||
* this parameter has always meant.
|
||||
*/
|
||||
export type CommandHqCategoryFilter = number | 'none' | `plugin:${string}` | undefined;
|
||||
|
||||
/** One grid card — `ClaudeSessionSummary` plus what Command HQ knows about it. See `GET /commanders`. */
|
||||
export type CommandHqCommander = {
|
||||
@@ -100,3 +108,23 @@ export type CommandHqFire = {
|
||||
taskId: string;
|
||||
firedAt: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A commander a plugin OFFERS but which does not exist yet.
|
||||
*
|
||||
* Deliberately a different type from `CommandHqCommander`, and fetched from a different endpoint. One
|
||||
* runs, has a transcript and costs money; the other is a description on disk. Modelling them as one
|
||||
* type with an `exists` flag would mean every consumer checking the flag before touching anything, and
|
||||
* the one that forgets renders a card with a cost of zero for something that was never started.
|
||||
*/
|
||||
export type PluginCommanderTemplate = {
|
||||
dirName: string;
|
||||
plugin: string;
|
||||
name: string;
|
||||
description: string;
|
||||
watches?: string;
|
||||
inputs: Record<
|
||||
string,
|
||||
{ type: string; description?: string; default?: string; options?: string[]; required?: boolean }
|
||||
>;
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
CommandHqCommander,
|
||||
CommandHqCommanderCreated,
|
||||
CommandHqCommanderDetail,
|
||||
PluginCommanderTemplate,
|
||||
} from './types';
|
||||
|
||||
const CATEGORIES_KEY = ['command-hq', 'categories'] as const;
|
||||
@@ -173,3 +174,36 @@ export function useCommandHqChildren(sessionId: string | undefined) {
|
||||
isLoading,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* What the installed plugins offer, and making one real.
|
||||
*
|
||||
* The list is cheap and changes only when a plugin is installed, updated or disabled — so it is not
|
||||
* refetched on a window focus. A plugin the owner just installed appears when Command HQ next mounts,
|
||||
* which is the same moment its dock tile does.
|
||||
*/
|
||||
export const usePluginCommanderTemplates = () => {
|
||||
const { get, post } = useClient();
|
||||
const qc = useQueryClient();
|
||||
|
||||
const { data, isLoading } = useQuery<{ templates: PluginCommanderTemplate[] }>({
|
||||
queryKey: ['command-hq', 'plugin-commanders'],
|
||||
queryFn: () => get('/command-hq/plugin-commanders'),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const createFromTemplate = useMutation({
|
||||
mutationFn: (input: { plugin: string; template: string; name: string; config: Record<string, string> }) =>
|
||||
post<CommandHqCommanderCreated>('/command-hq/commanders', input),
|
||||
// The grid reads `commanders`, and a newly created one has to appear in it without a reload —
|
||||
// creating from a template is the one moment somebody is certain something should have happened.
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['command-hq', 'commanders'] }),
|
||||
});
|
||||
|
||||
return { templates: data?.templates ?? EMPTY_TEMPLATES, isLoading, createFromTemplate };
|
||||
};
|
||||
|
||||
// Module-level, so an empty result is the SAME array every render. A fresh `[]` in the fallback is a new
|
||||
// identity each time, which re-runs every effect and memo that depends on it — the trap this file already
|
||||
// avoids for categories and commanders.
|
||||
const EMPTY_TEMPLATES: PluginCommanderTemplate[] = [];
|
||||
|
||||
@@ -257,7 +257,11 @@
|
||||
},
|
||||
"startHint": "Er sieht sich zunächst an, worauf Sie ihn hingewiesen haben, und fragt Sie dann, worauf er achten soll.",
|
||||
"startBlocked": "{{missing}} — dann startet dies ihn.",
|
||||
"confirmDeletingNamed": "Löschen von {{name}} bestätigen"
|
||||
"confirmDeletingNamed": "Löschen von {{name}} bestätigen",
|
||||
"fromPlugins": "Von Plugins",
|
||||
"available": "Verfügbar",
|
||||
"configureAndCreate": "Einrichten und erstellen",
|
||||
"notCreatedYet": "Noch nicht erstellt — richten Sie es ein, um zu starten."
|
||||
},
|
||||
"chatHistory": {
|
||||
"newChat": "Neuer Chat",
|
||||
|
||||
@@ -257,7 +257,11 @@
|
||||
},
|
||||
"startHint": "It will look at what you pointed it to, then ask you what to watch for.",
|
||||
"startBlocked": "{{missing}} — then this starts it.",
|
||||
"confirmDeletingNamed": "Confirm deleting {{name}}"
|
||||
"confirmDeletingNamed": "Confirm deleting {{name}}",
|
||||
"fromPlugins": "From plugins",
|
||||
"available": "Available",
|
||||
"configureAndCreate": "Configure and create",
|
||||
"notCreatedYet": "Not created yet — configure it to start."
|
||||
},
|
||||
"chatHistory": {
|
||||
"newChat": "New Chat",
|
||||
|
||||
@@ -257,7 +257,11 @@
|
||||
},
|
||||
"startHint": "Mirará aquello que le has señalado y luego te preguntará qué debe vigilar.",
|
||||
"startBlocked": "{{missing}} — y entonces esto lo inicia.",
|
||||
"confirmDeletingNamed": "Confirmar la eliminación de {{name}}"
|
||||
"confirmDeletingNamed": "Confirmar la eliminación de {{name}}",
|
||||
"fromPlugins": "De plugins",
|
||||
"available": "Disponible",
|
||||
"configureAndCreate": "Configurar y crear",
|
||||
"notCreatedYet": "Aún no creado — configúralo para empezar."
|
||||
},
|
||||
"chatHistory": {
|
||||
"newChat": "Nuevo chat",
|
||||
|
||||
@@ -257,7 +257,11 @@
|
||||
},
|
||||
"startHint": "Il va regarder ce vers quoi vous l'avez orienté, puis vous demander ce qu'il doit surveiller.",
|
||||
"startBlocked": "{{missing}} — ensuite ceci le démarre.",
|
||||
"confirmDeletingNamed": "Confirmer la suppression de {{name}}"
|
||||
"confirmDeletingNamed": "Confirmer la suppression de {{name}}",
|
||||
"fromPlugins": "Depuis les plugins",
|
||||
"available": "Disponible",
|
||||
"configureAndCreate": "Configurer et créer",
|
||||
"notCreatedYet": "Pas encore créé — configurez-le pour commencer."
|
||||
},
|
||||
"chatHistory": {
|
||||
"newChat": "Nouveau Chat",
|
||||
|
||||
@@ -257,7 +257,11 @@
|
||||
},
|
||||
"startHint": "Vai olhar para aquilo que lhe indicou e depois perguntar-lhe o que deve vigiar.",
|
||||
"startBlocked": "{{missing}} — depois é isto que o inicia.",
|
||||
"confirmDeletingNamed": "Confirmar a eliminação de {{name}}"
|
||||
"confirmDeletingNamed": "Confirmar a eliminação de {{name}}",
|
||||
"fromPlugins": "De plugins",
|
||||
"available": "Disponível",
|
||||
"configureAndCreate": "Configurar e criar",
|
||||
"notCreatedYet": "Ainda não criado — configure para começar."
|
||||
},
|
||||
"chatHistory": {
|
||||
"newChat": "Novo chat",
|
||||
|
||||
@@ -257,7 +257,11 @@
|
||||
},
|
||||
"startHint": "Den tittar på det du pekade den mot, och frågar dig sedan vad den ska bevaka.",
|
||||
"startBlocked": "{{missing}} — sedan startar detta den.",
|
||||
"confirmDeletingNamed": "Bekräfta borttagning av {{name}}"
|
||||
"confirmDeletingNamed": "Bekräfta borttagning av {{name}}",
|
||||
"fromPlugins": "Från plugin",
|
||||
"available": "Tillgänglig",
|
||||
"configureAndCreate": "Konfigurera och skapa",
|
||||
"notCreatedYet": "Inte skapad än — konfigurera för att starta."
|
||||
},
|
||||
"chatHistory": {
|
||||
"newChat": "Ny chatt",
|
||||
|
||||
Reference in New Issue
Block a user