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:
@@ -126,6 +126,21 @@ const stripComments = (source: string): string =>
|
||||
const stripCodeElements = (source: string): string =>
|
||||
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
|
||||
* 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++;
|
||||
let source: string;
|
||||
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 {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { errorText } from 'helpers/error-text';
|
||||
import { categoryFromParam, commandHqListPath } from './command-hq-routes';
|
||||
import { useCommandHqCategories } from './useCommandHq';
|
||||
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
|
||||
// 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 = () => {
|
||||
const { t } = useTranslation();
|
||||
const [searchParams] = useSearchParams();
|
||||
const active: CommandHqCategoryFilter = categoryFromParam(searchParams.get('category'));
|
||||
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 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
|
||||
type="button"
|
||||
onClick={() => setAdding(true)}
|
||||
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" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<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="Uncategorised" active={active === 'none'} to={commandHqListPath('none')} />
|
||||
<Row label={t('commandHq.allCommanders')} active={active === undefined} to={commandHqListPath(undefined)} />
|
||||
<Row label={t('commandHq.uncategorised')} active={active === 'none'} to={commandHqListPath('none')} />
|
||||
{categories.map((category) => (
|
||||
<Row
|
||||
key={category.id}
|
||||
@@ -106,13 +110,13 @@ export const CommandHqCategories = () => {
|
||||
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"
|
||||
/>
|
||||
</form>
|
||||
)}
|
||||
{!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>
|
||||
</div>
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from './useCommandHq';
|
||||
import { formatCommanderCost, formatTokens } from './format';
|
||||
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
|
||||
// read the route directly, per `docs/navigation-audit.md`):
|
||||
@@ -33,10 +34,11 @@ import { NEW_COMMANDER_GUIDE } from './newCommanderGuide';
|
||||
const CHAT_TAIL = 20;
|
||||
|
||||
function EmptyState() {
|
||||
const { t, tUi } = useTranslation();
|
||||
return (
|
||||
<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" />
|
||||
<p>Select a commander, or start a new one.</p>
|
||||
<p>{t('commandHq.selectOne')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -48,22 +50,24 @@ function EmptyState() {
|
||||
* 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.
|
||||
*/
|
||||
const NewCommanderGuide = () => (
|
||||
const NewCommanderGuide = () => {
|
||||
const { t, tUi } = useTranslation();
|
||||
return (
|
||||
<div className="shrink-0 border-b border-border px-5 py-4">
|
||||
<div className="mx-auto flex max-w-2xl flex-col gap-4">
|
||||
{NEW_COMMANDER_GUIDE.map((section) => (
|
||||
<section key={section.title}>
|
||||
<section key={section.titleKey}>
|
||||
<h3 className="flex items-center gap-2 text-sm font-semibold text-foreground">
|
||||
{section.title}
|
||||
{t(section.titleKey)}
|
||||
{section.provisional && (
|
||||
<span className="rounded bg-warning/15 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-warning">
|
||||
draft
|
||||
{t('commandHq.guide.draft')}
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
{section.body.map((paragraph) => (
|
||||
<p key={paragraph} className="mt-1 text-sm leading-relaxed text-muted-foreground">
|
||||
{paragraph}
|
||||
{section.bodyKeys.map((bodyKey) => (
|
||||
<p key={bodyKey} className="mt-1 text-sm leading-relaxed text-muted-foreground">
|
||||
{t(bodyKey)}
|
||||
</p>
|
||||
))}
|
||||
</section>
|
||||
@@ -71,6 +75,7 @@ const NewCommanderGuide = () => (
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* The whole opening move, as one button.
|
||||
@@ -86,6 +91,7 @@ const NewCommanderGuide = () => (
|
||||
*/
|
||||
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.`,
|
||||
'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.',
|
||||
@@ -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.',
|
||||
].join('\n');
|
||||
|
||||
const StartCommanderButton = ({ ready, blockedBy, onStart }: StartCommanderButtonProps) => (
|
||||
const StartCommanderButton = ({ ready, blockedBy, onStart }: StartCommanderButtonProps) => {
|
||||
const { t, tUi } = useTranslation();
|
||||
return (
|
||||
<div className="shrink-0 border-b border-border px-5 py-4">
|
||||
<div className="mx-auto flex max-w-2xl flex-col items-start gap-2">
|
||||
<Button onClick={onStart} disabled={!ready} size="lg">
|
||||
Start the conversation
|
||||
{t('commandHq.startConversation')}
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{ready
|
||||
? 'It will look around this directory, then ask you what to watch for.'
|
||||
: `${blockedBy} — then this starts it.`}
|
||||
{ready ? t('commandHq.startHint') : t('commandHq.startBlocked', { missing: blockedBy })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type StartCommanderButtonProps = {
|
||||
ready: boolean;
|
||||
@@ -155,47 +162,50 @@ const CommanderSetup = ({
|
||||
onName: (next: string) => void;
|
||||
cwd: string | null;
|
||||
onPickDirectory: () => void;
|
||||
}) => (
|
||||
}) => {
|
||||
const { t, tUi } = useTranslation();
|
||||
return (
|
||||
<div className="shrink-0 border-b border-border bg-muted/20 px-5 py-4">
|
||||
<div className="mx-auto flex max-w-2xl flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="commander-name" className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Name
|
||||
{t('commandHq.name')}
|
||||
</label>
|
||||
<input
|
||||
id="commander-name"
|
||||
autoFocus
|
||||
value={name}
|
||||
onChange={(ev) => onName(ev.target.value)}
|
||||
placeholder="Platform deploys"
|
||||
placeholder={t('commandHq.platformDeploys')}
|
||||
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. */}
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
Applied to the conversation as it starts, instead of the harness naming it after your first message.
|
||||
</span>
|
||||
<span className="text-[11px] text-muted-foreground">{t('commandHq.nameHint')}</span>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
{t('commandHq.workingDirectory')}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPickDirectory}
|
||||
className={`flex items-center gap-2 rounded-lg border px-3 py-2 text-left text-sm transition-colors ${
|
||||
cwd ? 'border-border bg-background' : 'border-dashed border-border text-muted-foreground hover:bg-muted/40'
|
||||
cwd
|
||||
? 'border-border bg-background'
|
||||
: 'border-dashed border-border text-muted-foreground hover:bg-muted/40'
|
||||
}`}
|
||||
>
|
||||
<FolderOpen className="h-4 w-4 shrink-0" />
|
||||
<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">
|
||||
Where every command it runs resolves from, and where its history is kept. It cannot be moved later.
|
||||
</span>
|
||||
<span className="text-[11px] text-muted-foreground">{t('commandHq.cwdHint')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 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;
|
||||
onStarted: (id: string) => void;
|
||||
}) {
|
||||
const { t, tUi } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
/*
|
||||
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">
|
||||
New commander
|
||||
{t('commandHq.new')}
|
||||
</div>
|
||||
)}
|
||||
{/* `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.
|
||||
*/
|
||||
const SpawnedChildren = ({ sessionId }: { sessionId: string }) => {
|
||||
const { t, tUi } = useTranslation();
|
||||
const { children, totalUsd, unknownCost } = useCommandHqChildren(sessionId);
|
||||
if (!children.length) return null;
|
||||
|
||||
return (
|
||||
<div className="shrink-0 border-b border-border px-4 py-2">
|
||||
<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>
|
||||
{/*
|
||||
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) {
|
||||
const { t, tUi } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const category = categoryFromParam(searchParams.get('category'));
|
||||
@@ -511,7 +524,7 @@ function MetaBar({ sessionId, title, isConnected, isGenerating, onDisconnect }:
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
<Unplug className="h-3.5 w-3.5" />
|
||||
@@ -519,12 +532,12 @@ function MetaBar({ sessionId, title, isConnected, isGenerating, onDisconnect }:
|
||||
)}
|
||||
{confirmingDelete ? (
|
||||
<span className="ml-1 flex items-center gap-1">
|
||||
<span className="text-destructive">Delete?</span>
|
||||
<span className="text-destructive">{t('commandHq.deleteQ')}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void confirmDelete()}
|
||||
title="Confirm delete — removes the conversation too, not just the Command HQ entry"
|
||||
aria-label="Confirm delete"
|
||||
title={t('commandHq.confirmDeleteHint')}
|
||||
aria-label={t('commandHq.confirmDelete')}
|
||||
className="rounded p-1 text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
@@ -532,8 +545,8 @@ function MetaBar({ sessionId, title, isConnected, isGenerating, onDisconnect }:
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmingDelete(false)}
|
||||
title="Cancel"
|
||||
aria-label="Cancel delete"
|
||||
title={tUi('button.cancel')}
|
||||
aria-label={t('commandHq.cancelDelete')}
|
||||
className="rounded p-1 opacity-50 hover:opacity-100"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
@@ -543,8 +556,8 @@ function MetaBar({ sessionId, title, isConnected, isGenerating, onDisconnect }:
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmingDelete(true)}
|
||||
title="Delete commander"
|
||||
aria-label="Delete commander"
|
||||
title={t('commandHq.deleteCommander')}
|
||||
aria-label={t('commandHq.deleteCommander')}
|
||||
className="ml-1 rounded p-1 hover:bg-muted hover:text-destructive"
|
||||
>
|
||||
<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))}
|
||||
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) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
@@ -620,6 +633,7 @@ type ResumeState = {
|
||||
* Same shape here.
|
||||
*/
|
||||
function ResumedCommander({ sessionId, resume }: { sessionId: string; resume: ResumeState }) {
|
||||
const { t, tUi } = useTranslation();
|
||||
const chat = useChat(undefined, resume.model ?? undefined, {
|
||||
replaceUrl: false,
|
||||
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. */
|
||||
function ExistingCommander({ sessionId }: { sessionId: string }) {
|
||||
const { t, tUi } = useTranslation();
|
||||
const client = useClient();
|
||||
const [resume, setResume] = useState<ResumeState | undefined>(undefined);
|
||||
const [resuming, setResuming] = useState(true);
|
||||
@@ -683,7 +698,11 @@ function ExistingCommander({ sessionId }: { sessionId: string }) {
|
||||
}, [sessionId]);
|
||||
|
||||
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} />;
|
||||
|
||||
@@ -8,6 +8,7 @@ import { categoryFromParam, commandHqListPath, commandHqNewPath, commandHqSessio
|
||||
import { useCommandHqCommanderActions, useCommandHqCommanders } from './useCommandHq';
|
||||
import { formatCommanderCost, formatTokens } from './format';
|
||||
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.
|
||||
// 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.
|
||||
*/
|
||||
const Card = ({ commander, to, selected, onDeleted }: CardProps) => {
|
||||
const { t, tUi } = useTranslation();
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const { deleteCommander } = useCommandHqCommanderActions(commander.id);
|
||||
|
||||
@@ -84,16 +86,16 @@ const Card = ({ commander, to, selected, onDeleted }: CardProps) => {
|
||||
<button
|
||||
onClick={() => void confirmDelete()}
|
||||
className="cursor-pointer rounded p-1 text-destructive hover:bg-destructive/10"
|
||||
title="Confirm delete — removes the conversation too, not just the Command HQ entry"
|
||||
aria-label={`Confirm deleting ${commander.title}`}
|
||||
title={t('commandHq.confirmDeleteHint')}
|
||||
aria-label={t('commandHq.confirmDeletingNamed', { name: commander.title })}
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirming(false)}
|
||||
className="cursor-pointer rounded p-1 opacity-50 hover:opacity-100"
|
||||
title="Cancel"
|
||||
aria-label="Cancel delete"
|
||||
title={tUi('button.cancel')}
|
||||
aria-label={t('commandHq.cancelDelete')}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
@@ -102,8 +104,8 @@ const Card = ({ commander, to, selected, onDeleted }: CardProps) => {
|
||||
<button
|
||||
onClick={() => setConfirming(true)}
|
||||
className="absolute right-2 top-2 z-10 cursor-pointer rounded p-1 text-muted-foreground opacity-0 transition-opacity hover:text-destructive focus-visible:opacity-100 group-hover:opacity-100"
|
||||
title="Delete commander"
|
||||
aria-label={`Delete ${commander.title}`}
|
||||
title={t('commandHq.deleteCommander')}
|
||||
aria-label={t('commandHq.deleteNamed', { name: commander.title })}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
@@ -113,6 +115,7 @@ const Card = ({ commander, to, selected, onDeleted }: CardProps) => {
|
||||
};
|
||||
|
||||
export const CommandHqGrid = () => {
|
||||
const { t, tUi } = useTranslation();
|
||||
const [searchParams] = useSearchParams();
|
||||
const category: CommandHqCategoryFilter = categoryFromParam(searchParams.get('category'));
|
||||
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"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
New commander
|
||||
{t('commandHq.new')}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -163,12 +166,12 @@ export const CommandHqGrid = () => {
|
||||
|
||||
{!isLoading && commanders.length === 0 && (
|
||||
<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
|
||||
to={commandHqNewPath(category)}
|
||||
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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { PanelTranslationKey } from '../../locales';
|
||||
|
||||
/*
|
||||
What the pane says before a new commander has been spoken to.
|
||||
|
||||
@@ -17,28 +19,22 @@
|
||||
*/
|
||||
|
||||
export type GuideSection = {
|
||||
title: string;
|
||||
titleKey: PanelTranslationKey;
|
||||
/** 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. */
|
||||
provisional?: boolean;
|
||||
};
|
||||
|
||||
export const NEW_COMMANDER_GUIDE: GuideSection[] = [
|
||||
{
|
||||
title: 'What a commander is',
|
||||
body: [
|
||||
'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.',
|
||||
],
|
||||
titleKey: 'commandHq.guide.whatIsIt',
|
||||
bodyKeys: ['commandHq.guide.whatIsItBody1', 'commandHq.guide.whatIsItBody2'],
|
||||
},
|
||||
{
|
||||
title: 'Starting one',
|
||||
titleKey: 'commandHq.guide.starting',
|
||||
provisional: true,
|
||||
body: [
|
||||
'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.',
|
||||
],
|
||||
bodyKeys: ['commandHq.guide.startingBody1', 'commandHq.guide.startingBody2'],
|
||||
},
|
||||
{
|
||||
// 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
|
||||
// obvious, one where it needs a conversation to pin down, one that is plainly not a commander at all so
|
||||
// the boundary is visible.
|
||||
title: 'Examples of a first message',
|
||||
titleKey: 'commandHq.guide.examples',
|
||||
provisional: true,
|
||||
body: [
|
||||
'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.',
|
||||
],
|
||||
bodyKeys: ['commandHq.guide.examplesBody1'],
|
||||
},
|
||||
{
|
||||
title: 'While it runs',
|
||||
titleKey: 'commandHq.guide.whileRunning',
|
||||
provisional: true,
|
||||
body: [
|
||||
'TODO: what the owner sees — where its cost shows, how to read what its spawned agents are doing, and how to stop it.',
|
||||
],
|
||||
bodyKeys: ['commandHq.guide.whileRunningBody1'],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -213,5 +213,49 @@
|
||||
"sidecar": "Sidecar",
|
||||
"deleteNamed": "Delete {{name}}",
|
||||
"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",
|
||||
"deleteNamed": "Eliminar {{name}}",
|
||||
"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}}"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user