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 =>
|
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}}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user