an explicit per-line exemption, and command hq keyed (383 -> 344)

Adds `// i18n-exempt: <reason>`, which exempts the next line. The reason is mandatory
-- the regex will not match a bare marker -- so somebody has to write down why, and
`grep -rn i18n-exempt` lists every one for review.

It exists because CommandHQ's startBrief is a prompt written FOR THE MODEL: the person
sees a short displayText instead, and translating it would change how the agent
behaves. No syntactic rule can tell that from prose, and the alternative was a seventh
scanner heuristic. <Untranslated> covers JSX, this covers everything else, and together
they should mean the scanner needs no further special cases.

The new-commander guide was module-level data whose `body` paragraphs the scanner never
saw -- eight paragraphs of real user-facing prose. It carries titleKey and bodyKeys now
and the renderer resolves them.

Four implicit-return arrows needed bodies to hold the hook.
This commit is contained in:
2026-08-23 15:03:25 +00:00
parent d0904b2c84
commit 1e268d553d
7 changed files with 243 additions and 121 deletions
+17 -1
View File
@@ -126,6 +126,21 @@ const stripComments = (source: string): string =>
const stripCodeElements = (source: string): string => const stripCodeElements = (source: string): string =>
source.replace(/<(code|kbd|pre|samp)\b[^>]*>[\s\S]*?<\/\1>/g, '<$1/>'); source.replace(/<(code|kbd|pre|samp)\b[^>]*>[\s\S]*?<\/\1>/g, '<$1/>');
/**
* An explicit, per-line opt-out: `// i18n-exempt: <reason>` exempts the NEXT line.
*
* For the cases no syntactic rule can reach — chiefly a prompt written for a model rather than a
* reader. `<Untranslated>` covers JSX; this covers the rest, and the two together mean the scanner does
* not need another heuristic every time a new shape of never-translate string turns up.
*
* A reason is mandatory: the regex will not match a bare `// i18n-exempt`. That is the whole safeguard —
* somebody has to write down why, and `grep -rn "i18n-exempt"` lists every one for review.
*/
const stripExemptLines = (source: string): string => {
const lines = source.split('\n');
return lines.map((line, i) => (/\/\/\s*i18n-exempt:\s*\S/.test(lines[i - 1] ?? '') ? '' : line)).join('\n');
};
/** /**
* `<Untranslated reason="…">` marks text that must stay in one language — today, a label quoted from * `<Untranslated reason="…">` marks text that must stay in one language — today, a label quoted from
* another product's UI, which docs/localization.md classifies as an identifier. * another product's UI, which docs/localization.md classifies as an identifier.
@@ -169,7 +184,8 @@ function scan(root: string): { files: number; hits: Hit[] } {
files++; files++;
let source: string; let source: string;
try { try {
source = stripUntranslated(stripCodeElements(stripComments(readFileSync(file, 'utf8')))); // Exempt lines go FIRST: stripComments would remove the marker they depend on.
source = stripUntranslated(stripCodeElements(stripComments(stripExemptLines(readFileSync(file, 'utf8')))));
} catch { } catch {
continue; continue;
} }
@@ -6,6 +6,7 @@ import { errorText } from 'helpers/error-text';
import { categoryFromParam, commandHqListPath } from './command-hq-routes'; import { categoryFromParam, commandHqListPath } from './command-hq-routes';
import { useCommandHqCategories } from './useCommandHq'; import { useCommandHqCategories } from './useCommandHq';
import type { CommandHqCategoryFilter } from './types'; import type { CommandHqCategoryFilter } from './types';
import { useTranslation } from '../../locales';
// Left panel of the /command-hq workspace — a flat category list, click to filter the grid. Deliberately // Left panel of the /command-hq workspace — a flat category list, click to filter the grid. Deliberately
// simple for v1: no nesting, no search, no drag-to-reorder — see `docs/command-hq.md` → "Layout, v1". // simple for v1: no nesting, no search, no drag-to-reorder — see `docs/command-hq.md` → "Layout, v1".
@@ -32,6 +33,7 @@ const Row = ({ label, active, to, count }: RowProps) => (
); );
export const CommandHqCategories = () => { export const CommandHqCategories = () => {
const { t } = useTranslation();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const active: CommandHqCategoryFilter = categoryFromParam(searchParams.get('category')); const active: CommandHqCategoryFilter = categoryFromParam(searchParams.get('category'));
const { categories, createCategory } = useCommandHqCategories(); const { categories, createCategory } = useCommandHqCategories();
@@ -70,20 +72,22 @@ export const CommandHqCategories = () => {
*/ */
<div className="flex h-full min-h-0 flex-col bg-muted/30"> <div className="flex h-full min-h-0 flex-col bg-muted/30">
<div className="flex shrink-0 items-center justify-between border-b border-border px-3 py-2"> <div className="flex shrink-0 items-center justify-between border-b border-border px-3 py-2">
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Categories</span> <span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{t('commandHq.categories')}
</span>
<button <button
type="button" type="button"
onClick={() => setAdding(true)} onClick={() => setAdding(true)}
className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground" className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
title="New category" title={t('commandHq.newCategory')}
> >
<Plus className="h-3.5 w-3.5" /> <Plus className="h-3.5 w-3.5" />
</button> </button>
</div> </div>
<nav className="flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto p-2"> <nav className="flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto p-2">
<Row label="All commanders" active={active === undefined} to={commandHqListPath(undefined)} /> <Row label={t('commandHq.allCommanders')} active={active === undefined} to={commandHqListPath(undefined)} />
<Row label="Uncategorised" active={active === 'none'} to={commandHqListPath('none')} /> <Row label={t('commandHq.uncategorised')} active={active === 'none'} to={commandHqListPath('none')} />
{categories.map((category) => ( {categories.map((category) => (
<Row <Row
key={category.id} key={category.id}
@@ -106,13 +110,13 @@ export const CommandHqCategories = () => {
setAdding(false); setAdding(false);
} }
}} }}
placeholder="Category name" placeholder={t('commandHq.categoryName')}
className="mx-1 rounded-lg border border-border bg-background px-2 py-1.5 text-sm outline-none focus:border-primary" className="mx-1 rounded-lg border border-border bg-background px-2 py-1.5 text-sm outline-none focus:border-primary"
/> />
</form> </form>
)} )}
{!adding && categories.length === 0 && ( {!adding && categories.length === 0 && (
<div className="px-3 py-1 text-xs text-muted-foreground">No categories yet</div> <div className="px-3 py-1 text-xs text-muted-foreground">{t('commandHq.noCategories')}</div>
)} )}
</nav> </nav>
</div> </div>
@@ -20,6 +20,7 @@ import {
} from './useCommandHq'; } from './useCommandHq';
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';
// Right panel of the /command-hq workspace. Three states, chosen by the URL alone (no channel — panels // Right panel of the /command-hq workspace. Three states, chosen by the URL alone (no channel — panels
// read the route directly, per `docs/navigation-audit.md`): // read the route directly, per `docs/navigation-audit.md`):
@@ -33,10 +34,11 @@ import { NEW_COMMANDER_GUIDE } from './newCommanderGuide';
const CHAT_TAIL = 20; const CHAT_TAIL = 20;
function EmptyState() { function EmptyState() {
const { t, tUi } = useTranslation();
return ( return (
<div className="flex h-full flex-col items-center justify-center gap-2 text-center text-sm text-muted-foreground"> <div className="flex h-full flex-col items-center justify-center gap-2 text-center text-sm text-muted-foreground">
<Radar className="h-8 w-8 opacity-40" /> <Radar className="h-8 w-8 opacity-40" />
<p>Select a commander, or start a new one.</p> <p>{t('commandHq.selectOne')}</p>
</div> </div>
); );
} }
@@ -48,29 +50,32 @@ function EmptyState() {
* carries a visible marker so a guide that is still being written cannot be mistaken for one that is done, * carries a visible marker so a guide that is still being written cannot be mistaken for one that is done,
* which matters because this ships on every deploy whether the copy is ready or not. * which matters because this ships on every deploy whether the copy is ready or not.
*/ */
const NewCommanderGuide = () => ( const NewCommanderGuide = () => {
<div className="shrink-0 border-b border-border px-5 py-4"> const { t, tUi } = useTranslation();
<div className="mx-auto flex max-w-2xl flex-col gap-4"> return (
{NEW_COMMANDER_GUIDE.map((section) => ( <div className="shrink-0 border-b border-border px-5 py-4">
<section key={section.title}> <div className="mx-auto flex max-w-2xl flex-col gap-4">
<h3 className="flex items-center gap-2 text-sm font-semibold text-foreground"> {NEW_COMMANDER_GUIDE.map((section) => (
{section.title} <section key={section.titleKey}>
{section.provisional && ( <h3 className="flex items-center gap-2 text-sm font-semibold text-foreground">
<span className="rounded bg-warning/15 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-warning"> {t(section.titleKey)}
draft {section.provisional && (
</span> <span className="rounded bg-warning/15 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-warning">
)} {t('commandHq.guide.draft')}
</h3> </span>
{section.body.map((paragraph) => ( )}
<p key={paragraph} className="mt-1 text-sm leading-relaxed text-muted-foreground"> </h3>
{paragraph} {section.bodyKeys.map((bodyKey) => (
</p> <p key={bodyKey} className="mt-1 text-sm leading-relaxed text-muted-foreground">
))} {t(bodyKey)}
</section> </p>
))} ))}
</section>
))}
</div>
</div> </div>
</div> );
); };
/** /**
* The whole opening move, as one button. * The whole opening move, as one button.
@@ -86,6 +91,7 @@ const NewCommanderGuide = () => (
*/ */
const startBrief = (name: string): string => const startBrief = (name: string): string =>
[ [
// i18n-exempt: agent brief — the MODEL reads this; the transcript shows displayText to the person
`You have been named "${name}", and that is the only thing I have told you about what I want.`, `You have been named "${name}", and that is the only thing I have told you about what I want.`,
'It is not decoration — I chose it before anything else, so treat it as the subject and read this', 'It is not decoration — I chose it before anything else, so treat it as the subject and read this',
'directory looking for what it refers to.', 'directory looking for what it refers to.',
@@ -112,20 +118,21 @@ const startBrief = (name: string): string =>
'Take as long as you like reading first. The constraint is on what you say, not on what you do.', 'Take as long as you like reading first. The constraint is on what you say, not on what you do.',
].join('\n'); ].join('\n');
const StartCommanderButton = ({ ready, blockedBy, onStart }: StartCommanderButtonProps) => ( const StartCommanderButton = ({ ready, blockedBy, onStart }: StartCommanderButtonProps) => {
<div className="shrink-0 border-b border-border px-5 py-4"> const { t, tUi } = useTranslation();
<div className="mx-auto flex max-w-2xl flex-col items-start gap-2"> return (
<Button onClick={onStart} disabled={!ready} size="lg"> <div className="shrink-0 border-b border-border px-5 py-4">
Start the conversation <div className="mx-auto flex max-w-2xl flex-col items-start gap-2">
</Button> <Button onClick={onStart} disabled={!ready} size="lg">
<p className="text-xs text-muted-foreground"> {t('commandHq.startConversation')}
{ready </Button>
? 'It will look around this directory, then ask you what to watch for.' <p className="text-xs text-muted-foreground">
: `${blockedBy} — then this starts it.`} {ready ? t('commandHq.startHint') : t('commandHq.startBlocked', { missing: blockedBy })}
</p> </p>
</div>
</div> </div>
</div> );
); };
type StartCommanderButtonProps = { type StartCommanderButtonProps = {
ready: boolean; ready: boolean;
@@ -155,47 +162,50 @@ const CommanderSetup = ({
onName: (next: string) => void; onName: (next: string) => void;
cwd: string | null; cwd: string | null;
onPickDirectory: () => void; onPickDirectory: () => void;
}) => ( }) => {
<div className="shrink-0 border-b border-border bg-muted/20 px-5 py-4"> const { t, tUi } = useTranslation();
<div className="mx-auto flex max-w-2xl flex-col gap-3"> return (
<div className="flex flex-col gap-1"> <div className="shrink-0 border-b border-border bg-muted/20 px-5 py-4">
<label htmlFor="commander-name" className="text-xs font-medium uppercase tracking-wide text-muted-foreground"> <div className="mx-auto flex max-w-2xl flex-col gap-3">
Name <div className="flex flex-col gap-1">
</label> <label htmlFor="commander-name" className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
<input {t('commandHq.name')}
id="commander-name" </label>
autoFocus <input
value={name} id="commander-name"
onChange={(ev) => onName(ev.target.value)} autoFocus
placeholder="Platform deploys" value={name}
className="rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none focus:border-primary" onChange={(ev) => onName(ev.target.value)}
/> placeholder={t('commandHq.platformDeploys')}
{/* Said here because it is not obvious and it is not reversible cheaply: the harness would otherwise className="rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none focus:border-primary"
/>
{/* Said here because it is not obvious and it is not reversible cheaply: the harness would otherwise
name this after whatever the first message happens to say. */} name this after whatever the first message happens to say. */}
<span className="text-[11px] text-muted-foreground"> <span className="text-[11px] text-muted-foreground">{t('commandHq.nameHint')}</span>
Applied to the conversation as it starts, instead of the harness naming it after your first message. </div>
</span>
</div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Working directory</span> <span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
<button {t('commandHq.workingDirectory')}
type="button" </span>
onClick={onPickDirectory} <button
className={`flex items-center gap-2 rounded-lg border px-3 py-2 text-left text-sm transition-colors ${ type="button"
cwd ? 'border-border bg-background' : 'border-dashed border-border text-muted-foreground hover:bg-muted/40' onClick={onPickDirectory}
}`} className={`flex items-center gap-2 rounded-lg border px-3 py-2 text-left text-sm transition-colors ${
> cwd
<FolderOpen className="h-4 w-4 shrink-0" /> ? 'border-border bg-background'
<span className="min-w-0 flex-1 truncate font-mono text-xs">{cwd ?? 'Choose a directory…'}</span> : 'border-dashed border-border text-muted-foreground hover:bg-muted/40'
</button> }`}
<span className="text-[11px] text-muted-foreground"> >
Where every command it runs resolves from, and where its history is kept. It cannot be moved later. <FolderOpen className="h-4 w-4 shrink-0" />
</span> <span className="min-w-0 flex-1 truncate font-mono text-xs">{cwd ?? 'Choose a directory…'}</span>
</button>
<span className="text-[11px] text-muted-foreground">{t('commandHq.cwdHint')}</span>
</div>
</div> </div>
</div> </div>
</div> );
); };
/** /**
* A commander started in THIS pane, and it keeps the same live chat for as long as the pane is open. * A commander started in THIS pane, and it keeps the same live chat for as long as the pane is open.
@@ -223,6 +233,7 @@ function NewCommander({
category: number | 'none' | undefined; category: number | 'none' | undefined;
onStarted: (id: string) => void; onStarted: (id: string) => void;
}) { }) {
const { t, tUi } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
/* /*
A name and a working directory, both required BEFORE the first message. A name and a working directory, both required BEFORE the first message.
@@ -333,7 +344,7 @@ function NewCommander({
/> />
) : ( ) : (
<div className="flex shrink-0 items-center gap-2 border-b border-border bg-background/60 px-4 py-2 text-sm font-medium text-foreground/80"> <div className="flex shrink-0 items-center gap-2 border-b border-border bg-background/60 px-4 py-2 text-sm font-medium text-foreground/80">
New commander {t('commandHq.new')}
</div> </div>
)} )}
{/* `context` is a prop of EmbeddableChat, and it is NOT the `context` handed to `useChat` above. {/* `context` is a prop of EmbeddableChat, and it is NOT the `context` handed to `useChat` above.
@@ -407,13 +418,14 @@ function NewCommander({
* the normal case and does not need a box explaining that. * the normal case and does not need a box explaining that.
*/ */
const SpawnedChildren = ({ sessionId }: { sessionId: string }) => { const SpawnedChildren = ({ sessionId }: { sessionId: string }) => {
const { t, tUi } = useTranslation();
const { children, totalUsd, unknownCost } = useCommandHqChildren(sessionId); const { children, totalUsd, unknownCost } = useCommandHqChildren(sessionId);
if (!children.length) return null; if (!children.length) return null;
return ( return (
<div className="shrink-0 border-b border-border px-4 py-2"> <div className="shrink-0 border-b border-border px-4 py-2">
<div className="flex items-baseline gap-2 text-xs text-muted-foreground"> <div className="flex items-baseline gap-2 text-xs text-muted-foreground">
<span className="font-medium uppercase tracking-wide">Spawned</span> <span className="font-medium uppercase tracking-wide">{t('commandHq.spawned')}</span>
<span>{children.length}</span> <span>{children.length}</span>
{/* {/*
Cost is stated as a floor when any child's is unknown, never as a confident total. A commander's Cost is stated as a floor when any child's is unknown, never as a confident total. A commander's
@@ -460,6 +472,7 @@ type MetaBarProps = {
}; };
function MetaBar({ sessionId, title, isConnected, isGenerating, onDisconnect }: MetaBarProps) { function MetaBar({ sessionId, title, isConnected, isGenerating, onDisconnect }: MetaBarProps) {
const { t, tUi } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const category = categoryFromParam(searchParams.get('category')); const category = categoryFromParam(searchParams.get('category'));
@@ -511,7 +524,7 @@ function MetaBar({ sessionId, title, isConnected, isGenerating, onDisconnect }:
<button <button
type="button" type="button"
onClick={onDisconnect} onClick={onDisconnect}
title="End session — releases it so it can be resumed elsewhere" title={t('commandHq.endSession')}
className="ml-1 rounded p-1 hover:bg-muted hover:text-destructive" className="ml-1 rounded p-1 hover:bg-muted hover:text-destructive"
> >
<Unplug className="h-3.5 w-3.5" /> <Unplug className="h-3.5 w-3.5" />
@@ -519,12 +532,12 @@ function MetaBar({ sessionId, title, isConnected, isGenerating, onDisconnect }:
)} )}
{confirmingDelete ? ( {confirmingDelete ? (
<span className="ml-1 flex items-center gap-1"> <span className="ml-1 flex items-center gap-1">
<span className="text-destructive">Delete?</span> <span className="text-destructive">{t('commandHq.deleteQ')}</span>
<button <button
type="button" type="button"
onClick={() => void confirmDelete()} onClick={() => void confirmDelete()}
title="Confirm delete — removes the conversation too, not just the Command HQ entry" title={t('commandHq.confirmDeleteHint')}
aria-label="Confirm delete" aria-label={t('commandHq.confirmDelete')}
className="rounded p-1 text-destructive hover:bg-destructive/10" className="rounded p-1 text-destructive hover:bg-destructive/10"
> >
<Check className="h-3.5 w-3.5" /> <Check className="h-3.5 w-3.5" />
@@ -532,8 +545,8 @@ function MetaBar({ sessionId, title, isConnected, isGenerating, onDisconnect }:
<button <button
type="button" type="button"
onClick={() => setConfirmingDelete(false)} onClick={() => setConfirmingDelete(false)}
title="Cancel" title={tUi('button.cancel')}
aria-label="Cancel delete" aria-label={t('commandHq.cancelDelete')}
className="rounded p-1 opacity-50 hover:opacity-100" className="rounded p-1 opacity-50 hover:opacity-100"
> >
<X className="h-3.5 w-3.5" /> <X className="h-3.5 w-3.5" />
@@ -543,8 +556,8 @@ function MetaBar({ sessionId, title, isConnected, isGenerating, onDisconnect }:
<button <button
type="button" type="button"
onClick={() => setConfirmingDelete(true)} onClick={() => setConfirmingDelete(true)}
title="Delete commander" title={t('commandHq.deleteCommander')}
aria-label="Delete commander" aria-label={t('commandHq.deleteCommander')}
className="ml-1 rounded p-1 hover:bg-muted hover:text-destructive" className="ml-1 rounded p-1 hover:bg-muted hover:text-destructive"
> >
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
@@ -559,7 +572,7 @@ function MetaBar({ sessionId, title, isConnected, isGenerating, onDisconnect }:
onChange={(ev) => setCategory.mutate(ev.target.value === '' ? null : Number(ev.target.value))} onChange={(ev) => setCategory.mutate(ev.target.value === '' ? null : Number(ev.target.value))}
className="rounded border border-border bg-background px-1.5 py-0.5 text-xs" className="rounded border border-border bg-background px-1.5 py-0.5 text-xs"
> >
<option value="">Uncategorised</option> <option value="">{t('commandHq.uncategorised')}</option>
{categories.map((c) => ( {categories.map((c) => (
<option key={c.id} value={c.id}> <option key={c.id} value={c.id}>
{c.name} {c.name}
@@ -620,6 +633,7 @@ type ResumeState = {
* Same shape here. * Same shape here.
*/ */
function ResumedCommander({ sessionId, resume }: { sessionId: string; resume: ResumeState }) { function ResumedCommander({ sessionId, resume }: { sessionId: string; resume: ResumeState }) {
const { t, tUi } = useTranslation();
const chat = useChat(undefined, resume.model ?? undefined, { const chat = useChat(undefined, resume.model ?? undefined, {
replaceUrl: false, replaceUrl: false,
context: 'command-hq', context: 'command-hq',
@@ -651,6 +665,7 @@ function ResumedCommander({ sessionId, resume }: { sessionId: string; resume: Re
/** Fetches the transcript and nothing else. Mounts the chat only when it has one. */ /** Fetches the transcript and nothing else. Mounts the chat only when it has one. */
function ExistingCommander({ sessionId }: { sessionId: string }) { function ExistingCommander({ sessionId }: { sessionId: string }) {
const { t, tUi } = useTranslation();
const client = useClient(); const client = useClient();
const [resume, setResume] = useState<ResumeState | undefined>(undefined); const [resume, setResume] = useState<ResumeState | undefined>(undefined);
const [resuming, setResuming] = useState(true); const [resuming, setResuming] = useState(true);
@@ -683,7 +698,11 @@ function ExistingCommander({ sessionId }: { sessionId: string }) {
}, [sessionId]); }, [sessionId]);
if (resuming || !resume) { if (resuming || !resume) {
return <div className="flex h-full items-center justify-center text-sm text-muted-foreground">Loading…</div>; return (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
{t('commandHq.loading')}
</div>
);
} }
return <ResumedCommander key={sessionId} sessionId={sessionId} resume={resume} />; return <ResumedCommander key={sessionId} sessionId={sessionId} resume={resume} />;
@@ -8,6 +8,7 @@ import { categoryFromParam, commandHqListPath, commandHqNewPath, commandHqSessio
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';
import { useTranslation } from '../../locales';
// 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
@@ -39,6 +40,7 @@ type CardProps = {
* control sits in its own stacking context (`z-10`) to intercept its own clicks first. * control sits in its own stacking context (`z-10`) to intercept its own clicks first.
*/ */
const Card = ({ commander, to, selected, onDeleted }: CardProps) => { const Card = ({ commander, to, selected, onDeleted }: CardProps) => {
const { t, tUi } = useTranslation();
const [confirming, setConfirming] = useState(false); const [confirming, setConfirming] = useState(false);
const { deleteCommander } = useCommandHqCommanderActions(commander.id); const { deleteCommander } = useCommandHqCommanderActions(commander.id);
@@ -84,16 +86,16 @@ const Card = ({ commander, to, selected, onDeleted }: CardProps) => {
<button <button
onClick={() => void confirmDelete()} onClick={() => void confirmDelete()}
className="cursor-pointer rounded p-1 text-destructive hover:bg-destructive/10" 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" title={t('commandHq.confirmDeleteHint')}
aria-label={`Confirm deleting ${commander.title}`} aria-label={t('commandHq.confirmDeletingNamed', { name: commander.title })}
> >
<Check className="h-3.5 w-3.5" /> <Check className="h-3.5 w-3.5" />
</button> </button>
<button <button
onClick={() => setConfirming(false)} onClick={() => setConfirming(false)}
className="cursor-pointer rounded p-1 opacity-50 hover:opacity-100" className="cursor-pointer rounded p-1 opacity-50 hover:opacity-100"
title="Cancel" title={tUi('button.cancel')}
aria-label="Cancel delete" aria-label={t('commandHq.cancelDelete')}
> >
<X className="h-3.5 w-3.5" /> <X className="h-3.5 w-3.5" />
</button> </button>
@@ -102,8 +104,8 @@ const Card = ({ commander, to, selected, onDeleted }: CardProps) => {
<button <button
onClick={() => setConfirming(true)} 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" 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" title={t('commandHq.deleteCommander')}
aria-label={`Delete ${commander.title}`} aria-label={t('commandHq.deleteNamed', { name: commander.title })}
> >
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
</button> </button>
@@ -113,6 +115,7 @@ const Card = ({ commander, to, selected, onDeleted }: CardProps) => {
}; };
export const CommandHqGrid = () => { export const CommandHqGrid = () => {
const { t, tUi } = useTranslation();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const category: CommandHqCategoryFilter = categoryFromParam(searchParams.get('category')); const category: CommandHqCategoryFilter = categoryFromParam(searchParams.get('category'));
const { sessionId } = useParams<{ sessionId: string }>(); const { sessionId } = useParams<{ sessionId: string }>();
@@ -136,7 +139,7 @@ export const CommandHqGrid = () => {
className="flex items-center gap-1.5 rounded-lg bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:opacity-90" className="flex items-center gap-1.5 rounded-lg bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:opacity-90"
> >
<Plus className="h-3.5 w-3.5" /> <Plus className="h-3.5 w-3.5" />
New commander {t('commandHq.new')}
</Link> </Link>
</div> </div>
@@ -163,12 +166,12 @@ export const CommandHqGrid = () => {
{!isLoading && commanders.length === 0 && ( {!isLoading && commanders.length === 0 && (
<div className="flex flex-1 flex-col items-center justify-center gap-3 text-center text-sm text-muted-foreground"> <div className="flex flex-1 flex-col items-center justify-center gap-3 text-center text-sm text-muted-foreground">
<p>No commanders here yet.</p> <p>{t('commandHq.none')}</p>
<Link <Link
to={commandHqNewPath(category)} to={commandHqNewPath(category)}
className="rounded-lg bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:opacity-90" className="rounded-lg bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:opacity-90"
> >
Start one {t('commandHq.startOne')}
</Link> </Link>
</div> </div>
)} )}
@@ -1,3 +1,5 @@
import type { PanelTranslationKey } from '../../locales';
/* /*
What the pane says before a new commander has been spoken to. What the pane says before a new commander has been spoken to.
@@ -17,28 +19,22 @@
*/ */
export type GuideSection = { export type GuideSection = {
title: string; titleKey: PanelTranslationKey;
/** One paragraph per entry. Kept as an array so a section can grow without touching the renderer. */ /** One paragraph per entry. Kept as an array so a section can grow without touching the renderer. */
body: string[]; bodyKeys: PanelTranslationKey[];
/** True until the copy is settled. Renders a marker; the whole point is that it is visible. */ /** True until the copy is settled. Renders a marker; the whole point is that it is visible. */
provisional?: boolean; provisional?: boolean;
}; };
export const NEW_COMMANDER_GUIDE: GuideSection[] = [ export const NEW_COMMANDER_GUIDE: GuideSection[] = [
{ {
title: 'What a commander is', titleKey: 'commandHq.guide.whatIsIt',
body: [ bodyKeys: ['commandHq.guide.whatIsItBody1', 'commandHq.guide.whatIsItBody2'],
'An agent that keeps working when you are not here — it waits for something to happen, acts on it, and reports back.',
'It is an ordinary conversation. You start by telling it what to watch for and what to do about it, in your own words.',
],
}, },
{ {
title: 'Starting one', titleKey: 'commandHq.guide.starting',
provisional: true, provisional: true,
body: [ bodyKeys: ['commandHq.guide.startingBody1', 'commandHq.guide.startingBody2'],
'Describe the job here, as you would to anybody. Say what it should watch, where, and what to do when it fires.',
'TODO: the concrete steps, once the creation flow is settled. This replaces "open the background-job dialog and arm a watcher", which nobody could be expected to guess.',
],
}, },
{ {
// Requested 2026-08-22, deliberately NOT written yet: an example first prompt is a promise about what // Requested 2026-08-22, deliberately NOT written yet: an example first prompt is a promise about what
@@ -48,17 +44,13 @@ export const NEW_COMMANDER_GUIDE: GuideSection[] = [
// Two or three, and they should differ in SHAPE rather than in subject: one where the trigger is // Two or three, and they should differ in SHAPE rather than in subject: one where the trigger is
// obvious, one where it needs a conversation to pin down, one that is plainly not a commander at all so // obvious, one where it needs a conversation to pin down, one that is plainly not a commander at all so
// the boundary is visible. // the boundary is visible.
title: 'Examples of a first message', titleKey: 'commandHq.guide.examples',
provisional: true, provisional: true,
body: [ bodyKeys: ['commandHq.guide.examplesBody1'],
'TODO: two or three example opening prompts, once the creation flow is settled. They are what turns "describe the job" from an instruction into something a first-time user can act on.',
],
}, },
{ {
title: 'While it runs', titleKey: 'commandHq.guide.whileRunning',
provisional: true, provisional: true,
body: [ bodyKeys: ['commandHq.guide.whileRunningBody1'],
'TODO: what the owner sees — where its cost shows, how to read what its spawned agents are doing, and how to stop it.',
],
}, },
]; ];
@@ -213,5 +213,49 @@
"sidecar": "Sidecar", "sidecar": "Sidecar",
"deleteNamed": "Delete {{name}}", "deleteNamed": "Delete {{name}}",
"updateTo": "Update to {{version}}" "updateTo": "Update to {{version}}"
},
"commandHq": {
"selectOne": "Select a commander, or start a new one.",
"startConversation": "Start the conversation",
"name": "Name",
"nameHint": "Applied to the conversation as it starts, instead of the harness naming it after your first message.",
"workingDirectory": "Working directory",
"cwdHint": "Where every command it runs resolves from, and where its history is kept. It cannot be moved later.",
"new": "New commander",
"spawned": "Spawned",
"deleteQ": "Delete?",
"uncategorised": "Uncategorised",
"loading": "Loading…",
"platformDeploys": "Platform deploys",
"endSession": "End session — releases it so it can be resumed elsewhere",
"confirmDeleteHint": "Confirm delete — removes the conversation too, not just the Command HQ entry",
"confirmDelete": "Confirm delete",
"cancelDelete": "Cancel delete",
"deleteCommander": "Delete commander",
"none": "No commanders here yet.",
"startOne": "Start one",
"confirmDeleteNamed": "Confirm deleting {{name}}",
"deleteNamed": "Delete {{name}}",
"categories": "Categories",
"noCategories": "No categories yet",
"newCategory": "New category",
"allCommanders": "All commanders",
"categoryName": "Category name",
"guide": {
"whatIsIt": "What a commander is",
"starting": "Starting one",
"examples": "Examples of a first message",
"whileRunning": "While it runs",
"whatIsItBody1": "An agent that keeps working when you are not here — it waits for something to happen, acts on it, and reports back.",
"whatIsItBody2": "It is an ordinary conversation. You start by telling it what to watch for and what to do about it, in your own words.",
"startingBody1": "Describe the job here, as you would to anybody. Say what it should watch, where, and what to do when it fires.",
"startingBody2": "TODO: the concrete steps, once the creation flow is settled. This replaces “open the background-job dialog and arm a watcher”, which nobody could be expected to guess.",
"examplesBody1": "TODO: two or three example opening prompts, once the creation flow is settled. They are what turns “describe the job” from an instruction into something a first-time user can act on.",
"whileRunningBody1": "TODO: what the owner sees — where its cost shows, how to read what its spawned agents are doing, and how to stop it.",
"draft": "draft"
},
"startHint": "It will look around this directory, then ask you what to watch for.",
"startBlocked": "{{missing}} — then this starts it.",
"confirmDeletingNamed": "Confirm deleting {{name}}"
} }
} }
@@ -213,5 +213,49 @@
"sidecar": "Sidecar", "sidecar": "Sidecar",
"deleteNamed": "Eliminar {{name}}", "deleteNamed": "Eliminar {{name}}",
"updateTo": "Atualizar para {{version}}" "updateTo": "Atualizar para {{version}}"
},
"commandHq": {
"selectOne": "Selecione um comandante ou inicie um novo.",
"startConversation": "Iniciar a conversa",
"name": "Nome",
"nameHint": "Aplicado à conversa quando esta começa, em vez de o harness a nomear a partir da sua primeira mensagem.",
"workingDirectory": "Diretório de trabalho",
"cwdHint": "A partir de onde todos os comandos que executa são resolvidos, e onde o histórico é guardado. Não pode ser movido depois.",
"new": "Novo comandante",
"spawned": "Iniciado",
"deleteQ": "Eliminar?",
"uncategorised": "Sem categoria",
"loading": "A carregar…",
"platformDeploys": "Implementações da plataforma",
"endSession": "Terminar sessão — liberta-a para poder ser retomada noutro lugar",
"confirmDeleteHint": "Confirmar eliminação — remove também a conversa, não apenas a entrada do Command HQ",
"confirmDelete": "Confirmar eliminação",
"cancelDelete": "Cancelar eliminação",
"deleteCommander": "Eliminar comandante",
"none": "Ainda não há comandantes aqui.",
"startOne": "Iniciar um",
"confirmDeleteNamed": "Confirmar a eliminação de {{name}}",
"deleteNamed": "Eliminar {{name}}",
"categories": "Categorias",
"noCategories": "Ainda não há categorias",
"newCategory": "Nova categoria",
"allCommanders": "Todos os comandantes",
"categoryName": "Nome da categoria",
"guide": {
"whatIsIt": "O que é um comandante",
"starting": "Como iniciar um",
"examples": "Exemplos de uma primeira mensagem",
"whileRunning": "Enquanto corre",
"whatIsItBody1": "Um agente que continua a trabalhar quando não está aqui — espera que algo aconteça, age em consequência e dá conta do resultado.",
"whatIsItBody2": "É uma conversa comum. Começa por lhe dizer o que deve vigiar e o que fazer quando isso acontecer, pelas suas próprias palavras.",
"startingBody1": "Descreva aqui o trabalho, como o faria a qualquer pessoa. Diga o que deve vigiar, onde, e o que fazer quando acontecer.",
"startingBody2": "TODO: os passos concretos, quando o fluxo de criação estiver definido. Isto substitui “abrir a caixa de diálogo de trabalhos em segundo plano e armar um vigilante”, que ninguém poderia adivinhar.",
"examplesBody1": "TODO: dois ou três exemplos de mensagens iniciais, quando o fluxo de criação estiver definido. São eles que transformam “descreva o trabalho” de uma instrução em algo sobre o qual um utilizador novo pode agir.",
"whileRunningBody1": "TODO: o que o proprietário vê — onde aparece o custo, como ler o que os agentes que ele inicia estão a fazer, e como o parar.",
"draft": "rascunho"
},
"startHint": "Vai observar este diretório e depois perguntar-lhe o que deve vigiar.",
"startBlocked": "{{missing}} — depois é isto que o inicia.",
"confirmDeletingNamed": "Confirmar a eliminação de {{name}}"
} }
} }