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. */
|
/** What the owner called it. Null for rows filed before the column existed — fall back to the transcript. */
|
||||||
name: string | null;
|
name: string | null;
|
||||||
categoryId: number | null;
|
categoryId: number | null;
|
||||||
|
plugin: string | null;
|
||||||
|
pluginConfig: Record<string, string> | null;
|
||||||
watcherArmed: boolean;
|
watcherArmed: boolean;
|
||||||
watcherTaskId: string | null;
|
watcherTaskId: string | null;
|
||||||
watcherArmedAt: Date | null;
|
watcherArmedAt: Date | null;
|
||||||
@@ -78,6 +80,8 @@ const SESSION_ROW_COLUMNS = {
|
|||||||
referencePaths: commandHqSessions.referencePaths,
|
referencePaths: commandHqSessions.referencePaths,
|
||||||
name: commandHqSessions.name,
|
name: commandHqSessions.name,
|
||||||
categoryId: commandHqSessions.categoryId,
|
categoryId: commandHqSessions.categoryId,
|
||||||
|
plugin: commandHqSessions.plugin,
|
||||||
|
pluginConfig: commandHqSessions.pluginConfig,
|
||||||
watcherArmed: commandHqSessions.watcherArmed,
|
watcherArmed: commandHqSessions.watcherArmed,
|
||||||
watcherTaskId: commandHqSessions.watcherTaskId,
|
watcherTaskId: commandHqSessions.watcherTaskId,
|
||||||
watcherArmedAt: commandHqSessions.watcherArmedAt,
|
watcherArmedAt: commandHqSessions.watcherArmedAt,
|
||||||
@@ -166,9 +170,12 @@ export async function getCommandHqSessionById(userId: number, id: number): Promi
|
|||||||
export async function listCommandHqSessions(
|
export async function listCommandHqSessions(
|
||||||
userId: number,
|
userId: number,
|
||||||
categoryId?: number | null,
|
categoryId?: number | null,
|
||||||
|
/** Instead of a category: only commanders shipped by this plugin. The two never combine. */
|
||||||
|
plugin?: string,
|
||||||
): Promise<CommandHqSessionRow[]> {
|
): Promise<CommandHqSessionRow[]> {
|
||||||
const where =
|
const where = plugin
|
||||||
categoryId === undefined
|
? and(eq(commandHqSessions.userId, userId), eq(commandHqSessions.plugin, plugin))
|
||||||
|
: categoryId === undefined
|
||||||
? eq(commandHqSessions.userId, userId)
|
? eq(commandHqSessions.userId, userId)
|
||||||
: categoryId === null
|
: categoryId === null
|
||||||
? and(eq(commandHqSessions.userId, userId), isCategoryNull())
|
? and(eq(commandHqSessions.userId, userId), isCategoryNull())
|
||||||
|
|||||||
@@ -203,6 +203,17 @@ type CommanderSummary = ClaudeSessionSummary & {
|
|||||||
watcherLastFiredAt: Date | null;
|
watcherLastFiredAt: Date | null;
|
||||||
/** The running total — every turn this commander has ever cost, summed. See `GET .../turns` for the raw series. */
|
/** The running total — every turn this commander has ever cost, summed. See `GET .../turns` for the raw series. */
|
||||||
cost: CommandHqCostSummary;
|
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> {
|
async function isRunning(sessionId: string): Promise<boolean> {
|
||||||
@@ -224,7 +235,15 @@ commandHqRouter.get('/commanders', async (ctx) => {
|
|||||||
const user = ctx.get('user');
|
const user = ctx.get('user');
|
||||||
const who = await commandHqIdentity(user);
|
const who = await commandHqIdentity(user);
|
||||||
const categoryParam = ctx.req.query('category');
|
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
|
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
|
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.
|
`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 withTranscript = rows.filter((row) => row.sessionId !== null);
|
||||||
const summaries = withTranscript
|
const summaries = withTranscript
|
||||||
.map((row) => loadClaudeSessionSummaryById(who, row.sessionId!))
|
.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
|
const commanders: CommanderSummary[] = filed
|
||||||
.map((row) => ({
|
.map((row) => ({
|
||||||
...liveById.get(row.sessionId!)!,
|
...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
|
// 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
|
// on `dirName`, not the transcript id: the pid file lives in this commander's OWN `state/`, not
|
||||||
// beside the transcript.
|
// 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,
|
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
|
// 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).
|
// `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));
|
.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,
|
categoryId: row.categoryId,
|
||||||
tagIds: tagsByRow.get(row.id) ?? [],
|
tagIds: tagsByRow.get(row.id) ?? [],
|
||||||
running,
|
running,
|
||||||
|
plugin: row.plugin,
|
||||||
|
pluginConfig: row.pluginConfig,
|
||||||
watcherArmed: row.watcherArmed || liveLoopPid(who.email, row.dirName) !== null,
|
watcherArmed: row.watcherArmed || liveLoopPid(who.email, row.dirName) !== null,
|
||||||
watcherRunning: liveLoopPid(who.email, row.dirName) !== null,
|
watcherRunning: liveLoopPid(who.email, row.dirName) !== null,
|
||||||
watcherArmedAt: row.watcherArmedAt,
|
watcherArmedAt: row.watcherArmedAt,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Plus } from 'lucide-react';
|
|||||||
import { toast } from '@/components/ui/sonner';
|
import { toast } from '@/components/ui/sonner';
|
||||||
import { errorText } from 'helpers/error-text';
|
import { errorText } from 'helpers/error-text';
|
||||||
import { categoryFromParam, commandHqListPath } from './command-hq-routes';
|
import { categoryFromParam, commandHqListPath } from './command-hq-routes';
|
||||||
|
import { usePluginCommanderTemplates } from './useCommandHq';
|
||||||
import { useCommandHqCategories } from './useCommandHq';
|
import { useCommandHqCategories } from './useCommandHq';
|
||||||
import type { CommandHqCategoryFilter } from './types';
|
import type { CommandHqCategoryFilter } from './types';
|
||||||
import { useTranslation } from '../../locales';
|
import { useTranslation } from '../../locales';
|
||||||
@@ -36,6 +37,11 @@ export const CommandHqCategories = () => {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const active: CommandHqCategoryFilter = categoryFromParam(searchParams.get('category'));
|
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 { categories, createCategory } = useCommandHqCategories();
|
||||||
const [adding, setAdding] = useState(false);
|
const [adding, setAdding] = useState(false);
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
@@ -96,6 +102,34 @@ export const CommandHqCategories = () => {
|
|||||||
to={commandHqListPath(category.id)}
|
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 && (
|
{adding && (
|
||||||
// No submit button — a form with exactly one text field still submits on Enter.
|
// No submit button — a form with exactly one text field still submits on Enter.
|
||||||
<form onSubmit={submitNew}>
|
<form onSubmit={submitNew}>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
useCommandHqCommanderActions,
|
useCommandHqCommanderActions,
|
||||||
useCreateCommander,
|
useCreateCommander,
|
||||||
} from './useCommandHq';
|
} from './useCommandHq';
|
||||||
import type { CommandHqCommanderCreated } from './types';
|
import type { CommandHqCommanderCreated, CommandHqCategoryFilter } from './types';
|
||||||
import { formatCommanderCost, formatTokens } from './format';
|
import { formatCommanderCost, formatTokens } from './format';
|
||||||
import { NEW_COMMANDER_GUIDE } from './newCommanderGuide';
|
import { NEW_COMMANDER_GUIDE } from './newCommanderGuide';
|
||||||
import { useTranslation } from '../../locales';
|
import { useTranslation } from '../../locales';
|
||||||
@@ -246,7 +246,7 @@ function NewCommanderSetup({
|
|||||||
category,
|
category,
|
||||||
onCreated,
|
onCreated,
|
||||||
}: {
|
}: {
|
||||||
category: number | 'none' | undefined;
|
category: CommandHqCategoryFilter;
|
||||||
onCreated: (commander: CommandHqCommanderCreated) => void;
|
onCreated: (commander: CommandHqCommanderCreated) => void;
|
||||||
}) {
|
}) {
|
||||||
/*
|
/*
|
||||||
@@ -335,7 +335,7 @@ function ActiveNewCommander({
|
|||||||
onStarted,
|
onStarted,
|
||||||
}: {
|
}: {
|
||||||
commander: CommandHqCommanderCreated;
|
commander: CommandHqCommanderCreated;
|
||||||
category: number | 'none' | undefined;
|
category: CommandHqCategoryFilter;
|
||||||
onStarted: (id: string) => void;
|
onStarted: (id: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t, tUi, locale } = useTranslation();
|
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
|
* version had (a `messages.length === 0` latch that could not tell "never started" from "started, and the
|
||||||
* socket dropped").
|
* socket dropped").
|
||||||
*/
|
*/
|
||||||
function NewCommander({
|
function NewCommander({ category, onStarted }: { category: CommandHqCategoryFilter; onStarted: (id: string) => void }) {
|
||||||
category,
|
|
||||||
onStarted,
|
|
||||||
}: {
|
|
||||||
category: number | 'none' | undefined;
|
|
||||||
onStarted: (id: string) => void;
|
|
||||||
}) {
|
|
||||||
const [commander, setCommander] = useState<CommandHqCommanderCreated | null>(null);
|
const [commander, setCommander] = useState<CommandHqCommanderCreated | null>(null);
|
||||||
if (!commander) return <NewCommanderSetup category={category} onCreated={setCommander} />;
|
if (!commander) return <NewCommanderSetup category={category} onCreated={setCommander} />;
|
||||||
return <ActiveNewCommander commander={commander} category={category} onStarted={onStarted} />;
|
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 { toast } from '@/components/ui/sonner';
|
||||||
import { errorText } from 'helpers/error-text';
|
import { errorText } from 'helpers/error-text';
|
||||||
import { categoryFromParam, commandHqListPath, commandHqNewPath, commandHqSessionPath } from './command-hq-routes';
|
import { categoryFromParam, commandHqListPath, commandHqNewPath, commandHqSessionPath } from './command-hq-routes';
|
||||||
|
import { PluginCommanderOffers } from './PluginCommanderOffers';
|
||||||
import { useCommandHqCommanderActions, useCommandHqCommanders } from './useCommandHq';
|
import { useCommandHqCommanderActions, useCommandHqCommanders } from './useCommandHq';
|
||||||
import { formatCommanderCost, formatTokens } from './format';
|
import { formatCommanderCost, formatTokens } from './format';
|
||||||
import type { CommandHqCategoryFilter, CommandHqCommander } from './types';
|
import type { CommandHqCategoryFilter, CommandHqCommander } from './types';
|
||||||
@@ -170,6 +171,12 @@ export const CommandHqGrid = () => {
|
|||||||
row.
|
row.
|
||||||
*/}
|
*/}
|
||||||
<div className="flex flex-col gap-3 p-4">
|
<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) => (
|
{commanders.map((commander) => (
|
||||||
<Card
|
<Card
|
||||||
key={commander.id}
|
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 => {
|
export const categoryFromParam = (raw: string | null): CommandHqCategoryFilter => {
|
||||||
if (raw === null) return undefined;
|
if (raw === null) return undefined;
|
||||||
if (raw === 'none') return 'none';
|
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);
|
const id = Number(raw);
|
||||||
return Number.isFinite(id) ? id : undefined;
|
return Number.isFinite(id) ? id : undefined;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -26,8 +26,16 @@ export type CommandHqCostSummary = {
|
|||||||
totalUsd: number;
|
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`. */
|
/** One grid card — `ClaudeSessionSummary` plus what Command HQ knows about it. See `GET /commanders`. */
|
||||||
export type CommandHqCommander = {
|
export type CommandHqCommander = {
|
||||||
@@ -100,3 +108,23 @@ export type CommandHqFire = {
|
|||||||
taskId: string;
|
taskId: string;
|
||||||
firedAt: 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,
|
CommandHqCommander,
|
||||||
CommandHqCommanderCreated,
|
CommandHqCommanderCreated,
|
||||||
CommandHqCommanderDetail,
|
CommandHqCommanderDetail,
|
||||||
|
PluginCommanderTemplate,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
const CATEGORIES_KEY = ['command-hq', 'categories'] as const;
|
const CATEGORIES_KEY = ['command-hq', 'categories'] as const;
|
||||||
@@ -173,3 +174,36 @@ export function useCommandHqChildren(sessionId: string | undefined) {
|
|||||||
isLoading,
|
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.",
|
"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.",
|
"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": {
|
"chatHistory": {
|
||||||
"newChat": "Neuer Chat",
|
"newChat": "Neuer Chat",
|
||||||
|
|||||||
@@ -257,7 +257,11 @@
|
|||||||
},
|
},
|
||||||
"startHint": "It will look at what you pointed it to, then ask you what to watch for.",
|
"startHint": "It will look at what you pointed it to, then ask you what to watch for.",
|
||||||
"startBlocked": "{{missing}} — then this starts it.",
|
"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": {
|
"chatHistory": {
|
||||||
"newChat": "New Chat",
|
"newChat": "New Chat",
|
||||||
|
|||||||
@@ -257,7 +257,11 @@
|
|||||||
},
|
},
|
||||||
"startHint": "Mirará aquello que le has señalado y luego te preguntará qué debe vigilar.",
|
"startHint": "Mirará aquello que le has señalado y luego te preguntará qué debe vigilar.",
|
||||||
"startBlocked": "{{missing}} — y entonces esto lo inicia.",
|
"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": {
|
"chatHistory": {
|
||||||
"newChat": "Nuevo chat",
|
"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.",
|
"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.",
|
"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": {
|
"chatHistory": {
|
||||||
"newChat": "Nouveau Chat",
|
"newChat": "Nouveau Chat",
|
||||||
|
|||||||
@@ -257,7 +257,11 @@
|
|||||||
},
|
},
|
||||||
"startHint": "Vai olhar para aquilo que lhe indicou e depois perguntar-lhe o que deve vigiar.",
|
"startHint": "Vai olhar para aquilo que lhe indicou e depois perguntar-lhe o que deve vigiar.",
|
||||||
"startBlocked": "{{missing}} — depois é isto que o inicia.",
|
"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": {
|
"chatHistory": {
|
||||||
"newChat": "Novo chat",
|
"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.",
|
"startHint": "Den tittar på det du pekade den mot, och frågar dig sedan vad den ska bevaka.",
|
||||||
"startBlocked": "{{missing}} — sedan startar detta den.",
|
"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": {
|
"chatHistory": {
|
||||||
"newChat": "Ny chatt",
|
"newChat": "Ny chatt",
|
||||||
|
|||||||
Reference in New Issue
Block a user