From 1e268d553d94308eab58536236740799e37cafb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sun, 23 Aug 2026 15:03:25 +0000 Subject: [PATCH] an explicit per-line exemption, and command hq keyed (383 -> 344) Adds `// i18n-exempt: `, 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. 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. --- scripts/i18n/scan-hardcoded.ts | 18 +- .../apps/CommandHQ/CommandHqCategories.tsx | 16 +- .../src/apps/CommandHQ/CommandHqDetail.tsx | 189 ++++++++++-------- .../src/apps/CommandHQ/CommandHqGrid.tsx | 21 +- .../src/apps/CommandHQ/newCommanderGuide.ts | 32 ++- .../officerdev/src/locales/en-GB.json | 44 ++++ .../officerdev/src/locales/pt-PT.json | 44 ++++ 7 files changed, 243 insertions(+), 121 deletions(-) diff --git a/scripts/i18n/scan-hardcoded.ts b/scripts/i18n/scan-hardcoded.ts index cb18a1a4..c36cbf1e 100755 --- a/scripts/i18n/scan-hardcoded.ts +++ b/scripts/i18n/scan-hardcoded.ts @@ -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: ` exempts the NEXT line. + * + * For the cases no syntactic rule can reach — chiefly a prompt written for a model rather than a + * reader. `` 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'); +}; + /** * `` 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; } diff --git a/src/workspaces/officerdev/src/apps/CommandHQ/CommandHqCategories.tsx b/src/workspaces/officerdev/src/apps/CommandHQ/CommandHqCategories.tsx index 42584cea..6f656eff 100644 --- a/src/workspaces/officerdev/src/apps/CommandHQ/CommandHqCategories.tsx +++ b/src/workspaces/officerdev/src/apps/CommandHQ/CommandHqCategories.tsx @@ -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 = () => { */
- Categories + + {t('commandHq.categories')} +
diff --git a/src/workspaces/officerdev/src/apps/CommandHQ/CommandHqDetail.tsx b/src/workspaces/officerdev/src/apps/CommandHQ/CommandHqDetail.tsx index 48a46507..3c85e5ec 100644 --- a/src/workspaces/officerdev/src/apps/CommandHQ/CommandHqDetail.tsx +++ b/src/workspaces/officerdev/src/apps/CommandHQ/CommandHqDetail.tsx @@ -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 (
-

Select a commander, or start a new one.

+

{t('commandHq.selectOne')}

); } @@ -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, * which matters because this ships on every deploy whether the copy is ready or not. */ -const NewCommanderGuide = () => ( -
-
- {NEW_COMMANDER_GUIDE.map((section) => ( -
-

- {section.title} - {section.provisional && ( - - draft - - )} -

- {section.body.map((paragraph) => ( -

- {paragraph} -

- ))} -
- ))} +const NewCommanderGuide = () => { + const { t, tUi } = useTranslation(); + return ( +
+
+ {NEW_COMMANDER_GUIDE.map((section) => ( +
+

+ {t(section.titleKey)} + {section.provisional && ( + + {t('commandHq.guide.draft')} + + )} +

+ {section.bodyKeys.map((bodyKey) => ( +

+ {t(bodyKey)} +

+ ))} +
+ ))} +
-
-); + ); +}; /** * 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) => ( -
-
- -

- {ready - ? 'It will look around this directory, then ask you what to watch for.' - : `${blockedBy} — then this starts it.`} -

+const StartCommanderButton = ({ ready, blockedBy, onStart }: StartCommanderButtonProps) => { + const { t, tUi } = useTranslation(); + return ( +
+
+ +

+ {ready ? t('commandHq.startHint') : t('commandHq.startBlocked', { missing: blockedBy })} +

+
-
-); + ); +}; type StartCommanderButtonProps = { ready: boolean; @@ -155,47 +162,50 @@ const CommanderSetup = ({ onName: (next: string) => void; cwd: string | null; onPickDirectory: () => void; -}) => ( -
-
-
- - onName(ev.target.value)} - placeholder="Platform deploys" - 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 +}) => { + const { t, tUi } = useTranslation(); + return ( +
+
+
+ + onName(ev.target.value)} + 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. */} - - Applied to the conversation as it starts, instead of the harness naming it after your first message. - -
+ {t('commandHq.nameHint')} +
-
- Working directory - - - Where every command it runs resolves from, and where its history is kept. It cannot be moved later. - +
+ + {t('commandHq.workingDirectory')} + + + {t('commandHq.cwdHint')} +
-
-); + ); +}; /** * 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({ /> ) : (
- New commander + {t('commandHq.new')}
)} {/* `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 (
- Spawned + {t('commandHq.spawned')} {children.length} {/* 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 }: @@ -102,8 +104,8 @@ const Card = ({ commander, to, selected, onDeleted }: CardProps) => { @@ -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" > - New commander + {t('commandHq.new')}
@@ -163,12 +166,12 @@ export const CommandHqGrid = () => { {!isLoading && commanders.length === 0 && (
-

No commanders here yet.

+

{t('commandHq.none')}

- Start one + {t('commandHq.startOne')}
)} diff --git a/src/workspaces/officerdev/src/apps/CommandHQ/newCommanderGuide.ts b/src/workspaces/officerdev/src/apps/CommandHQ/newCommanderGuide.ts index b4b5c14e..7a3ad47b 100644 --- a/src/workspaces/officerdev/src/apps/CommandHQ/newCommanderGuide.ts +++ b/src/workspaces/officerdev/src/apps/CommandHQ/newCommanderGuide.ts @@ -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'], }, ]; diff --git a/src/workspaces/officerdev/src/locales/en-GB.json b/src/workspaces/officerdev/src/locales/en-GB.json index 132a4f21..e00c1689 100644 --- a/src/workspaces/officerdev/src/locales/en-GB.json +++ b/src/workspaces/officerdev/src/locales/en-GB.json @@ -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}}" } } diff --git a/src/workspaces/officerdev/src/locales/pt-PT.json b/src/workspaces/officerdev/src/locales/pt-PT.json index 7e8f6530..eb204e4c 100644 --- a/src/workspaces/officerdev/src/locales/pt-PT.json +++ b/src/workspaces/officerdev/src/locales/pt-PT.json @@ -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}}" } }