diff --git a/AGENTS.md b/AGENTS.md index 4554a451d2..7e048ce9b8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -104,11 +104,15 @@ never call `t()` at module scope (it freezes at the boot locale and the pseudo-locale cannot detect it); never pass an English plural ending as a value — use `count` with `_one`/`_other`. `pnpm run i18n:check` enforces the last two. -A clean lint is not proof a file is done. The rule only sees literals in JSX, and -it **skips anything assigned to a SCREAMING_CASE identifier** — so a -`const ROWS = [{ label: "Disk usage" }]` config table passes silently. Review -those by eye. The pseudo-locale (Settings → General → Appearance, dev/e2e builds) -is the completeness check. Full guide: [`docs/i18n.md`](docs/i18n.md). +A clean lint is not proof a file is done: the rule only sees literals in JSX. The +positions it cannot inspect — SCREAMING_CASE config tables, plain `.ts` helpers, +parameter defaults, toast and setter arguments — are gated by +`scripts/check-nonjsx-copy.mjs`, which runs inside `pnpm run i18n:check` and the +new-code ratchet. Mark a genuine non-string (a persisted value, an agent-facing +prompt, a `===` token) with `// i18n-exempt: `; an unexplained marker +fails. The pseudo-locale (Settings → General → Appearance, dev/e2e builds) is +still the completeness check for copy no literal scan can see. Full guide: +[`docs/i18n.md`](docs/i18n.md). UI punctuation is plain and intentional: do not use the Unicode em dash (U+2014) in user-facing copy or locale values. Use a period, colon, comma, semicolon, or diff --git a/apps/web/AGENTS.md b/apps/web/AGENTS.md index e7f6ee34e1..52c721f23f 100644 --- a/apps/web/AGENTS.md +++ b/apps/web/AGENTS.md @@ -234,19 +234,19 @@ stays an allowlist because a repo-wide error breaks every unrelated PR that adds a label — what made the first attempt unmergeable. **Append a path in the same PR that adds it or externalizes it** (`lib/sidebar` is one still off the list). Never delete an entry to make a build pass; `check-guard-allowlist.mjs` rejects -that unless the file is gone. Use `pnpm run lint:i18n ` to preview the -guard on a path not yet on the list. +that unless the file is gone. `pnpm run lint:i18n ` previews the guard on a +path not yet listed. `pnpm run i18n:ratchet` guards all new/changed lines independently of that allowlist; untouched literals are not reported. -The rule sees JSX literals but misses `confirm()` and plain `.ts` helper copy; it -also skips SCREAMING_CASE constants, so review config tables by eye. `i18n:check` -gates key/catalog drift, `` indices, inline plurals, module-scope `t()`, and -the **pseudo-locale** (Settings → General → Appearance) completeness check. It -needs **Node 24**. Full guide: -[`docs/i18n.md`](../../docs/i18n.md); spec: -[`docs/specs/platform/i18n.md`](../../docs/specs/platform/i18n.md). +The rule sees only JSX literals. What it cannot inspect — SCREAMING_CASE tables, +plain `.ts` helpers, parameter defaults, toast/setter arguments — is gated by +`scripts/check-nonjsx-copy.mjs` on the same allowlist, run by `i18n:check` and +the ratchet; silence a legitimate one with `// i18n-exempt: ` (required). +`i18n:check` also gates key/catalog drift, `` indices, inline plurals, +module-scope `t()`, and the **pseudo-locale** check. Needs **Node 24**. Guide: +[`docs/i18n.md`](../../docs/i18n.md); spec [`docs/specs/platform/i18n.md`](../../docs/specs/platform/i18n.md). ## Markdown safety diff --git a/apps/web/app/auth/invite-page.tsx b/apps/web/app/auth/invite-page.tsx index f2c2fa7045..6b88f3369a 100644 --- a/apps/web/app/auth/invite-page.tsx +++ b/apps/web/app/auth/invite-page.tsx @@ -14,13 +14,14 @@ type InvitePageProps = { }; function useInvitePreview(token: string | undefined) { + const { t } = useTranslation(); const [preview, setPreview] = useState(null); const [previewError, setPreviewError] = useState(null); const [loading, setLoading] = useState(true); useEffect(() => { if (!token) { - setPreviewError("This invite link is missing its token."); + setPreviewError(t("auth:inviteLinkMissingToken")); setLoading(false); return; } @@ -35,8 +36,8 @@ function useInvitePreview(token: string | undefined) { if (cancelled) return; setPreviewError( err instanceof ApiError - ? "This invite link is invalid or has expired. Ask an admin for a new one." - : "Could not load this invite. Please try again.", + ? t("auth:inviteLinkInvalidOrExpired") + : t("auth:couldNotLoadInvite"), ); }) .finally(() => { @@ -163,8 +164,8 @@ export function InvitePage({ token }: InvitePageProps) { setSubmitting(false); setError( err instanceof ApiError - ? (err.message ?? "Could not accept this invite.") - : "Something went wrong. Please try again.", + ? (err.message ?? t("auth:couldNotAcceptInvite")) + : t("auth:somethingWentWrong"), ); } } diff --git a/apps/web/app/auth/login-page.tsx b/apps/web/app/auth/login-page.tsx index 595a9347c5..272bfcde13 100644 --- a/apps/web/app/auth/login-page.tsx +++ b/apps/web/app/auth/login-page.tsx @@ -61,14 +61,10 @@ export function LoginPage() { } catch (err) { setSubmitting(false); if (err instanceof ApiError) { - setError( - err.status === 429 - ? "Too many attempts. Please wait a moment and try again." - : "Invalid email or password.", - ); + setError(err.status === 429 ? t("auth:tooManyAttempts") : t("auth:invalidEmailOrPassword")); return; } - setError("Something went wrong. Please try again."); + setError(t("auth:somethingWentWrong")); } } diff --git a/apps/web/app/auth/setup-wizard.tsx b/apps/web/app/auth/setup-wizard.tsx index 4e39f17254..dca16a2e23 100644 --- a/apps/web/app/auth/setup-wizard.tsx +++ b/apps/web/app/auth/setup-wizard.tsx @@ -116,12 +116,12 @@ export function SetupWizard() { if (err instanceof ApiError) { setError( err.status === 409 - ? "Setup is no longer available - an account already exists." - : (err.message ?? "Setup failed. Please try again."), + ? t("auth:setupNoLongerAvailable") + : (err.message ?? t("auth:setupFailed")), ); return; } - setError("Something went wrong. Please try again."); + setError(t("auth:somethingWentWrong")); } } diff --git a/apps/web/app/github/github-page-client.tsx b/apps/web/app/github/github-page-client.tsx index 133b8325b8..fdc4d5b078 100644 --- a/apps/web/app/github/github-page-client.tsx +++ b/apps/web/app/github/github-page-client.tsx @@ -322,6 +322,7 @@ function useGitHubPageState(workspaceId: string | null, enabled: boolean) { }); const canSaveCurrent = customQuery.trim().length > 0 || repoFilter.length > 0; + // i18n-exempt: persisted as the saved query's name, so it must not depend on the creating locale. const suggestedLabel = customQuery.trim() || (repoFilter ? `In ${repoFilter}` : "Saved query"); const onOpenSaveDialog = () => canSaveCurrent && setSaveDialogOpen(true); diff --git a/apps/web/app/gitlab/gitlab-page-client.tsx b/apps/web/app/gitlab/gitlab-page-client.tsx index 8a768d8cd2..2ec926012d 100644 --- a/apps/web/app/gitlab/gitlab-page-client.tsx +++ b/apps/web/app/gitlab/gitlab-page-client.tsx @@ -213,6 +213,7 @@ function useGitLabPageState(searchEnabled: boolean, workspaceId?: string) { // Use committedQuery (not the unflushed draft) so the saved preset always // matches what is currently displayed in the list. const canSaveCurrent = committedQuery.trim().length > 0 || projectFilter.length > 0; + // i18n-exempt: persisted as the saved query's name, so it must not depend on the creating locale. const suggestedLabel = committedQuery.trim() || (projectFilter ? `In ${projectFilter}` : "Saved query"); const onOpenSaveDialog = () => { diff --git a/apps/web/app/office/components/agent-avatar.tsx b/apps/web/app/office/components/agent-avatar.tsx index 1cbacb9a64..b9e49af0ae 100644 --- a/apps/web/app/office/components/agent-avatar.tsx +++ b/apps/web/app/office/components/agent-avatar.tsx @@ -56,6 +56,7 @@ function tintFor(name: string): string { * elements (tabs, dots, badges) when a full avatar is overkill. */ export function agentTint(name: string | null | undefined): string { + // i18n-exempt: hash seed for the tint, never rendered. return tintFor((name ?? "").trim() || "Agent"); } diff --git a/apps/web/app/office/setup/setup-task-defaults.ts b/apps/web/app/office/setup/setup-task-defaults.ts index 18637c43cb..154cf7f8fe 100644 --- a/apps/web/app/office/setup/setup-task-defaults.ts +++ b/apps/web/app/office/setup/setup-task-defaults.ts @@ -6,8 +6,10 @@ * translating either would change stored data and the instructions the model * receives, which is a bug rather than a missing translation. */ +// i18n-exempt: persisted as the task's own title. See the file header. export const DEFAULT_ONBOARDING_TASK_TITLE = "Setup Workspace"; +// i18n-exempt: sent verbatim to the coordinator agent as its prompt. See the file header. export const DEFAULT_ONBOARDING_TASK_DESCRIPTION = `Investigate the project in https://github.com/org/repo. Replace this URL with each repository that should be managed in this workspace. As the CEO agent: diff --git a/apps/web/app/office/workspace/routing/components/provider-order-editor.tsx b/apps/web/app/office/workspace/routing/components/provider-order-editor.tsx index 82d9f181f8..365d4732ea 100644 --- a/apps/web/app/office/workspace/routing/components/provider-order-editor.tsx +++ b/apps/web/app/office/workspace/routing/components/provider-order-editor.tsx @@ -132,6 +132,7 @@ export function providerLabel(id: string): string { return PROVIDER_LABELS[id] ?? id; } +// i18n-exempt: agent product names. const PROVIDER_LABELS: Record = { "claude-acp": "Claude (ACP)", "codex-acp": "Codex (ACP)", diff --git a/apps/web/app/office/workspace/settings/components/git-section.tsx b/apps/web/app/office/workspace/settings/components/git-section.tsx index 2844615464..56f1b49f7f 100644 --- a/apps/web/app/office/workspace/settings/components/git-section.tsx +++ b/apps/web/app/office/workspace/settings/components/git-section.tsx @@ -102,6 +102,7 @@ export function GitSection() { () => runOp( async () => { + // i18n-exempt: becomes the git commit message. See the comment below. await officeApi.gitPush(activeWorkspaceId, { // Literal: this becomes the git COMMIT MESSAGE, so it is persisted // content rather than UI copy. The input placeholder that shows it diff --git a/apps/web/app/settings/agents/[agentId]/profile-mcp-config-card.tsx b/apps/web/app/settings/agents/[agentId]/profile-mcp-config-card.tsx index 36ff564438..f5e9df3172 100644 --- a/apps/web/app/settings/agents/[agentId]/profile-mcp-config-card.tsx +++ b/apps/web/app/settings/agents/[agentId]/profile-mcp-config-card.tsx @@ -45,6 +45,7 @@ type ProfileMcpConfigCardProps = { onToastError: (error: unknown) => void; }; +// i18n-exempt: MCP server config the user copies verbatim, including the example header value. const POPULAR_SERVERS: Record> = { playwright: { type: "stdio", @@ -76,11 +77,14 @@ const POPULAR_SERVERS: Record> = { // Tool names and MCP-server product names are identifiers, not copy: they are // interpolated as values so the pseudo-locale cannot turn them into something // the user cannot type or look up. +// i18n-exempt: server name and tool ids are config values the user copies verbatim into an agent. const KANDEV_MCP_NAME = "Kandev MCP"; +// i18n-exempt: server name and tool ids are config values the user copies verbatim into an agent. const KANDEV_TOOL_NAMES = "list_workspaces, list_boards, list_workflow_steps, list_tasks, create_task, update_task"; +// i18n-exempt: MCP server product names. const POPULAR_SERVER_NAMES: Record = { playwright: "Playwright MCP", "chrome-devtools": "Chrome DevTools MCP", diff --git a/apps/web/app/settings/executor/new/page.tsx b/apps/web/app/settings/executor/new/page.tsx index ae7af6b785..fac880de5d 100644 --- a/apps/web/app/settings/executor/new/page.tsx +++ b/apps/web/app/settings/executor/new/page.tsx @@ -231,6 +231,7 @@ function ExecutorCreatePageContent() { // own. Translating it would make an executor's stored name depend on the // locale it happened to be created in. The Select labels beside it are copy // and do go through `t()`. + // i18n-exempt: seeded executor name is persisted user data. See the comment above. const [name, setName] = useState(() => initialType === "remote_docker" ? "Remote Docker" : "Local Docker", ); diff --git a/apps/web/app/settings/workspace/use-workflow-creation.ts b/apps/web/app/settings/workspace/use-workflow-creation.ts index f7fbae5f34..8974921d2b 100644 --- a/apps/web/app/settings/workspace/use-workflow-creation.ts +++ b/apps/web/app/settings/workspace/use-workflow-creation.ts @@ -18,6 +18,7 @@ import { createWorkflowDuplication } from "./workflow-duplication"; // These names are PERSISTED as the workflow's step names, so they deliberately // stay English — translating them would write localized values into the // database. +// i18n-exempt: persisted workflow step names. See the comment above. export const DEFAULT_CUSTOM_STEPS: StepDefinition[] = [ { name: "Todo", position: 0, color: "bg-slate-500" }, { name: "In Progress", position: 1, color: "bg-blue-500" }, @@ -118,6 +119,7 @@ export function useWorkflowCreation({ ? workflowTemplates.find((item) => item.id === selectedTemplateId) : undefined; const tempId = newClientId("temp-workflow"); + // i18n-exempt: persisted workflow name. See the comment below. const workflow: Workflow = { id: toWorkflowId(tempId), workspace_id: toWorkspaceId(workspace.id), diff --git a/apps/web/app/settings/workspace/workspace-repositories-client.tsx b/apps/web/app/settings/workspace/workspace-repositories-client.tsx index eb3c769bff..ed37e200d9 100644 --- a/apps/web/app/settings/workspace/workspace-repositories-client.tsx +++ b/apps/web/app/settings/workspace/workspace-repositories-client.tsx @@ -59,8 +59,10 @@ function buildDraftRepo( manualRepoPath: string, ): RepositoryItem { const path = selectedRepo?.path ?? manualValidation.path ?? manualRepoPath.trim(); + // i18n-exempt: persisted repository name. See the comment below. // "New Repository" is PERSISTED as the repository's name, not rendered copy — // a stored name must not depend on the locale it was created in. + // i18n-exempt: persisted repository name. See the comment above. const name = selectedRepo?.name ?? path.split("/").filter(Boolean).slice(-1)[0] ?? "New Repository"; return { @@ -108,6 +110,7 @@ async function saveNewRepository( ) { const created = await createRepositoryAction({ workspace_id: workspace?.id ?? repo.workspace_id, + // i18n-exempt: persisted repository name, same contract as buildDraftRepo above. name: repo.name.trim() || "New Repository", source_type: repo.source_type || "local", local_path: repo.local_path, @@ -129,6 +132,7 @@ async function saveNewRepository( // Like the repository name above, the seeded script name and command are // PERSISTED and sent to a shell verbatim, so both stay in English. const scripts = await Promise.all( + // i18n-exempt: persisted script name, sent to a shell verbatim. See the comment above. repo.scripts.map((script, index) => createRepositoryScriptAction({ repository_id: created.id, @@ -195,6 +199,7 @@ async function saveExistingRepository({ if (script.id.startsWith("temp-script-")) return createRepositoryScriptAction({ repository_id: repoId, + // i18n-exempt: persisted script name, sent to a shell verbatim. name: script.name.trim() || "New Script", command: script.command.trim() || 'echo ""', position: script.position ?? index, diff --git a/apps/web/components/agent/cli-profile-editor.tsx b/apps/web/components/agent/cli-profile-editor.tsx index 670b0bb439..83e07243b2 100644 --- a/apps/web/components/agent/cli-profile-editor.tsx +++ b/apps/web/components/agent/cli-profile-editor.tsx @@ -24,6 +24,7 @@ import { buildDefaultPermissions, PERMISSION_APPLY_AGENTCTL_AUTO_APPROVE, } from "@/lib/agent-permissions"; +import { t } from "@/lib/i18n"; export type CliProfileEditorMode = "create" | "edit"; @@ -235,8 +236,8 @@ export function CliProfileEditor({ } function buttonLabel(mode: CliProfileEditorMode, saving: boolean): string { - if (saving) return mode === "edit" ? "Saving..." : "Creating..."; - return mode === "edit" ? "Save profile" : "Create profile"; + if (saving) return mode === "edit" ? t("agents:savingProfile") : t("agents:creatingProfile"); + return mode === "edit" ? t("agents:saveProfile") : t("agents:createProfile"); } function EditorFooter({ diff --git a/apps/web/components/automations/automation-editor.tsx b/apps/web/components/automations/automation-editor.tsx index 13ac9691ff..24ac4df1e9 100644 --- a/apps/web/components/automations/automation-editor.tsx +++ b/apps/web/components/automations/automation-editor.tsx @@ -51,6 +51,7 @@ type AutomationEditorProps = { // verbatim, and it is compared with `===` in useAutoPromptUpdate to decide // whether the user has edited it. Both make it protocol, not copy — the same // call the Jira (#2177), Linear (#2179) and Sentry (#2182) migrations made. +// i18n-exempt: persisted prompt, sent to the agent and compared with ===. See the comment above. const DEFAULT_PROMPT = "Run scheduled automation.\n\nTrigger: {{trigger.type}}"; const defaultForm: FormState = { diff --git a/apps/web/components/automations/automation-payload.ts b/apps/web/components/automations/automation-payload.ts index 3f4643a8a3..843edbb790 100644 --- a/apps/web/components/automations/automation-payload.ts +++ b/apps/web/components/automations/automation-payload.ts @@ -121,6 +121,7 @@ export function buildCreatePayload( repositoryIds: string[], pending: PendingTrigger[], ): CreateAutomationRequest { + // i18n-exempt: persisted automation name. See the comment below. return { workspace_id: workspaceId, // Persisted as the automation's name — user data, so it stays English diff --git a/apps/web/components/automations/automation-repository-selection.ts b/apps/web/components/automations/automation-repository-selection.ts index cebdf2284d..81ba41623f 100644 --- a/apps/web/components/automations/automation-repository-selection.ts +++ b/apps/web/components/automations/automation-repository-selection.ts @@ -66,6 +66,7 @@ export function pickSelectionFromOptionId( if (optionId.startsWith(DISCOVERED_PREFIX)) { const path = optionId.slice(DISCOVERED_PREFIX.length); const match = discoveredRepos.find((r) => r.path === path); + // i18n-exempt: persisted repository name. See the comment below. return { kind: "discovered", path, diff --git a/apps/web/components/automations/schedule-expression.ts b/apps/web/components/automations/schedule-expression.ts index 66184d340a..0ece1fb433 100644 --- a/apps/web/components/automations/schedule-expression.ts +++ b/apps/web/components/automations/schedule-expression.ts @@ -14,6 +14,7 @@ * schedule composer edits, and computes the next firing so the editor can show * the resolved instant instead of leaving it implicit. */ +import { t } from "@/lib/i18n"; export type Frequency = | "every-5m" @@ -420,21 +421,24 @@ export function timeZoneAbbreviation(timeZone: string, at: Date = new Date()): s } } -const WEEKDAY_NAMES = [ - "Sunday", - "Monday", - "Tuesday", - "Wednesday", - "Thursday", - "Friday", - "Saturday", +// Catalog KEYS, not copy: this array is built once at module load, so resolving +// it here would freeze the weekday names at the boot locale (docs/i18n.md, +// "no module-scope t()"). `describeSchedule` resolves them per call instead. +const WEEKDAY_KEYS = [ + "common:weekdaySunday", + "common:weekdayMonday", + "common:weekdayTuesday", + "common:weekdayWednesday", + "common:weekdayThursday", + "common:weekdayFriday", + "common:weekdaySaturday", ]; -const INTERVAL_LABELS: Partial> = { - "every-5m": "Every 5 minutes", - "every-15m": "Every 15 minutes", - "every-30m": "Every 30 minutes", - "every-6h": "Every 6 hours", +const INTERVAL_LABEL_KEYS: Partial> = { + "every-5m": "automations:scheduleIntervalEvery5Minutes", + "every-15m": "automations:scheduleIntervalEvery15Minutes", + "every-30m": "automations:scheduleIntervalEvery30Minutes", + "every-6h": "automations:scheduleIntervalEvery6Hours", }; const pad = (value: number) => String(value).padStart(2, "0"); @@ -445,11 +449,11 @@ const pad = (value: number) => String(value).padStart(2, "0"); */ export function describeSchedule(expression: string, timeZone: string): string { const trimmed = expression.trim(); - if (!trimmed) return "No schedule"; + if (!trimmed) return t("automations:scheduleNone"); const spec = parseExpression(trimmed); - const interval = INTERVAL_LABELS[spec.frequency]; - if (interval) return interval; + const intervalKey = INTERVAL_LABEL_KEYS[spec.frequency]; + if (intervalKey) return t(intervalKey); const zone = timeZone || "UTC"; const zoneLabel = timeZoneAbbreviation(zone); @@ -457,11 +461,15 @@ export function describeSchedule(expression: string, timeZone: string): string { switch (spec.frequency) { case "hourly": - return `Every hour at :${pad(spec.minute)}`; + return t("automations:scheduleEveryHourAt", { minute: pad(spec.minute) }); case "daily": - return `Every day at ${at} ${zoneLabel}`; + return t("automations:scheduleEveryDayAt", { time: at, zone: zoneLabel }); case "weekly": - return `Every ${WEEKDAY_NAMES[spec.weekday]} at ${at} ${zoneLabel}`; + return t("automations:scheduleEveryWeekdayAt", { + weekday: t(WEEKDAY_KEYS[spec.weekday]), + time: at, + zone: zoneLabel, + }); default: return `${trimmed} · ${zoneLabel}`; } diff --git a/apps/web/components/automations/schedule-selector.tsx b/apps/web/components/automations/schedule-selector.tsx index 72e50f2a32..335d50c9cb 100644 --- a/apps/web/components/automations/schedule-selector.tsx +++ b/apps/web/components/automations/schedule-selector.tsx @@ -29,25 +29,28 @@ type ScheduleSelectorProps = { onChange: (config: Record) => void; }; -const FREQUENCY_OPTIONS: { value: Frequency; label: string }[] = [ - { value: "every-5m", label: "every 5 minutes" }, - { value: "every-15m", label: "every 15 minutes" }, - { value: "every-30m", label: "every 30 minutes" }, - { value: "hourly", label: "every hour" }, - { value: "every-6h", label: "every 6 hours" }, - { value: "daily", label: "every day" }, - { value: "weekly", label: "every week" }, - { value: "custom", label: "a custom schedule" }, +// Catalog keys rather than copy: both tables are built at module load, so a +// `t()` here would freeze at the boot locale (docs/i18n.md). Each render +// resolves them. +const FREQUENCY_OPTIONS: { value: Frequency; labelKey: string }[] = [ + { value: "every-5m", labelKey: "automations:frequencyEvery5Minutes" }, + { value: "every-15m", labelKey: "automations:frequencyEvery15Minutes" }, + { value: "every-30m", labelKey: "automations:frequencyEvery30Minutes" }, + { value: "hourly", labelKey: "automations:frequencyEveryHour" }, + { value: "every-6h", labelKey: "automations:frequencyEvery6Hours" }, + { value: "daily", labelKey: "automations:frequencyEveryDay" }, + { value: "weekly", labelKey: "automations:frequencyEveryWeek" }, + { value: "custom", labelKey: "automations:frequencyCustom" }, ]; -const WEEKDAYS = [ - "Sunday", - "Monday", - "Tuesday", - "Wednesday", - "Thursday", - "Friday", - "Saturday", +const WEEKDAY_KEYS = [ + "common:weekdaySunday", + "common:weekdayMonday", + "common:weekdayTuesday", + "common:weekdayWednesday", + "common:weekdayThursday", + "common:weekdayFriday", + "common:weekdaySaturday", ] as const; const DESCRIPTORS = new Set([ @@ -211,7 +214,7 @@ export function ScheduleSelector({ config, isDirty = false, onChange }: Schedule const handleCustomBlur = () => { const trimmed = customDraft.trim(); if (!isValidExpression(trimmed)) { - setCustomError("That's not a schedule we can read. Try a 5-field cron like 0 9 * * 1-5."); + setCustomError(t("automations:scheduleUnparseable")); return; } setCustomError(null); @@ -269,6 +272,7 @@ function FrequencySelect({ value: Frequency; onChange: (value: Frequency) => void; }) { + const { t } = useTranslation(); return ( )} - at + {t("automations:scheduleAtSeparator")} ; // Azure DevOps names these two PAT scope groups in its own token UI. They are // pointers into that screen, not copy, so they are interpolated as values — the // pseudo-locale must not transliterate a label the user has to find verbatim. +// i18n-exempt: Azure DevOps' own PAT scope names; the user picks these exact entries in Azure's UI. const PAT_SCOPE_WORK_ITEMS = "Work Items"; +// i18n-exempt: Azure DevOps' own PAT scope names; the user picks these exact entries in Azure's UI. const PAT_SCOPE_CODE = "Code"; function PATSetupHelp({ organizationUrl }: { organizationUrl: string }) { diff --git a/apps/web/components/azure-devops/azure-devops-task-launcher.tsx b/apps/web/components/azure-devops/azure-devops-task-launcher.tsx index 2404c8c397..a783918d73 100644 --- a/apps/web/components/azure-devops/azure-devops-task-launcher.tsx +++ b/apps/web/components/azure-devops/azure-devops-task-launcher.tsx @@ -66,6 +66,7 @@ function launchText(payload: AzureDevOpsLaunchPayload) { "", plainText(pullRequest.description), ].join("\n"); + // i18n-exempt: persisted as the created task's title. return { title: truncateRemoteTaskTitle( payload.action diff --git a/apps/web/components/azure-devops/azure-devops-workspace-defaults.ts b/apps/web/components/azure-devops/azure-devops-workspace-defaults.ts index 391bd287e8..83632a6cb9 100644 --- a/apps/web/components/azure-devops/azure-devops-workspace-defaults.ts +++ b/apps/web/components/azure-devops/azure-devops-workspace-defaults.ts @@ -26,6 +26,7 @@ import type { AzureDevOpsActionPreset, AzureDevOpsQueryPreset } from "@/lib/type type Translate = (key: string) => string; +// i18n-exempt: WIQL is Azure DevOps' query language, not prose. See the file header. const WIQL_START = "SELECT [System.Id] FROM WorkItems WHERE [System.TeamProject] = @project"; const WIQL_ORDER = " ORDER BY [System.ChangedDate] DESC"; @@ -33,6 +34,7 @@ function wiql(condition?: string): string { return `${WIQL_START}${condition ? ` AND ${condition}` : ""}${WIQL_ORDER}`; } +// i18n-exempt: persisted preset seed, and must match the server-side defaults. See the file header. export const DEFAULT_AZURE_WORK_ITEM_QUERIES: AzureDevOpsQueryPreset[] = [ { id: "recent", @@ -63,6 +65,7 @@ export const DEFAULT_AZURE_WORK_ITEM_QUERIES: AzureDevOpsQueryPreset[] = [ }, ]; +// i18n-exempt: persisted preset seed, and must match the server-side defaults. See the file header. export const DEFAULT_AZURE_PULL_REQUEST_QUERIES: AzureDevOpsQueryPreset[] = [ { id: "review-requested", @@ -90,6 +93,7 @@ export const DEFAULT_AZURE_PULL_REQUEST_QUERIES: AzureDevOpsQueryPreset[] = [ }, ]; +// i18n-exempt: persisted preset seed, and must match the server-side defaults. See the file header. export const DEFAULT_AZURE_WORK_ITEM_ACTIONS: AzureDevOpsActionPreset[] = [ { id: "implement", @@ -117,6 +121,7 @@ export const DEFAULT_AZURE_WORK_ITEM_ACTIONS: AzureDevOpsActionPreset[] = [ }, ]; +// i18n-exempt: persisted preset seed, and must match the server-side defaults. See the file header. export const DEFAULT_AZURE_PULL_REQUEST_ACTIONS: AzureDevOpsActionPreset[] = [ { id: "review", diff --git a/apps/web/components/config-chat/use-config-chat.ts b/apps/web/components/config-chat/use-config-chat.ts index e97ffa8383..4d87dbbc41 100644 --- a/apps/web/components/config-chat/use-config-chat.ts +++ b/apps/web/components/config-chat/use-config-chat.ts @@ -112,6 +112,7 @@ function registerStartedSession({ // locale-dependent value that then renders unchanged after a locale switch, // on surfaces this directory does not own. Same call as the built-in layout // profile names and the seeded workflow step names. + // i18n-exempt: persisted as the task title. See the comment above. const derivedName = prompt.slice(0, 40) || "Config Chat"; store.renameQuickChatSession(response.session_id, derivedName); void persistQuickChatRename(response.session_id, response.task_id, derivedName).catch( diff --git a/apps/web/components/create-local-repository-surface.tsx b/apps/web/components/create-local-repository-surface.tsx index e13beb2600..8bed6386e0 100644 --- a/apps/web/components/create-local-repository-surface.tsx +++ b/apps/web/components/create-local-repository-surface.tsx @@ -32,16 +32,17 @@ import { createDirectory } from "@/lib/api/domains/fs-api"; import type { Repository } from "@/lib/types/http"; import { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; +import { t } from "@/lib/i18n"; // The returned strings are never rendered: the only caller uses this as a // boolean gate (`!nameError`), so they stay English rather than becoming dead // catalog entries. export function validateLocalRepositoryName(name: string): string | null { const trimmed = name.trim(); - if (!trimmed) return "Enter a repository name."; - if (trimmed === "." || trimmed === "..") return "Choose a different repository name."; + if (!trimmed) return t("workspaces:enterRepositoryName"); + if (trimmed === "." || trimmed === "..") return t("workspaces:chooseDifferentRepositoryName"); if (trimmed.includes("/") || trimmed.includes("\\") || trimmed.includes("\0")) { - return "The repository name must be one folder name."; + return t("workspaces:repositoryNameMustBeOneFolder"); } return null; } diff --git a/apps/web/components/diff/use-expandable-diff.ts b/apps/web/components/diff/use-expandable-diff.ts index 8ee8026b61..d6356a4945 100644 --- a/apps/web/components/diff/use-expandable-diff.ts +++ b/apps/web/components/diff/use-expandable-diff.ts @@ -2,6 +2,7 @@ import { useState, useCallback, useMemo, useEffect, useRef } from "react"; import { processFile, type FileDiffMetadata } from "@pierre/diffs"; import { getWebSocketClient } from "@/lib/ws/connection"; import { requestFileContent, requestFileContentAtRef } from "@/lib/ws/workspace-files"; +import { t } from "@/lib/i18n"; type UseExpandableDiffOptions = { sessionId: string | undefined; @@ -222,7 +223,7 @@ export function useExpandableDiff({ ); if (version === requestVersionRef.current) setLoadedContent(content); } catch (err) { - const msg = err instanceof Error ? err.message : "Failed to load file content"; + const msg = err instanceof Error ? err.message : t("task:failedToLoadFileContent"); console.error("[useExpandableDiff]", msg); if (version === requestVersionRef.current) setError(msg); } finally { diff --git a/apps/web/components/editors/external-vcs-file-link.tsx b/apps/web/components/editors/external-vcs-file-link.tsx index b4d800e761..60a4d8a5c8 100644 --- a/apps/web/components/editors/external-vcs-file-link.tsx +++ b/apps/web/components/editors/external-vcs-file-link.tsx @@ -12,6 +12,7 @@ import { useSessionGitStatusByRepo, } from "@/hooks/domains/session/use-session-git-status"; import type { ExternalVcsProvider } from "@/lib/utils/external-vcs-file-url"; +import { t } from "@/lib/i18n"; export type ExternalVcsFileLinkProps = UseExternalVcsFileLinkInput & { size?: "xs" | "sm" | "touch"; @@ -66,7 +67,7 @@ export function useExternalVcsFileStatus( } function externalLinkLabel(provider: ExternalVcsProvider): string { - return `Open file in ${providerNames[provider]}`; + return t("task:openFileInProvider", { provider: providerNames[provider] }); } export function ExternalVcsFileLink({ size = "xs", ...input }: ExternalVcsFileLinkProps) { diff --git a/apps/web/components/editors/tiptap/tiptap-mermaid-extension.ts b/apps/web/components/editors/tiptap/tiptap-mermaid-extension.ts index df26bc3f0f..d467f2c016 100644 --- a/apps/web/components/editors/tiptap/tiptap-mermaid-extension.ts +++ b/apps/web/components/editors/tiptap/tiptap-mermaid-extension.ts @@ -77,6 +77,7 @@ function initMermaid(theme: "dark" | "light" = "dark") { } /** SVG icon markup for the code toggle button (Tabler IconCode). */ +// i18n-exempt: inline SVG markup. const CODE_ICON_SVG = ``; /** Mermaid keywords regex for syntax highlighting. */ diff --git a/apps/web/components/github/action-presets-section.tsx b/apps/web/components/github/action-presets-section.tsx index f14f0c6105..e6f7080f15 100644 --- a/apps/web/components/github/action-presets-section.tsx +++ b/apps/web/components/github/action-presets-section.tsx @@ -52,6 +52,7 @@ function actionPromptPlaceholders(t: TFunction): ScriptPlaceholder[] { // preset seeded in one locale and saved unedited would keep that locale's text // forever. Same contract as DEFAULT_PR_PRESETS in my-github/action-presets.ts. function newPreset(): GitHubActionPreset { + // i18n-exempt: persisted, editable preset label. See the comment above. return { id: `preset_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`, label: "New action", diff --git a/apps/web/components/github/default-queries-section.tsx b/apps/web/components/github/default-queries-section.tsx index 4b46bc57bd..b7df499c60 100644 --- a/apps/web/components/github/default-queries-section.tsx +++ b/apps/web/components/github/default-queries-section.tsx @@ -26,6 +26,7 @@ import { useTranslation } from "react-i18next"; // and is editable in the row below, so it must stay locale-neutral — see // `newPreset` in action-presets-section.tsx for the same contract. function newPreset(): StoredQueryPreset { + // i18n-exempt: persisted, editable preset label. See the comment above. return { value: `q_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`, label: "New query", diff --git a/apps/web/components/github/issue-watch-dialog.tsx b/apps/web/components/github/issue-watch-dialog.tsx index 454d43f1e6..8174d7bc69 100644 --- a/apps/web/components/github/issue-watch-dialog.tsx +++ b/apps/web/components/github/issue-watch-dialog.tsx @@ -74,7 +74,9 @@ type FormState = { cleanupPolicy: CleanupPolicy; }; +// i18n-exempt: GitHub search syntax and label ids, shown as examples. const DEFAULT_QUERY = "type:issue state:open"; +// i18n-exempt: GitHub label ids, shown as examples. const ISSUE_LABEL_EXAMPLES = "bug, enhancement, priority:high"; function makeDefaultForm(workspaceId: string): FormState { diff --git a/apps/web/components/github/issue-watch-placeholders.ts b/apps/web/components/github/issue-watch-placeholders.ts index b17112c630..712dd01bae 100644 --- a/apps/web/components/github/issue-watch-placeholders.ts +++ b/apps/web/components/github/issue-watch-placeholders.ts @@ -56,6 +56,7 @@ export function issueWatchPlaceholders(t: TFunction): ScriptPlaceholder[] { * Seeded into the prompt field, then persisted and sent to the agent verbatim. * Deliberately NOT translated — see `DEFAULT_REVIEW_WATCH_PROMPT`. */ +// i18n-exempt: default watch prompt, persisted and sent verbatim to the agent. export const DEFAULT_ISSUE_WATCH_PROMPT = `You have been assigned a GitHub issue to work on. **Issue:** {{issue.link}} diff --git a/apps/web/components/github/my-github/action-presets.ts b/apps/web/components/github/my-github/action-presets.ts index ebf4a7e7a3..2c73644bc1 100644 --- a/apps/web/components/github/my-github/action-presets.ts +++ b/apps/web/components/github/my-github/action-presets.ts @@ -68,6 +68,7 @@ export function toTaskPreset(stored: GitHubActionPreset): TaskPreset { // into a user's saved presets and leave them there after a locale switch. // `prompt_template` is additionally sent to the agent verbatim. Localizing these // needs a key/persisted-value split, the same open item as PR_PRESETS. +// i18n-exempt: persisted preset seed. Localizing needs a key/value split in the preset type. export const DEFAULT_PR_PRESETS: GitHubActionPreset[] = [ { id: "review", @@ -95,6 +96,7 @@ export const DEFAULT_PR_PRESETS: GitHubActionPreset[] = [ }, ]; +// i18n-exempt: persisted preset seed. Localizing needs a key/value split in the preset type. export const DEFAULT_ISSUE_PRESETS: GitHubActionPreset[] = [ { id: "implement", diff --git a/apps/web/components/github/my-github/search-bar.tsx b/apps/web/components/github/my-github/search-bar.tsx index 4d62bf270c..2cde87f8f0 100644 --- a/apps/web/components/github/my-github/search-bar.tsx +++ b/apps/web/components/github/my-github/search-bar.tsx @@ -27,6 +27,7 @@ export type PresetOption = { // them there after a locale switch. Localizing these needs a key/persisted-value // split in this module, the same open item as the built-in layout profile names. // `value`, `filter` and `group` are wire values and a GitHub search query. +// i18n-exempt: persisted saved-search seed, and the query beside it is GitHub search syntax. export const PR_PRESETS: PresetOption[] = [ { value: "review_requested", @@ -65,6 +66,7 @@ export const PR_PRESETS: PresetOption[] = [ }, ]; +// i18n-exempt: persisted saved-search seed, and the query beside it is GitHub search syntax. export const ISSUE_PRESETS: PresetOption[] = [ { value: "assigned", diff --git a/apps/web/components/github/pr-comments-section.tsx b/apps/web/components/github/pr-comments-section.tsx index 823897cc20..57db2284ff 100644 --- a/apps/web/components/github/pr-comments-section.tsx +++ b/apps/web/components/github/pr-comments-section.tsx @@ -4,6 +4,7 @@ import type { PRComment } from "@/lib/types/github"; import { CollapsibleSection, AddToContextButton, FeedbackItemRow } from "./pr-shared"; import { useTranslation } from "react-i18next"; +// i18n-exempt: builds the message body sent to the agent, not UI copy. function buildCommentMessage(comment: PRComment, prUrl: string): string { const location = comment.path ? `\`${comment.path}${comment.line > 0 ? `:${comment.line}` : ""}\`` diff --git a/apps/web/components/github/pr-shared.tsx b/apps/web/components/github/pr-shared.tsx index 1a3ad02831..c93a017a2b 100644 --- a/apps/web/components/github/pr-shared.tsx +++ b/apps/web/components/github/pr-shared.tsx @@ -7,22 +7,23 @@ import { Button } from "@kandev/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { markdownComponents, remarkPlugins } from "@/components/shared/markdown-components"; import { useTranslation } from "react-i18next"; +import { t } from "@/lib/i18n"; export function formatTimeAgo(dateStr: string): string { const date = new Date(dateStr); if (isNaN(date.getTime())) return ""; const diff = Date.now() - date.getTime(); - if (diff < 0) return "just now"; + if (diff < 0) return t("github:timeAgoJustNow"); const minutes = Math.floor(diff / 60000); - if (minutes < 1) return "just now"; - if (minutes < 60) return `${minutes}m ago`; + if (minutes < 1) return t("github:timeAgoJustNow"); + if (minutes < 60) return t("github:timeAgoMinutes", { count: minutes }); const hours = Math.floor(minutes / 60); - if (hours < 24) return `${hours}h ago`; + if (hours < 24) return t("github:timeAgoHours", { count: hours }); const days = Math.floor(hours / 24); - if (days < 7) return `${days}d ago`; - if (days < 30) return `${Math.floor(days / 7)}w ago`; - if (days < 365) return `${Math.floor(days / 30)}mo ago`; - return `${Math.floor(days / 365)}y ago`; + if (days < 7) return t("github:timeAgoDays", { count: days }); + if (days < 30) return t("github:timeAgoWeeks", { count: Math.floor(days / 7) }); + if (days < 365) return t("github:timeAgoMonths", { count: Math.floor(days / 30) }); + return t("github:timeAgoYears", { count: Math.floor(days / 365) }); } function formatMs(ms: number): string { diff --git a/apps/web/components/github/review-watch-placeholders.ts b/apps/web/components/github/review-watch-placeholders.ts index 8f7d6c16ad..e61850e989 100644 --- a/apps/web/components/github/review-watch-placeholders.ts +++ b/apps/web/components/github/review-watch-placeholders.ts @@ -61,6 +61,7 @@ export function reviewWatchPlaceholders(t: TFunction): ScriptPlaceholder[] { * Deliberately NOT translated: it is agent instructions and stored data, so * translating it would write locale-dependent text into a user's saved watch. */ +// i18n-exempt: default watch prompt, persisted and sent verbatim to the agent. export const DEFAULT_REVIEW_WATCH_PROMPT = `Review Pull Request #{{pr.number}}: {{pr.title}} Repository: {{pr.repo}} PR: {{pr.link}} diff --git a/apps/web/components/github/review-watch-prompt-field.tsx b/apps/web/components/github/review-watch-prompt-field.tsx index 442ffbdd42..dcaa2f9533 100644 --- a/apps/web/components/github/review-watch-prompt-field.tsx +++ b/apps/web/components/github/review-watch-prompt-field.tsx @@ -16,7 +16,9 @@ import { useMemo } from "react"; // than written into the catalog: they contain a literal `{{task_prompt}}` token, // which i18next would read as an interpolation and substitute away, and the // pseudo-locale would transliterate the URL. +// i18n-exempt: example prompt text, shown as a sample of what is sent verbatim to the agent. const STEP_PROMPT_EXAMPLE = "Analyze the task: {{task_prompt}}"; +// i18n-exempt: example prompt text, shown as a sample of what is sent verbatim to the agent. const FINAL_PROMPT_EXAMPLE = "Analyze the task: Pull Request ready for review: https://..."; // Pulled out of review-watch-dialog.tsx to keep that file under the 600-line diff --git a/apps/web/components/gitlab/action-presets-section.tsx b/apps/web/components/gitlab/action-presets-section.tsx index ac22425b4b..8f18f98188 100644 --- a/apps/web/components/gitlab/action-presets-section.tsx +++ b/apps/web/components/gitlab/action-presets-section.tsx @@ -20,6 +20,7 @@ import { useTranslation } from "react-i18next"; // seeded in one locale and saved unedited would keep that locale's text forever. // Same contract as `newPreset` in components/github/action-presets-section.tsx. function newPreset(): GitLabActionPreset { + // i18n-exempt: persisted, editable preset label. See the comment above. return { id: `preset_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`, label: "New action", diff --git a/apps/web/components/gitlab/mr-detail-panel.tsx b/apps/web/components/gitlab/mr-detail-panel.tsx index e03f755283..5eb0b8a688 100644 --- a/apps/web/components/gitlab/mr-detail-panel.tsx +++ b/apps/web/components/gitlab/mr-detail-panel.tsx @@ -482,7 +482,10 @@ export function MRDetailPanelComponent({ const workspaceId = useAppStore((state) => state.workspaces.activeId); const sessionId = useAppStore((state) => state.tasks.activeSessionId); const { status, loading } = useGitLabStatus(); - useEffect(() => setPanelTitle(panelId, mr ? `MR !${mr.mr_iid}` : "Merge Request"), [mr, panelId]); + useEffect( + () => setPanelTitle(panelId, mr ? `MR !${mr.mr_iid}` : t("task:panelMergeRequest")), + [mr, panelId, t], + ); if (!mr || !workspaceId || !sessionId) return (
diff --git a/apps/web/components/gitlab/mr-reviewer-control.tsx b/apps/web/components/gitlab/mr-reviewer-control.tsx index 8e17742c2e..deb7800419 100644 --- a/apps/web/components/gitlab/mr-reviewer-control.tsx +++ b/apps/web/components/gitlab/mr-reviewer-control.tsx @@ -67,6 +67,7 @@ function useProjectMemberSearch({ host, project, }: Pick) { + const { t } = useTranslation(); const [query, setQuery] = useState(""); const [members, setMembers] = useState([]); const [searching, setSearching] = useState(false); @@ -101,7 +102,9 @@ function useProjectMemberSearch({ if (isActive(generation, requestIdentity)) setMembers(result); } catch (searchError) { if (isActive(generation, requestIdentity)) { - setError(searchError instanceof Error ? searchError.message : "Member search failed"); + setError( + searchError instanceof Error ? searchError.message : t("gitlab:memberSearchFailed"), + ); } } finally { if (isActive(generation, requestIdentity)) setSearching(false); diff --git a/apps/web/components/gitlab/watch-form.ts b/apps/web/components/gitlab/watch-form.ts index 24c17869c2..2f925a04bc 100644 --- a/apps/web/components/gitlab/watch-form.ts +++ b/apps/web/components/gitlab/watch-form.ts @@ -29,8 +29,10 @@ export type GitLabWatchForm = { // locale would keep that locale's prompt forever, and the agent reads it as an // instruction rather than as UI copy. Same contract as // DEFAULT_ISSUE_WATCH_PROMPT in components/github/issue-watch-placeholders.ts. +// i18n-exempt: default prompt, persisted and sent verbatim to the agent. const DEFAULT_REVIEW_PROMPT = "Review GitLab merge request {{mr.url}}. Summarize risks and leave actionable feedback."; +// i18n-exempt: default prompt, persisted and sent verbatim to the agent. const DEFAULT_ISSUE_PROMPT = "Investigate GitLab issue {{issue.url}} and implement the requested change."; diff --git a/apps/web/components/integrations/change-request-detail-checks.tsx b/apps/web/components/integrations/change-request-detail-checks.tsx index 3f3b11a5a0..855375ada5 100644 --- a/apps/web/components/integrations/change-request-detail-checks.tsx +++ b/apps/web/components/integrations/change-request-detail-checks.tsx @@ -56,6 +56,7 @@ function checkSummary(checks: ChangeRequestDetailCheck[]) { ].filter(Boolean); } +// i18n-exempt: builds the message body sent to the agent, not UI copy. function buildCheckContext(check: ChangeRequestDetailCheck) { return [ `CI check **${check.name}** failed (${checkConclusionLabel(check)}).`, diff --git a/apps/web/components/integrations/integration-repository-filter.test.tsx b/apps/web/components/integrations/integration-repository-filter.test.tsx index 27a7e7253a..a61a41599f 100644 --- a/apps/web/components/integrations/integration-repository-filter.test.tsx +++ b/apps/web/components/integrations/integration-repository-filter.test.tsx @@ -25,7 +25,10 @@ describe("IntegrationRepositoryFilter", () => { ); fireEvent.click(screen.getByText("All repositories")); - fireEvent.change(await screen.findByPlaceholderText("Filter repositories..."), { + // The unset props fall back to the `integrations:*` catalog, which spells the + // placeholder with a real ellipsis. Both app consumers pass all three props, + // so this default is only ever rendered here. + fireEvent.change(await screen.findByPlaceholderText("Filter repositories…"), { target: { value: "web" }, }); fireEvent.click(await screen.findByRole("option", { name: "acme/web" })); diff --git a/apps/web/components/integrations/integration-repository-filter.tsx b/apps/web/components/integrations/integration-repository-filter.tsx index 9e847d5106..e8a1be0e83 100644 --- a/apps/web/components/integrations/integration-repository-filter.tsx +++ b/apps/web/components/integrations/integration-repository-filter.tsx @@ -2,6 +2,7 @@ import { useMemo } from "react"; import { Combobox, type ComboboxOption } from "@/components/combobox"; +import { useTranslation } from "react-i18next"; const ALL_REPOSITORIES = "__all_repositories__"; @@ -30,19 +31,25 @@ export function IntegrationRepositoryFilter({ onValueChange, options, ariaLabel, - allLabel = "All repositories", - searchPlaceholder = "Filter repositories...", - emptyMessage = "No repositories found.", + allLabel, + searchPlaceholder, + emptyMessage, testId, dropdownTestId, triggerClassName, className, }: IntegrationRepositoryFilterProps) { + const { t } = useTranslation(); + // Resolved here rather than as parameter defaults: a destructuring default is + // evaluated before the component body runs, so `t` is not yet in scope there. + const allRepositoriesLabel = allLabel ?? t("integrations:allRepositories"); + const searchPlaceholderText = searchPlaceholder ?? t("integrations:filterRepositories"); + const emptyMessageText = emptyMessage ?? t("integrations:noRepositoriesFound"); const comboboxOptions = useMemo( () => [ { value: ALL_REPOSITORIES, - label: allLabel, + label: allRepositoriesLabel, keywords: ["all", "repositories", "repos"], }, ...options.map((option) => ({ @@ -51,7 +58,7 @@ export function IntegrationRepositoryFilter({ keywords: option.keywords ?? [option.label], })), ], - [allLabel, options], + [allRepositoriesLabel, options], ); return ( @@ -64,9 +71,9 @@ export function IntegrationRepositoryFilter({ }} options={comboboxOptions} ariaLabel={ariaLabel} - placeholder={allLabel} - searchPlaceholder={searchPlaceholder} - emptyMessage={emptyMessage} + placeholder={allRepositoriesLabel} + searchPlaceholder={searchPlaceholderText} + emptyMessage={emptyMessageText} triggerClassName={triggerClassName} className={className} testId={testId} diff --git a/apps/web/components/jira/jira-issue-watch-dialog.tsx b/apps/web/components/jira/jira-issue-watch-dialog.tsx index 257a4d5eb5..0b220e831c 100644 --- a/apps/web/components/jira/jira-issue-watch-dialog.tsx +++ b/apps/web/components/jira/jira-issue-watch-dialog.tsx @@ -94,6 +94,7 @@ function parseMaxInflightTasks(raw: string): number | null | "invalid" { // `status`, `created`), operators and a project key — and the string is // persisted on the watch and sent upstream verbatim. Translating any of it would // produce a query Jira rejects. +// i18n-exempt: JQL is Jira's query language, not prose. const DEFAULT_JQL = `project = PROJ AND status = "Open" ORDER BY created DESC`; function makeEmptyForm(workspaceId: string): FormState { diff --git a/apps/web/components/jira/jira-issue-watch-placeholders.ts b/apps/web/components/jira/jira-issue-watch-placeholders.ts index 6b321f4cea..8a11fa7c49 100644 --- a/apps/web/components/jira/jira-issue-watch-placeholders.ts +++ b/apps/web/components/jira/jira-issue-watch-placeholders.ts @@ -85,6 +85,7 @@ export function jiraIssueWatchPlaceholders(t: TFunction): ScriptPlaceholder[] { * instruction rather than as UI copy. Same contract as * `DEFAULT_ISSUE_WATCH_PROMPT` in components/github/issue-watch-placeholders.ts. */ +// i18n-exempt: default watch prompt, persisted and sent verbatim to the agent. export const DEFAULT_JIRA_ISSUE_WATCH_PROMPT = `You have been assigned a JIRA ticket to work on. **Ticket:** {{issue.url}} diff --git a/apps/web/components/jira/jira-secret-help.tsx b/apps/web/components/jira/jira-secret-help.tsx index 283df70ecf..0d4b66527b 100644 --- a/apps/web/components/jira/jira-secret-help.tsx +++ b/apps/web/components/jira/jira-secret-help.tsx @@ -21,6 +21,7 @@ import type { JiraAuthMethod } from "@/lib/types/jira"; const SESSION_COOKIE_NAME = "cloud.session.token"; const SSO_SESSION_COOKIE_NAME = "tenant.session.token"; const ATLASSIAN_COOKIE_HOST = "https://*.atlassian.net"; +// i18n-exempt: keyboard shortcut, interpolated so no locale rewrites it. See the comment above. const DEVTOOLS_SHORTCUT = "Cmd+Opt+I / Ctrl+Shift+I"; // Atlassian's API-token page. The visible link text is the URL itself, so it is // an identifier in both roles and never reaches the catalog. diff --git a/apps/web/components/jira/my-jira/presets.ts b/apps/web/components/jira/my-jira/presets.ts index 843772c63d..2c2c9bcf52 100644 --- a/apps/web/components/jira/my-jira/presets.ts +++ b/apps/web/components/jira/my-jira/presets.ts @@ -107,6 +107,7 @@ export function toTaskPreset(stored: JiraStoredPreset): JiraTaskPreset { // into user settings on the first save, so a locale-dependent value would be // written into the user's data and stay there after a locale switch. The prompt // templates are also sent to the agent as instructions rather than shown as copy. +// i18n-exempt: persisted preset seed. Localizing needs a key/value split in the preset type. export const DEFAULT_JIRA_PRESETS: JiraStoredPreset[] = [ { id: "implement", diff --git a/apps/web/components/jira/task-presets-section.tsx b/apps/web/components/jira/task-presets-section.tsx index 748b40901c..18b83e2047 100644 --- a/apps/web/components/jira/task-presets-section.tsx +++ b/apps/web/components/jira/task-presets-section.tsx @@ -65,6 +65,7 @@ const MY_JIRA_ROUTE = "/jira"; // seeded in one locale and saved unedited would keep that locale's text forever. // Same contract as `newPreset` in components/github/action-presets-section.tsx. function newPreset(): JiraStoredPreset { + // i18n-exempt: persisted, editable preset label. See the comment above. return { id: `preset_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`, label: "New action", diff --git a/apps/web/components/kanban/swimlane-graph-content.test.tsx b/apps/web/components/kanban/swimlane-graph-content.test.tsx index 08046bb1f5..e0d3d7a3a8 100644 --- a/apps/web/components/kanban/swimlane-graph-content.test.tsx +++ b/apps/web/components/kanban/swimlane-graph-content.test.tsx @@ -4,7 +4,8 @@ import { StateProvider } from "@/components/state-provider"; import type { Task } from "@/components/kanban-card"; import type { WorkflowStep } from "@/components/kanban-column"; import type { ForegroundActivity, TaskPendingAction } from "@/lib/types/http"; -import { SwimlaneGraphContent } from "./swimlane-graph-content"; +import { i18n, t } from "@/lib/i18n"; +import { SwimlaneGraphContent, moveTaskAcrossSwimlaneSteps } from "./swimlane-graph-content"; afterEach(() => { cleanup(); @@ -98,3 +99,65 @@ describe("SwimlaneGraphContent — waiting-for-input variants", () => { expect(container.querySelector(ICON_LOADER2)).toBeNull(); }); }); + +describe("moveTaskAcrossSwimlaneSteps — move failure reporting", () => { + const WORKFLOW_ID = "wf-1"; + const TASK_ID = "task-1"; + + function harness() { + const snapshot = { + tasks: [{ id: TASK_ID, workflowStepId: "step-1", position: 0 }], + }; + const snapshots: Record = { [WORKFLOW_ID]: snapshot }; + const state = { + kanbanMulti: { snapshots }, + setWorkflowSnapshot: (id: string, next: typeof snapshot) => { + snapshots[id] = next; + }, + }; + return { getState: () => state } as never; + } + + async function move(rejection: unknown) { + const errors: { message: string }[] = []; + await moveTaskAcrossSwimlaneSteps({ + task: { id: TASK_ID, primarySessionId: null } as never, + taskId: TASK_ID, + targetColumnId: "step-2", + workflowId: WORKFLOW_ID, + store: harness(), + moveTaskById: (() => Promise.reject(rejection)) as never, + onMoveError: (error) => errors.push(error), + }); + return errors; + } + + it("reports the localized fallback when the rejection is not an Error", async () => { + // Asserted under PSEUDO, not English. `toBe(t(key))` in English cannot tell a + // `t()` call from the identical hardcoded literal — a mutation putting + // "Failed to move task" back survived that version of this test. Under a + // locale that transliterates, only a real catalog lookup comes out accented. + await i18n.changeLanguage("pseudo"); + try { + const [error] = await move("plain string rejection"); + expect(error.message).toBe(t("task:failedToMoveTask")); + expect(error.message).toMatch(/[^\x20-\x7E]/); + } finally { + await i18n.changeLanguage("en"); + } + }); + + it("reports the fallback with the task identity attached", async () => { + expect(await move("plain string rejection")).toEqual([ + { message: t("task:failedToMoveTask"), taskId: TASK_ID, sessionId: null }, + ]); + }); + + it("preserves a real Error's message instead of replacing it with copy", async () => { + // A server diagnostic is not copy: swallowing it would leave the user with a + // generic sentence and no way to tell two failures apart. + expect(await move(new Error("step is at its WIP limit"))).toEqual([ + { message: "step is at its WIP limit", taskId: TASK_ID, sessionId: null }, + ]); + }); +}); diff --git a/apps/web/components/kanban/swimlane-graph-content.tsx b/apps/web/components/kanban/swimlane-graph-content.tsx index 407f122645..0bf3a629af 100644 --- a/apps/web/components/kanban/swimlane-graph-content.tsx +++ b/apps/web/components/kanban/swimlane-graph-content.tsx @@ -26,6 +26,7 @@ import type { KanbanState } from "@/lib/state/slices/kanban/types"; import { useTaskPendingInput } from "@/hooks/use-task-pending-input"; import { compareTasksByCreatedDesc } from "@/lib/kanban/task-order"; import { useTranslation } from "react-i18next"; +import { t } from "@/lib/i18n"; export type SwimlaneGraphContentProps = { workflowId: string; @@ -146,7 +147,15 @@ type SwimlaneGraphDndOptions = { onMoveError?: (error: MoveTaskError) => void; }; -async function moveTaskAcrossSwimlaneSteps({ +/** + * Optimistically moves a task, rolling the snapshot back and reporting a message + * when the backend refuses. + * + * Exported for tests: the localized fallback on the non-`Error` branch is the + * kind of copy no rendering test reaches, since it is handed to `onMoveError` + * rather than to the DOM. + */ +export async function moveTaskAcrossSwimlaneSteps({ task, taskId, targetColumnId, @@ -199,7 +208,7 @@ async function moveTaskAcrossSwimlaneSteps({ .getState() .setWorkflowSnapshot(workflowId, { ...currentSnapshot, tasks: originalTasks }); } - const message = error instanceof Error ? error.message : "Failed to move task"; + const message = error instanceof Error ? error.message : t("task:failedToMoveTask"); onMoveError?.({ message, taskId, sessionId: task.primarySessionId ?? null }); } } diff --git a/apps/web/components/kanban/swimlane-kanban-content.tsx b/apps/web/components/kanban/swimlane-kanban-content.tsx index 2aa7a188d9..dbf2d03f49 100644 --- a/apps/web/components/kanban/swimlane-kanban-content.tsx +++ b/apps/web/components/kanban/swimlane-kanban-content.tsx @@ -33,6 +33,7 @@ import { useKanbanExternalLinkAvailability, } from "@/components/kanban-external-link-availability"; import { useTranslation } from "react-i18next"; +import { t } from "@/lib/i18n"; /** * Sentinel step ID used to collect tasks whose workflow_step_id no longer @@ -139,7 +140,7 @@ function useSwimlaneKanbanDnd({ tasks, workflowId, onMoveError }: SwimlaneKanban .getState() .setWorkflowSnapshot(workflowId, { ...currentSnapshot, tasks: originalTasks }); } - const message = error instanceof Error ? error.message : "Failed to move task"; + const message = error instanceof Error ? error.message : t("task:failedToMoveTask"); onMoveError?.({ message, taskId, sessionId: task.primarySessionId ?? null }); } }, diff --git a/apps/web/components/linear/linear-issue-watch-placeholders.ts b/apps/web/components/linear/linear-issue-watch-placeholders.ts index 6fa488d249..7ceed5e9fc 100644 --- a/apps/web/components/linear/linear-issue-watch-placeholders.ts +++ b/apps/web/components/linear/linear-issue-watch-placeholders.ts @@ -86,6 +86,7 @@ export function linearIssueWatchPlaceholders(t: TFunction): ScriptPlaceholder[] // would keep that locale's prompt forever, and the agent reads it as an // instruction rather than as UI copy — it also has to keep matching the backend // .md, which has no locale at all. +// i18n-exempt: default watch prompt, persisted and sent verbatim to the agent. export const DEFAULT_LINEAR_ISSUE_WATCH_PROMPT = `You have been assigned a Linear issue to work on. **Issue:** {{issue.url}} diff --git a/apps/web/components/quick-chat/use-quick-chat-modal.ts b/apps/web/components/quick-chat/use-quick-chat-modal.ts index 39d98c352f..c1a2121687 100644 --- a/apps/web/components/quick-chat/use-quick-chat-modal.ts +++ b/apps/web/components/quick-chat/use-quick-chat-modal.ts @@ -168,6 +168,7 @@ async function startQuickChatForAgent( (session.kind ?? "chat") === "chat" && !isQuickChatSetupSessionId(session.sessionId), ).length + 1; + // i18n-exempt: persisted as the quick-chat task title, same contract as use-config-chat.ts. const initialName = `${agent?.label || "Agent"} - Chat ${sessionCount}`; const response = await startQuickChat(workspaceId, { agent_profile_id: agentId, diff --git a/apps/web/components/review/review-run-button.tsx b/apps/web/components/review/review-run-button.tsx index ee8dff59ca..9fc760f1fe 100644 --- a/apps/web/components/review/review-run-button.tsx +++ b/apps/web/components/review/review-run-button.tsx @@ -51,6 +51,7 @@ function RunNotice({ code, message }: { code: string; message: string }) { } function useRunReview(taskId: string | null | undefined, sessionId?: string | null) { + const { t } = useTranslation(); const [notice, setNotice] = useState<{ code: string; message: string } | null>(null); const [starting, setStarting] = useState(false); @@ -62,7 +63,7 @@ function useRunReview(taskId: string | null | undefined, sessionId?: string | nu await runTaskReview({ taskId, sessionId: sessionId ?? undefined }); } catch (error) { const code = (error as { code?: string })?.code ?? ""; - const message = error instanceof Error ? error.message : "Could not start the review"; + const message = error instanceof Error ? error.message : t("review:couldNotStartReview"); setNotice({ code, message }); } finally { setStarting(false); diff --git a/apps/web/components/sentry/sentry-issue-watch-placeholders.ts b/apps/web/components/sentry/sentry-issue-watch-placeholders.ts index e628344909..e38012d42a 100644 --- a/apps/web/components/sentry/sentry-issue-watch-placeholders.ts +++ b/apps/web/components/sentry/sentry-issue-watch-placeholders.ts @@ -103,6 +103,7 @@ export function sentryIssueWatchPlaceholders(t: TFunction): ScriptPlaceholder[] // would keep that locale's prompt forever, and the agent reads it as an // instruction rather than as UI copy — it also has to keep matching the backend // .md, which has no locale at all. +// i18n-exempt: default watch prompt, persisted and sent verbatim to the agent. export const DEFAULT_SENTRY_ISSUE_WATCH_PROMPT = `You have been assigned a Sentry issue to triage and fix. **Issue:** {{issue.url}} diff --git a/apps/web/components/settings/editor-form.tsx b/apps/web/components/settings/editor-form.tsx index c6da80988b..900058e183 100644 --- a/apps/web/components/settings/editor-form.tsx +++ b/apps/web/components/settings/editor-form.tsx @@ -40,10 +40,12 @@ const CUSTOM_KIND_OPTIONS: Array<{ value: CustomKind; labelKey: string }> = [ /** Placeholder tokens the launcher substitutes — identifiers, not copy. */ const PLACEHOLDER_HINT = "{cwd} {file} {rel} {line} {column}"; /** Example shell command shown in the field; a value the user types verbatim. */ +// i18n-exempt: shell command example the user copies verbatim. const COMMAND_EXAMPLE = "code --goto {file}:{line}"; /** Example of the URL this kind builds — a URL, not translatable copy. */ const REMOTE_SSH_EXAMPLE = "vscode://vscode-remote/ssh-remote+user@host:/path/file:line"; /** Accepted URL schemes. The user types one of these verbatim into the field. */ +// i18n-exempt: editor URL scheme identifiers, not prose. const EDITOR_SCHEME_EXAMPLES = "vscode, cursor"; export function getCustomKindLabel(t: TranslateKey, kind: string) { diff --git a/apps/web/components/settings/executor-profile-dialog.tsx b/apps/web/components/settings/executor-profile-dialog.tsx index 1fe1c43b0e..9b7ac9feab 100644 --- a/apps/web/components/settings/executor-profile-dialog.tsx +++ b/apps/web/components/settings/executor-profile-dialog.tsx @@ -16,8 +16,6 @@ import { createExecutorProfile } from "@/lib/api/domains/settings-api"; import type { ExecutorProfile } from "@/lib/types/http"; import { useTranslation } from "react-i18next"; -const PROFILE_NAME_EXAMPLE = "Production, Development"; - type ExecutorProfileDialogProps = { open: boolean; onOpenChange: (open: boolean) => void; @@ -76,7 +74,7 @@ export function ExecutorProfileDialog({ value={name} onChange={(e) => setName(e.target.value)} placeholder={t("executors:eGProductionDevelopment", { - example: PROFILE_NAME_EXAMPLE, + example: t("executors:profileNameExample"), })} autoFocus /> diff --git a/apps/web/components/settings/external-mcp-settings.tsx b/apps/web/components/settings/external-mcp-settings.tsx index 980acd5a80..ac1f573ff2 100644 --- a/apps/web/components/settings/external-mcp-settings.tsx +++ b/apps/web/components/settings/external-mcp-settings.tsx @@ -171,6 +171,7 @@ function ToolsPreview() { * locale — the surrounding sentence is the only copy, and it interpolates them * so the pseudo-locale cannot turn them into dead pointers. */ +// i18n-exempt: third-party product names. const SNIPPET_CARDS: Array<{ title: string; build: (streamableUrl: string) => string; @@ -189,6 +190,7 @@ const SNIPPET_CARDS: Array<{ build: buildClaudeCodeConfig, buildCli: buildClaudeCodeCliCommand, cliTarget: "~/.claude.json", + // i18n-exempt: third-party product names. }, { title: "Cursor", path: "~/.cursor/mcp.json", build: buildCursorConfig }, { @@ -198,6 +200,7 @@ const SNIPPET_CARDS: Array<{ buildCli: buildCodexCliCommand, cliTarget: "~/.codex/config.toml", }, + // i18n-exempt: third-party product names. { title: "Auggie CLI", path: "~/.augment/settings.json", @@ -213,6 +216,7 @@ const SNIPPET_CARDS: Array<{ globalPath: "~/.config/opencode/opencode.json", }, build: buildOpenCodeConfig, + // i18n-exempt: third-party product names. }, { title: "GitHub Copilot CLI", path: "~/.copilot/mcp-config.json", build: buildCopilotCliConfig }, ]; diff --git a/apps/web/components/settings/lsp-language-options.ts b/apps/web/components/settings/lsp-language-options.ts index 730ccf8b2c..6e84dbadf5 100644 --- a/apps/web/components/settings/lsp-language-options.ts +++ b/apps/web/components/settings/lsp-language-options.ts @@ -46,6 +46,7 @@ export function lspAutoInstallConfigurable( return language.autoInstallSupported && preferenceLanguages.includes(language.id); } +// i18n-exempt: programming language names are not translated. export const LSP_LANGUAGE_OPTIONS: LspLanguageOption[] = [ { id: "typescript", diff --git a/apps/web/components/settings/profile-edit/docker-sections.tsx b/apps/web/components/settings/profile-edit/docker-sections.tsx index 68fcf134fe..3044954703 100644 --- a/apps/web/components/settings/profile-edit/docker-sections.tsx +++ b/apps/web/components/settings/profile-edit/docker-sections.tsx @@ -28,6 +28,7 @@ const DEFAULT_IMAGE_TAG = "kandev/multi-agent:latest"; // // The kandev backend mounts the agentctl binary into /usr/local/bin/agentctl // at container creation time, so users do NOT need to bake it in here. +// i18n-exempt: Dockerfile contents, not prose. const DEFAULT_DOCKERFILE = `FROM node:22-slim RUN apt-get update \\ diff --git a/apps/web/components/settings/secrets-settings.tsx b/apps/web/components/settings/secrets-settings.tsx index 5d345df33f..87c9635b3f 100644 --- a/apps/web/components/settings/secrets-settings.tsx +++ b/apps/web/components/settings/secrets-settings.tsx @@ -498,6 +498,7 @@ export function SecretsSettings({ workspaceId, initialItems, }: SecretsSettingsProps) { + const { t } = useTranslation(); const state = useSecretsState(scope, workspaceId, initialItems); const actions = useSecretsActions(state); const globalAdd = useAppStore((s) => s.addSecret); @@ -509,7 +510,7 @@ export function SecretsSettings({ ? (workspaceNames.find((workspace) => workspace.id === secret.workspace_id)?.name ?? secret.workspace_id ?? "workspace") - : "Global"; + : t("settings:globalScope"); // Route the transfer result by the RETURNED item's scope, never the page // scope: Global targets always land in the Global store (from any page), diff --git a/apps/web/components/settings/system/storage/storage-workspace-dependency-settings.tsx b/apps/web/components/settings/system/storage/storage-workspace-dependency-settings.tsx index d250486512..fc6bbf4db1 100644 --- a/apps/web/components/settings/system/storage/storage-workspace-dependency-settings.tsx +++ b/apps/web/components/settings/system/storage/storage-workspace-dependency-settings.tsx @@ -6,6 +6,7 @@ import { StorageSettingHelp } from "./storage-setting-help"; import { SettingRow } from "./storage-policy-fields"; // Keep this display snapshot in sync with backend DependencyDirectoryAllowlist. +// i18n-exempt: dependency directory name on disk. const DEPENDENCY_DIRECTORIES = [ "node_modules", "bower_components", diff --git a/apps/web/components/settings/workflow-card-actions.ts b/apps/web/components/settings/workflow-card-actions.ts index cf4ac89eb3..31f2e784c1 100644 --- a/apps/web/components/settings/workflow-card-actions.ts +++ b/apps/web/components/settings/workflow-card-actions.ts @@ -79,6 +79,7 @@ function openStepDeleteDialog({ // `name` becomes the step's stored name and `color` its Tailwind class — so // neither is translated; translating the name would write a localized string // into the database. +// i18n-exempt: persisted workflow step name, same contract as DEFAULT_CUSTOM_STEPS. const NEW_STEP_DEFAULTS = { name: "New Step", color: "bg-slate-500" } as const; function createDraftStep(workflow: Workflow, position: number): WorkflowStep { diff --git a/apps/web/components/settings/workflow-pipeline-editor-helpers.tsx b/apps/web/components/settings/workflow-pipeline-editor-helpers.tsx index 0581813972..0ce4d23296 100644 --- a/apps/web/components/settings/workflow-pipeline-editor-helpers.tsx +++ b/apps/web/components/settings/workflow-pipeline-editor-helpers.tsx @@ -71,6 +71,7 @@ export const STEP_COLORS = [ // The `prompt` bodies are deliberately NOT translated: clicking a template // writes the text into `WorkflowStep.prompt`, which is persisted and sent to // the agent verbatim. Only the button `labelKey` is copy. +// i18n-exempt: seeded step prompts, persisted and sent verbatim to the agent. export const PROMPT_TEMPLATES = [ { labelKey: "workflows:templatePlan", diff --git a/apps/web/components/settings/workflow-step-mutations.ts b/apps/web/components/settings/workflow-step-mutations.ts index 651e193f9f..9f8d158a93 100644 --- a/apps/web/components/settings/workflow-step-mutations.ts +++ b/apps/web/components/settings/workflow-step-mutations.ts @@ -6,6 +6,7 @@ import { createWorkflowStepAction, updateWorkflowStepAction } from "@/app/action // See `workflow-card-actions.ts`: both fields are persisted verbatim, so the // seeded step name deliberately stays English. +// i18n-exempt: persisted workflow step name, same contract as DEFAULT_CUSTOM_STEPS. const NEW_STEP_DEFAULTS = { name: "New Step", color: "bg-slate-500" } as const; function fallbackErrorMessage(error: unknown): string { diff --git a/apps/web/components/task-create-dialog-effects.ts b/apps/web/components/task-create-dialog-effects.ts index df3deb65c0..3c68666bed 100644 --- a/apps/web/components/task-create-dialog-effects.ts +++ b/apps/web/components/task-create-dialog-effects.ts @@ -554,7 +554,7 @@ export function useGitHubUrlErrorEffect(fs: DialogFormState, open: boolean) { } const parsed = parseGitHubAnyUrl(trimmed); if (!parsed) { - setGitHubUrlError("Invalid GitHub URL - expected github.com/owner/repo or .../pull/123"); + setGitHubUrlError(t("task:invalidGitHubUrl")); return; } setGitHubUrlError(null); diff --git a/apps/web/components/task/TaskHeader.tsx b/apps/web/components/task/TaskHeader.tsx index b6f9ebd815..ae5c2c2e41 100644 --- a/apps/web/components/task/TaskHeader.tsx +++ b/apps/web/components/task/TaskHeader.tsx @@ -13,6 +13,7 @@ import { Badge } from "@kandev/ui/badge"; import type { ForegroundActivity } from "@/lib/types/http"; +import { useTranslation } from "react-i18next"; export type TaskHeaderProps = { identifier?: string | null; @@ -51,12 +52,12 @@ function resolveBadgeLabel( hasPendingClarification: boolean, hasPendingPermission: boolean, ): string | null { - if (hasPendingPermission) return "Permission requested"; - if (hasPendingClarification) return "Waiting for input"; - if (foregroundActivity === "generating") return "Generating"; - if (foregroundActivity === "background") return "Background running"; - if (state === "WAITING_FOR_INPUT") return "Waiting for input"; - return state ?? null; + if (hasPendingPermission) return "task:sessionStatusPermissionRequested"; + if (hasPendingClarification) return "task:sessionStatusWaitingForInput"; + if (foregroundActivity === "generating") return "task:sessionStatusGenerating"; + if (foregroundActivity === "background") return "task:sessionStatusBackgroundRunning"; + if (state === "WAITING_FOR_INPUT") return "task:sessionStatusWaitingForInput"; + return null; } export function TaskHeader({ @@ -69,12 +70,16 @@ export function TaskHeader({ hasPendingClarification = false, hasPendingPermission = false, }: TaskHeaderProps) { - const badgeLabel = resolveBadgeLabel( + const { t } = useTranslation(); + const badgeKey = resolveBadgeLabel( state, foregroundActivity, hasPendingClarification, hasPendingPermission, ); + // `state` is a workflow-step name the user chose, so it is data and is + // rendered verbatim; only the derived activity badges are copy. + const badgeLabel = badgeKey ? t(badgeKey) : (state ?? null); return (
{identifier && ( diff --git a/apps/web/components/task/changes-git-credential-display.ts b/apps/web/components/task/changes-git-credential-display.ts index 71d328fe82..cbf842738f 100644 --- a/apps/web/components/task/changes-git-credential-display.ts +++ b/apps/web/components/task/changes-git-credential-display.ts @@ -12,18 +12,24 @@ type GitCredentialSnapshot = { transport?: unknown; }; -const workspaceMethodLabels: Record = { - pat: "workspace personal access token", - gh_cli: "workspace GitHub CLI account", - github_app_installation: "workspace GitHub App", +// Catalog keys, resolved per call: this module is imported once, so a `t()` +// here would freeze at the boot locale (docs/i18n.md). +const workspaceMethodKeys: Record = { + pat: "task:credentialWorkspacePat", + gh_cli: "task:credentialWorkspaceGhCli", + github_app_installation: "task:credentialWorkspaceGithubApp", }; -const transportLabels: Record = { - managed_https: "Kandev-managed GitHub HTTPS and gh", - profile_token: "executor-profile token", - executor_selected: "executor Git and gh credentials", +const transportKeys: Record = { + managed_https: "task:credentialTransportManagedHttps", + profile_token: "task:credentialTransportProfileToken", + executor_selected: "task:credentialTransportExecutorSelected", }; +function label(keys: Record, id: string, fallbackKey: string): string { + return t(keys[id] ?? fallbackKey); +} + function readSnapshot( metadata: Record | null | undefined, ): GitCredentialSnapshot | null { @@ -45,21 +51,21 @@ export function getGitCredentialDisplay( switch (snapshot.source) { case "workspace": return { - source: "Managed workspace identity", - detail: workspaceMethodLabels[workspaceMethod] ?? "workspace GitHub credentials", - transport: transportLabels[transport] ?? "Kandev-managed task credentials", + source: t("task:credentialSourceWorkspace"), + detail: label(workspaceMethodKeys, workspaceMethod, "task:credentialWorkspaceFallback"), + transport: label(transportKeys, transport, "task:credentialTransportManagedFallback"), }; case "executor_profile": return { - source: "Executor-profile token", - detail: actor || "An explicit GH_TOKEN or GITHUB_TOKEN", - transport: transportLabels[transport] ?? "executor-profile token", + source: t("task:credentialSourceExecutorProfile"), + detail: actor || t("task:credentialExplicitToken"), + transport: label(transportKeys, transport, "task:credentialTransportProfileToken"), }; case "executor": return { - source: "Executor credentials", + source: t("task:credentialSourceExecutor"), detail: actor || t("task:credentialsAvailableInExecutor"), - transport: transportLabels[transport] ?? "executor Git and gh credentials", + transport: label(transportKeys, transport, "task:credentialTransportExecutorSelected"), }; default: return null; diff --git a/apps/web/components/task/changes-panel-data.tsx b/apps/web/components/task/changes-panel-data.tsx index 8750bbad33..ecfae6699e 100644 --- a/apps/web/components/task/changes-panel-data.tsx +++ b/apps/web/components/task/changes-panel-data.tsx @@ -42,6 +42,7 @@ import { } from "./changes-panel-helpers"; import type { CommitDetailTarget, OpenDiffOptions } from "./changes-diff-target"; import type { PRDiffFile, TaskPR } from "@/lib/types/github"; +import { gitOperationLabel } from "@/hooks/use-git-with-feedback"; import { getGitCredentialDisplay } from "./changes-git-credential-display"; import type { RemoteContributionRelation } from "@/hooks/domains/session/remote-contribution-relation"; import { @@ -56,6 +57,7 @@ import { useRemoteContributionResolution } from "./use-remote-contribution-resol import { useTranslation } from "react-i18next"; function useChangesPanelStoreData() { + const { t } = useTranslation(); const activeTaskId = useAppStore((state) => state.tasks.activeTaskId); const activeSessionId = useEnvironmentSessionId(); const taskTitle = useAppStore((state) => { @@ -68,9 +70,12 @@ function useChangesPanelStoreData() { const activeSessionMetadata = useAppStore((state) => activeSessionId ? state.taskSessions.items[activeSessionId]?.metadata : undefined, ); + // `t` is a dependency even though the helper resolves through the module-level + // translator: without it the memo returns the labels built under the previous + // locale and the panel never restates them. const gitCredentialDisplay = useMemo( () => getGitCredentialDisplay(activeSessionMetadata), - [activeSessionMetadata], + [activeSessionMetadata, t], ); return { activeTaskId, @@ -155,18 +160,19 @@ function usePerRepoCallbacks( vcsDialogs: ReturnType, gitHandlers: ReturnType, ) { + const { t } = useTranslation(); return useMemo( () => ({ onRepoStageAll: (repo: string) => { gitHandlers.handleGitOperation( () => git.stage(undefined, repo), - repo ? `Stage all (${repo})` : "Stage all", + gitOperationLabel(t, "task:stageAll", repo), ); }, onRepoUnstageAll: (repo: string) => { gitHandlers.handleGitOperation( () => git.unstage(undefined, repo), - repo ? `Unstage all (${repo})` : "Unstage all", + gitOperationLabel(t, "task:unstageAll", repo), ); }, onRepoCommit: (repo: string) => vcsDialogs.openCommitDialog(repo), diff --git a/apps/web/components/task/chat-context-items.ts b/apps/web/components/task/chat-context-items.ts index 8df0bc6bca..1ca1d1c0b2 100644 --- a/apps/web/components/task/chat-context-items.ts +++ b/apps/web/components/task/chat-context-items.ts @@ -75,7 +75,7 @@ function buildPlanContextItem(params: BuildContextItemsParams): ContextItem | nu return { kind: "plan", id: PLAN_CONTEXT_PATH, - label: "Plan", + label: t("task:panelPlan"), taskId: taskId ?? undefined, pinned: planFile?.pinned, onRemove: sid ? () => removeContextFile(sid, PLAN_CONTEXT_PATH) : undefined, diff --git a/apps/web/components/task/chat/chat-input-area.tsx b/apps/web/components/task/chat/chat-input-area.tsx index d796385893..0797dc7c67 100644 --- a/apps/web/components/task/chat/chat-input-area.tsx +++ b/apps/web/components/task/chat/chat-input-area.tsx @@ -86,12 +86,12 @@ export function resolveInputPlaceholder( hasClarification: boolean, needsRecovery: boolean, ): string { - if (needsRecovery) return "Choose a recovery option above to continue..."; - if (hasClarification) return "Queue instructions while the question is pending..."; - if (isAgentBusy) return "Queue instructions to the agent..."; - if (activeDocumentType === "file") return "Continue working on the file..."; - if (planModeEnabled) return "Continue working on the plan..."; - return "Continue working on the task..."; + if (needsRecovery) return t("task:composerPlaceholderRecovery"); + if (hasClarification) return t("task:composerPlaceholderClarification"); + if (isAgentBusy) return t("task:composerPlaceholderBusy"); + if (activeDocumentType === "file") return t("task:composerPlaceholderFile"); + if (planModeEnabled) return t("task:composerPlaceholderPlan"); + return t("task:composerPlaceholderTask"); } type PlaceholderArgs = { @@ -107,7 +107,7 @@ type PlaceholderArgs = { /** Picks the composer placeholder: an explicit override wins, then the * "switching agent" state, then {@link resolveInputPlaceholder}. */ function pickInputPlaceholder(a: PlaceholderArgs): string { - if (a.isMoving) return "Switching agent..."; + if (a.isMoving) return t("task:composerPlaceholderSwitchingAgent"); // Preserve the prior `??` semantics: an explicit "" override (caller wants // no placeholder text) must NOT fall through to the resolver default. if (a.override !== undefined) return a.override; diff --git a/apps/web/components/task/chat/chat-input-container.tsx b/apps/web/components/task/chat/chat-input-container.tsx index fbdb4d03ef..2fa668dfd0 100644 --- a/apps/web/components/task/chat/chat-input-container.tsx +++ b/apps/web/components/task/chat/chat-input-container.tsx @@ -142,10 +142,10 @@ function FailedSessionBanner({ taskId, sessionId, workspaceId, - message = "This agent has stopped.", + message, detail, - resumeLabel = "Resume", - resumingLabel = "Resuming...", + resumeLabel, + resumingLabel, }: { showDialog: boolean; onShowDialog: (open: boolean) => void; @@ -158,6 +158,11 @@ function FailedSessionBanner({ resumingLabel?: string; }) { const { t } = useTranslation(); + // Resolved in the body, not as parameter defaults: those are evaluated before + // the component body, where `t` does not yet exist (docs/i18n.md). + const bannerMessage = message ?? t("task:agentHasStopped"); + const resumeText = resumeLabel ?? t("task:resume"); + const resumingText = resumingLabel ?? t("task:resuming"); const [isResuming, setIsResuming] = useState(false); const [isStartingFresh, setIsStartingFresh] = useState(false); @@ -196,7 +201,7 @@ function FailedSessionBanner({
- {message} + {bannerMessage} {detail && ({detail})}
@@ -213,7 +218,7 @@ function FailedSessionBanner({ disabled={isResuming || !profileExists} > - {isResuming ? resumingLabel : resumeLabel} + {isResuming ? resumingText : resumeText} diff --git a/apps/web/components/task/chat/chat-input-toolbar-primitives.tsx b/apps/web/components/task/chat/chat-input-toolbar-primitives.tsx index 27f0c39cf6..d59f22159e 100644 --- a/apps/web/components/task/chat/chat-input-toolbar-primitives.tsx +++ b/apps/web/components/task/chat/chat-input-toolbar-primitives.tsx @@ -23,6 +23,7 @@ import { formatShortcut } from "@/lib/keyboard/utils"; import { cn } from "@/lib/utils"; import type { MCPAttachmentHistory } from "@/lib/state/slices/session-runtime/types"; import { useTranslation } from "react-i18next"; +import { t } from "@/lib/i18n"; type SubmitButtonProps = { isAgentBusy: boolean; @@ -51,8 +52,8 @@ function submitTooltipDescription( submitDisabledReason?: string, ) { if (submitDisabledReason) return submitDisabledReason; - if (isAgentBusy) return "Queue message"; - if (planModeEnabled) return "Request plan changes"; + if (isAgentBusy) return t("task:submitTooltipQueueMessage"); + if (planModeEnabled) return t("task:submitTooltipRequestPlanChanges"); return undefined; } diff --git a/apps/web/components/task/chat/context-popover.tsx b/apps/web/components/task/chat/context-popover.tsx index da45eaaf03..c3243efa86 100644 --- a/apps/web/components/task/chat/context-popover.tsx +++ b/apps/web/components/task/chat/context-popover.tsx @@ -11,6 +11,7 @@ import { useCustomPrompts } from "@/hooks/domains/settings/use-custom-prompts"; import { isDirectory, getFileName } from "@/lib/utils/file-path"; import type { ContextFile } from "@/lib/state/context-files-store"; import { useTranslation } from "react-i18next"; +import { t } from "@/lib/i18n"; type ContextPopoverProps = { open: boolean; @@ -25,7 +26,12 @@ type ContextPopoverProps = { const FILE_SEARCH_DEBOUNCE = 300; -const PLAN_CONTEXT_FILE: ContextFile = { path: "plan:context", name: "Plan", pinned: true }; +// `name` is display copy; it is resolved per call rather than at module load. +const planContextFile = (): ContextFile => ({ + path: "plan:context", + name: t("task:panelPlan"), + pinned: true, +}); function FileResultsList({ isLoading, @@ -228,11 +234,11 @@ function ContextPopoverList({ {!query && (
onToggleFile(PLAN_CONTEXT_FILE)} + onClick={() => onToggleFile(planContextFile())} > onToggleFile(PLAN_CONTEXT_FILE)} + onCheckedChange={() => onToggleFile(planContextFile())} className="h-3.5 w-3.5" /> diff --git a/apps/web/components/task/chat/messages/agent-error-label.ts b/apps/web/components/task/chat/messages/agent-error-label.ts index a3e5ca1b3f..f89e9d8021 100644 --- a/apps/web/components/task/chat/messages/agent-error-label.ts +++ b/apps/web/components/task/chat/messages/agent-error-label.ts @@ -21,6 +21,7 @@ export const ENVIRONMENT_SETUP_FAILED_KEY = "task:environmentSetupFailed"; // backend: `environment preparation failed:` (manager_launch.go), the launch // race ("already has an agent running" / "race resolved during register"), // container launch failures, and branch/fresh-branch prep errors. +// i18n-exempt: substrings matched against backend error text, not copy. const SETUP_FAILURE_SIGNATURES = [ "environment preparation failed", "failed to launch container", diff --git a/apps/web/components/task/chat/messages/clarification-request-message.tsx b/apps/web/components/task/chat/messages/clarification-request-message.tsx index 1aeb8f8491..39b2091561 100644 --- a/apps/web/components/task/chat/messages/clarification-request-message.tsx +++ b/apps/web/components/task/chat/messages/clarification-request-message.tsx @@ -45,7 +45,7 @@ export function ClarificationRequestMessage({ comment }: ClarificationRequestMes // Get the answer summary for display const getAnswerSummary = () => { const response = metadata.response; - if (!response) return "No selection"; + if (!response) return t("task:clarificationNoSelection"); const parts: string[] = []; @@ -64,7 +64,7 @@ export function ClarificationRequestMessage({ comment }: ClarificationRequestMes parts.push(`"${response.custom_text}"`); } - return parts.length > 0 ? parts.join(", ") : "No selection"; + return parts.length > 0 ? parts.join(", ") : t("task:clarificationNoSelection"); }; return ( diff --git a/apps/web/components/task/chat/messages/kandev/walkthrough-renderer.tsx b/apps/web/components/task/chat/messages/kandev/walkthrough-renderer.tsx index f59170caa0..3bba290929 100644 --- a/apps/web/components/task/chat/messages/kandev/walkthrough-renderer.tsx +++ b/apps/web/components/task/chat/messages/kandev/walkthrough-renderer.tsx @@ -5,8 +5,9 @@ import { IdChip, KandevBody, KandevRow, KeyValueRow, ListItemRow, SummaryDot } f import { pickArray, pickNumber, pickString } from "./parse"; import type { KandevRenderer } from "./types"; import { useTranslation } from "react-i18next"; +import { t } from "@/lib/i18n"; -const DEFAULT_TITLE = "Walkthrough"; +// i18n-exempt: prefix matched against the agent's raw tool result, not copy. const RESULT_JSON_PREFIX = "Walkthrough saved:"; type WalkthroughStepLike = Record; @@ -39,7 +40,7 @@ function resultPayload(result: unknown): unknown { function titleFrom(args: unknown, result: unknown): string { const payload = resultPayload(result); - return pickString(payload, "title") ?? pickString(args, "title") ?? DEFAULT_TITLE; + return pickString(payload, "title") ?? pickString(args, "title") ?? t("common:walkthrough"); } function stepsFrom(args: unknown, result: unknown): WalkthroughStepLike[] { diff --git a/apps/web/components/task/chat/messages/message-comment-surface.tsx b/apps/web/components/task/chat/messages/message-comment-surface.tsx index 828cd009ff..931c2358b4 100644 --- a/apps/web/components/task/chat/messages/message-comment-surface.tsx +++ b/apps/web/components/task/chat/messages/message-comment-surface.tsx @@ -430,6 +430,7 @@ function useMessageCommentActions({ rootRef: RefObject; close: () => void; }) { + const { t } = useTranslation(); const addComment = useCommentsStore((state) => state.addComment); const updateComment = useCommentsStore((state) => state.updateComment); const removeComment = useCommentsStore((state) => state.removeComment); @@ -449,7 +450,7 @@ function useMessageCommentActions({ const renderedText = rootRef.current?.textContent ?? ""; const comment = commentFromTarget(message, sessionId, target, renderedText, feedback); if (!comment) { - setSaveError("The agent response changed. Select the text again."); + setSaveError(t("task:agentResponseChangedSelectAgain")); return null; } setSaveError(null); diff --git a/apps/web/components/task/chat/messages/monitor-message.tsx b/apps/web/components/task/chat/messages/monitor-message.tsx index 8b1267db40..9a207b140f 100644 --- a/apps/web/components/task/chat/messages/monitor-message.tsx +++ b/apps/web/components/task/chat/messages/monitor-message.tsx @@ -29,9 +29,10 @@ function formatStatusLabel( ended: boolean, endReason: string | undefined, ) { - if (!ended) return "watching"; - if (status === "cancelled" || endReason === "session_restart") return "ended (session restart)"; - return "ended"; + if (!ended) return t("task:monitorStatusWatching"); + if (status === "cancelled" || endReason === "session_restart") + return t("task:monitorStatusEndedSessionRestart"); + return t("task:monitorStatusEnded"); } type MonitorViewModel = { diff --git a/apps/web/components/task/chat/messages/shell-output-disclosure.tsx b/apps/web/components/task/chat/messages/shell-output-disclosure.tsx index ea30849d7e..5ca41ed2c0 100644 --- a/apps/web/components/task/chat/messages/shell-output-disclosure.tsx +++ b/apps/web/components/task/chat/messages/shell-output-disclosure.tsx @@ -10,6 +10,7 @@ import { cn } from "@/lib/utils"; import { isTerminalToolCallStatus, normalizeToolCallStatus } from "@/lib/utils/tool-call-status"; import type { ShellExecOutputSummary } from "../types"; import { useTranslation } from "react-i18next"; +import { t } from "@/lib/i18n"; type ShellOutputDisclosureProps = { sessionId: string; @@ -20,9 +21,9 @@ type ShellOutputDisclosureProps = { function formatOutputSize(summary: ShellExecOutputSummary | undefined) { const bytes = (summary?.stdout_bytes ?? 0) + (summary?.stderr_bytes ?? 0); - if (bytes === 0) return "Output"; - if (bytes < 1024) return `Output · ${bytes} B`; - return `Output · ${(bytes / 1024).toFixed(bytes < 10_240 ? 1 : 0)} KB`; + if (bytes === 0) return t("task:output"); + if (bytes < 1024) return `${t("task:output")} · ${bytes} B`; + return `${t("task:output")} · ${(bytes / 1024).toFixed(bytes < 10_240 ? 1 : 0)} KB`; } function OutputTranscript({ output }: { output: ShellCommandOutput }) { diff --git a/apps/web/components/task/chat/tiptap-code-block-view.tsx b/apps/web/components/task/chat/tiptap-code-block-view.tsx index a1c72db50f..fa370cf7e9 100644 --- a/apps/web/components/task/chat/tiptap-code-block-view.tsx +++ b/apps/web/components/task/chat/tiptap-code-block-view.tsx @@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next"; // `value` is the highlight.js language id and the language names are proper // nouns, so both stay untranslated. Only the "no language" option is copy, and // it travels as a catalog key because this table is built at module load. +// i18n-exempt: programming language names are not translated. const CODE_LANGUAGES: Array<{ value: string; label?: string; labelKey?: string }> = [ { value: "", labelKey: "task:codeLanguagePlain" }, { value: "javascript", label: "JavaScript" }, diff --git a/apps/web/components/task/chat/use-chat-input-container.ts b/apps/web/components/task/chat/use-chat-input-container.ts index 37e24c2106..6415ddcb06 100644 --- a/apps/web/components/task/chat/use-chat-input-container.ts +++ b/apps/web/components/task/chat/use-chat-input-container.ts @@ -155,7 +155,7 @@ function computeDerivedState(params: { // transition for local quick-chat sessions doesn't deserve its own // tooltip — the editor is disabled, that's the signal. const submitDisabledReason = - isDisabled && params.isPreparingEnvironment ? "The agent is still being set up." : undefined; + isDisabled && params.isPreparingEnvironment ? t("task:agentStillBeingSetUp") : undefined; const hasPendingComments = !!( params.pendingCommentsByFile && Object.keys(params.pendingCommentsByFile).length > 0 ); diff --git a/apps/web/components/task/chat/use-chat-input-state.ts b/apps/web/components/task/chat/use-chat-input-state.ts index 4da1c3285b..2cb1f1032f 100644 --- a/apps/web/components/task/chat/use-chat-input-state.ts +++ b/apps/web/components/task/chat/use-chat-input-state.ts @@ -38,6 +38,7 @@ import type { ImagePasteIssue } from "./clipboard-attachments"; import { deleteAttachment, uploadAttachment } from "@/lib/api/domains/attachment-api"; import { ApiError } from "@/lib/api/client"; import { useTranslation } from "react-i18next"; +import { t } from "@/lib/i18n"; type UseChatInputStateProps = { sessionId: string | null; @@ -278,7 +279,7 @@ function useAttachments(sessionId: string | null, workspaceId?: string | null) { } catch (error) { updateAttachment(attachment.id, { uploadStatus: "failed", - uploadError: error instanceof ApiError ? error.message : "Upload failed", + uploadError: error instanceof ApiError ? error.message : t("task:attachmentUploadFailed"), }); } }, diff --git a/apps/web/components/task/dockview-desktop-layout.test.ts b/apps/web/components/task/dockview-desktop-layout.test.ts index 04a6fbc18e..40dded09fe 100644 --- a/apps/web/components/task/dockview-desktop-layout.test.ts +++ b/apps/web/components/task/dockview-desktop-layout.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from "vitest"; -import { CHAT_PANEL_FALLBACK_LABEL, resolveChatPanelTitle } from "./dockview-panel-content"; +import { t } from "@/lib/i18n"; +import { resolveChatPanelTitle } from "./dockview-panel-content"; /** * Regression: the generic "chat" placeholder dockview panel used to fall back @@ -8,27 +9,34 @@ import { CHAT_PANEL_FALLBACK_LABEL, resolveChatPanelTitle } from "./dockview-pan * gate inside `useChatSessionTitle` that suppressed the agent label for the * non-session-scoped placeholder. The pure resolver imported here is the place * the gate would have to be re-introduced, so this test pins the behavior. + * + * The fallback is asserted through the catalog rather than as the literal + * "Agent": what this test is about is WHICH value is chosen, not what that + * value's English happens to be, and pinning the copy here would make an + * ordinary wording change fail four unrelated assertions. */ +const FALLBACK = () => t("task:panelAgent"); + describe("resolveChatPanelTitle", () => { it("returns the agent label when one is provided", () => { - expect(resolveChatPanelTitle("Opus")).toBe("Opus"); + expect(resolveChatPanelTitle("Opus", t)).toBe("Opus"); }); it("falls back to the generic 'Agent' label when null", () => { - expect(resolveChatPanelTitle(null)).toBe(CHAT_PANEL_FALLBACK_LABEL); + expect(resolveChatPanelTitle(null, t)).toBe(FALLBACK()); }); it("falls back to the generic 'Agent' label when undefined", () => { - expect(resolveChatPanelTitle(undefined)).toBe(CHAT_PANEL_FALLBACK_LABEL); + expect(resolveChatPanelTitle(undefined, t)).toBe(FALLBACK()); }); it("falls back to the generic 'Agent' label when the agent label is empty", () => { - expect(resolveChatPanelTitle("")).toBe(CHAT_PANEL_FALLBACK_LABEL); + expect(resolveChatPanelTitle("", t)).toBe(FALLBACK()); }); it("uses the agent label verbatim — does not coerce or relabel valid names", () => { for (const name of ["Mock", "Claude Code", "GPT-5", "amp"]) { - expect(resolveChatPanelTitle(name)).toBe(name); + expect(resolveChatPanelTitle(name, t)).toBe(name); } }); }); diff --git a/apps/web/components/task/dockview-panel-content.tsx b/apps/web/components/task/dockview-panel-content.tsx index bde0276355..ab75b4947e 100644 --- a/apps/web/components/task/dockview-panel-content.tsx +++ b/apps/web/components/task/dockview-panel-content.tsx @@ -27,14 +27,17 @@ import { TaskPlanPanel } from "./task-plan-panel"; import { TerminalPanel } from "./terminal-panel"; import { TodosContent } from "./todos-panel-content"; import { VscodePanel } from "./vscode-panel"; +import { useTranslation } from "react-i18next"; -export const CHAT_PANEL_FALLBACK_LABEL = "Agent"; - -export function resolveChatPanelTitle(agentLabel: string | null | undefined): string { - return agentLabel || CHAT_PANEL_FALLBACK_LABEL; +export function resolveChatPanelTitle( + agentLabel: string | null | undefined, + translate: (key: string) => string, +): string { + return agentLabel || translate("task:panelAgent"); } function useChatSessionTitle(panelId: string, sessionId: string | null) { + const { t } = useTranslation(); const agentLabel = useAppStore((state) => { if (!sessionId) return null; const session = state.taskSessions.items[sessionId]; @@ -49,9 +52,11 @@ function useChatSessionTitle(panelId: string, sessionId: string | null) { const parts = profile.label.split(" \u2022 "); return parts[1] || parts[0] || profile.label; }); + // `t` is a dependency: a locale switch changes the title with no change to + // the panel or the label, and without it the tab keeps the old language. useEffect(() => { - setPanelTitle(panelId, resolveChatPanelTitle(agentLabel)); - }, [panelId, agentLabel]); + setPanelTitle(panelId, resolveChatPanelTitle(agentLabel, t)); + }, [panelId, agentLabel, t]); } function ChatContent({ panelId, params }: { panelId: string; params: Record }) { @@ -127,6 +132,7 @@ function DiffViewerContent({ } function ChangesContent({ panelId }: { panelId: string }) { + const { t } = useTranslation(); const addDiffViewerPanel = useDockviewStore((s) => s.addDiffViewerPanel); const addFileDiffPanel = useDockviewStore((s) => s.addFileDiffPanel); const addCommitDetailPanel = useDockviewStore((s) => s.addCommitDetailPanel); @@ -138,9 +144,10 @@ function ChangesContent({ panelId }: { panelId: string }) { const totalCount = useSessionChangesCount(activeSessionId); useEffect(() => { - const title = totalCount > 0 ? `Changes (${totalCount})` : "Changes"; + const title = + totalCount > 0 ? `${t("task:panelChanges")} (${totalCount})` : t("task:panelChanges"); setPanelTitle(panelId, title); - }, [totalCount, panelId]); + }, [totalCount, panelId, t]); const handleEditFile = useCallback( (path: string, repo?: string) => openFile(path, repo), diff --git a/apps/web/components/task/dockview-shared.tsx b/apps/web/components/task/dockview-shared.tsx index c4b0760034..6c4ed339ff 100644 --- a/apps/web/components/task/dockview-shared.tsx +++ b/apps/web/components/task/dockview-shared.tsx @@ -46,6 +46,7 @@ import { setPanelTitle, panelPortalManager } from "@/lib/layout/panel-portal-man import { getWebSocketClient } from "@/lib/ws/connection"; import { usePortalSlot } from "@/lib/layout/panel-portal-host"; import { ENV_SCOPED_DOCKVIEW_COMPONENTS } from "@/lib/state/dockview-env-scoped-components"; +import { useTranslation } from "react-i18next"; // --------------------------------------------------------------------------- // PORTAL SLOT — generic dockview component that adopts a persistent portal @@ -304,6 +305,7 @@ function DiffViewerContent({ } function ChangesContent({ panelId }: { panelId: string }) { + const { t } = useTranslation(); const addDiffViewerPanel = useDockviewStore((s) => s.addDiffViewerPanel); const addFileDiffPanel = useDockviewStore((s) => s.addFileDiffPanel); const addCommitDetailPanel = useDockviewStore((s) => s.addCommitDetailPanel); @@ -318,9 +320,10 @@ function ChangesContent({ panelId }: { panelId: string }) { const totalCount = fileCount + commits.length; useEffect(() => { - const title = totalCount > 0 ? `Changes (${totalCount})` : "Changes"; + const title = + totalCount > 0 ? `${t("task:panelChanges")} (${totalCount})` : t("task:panelChanges"); setPanelTitle(panelId, title); - }, [totalCount, panelId]); + }, [totalCount, panelId, t]); const handleEditFile = useCallback( (path: string, repo?: string) => openFile(path, repo), diff --git a/apps/web/components/task/ensure-session-error.tsx b/apps/web/components/task/ensure-session-error.tsx index 2007bf8341..96278dad23 100644 --- a/apps/web/components/task/ensure-session-error.tsx +++ b/apps/web/components/task/ensure-session-error.tsx @@ -16,6 +16,7 @@ export type EnsureSessionErrorInfo = { }; // Matches the backend's exact validation message in task_http_handlers.go / task_ws_handlers.go. +// i18n-exempt: matches the backend's exact validation message. See the comment above. const AGENT_PROFILE_MISSING_HINT = "agent_profile_id is required"; export function describeEnsureError( diff --git a/apps/web/components/task/new-session-dialog.tsx b/apps/web/components/task/new-session-dialog.tsx index f22603c7cb..bf63fdc7c7 100644 --- a/apps/web/components/task/new-session-dialog.tsx +++ b/apps/web/components/task/new-session-dialog.tsx @@ -132,6 +132,7 @@ function activateNewSession( } function useSessionOptions(taskId: string) { + const { t } = useTranslation(); const { sessions, loadSessions } = useTaskSessions(taskId); const agentProfiles = useAppStore((s) => s.agentProfiles.items); useEffect(() => { @@ -144,10 +145,10 @@ function useSessionOptions(taskId: string) { ); return sorted.map((s, idx) => { const profile = agentProfiles.find((p: { id: string }) => p.id === s.agent_profile_id); - const name = profile ? agentProfileDisplayLabel(profile) : "Agent"; + const name = profile ? agentProfileDisplayLabel(profile) : t("task:panelAgent"); return { id: s.id, label: name, index: idx + 1, agentName: profile?.agent_name }; }); - }, [sessions, agentProfiles]); + }, [sessions, agentProfiles, t]); } function isMissingCompatibleProfile( diff --git a/apps/web/components/task/new-subtask-dialog.tsx b/apps/web/components/task/new-subtask-dialog.tsx index 9714b20262..9fc08af589 100644 --- a/apps/web/components/task/new-subtask-dialog.tsx +++ b/apps/web/components/task/new-subtask-dialog.tsx @@ -170,6 +170,7 @@ function useSubtaskDialogState(parentTaskId: string, parentSessions: TaskSession } function useSessionOptions(taskId: string) { + const { t } = useTranslation(); const { sessions, loadSessions } = useTaskSessions(taskId); const agentProfiles = useAppStore((s) => s.agentProfiles.items); useEffect(() => { @@ -183,10 +184,10 @@ function useSessionOptions(taskId: string) { return sorted.map((s, idx) => { const profile = agentProfiles.find((p: { id: string }) => p.id === s.agent_profile_id); const parts = profile?.label.split(" \u2022 "); - const name = parts?.[1] || parts?.[0] || "Agent"; + const name = parts?.[1] || parts?.[0] || t("task:panelAgent"); return { id: s.id, label: name, index: idx + 1, agentName: profile?.agent_name }; }); - }, [sessions, agentProfiles]); + }, [sessions, agentProfiles, t]); } function useExecutorProfiles( diff --git a/apps/web/components/task/session-tab.tsx b/apps/web/components/task/session-tab.tsx index 41d4d3725b..26e49ba508 100644 --- a/apps/web/components/task/session-tab.tsx +++ b/apps/web/components/task/session-tab.tsx @@ -34,6 +34,7 @@ import { TabRenameInput } from "./tab-rename-input"; import { useTabMaximizeOnDoubleClick } from "./use-tab-maximize"; import { SessionTabCloseAction } from "./session-tab-close-action"; import { useSessionTabDelete } from "./use-session-tab-delete"; +import { useTranslation } from "react-i18next"; function useSessionTabState(sessionId: string | undefined) { const isPrimary = useAppStore((state) => { @@ -137,6 +138,7 @@ function useSessionRenameCommitter( currentName: string | null, onDone: () => void, ) { + const { t } = useTranslation(); const appStoreApi = useAppStoreApi(); const { toast } = useToast(); return useCallback( @@ -156,8 +158,8 @@ function useSessionRenameCommitter( } catch (error) { console.error("rename session:", error); toast({ - title: "Rename failed", - description: error instanceof Error ? error.message : "Unknown error", + title: t("task:renameFailed"), + description: error instanceof Error ? error.message : t("common:unknownError"), variant: "error", }); } diff --git a/apps/web/components/task/shell-terminal.tsx b/apps/web/components/task/shell-terminal.tsx index f2b3b7047f..9fa6cdcb33 100644 --- a/apps/web/components/task/shell-terminal.tsx +++ b/apps/web/components/task/shell-terminal.tsx @@ -69,6 +69,7 @@ function useTerminalInit({ disableStdin: isReadOnlyMode, convertEol: isReadOnlyMode, fontSize: fontSize ?? (isReadOnlyMode ? 12 : 13), + // i18n-exempt: CSS font stack. fontFamily: fontFamily || 'Menlo, Monaco, "Courier New", monospace', macOptionIsMeta: true, theme: getTerminalTheme(terminalRef.current), diff --git a/apps/web/components/task/task-archived-context.tsx b/apps/web/components/task/task-archived-context.tsx index 8a348a6a63..95c52a82b2 100644 --- a/apps/web/components/task/task-archived-context.tsx +++ b/apps/web/components/task/task-archived-context.tsx @@ -2,6 +2,7 @@ import { createContext, useContext, memo } from "react"; import { PanelRoot, PanelBody } from "./panel-primitives"; +import { useTranslation } from "react-i18next"; type TaskArchivedState = { isArchived: boolean; @@ -24,15 +25,18 @@ export function useArchivedTaskState() { } export const ArchivedPanelPlaceholder = memo(function ArchivedPanelPlaceholder({ - message = "Workspace not available - this task is archived", + message, }: { message?: string; }) { + const { t } = useTranslation(); + // Resolved in the body: a parameter default runs before the hook is in scope. + const text = message ?? t("task:workspaceUnavailableArchived"); return (
- {message} + {text}
diff --git a/apps/web/components/task/task-session-sidebar.tsx b/apps/web/components/task/task-session-sidebar.tsx index 39e2788c6f..d8d5c9aeb1 100644 --- a/apps/web/components/task/task-session-sidebar.tsx +++ b/apps/web/components/task/task-session-sidebar.tsx @@ -184,6 +184,7 @@ function useMoveToStep(store: StoreApi) { } function useArchiveActions(store: StoreApi) { + const { t } = useTranslation(); const archiveAndSwitch = useArchiveAndSwitchTask({ useLayoutSwitch: true }); const [archivingTask, setArchivingTask] = useState<{ id: string; @@ -199,7 +200,7 @@ function useArchiveActions(store: StoreApi) { const task = findSidebarTask(state, taskId); setArchivingTask({ id: taskId, - title: task?.title ?? "this task", + title: task?.title ?? t("task:thisTask"), executorType: task?.primaryExecutorType, }); }, @@ -239,6 +240,7 @@ function useDeleteActions( store: StoreApi, removeTaskFromBoard: ReturnType["removeTaskFromBoard"], ) { + const { t } = useTranslation(); const { deleteTaskById } = useTaskActions(); const [deletingTask, setDeletingTask] = useState<{ id: string; @@ -253,7 +255,7 @@ function useDeleteActions( const task = findSidebarTask(state, taskId); setDeletingTask({ id: taskId, - title: task?.title ?? "this task", + title: task?.title ?? t("task:thisTask"), executorType: task?.primaryExecutorType, }); }, diff --git a/apps/web/components/task/terminal-tab.tsx b/apps/web/components/task/terminal-tab.tsx index 03aa822cee..31a0c413fd 100644 --- a/apps/web/components/task/terminal-tab.tsx +++ b/apps/web/components/task/terminal-tab.tsx @@ -16,6 +16,7 @@ import { CloseTerminalConfirmDialog } from "./close-terminal-confirm-dialog"; import { TabRenameInput } from "./tab-rename-input"; import { markTerminalPanelTerminateClose } from "./dockview-layout-setup"; import { useTranslation } from "react-i18next"; +import { t } from "@/lib/i18n"; /** * Custom dockview tab for terminal panels. @@ -56,7 +57,7 @@ function pickDisplayName( fallback: string, ): string { if (shell?.customName && shell.customName !== "") return shell.customName; - if (shell?.kind === "ordinary") return "Terminal"; + if (shell?.kind === "ordinary") return t("task:panelTerminal"); if (shell?.label) return shell.label; return fallback; } diff --git a/apps/web/hooks/domains/kanban/use-plan-actions.ts b/apps/web/hooks/domains/kanban/use-plan-actions.ts index ff4d9eaef4..f00eca4ed9 100644 --- a/apps/web/hooks/domains/kanban/use-plan-actions.ts +++ b/apps/web/hooks/domains/kanban/use-plan-actions.ts @@ -96,6 +96,7 @@ export function useNextWorkflowStep(taskId: string | null) { return { proceedStepName, nextStepIsWorkStep, proceed, isMoving }; } +// i18n-exempt: system block sent verbatim to the agent. const IMPLEMENT_PLAN_SYSTEM_BLOCK = ` IMPLEMENT PLAN: The user has approved the plan and wants you to implement it now. Read the current plan using the get_task_plan_kandev MCP tool. @@ -104,6 +105,7 @@ After completing the implementation, provide a summary of what was done. `; export function buildImplementPlanContent(userText: string): string { + // i18n-exempt: becomes the message content sent to the agent and stored in the transcript. const visibleText = userText.trim() || "Implement the plan"; return `${visibleText}\n\n${IMPLEMENT_PLAN_SYSTEM_BLOCK}`; } diff --git a/apps/web/hooks/domains/session/use-session-resumption.ts b/apps/web/hooks/domains/session/use-session-resumption.ts index 1b9e939cd8..b2f395e955 100644 --- a/apps/web/hooks/domains/session/use-session-resumption.ts +++ b/apps/web/hooks/domains/session/use-session-resumption.ts @@ -181,7 +181,7 @@ export async function resumeWithSilentFallback( return true; } setters.setResumptionState("error"); - setters.setError("Failed to resume session - workspace restore also unavailable"); + setters.setError(t("task:failedToResumeAndRestore")); return false; } @@ -424,7 +424,7 @@ async function checkAndResume({ } } catch (err) { setters.setResumptionState("error"); - setters.setError(err instanceof Error ? err.message : "Unknown error"); + setters.setError(err instanceof Error ? err.message : t("common:unknownError")); } } @@ -643,11 +643,11 @@ export function useSessionResumption( return true; } setResumptionState("error"); - setError("Failed to resume session"); + setError(t("task:failedToResumeSession")); return false; } catch (err) { setResumptionState("error"); - setError(err instanceof Error ? err.message : "Unknown error"); + setError(err instanceof Error ? err.message : t("common:unknownError")); return false; } }, [ diff --git a/apps/web/hooks/use-summarize-session.ts b/apps/web/hooks/use-summarize-session.ts index c5ad7977a3..088e9ecc8f 100644 --- a/apps/web/hooks/use-summarize-session.ts +++ b/apps/web/hooks/use-summarize-session.ts @@ -13,6 +13,7 @@ function formatTranscript(messages: Message[]): string { return messages .filter((m) => m.type === "message" || m.type === "content") .map((m) => { + // i18n-exempt: transcript role markers sent verbatim to the summarizing model, not shown to a user. const role = m.author_type === "user" ? "User" : "Agent"; return `${role}: ${m.content}`; }) @@ -46,7 +47,7 @@ export function useSummarizeSession() { } catch (error) { return { summary: null, - error: error instanceof Error ? error.message : "Could not generate a summary", + error: error instanceof Error ? error.message : t("task:couldNotGenerateSummary"), }; } finally { setIsSummarizing(false); diff --git a/apps/web/hooks/use-update-available-toast.ts b/apps/web/hooks/use-update-available-toast.ts index b1489dd66e..d841941bfe 100644 --- a/apps/web/hooks/use-update-available-toast.ts +++ b/apps/web/hooks/use-update-available-toast.ts @@ -22,8 +22,7 @@ export function useUpdateAvailableToast() { const title = notification.title || t("task:kandevUpdateAvailable"); const body = - notification.body || - `Kandev ${notification.version} is available. Open Settings > System > Updates to review it.`; + notification.body || t("task:updateAvailableToast", { version: notification.version }); toast({ title, description: body }); if (nativeNotifications.isAvailable()) { diff --git a/apps/web/lib/api/domains/plan-api.ts b/apps/web/lib/api/domains/plan-api.ts index 29c050e4b3..b133ed0b5e 100644 --- a/apps/web/lib/api/domains/plan-api.ts +++ b/apps/web/lib/api/domains/plan-api.ts @@ -1,6 +1,7 @@ import { getWebSocketClient } from "@/lib/ws/connection"; import type { TaskPlan, TaskPlanRevision } from "@/lib/types/http"; +// i18n-exempt: diagnostic thrown to the caller, never rendered. const WS_CLIENT_UNAVAILABLE = "WebSocket client not available"; /** @@ -35,6 +36,7 @@ export async function createTaskPlan( } const response = await client.request("task.plan.create", { task_id: taskId, + // i18n-exempt: canonical English persisted as the plan title. See the comment below. // Canonical English on purpose: the backend persists this into // `task_plans.title` / `task_plan_revisions.title`, so a localized default // would freeze whichever locale created the plan into the stored record. diff --git a/apps/web/lib/github-auth.ts b/apps/web/lib/github-auth.ts index 8dc01544d7..0c78c2e8e9 100644 --- a/apps/web/lib/github-auth.ts +++ b/apps/web/lib/github-auth.ts @@ -29,6 +29,7 @@ export function getGitHubMutationActor(status: GitHubStatus | null) { const effective = status?.effective_manual_mutation_actor; if (!effective) return null; if (effective.kind === "app") { + // i18n-exempt: GitHub's own product term for an app-authenticated actor. return effective.login ? `${effective.login} GitHub App` : "GitHub App"; } return effective.login ?? null; diff --git a/apps/web/lib/i18n/index.ts b/apps/web/lib/i18n/index.ts index 9144bb4c92..a0f1ecd00f 100644 --- a/apps/web/lib/i18n/index.ts +++ b/apps/web/lib/i18n/index.ts @@ -22,6 +22,7 @@ export const SUPPORTED_LOCALES = ["en", "pt-pt", "zh-cn", "zh-tw", "zh-hk", "pse export type SupportedLocale = (typeof SUPPORTED_LOCALES)[number]; /** Human-readable labels for the language switcher. */ +// i18n-exempt: fixed endonyms: each locale names itself in its own language, in every UI locale. export const LOCALE_LABELS: Record = { en: "English", "pt-pt": "Português (Portugal)", diff --git a/apps/web/lib/layout/layout-profiles.ts b/apps/web/lib/layout/layout-profiles.ts index 660172e13e..d893f07184 100644 --- a/apps/web/lib/layout/layout-profiles.ts +++ b/apps/web/lib/layout/layout-profiles.ts @@ -52,6 +52,7 @@ export type BuiltInLayoutProfile = BuiltInLayoutProfileDescriptor & { * * "VS Code" is a product name and so has no `nameKey`. */ +// i18n-exempt: canonical English persisted in saved layouts; `descriptionKey` beside it is what renders. export const BUILT_IN_LAYOUT_PROFILES: readonly BuiltInLayoutProfileDescriptor[] = [ { id: "default", diff --git a/apps/web/lib/settings/external-mcp-tools.ts b/apps/web/lib/settings/external-mcp-tools.ts index 732a7e5f99..28738747d2 100644 --- a/apps/web/lib/settings/external-mcp-tools.ts +++ b/apps/web/lib/settings/external-mcp-tools.ts @@ -26,6 +26,7 @@ export type ExternalMcpToolGroup = { }; /** Task-state enum accepted by `update_task_state_kandev`, verbatim wire values. */ +// i18n-exempt: task state enum values, matched by the backend. const TASK_STATES = "open, in_progress, complete, blocked, cancelled"; /** Request field on `create_task_kandev`, verbatim wire value. */ diff --git a/apps/web/lib/state/layout-manager/constants.ts b/apps/web/lib/state/layout-manager/constants.ts index 3d4a2c522e..bd6e6c7ac8 100644 --- a/apps/web/lib/state/layout-manager/constants.ts +++ b/apps/web/lib/state/layout-manager/constants.ts @@ -85,6 +85,7 @@ export const STRUCTURAL_COMPONENTS = new Set([ * `vscode` has no `titleKey` on purpose: "VS Code" is a product name, and * `panelTitle()` falls back to `title` when a key is absent. */ +// i18n-exempt: canonical English persisted in saved layouts; `titleKey` beside it is what renders. export const PANEL_REGISTRY: Record & { titleKey?: string }> = { chat: { component: "chat", diff --git a/apps/web/lib/state/layout-manager/serializer.ts b/apps/web/lib/state/layout-manager/serializer.ts index ad23c262b9..74b374e25a 100644 --- a/apps/web/lib/state/layout-manager/serializer.ts +++ b/apps/web/lib/state/layout-manager/serializer.ts @@ -11,6 +11,7 @@ import { import { panelTitle } from "./panel-title"; /** Canonical English, because `normalizePanel` produces a stored LayoutState. */ +// i18n-exempt: canonical English persisted in saved layouts, compared when restoring. const TERMINAL_CANONICAL_TITLE = canonicalPanelTitle(TERMINAL_DEFAULT_ID) ?? "Terminal"; // Dockview serialized grid node types (internal format) diff --git a/apps/web/lib/state/slices/ui/sidebar-view-builtins.ts b/apps/web/lib/state/slices/ui/sidebar-view-builtins.ts index 2d974d4aa4..b4e4b1c1ce 100644 --- a/apps/web/lib/state/slices/ui/sidebar-view-builtins.ts +++ b/apps/web/lib/state/slices/ui/sidebar-view-builtins.ts @@ -25,6 +25,7 @@ export function createDefaultSidebarView(id: string, name: string): SidebarView */ export const DEFAULT_VIEW_NAME_KEY = "sidebar:viewAllTasks"; +// i18n-exempt: canonical English persisted as the built-in view's name; `sidebarViewName()` renders it. export const DEFAULT_VIEW: SidebarView = createDefaultSidebarView(DEFAULT_VIEW_ID, "All tasks"); /** diff --git a/apps/web/package.json b/apps/web/package.json index 4753f5cd63..8075ab2314 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -28,7 +28,7 @@ "e2e:waits": "node scripts/report-e2e-waits.mjs", "licenses:gen": "tsx scripts/generate-licenses.ts", "i18n:pseudo": "node scripts/generate-pseudo-locale.mjs", - "i18n:check": "node scripts/check-i18n-keys.mjs && node scripts/check-trans-indices.mjs && node scripts/check-inline-plurals.mjs && node scripts/check-module-scope-t.mjs && node scripts/check-no-em-dash-ui.mjs", + "i18n:check": "node scripts/check-i18n-keys.mjs && node scripts/check-trans-indices.mjs && node scripts/check-inline-plurals.mjs && node scripts/check-module-scope-t.mjs && node scripts/check-no-em-dash-ui.mjs && node scripts/check-nonjsx-copy.mjs", "i18n:ratchet": "node scripts/run-i18n-ratchet.mjs", "i18n:removed": "node scripts/check-removed-literals.mjs", "i18n:sweep": "node scripts/i18n-sweep.mjs", diff --git a/apps/web/scripts/check-new-code-i18n.mjs b/apps/web/scripts/check-new-code-i18n.mjs index 983f258e41..41de238464 100644 --- a/apps/web/scripts/check-new-code-i18n.mjs +++ b/apps/web/scripts/check-new-code-i18n.mjs @@ -18,10 +18,17 @@ * This mirrors `golangci-lint run --new-from-rev` in .pre-commit-config.yaml, so * it is the same contract the Go side already enforces. * - * NOTE: this raises the floor, it does not seal the box. The rule only sees plain - * literals and template literals in JSX — `confirm()` arguments and copy returned - * from plain `.ts` helpers stay invisible. The pseudo-locale remains the - * completeness check (docs/i18n.md). + * Two detectors run over the same lines, because one rule cannot see all of it: + * + * - `i18next/no-literal-string`, which inspects literals in JSX. + * - `findNonJsxCopy`, which inspects the positions that rule structurally + * cannot: config tables, plain helpers, parameter defaults and toast/setter + * arguments. Without it a brand-new file could hold a whole screen's copy in + * a `const ROWS = [...]` and pass (docs/i18n.md, "The guard has blind spots"). + * + * This raises the floor, it does not seal the box: copy assembled at runtime from + * fragments is still invisible, and the pseudo-locale remains the completeness + * check (docs/i18n.md). * * Usage: * node scripts/check-new-code-i18n.mjs [--base ] @@ -32,6 +39,7 @@ * explicit one is floored at that same fork point (see lib/git-base.mjs), because * a base sha captured when a PR opened goes stale the moment main moves. */ +import { readFileSync } from "node:fs"; import path from "node:path"; import { ESLint } from "eslint"; @@ -39,6 +47,8 @@ import { ESLint } from "eslint"; import { changedFiles, webDiff } from "./lib/changed-files.mjs"; import { lineInRanges, parseAddedLineRanges } from "./lib/diff-ranges.mjs"; import { REPO_ROOT, resolveBase, toPosixPath, WEB_DIR } from "./lib/git-base.mjs"; +import { findNonJsxCopy } from "./lib/nonjsx-copy.mjs"; +import { noLiteralStringOptions } from "../eslint.i18n.options.mjs"; const resolved = resolveBase(); if (resolved.skip) { @@ -71,17 +81,53 @@ const results = await eslint.lintFiles(targets.map((f) => path.join(REPO_ROOT, f const addedSet = new Set(added); const violations = []; +const record = (repoPath, whole, line, text) => { + if (!whole && !lineInRanges(addedRanges.get(repoPath), line)) return; + violations.push({ repoPath, whole, line, text }); +}; + for (const result of results) { const repoPath = toPosixPath(path.relative(REPO_ROOT, result.filePath)); - const whole = addedSet.has(repoPath); - const ranges = addedRanges.get(repoPath); for (const message of result.messages) { if (message.ruleId !== "i18next/no-literal-string") continue; - if (!whole && !lineInRanges(ranges, message.line)) continue; - violations.push({ repoPath, whole, line: message.line, text: message.message }); + record(repoPath, addedSet.has(repoPath), message.line, message.message); } } +// The same lines, judged for the copy the eslint rule cannot see. Scoped to the +// change exactly like the rule above, so an un-migrated file's existing config +// tables stay somebody else's migration. +const excludes = noLiteralStringOptions.words?.exclude ?? []; +for (const repoPath of targets) { + if (!/\.tsx?$/.test(repoPath) || /\.(test|spec|d)\.tsx?$/.test(repoPath)) continue; + const source = readFileSync(path.join(REPO_ROOT, repoPath), "utf8"); + const { findings, markersWithoutReason } = findNonJsxCopy(source, { + filename: repoPath, + excludes, + }); + for (const finding of findings) { + record( + repoPath, + addedSet.has(repoPath), + finding.line, + `Hardcoded copy in a non-JSX position [${finding.kind}]: ${JSON.stringify(finding.value)}`, + ); + } + // A reasonless `// i18n-exempt` still SILENCES the findings around it, so + // dropping this list would let new code opt out of the check by writing the + // marker and nothing else — the one hole the mandatory reason exists to close. + for (const line of markersWithoutReason) { + record( + repoPath, + addedSet.has(repoPath), + line, + "i18n-exempt marker with no reason — write `// i18n-exempt: `", + ); + } +} + +violations.sort((a, b) => a.repoPath.localeCompare(b.repoPath) || a.line - b.line); + if (violations.length === 0) { console.log( `✓ i18n new-code ratchet — ${added.length} added + ${modified.length} modified file(s) clean`, diff --git a/apps/web/scripts/check-nonjsx-copy.mjs b/apps/web/scripts/check-nonjsx-copy.mjs new file mode 100644 index 0000000000..e0b40595e8 --- /dev/null +++ b/apps/web/scripts/check-nonjsx-copy.mjs @@ -0,0 +1,80 @@ +#!/usr/bin/env node +/** + * Fail on user-facing copy that `i18next/no-literal-string` cannot see. + * + * That rule is `jsx-only`, so a migrated file can hold a config table, a helper + * that returns a label, a prop default, or a toast title in plain English and + * still report clean. docs/i18n.md calls these the guard's blind spots and names + * the pseudo-locale as the only completeness check — but the pseudo walk is + * manual, and it cannot see copy that never becomes a text node or that is + * interpolated into a translated frame. This closes the gap mechanically. + * + * SCOPE: only the paths in `i18nGuardFiles`, exactly like the eslint rule. A + * file is judged whole once its directory is migrated, and never before. That is + * the same ratchet the rest of this system uses — it can only tighten, and it + * never asks anyone to migrate a line they did not write. The repo-wide half of + * the contract is `check-new-code-i18n.mjs`, which runs this same detector over + * added and changed lines everywhere. + * + * Silence a finding with `// i18n-exempt: ` on the enclosing statement. + * A reason is required, because every shape here has a legitimate form worth + * telling apart from a defect: a value persisted in whichever locale created it, + * a prompt sent verbatim to a model, a wire value matched with `===`. + * + * Usage: node scripts/check-nonjsx-copy.mjs [ ...] + */ +import fs from "node:fs"; +import path from "node:path"; + +import { i18nGuardFiles, noLiteralStringOptions } from "../eslint.i18n.options.mjs"; +import { findNonJsxCopy } from "./lib/nonjsx-copy.mjs"; + +const ROOT = path.resolve(import.meta.dirname, ".."); +const patterns = process.argv.slice(2).filter((arg) => !arg.startsWith("--")); +const globs = patterns.length ? patterns : i18nGuardFiles; + +const files = new Set(); +for (const glob of globs) { + for (const match of fs.globSync(glob, { cwd: ROOT })) { + if (!/\.tsx?$/.test(match)) continue; + if (/\.(test|spec|d)\.tsx?$/.test(match)) continue; + files.add(match); + } +} + +const excludes = noLiteralStringOptions.words?.exclude ?? []; +const problems = []; +const unexplained = []; + +for (const file of [...files].sort()) { + const source = fs.readFileSync(path.join(ROOT, file), "utf8"); + const { findings, markersWithoutReason } = findNonJsxCopy(source, { filename: file, excludes }); + for (const finding of findings) problems.push({ file, ...finding }); + for (const line of markersWithoutReason) unexplained.push({ file, line }); +} + +if (unexplained.length) { + console.error(`\n✖ ${unexplained.length} i18n-exempt marker(s) with no reason.\n`); + for (const { file, line } of unexplained) { + console.error(` ${file}:${line} write \`// i18n-exempt: \``); + } +} + +if (problems.length) { + console.error( + `\n✖ ${problems.length} hardcoded user-facing string(s) in a position lint cannot see.\n\n` + + ` These sit on paths already listed in i18nGuardFiles, so the copy around\n` + + ` them is translated and these are regressions. Route each through t(), or\n` + + ` mark it \`// i18n-exempt: \` if it is a persisted value, an\n` + + ` agent-facing prompt, or a wire value. See docs/i18n.md.\n`, + ); + for (const { file, line, kind, context, value } of problems) { + const where = context ? `${kind} ${context}` : kind; + console.error(` ${file}:${line} [${where}] ${JSON.stringify(value)}`); + } + console.error(""); +} + +if (problems.length || unexplained.length) process.exit(1); + +console.log(`✓ no non-JSX copy — ${files.size} guarded file(s) checked.`); diff --git a/apps/web/scripts/lib/nonjsx-copy.mjs b/apps/web/scripts/lib/nonjsx-copy.mjs new file mode 100644 index 0000000000..4fb0176640 --- /dev/null +++ b/apps/web/scripts/lib/nonjsx-copy.mjs @@ -0,0 +1,418 @@ +/** + * Find user-facing copy sitting in a position `i18next/no-literal-string` + * structurally cannot see. + * + * That rule runs in `jsx-only` mode, so it inspects literals in JSX and nothing + * else. docs/i18n.md enumerates what falls through, and every shape below was + * found in already-migrated code that lint, `i18n:check`, the ratchet and the + * pseudo-locale walk all reported clean: + * + * const ROWS = [{ label: "Disk usage" }] // SCREAMING_CASE is skipped + * function badge() { return "Waiting for input" } // a plain helper holds no JSX + * function C({ label = "Running..." }) {} // evaluated before the hook + * toast({ title: "Rename failed" }) // never a JSX literal + * + * The last one is the expensive class: the string is handed to a TRANSLATED + * frame, so under the pseudo-locale it renders inside accented copy and reads as + * done. `t("common:gitOperationFailed", { operation: "Stage all" })` is the + * worked example — its five sibling call sites pass a `t()` result. + * + * Scope is the same contract as the eslint guard: callers pass the files to + * judge, and `check-nonjsx-copy.mjs` passes `i18nGuardFiles`. Judging only + * already-migrated paths is what keeps this from becoming the repo-wide treadmill + * that killed the first migration attempt (docs/i18n.md, "The guard is scoped"). + * + * A finding is silenced with `// i18n-exempt: ` on the enclosing + * statement. The reason is mandatory — the shapes this finds are genuinely + * ambiguous (a persisted default, a prompt sent verbatim to a model, a wire + * value), and the next reader needs to know which one it is without re-deriving + * it. See `exemptionsFor`. + */ +import { parseSync } from "@babel/core"; + +/** Property names whose value is displayed rather than used as data. */ +const COPY_KEYS = new Set([ + "alt", + "ariaLabel", + "aria-label", + "body", + "caption", + "cancelText", + "confirmText", + "description", + "emptyMessage", + "emptyText", + "errorMessage", + "heading", + "helpText", + "hint", + "label", + "message", + "placeholder", + "subtitle", + "successMessage", + "summary", + "text", + "title", + "tooltip", +]); + +const FUNCTION_TYPES = new Set([ + "ArrowFunctionExpression", + "ClassMethod", + "FunctionDeclaration", + "FunctionExpression", + "ObjectMethod", +]); + +/** + * Calls whose string arguments reach the user directly. + * + * The `set*` half is matched by SHAPE rather than by an enumerated list. Every + * screen names its own state setter — `setPreviewError`, `setSaveWarning`, + * `setJoinStatus` — so a fixed list only ever catches the ones someone + * remembered to add, and the first file this check was pointed at held a + * `setPreviewError` two lines above a name that was on the list. + */ +const DISPLAY_CALLS = + /^(alert|confirm|prompt|toast|toast\.\w+|window\.(alert|confirm|prompt)|set[A-Za-z]*(Error|Message|Notice|Warning|Status|Label|Text|Title|Reason|Summary|Hint))$/; + +/** + * Identifiers that hold styling rather than prose. Tailwind class lists are the + * single largest source of false positives here — they are long, spaced, and + * word-like — and they arrive through exactly these names. + */ +const STYLE_NAME = /(class(name)?|style|css|selector|query|variant|token)s?$/i; + +/** + * Strings that are not translatable copy even when they read like prose. + * + * Deliberately narrow. Anything judgement-shaped (a persisted default, an + * agent-facing prompt) is NOT listed here — it takes an `i18n-exempt` comment + * with a reason, so the decision stays visible at the call site instead of being + * silently absorbed by a pattern in this file. + */ +const NOT_COPY = [ + /^\s*$/, + /^[^A-Za-z]*$/, + // CSS: selectors, media queries, declarations, and class lists. + /^[.#[(]/, + /^[a-z-]+\s*:\s*[^;]+$/i, + /(^|\s)(flex|grid|hidden|absolute|relative|sticky|fixed|inline-\w+|block|truncate|overflow-\w+|shrink|grow|group|peer)(\s|$)/, + /(^|\s)[a-z-]+-(\[|\d|auto|full|px|none|foreground|background|muted|primary|secondary|border|card)/, + // Identifiers, keys, paths, and wire values. + /^[a-z][A-Za-z0-9]*$/, + /^[A-Z][A-Z0-9_]*$/, + /^[\w.-]+:[\w.-]+$/, + /^[~./]/, + /^(https?|file|ssh|git|data|mailto):/, + // Log and debug prefixes are developer output, never shown to a user. + /^\[[A-Za-z][\w\s]*\]/, + // Single short word: too little signal to call it copy either way. + /^\S{1,3}$/, +]; + +/** A single hyphenated CSS token: `text-muted-foreground`, `gap-2`. */ +const HYPHENATED_TOKEN = /^[a-z][\w-]*$/; + +/** + * A whitespace-separated list of hyphenated tokens — a class list, not prose. + * + * Checked token by token rather than with one regex. The single-regex form + * (`/^[a-z][\w-]*(-[\w-]+)+(\s+...)*$/`) nests a quantifier inside a + * quantifier over an alphabet that includes `-`, so `"a"` followed by many `-` + * has exponentially many parses and CodeQL flagged it as backtracking-prone. + * Splitting first makes it linear and says what it means. + * + * Rejecting every all-lowercase multi-word string would be the easy version and + * it hides real copy: "every hour", "ended (session restart)" and "watching" are + * all lowercase and all shown to a user. Requiring a hyphen in EVERY token is + * what separates them, since no English phrase has one. + */ +function isClassTokenList(text) { + const tokens = text.split(/\s+/); + return tokens.every((token) => token.includes("-") && HYPHENATED_TOKEN.test(token)); +} + +/** + * True when `value` reads as something a user is meant to understand. + * + * @param {string} value + * @param {string[]} [extraExcludes] + */ +export function looksLikeCopy(value, extraExcludes = []) { + if (typeof value !== "string") return false; + const text = value.trim(); + if (!text) return false; + if (NOT_COPY.some((pattern) => pattern.test(text))) return false; + if (isClassTokenList(text)) return false; + // The eslint guard's own exclusions, so the two agree on what a brand noun, + // acronym, unit or keyboard glyph is. The plugin anchors every entry. + if (extraExcludes.some((pattern) => new RegExp(`^${pattern}$`).test(text))) return false; + const words = text.split(/\s+/).filter((word) => /[A-Za-z]{2,}/.test(word)); + if (words.length >= 2) return true; + // One word only counts when it is capitalized prose ("Staged", "Walkthrough"). + return /^[A-Z][a-z]{3,}[.!?]?$/.test(text); +} + +/** + * The copy a literal carries, or null when it carries none. + * + * A template literal is judged on its LONGEST static chunk, not on the chunks + * joined: `` `Every day at ${at} ${zone}` `` is copy because "Every day at " + * is, while `` `${w}px ${h}em` `` is not — joining would splice "px" and "em" + * into a two-word phrase and report a CSS value as a sentence. + */ +function literalCopy(node, excludes) { + if (node.type === "StringLiteral") { + return looksLikeCopy(node.value, excludes) ? node.value : null; + } + if (node.type !== "TemplateLiteral") return null; + const chunks = node.quasis.map((quasi) => quasi.value.cooked ?? quasi.value.raw); + const longest = chunks.reduce((a, b) => (b.trim().length > a.trim().length ? b : a), ""); + if (!looksLikeCopy(longest, excludes)) return null; + return chunks.join("${…}"); +} + +function walk(node, visit, ancestors = []) { + if (!node || typeof node.type !== "string") return; + visit(node, ancestors); + const next = [...ancestors, node]; + for (const key of Object.keys(node)) { + if (key === "loc" || key.endsWith("Comments")) continue; + const child = node[key]; + if (Array.isArray(child)) { + for (const item of child) if (item && typeof item.type === "string") walk(item, visit, next); + } else if (child && typeof child.type === "string") { + walk(child, visit, next); + } + } +} + +function nameOf(node) { + if (!node) return ""; + if (node.type === "Identifier") return node.name; + if (node.type === "StringLiteral") return node.value; + return ""; +} + +/** + * The names a call could be recognised by: the whole callee, and — for a member + * call — the property on its own. + * + * The bare property matters because setters are routinely passed around in a + * bag: `setters.setError("Failed to resume session")` is the same display call + * as `setError(...)`, and matching only the dotted form misses it. This file's + * first run over `use-session-resumption.ts` caught the free-function call two + * lines from a `setters.` one and reported only the former. + */ +function calleeNames(callee) { + if (!callee) return []; + if (callee.type === "Identifier") return [callee.name]; + if (callee.type === "MemberExpression") { + const property = nameOf(callee.property); + return [`${nameOf(callee.object)}.${property}`, property]; + } + return []; +} + +/** The function a node sits in, and the name it was declared under. */ +function enclosingFunction(ancestors) { + for (let index = ancestors.length - 1; index >= 0; index -= 1) { + const node = ancestors[index]; + if (!FUNCTION_TYPES.has(node.type)) continue; + const parent = ancestors[index - 1]; + const name = + node.id?.name || + (parent?.type === "VariableDeclarator" ? nameOf(parent.id) : "") || + (parent?.type === "ObjectProperty" ? nameOf(parent.key) : ""); + return { node, name }; + } + return null; +} + +/** + * The nodes an exemption comment may attach to, innermost first. + * + * Two granularities, because the legitimate cases come in two sizes. A + * `const TABLE = [...]` is one decision, so a comment above the declaration + * covers every row. A prompt builder is also one decision, but its strings sit + * in `return` statements inside the function — attaching only to the nearest + * statement would need a marker on each, repeating the same sentence four times + * and inviting whoever adds the fifth to skip it. + * + * So the enclosing FUNCTION counts too — but ONLY via a marker that LEADS the + * function declaration itself. An earlier version scanned every line from each + * target's start down to the finding, which meant a marker on one statement + * inside a function silenced every literal after it in that function: exempting + * a persisted assignment on line 10 also passed a `return "Visible label"` on + * line 30. Matching leading comments makes the attachment explicit rather than + * positional. + */ +function exemptionTargets(ancestors) { + // Every ancestor, because a leading comment is attached by Babel to exactly + // the node it precedes and so cannot leak sideways. That makes the natural + // placements all work and all stay precise: above one row of a config table + // it covers that row, above the `const TABLE = [...]` it covers the table, + // above a prompt builder it covers that function. What it will not do is + // reach a sibling — the case that made the previous line-range version wrong. + return [...ancestors].reverse(); +} + +/** + * Lines carrying `// i18n-exempt: `, mapped to the reason. + * + * A marker with no reason after the colon is reported rather than honoured — an + * unexplained exemption is the thing this check exists to prevent. + */ +export function exemptionsFor(comments) { + const withReason = new Set(); + const withoutReason = []; + for (const comment of comments ?? []) { + const match = /i18n-exempt:?(.*)$/.exec(comment.value); + if (!match) continue; + const line = comment.loc.start.line; + if (match[1].trim().length >= 3) withReason.add(line); + else withoutReason.push(line); + } + return { withReason, withoutReason }; +} + +/** Positions this check never judges, whatever the literal says. */ +function isJudgeablePosition(node, parent, ancestors) { + // JSX belongs to the eslint rule; reporting it here would double every + // finding on a guarded path. + if (ancestors.some((ancestor) => ancestor.type.startsWith("JSX"))) return false; + // Types, imports and enum members are never rendered. + if (/^(TS|Import|Export)/.test(parent.type)) return false; + // An object KEY is an identifier even when it is quoted. + return !(parent.type === "ObjectProperty" && parent.key === node); +} + +/** Whether a literal's position is one this check judges, and under what name. */ +function classify(node, ancestors) { + const parent = ancestors[ancestors.length - 1]; + if (!parent || !isJudgeablePosition(node, parent, ancestors)) return null; + + const propertyKey = + parent.type === "ObjectProperty" && parent.value === node ? nameOf(parent.key) : null; + const screaming = ancestors.find( + (ancestor) => + ancestor.type === "VariableDeclarator" && + ancestor.id?.type === "Identifier" && + /^[A-Z][A-Z0-9_]{2,}$/.test(ancestor.id.name), + ); + + // Every prose-shaped value in a config table, not only the slots named `label` + // or `title`. A lookup table keys its copy by an ENUM — `INTERVAL_LABELS` is + // `{ "every-5m": "Every 5 minutes" }` — so a copy-key filter reads the key + // `every-5m`, decides the slot is data, and skips the sentence in it. + // `looksLikeCopy` already rejects ids, so the ids and icon names sitting + // beside the copy never reach here. + if (screaming && !STYLE_NAME.test(screaming.id.name)) { + const suffix = propertyKey ? `.${propertyKey}` : ""; + return { kind: "config-table", context: `${screaming.id.name}${suffix}` }; + } + if (propertyKey && COPY_KEYS.has(propertyKey)) { + return { kind: "copy-property", context: propertyKey }; + } + if (parent.type === "CallExpression") { + const name = calleeNames(parent.callee).find((candidate) => DISPLAY_CALLS.test(candidate)); + if (name) return { kind: "display-call", context: name }; + } + if (parent.type === "AssignmentPattern" && parent.right === node) { + return isParameterDefault(parent, ancestors) + ? { kind: "param-default", context: nameOf(parent.left) } + : null; + } + return classifyReturn(node, parent, ancestors); +} + +/** A default in a function's parameter list, which runs before the body. */ +function isParameterDefault(pattern, ancestors) { + const fn = enclosingFunction(ancestors); + return Boolean(fn?.node.params?.some((param) => ancestors.includes(param) || param === pattern)); +} + +/** + * A string handed back by a plain helper. + * + * Components are excluded by name: they return JSX, which the eslint rule + * already covers, and their `className` ternaries would otherwise all land here. + */ +function classifyReturn(node, parent, ancestors) { + const returnish = + parent.type === "ReturnStatement" || + (parent.type === "ArrowFunctionExpression" && parent.body === node) || + ((parent.type === "ConditionalExpression" || parent.type === "LogicalExpression") && + parent.test !== node); + if (!returnish) return null; + const fn = enclosingFunction(ancestors); + if (!fn || /^[A-Z]/.test(fn.name) || STYLE_NAME.test(fn.name)) return null; + const declarator = ancestors.find( + (ancestor) => ancestor.type === "VariableDeclarator" && ancestor.id?.type === "Identifier", + ); + if (declarator && STYLE_NAME.test(nameOf(declarator.id))) return null; + return { kind: "helper-return", context: fn.name }; +} + +/** + * Copy in a non-JSX position, one entry per string. + * + * `source` is parsed as TS/TSX. Anything that fails to parse yields no findings + * rather than throwing: this runs over whole directories, and a gate that dies on + * one unparseable file reports nothing about the other fourteen hundred. + * + * Returns `{ findings, markersWithoutReason }` on every path, including that one. + * + * @param {string} source + * @param {{ filename?: string, excludes?: string[] }} [options] + */ +export function findNonJsxCopy(source, options = {}) { + const { filename = "input.tsx", excludes = [] } = options; + let ast; + try { + ast = parseSync(source, { + filename, + babelrc: false, + configFile: false, + sourceType: "module", + parserOpts: { plugins: ["typescript", "jsx"] }, + }); + } catch { + // Same shape as a clean parse: callers destructure `{ findings }`, and + // handing them a bare array here would throw on the file after the broken + // one rather than skipping it. + return { findings: [], markersWithoutReason: [] }; + } + + const { withReason, withoutReason } = exemptionsFor(ast.comments); + const findings = []; + const seen = new Set(); + + const report = (node, ancestors, kind, context, value) => { + const line = node.loc.start.line; + // A marker silences a finding when it LEADS one of the finding's enclosing + // statements or functions, or trails the finding's own line. Babel's + // `leadingComments` is what makes "leads" precise; a line-range scan let a + // marker anywhere inside a function cover everything after it. + if (withReason.has(line)) return; + for (const target of exemptionTargets(ancestors)) { + const leading = target.leadingComments ?? []; + if (leading.some((comment) => withReason.has(comment.loc.start.line))) return; + } + const id = `${line}:${node.loc.start.column}`; + if (seen.has(id)) return; + seen.add(id); + findings.push({ line, column: node.loc.start.column, kind, context, value }); + }; + + walk(ast.program, (node, ancestors) => { + const value = literalCopy(node, excludes); + if (value === null) return; + const hit = classify(node, ancestors); + if (hit) report(node, ancestors, hit.kind, hit.context, value); + }); + + return { findings, markersWithoutReason: withoutReason }; +} diff --git a/apps/web/scripts/lib/nonjsx-copy.test.ts b/apps/web/scripts/lib/nonjsx-copy.test.ts new file mode 100644 index 0000000000..15f5047908 --- /dev/null +++ b/apps/web/scripts/lib/nonjsx-copy.test.ts @@ -0,0 +1,251 @@ +/** + * Copy the jsx-only guard cannot see. + * + * Every `it` below that names a shape is a string this check found in already + * migrated code — code that `pnpm lint`, `i18n:check`, the new-code ratchet and + * a pseudo-locale walk all reported clean. The point of the file is not that + * the detector fires, it is that each shape is a DISTINCT hole: a test suite + * asserting only "finds a hardcoded label" would have passed against three + * successive versions of this detector that each missed something real. + * + * Four of these are regressions against the detector's own first drafts, and are + * marked as such. They were not found by reasoning about the code; they were + * found by pointing it at the repository and reading what it did not say. + */ +import { describe, expect, it } from "vitest"; + +import { findNonJsxCopy, looksLikeCopy } from "./nonjsx-copy.mjs"; + +/** Stand-in copy, reused so the fixtures differ only in the shape under test. */ +const COPY = "Disk usage"; + +/** The values reported for `source`, in order. */ +function copyIn(source: string, excludes: string[] = []) { + return findNonJsxCopy(source, { filename: "sample.tsx", excludes }).findings.map((f) => f.value); +} + +function kindsIn(source: string) { + return findNonJsxCopy(source, { filename: "sample.tsx" }).findings.map((f) => f.kind); +} + +describe("looksLikeCopy", () => { + it("accepts a sentence and a capitalized word", () => { + expect(looksLikeCopy("Waiting for input")).toBe(true); + expect(looksLikeCopy("Staged")).toBe(true); + }); + + it("rejects identifiers, enum values, paths and urls", () => { + for (const value of [ + "waitingForInput", + "waiting_for_input", + "WAITING", + "task:panelAgent", + "./relative/path", + "https://example.com/x", + ]) { + expect(looksLikeCopy(value), value).toBe(false); + } + }); + + it("rejects Tailwind class lists, which are long and word-like", () => { + for (const value of [ + "flex items-center gap-2", + "h-6 gap-1 px-2.5 text-xs cursor-pointer", + "text-muted-foreground", + ]) { + expect(looksLikeCopy(value), value).toBe(false); + } + }); + + it("keeps lowercase prose that an all-lowercase filter would have eaten", () => { + // REGRESSION: the first draft rejected every all-lowercase multi-word + // string as a class list. That silently dropped four schedule labels and a + // monitor status, all of them rendered. + expect(looksLikeCopy("every hour")).toBe(true); + expect(looksLikeCopy("ended (session restart)")).toBe(true); + }); + + it("defers to the eslint guard's own exclusions for brand nouns", () => { + expect(looksLikeCopy("Azure DevOps")).toBe(true); + expect(looksLikeCopy("Azure DevOps", ["(Kandev|GitHub|Azure DevOps)"])).toBe(false); + }); + + it("does not backtrack on a long run of hyphens", () => { + // REGRESSION (CodeQL): the class-list test was one regex nesting a + // quantifier inside a quantifier over an alphabet containing `-`, so + // `"a" + "-".repeat(n)` had exponentially many parses — measured at 1ms for + // n=24 and 51ms for n=32, i.e. minutes by n=50. It is now checked token by + // token, which is linear. This gate runs over ~1,400 files and in + // pre-commit, so a hang here stops commits rather than merely being slow. + // The trailing "!" is load-bearing: it is what makes the match FAIL after + // the engine has explored every split of the hyphen run. Without it the old + // regex matched on the first try and returned in microseconds, so a probe + // built from hyphens alone passes against the very pattern it should catch. + for (const input of ["a".concat("-".repeat(40), "!"), "a-- a".concat("-".repeat(40), "!")]) { + const started = performance.now(); + looksLikeCopy(input); + expect(performance.now() - started).toBeLessThan(50); + } + }); +}); + +describe("findNonJsxCopy", () => { + it("finds a label in a SCREAMING_CASE table, which eslint skips entirely", () => { + const source = `const ROWS = [{ id: "disk", label: "Disk usage" }];`; + expect(copyIn(source)).toEqual([COPY]); + expect(kindsIn(source)).toEqual(["config-table"]); + }); + + it("finds copy in a table keyed by an enum, not by a copy-shaped name", () => { + // REGRESSION: an earlier draft only inspected slots called `label`/`title`, + // so a lookup table read its key ("every-5m"), called the slot data, and + // skipped the sentence inside it. + const source = `const INTERVAL_LABELS = { "every-5m": "Every 5 minutes" };`; + expect(copyIn(source)).toEqual(["Every 5 minutes"]); + }); + + it("finds a string returned from a plain helper", () => { + const source = `function badge(x) { if (x) return "Waiting for input"; return null; }`; + expect(copyIn(source)).toEqual(["Waiting for input"]); + expect(kindsIn(source)).toEqual(["helper-return"]); + }); + + it("finds a default in a destructuring parameter", () => { + const source = `function C({ label = "Workspace not available" }) { return label; }`; + expect(copyIn(source)).toEqual(["Workspace not available"]); + expect(kindsIn(source)).toEqual(["param-default"]); + }); + + it("finds a toast title", () => { + const source = `function f() { toast({ title: "Rename failed" }); }`; + expect(copyIn(source)).toEqual(["Rename failed"]); + }); + + it("finds a state setter named for its own screen", () => { + // REGRESSION: the first draft enumerated setter names. `setPreviewError` + // was not on the list, and sat two lines above one that was. + const source = `function f() { setPreviewError("This invite link is missing its token."); }`; + expect(kindsIn(source)).toEqual(["display-call"]); + }); + + it("finds a setter reached through an object", () => { + // REGRESSION: matching only the dotted callee name missed + // `setters.setError(...)`, which is how a setter bag is passed down. + const source = `function f() { setters.setError("Failed to resume session"); }`; + expect(copyIn(source)).toEqual(["Failed to resume session"]); + }); + + it("finds copy inside a template literal", () => { + const source = "function d(at, zone) { return `Every day at ${at} ${zone}`; }"; + expect(copyIn(source)).toEqual(["Every day at ${…} ${…}"]); + }); + + it("does not read a template's chunks as one phrase", () => { + // Joining the static chunks would splice "px" and "em" into a two-word + // phrase and report a CSS value as a sentence. + expect(copyIn("function s(w, h) { return `${w}px ${h}em`; }")).toEqual([]); + }); + + it("leaves JSX to the eslint rule rather than double-reporting it", () => { + expect( + copyIn(`function C() { return Archive board; }`), + ).toEqual([]); + }); + + it("ignores a component's className ternary", () => { + const source = `function rowClassName(active) { return active ? "bg-muted text-foreground" : "text-muted-foreground"; }`; + expect(copyIn(source)).toEqual([]); + }); + + it("ignores a type, an import and an object key", () => { + const source = [ + `import { x } from "./some module";`, + `type Mode = "Read only";`, + `const m = { "Disk usage": 1 };`, + ].join("\n"); + expect(copyIn(source)).toEqual([]); + }); +}); + +describe("i18n-exempt markers", () => { + it("silences the statement they sit above", () => { + const source = [ + `// i18n-exempt: persisted preset seed.`, + `const PRESETS = [{ label: "New action" }];`, + ].join("\n"); + expect(copyIn(source)).toEqual([]); + }); + + it("silences a whole helper when placed above the function", () => { + // A prompt builder is one decision with several return statements; requiring + // a marker on each invites whoever adds the next one to skip it. + const source = [ + `// i18n-exempt: sent verbatim to the agent.`, + `function buildPrompt(a, b) {`, + ` if (a) return "Comment from someone on a file:";`, + ` return "Comment from someone:";`, + `}`, + ].join("\n"); + expect(copyIn(source)).toEqual([]); + }); + + it("does not leak from one statement to a later one in the same function", () => { + // REGRESSION (review of #2711): the marker used to attach by LINE RANGE — + // every line from an enclosing target's start down to the finding — so a + // marker on one statement inside a function silenced every literal after it + // in that function. Attachment is now by Babel's `leadingComments`, which + // binds a comment to exactly the node it precedes. + const source = [ + `function build(flag) {`, + ` // i18n-exempt: persisted preset seed.`, + ` const seeded = "New action";`, + ` if (flag) return "Visible user label";`, + ` return seeded;`, + `}`, + ].join("\n"); + expect(copyIn(source)).toEqual(["Visible user label"]); + }); + + it("silences one row of a config table without covering its siblings", () => { + const source = [ + `const ROWS = [`, + ` // i18n-exempt: directory name on disk.`, + ` { label: "Pods" },`, + ` { label: "Disk usage" },`, + `];`, + ].join("\n"); + expect(copyIn(source)).toEqual([COPY]); + }); + + it("does not silence the next statement as well", () => { + const source = [ + `// i18n-exempt: persisted preset seed.`, + `const PRESETS = [{ label: "New action" }];`, + `const HEADERS = [{ label: "Disk usage" }];`, + ].join("\n"); + expect(copyIn(source)).toEqual([COPY]); + }); + + it("rejects a marker with no reason instead of honouring it", () => { + // An unexplained exemption is the thing this check exists to prevent: the + // shapes it finds all have a legitimate form, and the reason is how the + // next reader tells them apart. + const source = [`// i18n-exempt`, `const ROWS = [{ label: "Disk usage" }];`].join("\n"); + const result = findNonJsxCopy(source, { filename: "sample.tsx" }); + expect(result.markersWithoutReason).toEqual([1]); + expect(result.findings.map((f) => f.value)).toEqual([COPY]); + }); +}); + +describe("robustness", () => { + it("reports nothing rather than throwing on a file it cannot parse", () => { + // This runs over ~1,400 files; a gate that dies on one of them reports + // nothing about the other 1,399 while still exiting 0. + // The shape must match a clean parse too: every caller destructures + // `{ findings }`, so a bare array here throws on the NEXT file instead. + expect(findNonJsxCopy("function ( { unbalanced", { filename: "broken.tsx" })).toEqual({ + findings: [], + markersWithoutReason: [], + }); + }); +}); diff --git a/apps/web/src/locales/en/agents.json b/apps/web/src/locales/en/agents.json index 48366f03cc..35530d1988 100644 --- a/apps/web/src/locales/en/agents.json +++ b/apps/web/src/locales/en/agents.json @@ -234,5 +234,9 @@ "duplicateProfileNamed": "Duplicate {{name}}", "duplicateProfileSuccess": "Profile duplicated", "duplicateSaveBlocked": "Settings are still saving. Try again in a moment.", - "failedToDuplicateProfile": "Failed to duplicate profile" + "failedToDuplicateProfile": "Failed to duplicate profile", + "saveProfile": "Save profile", + "createProfile": "Create profile", + "savingProfile": "Saving...", + "creatingProfile": "Creating..." } diff --git a/apps/web/src/locales/en/auth.json b/apps/web/src/locales/en/auth.json index 45aa1abe2b..3313bc3c1c 100644 --- a/apps/web/src/locales/en/auth.json +++ b/apps/web/src/locales/en/auth.json @@ -4,17 +4,26 @@ "authenticationIsEnabledForThisDeployment": "Authentication is enabled for this deployment. Create the first account below - it becomes the admin and can invite additional users.", "checkingInvite": "Checking invite...", "continueWithProvider": "Continue with {{provider}}", + "couldNotAcceptInvite": "Could not accept this invite.", + "couldNotLoadInvite": "Could not load this invite. Please try again.", "createAdminAccount": "Create admin account", "creatingAdminAccount": "Creating admin account...", "displayName": "Display name", "email": "Email", + "invalidEmailOrPassword": "Invalid email or password.", + "inviteLinkInvalidOrExpired": "This invite link is invalid or has expired. Ask an admin for a new one.", + "inviteLinkMissingToken": "This invite link is missing its token.", "joining": "Joining...", "joinThisKandevDeployment": "Join this Kandev deployment.", "orContinueWith": "or continue with", "password": "Password", + "setupFailed": "Setup failed. Please try again.", "setUpKandev": "Set up Kandev", + "setupNoLongerAvailable": "Setup is no longer available - an account already exists.", "signIn": "Sign in", "signingIn": "Signing in...", "signInToYourKandevAccount": "Sign in to your Kandev account to continue.", + "somethingWentWrong": "Something went wrong. Please try again.", + "tooManyAttempts": "Too many attempts. Please wait a moment and try again.", "youVeBeenInvitedAs": "You've been invited as {{role}}." } diff --git a/apps/web/src/locales/en/automations.json b/apps/web/src/locales/en/automations.json index e6b23086aa..4ee6bfa841 100644 --- a/apps/web/src/locales/en/automations.json +++ b/apps/web/src/locales/en/automations.json @@ -202,5 +202,23 @@ "workflowLabel": "Workflow", "workflowLoadError": "Couldn't load workflows.", "workflowStepHelpNoWorkflow": "Select a workflow before choosing a step.", - "workflowStepLabel": "Workflow Step" + "workflowStepLabel": "Workflow Step", + "scheduleNone": "No schedule", + "scheduleIntervalEvery5Minutes": "Every 5 minutes", + "scheduleIntervalEvery15Minutes": "Every 15 minutes", + "scheduleIntervalEvery30Minutes": "Every 30 minutes", + "scheduleIntervalEvery6Hours": "Every 6 hours", + "scheduleEveryHourAt": "Every hour at :{{minute}}", + "scheduleEveryDayAt": "Every day at {{time}} {{zone}}", + "scheduleEveryWeekdayAt": "Every {{weekday}} at {{time}} {{zone}}", + "frequencyEvery5Minutes": "every 5 minutes", + "frequencyEvery15Minutes": "every 15 minutes", + "frequencyEvery30Minutes": "every 30 minutes", + "frequencyEveryHour": "every hour", + "frequencyEvery6Hours": "every 6 hours", + "frequencyEveryDay": "every day", + "frequencyEveryWeek": "every week", + "frequencyCustom": "a custom schedule", + "scheduleUnparseable": "That's not a schedule we can read. Try a 5-field cron like 0 9 * * 1-5.", + "scheduleAtSeparator": "at" } diff --git a/apps/web/src/locales/en/common.json b/apps/web/src/locales/en/common.json index 39dfcb5800..738cb9562f 100644 --- a/apps/web/src/locales/en/common.json +++ b/apps/web/src/locales/en/common.json @@ -448,7 +448,6 @@ "youCanUseTheNavigationOr": "You can use the navigation or reload to try again.", "yourAgentImplementsItInThe": "Your agent implements it in the kandev repository, with tests", "youVerifyAndTestTheChange": "You verify and test the change in a second kandev instance", - "failedToRenderDiagram": "Failed to render diagram", "demoAgentMessagesDescription": "Visual inspection of agent message normalization. Each card shows the rendered output alongside raw JSON.", "demoAgentMessagesQa": "Agent Messages QA", "demoErrorLoading": "Error loading {{mode}}", @@ -476,5 +475,12 @@ "demoShowingMessages_one": "Showing {{count}} of {{total}} message", "demoShowingMessages_other": "Showing {{count}} of {{total}} messages", "demoTestFixtures": "Test Fixtures", - "demoToolType": "Tool type:" + "demoToolType": "Tool type:", + "weekdaySunday": "Sunday", + "weekdayMonday": "Monday", + "weekdayTuesday": "Tuesday", + "weekdayWednesday": "Wednesday", + "weekdayThursday": "Thursday", + "weekdayFriday": "Friday", + "weekdaySaturday": "Saturday" } diff --git a/apps/web/src/locales/en/executors.json b/apps/web/src/locales/en/executors.json index 467a8aa8f6..e14e67b521 100644 --- a/apps/web/src/locales/en/executors.json +++ b/apps/web/src/locales/en/executors.json @@ -258,5 +258,6 @@ "user": "User", "usesTheLocalDockerDaemonOn": "Uses the local Docker daemon on this machine.", "value": "Value", - "verifyingConnection": "Verifying connection..." + "verifyingConnection": "Verifying connection...", + "profileNameExample": "Production, Development" } diff --git a/apps/web/src/locales/en/github.json b/apps/web/src/locales/en/github.json index cca9655e34..520a795c6c 100644 --- a/apps/web/src/locales/en/github.json +++ b/apps/web/src/locales/en/github.json @@ -568,5 +568,18 @@ "workspaceGithubAccess": "Workspace GitHub access", "workspaceGithubConnectionRemoved": "Workspace GitHub connection removed", "workspaceGithubIdentity": "Workspace GitHub identity", - "yourReviewIsRequested": "Your review is requested" + "yourReviewIsRequested": "Your review is requested", + "timeAgoJustNow": "just now", + "timeAgoMinutes_one": "{{count}}m ago", + "timeAgoMinutes_other": "{{count}}m ago", + "timeAgoHours_one": "{{count}}h ago", + "timeAgoHours_other": "{{count}}h ago", + "timeAgoDays_one": "{{count}}d ago", + "timeAgoDays_other": "{{count}}d ago", + "timeAgoWeeks_one": "{{count}}w ago", + "timeAgoWeeks_other": "{{count}}w ago", + "timeAgoMonths_one": "{{count}}mo ago", + "timeAgoMonths_other": "{{count}}mo ago", + "timeAgoYears_one": "{{count}}y ago", + "timeAgoYears_other": "{{count}}y ago" } diff --git a/apps/web/src/locales/en/gitlab.json b/apps/web/src/locales/en/gitlab.json index 1363f3fd88..b9a06ea11a 100644 --- a/apps/web/src/locales/en/gitlab.json +++ b/apps/web/src/locales/en/gitlab.json @@ -350,5 +350,6 @@ "mrChipDrawerClose": "Close merge request status", "gitlabRejectedAssociation": "GitLab rejected the association.", "taskCreatedMrNotLinked": "Task created, but merge request was not linked", - "taskCreatedMrNotLinkedDescription": "Open the task and use Link GitLab merge request to retry. {{error}}" + "taskCreatedMrNotLinkedDescription": "Open the task and use Link GitLab merge request to retry. {{error}}", + "memberSearchFailed": "Member search failed" } diff --git a/apps/web/src/locales/en/integrations.json b/apps/web/src/locales/en/integrations.json index c40c36c155..68d5c60ff3 100644 --- a/apps/web/src/locales/en/integrations.json +++ b/apps/web/src/locales/en/integrations.json @@ -188,5 +188,6 @@ "saveQuery": "Save query", "waitingForTheNextBackendHealth": "Waiting for the next backend health check…", "cursorPaginationSummary_one": "Page {{page}} · {{count}} result", - "cursorPaginationSummary_other": "Page {{page}} · {{count}} results" + "cursorPaginationSummary_other": "Page {{page}} · {{count}} results", + "allRepositories": "All repositories" } diff --git a/apps/web/src/locales/en/review.json b/apps/web/src/locales/en/review.json index 4974ff48a8..ca5c97126f 100644 --- a/apps/web/src/locales/en/review.json +++ b/apps/web/src/locales/en/review.json @@ -88,5 +88,6 @@ "findingQueuedForAgent": "Finding queued for the agent", "findingSentToAgent": "Finding sent to the agent", "walkthroughRequestQueued": "Walkthrough request queued", - "walkthroughRequestSent": "Walkthrough request sent" + "walkthroughRequestSent": "Walkthrough request sent", + "couldNotStartReview": "Could not start the review" } diff --git a/apps/web/src/locales/en/task.json b/apps/web/src/locales/en/task.json index 87623eddd0..4f1f16ff87 100644 --- a/apps/web/src/locales/en/task.json +++ b/apps/web/src/locales/en/task.json @@ -1554,7 +1554,6 @@ "failedToDownloadFile": "Failed to download file", "failedToFetchPlan": "Failed to fetch plan", "failedToLoadPlanRevisions": "Failed to load revisions", - "failedToRenameItem": "Failed to rename item", "failedToRevertPlan": "Failed to revert plan", "failedToSavePlan": "Failed to save plan", "failedToStartTaskPlanMode": "Failed to start task in plan mode", @@ -1586,5 +1585,44 @@ "sessionActionResume": "Resuming session", "sessionActionSetPrimary": "Set primary", "sessionActionStop": "Stopping session", - "sessionActionSuccessful": "{{action}} successful" + "sessionActionSuccessful": "{{action}} successful", + "composerPlaceholderRecovery": "Choose a recovery option above to continue...", + "composerPlaceholderClarification": "Queue instructions while the question is pending...", + "composerPlaceholderBusy": "Queue instructions to the agent...", + "composerPlaceholderFile": "Continue working on the file...", + "composerPlaceholderPlan": "Continue working on the plan...", + "composerPlaceholderTask": "Continue working on the task...", + "composerPlaceholderSwitchingAgent": "Switching agent...", + "agentHasStopped": "This agent has stopped.", + "resuming": "Resuming...", + "submitTooltipQueueMessage": "Queue message", + "submitTooltipRequestPlanChanges": "Request plan changes", + "clarificationNoSelection": "No selection", + "monitorStatusWatching": "watching", + "monitorStatusEnded": "ended", + "monitorStatusEndedSessionRestart": "ended (session restart)", + "agentStillBeingSetUp": "The agent is still being set up.", + "attachmentUploadFailed": "Upload failed", + "renameFailed": "Rename failed", + "workspaceUnavailableArchived": "Workspace not available - this task is archived", + "credentialSourceWorkspace": "Managed workspace identity", + "credentialSourceExecutorProfile": "Executor-profile token", + "credentialSourceExecutor": "Executor credentials", + "credentialWorkspacePat": "workspace personal access token", + "credentialWorkspaceGhCli": "workspace GitHub CLI account", + "credentialWorkspaceGithubApp": "workspace GitHub App", + "credentialWorkspaceFallback": "workspace GitHub credentials", + "credentialTransportManagedHttps": "Kandev-managed GitHub HTTPS and gh", + "credentialTransportProfileToken": "executor-profile token", + "credentialTransportExecutorSelected": "executor Git and gh credentials", + "credentialTransportManagedFallback": "Kandev-managed task credentials", + "credentialExplicitToken": "An explicit GH_TOKEN or GITHUB_TOKEN", + "agentResponseChangedSelectAgain": "The agent response changed. Select the text again.", + "invalidGitHubUrl": "Invalid GitHub URL - expected github.com/owner/repo or .../pull/123", + "couldNotGenerateSummary": "Could not generate a summary", + "updateAvailableToast": "Kandev {{version}} is available. Open Settings > System > Updates to review it.", + "failedToLoadFileContent": "Failed to load file content", + "openFileInProvider": "Open file in {{provider}}", + "failedToResumeAndRestore": "Failed to resume session - workspace restore also unavailable", + "sessionStatusGenerating": "Generating" } diff --git a/apps/web/src/locales/en/workspaces.json b/apps/web/src/locales/en/workspaces.json index 4c6be9c746..da0a7f5749 100644 --- a/apps/web/src/locales/en/workspaces.json +++ b/apps/web/src/locales/en/workspaces.json @@ -118,5 +118,8 @@ "workspaceNotFound": "Workspace not found", "workspaceSettings": "Workspace Settings", "worktreeBranchTemplate": "Worktree Branch Template", - "worktreeSync": "Worktree Sync" + "worktreeSync": "Worktree Sync", + "enterRepositoryName": "Enter a repository name.", + "chooseDifferentRepositoryName": "Choose a different repository name.", + "repositoryNameMustBeOneFolder": "The repository name must be one folder name." } diff --git a/apps/web/src/locales/pseudo/agents.json b/apps/web/src/locales/pseudo/agents.json index e9b6c13f8c..a33580d6cc 100644 --- a/apps/web/src/locales/pseudo/agents.json +++ b/apps/web/src/locales/pseudo/agents.json @@ -234,5 +234,9 @@ "duplicateProfileNamed": "Ďũƥĺĩćàţē {{name}}", "duplicateProfileSuccess": "Ƥŕōƒĩĺē ďũƥĺĩćàţēď", "duplicateSaveBlocked": "Śēţţĩńĝś àŕē śţĩĺĺ śàvĩńĝ. Ţŕŷ àĝàĩń ĩń à ḿōḿēńţ.", - "failedToDuplicateProfile": "Ƒàĩĺēď ţō ďũƥĺĩćàţē ƥŕōƒĩĺē" + "failedToDuplicateProfile": "Ƒàĩĺēď ţō ďũƥĺĩćàţē ƥŕōƒĩĺē", + "saveProfile": "Śàvē ƥŕōƒĩĺē", + "createProfile": "Ćŕēàţē ƥŕōƒĩĺē", + "savingProfile": "Śàvĩńĝ...", + "creatingProfile": "Ćŕēàţĩńĝ..." } diff --git a/apps/web/src/locales/pseudo/auth.json b/apps/web/src/locales/pseudo/auth.json index 31dbfabeea..4ba48ca020 100644 --- a/apps/web/src/locales/pseudo/auth.json +++ b/apps/web/src/locales/pseudo/auth.json @@ -4,17 +4,26 @@ "authenticationIsEnabledForThisDeployment": "Àũţĥēńţĩćàţĩōń ĩś ēńàƀĺēď ƒōŕ ţĥĩś ďēƥĺōŷḿēńţ. Ćŕēàţē ţĥē ƒĩŕśţ àććōũńţ ƀēĺōŵ - ĩţ ƀēćōḿēś ţĥē àďḿĩń àńď ćàń ĩńvĩţē àďďĩţĩōńàĺ ũśēŕś.", "checkingInvite": "Ćĥēćķĩńĝ ĩńvĩţē...", "continueWithProvider": "Ćōńţĩńũē ŵĩţĥ {{provider}}", + "couldNotAcceptInvite": "Ćōũĺď ńōţ àććēƥţ ţĥĩś ĩńvĩţē.", + "couldNotLoadInvite": "Ćōũĺď ńōţ ĺōàď ţĥĩś ĩńvĩţē. Ƥĺēàśē ţŕŷ àĝàĩń.", "createAdminAccount": "Ćŕēàţē àďḿĩń àććōũńţ", "creatingAdminAccount": "Ćŕēàţĩńĝ àďḿĩń àććōũńţ...", "displayName": "Ďĩśƥĺàŷ ńàḿē", "email": "Ēḿàĩĺ", + "invalidEmailOrPassword": "Ĩńvàĺĩď ēḿàĩĺ ōŕ ƥàśśŵōŕď.", + "inviteLinkInvalidOrExpired": "Ţĥĩś ĩńvĩţē ĺĩńķ ĩś ĩńvàĺĩď ōŕ ĥàś ēxƥĩŕēď. Àśķ àń àďḿĩń ƒōŕ à ńēŵ ōńē.", + "inviteLinkMissingToken": "Ţĥĩś ĩńvĩţē ĺĩńķ ĩś ḿĩśśĩńĝ ĩţś ţōķēń.", "joining": "Ĵōĩńĩńĝ...", "joinThisKandevDeployment": "Ĵōĩń ţĥĩś Ķàńďēv ďēƥĺōŷḿēńţ.", "orContinueWith": "ōŕ ćōńţĩńũē ŵĩţĥ", "password": "Ƥàśśŵōŕď", + "setupFailed": "Śēţũƥ ƒàĩĺēď. Ƥĺēàśē ţŕŷ àĝàĩń.", "setUpKandev": "Śēţ ũƥ Ķàńďēv", + "setupNoLongerAvailable": "Śēţũƥ ĩś ńō ĺōńĝēŕ àvàĩĺàƀĺē - àń àććōũńţ àĺŕēàďŷ ēxĩśţś.", "signIn": "Śĩĝń ĩń", "signingIn": "Śĩĝńĩńĝ ĩń...", "signInToYourKandevAccount": "Śĩĝń ĩń ţō ŷōũŕ Ķàńďēv àććōũńţ ţō ćōńţĩńũē.", + "somethingWentWrong": "Śōḿēţĥĩńĝ ŵēńţ ŵŕōńĝ. Ƥĺēàśē ţŕŷ àĝàĩń.", + "tooManyAttempts": "Ţōō ḿàńŷ àţţēḿƥţś. Ƥĺēàśē ŵàĩţ à ḿōḿēńţ àńď ţŕŷ àĝàĩń.", "youVeBeenInvitedAs": "Ŷōũ'vē ƀēēń ĩńvĩţēď àś {{role}}." } diff --git a/apps/web/src/locales/pseudo/automations.json b/apps/web/src/locales/pseudo/automations.json index 3964e9118b..42c15cc211 100644 --- a/apps/web/src/locales/pseudo/automations.json +++ b/apps/web/src/locales/pseudo/automations.json @@ -202,5 +202,23 @@ "workflowLabel": "Ŵōŕķƒĺōŵ", "workflowLoadError": "Ćōũĺďń'ţ ĺōàď ŵōŕķƒĺōŵś.", "workflowStepHelpNoWorkflow": "Śēĺēćţ à ŵōŕķƒĺōŵ ƀēƒōŕē ćĥōōśĩńĝ à śţēƥ.", - "workflowStepLabel": "Ŵōŕķƒĺōŵ Śţēƥ" + "workflowStepLabel": "Ŵōŕķƒĺōŵ Śţēƥ", + "scheduleNone": "Ńō śćĥēďũĺē", + "scheduleIntervalEvery5Minutes": "Ēvēŕŷ 5 ḿĩńũţēś", + "scheduleIntervalEvery15Minutes": "Ēvēŕŷ 15 ḿĩńũţēś", + "scheduleIntervalEvery30Minutes": "Ēvēŕŷ 30 ḿĩńũţēś", + "scheduleIntervalEvery6Hours": "Ēvēŕŷ 6 ĥōũŕś", + "scheduleEveryHourAt": "Ēvēŕŷ ĥōũŕ àţ :{{minute}}", + "scheduleEveryDayAt": "Ēvēŕŷ ďàŷ àţ {{time}} {{zone}}", + "scheduleEveryWeekdayAt": "Ēvēŕŷ {{weekday}} àţ {{time}} {{zone}}", + "frequencyEvery5Minutes": "ēvēŕŷ 5 ḿĩńũţēś", + "frequencyEvery15Minutes": "ēvēŕŷ 15 ḿĩńũţēś", + "frequencyEvery30Minutes": "ēvēŕŷ 30 ḿĩńũţēś", + "frequencyEveryHour": "ēvēŕŷ ĥōũŕ", + "frequencyEvery6Hours": "ēvēŕŷ 6 ĥōũŕś", + "frequencyEveryDay": "ēvēŕŷ ďàŷ", + "frequencyEveryWeek": "ēvēŕŷ ŵēēķ", + "frequencyCustom": "à ćũśţōḿ śćĥēďũĺē", + "scheduleUnparseable": "Ţĥàţ'ś ńōţ à śćĥēďũĺē ŵē ćàń ŕēàď. Ţŕŷ à 5-ƒĩēĺď ćŕōń ĺĩķē 0 9 * * 1-5.", + "scheduleAtSeparator": "àţ" } diff --git a/apps/web/src/locales/pseudo/common.json b/apps/web/src/locales/pseudo/common.json index beb2de7344..5862790b6e 100644 --- a/apps/web/src/locales/pseudo/common.json +++ b/apps/web/src/locales/pseudo/common.json @@ -475,5 +475,12 @@ "demoShowingMessages_one": "Śĥōŵĩńĝ {{count}} ōƒ {{total}} ḿēśśàĝē", "demoShowingMessages_other": "Śĥōŵĩńĝ {{count}} ōƒ {{total}} ḿēśśàĝēś", "demoTestFixtures": "Ţēśţ Ƒĩxţũŕēś", - "demoToolType": "Ţōōĺ ţŷƥē:" + "demoToolType": "Ţōōĺ ţŷƥē:", + "weekdaySunday": "Śũńďàŷ", + "weekdayMonday": "Ḿōńďàŷ", + "weekdayTuesday": "Ţũēśďàŷ", + "weekdayWednesday": "Ŵēďńēśďàŷ", + "weekdayThursday": "Ţĥũŕśďàŷ", + "weekdayFriday": "Ƒŕĩďàŷ", + "weekdaySaturday": "Śàţũŕďàŷ" } diff --git a/apps/web/src/locales/pseudo/executors.json b/apps/web/src/locales/pseudo/executors.json index 1da981383b..28fed06ac9 100644 --- a/apps/web/src/locales/pseudo/executors.json +++ b/apps/web/src/locales/pseudo/executors.json @@ -258,5 +258,6 @@ "user": "Ũśēŕ", "usesTheLocalDockerDaemonOn": "Ũśēś ţĥē ĺōćàĺ Ďōćķēŕ ďàēḿōń ōń ţĥĩś ḿàćĥĩńē.", "value": "Vàĺũē", - "verifyingConnection": "Vēŕĩƒŷĩńĝ ćōńńēćţĩōń..." + "verifyingConnection": "Vēŕĩƒŷĩńĝ ćōńńēćţĩōń...", + "profileNameExample": "Ƥŕōďũćţĩōń, Ďēvēĺōƥḿēńţ" } diff --git a/apps/web/src/locales/pseudo/github.json b/apps/web/src/locales/pseudo/github.json index bee5f3f6cf..85f0135d41 100644 --- a/apps/web/src/locales/pseudo/github.json +++ b/apps/web/src/locales/pseudo/github.json @@ -568,5 +568,18 @@ "workspaceGithubAccess": "Ŵōŕķśƥàćē ĜĩţĤũƀ àććēśś", "workspaceGithubConnectionRemoved": "Ŵōŕķśƥàćē ĜĩţĤũƀ ćōńńēćţĩōń ŕēḿōvēď", "workspaceGithubIdentity": "Ŵōŕķśƥàćē ĜĩţĤũƀ ĩďēńţĩţŷ", - "yourReviewIsRequested": "Ŷōũŕ ŕēvĩēŵ ĩś ŕēqũēśţēď" + "yourReviewIsRequested": "Ŷōũŕ ŕēvĩēŵ ĩś ŕēqũēśţēď", + "timeAgoJustNow": "ĵũśţ ńōŵ", + "timeAgoMinutes_one": "{{count}}ḿ àĝō", + "timeAgoMinutes_other": "{{count}}ḿ àĝō", + "timeAgoHours_one": "{{count}}ĥ àĝō", + "timeAgoHours_other": "{{count}}ĥ àĝō", + "timeAgoDays_one": "{{count}}ď àĝō", + "timeAgoDays_other": "{{count}}ď àĝō", + "timeAgoWeeks_one": "{{count}}ŵ àĝō", + "timeAgoWeeks_other": "{{count}}ŵ àĝō", + "timeAgoMonths_one": "{{count}}ḿō àĝō", + "timeAgoMonths_other": "{{count}}ḿō àĝō", + "timeAgoYears_one": "{{count}}ŷ àĝō", + "timeAgoYears_other": "{{count}}ŷ àĝō" } diff --git a/apps/web/src/locales/pseudo/gitlab.json b/apps/web/src/locales/pseudo/gitlab.json index 4e8dc78384..49d1c0d6d0 100644 --- a/apps/web/src/locales/pseudo/gitlab.json +++ b/apps/web/src/locales/pseudo/gitlab.json @@ -350,5 +350,6 @@ "mrChipDrawerClose": "Ćĺōśē ḿēŕĝē ŕēqũēśţ śţàţũś", "gitlabRejectedAssociation": "ĜĩţĹàƀ ŕēĵēćţēď ţĥē àśśōćĩàţĩōń.", "taskCreatedMrNotLinked": "Ţàśķ ćŕēàţēď, ƀũţ ḿēŕĝē ŕēqũēśţ ŵàś ńōţ ĺĩńķēď", - "taskCreatedMrNotLinkedDescription": "Ōƥēń ţĥē ţàśķ àńď ũśē Ĺĩńķ ĜĩţĹàƀ ḿēŕĝē ŕēqũēśţ ţō ŕēţŕŷ. {{error}}" + "taskCreatedMrNotLinkedDescription": "Ōƥēń ţĥē ţàśķ àńď ũśē Ĺĩńķ ĜĩţĹàƀ ḿēŕĝē ŕēqũēśţ ţō ŕēţŕŷ. {{error}}", + "memberSearchFailed": "Ḿēḿƀēŕ śēàŕćĥ ƒàĩĺēď" } diff --git a/apps/web/src/locales/pseudo/integrations.json b/apps/web/src/locales/pseudo/integrations.json index cfb1c24274..9f32d1c5e3 100644 --- a/apps/web/src/locales/pseudo/integrations.json +++ b/apps/web/src/locales/pseudo/integrations.json @@ -188,5 +188,6 @@ "saveQuery": "Śàvē qũēŕŷ", "waitingForTheNextBackendHealth": "Ŵàĩţĩńĝ ƒōŕ ţĥē ńēxţ ƀàćķēńď ĥēàĺţĥ ćĥēćķ…", "cursorPaginationSummary_one": "Ƥàĝē {{page}} · {{count}} ŕēśũĺţ", - "cursorPaginationSummary_other": "Ƥàĝē {{page}} · {{count}} ŕēśũĺţś" + "cursorPaginationSummary_other": "Ƥàĝē {{page}} · {{count}} ŕēśũĺţś", + "allRepositories": "Àĺĺ ŕēƥōśĩţōŕĩēś" } diff --git a/apps/web/src/locales/pseudo/review.json b/apps/web/src/locales/pseudo/review.json index 0b894ed57a..aa8ae02f11 100644 --- a/apps/web/src/locales/pseudo/review.json +++ b/apps/web/src/locales/pseudo/review.json @@ -88,5 +88,6 @@ "findingQueuedForAgent": "Ƒĩńďĩńĝ qũēũēď ƒōŕ ţĥē àĝēńţ", "findingSentToAgent": "Ƒĩńďĩńĝ śēńţ ţō ţĥē àĝēńţ", "walkthroughRequestQueued": "Ŵàĺķţĥŕōũĝĥ ŕēqũēśţ qũēũēď", - "walkthroughRequestSent": "Ŵàĺķţĥŕōũĝĥ ŕēqũēśţ śēńţ" + "walkthroughRequestSent": "Ŵàĺķţĥŕōũĝĥ ŕēqũēśţ śēńţ", + "couldNotStartReview": "Ćōũĺď ńōţ śţàŕţ ţĥē ŕēvĩēŵ" } diff --git a/apps/web/src/locales/pseudo/task.json b/apps/web/src/locales/pseudo/task.json index a206d2451c..76d55dd433 100644 --- a/apps/web/src/locales/pseudo/task.json +++ b/apps/web/src/locales/pseudo/task.json @@ -1270,7 +1270,7 @@ "start2": "Śţàŕţ", "startAgent": "Śţàŕţ àĝēńţ", "startAgent2": "Śţàŕţ Àĝēńţ", - "composerStartAgentHint": "Śēńďĩńĝ à ḿēśśàĝē ŵĩļļ àũţō-śţàŕţ ţħē àĝēńţ.", + "composerStartAgentHint": "Śēńďĩńĝ à ḿēśśàĝē ŵĩĺĺ àũţō-śţàŕţ ţĥē àĝēńţ.", "startDevServerPreview": "Śţàŕţ ďēv śēŕvēŕ ƥŕēvĩēŵ", "startFreshSession": "Śţàŕţ ƒŕēśĥ śēśśĩōń", "startPlanMode": "Śţàŕţ Ƥĺàń Ḿōďē", @@ -1585,5 +1585,44 @@ "sessionActionResume": "Ŕēśũḿĩńĝ śēśśĩōń", "sessionActionSetPrimary": "Śēţ ƥŕĩḿàŕŷ", "sessionActionStop": "Śţōƥƥĩńĝ śēśśĩōń", - "sessionActionSuccessful": "{{action}} śũććēśśƒũĺ" + "sessionActionSuccessful": "{{action}} śũććēśśƒũĺ", + "composerPlaceholderRecovery": "Ćĥōōśē à ŕēćōvēŕŷ ōƥţĩōń àƀōvē ţō ćōńţĩńũē...", + "composerPlaceholderClarification": "Qũēũē ĩńśţŕũćţĩōńś ŵĥĩĺē ţĥē qũēśţĩōń ĩś ƥēńďĩńĝ...", + "composerPlaceholderBusy": "Qũēũē ĩńśţŕũćţĩōńś ţō ţĥē àĝēńţ...", + "composerPlaceholderFile": "Ćōńţĩńũē ŵōŕķĩńĝ ōń ţĥē ƒĩĺē...", + "composerPlaceholderPlan": "Ćōńţĩńũē ŵōŕķĩńĝ ōń ţĥē ƥĺàń...", + "composerPlaceholderTask": "Ćōńţĩńũē ŵōŕķĩńĝ ōń ţĥē ţàśķ...", + "composerPlaceholderSwitchingAgent": "Śŵĩţćĥĩńĝ àĝēńţ...", + "agentHasStopped": "Ţĥĩś àĝēńţ ĥàś śţōƥƥēď.", + "resuming": "Ŕēśũḿĩńĝ...", + "submitTooltipQueueMessage": "Qũēũē ḿēśśàĝē", + "submitTooltipRequestPlanChanges": "Ŕēqũēśţ ƥĺàń ćĥàńĝēś", + "clarificationNoSelection": "Ńō śēĺēćţĩōń", + "monitorStatusWatching": "ŵàţćĥĩńĝ", + "monitorStatusEnded": "ēńďēď", + "monitorStatusEndedSessionRestart": "ēńďēď (śēśśĩōń ŕēśţàŕţ)", + "agentStillBeingSetUp": "Ţĥē àĝēńţ ĩś śţĩĺĺ ƀēĩńĝ śēţ ũƥ.", + "attachmentUploadFailed": "Ũƥĺōàď ƒàĩĺēď", + "renameFailed": "Ŕēńàḿē ƒàĩĺēď", + "workspaceUnavailableArchived": "Ŵōŕķśƥàćē ńōţ àvàĩĺàƀĺē - ţĥĩś ţàśķ ĩś àŕćĥĩvēď", + "credentialSourceWorkspace": "Ḿàńàĝēď ŵōŕķśƥàćē ĩďēńţĩţŷ", + "credentialSourceExecutorProfile": "Ēxēćũţōŕ-ƥŕōƒĩĺē ţōķēń", + "credentialSourceExecutor": "Ēxēćũţōŕ ćŕēďēńţĩàĺś", + "credentialWorkspacePat": "ŵōŕķśƥàćē ƥēŕśōńàĺ àććēśś ţōķēń", + "credentialWorkspaceGhCli": "ŵōŕķśƥàćē ĜĩţĤũƀ ĆĹĨ àććōũńţ", + "credentialWorkspaceGithubApp": "ŵōŕķśƥàćē ĜĩţĤũƀ Àƥƥ", + "credentialWorkspaceFallback": "ŵōŕķśƥàćē ĜĩţĤũƀ ćŕēďēńţĩàĺś", + "credentialTransportManagedHttps": "Ķàńďēv-ḿàńàĝēď ĜĩţĤũƀ ĤŢŢƤŚ àńď ĝĥ", + "credentialTransportProfileToken": "ēxēćũţōŕ-ƥŕōƒĩĺē ţōķēń", + "credentialTransportExecutorSelected": "ēxēćũţōŕ Ĝĩţ àńď ĝĥ ćŕēďēńţĩàĺś", + "credentialTransportManagedFallback": "Ķàńďēv-ḿàńàĝēď ţàśķ ćŕēďēńţĩàĺś", + "credentialExplicitToken": "Àń ēxƥĺĩćĩţ ĜĤ_ŢŌĶĒŃ ōŕ ĜĨŢĤŨƁ_ŢŌĶĒŃ", + "agentResponseChangedSelectAgain": "Ţĥē àĝēńţ ŕēśƥōńśē ćĥàńĝēď. Śēĺēćţ ţĥē ţēxţ àĝàĩń.", + "invalidGitHubUrl": "Ĩńvàĺĩď ĜĩţĤũƀ ŨŔĹ - ēxƥēćţēď ĝĩţĥũƀ.ćōḿ/ōŵńēŕ/ŕēƥō ōŕ .../ƥũĺĺ/123", + "couldNotGenerateSummary": "Ćōũĺď ńōţ ĝēńēŕàţē à śũḿḿàŕŷ", + "updateAvailableToast": "Ķàńďēv {{version}} ĩś àvàĩĺàƀĺē. Ōƥēń Śēţţĩńĝś > Śŷśţēḿ > Ũƥďàţēś ţō ŕēvĩēŵ ĩţ.", + "failedToLoadFileContent": "Ƒàĩĺēď ţō ĺōàď ƒĩĺē ćōńţēńţ", + "openFileInProvider": "Ōƥēń ƒĩĺē ĩń {{provider}}", + "failedToResumeAndRestore": "Ƒàĩĺēď ţō ŕēśũḿē śēśśĩōń - ŵōŕķśƥàćē ŕēśţōŕē àĺśō ũńàvàĩĺàƀĺē", + "sessionStatusGenerating": "Ĝēńēŕàţĩńĝ" } diff --git a/apps/web/src/locales/pseudo/workspaces.json b/apps/web/src/locales/pseudo/workspaces.json index fdf97a1047..423cf25808 100644 --- a/apps/web/src/locales/pseudo/workspaces.json +++ b/apps/web/src/locales/pseudo/workspaces.json @@ -118,5 +118,8 @@ "workspaceNotFound": "Ŵōŕķśƥàćē ńōţ ƒōũńď", "workspaceSettings": "Ŵōŕķśƥàćē Śēţţĩńĝś", "worktreeBranchTemplate": "Ŵōŕķţŕēē Ɓŕàńćĥ Ţēḿƥĺàţē", - "worktreeSync": "Ŵōŕķţŕēē Śŷńć" + "worktreeSync": "Ŵōŕķţŕēē Śŷńć", + "enterRepositoryName": "Ēńţēŕ à ŕēƥōśĩţōŕŷ ńàḿē.", + "chooseDifferentRepositoryName": "Ćĥōōśē à ďĩƒƒēŕēńţ ŕēƥōśĩţōŕŷ ńàḿē.", + "repositoryNameMustBeOneFolder": "Ţĥē ŕēƥōśĩţōŕŷ ńàḿē ḿũśţ ƀē ōńē ƒōĺďēŕ ńàḿē." } diff --git a/docs/i18n.md b/docs/i18n.md index 02b117d0e0..e17b256361 100644 --- a/docs/i18n.md +++ b/docs/i18n.md @@ -211,6 +211,7 @@ pnpm run i18n:check # the five repo-wide gates (runs in CI: Frontend Tests) pnpm run i18n:pseudo # regenerate the pseudo catalog from en pnpm run lint:i18n # preview the guard on a path that is NOT yet migrated pnpm run i18n:ratchet # new-code guard + allowlist-only-grows (pre-commit + CI) +node scripts/check-nonjsx-copy.mjs # copy lint cannot see (part of i18n:check) pnpm run i18n:removed # did this change rewrite any English? (diff-based, advisory) pnpm run i18n:parity # per-locale missing/extra keys (report, never a gate) pnpm run i18n:sweep # copy the jsx-only rule cannot see (report, never a gate) @@ -227,9 +228,11 @@ directory you are about to migrate; it never gates a merge. The real gate is `pnpm lint`, where the rule is an **error** — but only on the allowlist. Unlike the eslint guard, `i18n:check` is **not** scoped to the allowlist — its -five checks scan `components`, `app`, `hooks`, `lib`, and published Markdown in +checks scan `components`, `app`, `hooks`, `lib`, and published Markdown in `docs/public` repo-wide, and it runs in -CI (Frontend Tests → _Run i18n checks_). That is deliberate: each one pins a bug +CI (Frontend Tests → _Run i18n checks_). The one exception is +`check-nonjsx-copy.mjs`, which is scoped to `i18nGuardFiles` for the same reason +the eslint rule is (see [Non-JSX copy](#and-the-blind-spots-are-now-a-gate)). That is deliberate: each one pins a bug that fails **silently at runtime** rather than merely leaving a string in English, so there is no reason to let it into unmigrated code either. Unmigrated source files pass trivially, because a file that never calls `t()` cannot break the @@ -247,8 +250,9 @@ the English copy exists only in the catalog and cannot be recovered from source, so regenerating would destroy it. It fails on keys missing from the catalog and on `en`/`pseudo` divergence, warns about orphaned entries, validates every `` element's tag indices against its JSX children (see below), rejects -English plural endings passed as interpolation values, and rejects `t()` at module -scope. +English plural endings passed as interpolation values, rejects `t()` at module +scope, and rejects hardcoded copy in the non-JSX positions the eslint rule cannot +inspect. Module scope matters because the module-level `t` resolves when the file is imported: `const ROWS = [{ label: t("k") }]` pins that copy to the boot locale, so @@ -484,6 +488,59 @@ you cannot fix this in place. Resolve the fallback inside the body instead (`const pending = pendingLabel ?? t("system:jobRunning")`). Prop defaults on a shared presentational component are a common home for this. +### …and the blind spots are now a gate + +Everything above is still true about `i18next/no-literal-string`. What changed is +that a second detector runs over the same files: +`scripts/check-nonjsx-copy.mjs` (logic in `scripts/lib/nonjsx-copy.mjs`), part of +`pnpm run i18n:check`. It inspects exactly the positions the rule cannot: + +| shape | example | +| --- | --- | +| `config-table` | `const ROWS = [{ label: "Disk usage" }]` | +| `helper-return` | `function badge() { return "Waiting for input" }` | +| `param-default` | `function C({ label = "Running..." })` | +| `display-call` | `toast({ title: "Rename failed" })`, `setPreviewError("…")` | +| `copy-property` | `{ description: "…" }` outside a table | + +It is **scoped to `i18nGuardFiles`**, the same allowlist the eslint rule uses and +for the same reason: judging an un-migrated file's existing config tables would +recreate the treadmill described above. The repo-wide half is +`check-new-code-i18n.mjs`, which runs this same detector over added files and +changed lines everywhere — so the new-code ratchet no longer inherits the hole. + +Template literals are read too, judged on their **longest static chunk** rather +than on the chunks joined: `` `Every day at ${at} ${zone}` `` is copy, while +`` `${w}px ${h}em` `` is not. + +#### Silencing one, and why the reason is mandatory + +```ts +// i18n-exempt: persisted preset seed; localizing needs a key/value split. +const DEFAULT_PR_PRESETS = [{ label: "Review", hint: "Read the diff, flag issues" }]; +``` + +The marker attaches to the enclosing **statement** or **function** — one comment +covers a whole config table, or a whole prompt builder with four `return`s. A +marker with no reason after the colon is *reported*, not honoured. + +That is the load-bearing part. Every shape this check finds has a legitimate +form, and they are not distinguishable from the string: a value persisted in +whichever locale created it, a prompt sent verbatim to a model, a token compared +with `===`, a brand noun. The ~60 exemptions in the tree today are each one of +those, and every one says which. + +#### What it still cannot see + +It reads literals, so copy assembled at runtime from fragments is invisible, and +it takes the author's word for an exemption. The pseudo-locale remains the +completeness check. Two limits are worth knowing concretely: + +- A **single lowercase word** (`"ended"`, `"watching"`) is below the copy + threshold — too little signal to separate from an enum value. Its neighbours in + the same helper usually are above it, which is how those two were found. +- A string built by concatenation (`prefix + name`) is not a literal. + `externalize-strings.mjs` covers those shapes — it walks `.ts` as well as `.tsx` and handles template literals and dialog/toast arguments — but **a zero from `pnpm lint` does not by