diff --git a/AGENTS.md b/AGENTS.md index c35372bd9a..abc53d06ad 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -86,17 +86,23 @@ The web UI is localized with i18next (`namespace:key`). **All new user-facing co must go through `t()` / ``** — never a hardcoded literal — regardless of which directory you are in. -This is enforced, not just requested. Two ratchets, both of which only tighten: +This is enforced, not just requested, and the enforcement is now repo-wide: +- **`i18next/no-literal-string` is a lint error on every `.ts`/`.tsx` file** + (tests and `e2e/` excluded). It was scoped to `i18nGuardFiles` while the + migration was in flight; measured at zero violations across all 2560 source + files, it was widened, so "the file was not on the list" is no longer a way for + copy to ship untranslated. +- **`scripts/check-nonjsx-copy.mjs` scans the whole tree too**, by exclusion + (`node_modules`, `dist`, `e2e`, `scripts`, `src/locales`, tests) rather than by + allowlist, so a new directory is covered the day it is created. - **New code, everywhere** — `pnpm run i18n:ratchet` (pre-commit + CI) fails on a - hardcoded string in a file you added, or on a line you changed, regardless of - directory. Untouched lines are never judged, so you are never asked to migrate - code you did not write. -- **Listed paths, whole file** — `i18next/no-literal-string` is a lint error for - the paths in `i18nGuardFiles` (`apps/web/eslint.i18n.options.mjs`). The - migration of existing strings is **complete**, and the list covers every area it - touched; append a path in the same PR that adds it or externalizes one still - off the list. Never remove an entry to make a build pass — a check rejects that. + hardcoded string in a file you added, or on a line you changed. Untouched lines + are never judged. +- **`i18nGuardFiles`** (`apps/web/eslint.i18n.options.mjs`) survives as the + migration record and the scope `pnpm run lint:i18n` previews. Append a path + when you externalize one; never remove an entry — `check-guard-allowlist.mjs` + rejects that. Three rules that cause silent, hard-to-find bugs when broken: never translate a string compared with `===` (type-to-confirm tokens become impossible to type); @@ -110,8 +116,19 @@ 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: +fails, and the marker must be a `//` line comment — the detector's pattern is +line-anchored and will not see one buried in a `/** */` block. The pseudo-locale +(Settings → General → Appearance, dev/e2e builds) is still the completeness check +for copy no literal scan can see. + +**Translations gate the build.** `pt-pt`, `zh-cn`, `zh-hk` and `zh-tw` are +complete, and `check-i18n-keys.mjs` now fails on a missing key, an extra key, a +dropped placeholder, or a value left identical to English. Adding user-facing +copy means adding it in five languages; for the Traditional Chinese pair run +`pnpm run i18n:zh-hant` rather than hand-translating. When the correct +translation genuinely IS the English word, declare it in +`src/locales//_verbatim.json` with a reason — brand nouns, acronyms and +placeholder-only frames need no declaration at all. Full guide: [`docs/i18n.md`](docs/i18n.md). UI punctuation is plain and intentional: do not use the Unicode em dash (U+2014) @@ -122,21 +139,25 @@ rendered fallback strings. `pnpm run i18n:check` enforces it. Historical history and remains immutable. ### Knowledge + - **Public docs:** Website-ready user documentation lives in `docs/public/**`. Use `/docs-maintainer` when a change affects CLI commands, config keys, install/deploy flows, workflows, executors, public APIs, screenshots, or user-facing terminology. - **Specs:** Feature specs live in `docs/specs//spec.md` — the durable "what & why" of a feature, written before coding. Use `/spec` to write or update a spec. See `docs/specs/INDEX.md`. - **Decisions:** Architecture decisions are recorded in `docs/decisions/`. Read `docs/decisions/INDEX.md` for an overview. When making significant architectural choices, create a new ADR via `/record decision`. - **Plans:** Implementation plans are generated from specs via `/plan` and committed under `docs/plans//plan.md`, with individual sibling task files named `docs/plans//task--.md`. Specs are the living requirements; plans and task files are implementation records for the current buildout. ### Plan Implementation + - Specs, plans, and task files are the durable source of implementation scope, dependency order, and task-level validation. Keep their statuses and recorded command results accurate. The feature and fix skills define the workflow. ### Observability + - In dev mode (`KANDEV_MOCK_AGENT=true` or `debug.pprofEnabled`), `/debug/vars` exposes the stdlib expvar handler. Office provider-routing metrics live under `routing_*` (route attempts, fallbacks, parked runs, provider degraded/recovered counters). The metrics are also still emitted as structured `routing.metric.*` zap logs for human debugging. - ADR 0015's step-completion-signal telemetry lives under `workflow_*`: `workflow_step_completion_signal_received_total` (`internal/workflow/signalmetrics/`), labelled by `source` and `agent_type`, counts accepted `step_complete_kandev` signals. Its separate `workflow_step_completion_signal_fallback_used_total` counter is labelled by `agent_type` and counts manual fallback uses. The fallback button does not have a production increment site yet, so the counter remains zero until that UI ships. ### GitHub Operations + Skills use `gh` CLI by default. If a `gh` command fails (not installed, not authenticated, etc.), use whatever GitHub tools are available in the environment (MCP GitHub tools, API tools, etc.) to accomplish the same operation. The goal is the same — the tool may differ. For multiline Markdown issue or PR bodies, write the body to a file and pass it @@ -213,7 +234,7 @@ Contract authority is intentionally split by implementation boundary: frontend a ### Runtime profiles (prod / dev / e2e) -**`profiles.yaml` at the repo root** is the single source of truth for env-driven runtime defaults — feature flags, mock providers (agent / GitHub / Jira / Linear), debug switches, and e2e tuning knobs. The backend embeds it (`//go:embed` via `apps/backend/internal/profiles/`) and at startup calls `profiles.ApplyProfile()` to write the matching profile's env vars onto its own process, *only when each var is not already set* — so launchers, shells, and per-spec overrides still win. +**`profiles.yaml` at the repo root** is the single source of truth for env-driven runtime defaults — feature flags, mock providers (agent / GitHub / Jira / Linear), debug switches, and e2e tuning knobs. The backend embeds it (`//go:embed` via `apps/backend/internal/profiles/`) and at startup calls `profiles.ApplyProfile()` to write the matching profile's env vars onto its own process, _only when each var is not already set_ — so launchers, shells, and per-spec overrides still win. Runtime feature toggles add a SQLite-backed override tier managed through `Settings > System > Feature Toggles`. Effective values use this precedence: explicit environment variable > SQLite override > profile default. The typed runtime flag registry lives in `apps/backend/internal/runtimeflags/registry.go`; each registration owns the public metadata, environment variable, config reader, and config applier. Do not add parallel per-flag maps or switches. diff --git a/apps/web/AGENTS.md b/apps/web/AGENTS.md index 52c721f23f..dc3a5d8c4a 100644 --- a/apps/web/AGENTS.md +++ b/apps/web/AGENTS.md @@ -207,46 +207,46 @@ surface. ## Internationalization (i18n) -**Externalization is complete.** #2367 localized `app/office`, the last -un-migrated area. A hardcoded user-facing literal is now a regression, not -leftover migration work. New copy must go through `t()` / `` wherever you -write it. +**Externalization is complete.** A hardcoded user-facing literal is a +regression, not leftover migration work. New copy goes through `t()` / `` +wherever you write it. Add English keys to `src/locales/en/.json`; use `useTranslation()` in components and module-level `t` only inside plain helper calls. `` is only -for markup, and a `t()` child corrupts its tag indices. -Do not translate domain data, identifiers, test IDs, discriminants, comparison -tokens, or map keys; split display copy from logic before translating it. -Keep `lib/i18n/provider.tsx` module initialization; removing it blanks the app. +for markup, and a `t()` child corrupts its tag indices. Do not translate domain +data, identifiers, test IDs, discriminants, comparison tokens, or map keys; split +display copy from logic first. Keep `lib/i18n/provider.tsx` module +initialization; removing it blanks the app. Use `_one`/`_other` plural keys, +never English suffixes. Never capture `t()` in a module-level constant; it +freezes the boot locale. No Unicode em dash (U+2014) in copy or locale values. -Use i18next `_one`/`_other` plural keys; never build English suffixes locally. +`pnpm lint` fails on hardcoded UI strings: `i18next/no-literal-string` is an +**error on every `.ts`/`.tsx` file** (tests, `*.test-helpers.*`, `*.test-utils.*` +and `e2e/**` excluded). It was scoped to `i18nGuardFiles` during the migration; +measured at zero violations across all 2560 source files, it was widened. +`i18nGuardFiles` remains the migration record and the `lint:i18n ` preview +scope: append when you externalize a path, never delete an entry +(`check-guard-allowlist.mjs` rejects that). `i18n:ratchet` guards new/changed +lines independently. The rule sees only JSX literals; SCREAMING_CASE tables, +plain `.ts` helpers, parameter defaults and toast/setter arguments are gated by +`scripts/check-nonjsx-copy.mjs`, which scans the **whole tree by exclusion**. +Silence a legitimate one with `// i18n-exempt: ` (required) as a `//` +LINE comment — the detector's pattern is line-anchored, so a marker inside a +`/** */` block is silently ignored. -Never capture `t()` in a module-level constant; it freezes the boot locale. +**Real-locale catalogs gate.** `pt-pt`, `zh-cn`, `zh-hk`, `zh-tw` are complete; +`check-i18n-keys.mjs` fails on a missing/extra key, a dropped `{{placeholder}}` +or `` tag, an empty value, or a value identical to English. Untranslatable +values are handled in two tiers: those `looksLikeCopy` rejects as non-copy need +no declaration; prose that reads the same in the target language goes in +`src/locales//_verbatim.json` (or the shared `_verbatim.json`) with a +mandatory reason — reasonless or stale entries are errors. For zh-tw/zh-hk run +`pnpm run i18n:zh-hant`, do not hand-translate. -UI punctuation: avoid Unicode em dash (U+2014) in user-facing copy, locale values, -published documentation. `pnpm run i18n:check` and the `public-copy-em-dash` hook -enforce periods, colons, commas, semicolons, and parentheses across those sources. - -`pnpm lint` fails on hardcoded UI strings (`i18next/no-literal-string` is an -**error**), but **only on the `i18nGuardFiles` allowlist** in -`eslint.i18n.options.mjs`, which covers every area the migration touched. It -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. `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 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). +module-scope `t()`, em dashes, 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/actions/agents.ts b/apps/web/app/actions/agents.ts index b97ada6eab..ddd127f49e 100644 --- a/apps/web/app/actions/agents.ts +++ b/apps/web/app/actions/agents.ts @@ -215,6 +215,8 @@ export async function deleteAgentProfileAction( utilityAgents: body.utility_agents ?? [], }; } + // i18n-exempt: server-provided error text, or an HTTP status diagnostic when + // the server sent none. The toast title around it is translated. return { status: "error", message: body?.error || `Request failed: ${response.status} ${response.statusText}`, diff --git a/apps/web/app/demo/messages/demo-messages-data.ts b/apps/web/app/demo/messages/demo-messages-data.ts index 3b0dc4c897..5889ced20f 100644 --- a/apps/web/app/demo/messages/demo-messages-data.ts +++ b/apps/web/app/demo/messages/demo-messages-data.ts @@ -4,6 +4,8 @@ import { MARKDOWN_DEMO_MESSAGE_CONTENT } from "./demo-messages-content"; const AUTH_CONFIG_PATH = "src/auth/config.ts"; const B = { session_id: sessionId("demo"), task_id: taskId("demo-task") }; +// i18n-exempt: fixture content for the /demo/messages renderer playground; the +// strings stand in for agent output, which is never translated. export const dummyMessages: Message[] = [ // Task description { diff --git a/apps/web/app/gitlab/gitlab-page-client.tsx b/apps/web/app/gitlab/gitlab-page-client.tsx index 2ec926012d..abb3629aae 100644 --- a/apps/web/app/gitlab/gitlab-page-client.tsx +++ b/apps/web/app/gitlab/gitlab-page-client.tsx @@ -16,7 +16,7 @@ import { type SidebarSelection, } from "@/components/gitlab/my-gitlab/presets-sidebar"; import { PresetsScopeBar } from "@/components/gitlab/my-gitlab/presets-scope-bar"; -import { MR_PRESETS, ISSUE_PRESETS } from "@/components/gitlab/my-gitlab/presets"; +import { MR_PRESETS, ISSUE_PRESETS, presetLabel } from "@/components/gitlab/my-gitlab/presets"; import { useGitLabSearch } from "@/components/gitlab/my-gitlab/use-gitlab-search"; import { useSavedPresets, type SavedPreset } from "@/components/gitlab/my-gitlab/use-saved-presets"; import { @@ -77,8 +77,10 @@ function resolveTitle( } const presets = selection.kind === "mr" ? MR_PRESETS : ISSUE_PRESETS; return ( - presets.find((p) => p.value === selection.id)?.label ?? - (selection.kind === "mr" ? t("gitlab:titleMergeRequests") : t("gitlab:titleIssues")) + presetLabel( + t, + presets.find((p) => p.value === selection.id), + ) ?? (selection.kind === "mr" ? t("gitlab:titleMergeRequests") : t("gitlab:titleIssues")) ); } diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 869da8512c..5d84ac1847 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -19,6 +19,9 @@ import { SidebarViewsSyncBridge } from "@/components/sidebar-views-sync-bridge"; import { LogBufferBridge } from "@/components/log-buffer-bridge"; import { getFeatureFlagsAction, getRuntimeDebugModeAction } from "@/app/actions/features"; +// i18n-exempt: Next.js metadata convention object, left from the pre-Vite +// shell and consumed by nothing. The SPA's real and meta description +// are static markup in index.html. export const metadata = { title: "Kandev - AI Kanban", description: "AI-powered workflow management for developers", diff --git a/apps/web/app/stats/stats-data.tsx b/apps/web/app/stats/stats-data.tsx index b061f1f69a..d744cb02b5 100644 --- a/apps/web/app/stats/stats-data.tsx +++ b/apps/web/app/stats/stats-data.tsx @@ -22,6 +22,7 @@ import { type TaskStatsResponse, } from "@/lib/api/domains/stats-api"; import type { RangeKey } from "./stats-utils"; +import { t } from "@/lib/i18n"; export type SectionStatus<T> = | { kind: "loading" } @@ -51,7 +52,7 @@ const INITIAL_SECTIONS: StatsSections = { }; function errorMessage(e: unknown): string { - return e instanceof Error ? e.message : "Failed to load section"; + return e instanceof Error ? e.message : t("stats:failedToLoadSection"); } export function useStatsSections(workspaceId: string | undefined, range: RangeKey): StatsSections { diff --git a/apps/web/app/stats/stats-utils.test.ts b/apps/web/app/stats/stats-utils.test.ts index 5a08f79b59..3401a4e91c 100644 --- a/apps/web/app/stats/stats-utils.test.ts +++ b/apps/web/app/stats/stats-utils.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from "vitest"; +import { i18n } from "@/lib/i18n"; import type { StatsResponse } from "@/lib/types/http"; import { buildStatsSummary, @@ -155,6 +156,20 @@ describe("getSubtitle", () => { it("prefers stats over the error flag once data arrives", () => { expect(getSubtitle(sampleStats.global, true)).toBe("10 tasks · 7 sessions · 1h 2m"); }); + + it("localizes the ready branch, not only the error and loading ones", async () => { + // The regression: the error and loading returns went through the catalog + // while the success subtitle kept "tasks" and "sessions" hardcoded, so the + // page header was half translated. `check-nonjsx-copy.mjs` cannot see it — + // the template's longest static chunk is " tasks · ", which `looksLikeCopy` + // rejects as a single lowercase word. + await i18n.changeLanguage("pseudo"); + try { + expect(getSubtitle(sampleStats.global, false)).not.toMatch(/\b(tasks?|sessions?)\b/); + } finally { + await i18n.changeLanguage("en"); + } + }); }); describe("statsReducer", () => { diff --git a/apps/web/app/stats/stats-utils.ts b/apps/web/app/stats/stats-utils.ts index 96746239d1..d11429559e 100644 --- a/apps/web/app/stats/stats-utils.ts +++ b/apps/web/app/stats/stats-utils.ts @@ -1,5 +1,6 @@ import type { StatsRange } from "@/lib/api/domains/stats-api"; import type { StatsResponse } from "@/lib/types/http"; +import { t } from "@/lib/i18n"; export type RangeKey = StatsRange; @@ -13,11 +14,11 @@ export function isRangeKey(value: string | null | undefined): value is RangeKey export function getRangeLabel(range: RangeKey): string { switch (range) { case "week": - return "Last Week"; + return t("stats:rangeLastWeek"); case "month": - return "Last Month"; + return t("stats:rangeLastMonth"); case "all": - return "All Time"; + return t("stats:rangeAllTime"); } } @@ -33,9 +34,15 @@ export function formatDuration(ms: number): string { export function getSubtitle(global: StatsResponse["global"] | null, hasError: boolean): string { if (global) { - return `${global.total_tasks} tasks · ${global.total_sessions} sessions · ${formatDuration(global.total_duration_ms)}`; + // Composed from two count-bearing keys rather than one template, because + // i18next carries a single `count` per key and both nouns inflect. + return t("stats:subtitleReady", { + tasks: t("stats:subtitleTaskCount", { count: global.total_tasks }), + sessions: t("stats:subtitleSessionCount", { count: global.total_sessions }), + duration: formatDuration(global.total_duration_ms), + }); } - return hasError ? "Failed to load stats" : "Loading stats…"; + return hasError ? t("stats:failedToLoadStats") : t("stats:loadingStats"); } export type StatsState = { @@ -83,18 +90,37 @@ export function buildStatsSummary( const topRepo = repository_stats .filter((r) => r.total_tasks > 0) .sort((a, b) => b.total_tasks - a.total_tasks)[0]; - const topRepoLabel = topRepo ? `${topRepo.repository_name} (${topRepo.total_tasks} tasks)` : "-"; + const topRepoLabel = topRepo + ? t("stats:summaryTopRepo", { + repository: topRepo.repository_name, + count: topRepo.total_tasks, + }) + : "-"; const hasGitStats = git_stats && (git_stats.total_commits > 0 || git_stats.total_files_changed > 0); const gitLine = hasGitStats - ? `${git_stats.total_commits} commits, +${git_stats.total_insertions.toLocaleString()}/-${git_stats.total_deletions.toLocaleString()}` - : "no git activity"; + ? t("stats:summaryGitActivity", { + count: git_stats.total_commits, + insertions: git_stats.total_insertions.toLocaleString(), + deletions: git_stats.total_deletions.toLocaleString(), + }) + : t("stats:noGitActivity"); + // The whole report, not only the range and the empty-git fragment. A summary + // the user pastes elsewhere must not be half English and half translated. return [ - `*Kandev Stats - ${rangeLabel}*`, - `- Tasks: ${global.total_tasks} total (${global.completed_tasks} done, ${global.in_progress_tasks} in progress) · ${completion} completion`, - `- Completed (${rangeLabel}): ${completedInRange}`, - `- Time: ${formatDuration(global.total_duration_ms)} total · ${formatDuration(global.avg_duration_ms_per_task)} avg/task`, - `- Repos: ${repository_stats.length} tracked · Top repo: ${topRepoLabel}`, - `- Git: ${gitLine}`, + t("stats:summaryHeading", { range: rangeLabel }), + t("stats:summaryTasks", { + total: global.total_tasks, + done: global.completed_tasks, + inProgress: global.in_progress_tasks, + completion, + }), + t("stats:summaryCompletedInRange", { range: rangeLabel, completed: completedInRange }), + t("stats:summaryTime", { + total: formatDuration(global.total_duration_ms), + average: formatDuration(global.avg_duration_ms_per_task), + }), + t("stats:summaryRepos", { count: repository_stats.length, topRepo: topRepoLabel }), + t("stats:summaryGit", { git: gitLine }), ].join("\n"); } diff --git a/apps/web/app/tasks/rich-task-row-details.ts b/apps/web/app/tasks/rich-task-row-details.ts index 408de8d9f2..d1ed5ce004 100644 --- a/apps/web/app/tasks/rich-task-row-details.ts +++ b/apps/web/app/tasks/rich-task-row-details.ts @@ -1,4 +1,5 @@ import type { Repository, Task } from "@/lib/types/http"; +import { t } from "@/lib/i18n"; export type RichTaskRowDetails = { repositoryLabels: string[]; @@ -41,7 +42,8 @@ export function resolveRichTaskRowDetails({ repositoryLabels.push(label); } const parentTitle = task.parent_id - ? (parentTasks.find((parentTask) => parentTask.id === task.parent_id)?.title ?? "Subtask") + ? (parentTasks.find((parentTask) => parentTask.id === task.parent_id)?.title ?? + t("task:subtask")) : null; const reviewAttention = reviewAttentionFor(task); diff --git a/apps/web/components/combobox.tsx b/apps/web/components/combobox.tsx index 2b6d9a80fd..ea5b05dbda 100644 --- a/apps/web/components/combobox.tsx +++ b/apps/web/components/combobox.tsx @@ -17,6 +17,7 @@ import { import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { useTaskCreateDialogPopoverContainer } from "@/hooks/use-task-create-dialog-popover-container"; +import { t } from "@/lib/i18n"; export type ComboboxOption = { value: string; @@ -144,9 +145,9 @@ export const Combobox = memo(function Combobox({ onValueChange, ariaLabel, dropdownLabel, - placeholder = "Select option...", - searchPlaceholder = "Search...", - emptyMessage = "No option found.", + placeholder = t("common:selectOption"), + searchPlaceholder = t("common:searchPlaceholder"), + emptyMessage = t("common:noOptionFound"), disabled = false, className, triggerClassName, diff --git a/apps/web/components/debug-overlay.tsx b/apps/web/components/debug-overlay.tsx index bc9b4cebb8..173b2fd0e3 100644 --- a/apps/web/components/debug-overlay.tsx +++ b/apps/web/components/debug-overlay.tsx @@ -3,6 +3,7 @@ import type { PointerEvent } from "react"; import { useEffect, useMemo, useRef, useState } from "react"; import { cn } from "@/lib/utils"; +import { t } from "@/lib/i18n"; type DebugOverlayProps = { title?: string; @@ -22,7 +23,7 @@ function formatValue(value: unknown): string { } } -export function DebugOverlay({ title = "Debug", entries, className }: DebugOverlayProps) { +export function DebugOverlay({ title = t("common:debug"), entries, className }: DebugOverlayProps) { const [position, setPosition] = useState({ x: 0, y: 0 }); const dragOffsetRef = useRef<{ x: number; y: number } | null>(null); diff --git a/apps/web/components/diff/review-finding-severity.tsx b/apps/web/components/diff/review-finding-severity.tsx index cf17a93467..a673c6ecc8 100644 --- a/apps/web/components/diff/review-finding-severity.tsx +++ b/apps/web/components/diff/review-finding-severity.tsx @@ -1,8 +1,9 @@ "use client"; import { Badge } from "@kandev/ui/badge"; -import { severityLabel } from "@/lib/review/format"; +import { SEVERITY_LABEL_KEYS } from "@/lib/review/format"; import type { ReviewSeverity } from "@/lib/types/review"; +import { t } from "@/lib/i18n"; /** * Severity styling. `blocker` and `major` carry destructive weight because they @@ -25,7 +26,7 @@ export function ReviewFindingSeverityBadge({ severity }: { severity: ReviewSever }`} data-testid={`review-finding-severity-${severity}`} > - {severityLabel(severity)} + {t(SEVERITY_LABEL_KEYS[severity] ?? severity)} </Badge> ); } diff --git a/apps/web/components/github/my-github/use-github-search.ts b/apps/web/components/github/my-github/use-github-search.ts index e4ece33a79..d0b760ae8c 100644 --- a/apps/web/components/github/my-github/use-github-search.ts +++ b/apps/web/components/github/my-github/use-github-search.ts @@ -4,6 +4,7 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react import { searchUserPRs, searchUserIssues } from "@/lib/api/domains/github-api"; import type { GitHubPR, GitHubIssue } from "@/lib/types/github"; import type { PresetOption } from "./search-bar"; +import { t } from "@/lib/i18n"; type SearchKind = "pr" | "issue"; @@ -114,7 +115,7 @@ export function useGitHubSearch<T extends GitHubPR | GitHubIssue>({ setState((s) => ({ items: [], loading: false, - error: err instanceof Error ? err.message : "Failed to search GitHub", + error: err instanceof Error ? err.message : t("github:failedToSearchGitHub"), lastFetchedAt: s.lastFetchedAt, total: 0, })); diff --git a/apps/web/components/gitlab/my-gitlab/presets-scope-bar.tsx b/apps/web/components/gitlab/my-gitlab/presets-scope-bar.tsx index b9b39d7d6d..e29e5cd435 100644 --- a/apps/web/components/gitlab/my-gitlab/presets-scope-bar.tsx +++ b/apps/web/components/gitlab/my-gitlab/presets-scope-bar.tsx @@ -41,7 +41,12 @@ export function PresetsScopeBar({ testId="gitlab-presets-scope-bar" savedMenuTestId="gitlab-saved-queries-menu" kinds={KINDS.map(({ value, labelKey }) => ({ value, label: t(labelKey) }))} - presetsByKind={(kind) => (kind === "mr" ? mrPresets : issuePresets)} + presetsByKind={(kind) => + (kind === "mr" ? mrPresets : issuePresets).map((preset) => ({ + ...preset, + label: t(preset.labelKey), + })) + } /> ); } diff --git a/apps/web/components/gitlab/my-gitlab/presets-sidebar.tsx b/apps/web/components/gitlab/my-gitlab/presets-sidebar.tsx index 8dd33c8669..42aa05da8d 100644 --- a/apps/web/components/gitlab/my-gitlab/presets-sidebar.tsx +++ b/apps/web/components/gitlab/my-gitlab/presets-sidebar.tsx @@ -143,7 +143,7 @@ function PresetGroupList({ {items.map((p) => ( <PresetItem key={`${kind}-${p.value}`} - label={p.label} + label={t(p.labelKey)} Icon={p.icon} active={selected.source === "preset" && selected.id === p.value} onClick={() => onSelect({ kind, source: "preset", id: p.value })} diff --git a/apps/web/components/gitlab/my-gitlab/presets.ts b/apps/web/components/gitlab/my-gitlab/presets.ts index 54437fdd6c..86b3613f80 100644 --- a/apps/web/components/gitlab/my-gitlab/presets.ts +++ b/apps/web/components/gitlab/my-gitlab/presets.ts @@ -5,7 +5,8 @@ export type PresetGroup = "inbox" | "created"; export type PresetOption = { value: string; - label: string; + /** Catalog key; resolved where the preset is rendered. */ + labelKey: string; // Backend filter token consumed by gitlab.translateUserSearchFilter. // Accepted: "assigned_to_me", "created_by_me", "review_requested" (MRs only). // Everything else is treated as a raw `key=value&...` filter and is parsed @@ -16,24 +17,32 @@ export type PresetOption = { icon: Icon; }; +/** Display label for a preset, or undefined when there is no preset. */ +export function presetLabel( + translate: (key: string) => string, + preset: PresetOption | undefined, +): string | undefined { + return preset ? translate(preset.labelKey) : undefined; +} + export const MR_PRESETS: PresetOption[] = [ { value: "review_requested", - label: "Review requested", + labelKey: "gitlab:presetReviewRequested", filter: "review_requested", group: "inbox", icon: IconInbox, }, { value: "assigned", - label: "Assigned", + labelKey: "gitlab:presetAssigned", filter: "assigned_to_me", group: "inbox", icon: IconUserPlus, }, { value: "authored", - label: "Authored", + labelKey: "gitlab:presetAuthored", filter: "created_by_me", group: "created", icon: IconGitMerge, @@ -43,14 +52,14 @@ export const MR_PRESETS: PresetOption[] = [ export const ISSUE_PRESETS: PresetOption[] = [ { value: "assigned", - label: "Assigned", + labelKey: "gitlab:presetAssigned", filter: "assigned_to_me", group: "inbox", icon: IconInbox, }, { value: "created", - label: "Created", + labelKey: "gitlab:presetCreated", filter: "created_by_me", group: "created", icon: IconPlus, diff --git a/apps/web/components/gitlab/my-gitlab/use-gitlab-search.test.ts b/apps/web/components/gitlab/my-gitlab/use-gitlab-search.test.ts index 9aa4b8d54e..f39ef70a01 100644 --- a/apps/web/components/gitlab/my-gitlab/use-gitlab-search.test.ts +++ b/apps/web/components/gitlab/my-gitlab/use-gitlab-search.test.ts @@ -23,14 +23,14 @@ const CUSTOM_QUERY = "labels=bug"; const PRESETS: PresetOption[] = [ { value: PRESET_REVIEW, - label: "Review requested", + labelKey: "gitlab:presetReviewRequested", filter: PRESET_REVIEW, group: "inbox", icon: IconInbox, }, { value: PRESET_ASSIGNED, - label: "Assigned", + labelKey: "gitlab:presetAssigned", filter: "assigned_to_me", group: "inbox", icon: IconInbox, diff --git a/apps/web/components/gitlab/my-gitlab/use-gitlab-search.ts b/apps/web/components/gitlab/my-gitlab/use-gitlab-search.ts index d3147c8e13..43219f539c 100644 --- a/apps/web/components/gitlab/my-gitlab/use-gitlab-search.ts +++ b/apps/web/components/gitlab/my-gitlab/use-gitlab-search.ts @@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { searchUserIssues, searchUserMRs } from "@/lib/api/domains/gitlab-api"; import type { Issue, MR } from "@/lib/types/gitlab"; import type { PresetOption } from "./presets"; +import { t } from "@/lib/i18n"; type SearchKind = "mr" | "issue"; type Item = MR | Issue; @@ -134,7 +135,7 @@ export function useGitLabSearch({ setState((s) => ({ items: [], loading: false, - error: err instanceof Error ? err.message : "Failed to search GitLab", + error: err instanceof Error ? err.message : t("gitlab:failedToSearchGitLab"), lastFetchedAt: s.lastFetchedAt, total: 0, workspaceId: requestedWorkspaceId, diff --git a/apps/web/components/improve-kandev-dialog-model.ts b/apps/web/components/improve-kandev-dialog-model.ts index 847190e99c..d4cc755fa4 100644 --- a/apps/web/components/improve-kandev-dialog-model.ts +++ b/apps/web/components/improve-kandev-dialog-model.ts @@ -1,5 +1,6 @@ import type { ImproveKandevBootstrapResponse } from "@/lib/api/domains/improve-kandev-api"; import type { WorkflowStep } from "@/lib/types/http"; +import { t } from "@/lib/i18n"; export const IMPROVE_KANDEV_SKIP_INTRO_KEY = "kandev.improveKandev.skipIntro"; @@ -8,6 +9,8 @@ export const IMPROVE_KANDEV_SKIP_INTRO_KEY = "kandev.improveKandev.skipIntro"; * `improveWorkspaceName` in the backend bootstrap handler; the frontend uses it * to recognize the workspace for New Task routing. */ +// i18n-exempt: matched by value against `improveWorkspaceName` in the backend +// bootstrap handler to recognize the workspace; translating it breaks routing. export const IMPROVE_KANDEV_WORKSPACE_NAME = "Improve Kandev"; export type ImproveKandevKind = "bug" | "feature" | "issue"; @@ -22,15 +25,14 @@ export type ContributorAccessMessageKey = type StorageLike = Pick<Storage, "getItem" | "setItem" | "removeItem">; -const STEP_DESCRIPTIONS: Record<string, string> = { - improve: - "Agent reads the report, explores the codebase, and implements the change with TDD. Runs make fmt, typecheck, test, lint, then commits.", - test: "Agent boots a secondary kandev instance with make dev and tells you what to verify. You confirm the change works in the running app.", - pr: "Agent runs the pr skill: pushes the branch and opens a pull request to kdlbs/kandev for the maintainers to review.", - "open-issue": - "Agent gathers every required field from the repository template, confirms the public draft with you, and publishes the GitHub issue.", - "open issue": - "Agent gathers every required field from the repository template, confirms the public draft with you, and publishes the GitHub issue.", +// Catalog keys. Module scope, so `t()` here would pin the descriptions to the +// boot locale; `describeStep` resolves them per call. +const STEP_DESCRIPTION_KEYS: Record<string, string> = { + improve: "common:improveStepImprove", + test: "common:improveStepTest", + pr: "common:improveStepPr", + "open-issue": "common:improveStepOpenIssue", + "open issue": "common:improveStepOpenIssue", }; export type ImproveKandevReadyState = { @@ -70,8 +72,12 @@ export function contributorAccessMessage( return "common:improveKandevExecutorForkAtPr"; } +/** Resolved per call: the dialog re-renders on a locale switch and follows. */ export function getImproveKandevStepDescription(step: Pick<WorkflowStep, "id" | "name">): string { - return STEP_DESCRIPTIONS[step.id] ?? STEP_DESCRIPTIONS[step.name.toLowerCase()] ?? step.name; + const key = STEP_DESCRIPTION_KEYS[step.id] ?? STEP_DESCRIPTION_KEYS[step.name.toLowerCase()]; + // No key means a workflow step this dialog does not describe; its name is + // user/backend data and stays verbatim. + return key ? t(key) : step.name; } export function writeImproveKandevSkipIntro( diff --git a/apps/web/components/jira/my-jira/filter-model.ts b/apps/web/components/jira/my-jira/filter-model.ts index 88a97b015d..f3beabafd4 100644 --- a/apps/web/components/jira/my-jira/filter-model.ts +++ b/apps/web/components/jira/my-jira/filter-model.ts @@ -32,22 +32,30 @@ function searchClause(raw: string): string | null { return `text ~ ${quote(trimmed)}`; } +// i18n-exempt: JQL fragment sent to the Jira API; the keywords are query +// syntax the server parses, not display copy. function statusClause(statuses: string[]): string | null { if (statuses.length === 0) return null; return `status in (${statuses.map(quote).join(", ")})`; } +// i18n-exempt: JQL fragment sent to the Jira API; the keywords are query +// syntax the server parses, not display copy. function assigneeClause(a: AssigneeFilter): string | null { if (a === "me") return "assignee = currentUser()"; if (a === "unassigned") return "assignee is EMPTY"; return null; } +// i18n-exempt: JQL fragment sent to the Jira API; the keywords are query +// syntax the server parses, not display copy. function projectClause(keys: string[]): string | null { if (keys.length === 0) return null; return `project in (${keys.map(quote).join(", ")})`; } +// i18n-exempt: JQL fragment sent to the Jira API; the keywords are query +// syntax the server parses, not display copy. function sortClause(s: SortKey): string { if (s === "created") return "ORDER BY created DESC"; if (s === "priority") return "ORDER BY priority DESC, updated DESC"; diff --git a/apps/web/components/jira/my-jira/list-toolbar.tsx b/apps/web/components/jira/my-jira/list-toolbar.tsx index 1c267958ee..0f0a0051a1 100644 --- a/apps/web/components/jira/my-jira/list-toolbar.tsx +++ b/apps/web/components/jira/my-jira/list-toolbar.tsx @@ -20,7 +20,7 @@ import { DropdownMenuContent, DropdownMenuTrigger, } from "@kandev/ui/dropdown-menu"; -import type { SavedView } from "./use-saved-views"; +import { savedViewLabel, type SavedView } from "./use-saved-views"; import type { SortKey } from "./filter-model"; import { useTranslation } from "react-i18next"; @@ -76,7 +76,7 @@ export function ListToolbar({ activeViewId={activeViewId} onSelect={onSelectView} onDelete={onDeleteView} - activeName={activeView?.name} + activeName={activeView ? savedViewLabel(t, activeView) : undefined} /> <SaveViewButton onSave={onSaveView} /> <div className="ml-auto flex items-center gap-1"> @@ -265,7 +265,7 @@ function ViewRow({ className="flex-1 flex items-center gap-2 px-2 py-1.5 text-sm cursor-pointer rounded hover:bg-muted/50" > <IconCheck className={`h-3.5 w-3.5 ${active ? "opacity-100" : "opacity-0"}`} /> - <span className="truncate">{view.name}</span> + <span className="truncate">{savedViewLabel(t, view)}</span> </button> {!view.builtin && ( <button diff --git a/apps/web/components/jira/my-jira/use-saved-views.ts b/apps/web/components/jira/my-jira/use-saved-views.ts index eef09c39a3..78cd908314 100644 --- a/apps/web/components/jira/my-jira/use-saved-views.ts +++ b/apps/web/components/jira/my-jira/use-saved-views.ts @@ -7,7 +7,10 @@ import { createQueuedUserSettingsSync } from "@/lib/user-settings-sync"; export type SavedView = { id: string; + /** User-authored for custom views; empty for builtins, which use `nameKey`. */ name: string; + /** Catalog key for the built-in views, resolved where the view is listed. */ + nameKey?: string; filters: FilterState; // customJql is set when the user saved the view while the raw JQL editor was // overriding the structured filters. Restoring such a view re-applies the @@ -19,7 +22,8 @@ export type SavedView = { const BUILTIN_VIEWS: SavedView[] = [ { id: "builtin:assigned", - name: "Assigned to me", + name: "", + nameKey: "jira:builtinViewAssignedToMe", builtin: true, filters: { ...DEFAULT_FILTERS, assignee: "me" }, }, @@ -30,7 +34,8 @@ const BUILTIN_VIEWS: SavedView[] = [ // Users get an in-progress view by selecting a project and its statuses. { id: "builtin:unassigned", - name: "Unassigned", + name: "", + nameKey: "jira:builtinViewUnassigned", builtin: true, filters: { ...DEFAULT_FILTERS, assignee: "unassigned" }, }, @@ -207,3 +212,11 @@ export function useSavedViews() { } export const DEFAULT_VIEW = BUILTIN_VIEWS[0]; + +/** + * Display name for a view: the catalog copy for a builtin, the user's own + * wording for a saved one. Takes `t` so it resolves at render. + */ +export function savedViewLabel(translate: (key: string) => string, view: SavedView): string { + return view.nameKey ? translate(view.nameKey) : view.name; +} diff --git a/apps/web/components/kanban-card-edit-submenu.tsx b/apps/web/components/kanban-card-edit-submenu.tsx index e2e7a76f68..423aa4206d 100644 --- a/apps/web/components/kanban-card-edit-submenu.tsx +++ b/apps/web/components/kanban-card-edit-submenu.tsx @@ -37,7 +37,7 @@ export function buildEditMenuEntry({ kind: "item", key: "edit", icon, - label: "Edit", + label: t("common:edit"), disabled: disabled || !onEdit, onSelect: onEdit, }; @@ -48,7 +48,7 @@ export function buildEditMenuEntry({ key: "edit", testId: "kanban-edit-submenu", icon, - label: "Edit", + label: t("common:edit"), disabled, children: [ { diff --git a/apps/web/components/kanban/mobile-menu-task-list-options.tsx b/apps/web/components/kanban/mobile-menu-task-list-options.tsx index 882671d872..89fb2f2257 100644 --- a/apps/web/components/kanban/mobile-menu-task-list-options.tsx +++ b/apps/web/components/kanban/mobile-menu-task-list-options.tsx @@ -3,6 +3,8 @@ import { Checkbox } from "@kandev/ui/checkbox"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select"; import { + GROUP_OPTION_LABEL_KEYS, + SORT_OPTION_LABEL_KEYS, TASKS_LIST_GROUP_OPTIONS, TASKS_LIST_SORT_OPTIONS, type TasksListGroup, @@ -48,7 +50,7 @@ export function MobileTasksListOptions({ options }: { options: TasksListDisplayO <SelectContent> {TASKS_LIST_SORT_OPTIONS.map((option) => ( <SelectItem key={option.value} value={option.value} className="cursor-pointer"> - {option.label} + {t(SORT_OPTION_LABEL_KEYS[option.value])} </SelectItem> ))} </SelectContent> @@ -74,7 +76,7 @@ export function MobileTasksListOptions({ options }: { options: TasksListDisplayO <SelectContent> {TASKS_LIST_GROUP_OPTIONS.map((option) => ( <SelectItem key={option.value} value={option.value} className="cursor-pointer"> - {option.label} + {t(GROUP_OPTION_LABEL_KEYS[option.value])} </SelectItem> ))} </SelectContent> diff --git a/apps/web/components/kanban/swimlane-container.tsx b/apps/web/components/kanban/swimlane-container.tsx index 1287038f61..adbecd9b34 100644 --- a/apps/web/components/kanban/swimlane-container.tsx +++ b/apps/web/components/kanban/swimlane-container.tsx @@ -38,6 +38,7 @@ import type { Task } from "@/components/kanban-card"; import type { MoveTaskError } from "@/hooks/use-drag-and-drop"; import type { Repository } from "@/lib/types/http"; import type { WorkflowSnapshotData } from "@/lib/state/slices/kanban/types"; +import { t } from "@/lib/i18n"; export type SwimlaneContainerProps = { viewMode: string; @@ -78,9 +79,9 @@ function getEmptyMessage({ visibleWorkflows, showEmptyBoard, }: EmptyMessageOptions): string | null { - if (isLoading && Object.keys(snapshots).length === 0) return "Loading..."; - if (orderedWorkflows.length === 0) return "No workflows available yet."; - if (visibleWorkflows.length === 0 && !showEmptyBoard) return "No tasks yet"; + if (isLoading && Object.keys(snapshots).length === 0) return t("common:loading"); + if (orderedWorkflows.length === 0) return t("kanban:noWorkflowsAvailableYet"); + if (visibleWorkflows.length === 0 && !showEmptyBoard) return t("kanban:noTasksYet"); return null; } diff --git a/apps/web/components/kanban/task-search-input.tsx b/apps/web/components/kanban/task-search-input.tsx index 38444d0d1f..2e354fbd64 100644 --- a/apps/web/components/kanban/task-search-input.tsx +++ b/apps/web/components/kanban/task-search-input.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { Input } from "@kandev/ui/input"; import { IconSearch, IconX, IconLoader2 } from "@tabler/icons-react"; import { cn } from "@/lib/utils"; +import { t } from "@/lib/i18n"; interface TaskSearchInputProps { value: string; @@ -18,7 +19,7 @@ interface TaskSearchInputProps { export function TaskSearchInput({ value, onChange, - placeholder = "Search tasks...", + placeholder = t("kanban:searchTasksPlaceholder"), debounceMs = 300, isLoading = false, className, diff --git a/apps/web/components/mobile/pull-to-refresh.tsx b/apps/web/components/mobile/pull-to-refresh.tsx index aa3247ae28..ebd7e04ab8 100644 --- a/apps/web/components/mobile/pull-to-refresh.tsx +++ b/apps/web/components/mobile/pull-to-refresh.tsx @@ -8,13 +8,14 @@ import { pullDistance, shouldRefreshAfterPull, } from "@/lib/mobile/pull-to-refresh"; +import { t } from "@/lib/i18n"; type PullState = { x: number; y: number; eligible: boolean } | null; function refreshLabel(refreshing: boolean, distance: number): string { - if (refreshing) return "Refreshing tasks"; - if (distance >= PULL_TO_REFRESH_THRESHOLD) return "Release to refresh"; - return "Pull to refresh"; + if (refreshing) return t("kanban:pullToRefreshRefreshing"); + if (distance >= PULL_TO_REFRESH_THRESHOLD) return t("kanban:pullToRefreshRelease"); + return t("kanban:pullToRefreshPull"); } export function PullToRefresh({ diff --git a/apps/web/components/runs/automation-rows.test.ts b/apps/web/components/runs/automation-rows.test.ts index 382be85195..ccd603328e 100644 --- a/apps/web/components/runs/automation-rows.test.ts +++ b/apps/web/components/runs/automation-rows.test.ts @@ -1,3 +1,4 @@ +import { t } from "@/lib/i18n"; import { describe, expect, it } from "vitest"; import type { Automation, @@ -6,13 +7,13 @@ import type { AutomationSummary, } from "@/lib/types/automation"; import { - AUTOMATION_OFF_REASON, + AUTOMATION_OFF_REASON_KEY, automationState, buildAutomationRows, concurrencyReason, - NO_SCHEDULE_REASON, + NO_SCHEDULE_REASON_KEY, nextFiring, - SCHEDULE_OFF_REASON, + SCHEDULE_OFF_REASON_KEY, } from "./automation-rows"; const SINGAPORE = "Asia/Singapore"; @@ -85,20 +86,20 @@ describe("nextFiring", () => { it("explains a disabled automation instead of promising a time", () => { const next = nextFiring(mkAutomation({ enabled: false }), 0, NOW); - expect(next).toEqual({ kind: "reason", text: AUTOMATION_OFF_REASON }); + expect(next).toEqual({ kind: "reason", text: t(AUTOMATION_OFF_REASON_KEY) }); }); it("explains an automation with no schedule at all", () => { expect(nextFiring(mkAutomation({ triggers: [] }), 0, NOW)).toEqual({ kind: "reason", - text: NO_SCHEDULE_REASON, + text: t(NO_SCHEDULE_REASON_KEY), }); }); it("explains a schedule trigger that is switched off", () => { const off = mkAutomation({ triggers: [mkTrigger({ enabled: false })] }); - expect(nextFiring(off, 0, NOW)).toEqual({ kind: "reason", text: SCHEDULE_OFF_REASON }); + expect(nextFiring(off, 0, NOW)).toEqual({ kind: "reason", text: t(SCHEDULE_OFF_REASON_KEY) }); }); it("explains a firing blocked by the concurrency cap, naming the cap", () => { @@ -137,7 +138,7 @@ describe("nextFiring", () => { // cap is not why it will not fire. const off = mkAutomation({ enabled: false }); - expect(nextFiring(off, 1, NOW).text).toBe(AUTOMATION_OFF_REASON); + expect(nextFiring(off, 1, NOW).text).toBe(t(AUTOMATION_OFF_REASON_KEY)); }); it("states the rule for an interval it cannot resolve to an instant", () => { diff --git a/apps/web/components/runs/automation-rows.ts b/apps/web/components/runs/automation-rows.ts index 815774fa00..a395fb9b34 100644 --- a/apps/web/components/runs/automation-rows.ts +++ b/apps/web/components/runs/automation-rows.ts @@ -1,6 +1,7 @@ import { describeSchedule } from "@/components/automations/schedule-expression"; import type { Automation, AutomationRun, AutomationSummary } from "@/lib/types/automation"; import { formatNextFiring, nextFiringInstant, scheduleBinding } from "./automation-schedule"; +import { t } from "@/lib/i18n"; /** * What the row's dot says about health. Deliberately three words, not the run @@ -16,16 +17,18 @@ export type AutomationActivityState = "running" | "idle" | "paused"; */ export type NextFiring = { kind: "time"; text: string } | { kind: "reason"; text: string }; -export const AUTOMATION_OFF_REASON = "Switched off - it will not fire"; -export const NO_SCHEDULE_REASON = "No schedule - only runs when you trigger it"; -export const SCHEDULE_OFF_REASON = "Schedule trigger is switched off"; +// Catalog keys, not copy: these are module-scope constants, so a `t()` here +// would pin the reason text to the boot locale. Callers resolve them. +export const AUTOMATION_OFF_REASON_KEY = "automations:nextFiringSwitchedOff"; +export const NO_SCHEDULE_REASON_KEY = "automations:nextFiringNoSchedule"; +export const SCHEDULE_OFF_REASON_KEY = "automations:nextFiringScheduleOff"; /** * The concurrency cap states its own number. "Paused" alone reads as something * the user did; naming the cap points at the setting that would change it. */ export function concurrencyReason(maxConcurrentRuns: number): string { - return `Paused - a run is still open (max ${Math.max(1, maxConcurrentRuns)} at a time)`; + return t("automations:nextFiringConcurrencyPaused", { max: Math.max(1, maxConcurrentRuns) }); } export const STATE_DOT_CLASS: Record<AutomationActivityState, string> = { @@ -88,10 +91,10 @@ export function nextFiring( openRuns: number, now: Date = new Date(), ): NextFiring { - if (!automation.enabled) return { kind: "reason", text: AUTOMATION_OFF_REASON }; + if (!automation.enabled) return { kind: "reason", text: t(AUTOMATION_OFF_REASON_KEY) }; const { trigger, expression } = scheduleBinding(automation); - if (!trigger || !expression) return { kind: "reason", text: NO_SCHEDULE_REASON }; - if (!trigger.enabled) return { kind: "reason", text: SCHEDULE_OFF_REASON }; + if (!trigger || !expression) return { kind: "reason", text: t(NO_SCHEDULE_REASON_KEY) }; + if (!trigger.enabled) return { kind: "reason", text: t(SCHEDULE_OFF_REASON_KEY) }; if (atConcurrencyCap(automation, openRuns)) { return { kind: "reason", text: concurrencyReason(automation.max_concurrent_runs) }; } diff --git a/apps/web/components/runs/run-feed-item.tsx b/apps/web/components/runs/run-feed-item.tsx index 43c9f003d7..a0e49940a6 100644 --- a/apps/web/components/runs/run-feed-item.tsx +++ b/apps/web/components/runs/run-feed-item.tsx @@ -5,6 +5,7 @@ import { cn } from "@/lib/utils"; import { formatRelativeTime } from "@/lib/utils"; import type { AutomationRun } from "@/lib/types/automation"; import { isOpenRun, statusDotClass, statusLabelKey } from "./run-status"; +import { t } from "@/lib/i18n"; /** * What the run actually said, in priority order. An error outranks the summary @@ -14,8 +15,8 @@ import { isOpenRun, statusDotClass, statusLabelKey } from "./run-status"; export function outcomeText(run: AutomationRun): string { if (run.error_message) return run.error_message; if (run.summary) return run.summary; - if (isOpenRun(run.status)) return "Still running - no report yet."; - return "No report recorded."; + if (isOpenRun(run.status)) return t("automations:runStillRunningNoReport"); + return t("automations:runNoReportRecorded"); } type RunFeedItemProps = { diff --git a/apps/web/components/runs/use-automation-activity.ts b/apps/web/components/runs/use-automation-activity.ts index 35ff9d1d55..80ca9f3c34 100644 --- a/apps/web/components/runs/use-automation-activity.ts +++ b/apps/web/components/runs/use-automation-activity.ts @@ -7,6 +7,7 @@ import { listAutomationRuns, } from "@/lib/api/domains/automation-api"; import type { Automation, AutomationRun, AutomationSummary } from "@/lib/types/automation"; +import { t } from "@/lib/i18n"; /** * The detail view reads one automation's history. It is a reading surface, not @@ -80,7 +81,7 @@ export function useAutomationActivity(automationId: string) { .catch((err: unknown) => { if (requestRef.current !== requestId) return; setLoaded({ automationId, automation: null, runs: EMPTY_RUNS, openRuns: 0 }); - setError(err instanceof Error ? err.message : "Failed to load this automation"); + setError(err instanceof Error ? err.message : t("automations:failedToLoadThisAutomation")); }) .finally(() => { if (requestRef.current !== requestId) return; diff --git a/apps/web/components/runs/use-automation-summaries.ts b/apps/web/components/runs/use-automation-summaries.ts index b9e281ad0b..8c1c3bbfdc 100644 --- a/apps/web/components/runs/use-automation-summaries.ts +++ b/apps/web/components/runs/use-automation-summaries.ts @@ -3,6 +3,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { listAutomationSummaries } from "@/lib/api/domains/automation-api"; import type { AutomationSummary } from "@/lib/types/automation"; +import { t } from "@/lib/i18n"; const EMPTY_SUMMARIES: AutomationSummary[] = []; @@ -42,7 +43,9 @@ export function useAutomationSummaries(workspaceId: string | undefined) { .catch((err: unknown) => { if (requestRef.current !== requestId) return; setLoaded({ workspaceId, summaries: EMPTY_SUMMARIES }); - setError(err instanceof Error ? err.message : "Failed to load automation activity"); + setError( + err instanceof Error ? err.message : t("automations:failedToLoadAutomationActivity"), + ); }) .finally(() => { if (requestRef.current !== requestId) return; diff --git a/apps/web/components/runs/use-workspace-automations.ts b/apps/web/components/runs/use-workspace-automations.ts index 8f9023a2c3..48446ed086 100644 --- a/apps/web/components/runs/use-workspace-automations.ts +++ b/apps/web/components/runs/use-workspace-automations.ts @@ -3,6 +3,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { listAutomations } from "@/lib/api/domains/automation-api"; import type { Automation } from "@/lib/types/automation"; +import { t } from "@/lib/i18n"; const EMPTY_AUTOMATIONS: Automation[] = []; @@ -58,7 +59,7 @@ export function useWorkspaceAutomations(workspaceId: string | undefined) { .catch((err: unknown) => { if (requestRef.current !== requestId) return; setLoaded({ workspaceId, automations: EMPTY_AUTOMATIONS }); - setError(err instanceof Error ? err.message : "Failed to load automations"); + setError(err instanceof Error ? err.message : t("automations:failedToLoadAutomations")); }) .finally(() => { if (requestRef.current !== requestId) return; diff --git a/apps/web/components/runs/use-workspace-runs.ts b/apps/web/components/runs/use-workspace-runs.ts index 7e77995ebf..2ca14212a7 100644 --- a/apps/web/components/runs/use-workspace-runs.ts +++ b/apps/web/components/runs/use-workspace-runs.ts @@ -3,6 +3,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { listWorkspaceAutomationRuns } from "@/lib/api/domains/automation-api"; import type { WorkspaceAutomationRun } from "@/lib/types/automation"; +import { t } from "@/lib/i18n"; /** * The feed is a reading surface, not an audit trail — a couple of hundred rows @@ -57,7 +58,7 @@ export function useWorkspaceRuns(workspaceId: string | undefined) { .catch((err: unknown) => { if (requestRef.current !== requestId) return; setLoaded({ workspaceId, runs: EMPTY_RUNS }); - setError(err instanceof Error ? err.message : "Failed to load runs"); + setError(err instanceof Error ? err.message : t("automations:failedToLoadRuns")); }) .finally(() => { if (requestRef.current !== requestId) return; diff --git a/apps/web/components/settings/key-value-input.tsx b/apps/web/components/settings/key-value-input.tsx index 5a8b0515e3..1e674f3f6b 100644 --- a/apps/web/components/settings/key-value-input.tsx +++ b/apps/web/components/settings/key-value-input.tsx @@ -5,6 +5,7 @@ import { Button } from "@kandev/ui/button"; import { Input } from "@kandev/ui/input"; import type { KeyValue } from "@/lib/settings/types"; import { generateUUID } from "@/lib/utils"; +import { t } from "@/lib/i18n"; type KeyValueInputProps = { items: KeyValue[]; @@ -18,9 +19,9 @@ type KeyValueInputProps = { export function KeyValueInput({ items, onChange, - keyPlaceholder = "Key", - valuePlaceholder = "Value", - addButtonLabel = "Add Item", + keyPlaceholder = t("settings:keyValueKeyPlaceholder"), + valuePlaceholder = t("settings:keyValueValuePlaceholder"), + addButtonLabel = t("settings:keyValueAddItem"), masked = false, }: KeyValueInputProps) { const handleAdd = () => { diff --git a/apps/web/components/settings/lsp-language-cards.tsx b/apps/web/components/settings/lsp-language-cards.tsx index 608ad645b6..448b20fb07 100644 --- a/apps/web/components/settings/lsp-language-cards.tsx +++ b/apps/web/components/settings/lsp-language-cards.tsx @@ -10,6 +10,7 @@ import { lspLanguageDisplayLabel, } from "./lsp-language-options"; +// i18n-exempt: shell command the user runs verbatim. const NPM_INSTALL_COMMAND = "npm install"; type LspLanguageCardsProps = { diff --git a/apps/web/components/settings/settings-breadcrumb-labels.ts b/apps/web/components/settings/settings-breadcrumb-labels.ts index 6d294db66d..6354bd96fc 100644 --- a/apps/web/components/settings/settings-breadcrumb-labels.ts +++ b/apps/web/components/settings/settings-breadcrumb-labels.ts @@ -33,6 +33,7 @@ export type SegmentTitle = { * Integration slugs are NOT listed: those come from `WORKSPACE_INTEGRATIONS` * below, the same table the command palette and the route list read. */ +// i18n-exempt: brand names and initialisms, spelled the same in every locale. const SEGMENT_LABEL_OVERRIDES: Record<string, string> = { mcp: "MCP", ui: "UI", diff --git a/apps/web/components/settings/settings-save-provider.tsx b/apps/web/components/settings/settings-save-provider.tsx index 48345c006c..d501ee749a 100644 --- a/apps/web/components/settings/settings-save-provider.tsx +++ b/apps/web/components/settings/settings-save-provider.tsx @@ -22,6 +22,8 @@ import { export type SettingsSaveRevision = string | number; +// i18n-exempt: control-flow signal. The save coordinator matches this error by +// type to abandon a save silently; the message is never rendered. export class SettingsSaveCancelledError extends Error { constructor(message = "Save cancelled") { super(message); diff --git a/apps/web/components/settings/sleep-inhibition-settings.tsx b/apps/web/components/settings/sleep-inhibition-settings.tsx index 38cd3154be..f82f6cac62 100644 --- a/apps/web/components/settings/sleep-inhibition-settings.tsx +++ b/apps/web/components/settings/sleep-inhibition-settings.tsx @@ -26,7 +26,10 @@ import { useSleepInhibitionSettings } from "@/hooks/domains/settings/use-sleep-i import type { SleepInhibitionResponse } from "@/lib/types/system"; const MACOS_COMMAND = "/usr/bin/caffeinate -i -w <kandev-pid>"; +// i18n-exempt: Win32 API signature shown verbatim as code. const WINDOWS_API = "SetThreadExecutionState(ES_CONTINUOUS | ES_SYSTEM_REQUIRED)"; +// i18n-exempt: D-Bus method call shown verbatim as code; its arguments are +// wire values the systemd API matches, not display copy. const LINUX_METHOD = 'org.freedesktop.login1.Manager.Inhibit("sleep", "Kandev", "A Kandev task is running", "block")'; const CODE_CLASS = "break-all rounded bg-muted px-1 py-0.5 font-mono text-[0.7rem]"; diff --git a/apps/web/components/settings/terminal-settings.tsx b/apps/web/components/settings/terminal-settings.tsx index 3bed237964..923ce50c88 100644 --- a/apps/web/components/settings/terminal-settings.tsx +++ b/apps/web/components/settings/terminal-settings.tsx @@ -76,10 +76,11 @@ function FontGroupOptions() { </Badge> )} </SelectLabel> - {/* Preset labels are font family names — data, never translated. */} + {/* Preset labels are font family names - data, never translated. The + one entry naming no typeface carries `labelKey` instead. */} {(FONT_GROUPS[category] ?? []).map((preset) => ( <SelectItem key={preset.value} value={preset.value}> - {preset.label} + {preset.labelKey ? t(preset.labelKey) : preset.label} </SelectItem> ))} </SelectGroup> diff --git a/apps/web/components/shared/mermaid-utils.ts b/apps/web/components/shared/mermaid-utils.ts index 64d47e0669..2b7800d1b7 100644 --- a/apps/web/components/shared/mermaid-utils.ts +++ b/apps/web/components/shared/mermaid-utils.ts @@ -46,6 +46,7 @@ export function reportMermaidRenderFailure( originalCode: string, normalizedCode: string, ): void { + // i18n-exempt: console diagnostics for the developer, never shown in the UI. const normalizedSection = normalizedCode === originalCode ? "" : `\n\nNormalized diagram:\n${normalizedCode}`; diff --git a/apps/web/components/task-create-dialog-branch-utils.test.ts b/apps/web/components/task-create-dialog-branch-utils.test.ts index c414888a93..12f8957ec5 100644 --- a/apps/web/components/task-create-dialog-branch-utils.test.ts +++ b/apps/web/components/task-create-dialog-branch-utils.test.ts @@ -1,57 +1,63 @@ -import { describe, it, expect } from "vitest"; +import { afterEach, describe, it, expect } from "vitest"; +import { i18n } from "@/lib/i18n"; import { + computeBranchIntent, computeBranchPrefix, computeBranchTooltip, computeBranchDisabledReason, } from "./task-create-dialog-branch-utils"; -describe("computeBranchPrefix", () => { - it("returns empty when no branch is picked yet", () => { +afterEach(async () => { + await i18n.changeLanguage("en"); +}); + +describe("computeBranchIntent", () => { + it('returns "none" when no branch is picked yet', () => { expect( - computeBranchPrefix({ + computeBranchIntent({ isLocalExecutor: true, rowBranch: "", currentLocalBranch: "main", freshBranchEnabled: false, }), - ).toBe(""); + ).toBe("none"); }); - it("returns 'current: ' for local executor when row branch matches workspace current", () => { + it('returns "current" for local executor when row branch matches workspace current', () => { expect( - computeBranchPrefix({ + computeBranchIntent({ isLocalExecutor: true, rowBranch: "main", currentLocalBranch: "main", freshBranchEnabled: false, }), - ).toBe("current: "); + ).toBe("current"); }); - it("returns 'current: ' on detached HEAD when the picked value is the same short SHA", () => { + it('returns "current" on detached HEAD when the picked value is the same short SHA', () => { // Backend returns a short SHA in `current_branch` when the repo is on // detached HEAD; the autoselect seeds row.branch with that same value // so the chip can read "current: <sha>" instead of falling through to // "will switch to: master". expect( - computeBranchPrefix({ + computeBranchIntent({ isLocalExecutor: true, rowBranch: "4fbc5d7", currentLocalBranch: "4fbc5d7", freshBranchEnabled: false, }), - ).toBe("current: "); + ).toBe("current"); }); - it("returns 'will switch to: ' for local executor when row branch differs from current", () => { + it('returns "switch" for local executor when row branch differs from current', () => { expect( - computeBranchPrefix({ + computeBranchIntent({ isLocalExecutor: true, rowBranch: "develop", currentLocalBranch: "main", freshBranchEnabled: false, }), - ).toBe("will switch to: "); + ).toBe("switch"); }); it("treats unknown current branch as 'will switch to' on local executor", () => { @@ -59,75 +65,96 @@ describe("computeBranchPrefix", () => { // we can't claim the picked branch is "current", so fall through to the // destructive label. expect( - computeBranchPrefix({ + computeBranchIntent({ isLocalExecutor: true, rowBranch: "main", currentLocalBranch: "", freshBranchEnabled: false, }), - ).toBe("will switch to: "); + ).toBe("switch"); }); - it("returns 'from: ' for non-local executors regardless of current branch", () => { + it('returns "from" for non-local executors regardless of current branch', () => { expect( - computeBranchPrefix({ + computeBranchIntent({ isLocalExecutor: false, rowBranch: "main", currentLocalBranch: "main", freshBranchEnabled: false, }), - ).toBe("from: "); + ).toBe("from"); expect( - computeBranchPrefix({ + computeBranchIntent({ isLocalExecutor: false, rowBranch: "develop", currentLocalBranch: "", freshBranchEnabled: false, }), - ).toBe("from: "); + ).toBe("from"); }); - it("returns 'from: ' when fork-a-new-branch is enabled, even on local executor", () => { + it('returns "from" when fork-a-new-branch is enabled, even on local executor', () => { expect( - computeBranchPrefix({ + computeBranchIntent({ isLocalExecutor: true, rowBranch: "main", currentLocalBranch: "main", freshBranchEnabled: true, }), - ).toBe("from: "); + ).toBe("from"); expect( - computeBranchPrefix({ + computeBranchIntent({ isLocalExecutor: true, rowBranch: "develop", currentLocalBranch: "main", freshBranchEnabled: true, }), - ).toBe("from: "); + ).toBe("from"); + }); +}); + +describe("computeBranchPrefix", () => { + it("renders the qualifier for each intent and nothing for none", () => { + expect(computeBranchPrefix("none")).toBe(""); + expect(computeBranchPrefix("current")).toBe("current: "); + expect(computeBranchPrefix("switch")).toBe("will switch to: "); + expect(computeBranchPrefix("from")).toBe("from: "); + }); + + it("resolves at call time, so a locale switch reaches the qualifier", async () => { + await i18n.changeLanguage("pseudo"); + expect(computeBranchPrefix("switch")).toMatch(/[^\p{ASCII}]/u); }); }); describe("computeBranchTooltip", () => { - it('returns the "current" tooltip for "current: " prefix', () => { - expect(computeBranchTooltip("current: ")).toContain("as-is"); + it('returns the "current" tooltip for the current intent', () => { + expect(computeBranchTooltip("current")).toContain("as-is"); }); - it('returns the "switch" tooltip for "will switch to: " prefix', () => { - expect(computeBranchTooltip("will switch to: ")).toContain("git checkout"); + it('returns the "switch" tooltip for the switch intent', () => { + expect(computeBranchTooltip("switch")).toContain("git checkout"); }); - it('returns the "fork" tooltip for "from: " prefix', () => { - expect(computeBranchTooltip("from: ")).toContain("forked"); + it('returns the "fork" tooltip for the from intent', () => { + expect(computeBranchTooltip("from")).toContain("forked"); }); - it("returns a generic tooltip for empty or undefined prefix", () => { - expect(computeBranchTooltip("")).toContain("Branch the agent will run against"); + it("returns a generic tooltip for none or undefined", () => { + expect(computeBranchTooltip("none")).toContain("Branch the agent will run against"); expect(computeBranchTooltip(undefined)).toContain("Branch the agent will run against"); }); - it("returns a generic tooltip for an unrecognized prefix", () => { - // Defensive: unknown prefix shouldn't crash, just falls back. - expect(computeBranchTooltip("foo: ")).toContain("Branch the agent will run against"); + it("keeps prefix and tooltip paired under a non-English locale", async () => { + // The regression this guards: the tooltip used to be selected by matching + // the prefix STRING with `===`. Translating the prefix would have made every + // branch miss and collapsed all three tooltips to the generic one. + await i18n.changeLanguage("pseudo"); + const specific = new Set( + (["current", "switch", "from"] as const).map((intent) => computeBranchTooltip(intent)), + ); + expect(specific.size).toBe(3); + expect(specific.has(computeBranchTooltip("none"))).toBe(false); }); }); diff --git a/apps/web/components/task-create-dialog-branch-utils.ts b/apps/web/components/task-create-dialog-branch-utils.ts index 6ff2b36df7..1e5eee1b7f 100644 --- a/apps/web/components/task-create-dialog-branch-utils.ts +++ b/apps/web/components/task-create-dialog-branch-utils.ts @@ -2,25 +2,35 @@ * Pure helpers for the branch chip in the task-create dialog. * * Extracted from `task-create-dialog-repo-chips.tsx` to keep that file under - * the 600-line cap. No React deps — these are string functions tested - * directly in `task-create-dialog-branch-utils.test.ts`. + * the 600-line cap. Tested directly in `task-create-dialog-branch-utils.test.ts`. */ +import { t } from "@/lib/i18n"; /** - * Decide the muted text shown before the branch value to qualify intent. - * "current: " — local exec, picked branch matches the current checkout - * (also covers detached HEAD where the "branch" is the - * short SHA returned by the backend). - * "will switch to: " — local exec, picked branch differs from current. - * Surfaces the destructive `git checkout` decision the - * backend will make on submit. - * "from: " — fork mode (forking a new branch off this base) or - * non-local executors (worktree base). + * What the picked branch means for this row. * - * Returns "" when there's no value yet, so the chip's placeholder ("branch") - * doesn't get a misleading prefix. + * This used to be the prefix STRING itself: `computeBranchPrefix` returned + * `"will switch to: "` and `computeBranchTooltip` matched it with `===`. That + * made the qualifier both display copy and a comparison token, so translating + * it would have silently collapsed every tooltip to the generic fallback in + * every non-English locale. The discriminant carries the meaning; the copy + * hangs off it. */ -export function computeBranchPrefix({ +export type BranchIntent = "none" | "current" | "switch" | "from"; + +/** + * Decide what the picked branch means, so the chip can qualify it. + * "current" — local exec, picked branch matches the current checkout + * (also covers detached HEAD where the "branch" is the short SHA + * returned by the backend). + * "switch" — local exec, picked branch differs from current. Surfaces the + * destructive `git checkout` decision the backend makes on submit. + * "from" — fork mode (forking a new branch off this base) or non-local + * executors (worktree base). + * "none" — no value yet, so the chip's placeholder ("branch") does not get + * a misleading prefix. + */ +export function computeBranchIntent({ isLocalExecutor, rowBranch, currentLocalBranch, @@ -30,35 +40,40 @@ export function computeBranchPrefix({ rowBranch: string; currentLocalBranch: string; freshBranchEnabled: boolean; -}): string { - if (!rowBranch) return ""; - if (freshBranchEnabled) return "from: "; +}): BranchIntent { + if (!rowBranch) return "none"; + if (freshBranchEnabled) return "from"; if (isLocalExecutor) { - if (currentLocalBranch && rowBranch === currentLocalBranch) return "current: "; - return "will switch to: "; + if (currentLocalBranch && rowBranch === currentLocalBranch) return "current"; + return "switch"; } - return "from: "; + return "from"; } +const PREFIX_KEYS: Record<Exclude<BranchIntent, "none">, string> = { + current: "task:branchPrefixCurrent", + switch: "task:branchPrefixWillSwitchTo", + from: "task:branchPrefixFrom", +}; + +/** Muted text shown before the branch value, or "" when there is nothing to qualify. */ +export function computeBranchPrefix(intent: BranchIntent): string { + return intent === "none" ? "" : t(PREFIX_KEYS[intent]); +} + +const TOOLTIP_KEYS: Record<Exclude<BranchIntent, "none">, string> = { + current: "task:branchTooltipCurrent", + switch: "task:branchTooltipWillSwitchTo", + from: "task:branchTooltipFrom", +}; + /** - * Branch chip hover tooltip, picked to match whatever prefix the chip is - * currently showing so the explanation lines up with the qualifier text. - * "current: " → no git ops will run; agent uses your existing checkout - * "will switch to: " → backend runs `git checkout` before the agent starts - * "from: " → a new branch / worktree is forked off this base - * "" → no branch picked yet; generic hint + * Branch chip hover tooltip, matched to the intent so the explanation lines up + * with the qualifier the chip is showing. */ -export function computeBranchTooltip(branchPrefix: string | undefined): string { - if (branchPrefix === "current: ") { - return "Your repository's current checkout. The agent runs against it as-is, no git operations."; - } - if (branchPrefix === "will switch to: ") { - return "The backend will run `git checkout` to switch your repository to this branch before the agent starts."; - } - if (branchPrefix === "from: ") { - return "Base branch. A new branch (or worktree) is forked from here and the agent runs there."; - } - return "Branch the agent will run against."; +export function computeBranchTooltip(intent: BranchIntent | undefined): string { + if (!intent || intent === "none") return t("task:branchTooltipGeneric"); + return t(TOOLTIP_KEYS[intent]); } export function computeBranchDisabledReason({ @@ -72,11 +87,9 @@ export function computeBranchDisabledReason({ branchesLoading: boolean; optionCount: number; }): string | undefined { - if (branchLocked) { - return "The local executor uses your repository's current checkout, so the branch can't change here. Toggle 'Fork a new branch' to pick a different base."; - } - if (!hasRepo) return "Select a repository first."; - if (branchesLoading) return "Loading branches…"; - if (optionCount === 0) return "No branches available for this repository."; + if (branchLocked) return t("task:branchDisabledLocalExecutor"); + if (!hasRepo) return t("task:branchDisabledSelectRepositoryFirst"); + if (branchesLoading) return t("task:loadingBranches"); + if (optionCount === 0) return t("task:branchDisabledNoBranches"); return undefined; } diff --git a/apps/web/components/task-create-dialog-computed.ts b/apps/web/components/task-create-dialog-computed.ts index e68483e156..689d05de89 100644 --- a/apps/web/components/task-create-dialog-computed.ts +++ b/apps/web/components/task-create-dialog-computed.ts @@ -26,6 +26,7 @@ import type { RemoteAuthSpec } from "@/lib/api/domains/settings-api"; import type { AgentProfileOption } from "@/lib/state/slices/settings/types"; import { isSelectableAgentProfile } from "@/lib/state/slices/settings/types"; import { getMultiRepoExecutorDisabledReason } from "@/components/task-create-dialog-multi-repo-guard"; +import { t } from "@/lib/i18n"; /** * Worktree executor needs a repository to create the worktree from. Disable @@ -34,7 +35,7 @@ import { getMultiRepoExecutorDisabledReason } from "@/components/task-create-dia */ function worktreeDisabledReason(profile: ExecutorProfile): string | null { if ((profile.executor_type ?? "") !== "worktree") return null; - return "Worktree executor requires a repository."; + return t("task:worktreeExecutorRequiresRepository"); } /** diff --git a/apps/web/components/task-create-dialog-pill.tsx b/apps/web/components/task-create-dialog-pill.tsx index 5467ddcfe4..40ce9b42c3 100644 --- a/apps/web/components/task-create-dialog-pill.tsx +++ b/apps/web/components/task-create-dialog-pill.tsx @@ -17,6 +17,7 @@ import type { Branch } from "@/lib/types/http"; import { BranchRefreshButton } from "@/components/branch-refresh-button"; import { useTaskCreateDialogPopoverContainer } from "@/hooks/use-task-create-dialog-popover-container"; import { useTooltipMountGate } from "@/hooks/use-tooltip-mount-gate"; +import { t } from "@/lib/i18n"; export type PillOption = { value: string; @@ -621,6 +622,6 @@ export function computeBranchPlaceholder( ): string { if (!hasRepo) return "branch"; if (loading) return "loading…"; - if (optionCount === 0) return "no branches"; + if (optionCount === 0) return t("task:noBranchesShort"); return "branch"; } diff --git a/apps/web/components/task-create-dialog-setup.ts b/apps/web/components/task-create-dialog-setup.ts index 0c1c7bbada..cbacc67234 100644 --- a/apps/web/components/task-create-dialog-setup.ts +++ b/apps/web/components/task-create-dialog-setup.ts @@ -25,8 +25,10 @@ import { import type { TaskCreateDialogProps } from "@/components/task-create-dialog"; import { useResolvedTaskCreateWorkflowContext } from "@/components/task-create-dialog-workflow-context"; import { truncateRemoteTaskTitle } from "@/lib/task-title"; +import { t } from "@/lib/i18n"; -const PROMPT_INSERTED_MESSAGE = "Enhanced prompt inserted."; +// Catalog key: module scope, so it is resolved at the call site. +const PROMPT_INSERTED_MESSAGE_KEY = "task:enhancedPromptInserted"; function useEnhanceForDialog( fs: DialogFormState, @@ -61,7 +63,7 @@ function useEnhanceForDialog( const generation = promptDelivery.captureScope(); void enhancePrompt(current, (result) => { const inserted = promptDelivery.deliver(current, result, generation); - if (inserted) toast({ description: PROMPT_INSERTED_MESSAGE, variant: "success" }); + if (inserted) toast({ description: t(PROMPT_INSERTED_MESSAGE_KEY), variant: "success" }); return inserted; }); }, [enhancePrompt, fs.descriptionInputRef, promptDelivery, toast]); diff --git a/apps/web/components/task-create-dialog-workspace-repo-chips.tsx b/apps/web/components/task-create-dialog-workspace-repo-chips.tsx index ea4d95aba4..d44eb78047 100644 --- a/apps/web/components/task-create-dialog-workspace-repo-chips.tsx +++ b/apps/web/components/task-create-dialog-workspace-repo-chips.tsx @@ -26,9 +26,11 @@ import { type PillOption, } from "@/components/task-create-dialog-pill"; import { + computeBranchIntent, computeBranchPrefix, computeBranchTooltip, computeBranchDisabledReason, + type BranchIntent, } from "@/components/task-create-dialog-branch-utils"; import { useRepoBranchAutoselect } from "@/components/task-create-dialog-repo-branch-autoselect"; import { useTranslation } from "react-i18next"; @@ -113,7 +115,7 @@ export function WorkspaceRepoChips({ preferredDefaultBranchLoading={isLocalExecutor ? currentLocalBranchLoading : false} lastUsedBranch={lastUsedBranch} userSettingsLoaded={userSettingsLoaded} - branchPrefix={computeBranchPrefix({ + branchIntent={computeBranchIntent({ isLocalExecutor: !!isLocalExecutor, rowBranch: row.branch, currentLocalBranch: currentLocalBranch ?? "", @@ -250,7 +252,7 @@ type RepoChipProps = { * Empty when there's no branch value yet (chip shows the "branch" * placeholder unprefixed). */ - branchPrefix?: string; + branchIntent?: BranchIntent; onRepositoryChange: (value: string) => void; onBranchChange: (value: string) => void; onRemove: () => void; @@ -398,7 +400,7 @@ function RepoChip({ preferredDefaultBranchLoading, lastUsedBranch, userSettingsLoaded, - branchPrefix, + branchIntent, onRepositoryChange, onBranchChange, onRemove, @@ -460,7 +462,7 @@ function RepoChip({ icon={<IconGitBranch className="h-3 w-3 shrink-0 text-muted-foreground" />} value={branchValue} placeholder={branchPlaceholder} - prefix={branchPrefix} + prefix={computeBranchPrefix(branchIntent ?? "none")} options={branchOptions} onSelect={onBranchChange} disabled={branchLocked || !hasRepo || branchesLoading || branchOptions.length === 0} @@ -473,7 +475,7 @@ function RepoChip({ searchPlaceholder={t("task:searchBranches")} emptyMessage={t("task:noBranches")} testId="branch-chip-trigger" - tooltip={computeBranchTooltip(branchPrefix)} + tooltip={computeBranchTooltip(branchIntent)} onRefresh={refreshBranches} refreshing={branchesLoading} filter={scoreBranch} diff --git a/apps/web/components/task/dockview-session-handoff.ts b/apps/web/components/task/dockview-session-handoff.ts index a99146ce0a..ea0e06f1cd 100644 --- a/apps/web/components/task/dockview-session-handoff.ts +++ b/apps/web/components/task/dockview-session-handoff.ts @@ -1,4 +1,5 @@ import type { AddPanelOptions, DockviewApi } from "dockview-react"; +import { t } from "@/lib/i18n"; type StaleSessionPanel = { id: string; @@ -20,7 +21,7 @@ export function ensureSessionPanel( id: `session:${sessionId}`, component: "chat", tabComponent: "sessionTab", - title: "Agent", + title: t("common:agent"), params: { sessionId }, position, inactive, diff --git a/apps/web/components/task/dockview-session-tabs.ts b/apps/web/components/task/dockview-session-tabs.ts index 5cf2b500c8..a2ab7eb3d7 100644 --- a/apps/web/components/task/dockview-session-tabs.ts +++ b/apps/web/components/task/dockview-session-tabs.ts @@ -20,6 +20,7 @@ import { shouldPreserveActivePanel, } from "./dockview-session-tab-activation"; import { anchorIncomingSessionPanel, ensureSessionPanel } from "./dockview-session-handoff"; +import { t } from "@/lib/i18n"; const debug = createDebugLogger("dockview:session-tabs"); @@ -128,7 +129,7 @@ export function setupChatPanelSafetyNet( id: `session:${activeSessionId}`, component: "chat", tabComponent: "sessionTab", - title: "Agent", + title: t("common:agent"), params: { sessionId: activeSessionId }, position, }); diff --git a/apps/web/components/task/editor-worktree-options.ts b/apps/web/components/task/editor-worktree-options.ts index 04ca065632..6afa376e4d 100644 --- a/apps/web/components/task/editor-worktree-options.ts +++ b/apps/web/components/task/editor-worktree-options.ts @@ -1,5 +1,6 @@ import type { Worktree } from "@/lib/state/slices/session/types"; import type { Repository } from "@/lib/types/http"; +import { t } from "@/lib/i18n"; export type WorktreeOption = { worktreeId: string; @@ -23,7 +24,9 @@ export function buildWorktreeOptions( return worktrees.map((worktree, index) => { const repository = repositories.find((repo) => repo.id === worktree.repositoryId); const label = - repository?.name ?? worktreePathBasename(worktree.path) ?? `Worktree ${index + 1}`; + repository?.name ?? + worktreePathBasename(worktree.path) ?? + t("task:worktreeNumbered", { index: index + 1 }); return { worktreeId: worktree.id, label, branch: worktree.branch }; }); } diff --git a/apps/web/components/task/executor-environment-status.ts b/apps/web/components/task/executor-environment-status.ts index c9823b6ecd..f26bb7415c 100644 --- a/apps/web/components/task/executor-environment-status.ts +++ b/apps/web/components/task/executor-environment-status.ts @@ -1,4 +1,5 @@ import type { ContainerLiveStatus, TaskEnvironment } from "@/lib/api/domains/task-environment-api"; +import { t } from "@/lib/i18n"; export type StatusTone = "running" | "stopped" | "warn" | "error" | "neutral"; @@ -16,7 +17,7 @@ export function getEnvironmentStatusSnapshot( container: ContainerLiveStatus | null, ): EnvironmentStatusSnapshot { if (!env) { - return { key: "none", label: "not created", tone: "neutral" }; + return { key: "none", label: t("task:environmentNotCreated"), tone: "neutral" }; } const status = resolveExecutorEnvironmentStatus(env, container); return { ...status, key: `${status.tone}:${status.label}` }; diff --git a/apps/web/components/task/file-browser-actions.ts b/apps/web/components/task/file-browser-actions.ts index 65c8ac1fb9..c3d0e9e44e 100644 --- a/apps/web/components/task/file-browser-actions.ts +++ b/apps/web/components/task/file-browser-actions.ts @@ -7,6 +7,7 @@ import type { FileTreeNode, FileContentResponse, OpenFileTab } from "@/lib/types import { getFilesPanelScrollPosition, setFilesPanelScrollPosition } from "@/lib/local-storage"; import type { useToast } from "@/components/toast-provider"; import type { useFileBrowserTree } from "./file-browser-hooks"; +import { t } from "@/lib/i18n"; export type FetchAndOpenFileOptions = { repo?: string; @@ -157,7 +158,7 @@ export async function fetchAndOpenFile( }); } catch (error) { if (signal?.aborted) return; - const reason = error instanceof Error ? error.message : "Unknown error"; - toast({ title: "Failed to open file", description: reason, variant: "error" }); + const reason = error instanceof Error ? error.message : t("common:unknownError"); + toast({ title: t("task:failedToOpenFile"), description: reason, variant: "error" }); } } diff --git a/apps/web/components/task/file-browser-path.ts b/apps/web/components/task/file-browser-path.ts index 0447f71fed..3aa00a4e3b 100644 --- a/apps/web/components/task/file-browser-path.ts +++ b/apps/web/components/task/file-browser-path.ts @@ -1,5 +1,6 @@ import type { TaskSession } from "@/lib/types/http"; import { getSessionWorkspacePath } from "@/lib/session-workspace-path"; +import { t } from "@/lib/i18n"; const HOME_PATH_PATTERN = /^\/(?:Users|home)\/[^/]+\//; @@ -37,6 +38,6 @@ export function resolveFileBrowserPaths({ return { fullPath, - displayPath: treeLoaded ? "Workspace root" : "", + displayPath: treeLoaded ? t("task:workspaceRoot") : "", }; } diff --git a/apps/web/components/task/file-browser-tree-loader.ts b/apps/web/components/task/file-browser-tree-loader.ts index 27eb65a141..97faa63364 100644 --- a/apps/web/components/task/file-browser-tree-loader.ts +++ b/apps/web/components/task/file-browser-tree-loader.ts @@ -9,6 +9,7 @@ import { removeFailedExpansions, } from "./file-browser-restore"; import type { LoadState } from "./file-browser-hooks"; +import { t } from "@/lib/i18n"; const debugLoad = createDebugLogger("file-browser:load"); const MAX_RETRY_ATTEMPTS = 4; @@ -70,7 +71,7 @@ function scheduleRetry({ retry: () => void; }) { if (!isCurrentLoad()) return; - const message = error instanceof Error ? error.message : "Failed to load file tree"; + const message = error instanceof Error ? error.message : t("task:failedToLoadFileTree"); setLoadError(message); if (retryAttemptRef.current >= MAX_RETRY_ATTEMPTS) { setLoadState("manual"); diff --git a/apps/web/components/task/new-session-form-actions.ts b/apps/web/components/task/new-session-form-actions.ts index e96e67ee67..108d1306d6 100644 --- a/apps/web/components/task/new-session-form-actions.ts +++ b/apps/web/components/task/new-session-form-actions.ts @@ -101,7 +101,7 @@ export function useSessionLaunchSubmit({ activateSession( response.session_id, taskId, - profile?.label ?? "Agent", + profile?.label ?? t("common:agent"), groupId, setActiveSession, ); diff --git a/apps/web/components/task/recent-task-switcher-model.test.ts b/apps/web/components/task/recent-task-switcher-model.test.ts index fa512f3879..0f36a56492 100644 --- a/apps/web/components/task/recent-task-switcher-model.test.ts +++ b/apps/web/components/task/recent-task-switcher-model.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; +import { i18n, t } from "@/lib/i18n"; import type { RecentTaskEntry } from "@/lib/recent-tasks"; import { repositoryId as toRepositoryId, @@ -124,6 +125,10 @@ function buildContext(): RecentTaskBuildContext { }; } +afterEach(async () => { + await i18n.changeLanguage("en"); +}); + describe("recent task switcher model", () => { it("starts selection on the first non-current task when possible", () => { const items = [ @@ -172,13 +177,15 @@ describe("recent task switcher model", () => { }); it("maps task and session states to compact status badges", () => { - expect(getTaskStatusBadge("IN_PROGRESS", "RUNNING")).toMatchObject({ label: "Running" }); - expect(getTaskStatusBadge("REVIEW", "WAITING_FOR_INPUT")).toMatchObject({ - label: "Turn Finished", + expect(getTaskStatusBadge("IN_PROGRESS", "RUNNING")).toMatchObject({ + labelKey: "task:sessionStatusRunning", }); - expect(getTaskStatusBadge("TODO", undefined)).toMatchObject({ label: "Todo" }); + expect(getTaskStatusBadge("REVIEW", "WAITING_FOR_INPUT")).toMatchObject({ + labelKey: "task:sessionStatusTurnFinished", + }); + expect(getTaskStatusBadge("TODO", undefined)).toMatchObject({ labelKey: "task:statusTodo" }); expect(getTaskStatusBadge("FAILED", "FAILED")).toMatchObject({ - label: "Failed", + labelKey: "task:sessionStatusFailed", variant: "destructive", }); }); @@ -197,7 +204,7 @@ describe("recent task switcher model", () => { workflowName: "Main Flow", workflowStepTitle: "Working", }); - expect(display[0]?.statusBadge.label).toBe("Running"); + expect(display[0]?.statusBadge.labelKey).toBe("task:sessionStatusRunning"); expect(display[1]).toMatchObject({ taskId: PREVIOUS_TASK_ID, @@ -227,3 +234,19 @@ describe("recent task switcher model", () => { }); }); }); + +describe("status badge localization", () => { + it("emits catalog keys that resolve to the badge copy", () => { + expect(t(getTaskStatusBadge("IN_PROGRESS", "RUNNING").labelKey)).toBe("Running"); + expect(t(getTaskStatusBadge("REVIEW", "WAITING_FOR_INPUT").labelKey)).toBe("Turn Finished"); + expect(t(getTaskStatusBadge("TODO", undefined).labelKey)).toBe("Todo"); + expect(t(getTaskStatusBadge(undefined, undefined).labelKey)).toBe("Backlog"); + }); + + it("keeps badge copy out of module scope, so a locale switch reaches it", async () => { + await i18n.changeLanguage("pseudo"); + // The table is built at import time. Resolving `t()` there would have + // frozen these at "en" and this would still read "Running". + expect(t(getTaskStatusBadge("IN_PROGRESS", "RUNNING").labelKey)).toMatch(/[^\p{ASCII}]/u); + }); +}); diff --git a/apps/web/components/task/recent-task-switcher-model.ts b/apps/web/components/task/recent-task-switcher-model.ts index e8adf8844b..d2787af2a3 100644 --- a/apps/web/components/task/recent-task-switcher-model.ts +++ b/apps/web/components/task/recent-task-switcher-model.ts @@ -1,3 +1,4 @@ +import { t } from "@/lib/i18n"; import type { RecentTaskEntry } from "@/lib/recent-tasks"; import type { KanbanState, WorkflowSnapshotData } from "@/lib/state/slices/kanban/types"; import type { Repository, TaskSession, TaskSessionState, TaskState } from "@/lib/types/http"; @@ -28,7 +29,14 @@ export type RecentTaskBuildContext = { }; export type RecentTaskBadge = { - label: string; + /** + * Catalog key, resolved by `TaskBadges` in `recent-task-switcher.tsx`. + * + * Held as a key rather than a resolved string because these tables are + * module-scope: a `t()` here would run once at import and pin the badge copy + * to the boot locale. + */ + labelKey: string; variant: "default" | "secondary" | "outline" | "destructive"; }; @@ -65,8 +73,6 @@ type DisplayResolution = { workflowId: string | undefined; }; -const UNTITLED_TASK = "Untitled task"; - function getWorkflowName(workflowId: string | undefined, ctx: RecentTaskBuildContext) { if (!workflowId) return undefined; return ctx.workflows.find((workflow) => workflow.id === workflowId)?.name; @@ -139,20 +145,20 @@ function getResolvedSessionState( } const TASK_STATUS_BADGES: Partial<Record<TaskState, RecentTaskBadge>> = { - REVIEW: { label: "Review", variant: "secondary" }, - COMPLETED: { label: "Done", variant: "secondary" }, - IN_PROGRESS: { label: "In Progress", variant: "default" }, - SCHEDULING: { label: "In Progress", variant: "default" }, - BLOCKED: { label: "Blocked", variant: "destructive" }, - TODO: { label: "Todo", variant: "outline" }, - CREATED: { label: "New", variant: "outline" }, + REVIEW: { labelKey: "task:review", variant: "secondary" }, + COMPLETED: { labelKey: "task:statusDone", variant: "secondary" }, + IN_PROGRESS: { labelKey: "task:statusInProgress", variant: "default" }, + SCHEDULING: { labelKey: "task:statusInProgress", variant: "default" }, + BLOCKED: { labelKey: "task:statusBlocked", variant: "destructive" }, + TODO: { labelKey: "task:statusTodo", variant: "outline" }, + CREATED: { labelKey: "task:new", variant: "outline" }, }; const SESSION_STATUS_BADGES: Partial<Record<TaskSessionState, RecentTaskBadge>> = { - RUNNING: { label: "Running", variant: "default" }, - STARTING: { label: "Starting", variant: "default" }, - WAITING_FOR_INPUT: { label: "Turn Finished", variant: "secondary" }, - COMPLETED: { label: "Done", variant: "secondary" }, + RUNNING: { labelKey: "task:sessionStatusRunning", variant: "default" }, + STARTING: { labelKey: "task:sessionStatusStarting", variant: "default" }, + WAITING_FOR_INPUT: { labelKey: "task:sessionStatusTurnFinished", variant: "secondary" }, + COMPLETED: { labelKey: "task:statusDone", variant: "secondary" }, }; export function getTaskStatusBadge( @@ -160,16 +166,16 @@ export function getTaskStatusBadge( sessionState?: TaskSessionState | null, ): RecentTaskBadge { if (sessionState === "FAILED" || taskState === "FAILED") { - return { label: "Failed", variant: "destructive" }; + return { labelKey: "task:sessionStatusFailed", variant: "destructive" }; } if (sessionState === "CANCELLED" || taskState === "CANCELLED") { - return { label: "Cancelled", variant: "outline" }; + return { labelKey: "task:sessionStatusCancelled", variant: "outline" }; } const sessionBadge = sessionState ? SESSION_STATUS_BADGES[sessionState] : undefined; if (sessionBadge) return sessionBadge; const taskBadge = taskState ? TASK_STATUS_BADGES[taskState] : undefined; if (taskBadge) return taskBadge; - return { label: "Backlog", variant: "outline" }; + return { labelKey: "task:statusBacklog", variant: "outline" }; } function resolveDisplay(entry: RecentTaskEntry, ctx: RecentTaskBuildContext): DisplayResolution { @@ -255,7 +261,7 @@ function fallbackRecentEntry( previous: RecentTaskEntry | undefined, visitedAt: string | undefined, ): RecentTaskEntry { - return previous ?? { taskId, title: UNTITLED_TASK, visitedAt: visitedAt ?? nowIso() }; + return previous ?? { taskId, title: t("common:untitledTask"), visitedAt: visitedAt ?? nowIso() }; } function nowIso(): string { @@ -303,7 +309,7 @@ function buildEntryCore( ) { return { taskId, - title: displayItem?.title ?? previous?.title ?? UNTITLED_TASK, + title: displayItem?.title ?? previous?.title ?? t("common:untitledTask"), visitedAt: visitedAt ?? previous?.visitedAt ?? nowIso(), }; } diff --git a/apps/web/components/task/recent-task-switcher.tsx b/apps/web/components/task/recent-task-switcher.tsx index 292bcc3a2f..3a99d17db9 100644 --- a/apps/web/components/task/recent-task-switcher.tsx +++ b/apps/web/components/task/recent-task-switcher.tsx @@ -27,7 +27,7 @@ function TaskBadges({ item }: { item: RecentTaskDisplayItem }) { data-testid="recent-task-switcher-badge-status" className="max-w-full" > - {item.statusBadge.label} + {t(item.statusBadge.labelKey)} </Badge> {item.repositoryPath && ( <Badge diff --git a/apps/web/components/task/review-panel-provider.ts b/apps/web/components/task/review-panel-provider.ts index b64f2664af..7e8b3387e7 100644 --- a/apps/web/components/task/review-panel-provider.ts +++ b/apps/web/components/task/review-panel-provider.ts @@ -6,6 +6,7 @@ import { usePluginRegistry, type PluginReviewProviderRegistration } from "@/lib/ import type { ReviewItemSummary } from "@/lib/plugins/types"; import type { TaskMR } from "@/lib/types/gitlab"; import type { TaskPR } from "@/lib/types/github"; +import { t } from "@/lib/i18n"; /** Provider IDs are registry-owned strings; GitHub and GitLab are built-in adapters. */ export type ReviewProvider = string; @@ -17,7 +18,7 @@ function githubReviewItem(pr: TaskPR): ReviewItemSummary { return { providerId: "github", reviewKey: prTaskKey(pr), - title: pr.pr_title || `Pull Request #${pr.pr_number}`, + title: pr.pr_title || t("github:pullRequestNumbered", { number: pr.pr_number }), url: pr.pr_url, connectionScope: reviewConnectionScope(pr.pr_url, "github"), repositoryId: pr.repository_id || `${pr.owner}/${pr.repo}`, diff --git a/apps/web/components/task/session-context-summary.ts b/apps/web/components/task/session-context-summary.ts index cdb32f816a..189ea9eb7c 100644 --- a/apps/web/components/task/session-context-summary.ts +++ b/apps/web/components/task/session-context-summary.ts @@ -1,5 +1,6 @@ import type { RefObject } from "react"; import type { SummarizeSessionResult } from "@/hooks/use-summarize-session"; +import { t } from "@/lib/i18n"; type PromptValueTarget = { value: string } | { setValue: (value: string) => void }; type PromptValueRef = RefObject<PromptValueTarget | null>; @@ -43,10 +44,8 @@ export function applySummarizeSessionResult({ setPromptValue(""); setHasPrompt(false); toast({ - title: "Summarize failed", - description: - result.error ?? - "Could not generate a summary. Check that the summarize utility agent is configured and enabled in settings.", + title: t("task:summarizeFailed"), + description: result.error ?? t("task:summarizeCouldNotGenerate"), variant: "error", }); return; diff --git a/apps/web/components/task/session-sort.ts b/apps/web/components/task/session-sort.ts index 995b72e29d..8bec439db4 100644 --- a/apps/web/components/task/session-sort.ts +++ b/apps/web/components/task/session-sort.ts @@ -1,5 +1,6 @@ import type { TaskSession, TaskSessionState } from "@/lib/types/http"; import type { AgentProfileOption } from "@/lib/state/slices"; +import { t } from "@/lib/i18n"; const STATUS_ORDER: Record<TaskSessionState, number> = { RUNNING: 1, @@ -46,7 +47,7 @@ export function resolveAgentLabelFor( if (storeLabel) return storeLabel; const snapshotLabel = (session.agent_profile_snapshot?.label as string | undefined) ?? null; if (snapshotLabel) return snapshotLabel; - return "Unknown agent"; + return t("task:unknownAgent"); } export function pickActiveSessionId( diff --git a/apps/web/components/task/sidebar-mock-data.ts b/apps/web/components/task/sidebar-mock-data.ts index 45d8f0bece..fc3efa3035 100644 --- a/apps/web/components/task/sidebar-mock-data.ts +++ b/apps/web/components/task/sidebar-mock-data.ts @@ -10,6 +10,8 @@ const hrs = (h: number) => new Date(n - h * 60 * 60 * 1000).toISOString(); const base = { primarySessionId: null as null, isArchived: false } as const; /* prettier-ignore */ +// i18n-exempt: prototype fixture behind MOCK_SIDEBAR, which ships false; the +// titles stand in for user-authored task names. export const MOCK_ITEMS: TaskSwitcherItem[] = [ { ...base, id: "mock-1", title: "Full stack authentication migration", state: "IN_PROGRESS", sessionState: "RUNNING", repositories: [MOCK_REPO, "kdlbs/kandev-web", "kdlbs/infra"], diffStats: { additions: 88, deletions: 12 }, updatedAt: mins(2), createdAt: hrs(3) }, { ...base, id: "mock-1a", title: "Migrate auth endpoints to new provider", state: "IN_PROGRESS", sessionState: "RUNNING", diffStats: { additions: 24, deletions: 8 }, parentTaskId: "mock-1", updatedAt: mins(1), createdAt: hrs(2) }, diff --git a/apps/web/components/task/task-cleanup-summary.test.ts b/apps/web/components/task/task-cleanup-summary.test.ts index a439080544..4225e11c76 100644 --- a/apps/web/components/task/task-cleanup-summary.test.ts +++ b/apps/web/components/task/task-cleanup-summary.test.ts @@ -1,8 +1,13 @@ -import { describe, it, expect } from "vitest"; +import { afterEach, describe, it, expect } from "vitest"; +import { i18n } from "@/lib/i18n"; import { getCleanupSummary, getBulkCleanupSummary } from "./task-cleanup-summary"; const AGENT_STOP_LINE = "Any running agent sessions will be stopped."; +afterEach(async () => { + await i18n.changeLanguage("en"); +}); + describe("getCleanupSummary (single task)", () => { it("reassures the user that local executor leaves the repo untouched", () => { const { lines } = getCleanupSummary("local"); @@ -119,3 +124,30 @@ describe("getBulkCleanupSummary", () => { expect(getBulkCleanupSummary([]).lines).toEqual([AGENT_STOP_LINE]); }); }); + +describe("localization", () => { + it("resolves at call time, so a locale switch changes the copy", async () => { + await i18n.changeLanguage("pseudo"); + const { lines } = getCleanupSummary("worktree"); + // Every line, not just the first — the generic trailer used to be a separate + // module constant and would have stayed English while the rest translated. + for (const line of lines) expect(line).toMatch(/[^\p{ASCII}]/u); + }); + + it("routes the bulk counts through catalog plurals rather than English suffixes", async () => { + await i18n.changeLanguage("pseudo"); + const { lines } = getBulkCleanupSummary(["worktree", "worktree"]); + // The count is interpolated, so it survives pseudo; the words around it must + // not. A locally-built `n === 1 ? "worktree" : "worktrees"` would leave the + // noun in plain English here. + const worktreeLine = lines.find((line) => line.includes("2")); + expect(worktreeLine).toBeDefined(); + expect(worktreeLine).not.toMatch(/worktrees/); + expect(worktreeLine).toMatch(/[^\p{ASCII}]/u); + }); + + it("picks the singular catalog form for a group of one", () => { + const { lines } = getBulkCleanupSummary(["sprites"]); + expect(lines[0]).toBe("1 Sprites sandbox will be destroyed (uncommitted work lost)."); + }); +}); diff --git a/apps/web/components/task/task-cleanup-summary.ts b/apps/web/components/task/task-cleanup-summary.ts index ba55bb2b28..3ce0003dea 100644 --- a/apps/web/components/task/task-cleanup-summary.ts +++ b/apps/web/components/task/task-cleanup-summary.ts @@ -1,4 +1,5 @@ // Executor types match models.ExecutorType in apps/backend/internal/task/models/models.go. +import { t } from "@/lib/i18n"; export type CleanupSummary = { lines: string[]; @@ -7,59 +8,67 @@ export type CleanupSummary = { // mock_remote is test-only — intentionally absent so it falls through to GENERIC_LINE. type KnownExecutor = "local" | "worktree" | "local_docker" | "remote_docker" | "sprites" | "ssh"; -const SINGLE: Record<KnownExecutor, string> = { - local: "The agent ran directly in your repo - your files, branch, and folder are not touched.", - worktree: - "The task's git worktree and its branch will be deleted. Your main repo and other branches are not affected.", - local_docker: - "The Docker container running this task will be stopped and removed. Your host repo is not touched.", - remote_docker: "The remote Docker container running this task will be stopped and removed.", - sprites: - "The Sprites cloud sandbox for this task will be destroyed. Any uncommitted work inside it will be lost.", - ssh: "The task directory on the remote host will be removed (best-effort). Your local repo is not touched.", +/** + * Catalog keys, not copy. + * + * Resolved in `getCleanupSummary` / `getBulkCleanupSummary`, which the delete + * and archive dialogs call from their render bodies — so a locale switch + * re-renders them and the lines follow. Holding `t()` results here instead would + * freeze this copy at the boot locale. + */ +const SINGLE_KEYS: Record<KnownExecutor, string> = { + local: "task:cleanupSingleLocal", + worktree: "task:cleanupSingleWorktree", + local_docker: "task:cleanupSingleLocalDocker", + remote_docker: "task:cleanupSingleRemoteDocker", + sprites: "task:cleanupSingleSprites", + ssh: "task:cleanupSingleSsh", }; -const GENERIC_LINE = "Any running agent sessions will be stopped."; +const GENERIC_KEY = "task:cleanupAgentSessionsStopped"; function normalize(executorType: string | null | undefined): KnownExecutor | null { if (!executorType) return null; const key = executorType.toLowerCase(); - if (key in SINGLE) return key as KnownExecutor; + if (key in SINGLE_KEYS) return key as KnownExecutor; return null; } /** Single-task variant. */ export function getCleanupSummary(executorType: string | null | undefined): CleanupSummary { + const generic = t(GENERIC_KEY); const known = normalize(executorType); - if (!known) return { lines: [GENERIC_LINE] }; - return { lines: [SINGLE[known], GENERIC_LINE] }; + if (!known) return { lines: [generic] }; + return { lines: [t(SINGLE_KEYS[known]), generic] }; } -const PLURAL: Partial<Record<KnownExecutor, (n: number) => string>> = { - local: (n) => `${n} local ${pl(n, "task", "tasks")} - your repo folders won't be touched.`, - worktree: (n) => - `${n} ${pl(n, "worktree", "worktrees")} and ${pl(n, "its branch", "their branches")} will be deleted.`, - local_docker: (n) => `${n} Docker ${pl(n, "container", "containers")} will be removed.`, - remote_docker: (n) => `${n} remote Docker ${pl(n, "container", "containers")} will be removed.`, - sprites: (n) => - `${n} Sprites ${pl(n, "sandbox", "sandboxes")} will be destroyed (uncommitted work lost).`, - ssh: (n) => - `${n} remote task ${pl(n, "directory", "directories")} will be removed (best-effort).`, +/** + * Catalog keys for the grouped variant. + * + * Each resolves with `{ count }` against `_one`/`_other` entries, so the + * singular/plural split is the catalog's decision. The previous version built it + * here with a local `pl(n, "worktree", "worktrees")` helper — English-only by + * construction, and wrong in every locale whose plural rules are not English's. + */ +const PLURAL_KEYS: Record<KnownExecutor, string> = { + local: "task:cleanupBulkLocal", + worktree: "task:cleanupBulkWorktree", + local_docker: "task:cleanupBulkLocalDocker", + remote_docker: "task:cleanupBulkRemoteDocker", + sprites: "task:cleanupBulkSprites", + ssh: "task:cleanupBulkSsh", }; -function pl(n: number, one: string, many: string): string { - return n === 1 ? one : many; -} - /** Bulk variant — groups N tasks by executor type and emits one line per group. */ export function getBulkCleanupSummary( executorTypes: Array<string | null | undefined>, ): CleanupSummary { - if (executorTypes.length === 0) return { lines: [GENERIC_LINE] }; + const generic = t(GENERIC_KEY); + if (executorTypes.length === 0) return { lines: [generic] }; const counts = new Map<KnownExecutor, number>(); - for (const t of executorTypes) { - const known = normalize(t); + for (const executorType of executorTypes) { + const known = normalize(executorType); if (!known) continue; counts.set(known, (counts.get(known) ?? 0) + 1); } @@ -77,9 +86,8 @@ export function getBulkCleanupSummary( for (const key of order) { const count = counts.get(key); if (!count) continue; - const fmt = PLURAL[key]; - if (fmt) lines.push(fmt(count)); + lines.push(t(PLURAL_KEYS[key], { count })); } - lines.push(GENERIC_LINE); + lines.push(generic); return { lines }; } diff --git a/apps/web/components/task/task-session-sidebar-link-actions.ts b/apps/web/components/task/task-session-sidebar-link-actions.ts index 2dba86af69..73f94bb9ee 100644 --- a/apps/web/components/task/task-session-sidebar-link-actions.ts +++ b/apps/web/components/task/task-session-sidebar-link-actions.ts @@ -17,6 +17,7 @@ import { useAppStore } from "@/components/state-provider"; import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; import { usePathname } from "@/lib/routing/client-router"; import type { ExternalLinkProvider } from "./task-external-link-dialog"; +import { t } from "@/lib/i18n"; type StoreApi = { getState: () => { @@ -198,7 +199,7 @@ export function useSidebarLinkActions(store: StoreApi) { const task = findTaskInSnapshots(taskId, state.kanbanMulti.snapshots, state.kanban.tasks); return { id: taskId, - title: task?.title ?? fallbackTitle ?? "this task", + title: task?.title ?? fallbackTitle ?? t("task:thisTask"), repositoryId: task?.repositoryId, issueUrl: task?.issueUrl, issueNumber: task?.issueNumber, diff --git a/apps/web/components/task/use-passthrough-terminal.ts b/apps/web/components/task/use-passthrough-terminal.ts index 9fa729c9f4..6843034770 100644 --- a/apps/web/components/task/use-passthrough-terminal.ts +++ b/apps/web/components/task/use-passthrough-terminal.ts @@ -148,6 +148,7 @@ function initTerminalInstance( scrollOnUserInput: true, scrollback: 5000, fontSize: options.fontSize || 13, + // i18n-exempt: CSS font-family stack, not copy. fontFamily: options.fontFamily || 'Menlo, Monaco, "Courier New", monospace', macOptionIsMeta: true, theme: getTerminalTheme(termContainer), diff --git a/apps/web/components/task/use-review-dialog.ts b/apps/web/components/task/use-review-dialog.ts index bffb9afbc9..672a6f01ae 100644 --- a/apps/web/components/task/use-review-dialog.ts +++ b/apps/web/components/task/use-review-dialog.ts @@ -23,6 +23,7 @@ import { generateUUID } from "@/lib/utils"; import { useToast } from "@/components/toast-provider"; import type { DiffComment } from "@/lib/diff/types"; import type { FileInfo, GitStatusEntry } from "@/lib/state/slices/session-runtime/types"; +import { t } from "@/lib/i18n"; type ReviewGitStatusFiles = { files: Record<string, FileInfo> | null; @@ -172,7 +173,7 @@ export function useReviewDialog(effectiveSessionId: string | null) { 10000, ) .catch(() => { - toast({ title: "Failed to send comments", variant: "error" }); + toast({ title: t("task:failedToSendComments"), variant: "error" }); }); setReviewDialogOpen(false); }, diff --git a/apps/web/components/task/use-subtask-submit.ts b/apps/web/components/task/use-subtask-submit.ts index 7090d23283..bd13270924 100644 --- a/apps/web/components/task/use-subtask-submit.ts +++ b/apps/web/components/task/use-subtask-submit.ts @@ -15,6 +15,7 @@ import { useUtilityAgentGenerator } from "@/hooks/use-utility-agent-generator"; import type { Repository } from "@/lib/types/http"; import type { SubtaskWorkspaceMode, useSubtaskFormState } from "./new-subtask-form-state"; import { toContextItems, useDialogAttachments } from "./session-dialog-shared"; +import { t } from "@/lib/i18n"; type UseSubtaskSubmitOpts = { fs: ReturnType<typeof useSubtaskFormState>; @@ -162,8 +163,8 @@ export function useSubtaskSubmit(opts: UseSubtaskSubmitOpts) { onClose(); } catch (error) { toast({ - title: "Failed to create subtask", - description: error instanceof Error ? error.message : "Unknown error", + title: t("task:failedToCreateSubtask"), + description: error instanceof Error ? error.message : t("common:unknownError"), variant: "error", }); } finally { @@ -252,7 +253,7 @@ export function useSubtaskPromptZone(opts: { await enhancePrompt(current, (enhanced) => { const delivered = promptResultDelivery.deliver(current, enhanced, generation); if (delivered) { - toast({ description: "Enhanced prompt applied.", variant: "success" }); + toast({ description: t("task:enhancedPromptApplied"), variant: "success" }); } return delivered; diff --git a/apps/web/components/task/use-terminal-search.ts b/apps/web/components/task/use-terminal-search.ts index 08dbf0d050..47bb8cb5e8 100644 --- a/apps/web/components/task/use-terminal-search.ts +++ b/apps/web/components/task/use-terminal-search.ts @@ -3,6 +3,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import type { Terminal } from "@xterm/xterm"; import type { SearchAddon } from "@xterm/addon-search"; +import { t } from "@/lib/i18n"; type UseTerminalSearchOptions = { xtermRef: React.RefObject<Terminal | null>; @@ -112,7 +113,7 @@ export function useTerminalSearch({ const { addon } = useSearchAddon(xtermRef, isTerminalReady, handleResults); const hasError = regex && query.length > 0 && !isValidRegex(query); - const errorText = hasError ? "Invalid regex" : undefined; + const errorText = hasError ? t("task:invalidRegex") : undefined; const buildOptions = useCallback( () => ({ diff --git a/apps/web/components/workspace-source-picker/workspace-source-state.ts b/apps/web/components/workspace-source-picker/workspace-source-state.ts index 7a0518b85a..40832ac1eb 100644 --- a/apps/web/components/workspace-source-picker/workspace-source-state.ts +++ b/apps/web/components/workspace-source-picker/workspace-source-state.ts @@ -5,6 +5,7 @@ import type { } from "@/lib/types/http"; import { getWorkspaceSourceCapabilities } from "./executor-capabilities"; import { looksLikeSupportedRemoteURL } from "./remote-url"; +import { t } from "@/lib/i18n"; export type WorkspaceSourceRow = { key: string; @@ -84,18 +85,18 @@ function validateRow( ): string | null { if (row.kind === "folder") { const path = canonicalPath(row.localPath); - if (!path) return "Choose a folder."; - if (folderPaths.has(path)) return "This folder is already selected."; + if (!path) return t("workspaces:sourceChooseAFolder"); + if (folderPaths.has(path)) return t("workspaces:sourceFolderAlreadySelected"); folderPaths.add(path); return null; } const locator = repositoryLocator(row); if (row.remoteUrl && !row.provider && !isValidRemoteUrl(row.remoteUrl)) { - return "Enter a valid remote repository URL and base branch."; + return t("workspaces:sourceEnterValidRemoteUrl"); } - if (!locator || !row.baseBranch?.trim()) return "Choose a repository and base branch."; + if (!locator || !row.baseBranch?.trim()) return t("workspaces:sourceChooseRepoAndBranch"); const duplicateKey = `${locator}\u0000${row.baseBranch.trim()}\u0000${row.checkoutBranch?.trim() ?? ""}`; - if (repositoryKeys.has(duplicateKey)) return "This repository and branch are already selected."; + if (repositoryKeys.has(duplicateKey)) return t("workspaces:sourceRepoBranchAlreadySelected"); repositoryKeys.add(duplicateKey); return null; } diff --git a/apps/web/eslint.config.mjs b/apps/web/eslint.config.mjs index f4e060ccf7..f2cc3128b3 100644 --- a/apps/web/eslint.config.mjs +++ b/apps/web/eslint.config.mjs @@ -5,7 +5,7 @@ import sonarjs from "eslint-plugin-sonarjs"; import unusedImports from "eslint-plugin-unused-imports"; import i18next from "eslint-plugin-i18next"; import tseslint from "typescript-eslint"; -import { i18nGuardFiles, noLiteralStringOptions } from "./eslint.i18n.options.mjs"; +import { noLiteralStringOptions } from "./eslint.i18n.options.mjs"; import { e2eSleepGuardFiles, e2eSleepPlugin, @@ -56,17 +56,33 @@ const eslintConfig = defineConfig([ ], }, }, - // Hardcoded user-facing strings. An ERROR, but only on the allowlist in - // eslint.i18n.options.mjs — the paths that render user-facing copy, which is - // where a regression is real. A repo-wide error would break every unrelated PR - // that lands a literal; a warning would let listed paths drift back. A PR - // appends to `i18nGuardFiles` when it adds such a path or externalizes one - // still off the list — `lib/sidebar` is one the screen sweep did not cover. + // Hardcoded user-facing strings. An ERROR, REPO-WIDE. + // + // This was scoped to `i18nGuardFiles` while the migration was in flight: a + // repo-wide error would have broken every unrelated PR that landed a literal + // in un-migrated code, which is what made the first attempt unmergeable. That + // reason has expired. Measured across all 2560 source files the rule reports + // ZERO violations, so widening it costs nothing today and permanently removes + // "the file was not on the list" as a way for copy to ship untranslated. + // + // `i18nGuardFiles` is kept: it is the migration's record, the scope + // `pnpm run lint:i18n` previews, and a ratchet `check-guard-allowlist.mjs` + // still enforces. It is no longer what bounds this rule. { - files: i18nGuardFiles, + files: ["**/*.ts", "**/*.tsx"], // Test files build fixtures out of literal strings on purpose; guarding them - // would force every `label="Tasks"` in a test through the catalog. - ignores: ["**/*.test.ts", "**/*.test.tsx", "e2e/**"], + // would force every `label="Tasks"` in a test through the catalog. The + // `*.test-helpers` / `*.test-utils` pair are imported only by tests and are + // the same class. + ignores: [ + "**/*.test.ts", + "**/*.test.tsx", + "**/*.test-helpers.ts", + "**/*.test-helpers.tsx", + "**/*.test-utils.ts", + "**/*.test-utils.tsx", + "e2e/**", + ], plugins: { i18next }, rules: { "i18next/no-literal-string": ["error", noLiteralStringOptions] }, }, diff --git a/apps/web/eslint.i18n.options.mjs b/apps/web/eslint.i18n.options.mjs index db4f39815f..f6571aecf0 100644 --- a/apps/web/eslint.i18n.options.mjs +++ b/apps/web/eslint.i18n.options.mjs @@ -1,3 +1,8 @@ +/* eslint-disable max-lines -- `i18nGuardFiles` below is a 638-entry data list, + not logic. Splitting it into another module would break + `check-guard-allowlist.mjs`, which imports the BASE revision of this single + file from a temp directory to diff the array; a relative import of a sibling + would not resolve there. */ /** * Options for `i18next/no-literal-string`, the guard against hardcoded * user-facing strings. Kept in its own module because the list is long enough to @@ -1933,4 +1938,139 @@ export const i18nGuardFiles = [ "hooks/use-sidebar-views-sync.ts", "hooks/use-task-workflow-move.ts", "lib/keyboard/shortcut-overrides.ts", + // Third audit: the non-JSX sweep. `check-nonjsx-copy.mjs` used to default its + // scope to THIS list, so 1141 of 2564 source files were judged by neither gate + // — and the positions eslint structurally cannot see (config tables, helper + // returns, parameter defaults, toast arguments) are exactly what lives in the + // plain `.ts` modules that never appear here. These are the files that sweep + // externalized or marked `i18n-exempt`. The scanner now defaults to the whole + // tree and `i18next/no-literal-string` is repo-wide, so this list is the + // migration record and the `lint:i18n` preview scope, no longer the bound. + "app/actions/agents.ts", + "app/demo/messages/demo-messages-data.ts", + "app/layout.tsx", + "app/stats/stats-data.tsx", + "app/stats/stats-utils.ts", + "app/tasks/rich-task-row-details.ts", + "components/combobox.tsx", + "components/debug-overlay.tsx", + "components/github/my-github/use-github-search.ts", + "components/gitlab/my-gitlab/presets.ts", + "components/gitlab/my-gitlab/use-gitlab-search.ts", + "components/improve-kandev-dialog-model.ts", + "components/jira/my-jira/filter-model.ts", + "components/jira/my-jira/use-saved-views.ts", + "components/kanban-card-edit-submenu.tsx", + "components/kanban/swimlane-container.tsx", + "components/kanban/task-search-input.tsx", + "components/mobile/pull-to-refresh.tsx", + "components/runs/automation-rows.ts", + "components/runs/run-feed-item.tsx", + "components/runs/use-automation-activity.ts", + "components/runs/use-automation-summaries.ts", + "components/runs/use-workspace-automations.ts", + "components/runs/use-workspace-runs.ts", + "components/settings/key-value-input.tsx", + "components/settings/lsp-language-cards.tsx", + "components/settings/settings-breadcrumb-labels.ts", + "components/settings/settings-save-provider.tsx", + "components/settings/sleep-inhibition-settings.tsx", + "components/shared/mermaid-utils.ts", + "components/task-create-dialog-branch-utils.ts", + "components/task-create-dialog-computed.ts", + "components/task-create-dialog-pill.tsx", + "components/task-create-dialog-setup.ts", + "components/task/dockview-session-handoff.ts", + "components/task/dockview-session-tabs.ts", + "components/task/editor-worktree-options.ts", + "components/task/executor-environment-status.ts", + "components/task/file-browser-actions.ts", + "components/task/file-browser-path.ts", + "components/task/file-browser-tree-loader.ts", + "components/task/new-session-form-actions.ts", + "components/task/recent-task-switcher-model.ts", + "components/task/review-panel-provider.ts", + "components/task/session-context-summary.ts", + "components/task/session-sort.ts", + "components/task/sidebar-mock-data.ts", + "components/task/task-cleanup-summary.ts", + "components/task/task-session-sidebar-link-actions.ts", + "components/task/use-passthrough-terminal.ts", + "components/task/use-review-dialog.ts", + "components/task/use-subtask-submit.ts", + "components/task/use-terminal-search.ts", + "components/workspace-source-picker/workspace-source-state.ts", + "hooks/domains/azure-devops/use-azure-devops-watches.ts", + "hooks/domains/github/pr-commits-resource.ts", + "hooks/domains/github/use-github-app-registrations.ts", + "hooks/domains/github/use-pr-diff.ts", + "hooks/domains/github/use-pr-feedback.ts", + "hooks/domains/github/use-task-ci-options.ts", + "hooks/domains/gitlab/use-gitlab-issue-watches.ts", + "hooks/domains/gitlab/use-gitlab-review-watches.ts", + "hooks/domains/gitlab/use-mr-feedback.ts", + "hooks/domains/gitlab/use-task-mr-automation.ts", + "hooks/domains/jira/use-jira-issue-watches.ts", + "hooks/domains/kanban/use-sidebar-archived-tasks.ts", + "hooks/domains/kanban/use-swimlane-move.ts", + "hooks/domains/linear/use-linear-issue-watches.ts", + "hooks/domains/office/use-agent-route.ts", + "hooks/domains/office/use-provider-health.ts", + "hooks/domains/office/use-routing-preview.ts", + "hooks/domains/office/use-run-attempts.ts", + "hooks/domains/office/use-workspace-routing.ts", + "hooks/domains/session/use-clarification-group.ts", + "hooks/domains/session/use-cumulative-diff.ts", + "hooks/domains/session/use-session-launch.ts", + "hooks/domains/session/use-session-messages.ts", + "hooks/domains/session/use-terminals-build.ts", + "hooks/use-detach-task.ts", + "hooks/use-drag-and-drop.ts", + "hooks/use-entity-reference-search.ts", + "hooks/use-git-operations.ts", + "hooks/use-inline-mention.ts", + "hooks/use-message-handler.ts", + "hooks/use-nav-availability.ts", + "hooks/use-optimistic-task-mutation.ts", + "hooks/use-prompt-result-delivery.ts", + "hooks/use-session-failure-toast.ts", + "hooks/use-task-deleted-toast.ts", + "hooks/use-terminal-link-handler.ts", + "hooks/use-workflow-steps.ts", + "lib/api/domains/attachment-api.ts", + "lib/api/domains/automation-api.ts", + "lib/api/domains/frontend-error-log-api.ts", + "lib/api/domains/github-api.ts", + "lib/api/domains/queue-api.ts", + "lib/api/domains/review-api.ts", + "lib/api/domains/user-shell-api.ts", + "lib/api/domains/walkthrough-api.ts", + "lib/kanban/view-registry.ts", + "lib/keyboard/constants.ts", + "lib/logger/intercept.ts", + "lib/lsp/lsp-progress-view.ts", + "lib/notifications/sound.ts", + "lib/plugins/change-request-creation.ts", + "lib/review/format.ts", + "lib/routing/client-route-helpers.ts", + "lib/settings-discovery/catalog/index.ts", + "lib/settings/external-mcp-snippets.ts", + "lib/ssr/session-page-state.ts", + "lib/state/dockview-env-switch.ts", + "lib/state/dockview-layout-builders.ts", + "lib/state/layout-manager/session-panels.ts", + "lib/state/slices/ui/sidebar-task-prefs-actions.ts", + "lib/state/slices/ui/sidebar-view-actions.ts", + "lib/tasks/tasks-list-options.ts", + "lib/terminal/terminal-font.ts", + "lib/theme/colors.ts", + "lib/utils/file-change-status.ts", + "lib/utils.ts", + "lib/walkthrough-request.ts", + "lib/ws/client.ts", + "lib/ws/handlers/agent-session.ts", + "lib/ws/handlers/empty-turn-notice.ts", + "lib/ws/handlers/notifications.ts", + "lib/ws/handlers/quick-chat.ts", + "lib/ws/use-websocket.tsx", ]; diff --git a/apps/web/hooks/domains/azure-devops/use-azure-devops-watches.ts b/apps/web/hooks/domains/azure-devops/use-azure-devops-watches.ts index f597bc85d6..00497f4886 100644 --- a/apps/web/hooks/domains/azure-devops/use-azure-devops-watches.ts +++ b/apps/web/hooks/domains/azure-devops/use-azure-devops-watches.ts @@ -40,6 +40,8 @@ type WatchState = { error: string | null; }; +// i18n-exempt: precondition diagnostic for a programmer error; callers branch +// on the error type, never render this message. const WORKSPACE_REQUIRED = "workspace is required"; // eslint-disable-next-line max-lines-per-function -- this hook exposes the complete CRUD and reset API for both watch kinds. diff --git a/apps/web/hooks/domains/github/pr-commits-resource.ts b/apps/web/hooks/domains/github/pr-commits-resource.ts index 3a93d871b7..7c0fedadea 100644 --- a/apps/web/hooks/domains/github/pr-commits-resource.ts +++ b/apps/web/hooks/domains/github/pr-commits-resource.ts @@ -1,5 +1,6 @@ import type { PRCommitInfo } from "@/lib/types/github"; import { getWebSocketClient } from "@/lib/ws/connection"; +import { t } from "@/lib/i18n"; export type PRCommitsState = { commits: PRCommitInfo[]; @@ -67,7 +68,7 @@ function identityFor(request: PRCommitsRequest): string { } function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : "Failed to fetch PR commits"; + return error instanceof Error ? error.message : t("github:failedToFetchPrCommits"); } async function requestPRCommits(request: PRCommitsRequest): Promise<PRCommitsResponse | null> { diff --git a/apps/web/hooks/domains/github/use-github-app-registrations.ts b/apps/web/hooks/domains/github/use-github-app-registrations.ts index 2d772f5746..5316c5a5b8 100644 --- a/apps/web/hooks/domains/github/use-github-app-registrations.ts +++ b/apps/web/hooks/domains/github/use-github-app-registrations.ts @@ -17,6 +17,7 @@ import type { PrepareGitHubAppImportRequest, StartGitHubAppManifestRequest, } from "@/lib/types/github"; +import { t } from "@/lib/i18n"; const requestVersions = new Map<string, number>(); @@ -31,7 +32,7 @@ function isCurrentRequest(workspaceId: string, version: number) { } function errorMessage(error: unknown) { - return error instanceof Error ? error.message : "GitHub App registrations are unavailable"; + return error instanceof Error ? error.message : t("github:appRegistrationsUnavailable"); } export function parseGitHubCallbackResult( diff --git a/apps/web/hooks/domains/github/use-pr-diff.ts b/apps/web/hooks/domains/github/use-pr-diff.ts index 478559aee3..3d1600f5ab 100644 --- a/apps/web/hooks/domains/github/use-pr-diff.ts +++ b/apps/web/hooks/domains/github/use-pr-diff.ts @@ -5,6 +5,7 @@ import { useAppStore } from "@/components/state-provider"; import { getWebSocketClient } from "@/lib/ws/connection"; import { createDebugLogger } from "@/lib/debug/log"; import type { PRDiffFile } from "@/lib/types/github"; +import { t } from "@/lib/i18n"; const debug = createDebugLogger("review:pr-diff"); @@ -62,7 +63,7 @@ async function fetchPRFiles( setState({ sourceKey, files, loading: false, error: null }); debug("fetch.success", { owner, repo, prNumber, fileCount: files.length }); } catch (err) { - const message = err instanceof Error ? err.message : "Failed to fetch PR files"; + const message = err instanceof Error ? err.message : t("github:failedToFetchPrFiles"); setState({ sourceKey, files: [], loading: false, error: message }); debug("fetch.error", { owner, repo, prNumber, error: message }); } diff --git a/apps/web/hooks/domains/github/use-pr-feedback.ts b/apps/web/hooks/domains/github/use-pr-feedback.ts index 3d700c10a3..0368f4db7a 100644 --- a/apps/web/hooks/domains/github/use-pr-feedback.ts +++ b/apps/web/hooks/domains/github/use-pr-feedback.ts @@ -3,6 +3,7 @@ import { useState, useEffect, useCallback, useReducer } from "react"; import { getPRFeedback } from "@/lib/api/domains/github-api"; import type { PRFeedback } from "@/lib/types/github"; +import { t } from "@/lib/i18n"; export type PRFeedbackState = { /** `<workspaceId>/<owner>/<repo>/<prNumber>` of the request `feedback` belongs to. */ @@ -90,7 +91,7 @@ export function usePRFeedback( dispatch({ type: "error", key, - message: err instanceof Error ? err.message : "Failed to fetch PR feedback", + message: err instanceof Error ? err.message : t("github:failedToFetchPrFeedback"), }); }); return () => { diff --git a/apps/web/hooks/domains/github/use-task-ci-options.ts b/apps/web/hooks/domains/github/use-task-ci-options.ts index 7a3c6f3172..31753a3916 100644 --- a/apps/web/hooks/domains/github/use-task-ci-options.ts +++ b/apps/web/hooks/domains/github/use-task-ci-options.ts @@ -7,9 +7,10 @@ import { } from "@/lib/api/domains/github-api"; import { useAppStore } from "@/components/state-provider"; import type { TaskCIAutomationPatch, TaskCIAutomationOptions } from "@/lib/types/github"; +import { t } from "@/lib/i18n"; function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : "Failed to load CI automation options."; + return error instanceof Error ? error.message : t("github:failedToLoadCiAutomationOptions"); } export function useTaskCIAutomationOptions(taskId: string | null) { diff --git a/apps/web/hooks/domains/gitlab/use-gitlab-issue-watches.ts b/apps/web/hooks/domains/gitlab/use-gitlab-issue-watches.ts index b459d32591..6b62cd1623 100644 --- a/apps/web/hooks/domains/gitlab/use-gitlab-issue-watches.ts +++ b/apps/web/hooks/domains/gitlab/use-gitlab-issue-watches.ts @@ -15,6 +15,8 @@ import { } from "@/lib/api/domains/gitlab-api"; import { useAppStore } from "@/components/state-provider"; +// i18n-exempt: precondition diagnostic for a programmer error; callers branch +// on the error type, never render this message. const WORKSPACE_REQUIRED = "workspaceId required"; function filterByWorkspace<T extends { workspace_id: string }>( diff --git a/apps/web/hooks/domains/gitlab/use-gitlab-review-watches.ts b/apps/web/hooks/domains/gitlab/use-gitlab-review-watches.ts index 57dabaf32b..a8fd47c547 100644 --- a/apps/web/hooks/domains/gitlab/use-gitlab-review-watches.ts +++ b/apps/web/hooks/domains/gitlab/use-gitlab-review-watches.ts @@ -15,6 +15,8 @@ import { } from "@/lib/api/domains/gitlab-api"; import { useAppStore } from "@/components/state-provider"; +// i18n-exempt: precondition diagnostic for a programmer error; callers branch +// on the error type, never render this message. const WORKSPACE_REQUIRED = "workspaceId required"; function filterByWorkspace<T extends { workspace_id: string }>( diff --git a/apps/web/hooks/domains/gitlab/use-mr-feedback.ts b/apps/web/hooks/domains/gitlab/use-mr-feedback.ts index 2cff0fd975..b390b89393 100644 --- a/apps/web/hooks/domains/gitlab/use-mr-feedback.ts +++ b/apps/web/hooks/domains/gitlab/use-mr-feedback.ts @@ -5,6 +5,7 @@ import { useCommentsStore, type PRFeedbackComment } from "@/lib/state/slices/com import { useToast } from "@/components/toast-provider"; import { getMRCommits, getMRFeedback, getMRFiles } from "@/lib/api/domains/gitlab-api"; import type { GitLabMRCommit, GitLabMRFeedback, GitLabMRFile } from "@/lib/types/gitlab"; +import { t } from "@/lib/i18n"; type State = { feedback: GitLabMRFeedback | null; @@ -96,7 +97,7 @@ export function useMRFeedback( .map((result) => result.reason instanceof Error ? result.reason.message - : "GitLab rejected the request", + : t("gitlab:gitlabRejectedTheRequest"), ); dispatch({ type: "loaded", @@ -111,7 +112,7 @@ export function useMRFeedback( if (requestGeneration.current === generation) { dispatch({ type: "failed", - error: error instanceof Error ? error.message : "Failed to load merge request", + error: error instanceof Error ? error.message : t("gitlab:failedToLoadMergeRequest"), }); } }); @@ -143,7 +144,7 @@ export function useAddMRFeedbackAsContext(sessionId: string, projectPath: string content, }; addComment(comment); - toast({ description: "Added merge request feedback to task context", variant: "success" }); + toast({ description: t("gitlab:addedMrFeedbackToTaskContext"), variant: "success" }); }, [addComment, mrIid, projectPath, sessionId, toast], ); diff --git a/apps/web/hooks/domains/gitlab/use-task-mr-automation.ts b/apps/web/hooks/domains/gitlab/use-task-mr-automation.ts index 1540f902e8..7bb98ed2ae 100644 --- a/apps/web/hooks/domains/gitlab/use-task-mr-automation.ts +++ b/apps/web/hooks/domains/gitlab/use-task-mr-automation.ts @@ -5,11 +5,12 @@ import { getTaskMRAutomation, updateTaskMRAutomation } from "@/lib/api/domains/g import { useAppStore, useAppStoreApi } from "@/components/state-provider"; import type { AppState } from "@/lib/state/store"; import type { TaskMRAutomationOptions, TaskMRAutomationPatch } from "@/lib/types/gitlab"; +import { t } from "@/lib/i18n"; type AppStoreApi = ReturnType<typeof useAppStoreApi>; function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : "Failed to load MR automation options."; + return error instanceof Error ? error.message : t("gitlab:failedToLoadMrAutomationOptions"); } // True when `requestId` is still the latest request this hook issued for diff --git a/apps/web/hooks/domains/jira/use-jira-issue-watches.ts b/apps/web/hooks/domains/jira/use-jira-issue-watches.ts index cc7b254002..be3ede25f8 100644 --- a/apps/web/hooks/domains/jira/use-jira-issue-watches.ts +++ b/apps/web/hooks/domains/jira/use-jira-issue-watches.ts @@ -16,6 +16,8 @@ import type { CreateJiraIssueWatchInput, UpdateJiraIssueWatchInput } from "@/lib // WORKSPACE_REQUIRED is thrown by per-row mutation callbacks when the // install-wide listing case forgets to forward the row's workspaceId // (the install-wide hook leaves workspaceId undefined to fetch all rows). +// i18n-exempt: precondition diagnostic for a programmer error; callers branch +// on the error type, never render this message. const WORKSPACE_REQUIRED = "workspaceId required"; /** diff --git a/apps/web/hooks/domains/kanban/use-sidebar-archived-tasks.ts b/apps/web/hooks/domains/kanban/use-sidebar-archived-tasks.ts index 68a479529e..235ed240ba 100644 --- a/apps/web/hooks/domains/kanban/use-sidebar-archived-tasks.ts +++ b/apps/web/hooks/domains/kanban/use-sidebar-archived-tasks.ts @@ -6,6 +6,7 @@ import type { Task } from "@/lib/types/http"; import type { KanbanState } from "@/lib/state/slices/kanban/types"; import { isCurrentWorkspaceContext } from "@/lib/state/workspace-context"; import { useForegroundRefresh } from "@/hooks/use-foreground-refresh"; +import { t } from "@/lib/i18n"; const ARCHIVED_PAGE_SIZE = 100; const EMPTY_ARCHIVED_TASKS: KanbanState["tasks"] = []; @@ -100,7 +101,7 @@ export function useSidebarArchivedTasks(workspaceId: string | null, enabled: boo } latest.setSidebarArchivedTasksError( workspaceId, - loadError instanceof Error ? loadError.message : "Failed to load archived tasks", + loadError instanceof Error ? loadError.message : t("sidebar:failedToLoadArchivedTasks"), ); } finally { const latest = store.getState(); diff --git a/apps/web/hooks/domains/kanban/use-swimlane-move.ts b/apps/web/hooks/domains/kanban/use-swimlane-move.ts index cfc3bf2788..ace0951032 100644 --- a/apps/web/hooks/domains/kanban/use-swimlane-move.ts +++ b/apps/web/hooks/domains/kanban/use-swimlane-move.ts @@ -4,6 +4,7 @@ import { useTaskActions } from "@/hooks/use-task-actions"; import type { Task } from "@/components/kanban-card"; import type { MoveTaskError } from "@/hooks/use-drag-and-drop"; import type { KanbanState } from "@/lib/state/slices/kanban/types"; +import { t } from "@/lib/i18n"; export function useSwimlaneMove( workflowId: string, @@ -60,7 +61,7 @@ export function useSwimlaneMove( tasks: originalTasks, }); } - const message = error instanceof Error ? error.message : "Failed to move task"; + const message = error instanceof Error ? error.message : t("task:failedToMoveTask"); opts.onMoveError?.({ message, taskId: task.id, diff --git a/apps/web/hooks/domains/linear/use-linear-issue-watches.ts b/apps/web/hooks/domains/linear/use-linear-issue-watches.ts index de5c630446..76ca642bd4 100644 --- a/apps/web/hooks/domains/linear/use-linear-issue-watches.ts +++ b/apps/web/hooks/domains/linear/use-linear-issue-watches.ts @@ -15,6 +15,8 @@ import type { CreateLinearIssueWatchInput, UpdateLinearIssueWatchInput } from "@ // WORKSPACE_REQUIRED is thrown by per-row mutation callbacks when the // install-wide listing case forgets to forward the row's workspaceId. +// i18n-exempt: precondition diagnostic for a programmer error; callers branch +// on the error type, never render this message. const WORKSPACE_REQUIRED = "workspaceId required"; /** diff --git a/apps/web/hooks/domains/office/use-agent-route.ts b/apps/web/hooks/domains/office/use-agent-route.ts index 4bdcb014c8..5b5efbc2c5 100644 --- a/apps/web/hooks/domains/office/use-agent-route.ts +++ b/apps/web/hooks/domains/office/use-agent-route.ts @@ -4,6 +4,7 @@ import { useCallback, useEffect, useState } from "react"; import { useAppStore } from "@/components/state-provider"; import { getAgentRoute, updateAgentRouting } from "@/lib/api/domains/office-extended-api"; import type { AgentRouteData, AgentRoutingOverrides } from "@/lib/state/slices/office/types"; +import { t } from "@/lib/i18n"; export type UseAgentRouteResult = { data: AgentRouteData | undefined; @@ -27,7 +28,7 @@ export function useAgentRoute(agentId: string | null): UseAgentRouteResult { const res = await getAgentRoute(agentId); setAgentRouting(agentId, res); } catch (e) { - setError(e instanceof Error ? e.message : "Failed to load agent route"); + setError(e instanceof Error ? e.message : t("office:failedToLoadAgentRoute")); } finally { setIsLoading(false); } diff --git a/apps/web/hooks/domains/office/use-provider-health.ts b/apps/web/hooks/domains/office/use-provider-health.ts index a289a9a6a4..98aa83c665 100644 --- a/apps/web/hooks/domains/office/use-provider-health.ts +++ b/apps/web/hooks/domains/office/use-provider-health.ts @@ -4,6 +4,7 @@ import { useCallback, useEffect, useState } from "react"; import { useAppStore } from "@/components/state-provider"; import { getProviderHealth } from "@/lib/api/domains/office-extended-api"; import type { ProviderHealth } from "@/lib/state/slices/office/types"; +import { t } from "@/lib/i18n"; export type UseProviderHealthResult = { health: ProviderHealth[]; @@ -34,7 +35,7 @@ export function useProviderHealth(workspaceName: string | null): UseProviderHeal setProviderHealth(workspaceName, res.health ?? []); setFetched(true); } catch (e) { - setError(e instanceof Error ? e.message : "Failed to load provider health"); + setError(e instanceof Error ? e.message : t("office:failedToLoadProviderHealth")); } finally { setIsLoading(false); } diff --git a/apps/web/hooks/domains/office/use-routing-preview.ts b/apps/web/hooks/domains/office/use-routing-preview.ts index 7c2a9c3380..8b89eef622 100644 --- a/apps/web/hooks/domains/office/use-routing-preview.ts +++ b/apps/web/hooks/domains/office/use-routing-preview.ts @@ -4,6 +4,7 @@ import { useCallback, useEffect, useState } from "react"; import { useAppStore } from "@/components/state-provider"; import { getRoutingPreview } from "@/lib/api/domains/office-extended-api"; import type { AgentRoutePreview } from "@/lib/state/slices/office/types"; +import { t } from "@/lib/i18n"; export type UseRoutingPreviewResult = { agents: AgentRoutePreview[]; @@ -34,7 +35,7 @@ export function useRoutingPreview(workspaceName: string | null): UseRoutingPrevi setRoutingPreview(workspaceName, res.agents ?? []); setFetched(true); } catch (e) { - setError(e instanceof Error ? e.message : "Failed to load routing preview"); + setError(e instanceof Error ? e.message : t("office:failedToLoadRoutingPreview")); } finally { setIsLoading(false); } diff --git a/apps/web/hooks/domains/office/use-run-attempts.ts b/apps/web/hooks/domains/office/use-run-attempts.ts index b47dc8789c..652534c2f4 100644 --- a/apps/web/hooks/domains/office/use-run-attempts.ts +++ b/apps/web/hooks/domains/office/use-run-attempts.ts @@ -4,6 +4,7 @@ import { useCallback, useEffect, useState } from "react"; import { useAppStore } from "@/components/state-provider"; import { getRunAttempts } from "@/lib/api/domains/office-runs-api"; import type { RouteAttempt } from "@/lib/state/slices/office/types"; +import { t } from "@/lib/i18n"; export type UseRunAttemptsResult = { attempts: RouteAttempt[]; @@ -32,7 +33,7 @@ export function useRunAttempts(runId: string | null): UseRunAttemptsResult { setRunAttempts(runId, res.attempts ?? []); setFetched(true); } catch (e) { - setError(e instanceof Error ? e.message : "Failed to load route attempts"); + setError(e instanceof Error ? e.message : t("office:failedToLoadRouteAttempts")); } finally { setIsLoading(false); } diff --git a/apps/web/hooks/domains/office/use-workspace-routing.ts b/apps/web/hooks/domains/office/use-workspace-routing.ts index 6e6cbea5b0..c167fb837e 100644 --- a/apps/web/hooks/domains/office/use-workspace-routing.ts +++ b/apps/web/hooks/domains/office/use-workspace-routing.ts @@ -8,6 +8,7 @@ import { updateWorkspaceRouting, } from "@/lib/api/domains/office-extended-api"; import type { ExecutionProfileSummary, WorkspaceRouting } from "@/lib/state/slices/office/types"; +import { t } from "@/lib/i18n"; export type UseWorkspaceRoutingResult = { config: WorkspaceRouting | undefined; @@ -47,7 +48,7 @@ export function useWorkspaceRouting(workspaceName: string | null): UseWorkspaceR setProfilesWorkspace(workspaceName); } catch (e) { if (version !== requestVersion.current) return; - setError(e instanceof Error ? e.message : "Failed to load routing config"); + setError(e instanceof Error ? e.message : t("office:failedToLoadRoutingConfig")); } finally { if (version === requestVersion.current) setIsLoading(false); } diff --git a/apps/web/hooks/domains/session/use-clarification-group.ts b/apps/web/hooks/domains/session/use-clarification-group.ts index 65093c2241..00e9187978 100644 --- a/apps/web/hooks/domains/session/use-clarification-group.ts +++ b/apps/web/hooks/domains/session/use-clarification-group.ts @@ -196,6 +196,8 @@ export function useClarificationGroup( [pendingId, questionIds, storeApi], ); + // i18n-exempt: the default reason is POSTed as the clarification answer and + // reaches the agent verbatim; it is not rendered in the UI. const skipAll = useCallback( async (reason?: string) => { if (!pendingId) return; diff --git a/apps/web/hooks/domains/session/use-cumulative-diff.ts b/apps/web/hooks/domains/session/use-cumulative-diff.ts index 6cbe614421..ec504331c4 100644 --- a/apps/web/hooks/domains/session/use-cumulative-diff.ts +++ b/apps/web/hooks/domains/session/use-cumulative-diff.ts @@ -3,6 +3,7 @@ import { useAppStore } from "@/components/state-provider"; import { getWebSocketClient } from "@/lib/ws/connection"; import { createDebugLogger, isDebug } from "@/lib/debug/log"; import type { CumulativeDiff } from "@/lib/state/slices/session-runtime/types"; +import { t } from "@/lib/i18n"; const debug = createDebugLogger("review:cumulative"); @@ -175,7 +176,7 @@ export function useCumulativeDiff(sessionId: string | null) { } catch (err) { if (version !== requestVersionRef.current) return; console.error("Failed to fetch cumulative diff:", err); - const message = err instanceof Error ? err.message : "Failed to fetch cumulative diff"; + const message = err instanceof Error ? err.message : t("task:failedToFetchCumulativeDiff"); setError(message); debug("fetch.error", { sessionId, envKey, error: message }); } finally { diff --git a/apps/web/hooks/domains/session/use-session-launch.ts b/apps/web/hooks/domains/session/use-session-launch.ts index fe5c710440..82f85b4166 100644 --- a/apps/web/hooks/domains/session/use-session-launch.ts +++ b/apps/web/hooks/domains/session/use-session-launch.ts @@ -4,6 +4,7 @@ import { type LaunchSessionRequest, type LaunchSessionResponse, } from "@/lib/services/session-launch-service"; +import { t } from "@/lib/i18n"; export function useSessionLaunch(options?: { onSuccess?: (resp: LaunchSessionResponse) => void; @@ -25,7 +26,7 @@ export function useSessionLaunch(options?: { options?.onSuccess?.(resp); return resp; } catch (err) { - const message = err instanceof Error ? err.message : "Unknown error"; + const message = err instanceof Error ? err.message : t("common:unknownError"); setError(message); options?.onError?.(err instanceof Error ? err : new Error(message)); return null; diff --git a/apps/web/hooks/domains/session/use-session-messages.ts b/apps/web/hooks/domains/session/use-session-messages.ts index e3b82201e0..d48d0ccee2 100644 --- a/apps/web/hooks/domains/session/use-session-messages.ts +++ b/apps/web/hooks/domains/session/use-session-messages.ts @@ -10,6 +10,7 @@ import { } from "./use-session-subscription-retry"; import { doFetchMessages } from "./use-session-message-fetch"; import { autoBackfillUntilUserMessage, hasUserOrAgentMessage } from "./message-backfill"; +import { t } from "@/lib/i18n"; export { autoBackfillUntilUserMessage, @@ -170,7 +171,7 @@ function logFetchSummary( limit, hasMore: response.has_more ?? false, byType: summary.byType, - hint: "The fetch limit may be too small for this session's last turn - user prompt and agent replies live further back. Paginate or raise the limit to see them.", + hint: t("task:messageFetchLimitHint"), }); } } diff --git a/apps/web/hooks/domains/session/use-terminals-build.ts b/apps/web/hooks/domains/session/use-terminals-build.ts index f4dff95d37..a534395fda 100644 --- a/apps/web/hooks/domains/session/use-terminals-build.ts +++ b/apps/web/hooks/domains/session/use-terminals-build.ts @@ -4,13 +4,15 @@ export const TERMINAL_TYPE_DEV_SERVER = "dev-server"; export const BOTTOM_PANEL_TERMINAL_ID = "bottom-panel"; import type { Terminal, TerminalType } from "./use-terminals-types"; +import { t } from "@/lib/i18n"; export function deriveLabel(shell: UserShellInfo): string { if (shell.customName && shell.customName !== "") return shell.customName; if (shell.displayName) return shell.displayName; if (shell.label) return shell.label; - if (shell.kind === "ordinary" && shell.seq) return `Terminal ${shell.seq}`; - return shell.terminalId.startsWith("script-") ? "Script" : "Terminal"; + if (shell.kind === "ordinary" && shell.seq) + return t("common:terminalNumbered", { seq: shell.seq }); + return shell.terminalId.startsWith("script-") ? t("common:script") : t("common:terminal"); } export function isOrdinary(shell: UserShellInfo): boolean { @@ -114,7 +116,7 @@ export function syncDevTerminal(prev: Terminal[], previewOpen: boolean): Termina { id: TERMINAL_TYPE_DEV_SERVER, type: TERMINAL_TYPE_DEV_SERVER as TerminalType, - label: "Dev Server", + label: t("task:devServer"), closable: true, }, ...prev, diff --git a/apps/web/hooks/use-detach-task.ts b/apps/web/hooks/use-detach-task.ts index 339dedfee4..55201f9487 100644 --- a/apps/web/hooks/use-detach-task.ts +++ b/apps/web/hooks/use-detach-task.ts @@ -7,6 +7,7 @@ import { detachTask as requestDetachTask } from "@/lib/api"; import type { Task } from "@/lib/types/http"; import type { AppState } from "@/lib/state/store"; import { findTaskInSnapshots } from "@/lib/kanban/find-task"; +import { t } from "@/lib/i18n"; type DetachTarget = { id: string; @@ -21,7 +22,7 @@ function requestDetachOnce(taskId: string): Promise<Task> { if (existing) return existing; const request = requestDetachTask(taskId).catch((error) => { - toast.error(error instanceof Error ? error.message : "Failed to detach task"); + toast.error(error instanceof Error ? error.message : t("task:failedToDetachTask")); throw error; }); detachRequests.set(taskId, request); diff --git a/apps/web/hooks/use-drag-and-drop.ts b/apps/web/hooks/use-drag-and-drop.ts index 2441b70561..7567f20c51 100644 --- a/apps/web/hooks/use-drag-and-drop.ts +++ b/apps/web/hooks/use-drag-and-drop.ts @@ -6,6 +6,7 @@ import { useAppStore, useAppStoreApi } from "@/components/state-provider"; import { useTaskActions } from "@/hooks/use-task-actions"; import type { Task } from "@/components/kanban-card"; import type { KanbanState } from "@/lib/state/slices"; +import { t } from "@/lib/i18n"; export type MoveTaskError = { message: string; @@ -83,7 +84,7 @@ export function useDragAndDrop({ visibleTasks, onMoveError }: DragAndDropOptions }); } catch (error) { store.getState().hydrate({ kanban: { ...store.getState().kanban, tasks: originalTasks } }); - const message = error instanceof Error ? error.message : "Failed to move task"; + const message = error instanceof Error ? error.message : t("task:failedToMoveTask"); onMoveErrorRef.current?.({ message, taskId: task.id, diff --git a/apps/web/hooks/use-entity-reference-search.ts b/apps/web/hooks/use-entity-reference-search.ts index 79d6961aa8..84661d8e17 100644 --- a/apps/web/hooks/use-entity-reference-search.ts +++ b/apps/web/hooks/use-entity-reference-search.ts @@ -3,6 +3,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { searchEntityReferences } from "@/lib/api/domains/mentions-api"; import type { EntityReferenceSearchGroup } from "@/lib/types/entity-reference"; +import { t } from "@/lib/i18n"; const SEARCH_DEBOUNCE_MS = 250; @@ -31,10 +32,9 @@ const EMPTY_STATE: EntityReferenceSearchState = { error: null, }; -const RETRYABLE_SEARCH_ERROR: EntityReferenceSearchError = { - message: "Reference search failed. Try again.", - retryable: true, -}; +// Catalog key, resolved where the error is surfaced: a module-scope `t()` here +// would pin the message to the boot locale. +const RETRYABLE_SEARCH_ERROR_KEY = "task:referenceSearchFailedRetry"; export function useEntityReferenceSearch({ workspaceId, @@ -70,7 +70,11 @@ export function useEntityReferenceSearch({ }, () => { if (generationRef.current !== generation || controller.signal.aborted) return; - setState({ groups: [], isSearching: false, error: RETRYABLE_SEARCH_ERROR }); + setState({ + groups: [], + isSearching: false, + error: { message: t(RETRYABLE_SEARCH_ERROR_KEY), retryable: true }, + }); }, ); }, SEARCH_DEBOUNCE_MS); diff --git a/apps/web/hooks/use-git-operations.ts b/apps/web/hooks/use-git-operations.ts index 597bb9d881..0c2ec81e24 100644 --- a/apps/web/hooks/use-git-operations.ts +++ b/apps/web/hooks/use-git-operations.ts @@ -3,6 +3,7 @@ import { useState, useCallback, useMemo } from "react"; import { getWebSocketClient } from "@/lib/ws/connection"; import { useAppStore } from "@/components/state-provider"; +import { t } from "@/lib/i18n"; // GitOperationResult matches the backend response export interface GitOperationResult { @@ -273,7 +274,7 @@ export function useGitOperations(sessionId: string | null): UseGitOperationsRetu if (!result.success && result.error) setError(result.error); return result; } catch (e) { - const errorMessage = e instanceof Error ? e.message : "Operation failed"; + const errorMessage = e instanceof Error ? e.message : t("task:gitOperationFailedGeneric"); setError(errorMessage); throw e; } finally { diff --git a/apps/web/hooks/use-inline-mention.ts b/apps/web/hooks/use-inline-mention.ts index c2c6f5406f..c65caea17e 100644 --- a/apps/web/hooks/use-inline-mention.ts +++ b/apps/web/hooks/use-inline-mention.ts @@ -6,6 +6,7 @@ import { getWebSocketClient } from "@/lib/ws/connection"; import { searchWorkspaceFiles } from "@/lib/ws/workspace-files"; import { getFileName } from "@/lib/utils/file-path"; import type { RichTextInputHandle } from "@/components/task/chat/rich-text-input"; +import { t } from "@/lib/i18n"; export type TaskMentionData = { taskId: string; @@ -109,7 +110,7 @@ function makeFileItem( id: filePath, kind: "file", label: filePath, - description: "File", + description: t("task:file"), onSelect: (input, value, triggerStart, onChange) => { const cursorPos = input.getSelectionStart(); onChange(value.substring(0, triggerStart) + value.substring(cursorPos)); @@ -126,8 +127,8 @@ function makePlanItem(onPlanSelect: () => void): MentionItem { return { id: "__plan__", kind: "plan", - label: "Plan", - description: "Include the plan as context", + label: t("task:plan"), + description: t("task:includeThePlanAsContext"), onSelect: (input, value, triggerStart, onChange) => { const cursorPos = input.getSelectionStart(); onChange(value.substring(0, triggerStart) + value.substring(cursorPos)); diff --git a/apps/web/hooks/use-message-handler.ts b/apps/web/hooks/use-message-handler.ts index 7f761afa8b..b3ce875235 100644 --- a/apps/web/hooks/use-message-handler.ts +++ b/apps/web/hooks/use-message-handler.ts @@ -24,6 +24,7 @@ import { deriveSessionInputMode, type SessionInputMode, } from "./domains/session/session-input-mode"; +import { t } from "@/lib/i18n"; function buildDocumentContext( activeDocument: ActiveDocument | null, @@ -51,6 +52,7 @@ function buildDocumentContext( return context; } + // i18n-exempt: agent-facing prompt sent verbatim to the model, never rendered. return `\n\n<kandev-system>\nACTIVE DOCUMENT: The user is editing "${activeDocument.name}" (${activeDocument.path}) side-by-side with this chat.\nRead this file to understand the context before responding.\n</kandev-system>`; } @@ -61,7 +63,7 @@ function resolveStepTitle(stepId: string, state: AppState): string { const found = (snap.steps ?? []).find((s) => s.id === stepId); if (found) return found.title; } - return "Step"; + return t("common:step"); } // Strips characters that could break out of the <kandev-system> block when @@ -292,8 +294,8 @@ function requireSessionInputMode(state: AppState, selectedSessionId: string): Se // nothing session-specific to say. const message = selectedSession && TERMINAL_SESSION_STATES.has(selectedSession.state) - ? "Session has ended. Please create a new session to continue." - : "The selected session is not available for input."; + ? t("task:sessionEndedCreateNew") + : t("task:sessionNotAvailableForInput"); throw new MessageSendError("session-unavailable", message); } return inputMode; diff --git a/apps/web/hooks/use-nav-availability.ts b/apps/web/hooks/use-nav-availability.ts index 011d116820..be6b0291e4 100644 --- a/apps/web/hooks/use-nav-availability.ts +++ b/apps/web/hooks/use-nav-availability.ts @@ -14,6 +14,7 @@ import { useLinearEnabled } from "@/hooks/domains/linear/use-linear-enabled"; import { useHideDisabledIntegrationsInNav } from "@/hooks/domains/integrations/use-hide-disabled-integrations-in-nav"; import type { AvailabilityMap } from "@/lib/navigation/types"; import type { GitHubStatus } from "@/lib/types/github"; +import { t } from "@/lib/i18n"; /** * The workspace the backend resolves when a request names none: the oldest by @@ -30,13 +31,13 @@ function oldestWorkspace(items: ReadonlyArray<{ id: string; created_at?: string /** Human-readable status label for a not-yet-connected integration destination. */ function getStatusLabel(loading: boolean | undefined): string { - return loading ? "Checking" : "Setup"; + return loading ? t("common:checking") : t("task:setup"); } /** Derives the GitHub nav destination's ready/label pair from its auth status. */ export function getGitHubIntegrationStatus(status: GitHubStatus | null, loading: boolean) { - if (status?.authenticated) return { ready: true, label: "Connected" }; - if (status?.token_configured) return { ready: true, label: "Configured" }; + if (status?.authenticated) return { ready: true, label: t("common:connected") }; + if (status?.token_configured) return { ready: true, label: t("common:configured") }; return { ready: false, label: getStatusLabel(loading) }; } diff --git a/apps/web/hooks/use-optimistic-task-mutation.ts b/apps/web/hooks/use-optimistic-task-mutation.ts index b181afdba6..a0bdad89c6 100644 --- a/apps/web/hooks/use-optimistic-task-mutation.ts +++ b/apps/web/hooks/use-optimistic-task-mutation.ts @@ -5,6 +5,7 @@ import { toast } from "@/lib/toast/sonner"; import { useAppStoreApi } from "@/components/state-provider"; import type { Task } from "@/app/office/tasks/[id]/types"; import type { OfficeTask } from "@/lib/state/slices/office/types"; +import { t } from "@/lib/i18n"; /** * Context for the local (page-level) task representation. The office store @@ -68,7 +69,7 @@ export function useOptimisticTaskMutation() { if (storeSnapshot) { storeApi.getState().patchTaskInStore(taskId, storeSnapshot); } - const message = err instanceof Error ? err.message : "Update failed"; + const message = err instanceof Error ? err.message : t("task:updateFailed"); toast.error(message); throw err; } diff --git a/apps/web/hooks/use-prompt-result-delivery.ts b/apps/web/hooks/use-prompt-result-delivery.ts index 94dfcf79f1..70526c1404 100644 --- a/apps/web/hooks/use-prompt-result-delivery.ts +++ b/apps/web/hooks/use-prompt-result-delivery.ts @@ -6,6 +6,7 @@ import { useToast } from "@/components/toast-provider"; import { copyToClipboard } from "@/lib/utils/copy-to-clipboard"; import type { UtilityGenerationResult } from "./use-utility-agent-generator"; +import { t } from "@/lib/i18n"; type UsePromptResultDeliveryOptions = { scopeKey: string; @@ -22,9 +23,10 @@ export type PromptResultDelivery = { dismissPending: () => void; }; -const INSERT_FAILURE_MESSAGE = "Enhanced prompt was generated but could not be inserted."; -const COPY_SUCCESS_MESSAGE = "Enhanced prompt copied to clipboard."; -const COPY_FAILURE_MESSAGE = "Enhanced prompt could not be copied."; +// Catalog keys: module scope, so callers resolve them at use. +const INSERT_FAILURE_MESSAGE_KEY = "task:enhancedPromptCouldNotBeInserted"; +const COPY_SUCCESS_MESSAGE_KEY = "task:enhancedPromptCopiedToClipboard"; +const COPY_FAILURE_MESSAGE_KEY = "task:enhancedPromptCouldNotBeCopied"; type PendingResult = { result: UtilityGenerationResult; @@ -55,7 +57,7 @@ export function usePromptResultDelivery({ const retainPendingResult = useCallback( (result: UtilityGenerationResult, generation: number) => { setPendingResult({ result, generation }); - toast({ description: INSERT_FAILURE_MESSAGE, variant: "error" }); + toast({ description: t(INSERT_FAILURE_MESSAGE_KEY), variant: "error" }); }, [toast], ); @@ -101,7 +103,7 @@ export function usePromptResultDelivery({ const copied = await copyToClipboard(pendingResult.result.content); toast({ - description: copied ? COPY_SUCCESS_MESSAGE : COPY_FAILURE_MESSAGE, + description: t(copied ? COPY_SUCCESS_MESSAGE_KEY : COPY_FAILURE_MESSAGE_KEY), variant: copied ? "success" : "error", }); }, [pendingResult, toast]); diff --git a/apps/web/hooks/use-session-failure-toast.ts b/apps/web/hooks/use-session-failure-toast.ts index 4c36342f18..ef5aa4cbf9 100644 --- a/apps/web/hooks/use-session-failure-toast.ts +++ b/apps/web/hooks/use-session-failure-toast.ts @@ -6,6 +6,7 @@ import { useToast } from "@/components/toast-provider"; import { nativeNotifications } from "@/lib/desktop/native-notification-client"; import { listNotificationProviders } from "@/lib/api"; import type { NotificationProvider } from "@/lib/types/http"; +import { t } from "@/lib/i18n"; function localSessionNotificationsEnabled( providers: Array<{ type: string; enabled: boolean; events: string[] }>, @@ -33,7 +34,7 @@ export function useSessionFailureToast() { } shownRef.current.add(notification.sessionId); toast({ - title: "Task failed to start", + title: t("task:taskFailedToStart"), description: notification.message, variant: "error", }); @@ -72,7 +73,7 @@ export function useSessionFailureToast() { if (!localSessionNotificationsEnabled(providers)) return; await nativeNotifications.show({ eventId: `session.failed:${notification.sessionId}`, - title: "Task failed to start", + title: t("task:taskFailedToStart"), body: notification.message, taskId: notification.taskId, sessionId: notification.sessionId, diff --git a/apps/web/hooks/use-task-deleted-toast.ts b/apps/web/hooks/use-task-deleted-toast.ts index 02c0d8d66b..6abd3c96d1 100644 --- a/apps/web/hooks/use-task-deleted-toast.ts +++ b/apps/web/hooks/use-task-deleted-toast.ts @@ -4,23 +4,22 @@ import { useEffect, useRef } from "react"; import { useAppStore } from "@/components/state-provider"; import { useToast } from "@/components/toast-provider"; import type { TaskDeletionReason } from "@/lib/types/http"; +import { t } from "@/lib/i18n"; // Exhaustive over TaskDeletionReason: adding or renaming a reason is a compile // error here until a matching message is provided. -const REASON_MESSAGES: Record<TaskDeletionReason, string> = { - pr_approved_by_user: - "Its pull request was approved, so this review task was closed automatically.", - pr_merged_or_closed: - "Its pull request was merged or closed, so this review task was closed automatically.", - issue_closed: "Its issue was closed, so this task was closed automatically.", +// Catalog keys, resolved in `describeReason`. Module scope, so a `t()` here +// would pin the explanations to the boot locale. +const REASON_MESSAGE_KEYS: Record<TaskDeletionReason, string> = { + pr_approved_by_user: "task:taskClosedPrApproved", + pr_merged_or_closed: "task:taskClosedPrMergedOrClosed", + issue_closed: "task:taskClosedIssueClosed", }; /** Maps a backend deletion reason (an untyped wire string) to an explanation. */ function describeReason(reason: string | undefined): string { - return ( - (reason && REASON_MESSAGES[reason as TaskDeletionReason]) ?? - "This task was closed automatically." - ); + const key = reason && REASON_MESSAGE_KEYS[reason as TaskDeletionReason]; + return t(key || "task:taskClosedAutomatically"); } /** @@ -41,7 +40,9 @@ export function useTaskDeletedToast() { } shownRef.current.add(notification.taskId); toast({ - title: notification.title ? `"${notification.title}" was closed` : "Task closed", + title: notification.title + ? t("task:taskTitledWasClosed", { title: notification.title }) + : t("task:taskClosed"), description: describeReason(notification.reason), }); clearNotification(null); diff --git a/apps/web/hooks/use-terminal-link-handler.ts b/apps/web/hooks/use-terminal-link-handler.ts index 89c207546e..27c4332a99 100644 --- a/apps/web/hooks/use-terminal-link-handler.ts +++ b/apps/web/hooks/use-terminal-link-handler.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef } from "react"; import { useAppStore } from "@/components/state-provider"; import { useDockviewStore } from "@/lib/state/dockview-store"; import { openExternalLink } from "@/lib/desktop/external-links"; +import { t } from "@/lib/i18n"; /** * Returns a stable callback for handling terminal link clicks. @@ -24,7 +25,7 @@ export function useTerminalLinkHandler(): (event: MouseEvent, uri: string) => vo api.addPanel({ id: browserId, component: "browser", - title: "Browser", + title: t("task:browser"), params: { url: uri }, }); return; diff --git a/apps/web/hooks/use-workflow-steps.ts b/apps/web/hooks/use-workflow-steps.ts index 4a3d0c02fc..404b0f2394 100644 --- a/apps/web/hooks/use-workflow-steps.ts +++ b/apps/web/hooks/use-workflow-steps.ts @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import { listWorkflowSteps } from "@/lib/api/domains/workflow-api"; +import { t } from "@/lib/i18n"; export type WorkflowStepOption = { id: string; name: string }; @@ -68,8 +69,8 @@ export function stepPlaceholder( stepsLoading: boolean, stepsCount: number, ): string { - if (!workflowId) return "Select a workflow first"; - if (stepsLoading) return "Loading steps…"; - if (stepsCount === 0) return "No steps in this workflow"; - return "Select step"; + if (!workflowId) return t("workflows:selectAWorkflowFirst"); + if (stepsLoading) return t("workflows:loadingSteps"); + if (stepsCount === 0) return t("workflows:noStepsInThisWorkflow"); + return t("workflows:selectStep"); } diff --git a/apps/web/lib/api/domains/attachment-api.ts b/apps/web/lib/api/domains/attachment-api.ts index 6c2df7992e..78e55d134b 100644 --- a/apps/web/lib/api/domains/attachment-api.ts +++ b/apps/web/lib/api/domains/attachment-api.ts @@ -42,6 +42,8 @@ export async function uploadAttachment( } catch { // Keep the status as the useful error when the server returned no JSON. } + // i18n-exempt: server-provided error text, or an HTTP status diagnostic when + // the server sent none. Callers render translated copy for the generic case. const message = body && typeof body === "object" && "error" in body && typeof body.error === "string" ? body.error diff --git a/apps/web/lib/api/domains/automation-api.ts b/apps/web/lib/api/domains/automation-api.ts index df7e4a8a43..591cb335b2 100644 --- a/apps/web/lib/api/domains/automation-api.ts +++ b/apps/web/lib/api/domains/automation-api.ts @@ -14,6 +14,8 @@ import type { WorkspaceAutomationRun, } from "@/lib/types/automation"; +// i18n-exempt: precondition diagnostic for a programmer error; callers branch +// on the error type, never render this message. const WS_UNAVAILABLE = "WebSocket client not available"; function requireClient() { diff --git a/apps/web/lib/api/domains/frontend-error-log-api.ts b/apps/web/lib/api/domains/frontend-error-log-api.ts index 3f174f1143..0c092ceb4b 100644 --- a/apps/web/lib/api/domains/frontend-error-log-api.ts +++ b/apps/web/lib/api/domains/frontend-error-log-api.ts @@ -173,6 +173,7 @@ function extractObjectText( return value instanceof Error ? bounded(value.message, TEXT_LIMIT) : "[object]"; } +// i18n-exempt: builds the telemetry payload posted to the error-log endpoint. function errorDetails(value: unknown): FrontendErrorReport["error"] | undefined { if (!(value instanceof Error)) return undefined; return { diff --git a/apps/web/lib/api/domains/github-api.ts b/apps/web/lib/api/domains/github-api.ts index c307edfeeb..fe919dbc68 100644 --- a/apps/web/lib/api/domains/github-api.ts +++ b/apps/web/lib/api/domains/github-api.ts @@ -311,6 +311,9 @@ type AccessibleReposResponse = { // configured (HTTP 503 with `code: "github_not_configured"`). Callers use the // instanceof check to render a "Connect GitHub" CTA instead of a generic // error toast. +// i18n-exempt: transport/API diagnostic. Callers branch on the error code and +// render translated copy; this text only ever appears in a console or as an +// interpolated English diagnostic (see docs/i18n.md on interpolated values). export class GitHubUnavailableError extends Error { constructor(message = "GitHub is not configured") { super(message); diff --git a/apps/web/lib/api/domains/queue-api.ts b/apps/web/lib/api/domains/queue-api.ts index bb3c8654a7..c0f6f4a687 100644 --- a/apps/web/lib/api/domains/queue-api.ts +++ b/apps/web/lib/api/domains/queue-api.ts @@ -2,6 +2,8 @@ import type { QueueStatus, QueuedMessage } from "@/lib/state/slices/session/type import type { EntityReference } from "@/lib/types/entity-reference"; import { getWebSocketClient } from "@/lib/ws/connection"; +// i18n-exempt: precondition diagnostic for a programmer error; callers branch +// on the error type, never render this message. const WS_CLIENT_UNAVAILABLE = "WebSocket client not available"; /** Error thrown when the queue would exceed its per-session cap. */ @@ -56,6 +58,9 @@ const QUEUE_SEND_NOW_ERROR_CODES: ReadonlySet<QueueSendNowErrorCode> = new Set([ ]); /** Error returned when Send Now cannot safely claim its click-time selection. */ +// i18n-exempt: transport/API diagnostic. Callers branch on the error code and +// render translated copy; this text only ever appears in a console or as an +// interpolated English diagnostic (see docs/i18n.md on interpolated values). export class QueueSendNowError extends Error { readonly code: QueueSendNowErrorCode; @@ -70,6 +75,9 @@ export class QueueSendNowError extends Error { * visible pending queue (an entry was drained, removed, merged, or newly * queued since the client's snapshot). The reorder was rejected atomically; * callers refetch the authoritative queue. */ +// i18n-exempt: transport/API diagnostic. Callers branch on the error code and +// render translated copy; this text only ever appears in a console or as an +// interpolated English diagnostic (see docs/i18n.md on interpolated values). export class QueueReorderError extends Error { readonly code = "queue_changed" as const; diff --git a/apps/web/lib/api/domains/review-api.ts b/apps/web/lib/api/domains/review-api.ts index 2e8c6d63e7..52c54707a8 100644 --- a/apps/web/lib/api/domains/review-api.ts +++ b/apps/web/lib/api/domains/review-api.ts @@ -6,6 +6,8 @@ import type { TaskReviewSnapshot, } from "@/lib/types/review"; +// i18n-exempt: precondition diagnostic for a programmer error; callers branch +// on the error type, never render this message. const WS_CLIENT_UNAVAILABLE = "WebSocket client not available"; /** diff --git a/apps/web/lib/api/domains/user-shell-api.ts b/apps/web/lib/api/domains/user-shell-api.ts index 426b565b2c..c86c72bb99 100644 --- a/apps/web/lib/api/domains/user-shell-api.ts +++ b/apps/web/lib/api/domains/user-shell-api.ts @@ -1,6 +1,8 @@ import { getWebSocketClient } from "@/lib/ws/connection"; import { getBackendConfig } from "@/lib/config"; +// i18n-exempt: precondition diagnostic for a programmer error; callers branch +// on the error type, never render this message. const WS_UNAVAILABLE = "WebSocket client not available"; /** diff --git a/apps/web/lib/api/domains/walkthrough-api.ts b/apps/web/lib/api/domains/walkthrough-api.ts index 7a2c4132d8..a442f3d638 100644 --- a/apps/web/lib/api/domains/walkthrough-api.ts +++ b/apps/web/lib/api/domains/walkthrough-api.ts @@ -1,6 +1,8 @@ import { getWebSocketClient } from "@/lib/ws/connection"; import type { TaskWalkthrough } from "@/lib/types/http"; +// i18n-exempt: precondition diagnostic for a programmer error; callers branch +// on the error type, never render this message. const WS_CLIENT_UNAVAILABLE = "WebSocket client not available"; /** diff --git a/apps/web/lib/kanban/view-registry.ts b/apps/web/lib/kanban/view-registry.ts index 0611d13a96..e5ee4e2aeb 100644 --- a/apps/web/lib/kanban/view-registry.ts +++ b/apps/web/lib/kanban/view-registry.ts @@ -35,7 +35,8 @@ export type MobileWorkflowNavigation = { export type ViewRegistryEntry = { id: string; storedValue: string; - label: string; + /** Catalog key; resolved wherever the entry is labelled. */ + labelKey: string; icon: ComponentType<{ className?: string }>; component: ComponentType<ViewContentProps>; enabled: boolean; @@ -45,7 +46,7 @@ export const VIEW_REGISTRY: ViewRegistryEntry[] = [ { id: "kanban", storedValue: "", - label: "Kanban", + labelKey: "kanban:kanban", icon: IconLayoutColumns, component: SwimlaneKanbanContent as ComponentType<ViewContentProps>, enabled: true, @@ -53,7 +54,7 @@ export const VIEW_REGISTRY: ViewRegistryEntry[] = [ { id: "graph2", storedValue: "graph2", - label: "Pipeline", + labelKey: "kanban:pipeline", icon: IconTimeline, component: SwimlaneGraph2Content as ComponentType<ViewContentProps>, enabled: true, diff --git a/apps/web/lib/keyboard/constants.ts b/apps/web/lib/keyboard/constants.ts index 5617fab2b8..20369cbb3a 100644 --- a/apps/web/lib/keyboard/constants.ts +++ b/apps/web/lib/keyboard/constants.ts @@ -3,6 +3,7 @@ */ // Modifier keys +// i18n-exempt: DOM KeyboardEvent.key values, compared against real events. export const MODIFIER_KEYS = { CTRL: "Control", CMD: "Meta", diff --git a/apps/web/lib/logger/intercept.ts b/apps/web/lib/logger/intercept.ts index f51c5d251d..ecdaeec914 100644 --- a/apps/web/lib/logger/intercept.ts +++ b/apps/web/lib/logger/intercept.ts @@ -35,6 +35,8 @@ function preview(value: unknown): unknown { } } +// i18n-exempt: builds the telemetry payload posted to the error-log endpoint; +// the fallback names an Error, it is never rendered. function errorPreview(value: object): { name: string; message: string; stack?: string } | null { try { if (!(value instanceof Error)) return null; @@ -99,6 +101,8 @@ export function installConsoleInterceptor(): void { for (const [level, original] of originals) console[level] = original; }; + // i18n-exempt: stages a telemetry log entry for the error-log endpoint; the + // fallback message is developer output, never shown to the user. const onError = (event: ErrorEvent) => { try { const error = diff --git a/apps/web/lib/lsp/lsp-progress-view.ts b/apps/web/lib/lsp/lsp-progress-view.ts index 7f0bf8aee2..37c5f67ca4 100644 --- a/apps/web/lib/lsp/lsp-progress-view.ts +++ b/apps/web/lib/lsp/lsp-progress-view.ts @@ -116,6 +116,9 @@ export function getLspCompactSummary( return getLspConnectionLabel(status, snapshot); } +// i18n-exempt: `label` is a discriminant, not copy. `lsp-status-button.tsx` +// uses it as a `LIFECYCLE_ACTION_KEYS` lookup key, compares it with `===` to +// pick the button variant, and lowercases it into `data-lsp-action`. export function getLspLifecycleAction(status: LspStatus): LspLifecycleAction { switch (status.state) { case "disabled": diff --git a/apps/web/lib/notifications/sound.ts b/apps/web/lib/notifications/sound.ts index 0af2ccd489..0aa49a0607 100644 --- a/apps/web/lib/notifications/sound.ts +++ b/apps/web/lib/notifications/sound.ts @@ -10,16 +10,17 @@ export type SoundNote = { export type SoundPresetId = "plim" | "chime" | "ding" | "pop"; +// No `label`: the picker resolves `SOUND_PRESET_LABEL_KEYS` by id +// (`notification-sound-section.tsx`). The field that used to sit here held a +// second, untranslated copy of the same names that nothing rendered. export type SoundPreset = { id: SoundPresetId; - label: string; notes: SoundNote[]; }; export const SOUND_PRESETS: SoundPreset[] = [ { id: "plim", - label: "Plim", notes: [ { frequency: 880, startMs: 0, durationMs: 220 }, { frequency: 1318.51, startMs: 90, durationMs: 420 }, @@ -27,7 +28,6 @@ export const SOUND_PRESETS: SoundPreset[] = [ }, { id: "chime", - label: "Chime", notes: [ { frequency: 523.25, startMs: 0, durationMs: 260 }, { frequency: 659.25, startMs: 110, durationMs: 260 }, @@ -36,12 +36,10 @@ export const SOUND_PRESETS: SoundPreset[] = [ }, { id: "ding", - label: "Ding", notes: [{ frequency: 987.77, startMs: 0, durationMs: 600 }], }, { id: "pop", - label: "Pop", notes: [{ frequency: 329.63, startMs: 0, durationMs: 140 }], }, ]; diff --git a/apps/web/lib/plugins/change-request-creation.ts b/apps/web/lib/plugins/change-request-creation.ts index 83fc195cdd..86ea193db2 100644 --- a/apps/web/lib/plugins/change-request-creation.ts +++ b/apps/web/lib/plugins/change-request-creation.ts @@ -1,5 +1,6 @@ import type { PluginRepositoryProviderRegistration } from "./registry"; import type { PluginHostRepository } from "./types"; +import { t } from "@/lib/i18n"; type TaskRepositoryLink = { repository_id: string; position?: number }; type CreationTask = { @@ -144,7 +145,7 @@ export async function createChangeRequestWithProvider({ success: false, branch_pushed: true, output: pushOutput, - error: error instanceof Error ? error.message : "Change-request creation failed", + error: error instanceof Error ? error.message : t("integrations:changeRequestCreationFailed"), }; } } diff --git a/apps/web/lib/review/format.ts b/apps/web/lib/review/format.ts index 02c72b1d73..66f7f21f4b 100644 --- a/apps/web/lib/review/format.ts +++ b/apps/web/lib/review/format.ts @@ -1,6 +1,12 @@ import type { TaskReviewFinding } from "@/lib/types/review"; -/** Human label for a severity, used in headings and chips. */ +/** + * Severity wording for the markdown block sent to an agent. + * + * Held stable in English so a reviewer's UI locale cannot change what the + * agent is told. The on-screen chip resolves `SEVERITY_LABEL_KEYS` instead. + */ +// i18n-exempt: agent-facing prompt content, deliberately not localized. export const SEVERITY_LABELS: Record<string, string> = { blocker: "Blocker", major: "Major", @@ -12,6 +18,14 @@ export function severityLabel(severity: string): string { return SEVERITY_LABELS[severity] ?? severity; } +/** Catalog keys for the same severities, for anything rendered on screen. */ +export const SEVERITY_LABEL_KEYS: Record<string, string> = { + blocker: "review:severityBlocker", + major: "review:severityMajor", + minor: "review:severityMinor", + nit: "review:severityNit", +}; + /** `repo/path:12-14` or `path:12`, the form used in headings and agent context. */ export function findingLocation(finding: TaskReviewFinding): string { const path = finding.repository_name diff --git a/apps/web/lib/routing/client-route-helpers.ts b/apps/web/lib/routing/client-route-helpers.ts index 138cecf59f..01d3f5b571 100644 --- a/apps/web/lib/routing/client-route-helpers.ts +++ b/apps/web/lib/routing/client-route-helpers.ts @@ -1,3 +1,4 @@ +import { t } from "@/lib/i18n"; export type LoadState<T> = | { status: "loading" } | { status: "ready"; data: T } @@ -5,7 +6,7 @@ export type LoadState<T> = export function toRouteErrorState( error: unknown, - fallbackMessage = "Failed to load route", + fallbackMessage = t("common:failedToLoadRoute"), ): LoadState<never> { return { status: "error", diff --git a/apps/web/lib/settings-discovery/catalog/index.ts b/apps/web/lib/settings-discovery/catalog/index.ts index 1abf5f4119..05f35b1613 100644 --- a/apps/web/lib/settings-discovery/catalog/index.ts +++ b/apps/web/lib/settings-discovery/catalog/index.ts @@ -18,15 +18,22 @@ export const SETTINGS_DISCOVERY_DEFINITIONS = [ ...ACCOUNT_DISCOVERY_DEFINITIONS, ]; -// Exclusion reasons shared by more than one path. These are maintainer-facing -// notes, not UI copy, so they stay untranslated. +// i18n-exempt: maintainer-facing notes explaining why a route is out of the +// discovery catalog. Read only by catalog.test.ts and settings-routes.test.ts; +// never rendered. const TO_APPEARANCE = "Redirects to the canonical Appearance page."; +// i18n-exempt: maintainer-facing exclusion note; read by tests only. const TO_DATA_STORAGE = "Redirects to the canonical Data & storage page."; +// i18n-exempt: maintainer-facing exclusion note; read by tests only. const TO_KEYBOARD_SHORTCUTS = "Redirects to the canonical Keyboard Shortcuts page."; +// i18n-exempt: maintainer-facing exclusion note; read by tests only. const TO_TASK_BEHAVIOR = "Redirects to the canonical Task behavior page."; +// i18n-exempt: maintainer-facing exclusion note; read by tests only. const TO_TERMINAL_EDITORS = "Redirects to the canonical Terminal & Editors page."; +// i18n-exempt: maintainer-facing exclusion note; read by tests only. const TO_WORKSPACE_INTEGRATIONS = "Redirects into the active workspace's Integrations tab."; +// i18n-exempt: maintainer-facing exclusion notes, asserted by tests only. export const SETTINGS_DISCOVERY_ROUTE_EXCLUSIONS: Record<string, string> = { "/settings": "Owned by the top-level Go to Settings destination.", "/settings/preferences": TO_APPEARANCE, diff --git a/apps/web/lib/settings/external-mcp-snippets.ts b/apps/web/lib/settings/external-mcp-snippets.ts index 192ac1c094..10ac285a91 100644 --- a/apps/web/lib/settings/external-mcp-snippets.ts +++ b/apps/web/lib/settings/external-mcp-snippets.ts @@ -18,6 +18,7 @@ export function buildClaudeCodeConfig(streamableUrl: string): string { ); } +// i18n-exempt: CLI command the user copies and runs verbatim. export function buildClaudeCodeCliCommand(streamableUrl: string): string { return `claude mcp add --transport http --scope user ${SERVER_NAME} ${streamableUrl}`; } @@ -44,6 +45,7 @@ export function buildCodexConfig(streamableUrl: string): string { ].join("\n"); } +// i18n-exempt: CLI command the user copies and runs verbatim. export function buildCodexCliCommand(streamableUrl: string): string { return `codex mcp add ${SERVER_NAME} --url ${streamableUrl}`; } @@ -53,6 +55,7 @@ export function buildAuggieConfig(streamableUrl: string): string { return buildClaudeCodeConfig(streamableUrl); } +// i18n-exempt: CLI command the user copies and runs verbatim. export function buildAuggieCliCommand(streamableUrl: string): string { return `auggie mcp add ${SERVER_NAME} --transport http --url ${streamableUrl}`; } diff --git a/apps/web/lib/ssr/session-page-state.ts b/apps/web/lib/ssr/session-page-state.ts index f91409d7e6..d12d005a33 100644 --- a/apps/web/lib/ssr/session-page-state.ts +++ b/apps/web/lib/ssr/session-page-state.ts @@ -26,6 +26,8 @@ import { prepareResultToSessionState } from "@/lib/state/slices/session-runtime/ import type { SessionPrepareState } from "@/lib/state/slices/session-runtime/types"; import type { AppState } from "@/lib/state/store"; import { mapWorkspaceItem } from "@/lib/routing/route-bootstrap"; +// Aliased: `t` is the Terminal parameter name throughout this module. +import { t as translate } from "@/lib/i18n"; export const OPTIONAL_HYDRATION_TIMEOUT_MS = 5_000; @@ -501,8 +503,8 @@ function deriveHydratedLabel( if (t.display_name) return t.display_name; if (t.custom_name && t.custom_name !== "") return t.custom_name; if (t.label) return t.label; - if (isOrdinary && t.seq) return `Terminal ${t.seq}`; - return isScript ? "Script" : "Terminal"; + if (isOrdinary && t.seq) return translate("common:terminalNumbered", { seq: t.seq }); + return isScript ? translate("common:script") : translate("common:terminal"); } function pickTerminalKind( diff --git a/apps/web/lib/state/dockview-env-switch.ts b/apps/web/lib/state/dockview-env-switch.ts index 2194d50aae..850dc1c9a8 100644 --- a/apps/web/lib/state/dockview-env-switch.ts +++ b/apps/web/lib/state/dockview-env-switch.ts @@ -36,6 +36,7 @@ import { formatWidthsSnapshot, formatJsonRootSizes, } from "./dockview-widths-debug"; +import { t } from "@/lib/i18n"; const debug = createDebugLogger("dockview:env-switch"); const debugWidths = createDebugLogger("dockview:widths"); @@ -499,7 +500,7 @@ function addIncomingSessionPanel( id: `session:${sessionId}`, component: "chat", tabComponent: "sessionTab", - title: "Agent", + title: t("common:agent"), params: { sessionId }, position, inactive: options.inactive, diff --git a/apps/web/lib/state/dockview-layout-builders.ts b/apps/web/lib/state/dockview-layout-builders.ts index 6aa21fe23b..127fd860c1 100644 --- a/apps/web/lib/state/dockview-layout-builders.ts +++ b/apps/web/lib/state/dockview-layout-builders.ts @@ -17,6 +17,7 @@ import type { LayoutGroupIds } from "./layout-manager"; import { getGlobalSidebarWidth } from "@/lib/local-storage"; import { resolveResponsiveRightWidth } from "./layout-manager/right-width"; import { createDebugLogger, isDebug } from "@/lib/debug/log"; +import { t } from "@/lib/i18n"; // Re-export for consumers that import from this module export { getRootSplitview } from "./layout-manager"; @@ -72,9 +73,9 @@ export function applyLayoutFixups( captureSidebarTarget(api, sv); const oldChanges = api.getPanel("diff-files"); - if (oldChanges) oldChanges.api.setTitle("Changes"); + if (oldChanges) oldChanges.api.setTitle(t("task:changes")); const oldFiles = api.getPanel("all-files"); - if (oldFiles) oldFiles.api.setTitle("Files"); + if (oldFiles) oldFiles.api.setTitle(t("common:files")); captureRightTarget(api, sv, manualRightWidth); diff --git a/apps/web/lib/state/layout-manager/session-panels.ts b/apps/web/lib/state/layout-manager/session-panels.ts index 2f0ff3d3e0..2703fc4c7d 100644 --- a/apps/web/lib/state/layout-manager/session-panels.ts +++ b/apps/web/lib/state/layout-manager/session-panels.ts @@ -1,5 +1,6 @@ import { panel as knownPanel } from "./constants"; import type { LayoutColumn, LayoutGroup, LayoutNode, LayoutPanel, LayoutState } from "./types"; +import { t } from "@/lib/i18n"; const SESSION_PANEL_PREFIX = "session:"; const CHAT_COMPONENT = "chat"; @@ -16,7 +17,7 @@ function sessionPanel(sessionId: string): LayoutPanel { return { id: `${SESSION_PANEL_PREFIX}${sessionId}`, component: CHAT_COMPONENT, - title: "Agent", + title: t("common:agent"), tabComponent: "sessionTab", params: { sessionId }, }; diff --git a/apps/web/lib/state/slices/ui/sidebar-task-prefs-actions.ts b/apps/web/lib/state/slices/ui/sidebar-task-prefs-actions.ts index 054bba5ef2..e48056b6d0 100644 --- a/apps/web/lib/state/slices/ui/sidebar-task-prefs-actions.ts +++ b/apps/web/lib/state/slices/ui/sidebar-task-prefs-actions.ts @@ -1,5 +1,6 @@ import { updateUserSettings } from "@/lib/api/domains/settings-api"; import type { UISlice } from "./types"; +import { t } from "@/lib/i18n"; type ImmerSet = (recipe: (draft: UISlice) => void, shouldReplace?: false | undefined) => void; @@ -45,7 +46,8 @@ function syncSidebarTaskPrefs(prefs: UISlice["sidebarTaskPrefs"], set: ImmerSet) }); }) .catch((err) => { - const message = err instanceof Error ? err.message : "Failed to sync sidebar task prefs"; + const message = + err instanceof Error ? err.message : t("sidebar:failedToSyncSidebarTaskPrefs"); set((draft) => { if (syncVersion !== sidebarTaskPrefsSyncVersion) return; draft.sidebarTaskPrefs.syncError = message; diff --git a/apps/web/lib/state/slices/ui/sidebar-view-actions.ts b/apps/web/lib/state/slices/ui/sidebar-view-actions.ts index af68d15eca..b5d2a8043c 100644 --- a/apps/web/lib/state/slices/ui/sidebar-view-actions.ts +++ b/apps/web/lib/state/slices/ui/sidebar-view-actions.ts @@ -10,6 +10,7 @@ import type { } from "./sidebar-view-types"; import { toApiSidebarDraft, toApiSidebarView } from "./sidebar-view-wire"; import { createDefaultSidebarView, MAX_SIDEBAR_VIEWS } from "./sidebar-view-builtins"; +import { t } from "@/lib/i18n"; type ImmerSet = (recipe: (draft: UISlice) => void, shouldReplace?: false | undefined) => void; @@ -34,10 +35,11 @@ function reorderViewsById( function nextNewViewName(views: SidebarView[]): string { const names = new Set(views.map((view) => view.name)); - if (!names.has("New view")) return "New view"; + const base = t("sidebar:newView"); + if (!names.has(base)) return base; let suffix = 2; - while (names.has(`New view ${suffix}`)) suffix += 1; - return `New view ${suffix}`; + while (names.has(t("sidebar:newViewNumbered", { suffix }))) suffix += 1; + return t("sidebar:newViewNumbered", { suffix }); } const sidebarSettingsQueues = new WeakMap<ImmerSet, Promise<void>>(); @@ -106,7 +108,7 @@ function syncSidebarViewState( }, ) { enqueueSidebarSettingsSync(set, payload).catch((err) => { - const message = err instanceof Error ? err.message : "Failed to sync sidebar views"; + const message = err instanceof Error ? err.message : t("sidebar:failedToSyncSidebarViews"); set((draft) => { draft.sidebarViews.syncError = message; }); @@ -137,7 +139,7 @@ function mutateViews( const rollback = syncState.failedRollback ?? snapshot; syncState.failedRollback = rollback; if (thisRequestId !== syncState.latestRequestId) return; - const message = err instanceof Error ? err.message : "Failed to sync sidebar views"; + const message = err instanceof Error ? err.message : t("sidebar:failedToSyncSidebarViews"); set((draft) => { draft.sidebarViews.views = rollback.views; const activeViewStillExists = rollback.views.some( @@ -250,7 +252,7 @@ function buildSidebarBackendActions(set: ImmerSet, get: () => UISlice) { if (!s.draft) return false; s.views.push({ id: makeId("view"), - name: name.trim() || "Untitled view", + name: name.trim() || t("sidebar:untitledView"), filters: s.draft.filters, sort: s.draft.sort, group: s.draft.group, diff --git a/apps/web/lib/tasks/tasks-list-options.ts b/apps/web/lib/tasks/tasks-list-options.ts index 81beefe8fc..228aae18f5 100644 --- a/apps/web/lib/tasks/tasks-list-options.ts +++ b/apps/web/lib/tasks/tasks-list-options.ts @@ -1,19 +1,25 @@ import type { Task, TaskState } from "@/lib/types/http"; +// Values only. These carried a parallel `label: "Updated newest"` alongside the +// key maps below, described as a "fallback/debug value" — but the mobile menu +// (`mobile-menu-task-list-options.tsx`) rendered `option.label` directly, so the +// phone sort and group pickers shipped untranslated while the desktop ones +// resolved through the keys. One source of display copy, and no way to render +// the wrong one. export const TASKS_LIST_SORT_OPTIONS = [ - { value: "updated_desc", label: "Updated newest" }, - { value: "updated_asc", label: "Updated oldest" }, - { value: "created_desc", label: "Created newest" }, - { value: "created_asc", label: "Created oldest" }, - { value: "title_asc", label: "Title A-Z" }, - { value: "title_desc", label: "Title Z-A" }, + { value: "updated_desc" }, + { value: "updated_asc" }, + { value: "created_desc" }, + { value: "created_asc" }, + { value: "title_asc" }, + { value: "title_desc" }, ] as const; export const TASKS_LIST_GROUP_OPTIONS = [ - { value: "state", label: "State" }, - { value: "workflow", label: "Workflow" }, - { value: "repository", label: "Repository" }, - { value: "none", label: "None" }, + { value: "state" }, + { value: "workflow" }, + { value: "repository" }, + { value: "none" }, ] as const; export type TasksListSort = (typeof TASKS_LIST_SORT_OPTIONS)[number]["value"]; @@ -22,9 +28,8 @@ export type TasksListGroup = (typeof TASKS_LIST_GROUP_OPTIONS)[number]["value"]; export const DEFAULT_TASKS_LIST_SORT: TasksListSort = "updated_desc"; export const DEFAULT_TASKS_LIST_GROUP: TasksListGroup = "state"; -// i18next key for each option's display label. The `label` fields above stay -// as plain English fallback/debug values — UI rendering resolves through -// these keys instead, in `tasks:` catalog. +// The only source of display copy for these options; resolved at render against +// the `tasks:` catalog. export const SORT_OPTION_LABEL_KEYS: Record<TasksListSort, string> = { updated_desc: "tasks:sortUpdatedNewest", updated_asc: "tasks:sortUpdatedOldest", diff --git a/apps/web/lib/terminal/terminal-font.ts b/apps/web/lib/terminal/terminal-font.ts index 2b9a0062d9..c29cfb9fcf 100644 --- a/apps/web/lib/terminal/terminal-font.ts +++ b/apps/web/lib/terminal/terminal-font.ts @@ -3,9 +3,18 @@ export type FontCategory = "icons" | "ligatures" | "system"; export type TerminalFontPreset = { value: string; label: string; + /** + * Catalog key used instead of `label` when the entry names no typeface. + * Resolved at render (`FontGroupOptions`), never here — a module-scope `t()` + * would freeze the copy at the boot locale. + */ + labelKey?: string; category: FontCategory; }; +// i18n-exempt: `value` is a CSS font-family stack and `label` is the typeface's +// own name; both are verbatim data. The one entry that names no typeface +// ("System Default") carries `labelKey` and is resolved at render instead. export const TERMINAL_FONT_PRESETS: TerminalFontPreset[] = [ // Nerd Fonts (icon/glyph support) { @@ -58,6 +67,7 @@ export const TERMINAL_FONT_PRESETS: TerminalFontPreset[] = [ { value: 'Menlo, "DejaVu Sans Mono", Consolas, monospace', label: "System Default", + labelKey: "settings:terminalFontSystemDefault", category: "system", }, { @@ -72,6 +82,7 @@ export const TERMINAL_FONT_PRESETS: TerminalFontPreset[] = [ }, ]; +// i18n-exempt: CSS font-family stack, not copy. const DEFAULT_FONT_FAMILY = 'Menlo, Monaco, "Courier New", monospace'; export const DEFAULT_FONT_SIZE = 13; diff --git a/apps/web/lib/theme/colors.ts b/apps/web/lib/theme/colors.ts index 9bc5746437..ae392df322 100644 --- a/apps/web/lib/theme/colors.ts +++ b/apps/web/lib/theme/colors.ts @@ -59,6 +59,7 @@ export const DIFF_COLORS = { // --------------------------------------------------------------------------- // Font stack // --------------------------------------------------------------------------- +// i18n-exempt: CSS font-family stack and metrics, not copy. export const FONT = { mono: '"Monaspace Neon", "Geist Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace', size: 12, diff --git a/apps/web/lib/utils.test.ts b/apps/web/lib/utils.test.ts index 8ab2e1ea4e..9e1f07d5b0 100644 --- a/apps/web/lib/utils.test.ts +++ b/apps/web/lib/utils.test.ts @@ -1,6 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { i18n } from "@/lib/i18n"; import { extractRepoName, + formatPreciseTime, formatRelativeTime, formatUserHomePath, generateUUID, @@ -182,6 +184,29 @@ describe("formatRelativeTime", () => { expect(formatRelativeTime(ago(9 * SECOND))).toBe("just now"); }); + it("localizes EVERY branch, so a timestamp does not change language as it ages", async () => { + // The regression: "just now" was routed through the catalog while the rungs + // below it stayed hardcoded, so a non-English locale showed a translated + // string for ten seconds and then flipped back to English `10s ago`. + // + // Asserted as "none of the English we used to hardcode survives" rather than + // "the output is accented": the oldest branch of each ladder is a frame of + // nothing but `{{date}}` and `{{time}}`, whose values come from + // `toLocaleDateString` and are correctly ASCII under the pseudo-locale. + const HARDCODED = /\b(ago|yesterday|today|now)\b/i; + await i18n.changeLanguage("pseudo"); + try { + for (const at of [0, 10 * SECOND, MINUTE, HOUR, DAY, 3 * DAY, 400 * DAY]) { + expect(formatRelativeTime(ago(at))).not.toMatch(HARDCODED); + } + for (const at of [0, 10 * SECOND, MINUTE, 5 * HOUR, DAY, 400 * DAY]) { + expect(formatPreciseTime(ago(at))).not.toMatch(HARDCODED); + } + } finally { + await i18n.changeLanguage("en"); + } + }); + it("switches to seconds at 10s and holds until the 60s boundary", () => { expect(formatRelativeTime(ago(10 * SECOND))).toBe("10s ago"); expect(formatRelativeTime(ago(59 * SECOND))).toBe("59s ago"); diff --git a/apps/web/lib/utils.ts b/apps/web/lib/utils.ts index a658e1b4fa..e6c51b5120 100644 --- a/apps/web/lib/utils.ts +++ b/apps/web/lib/utils.ts @@ -1,5 +1,6 @@ import { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; +import { t } from "@/lib/i18n"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); @@ -212,12 +213,15 @@ export function formatRelativeTime(dateString: string): string { const diffHour = Math.floor(diffMin / 60); const diffDay = Math.floor(diffHour / 24); - if (diffSec < 10) return "just now"; - if (diffSec < 60) return `${diffSec}s ago`; - if (diffMin < 60) return `${diffMin}m ago`; - if (diffHour < 24) return `${diffHour}h ago`; - if (diffDay === 1) return "yesterday"; - if (diffDay < 7) return `${diffDay}d ago`; + // Every branch, not just the first. Localizing only "just now" would show a + // translated string for ten seconds and then flip to English `10s ago`, which + // is worse than leaving the whole ladder in English. + if (diffSec < 10) return t("common:justNow"); + if (diffSec < 60) return t("common:relativeSecondsAgo", { count: diffSec }); + if (diffMin < 60) return t("common:relativeMinutesAgo", { count: diffMin }); + if (diffHour < 24) return t("common:relativeHoursAgo", { count: diffHour }); + if (diffDay === 1) return t("common:relativeYesterday"); + if (diffDay < 7) return t("common:relativeDaysAgo", { count: diffDay }); return date.toLocaleDateString(); } @@ -233,16 +237,16 @@ export function formatPreciseTime(dateString: string): string { const diffSec = Math.floor(diffMs / 1000); const diffMin = Math.floor(diffSec / 60); - if (diffSec < 10) return "just now"; - if (diffSec < 60) return `${diffSec}s ago`; - if (diffMin < 60) return `${diffMin}m ago`; + if (diffSec < 10) return t("common:justNow"); + if (diffSec < 60) return t("common:relativeSecondsAgo", { count: diffSec }); + if (diffMin < 60) return t("common:relativeMinutesAgo", { count: diffMin }); const time = date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }); const sameDay = date.getFullYear() === now.getFullYear() && date.getMonth() === now.getMonth() && date.getDate() === now.getDate(); - if (sameDay) return `Today, ${time}`; + if (sameDay) return t("common:preciseToday", { time }); const yesterday = new Date(now); yesterday.setDate(now.getDate() - 1); @@ -250,7 +254,7 @@ export function formatPreciseTime(dateString: string): string { date.getFullYear() === yesterday.getFullYear() && date.getMonth() === yesterday.getMonth() && date.getDate() === yesterday.getDate(); - if (isYesterday) return `Yesterday, ${time}`; + if (isYesterday) return t("common:preciseYesterday", { time }); const sameYear = date.getFullYear() === now.getFullYear(); const dateLabel = date.toLocaleDateString(undefined, { @@ -258,7 +262,7 @@ export function formatPreciseTime(dateString: string): string { day: "numeric", year: sameYear ? undefined : "numeric", }); - return `${dateLabel}, ${time}`; + return t("common:preciseDated", { date: dateLabel, time }); } /** diff --git a/apps/web/lib/utils/file-change-status.ts b/apps/web/lib/utils/file-change-status.ts index 03299a0438..0ec3af6a4b 100644 --- a/apps/web/lib/utils/file-change-status.ts +++ b/apps/web/lib/utils/file-change-status.ts @@ -1,3 +1,5 @@ +import { t } from "@/lib/i18n"; + export type FileChangeStatus = "modified" | "added" | "deleted" | "untracked" | "renamed"; export function normalizeFileChangeStatus(status?: string): FileChangeStatus { @@ -15,18 +17,24 @@ export function normalizeFileChangeStatus(status?: string): FileChangeStatus { } } +/** + * Accessible name and tooltip for a changed file's status marker. + * + * Resolves at call time (`FileStatusIcon` calls it from its render body), so a + * locale switch re-labels the markers. + */ export function fileChangeStatusLabel(status: FileChangeStatus, oldPath?: string): string { switch (status) { case "added": - return "Added"; + return t("diff:fileStatusAdded"); case "untracked": - return "Untracked"; + return t("diff:fileStatusUntracked"); case "modified": - return "Modified"; + return t("diff:fileStatusModified"); case "deleted": - return "Deleted"; + return t("diff:fileStatusDeleted"); case "renamed": - return oldPath ? `Moved from ${oldPath}` : "Moved"; + return oldPath ? t("diff:fileStatusMovedFrom", { oldPath }) : t("diff:fileStatusMoved"); default: { const exhaustiveStatus: never = status; return exhaustiveStatus; diff --git a/apps/web/lib/walkthrough-request.ts b/apps/web/lib/walkthrough-request.ts index 09603f2d06..76db1a1f36 100644 --- a/apps/web/lib/walkthrough-request.ts +++ b/apps/web/lib/walkthrough-request.ts @@ -5,6 +5,7 @@ export const CHANGES_WALKTHROUGH_PROMPT_NAME = "changes-walkthrough"; // Custom prompts may still contain placeholders from earlier walkthrough prompt iterations. const DIFF_CONTEXT_PLACEHOLDER = "{{diff_context}}"; const CHANGED_FILES_PLACEHOLDER = "{{changed_files}}"; +// i18n-exempt: agent-facing prompt substitution, sent to the model verbatim. const STATIC_CONTEXT_INSTRUCTION = "Inspect the task changes and compare against the correct base."; function buildExpansionContent(template: string): string { diff --git a/apps/web/lib/ws/client.ts b/apps/web/lib/ws/client.ts index b5e78ae57b..823d0bc108 100644 --- a/apps/web/lib/ws/client.ts +++ b/apps/web/lib/ws/client.ts @@ -50,6 +50,9 @@ const DEFAULT_RECONNECT_OPTIONS: Required<ReconnectOptions> = { maxDelay: 30000, backoffMultiplier: 1.5, }; +// i18n-exempt: transport/API diagnostic. Callers branch on the error code and +// render translated copy; this text only ever appears in a console or as an +// interpolated English diagnostic (see docs/i18n.md on interpolated values). const WEBSOCKET_CONNECTION_CLOSED_ERROR = "WebSocket connection closed"; export class WebSocketClient { @@ -483,6 +486,9 @@ export class WebSocketClient { pending.resolve(payload); } + // i18n-exempt: transport/API diagnostic. Callers branch on the error code and + // render translated copy; this text only ever appears in a console or as an + // interpolated English diagnostic (see docs/i18n.md on interpolated values). private rejectPendingRequest(msgId: string, payload: unknown) { const pending = this.pendingRequests.get(msgId); if (!pending) return; diff --git a/apps/web/lib/ws/handlers/agent-session.ts b/apps/web/lib/ws/handlers/agent-session.ts index cc6358e2d8..ebed473408 100644 --- a/apps/web/lib/ws/handlers/agent-session.ts +++ b/apps/web/lib/ws/handlers/agent-session.ts @@ -13,6 +13,7 @@ import { import type { QueueStatusChangedPayload } from "@/lib/types/backend"; import { syncKanbanPrimarySessionState } from "@/lib/ws/handlers/agent-session-kanban-sync"; import { parseContextWindowEntry } from "@/lib/state/slices/session-runtime/context-window"; +import { t } from "@/lib/i18n"; const debug = createDebugLogger("session:state"); @@ -553,7 +554,9 @@ function maybeNotifySessionFailure(store: StoreApi<AppState>, ctx: SessionFailur store.getState().setSessionFailureNotification({ sessionId, taskId, - message: payload.error_message ? String(payload.error_message) : "Session failed unexpectedly", + message: payload.error_message + ? String(payload.error_message) + : t("task:sessionFailedUnexpectedly"), }); } diff --git a/apps/web/lib/ws/handlers/empty-turn-notice.ts b/apps/web/lib/ws/handlers/empty-turn-notice.ts index f8fb36b29f..ed51300363 100644 --- a/apps/web/lib/ws/handlers/empty-turn-notice.ts +++ b/apps/web/lib/ws/handlers/empty-turn-notice.ts @@ -4,6 +4,7 @@ import type { AvailableCommand } from "@/lib/state/slices/session-runtime/types" import type { TurnEventPayload } from "@/lib/types/backend"; import type { Message } from "@/lib/types/http"; import { sessionId, taskId } from "@/lib/types/http"; +import { t } from "@/lib/i18n"; /** * Extracts a leading `/command` token from a user prompt. Returns the bare @@ -27,17 +28,19 @@ export function isKnownCommand( return (available ?? []).some((c) => c.name.toLowerCase() === lower); } -const RESEND_HINT = "Try resending your request as a normal message, without the leading slash."; +// Catalog key; resolved inside `emptyTurnNoticeText`. +const RESEND_HINT_KEY = "chat:emptyTurnResendHint"; /** The notice text shown for an empty turn, tailored to the triggering prompt. */ export function emptyTurnNoticeText(command: string | null, known: boolean): string { if (!command) { - return "The agent finished without producing any output."; + return t("chat:emptyTurnNoOutput"); } + const hint = t(RESEND_HINT_KEY); if (!known) { - return `\`/${command}\` isn't a command this agent recognizes, so it returned no output. ${RESEND_HINT}`; + return t("chat:emptyTurnUnknownCommand", { command, hint }); } - return `\`/${command}\` ran but produced no output. ${RESEND_HINT}`; + return t("chat:emptyTurnKnownCommand", { command, hint }); } export interface EmptyTurnNoticeInput { diff --git a/apps/web/lib/ws/handlers/notifications.ts b/apps/web/lib/ws/handlers/notifications.ts index 1ab6cd49e7..033dd5a2a7 100644 --- a/apps/web/lib/ws/handlers/notifications.ts +++ b/apps/web/lib/ws/handlers/notifications.ts @@ -19,6 +19,8 @@ import type { import type { WsHandlers } from "@/lib/ws/handlers/types"; /** Check whether the notification should be suppressed. */ +// i18n-exempt: the returned reason is used only for truthiness at the call +// site; it is never rendered or logged. function shouldSuppressNotification(state: AppState, taskId: string | undefined): string | null { // Suppress when user is actively viewing this task. if (document.visibilityState === "visible" && taskId && state.tasks.activeTaskId === taskId) { diff --git a/apps/web/lib/ws/handlers/quick-chat.ts b/apps/web/lib/ws/handlers/quick-chat.ts index 9cd7fbe380..94627a8a4a 100644 --- a/apps/web/lib/ws/handlers/quick-chat.ts +++ b/apps/web/lib/ws/handlers/quick-chat.ts @@ -3,6 +3,9 @@ import type { AppState } from "@/lib/state/store"; import type { QuickChatSession } from "@/lib/state/slices/ui/types"; import type { TaskEventPayload } from "@/lib/types/backend"; +// i18n-exempt: wire value. The backend sends this exact title for an untitled +// quick chat and the check below compares it with `!==` to tell a real, +// user-chosen title apart from the placeholder. const QUICK_CHAT_PLACEHOLDER_TITLE = "Quick Chat"; function readMetadataString( diff --git a/apps/web/lib/ws/use-websocket.tsx b/apps/web/lib/ws/use-websocket.tsx index e358700420..e5d88b4af8 100644 --- a/apps/web/lib/ws/use-websocket.tsx +++ b/apps/web/lib/ws/use-websocket.tsx @@ -31,6 +31,8 @@ export function useWebSocket(store: StoreApi<AppState>, url: string) { if (status === "connected") { client.subscribeUser(); } + // i18n-exempt: transport diagnostic interpolated into the translated + // `sidebar:connectionErrorDetail` frame; see connection-status-item.tsx. setConnectionStatus(status, status === "error" ? "WebSocket connection failed" : null); }, { diff --git a/apps/web/scripts/check-i18n-keys.mjs b/apps/web/scripts/check-i18n-keys.mjs index 4c9b2f9c31..08718d4980 100644 --- a/apps/web/scripts/check-i18n-keys.mjs +++ b/apps/web/scripts/check-i18n-keys.mjs @@ -11,46 +11,49 @@ * - every catalog entry is referenced somewhere -> orphans are a WARNING * - `en` and `pseudo` have the same key sets -> drift is an ERROR * - real locales (anything that is not `en`/`pseudo`, discovered from the - * directory listing rather than named here) -> parity issues are a WARNING + * directory listing rather than named here) -> parity issues are an ERROR * - * ## Why real locales only warn + * ## Why real locales gate too * - * `en` and `pseudo` are ours: English is authored in the same PR as the code, - * and `pseudo` is GENERATED from it by `pnpm run i18n:pseudo`. A mismatch there - * is always the author's to fix in the change that caused it, so it gates. + * They did not used to. Gating them meant an ordinary English-only PR failed CI + * for work nobody in the merge path could do - #2261 added 13 `en` chat keys and + * left `main` red because #2243 had introduced `zh-cn` 20 minutes earlier. So + * the pass was advisory, and the cost of that showed up exactly where you would + * expect: pt-pt was 312 keys short and zh-hk/zh-tw 415, none of it visible in a + * green build. * - * Real-locale catalogs are translated OUT OF BAND, by a third party, on their - * own cadence. Gating on them means an ordinary English-only PR fails CI for - * work nobody in the merge path can do — and that is not hypothetical: #2261 - * added 13 `en` chat keys and left `main` red, because #2243 had introduced - * `zh-cn` and the gate 20 minutes earlier. So these are reported loudly, for - * whoever owns the translation, and do not block a merge. + * The four catalogs are now complete, so the failure mode is the other one: a + * PR that adds an `en` key and no translation silently re-opens the gap. This + * gates, and the cost is that adding user-facing copy means adding it in five + * languages. That is the actual cost of shipping five languages. * - * ## What this check is NOT + * ## The identical-to-English check * - * The real-locale pass is STRUCTURAL, not translational. It compares shapes, so - * a green run does not mean "translated" — measured against `main`: - * - * caught: missing key, extra key, missing/extra namespace, empty or - * non-string value, dropped `{{placeholder}}`, dropped `<n>` tag - * NOT caught: a value identical to English, a whole catalog copy-pasted from - * `en`, and swapped `_one`/`_other` forms - * - * The identical-to-English tolerance is deliberate — brand nouns and technical - * literals (`Kandev`, `GitHub`, `Webhook URL`) must stay verbatim, so there is - * no sound automatic rule here. Reviewing a translation is a human job. + * The structural pass compares SHAPES, so a catalog copy-pasted wholesale from + * `en` passes every part of it. `untranslatedValueIssues` closes that, in two + * tiers (see `scripts/lib/i18n-catalogs.mjs`): values the shared `looksLikeCopy` + * predicate says are not copy need no explanation, and the prose that survives + * it is declared in a `_verbatim.json` registry with a reason. Stale registry + * entries are themselves an error, so the files cannot rot into the shrinking + * allowlist this was meant not to be. * * Usage: node scripts/check-i18n-keys.mjs [--strict-orphans] */ import fs from "node:fs"; import path from "node:path"; +import { noLiteralStringOptions } from "../eslint.i18n.options.mjs"; import { discoverRealLocales, formatParityIssue, + formatVerbatimIssue, readLocaleNamespaces, + readVerbatimRegistry, realLocaleParityIssues, + staleVerbatimEntries, + untranslatedValueIssues, } from "./lib/i18n-catalogs.mjs"; +import { looksLikeCopy } from "./lib/looks-like-copy.mjs"; const ROOT = path.resolve(import.meta.dirname, ".."); const LOCALES = path.join(ROOT, "src", "locales"); @@ -135,9 +138,38 @@ const pseudoMissing = [...enKeys].filter((k) => !pseudoKeys.has(k)); const pseudoExtra = [...pseudoKeys].filter((k) => !enKeys.has(k)); const sourceNamespaces = readLocaleNamespaces(LOCALES, "en"); const realLocales = discoverRealLocales(LOCALES); -const realLocaleIssues = realLocales.flatMap((locale) => - realLocaleParityIssues(sourceNamespaces, readLocaleNamespaces(LOCALES, locale), locale), +const localeNamespaces = new Map( + realLocales.map((locale) => [locale, readLocaleNamespaces(LOCALES, locale)]), ); +const excludes = noLiteralStringOptions.words?.exclude ?? []; +const isCopy = (value) => looksLikeCopy(value, excludes); +const sharedVerbatim = readVerbatimRegistry(LOCALES); +const ownVerbatim = new Map( + realLocales.map((locale) => [locale, readVerbatimRegistry(LOCALES, locale)]), +); +const realLocaleIssues = realLocales.flatMap((locale) => [ + ...realLocaleParityIssues(sourceNamespaces, localeNamespaces.get(locale), locale), + ...untranslatedValueIssues(sourceNamespaces, localeNamespaces.get(locale), locale, isCopy, { + shared: sharedVerbatim, + own: ownVerbatim.get(locale), + }), +]); + +// Registry hygiene, so a `_verbatim.json` cannot outlive what it explains. +const flatLocales = realLocales.map((locale) => ({ + locale, + flat: new Map( + [...localeNamespaces.get(locale)].flatMap(([ns, messages]) => + [...messages].map(([key, value]) => [`${ns}:${key}`, value]), + ), + ), +})); +const verbatimIssues = [ + ...staleVerbatimEntries(sharedVerbatim, en, flatLocales), + ...realLocales.flatMap((locale) => + staleVerbatimEntries(ownVerbatim.get(locale), en, flatLocales, locale), + ), +]; let failed = false; @@ -159,15 +191,28 @@ if (pseudoMissing.length || pseudoExtra.length) { ); } -// Advisory, never fatal — see the "Why real locales only warn" note at the top. -// Deliberately NOT gated behind --strict-orphans or any flag: the point is that -// no invocation of this script can be made to fail on a translation catalog. if (realLocaleIssues.length) { - console.warn( - `\n⚠ ${realLocaleIssues.length} real-locale catalog parity issue(s) ` + - `— advisory, does not fail the build:`, + failed = true; + console.error(`\n✖ ${realLocaleIssues.length} real-locale catalog issue(s):\n`); + for (const issue of realLocaleIssues.slice(0, 60)) { + console.error(` ${formatParityIssue(issue)}`); + } + if (realLocaleIssues.length > 60) { + console.error(` … and ${realLocaleIssues.length - 60} more`); + } + console.error( + `\n Add the translation, or - if the value is prose that reads the same in\n` + + ` that language - declare it in src/locales/<locale>/_verbatim.json with a\n` + + ` reason. Brand nouns, acronyms and placeholder-only frames need neither.\n` + + ` For zh-tw / zh-hk run: pnpm run i18n:zh-hant\n`, ); - for (const issue of realLocaleIssues) console.warn(` ${formatParityIssue(issue)}`); +} + +if (verbatimIssues.length) { + failed = true; + console.error(`\n✖ ${verbatimIssues.length} stale verbatim registry entr(ies):\n`); + for (const issue of verbatimIssues) console.error(` ${formatVerbatimIssue(issue)}`); + console.error(""); } if (orphans.length) { @@ -185,12 +230,10 @@ if (!failed) { // zh-cn in sync", which was misleading twice over: it implied a real locale // gates the build, and it read as "translated" for a check that cannot see // English left inside a translated value. - const advisory = realLocales.length - ? ` ${realLocaleIssues.length} advisory ${realLocales.join(", ")} issue(s).` - : ""; + const gated = realLocales.length ? ` ${realLocales.join(", ")} complete.` : ""; console.log( `✓ i18n keys OK — ${used.size} key(s) referenced, ${en.size} en entr(ies), ` + - `${orphans.length} orphan(s), pseudo in sync.${advisory}`, + `${orphans.length} orphan(s), pseudo in sync.${gated}`, ); } process.exit(failed ? 1 : 0); diff --git a/apps/web/scripts/check-i18n-keys.test.ts b/apps/web/scripts/check-i18n-keys.test.ts index 4c97159a7d..e91d2b9055 100644 --- a/apps/web/scripts/check-i18n-keys.test.ts +++ b/apps/web/scripts/check-i18n-keys.test.ts @@ -13,8 +13,12 @@ const ZH_CN = "zh-cn"; const SCRIPT = path.resolve(import.meta.dirname, "check-i18n-keys.mjs"); const CATALOG_HELPER = path.resolve(import.meta.dirname, "lib", "i18n-catalogs.mjs"); +const COPY_HELPER = path.resolve(import.meta.dirname, "lib", "looks-like-copy.mjs"); +// The script reads the shared `words.exclude` list from here, so the fixture +// needs it at the same relative position (`../eslint.i18n.options.mjs`). +const GUARD_OPTIONS = path.resolve(import.meta.dirname, "..", "eslint.i18n.options.mjs"); -function runFixture(fixture: Fixture) { +function runFixture(fixture: Fixture, extras: Record<string, unknown> = {}) { const root = mkdtempSync(path.join(tmpdir(), "kandev-i18n-keys-")); try { const scriptsDir = path.join(root, "scripts"); @@ -24,6 +28,8 @@ function runFixture(fixture: Fixture) { const libDir = path.join(scriptsDir, "lib"); mkdirSync(libDir, { recursive: true }); copyFileSync(CATALOG_HELPER, path.join(libDir, "i18n-catalogs.mjs")); + copyFileSync(COPY_HELPER, path.join(libDir, "looks-like-copy.mjs")); + copyFileSync(GUARD_OPTIONS, path.join(root, "eslint.i18n.options.mjs")); for (const [locale, namespaces] of Object.entries(fixture)) { const localeDir = path.join(root, "src", "locales", locale); @@ -33,6 +39,14 @@ function runFixture(fixture: Fixture) { } } + // `"<locale>/_verbatim"` writes src/locales/<locale>/_verbatim.json; + // `"_verbatim"` writes the shared one beside the locale directories. + for (const [target, contents] of Object.entries(extras)) { + const file = path.join(root, "src", "locales", `${target}.json`); + mkdirSync(path.dirname(file), { recursive: true }); + writeFileSync(file, JSON.stringify(contents)); + } + return spawnSync(process.execPath, [script], { encoding: "utf8" }); } finally { rmSync(root, { recursive: true, force: true }); @@ -60,88 +74,81 @@ describe("real locale catalog parity", () => { expect(result.status).toBe(0); }); - it("warns about a missing namespace without failing", () => { + it("fails on a missing namespace", () => { const fixture = completeFixture(); delete fixture[ZH_CN].settings; const result = runFixture(fixture); - expect(result.status).toBe(0); - expect(result.stderr).toContain("advisory"); + expect(result.status).toBe(1); expect(result.stderr).toContain(ZH_CN); expect(result.stderr).toContain("settings"); expect(result.stderr).toContain("missing namespace"); }); - it("warns about an extra namespace without failing", () => { + it("fails on an extra namespace", () => { const fixture = completeFixture(); fixture[ZH_CN].extra = { surprise: "意外" }; const result = runFixture(fixture); - expect(result.status).toBe(0); - expect(result.stderr).toContain("advisory"); + expect(result.status).toBe(1); expect(result.stderr).toContain(ZH_CN); expect(result.stderr).toContain("extra"); expect(result.stderr).toContain("extra namespace"); }); - it("warns about a missing key without failing", () => { + it("fails on a missing key", () => { const fixture = completeFixture(); delete fixture[ZH_CN].common.second; const result = runFixture(fixture); - expect(result.status).toBe(0); - expect(result.stderr).toContain("advisory"); + expect(result.status).toBe(1); expect(result.stderr).toContain(ZH_CN); expect(result.stderr).toContain("common"); expect(result.stderr).toContain("missing key: second"); }); - it("warns about an extra key without failing", () => { + it("fails on an extra key", () => { const fixture = completeFixture(); fixture[ZH_CN].common.surprise = "意外"; const result = runFixture(fixture); - expect(result.status).toBe(0); - expect(result.stderr).toContain("advisory"); + expect(result.status).toBe(1); expect(result.stderr).toContain(ZH_CN); expect(result.stderr).toContain("common"); expect(result.stderr).toContain("extra key: surprise"); }); - it("warns about a translation that drops an interpolation placeholder", () => { + it("fails on a translation that drops an interpolation placeholder", () => { const fixture = completeFixture(); fixture.en.common.first = "Open {{url}} as {{user}}"; fixture[ZH_CN].common.first = "以 {{user}} 身份打开"; const result = runFixture(fixture); - expect(result.status).toBe(0); - expect(result.stderr).toContain("advisory"); + expect(result.status).toBe(1); expect(result.stderr).toContain(ZH_CN); expect(result.stderr).toContain("common"); expect(result.stderr).toContain("interpolation placeholder mismatch: first"); }); - it("warns about a translation that drops numeric Trans tags", () => { + it("fails on a translation that drops numeric Trans tags", () => { const fixture = completeFixture(); fixture.en.common.first = "Open <1>{{url}}</1>"; fixture[ZH_CN].common.first = "打开 {{url}}"; const result = runFixture(fixture); - expect(result.status).toBe(0); - expect(result.stderr).toContain("advisory"); + expect(result.status).toBe(1); expect(result.stderr).toContain(ZH_CN); expect(result.stderr).toContain("common"); expect(result.stderr).toContain("Trans tag structure mismatch: first"); }); - it("warns about a translation that changes a numeric Trans tag index", () => { + it("fails on a translation that changes a numeric Trans tag index", () => { const fixture = completeFixture(); fixture.en.common.first = "Open <1>{{url}}</1>"; fixture[ZH_CN].common.first = "打开 <2>{{url}}</2>"; const result = runFixture(fixture); - expect(result.status).toBe(0); - expect(result.stderr).toContain("advisory"); + expect(result.status).toBe(1); expect(result.stderr).toContain(ZH_CN); expect(result.stderr).toContain("common"); expect(result.stderr).toContain("Trans tag structure mismatch: first"); @@ -149,37 +156,34 @@ describe("real locale catalog parity", () => { }); describe("translated value validation", () => { - it("warns about an empty translated value without failing", () => { + it("fails on an empty translated value", () => { const fixture = completeFixture(); fixture[ZH_CN].common.first = ""; const result = runFixture(fixture); - expect(result.status).toBe(0); - expect(result.stderr).toContain("advisory"); + expect(result.status).toBe(1); expect(result.stderr).toContain(ZH_CN); expect(result.stderr).toContain("common"); expect(result.stderr).toContain("empty translated value: first"); }); - it("warns about a whitespace-only translated value without failing", () => { + it("fails on a whitespace-only translated value", () => { const fixture = completeFixture(); fixture[ZH_CN].common.first = " \t\n "; const result = runFixture(fixture); - expect(result.status).toBe(0); - expect(result.stderr).toContain("advisory"); + expect(result.status).toBe(1); expect(result.stderr).toContain(ZH_CN); expect(result.stderr).toContain("common"); expect(result.stderr).toContain("empty translated value: first"); }); - it("warns about a non-string translated value without failing", () => { + it("fails on a non-string translated value", () => { const fixture = completeFixture(); fixture[ZH_CN].common.first = 42; const result = runFixture(fixture); - expect(result.status).toBe(0); - expect(result.stderr).toContain("advisory"); + expect(result.status).toBe(1); expect(result.stderr).toContain(ZH_CN); expect(result.stderr).toContain("common"); expect(result.stderr).toContain("non-string translated value: first"); @@ -188,11 +192,10 @@ describe("translated value validation", () => { }); /** - * `en` <-> `pseudo` is the half that still gates, and after making real locales - * advisory it carries all of the weight — so it needs its own coverage rather - * than being assumed. Both catalogs are ours: English is authored beside the - * code and `pseudo` is generated from it, so a mismatch is always the author's - * to fix in the same change. + * `en` <-> `pseudo` gates, and gated alone while real locales were advisory, so + * it has its own coverage rather than being assumed. Both catalogs are ours: + * English is authored beside the code and `pseudo` is generated from it, so a + * mismatch is always the author's to fix in the same change. */ describe("pseudo catalog drift still fails the build", () => { it("rejects a key missing from pseudo", () => { @@ -221,25 +224,85 @@ describe("pseudo catalog drift still fails the build", () => { expect(result.status).toBe(1); expect(result.stderr).toContain("pseudo catalog is out of sync"); - expect(result.stderr).toContain("advisory"); }); }); /** - * The real-locale pass compares SHAPES, not meaning. These pin the limits so a - * green run is never mistaken for "translated" — the assumption that this check - * validates translation quality has already caused two wrong conclusions. + * The identical-to-English check, and the two tiers that keep its exemption + * mechanism from becoming a shrinking allowlist. The structural checks above all + * compare shapes, so a catalog copy-pasted from `en` used to pass every one of + * them; these are what closes that. */ -describe("the real-locale pass is structural, not translational", () => { - it("accepts a catalog copy-pasted wholesale from en", () => { +describe("identical-to-English detection", () => { + it("rejects a catalog copy-pasted wholesale from en", () => { const fixture = completeFixture(); fixture[ZH_CN] = structuredClone(fixture.en); const result = runFixture(fixture); - expect(result.status).toBe(0); - expect(result.stderr).not.toContain("advisory"); + expect(result.status).toBe(1); + expect(result.stderr).toContain("value identical to English"); }); + it("accepts an identical value the shared copy predicate calls non-copy", () => { + // Tier 1: no declaration needed. "Kandev" is a brand noun, "{{count}}" is a + // frame with no prose in it — `looksLikeCopy` rejects both. + const fixture = completeFixture(); + fixture.en.common.first = "Kandev"; + fixture.pseudo.common.first = "Kandev"; + fixture[ZH_CN].common.first = "Kandev"; + fixture.en.common.second = "{{count}}"; + fixture.pseudo.common.second = "{{count}}"; + fixture[ZH_CN].common.second = "{{count}}"; + const result = runFixture(fixture); + + expect(result.status).toBe(0); + }); + + it("accepts a declared verbatim value and names the registry when it is not", () => { + const fixture = completeFixture(); + fixture[ZH_CN].common.first = fixture.en.common.first as string; + + expect(runFixture(fixture).status).toBe(1); + + const declared = runFixture(fixture, { + [`${ZH_CN}/_verbatim`]: { "common:first": "same word in this language" }, + }); + expect(declared.status).toBe(0); + }); + + it("rejects a verbatim entry with no reason", () => { + const fixture = completeFixture(); + fixture[ZH_CN].common.first = fixture.en.common.first as string; + const result = runFixture(fixture, { [`${ZH_CN}/_verbatim`]: { "common:first": "" } }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("verbatim entry with no reason"); + }); + + it("rejects a verbatim entry the locale has since translated", () => { + // Deleting an entry cannot make anything pass, and neither can leaving a + // dead one behind. That is the difference from an allowlist you can shrink. + const fixture = completeFixture(); + const result = runFixture(fixture, { + [`${ZH_CN}/_verbatim`]: { "common:first": "no longer true, zh-cn translates it" }, + }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("every locale translates this"); + }); + + it("rejects a verbatim entry for a key that no longer exists", () => { + const fixture = completeFixture(); + const result = runFixture(fixture, { + [`${ZH_CN}/_verbatim`]: { "common:deleted": "points at nothing" }, + }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("no such key in en"); + }); +}); + +describe("what the real-locale pass still does not decide", () => { it("accepts swapped plural forms", () => { const fixture = completeFixture(); fixture.en.common = { items_one: "{{count}} item", items_other: "{{count}} items" }; diff --git a/apps/web/scripts/check-nonjsx-copy.mjs b/apps/web/scripts/check-nonjsx-copy.mjs index e0b40595e8..e5cafda613 100644 --- a/apps/web/scripts/check-nonjsx-copy.mjs +++ b/apps/web/scripts/check-nonjsx-copy.mjs @@ -9,12 +9,17 @@ * 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. + * SCOPE: the WHOLE `apps/web` source tree, by exclusion rather than by + * allowlist. This used to default to `i18nGuardFiles` — the eslint rule's + * allowlist — which left 1141 of 2564 source files judged by neither gate, and + * non-JSX positions are exactly what eslint structurally cannot see. Since this + * check has no lint-rule cost (it runs as one script, not per-PR-file), there is + * no reason to scope it: an exclusion list covers new directories automatically, + * so the hole cannot silently reopen the next time someone adds one. + * + * The eslint half of the contract is still `i18nGuardFiles`, and + * `check-new-code-i18n.mjs` runs this same detector over added and changed lines + * everywhere. * * Silence a finding with `// i18n-exempt: <reason>` on the enclosing statement. * A reason is required, because every shape here has a legitimate form worth @@ -26,21 +31,13 @@ import fs from "node:fs"; import path from "node:path"; -import { i18nGuardFiles, noLiteralStringOptions } from "../eslint.i18n.options.mjs"; +import { noLiteralStringOptions } from "../eslint.i18n.options.mjs"; import { findNonJsxCopy } from "./lib/nonjsx-copy.mjs"; +import { collectScannedFiles } from "./lib/nonjsx-scope.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 files = collectScannedFiles(fs, ROOT, patterns); const excludes = noLiteralStringOptions.words?.exclude ?? []; const problems = []; @@ -63,10 +60,12 @@ if (unexplained.length) { 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: <reason>\` if it is a persisted value, an\n` + - ` agent-facing prompt, or a wire value. See docs/i18n.md.\n`, + ` \`i18next/no-literal-string\` is jsx-only, so it cannot reach these —\n` + + ` config tables, helper returns, parameter defaults, and toast/setter\n` + + ` arguments. Route each through t(), or mark it\n` + + ` \`// i18n-exempt: <reason>\` if it is a persisted value, an agent-facing\n` + + ` prompt, a wire value, or a diagnostic the user never sees.\n` + + ` See docs/i18n.md.\n`, ); for (const { file, line, kind, context, value } of problems) { const where = context ? `${kind} ${context}` : kind; @@ -75,6 +74,11 @@ if (problems.length) { console.error(""); } -if (problems.length || unexplained.length) process.exit(1); +if (problems.length || unexplained.length) { + // Report the scope on the failure path too. A shrinking scope and a clean run + // look identical otherwise, and this check's whole value is the denominator. + console.error(` (${files.size} file(s) scanned)\n`); + process.exit(1); +} console.log(`✓ no non-JSX copy — ${files.size} guarded file(s) checked.`); diff --git a/apps/web/scripts/lib/i18n-catalogs.mjs b/apps/web/scripts/lib/i18n-catalogs.mjs index 5b59b6de97..71ce490d03 100644 --- a/apps/web/scripts/lib/i18n-catalogs.mjs +++ b/apps/web/scripts/lib/i18n-catalogs.mjs @@ -1,13 +1,16 @@ import fs from "node:fs"; import path from "node:path"; +/** The verbatim registry, not a namespace. Underscore-prefixed so it sorts apart. */ +export const VERBATIM_FILE = "_verbatim.json"; + export function readLocaleNamespaces(localesDir, locale) { const dir = path.join(localesDir, locale); const namespaces = new Map(); if (!fs.existsSync(dir)) return namespaces; for (const file of fs .readdirSync(dir) - .filter((entry) => entry.endsWith(".json")) + .filter((entry) => entry.endsWith(".json") && entry !== VERBATIM_FILE) .sort()) { const namespace = file.replace(/\.json$/, ""); const messages = JSON.parse(fs.readFileSync(path.join(dir, file), "utf8")); @@ -149,3 +152,110 @@ export function formatParityIssue(issue) { const suffix = issue.key ? `: ${issue.key}` : ""; return `${issue.locale} / ${issue.namespace}: ${issue.type}${suffix}`; } + +/** + * Read a verbatim registry: `{ "ns:key": "<reason>" }`. + * + * `localesDir/_verbatim.json` is the SHARED registry — values every locale may + * legitimately keep in English because they are proper nouns, technical terms, + * or frames made only of placeholders. `localesDir/<locale>/_verbatim.json` is + * the per-locale one, for the narrower case where the correct translation + * happens to BE the English word ("Total", "Manual", "Menu" in pt-PT). + * + * Missing file reads as empty: a locale that translates everything needs none. + */ +export function readVerbatimRegistry(localesDir, locale = null) { + const file = locale + ? path.join(localesDir, locale, VERBATIM_FILE) + : path.join(localesDir, VERBATIM_FILE); + if (!fs.existsSync(file)) return new Map(); + return new Map(Object.entries(JSON.parse(fs.readFileSync(file, "utf8")))); +} + +/** A reason must actually say something, exactly like an `i18n-exempt` marker. */ +const MIN_REASON_LENGTH = 3; + +function flatten(namespaces) { + const flat = new Map(); + for (const [namespace, messages] of namespaces) { + for (const [key, value] of messages) flat.set(`${namespace}:${key}`, value); + } + return flat; +} + +/** + * Values a locale left identical to English without saying why. + * + * The structural checks above compare SHAPES, so a catalog copy-pasted from `en` + * passes every one of them. This is the check that does not. + * + * Two tiers, so the registry only ever holds real decisions: + * + * 1. RULE. If the English value is not copy by `looksLikeCopy` — a brand noun, + * an acronym, a wire value, a frame of nothing but placeholders — then every + * locale is expected to keep it verbatim and nothing needs declaring. That + * predicate is the same one `i18next/no-literal-string` and + * `check-nonjsx-copy.mjs` use to decide what counts as copy, so the three + * cannot disagree about what a translatable string is. + * 2. DECLARATION. What survives tier 1 is prose, and prose that reads the same + * in the target language is a judgement a person made. It is declared in a + * registry with a reason, which is what separates "we checked, it is the + * same word" from "we never translated this". + * + * @param {Map<string, Map<string, string>>} source `en` namespaces + * @param {Map<string, Map<string, string>>} translated the locale's namespaces + * @param {string} locale + * @param {(value: string) => boolean} isCopy + * @param {{shared: Map<string, string>, own: Map<string, string>}} verbatim + */ +export function untranslatedValueIssues(source, translated, locale, isCopy, verbatim) { + const en = flatten(source); + const them = flatten(translated); + const issues = []; + for (const [id, value] of them) { + if (!en.has(id) || en.get(id) !== value) continue; + if (typeof value !== "string" || !isCopy(value)) continue; + const reason = verbatim.own.get(id) ?? verbatim.shared.get(id); + const [namespace, ...rest] = id.split(":"); + const key = rest.join(":"); + if (reason === undefined) { + issues.push({ locale, namespace, key, type: "value identical to English" }); + continue; + } + if (typeof reason !== "string" || reason.trim().length < MIN_REASON_LENGTH) { + issues.push({ locale, namespace, key, type: "verbatim entry with no reason" }); + } + } + return issues; +} + +/** + * Registry entries that guard nothing, so the files cannot accumulate debt. + * + * A per-locale entry is stale as soon as that locale translates the value; a + * shared entry is stale only when NO locale keeps it verbatim, since it exists + * for whichever ones still do. + * + * @param {Map<string, string>} registry + * @param {Map<string, string>} en flattened `en` catalog + * @param {Array<{locale: string, flat: Map<string, string>}>} locales + * @param {string|null} owner locale name, or null for the shared registry + */ +export function staleVerbatimEntries(registry, en, locales, owner = null) { + const stale = []; + for (const id of registry.keys()) { + if (!en.has(id)) { + stale.push({ id, owner, type: "no such key in en" }); + continue; + } + const scope = owner ? locales.filter((l) => l.locale === owner) : locales; + const kept = scope.some(({ flat }) => flat.get(id) === en.get(id)); + if (!kept) stale.push({ id, owner, type: "every locale translates this" }); + } + return stale; +} + +export function formatVerbatimIssue(issue) { + const where = issue.owner ? `${issue.owner}/${VERBATIM_FILE}` : VERBATIM_FILE; + return `${where}: ${issue.id} — ${issue.type}`; +} diff --git a/apps/web/scripts/lib/looks-like-copy.mjs b/apps/web/scripts/lib/looks-like-copy.mjs new file mode 100644 index 0000000000..b8a2140245 --- /dev/null +++ b/apps/web/scripts/lib/looks-like-copy.mjs @@ -0,0 +1,85 @@ +/** + * `looksLikeCopy`: does a string read as something a user is meant to understand? + * + * Lives in its own module, free of any dependency, because three checks share it + * and they must not disagree about what a translatable string is: + * + * - `nonjsx-copy.mjs`, for copy in positions eslint cannot see + * - `check-new-code-i18n.mjs`, through the same detector + * - `check-i18n-keys.mjs`, to decide whether a locale value left identical to + * English is a missing translation or a brand noun + * + * The last one is why this is separate: `nonjsx-copy.mjs` pulls in `@babel/core` + * to parse, and the catalog checker has no business loading a parser to ask + * whether "Webhook" is a word. + */ +/** + * 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); +} diff --git a/apps/web/scripts/lib/nonjsx-copy.mjs b/apps/web/scripts/lib/nonjsx-copy.mjs index 4fb0176640..5d07a9ac69 100644 --- a/apps/web/scripts/lib/nonjsx-copy.mjs +++ b/apps/web/scripts/lib/nonjsx-copy.mjs @@ -30,6 +30,10 @@ */ import { parseSync } from "@babel/core"; +import { looksLikeCopy } from "./looks-like-copy.mjs"; + +export { looksLikeCopy }; + /** Property names whose value is displayed rather than used as data. */ const COPY_KEYS = new Set([ "alt", @@ -84,77 +88,6 @@ const DISPLAY_CALLS = */ 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. * diff --git a/apps/web/scripts/lib/nonjsx-scope.mjs b/apps/web/scripts/lib/nonjsx-scope.mjs new file mode 100644 index 0000000000..186dfd4f18 --- /dev/null +++ b/apps/web/scripts/lib/nonjsx-scope.mjs @@ -0,0 +1,66 @@ +/** + * What `check-nonjsx-copy.mjs` scans by default: the whole `apps/web` source + * tree, minus the trees below. + * + * Kept here rather than inline in the script so it can be tested. The scope is + * the thing this check is worth: it defaulted to `i18nGuardFiles` for a long + * time, which left 1141 of 2564 source files judged by neither this nor the + * eslint rule — and a shrunken scope and a clean run look identical from the + * outside. `nonjsx-scope.test.ts` asserts the denominator directly. + * + * By EXCLUSION, not by allowlist, so a directory added tomorrow is covered the + * day it is created and this hole cannot silently reopen. + */ +import path from "node:path"; + +export const DEFAULT_GLOBS = ["**/*.ts", "**/*.tsx"]; + +/** + * Directories that hold no shipped user-facing copy. + * + * `e2e` and `scripts` are tooling; `src/locales` IS the catalog; `dist` and + * `node_modules` are build output. + */ +export const EXCLUDED_DIRS = [ + "node_modules", + "dist", + "e2e", + "scripts", + path.join("src", "locales"), +]; + +/** + * Files that build fixtures out of literal strings on purpose. + * + * `*.test-helpers.ts` / `*.test-utils.ts` are imported only by tests and are the + * same class as `*.test.ts` — guarding them would force every `title: "Test"` in + * a fixture through the catalog. + */ +export const EXCLUDED_FILES = + /(\.(test|spec|d)\.tsx?|\.test-(helpers|utils)\.tsx?|\.spec-helpers\.tsx?)$/; + +/** Whether a repo-relative path is outside the scanned scope. */ +export function isExcluded(file) { + if (EXCLUDED_FILES.test(file)) return true; + return EXCLUDED_DIRS.some((dir) => file === dir || file.startsWith(`${dir}${path.sep}`)); +} + +/** + * The scanned set for a list of globs, or the default scope when none is given. + * + * @param {{globSync: (pattern: string, options: {cwd: string}) => Iterable<string>}} fs + * @param {string} cwd + * @param {string[]} [patterns] + */ +export function collectScannedFiles(fs, cwd, patterns = []) { + const globs = patterns.length ? patterns : DEFAULT_GLOBS; + const files = new Set(); + for (const glob of globs) { + for (const match of fs.globSync(glob, { cwd })) { + if (!/\.tsx?$/.test(match)) continue; + if (isExcluded(match)) continue; + files.add(match); + } + } + return files; +} diff --git a/apps/web/scripts/lib/nonjsx-scope.test.ts b/apps/web/scripts/lib/nonjsx-scope.test.ts new file mode 100644 index 0000000000..8dd9269f3d --- /dev/null +++ b/apps/web/scripts/lib/nonjsx-scope.test.ts @@ -0,0 +1,103 @@ +/** + * The scanned scope of `check-nonjsx-copy.mjs`. + * + * This exists because the check's failure mode is silent. It defaulted to + * `i18nGuardFiles` for a long time and reported "✓ no non-JSX copy — 1421 + * guarded file(s) checked" the whole time, while 1141 source files were judged + * by neither it nor the eslint rule. A clean run and a shrunken scope read the + * same; only the denominator tells them apart, so the denominator is asserted. + */ +import fs from "node:fs"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { i18nGuardFiles } from "../../eslint.i18n.options.mjs"; +import { collectScannedFiles, isExcluded } from "./nonjsx-scope.mjs"; + +const WEB_ROOT = path.resolve(import.meta.dirname, "..", ".."); + +// `fs.globSync` landed in Node 22 and this repo pins @types/node 20, so the +// types do not know about it yet. The runtime (Node 24) does; the scripts have +// used it since they were written. +type GlobFs = { globSync: (pattern: string, options: { cwd: string }) => Iterable<string> }; +const globFs = fs as unknown as GlobFs; + +const scanned = () => collectScannedFiles(globFs, WEB_ROOT); + +describe("isExcluded", () => { + it("keeps ordinary source anywhere in the tree", () => { + for (const file of [ + "lib/utils.ts", + "components/task/task-cleanup-summary.ts", + "hooks/domains/office/use-agent-route.ts", + "app/stats/stats-utils.ts", + "src/spa-routes.tsx", + "vite.config.ts", + ]) { + expect(isExcluded(file)).toBe(false); + } + }); + + it("drops tooling, build output, the catalog itself, and test support", () => { + for (const file of [ + "node_modules/pkg/index.ts", + "dist/bundle.ts", + "e2e/tests/i18n/pseudo-coverage.spec.ts", + "scripts/check-nonjsx-copy.mjs.ts", + path.join("src", "locales", "en.ts"), + "lib/thing.test.ts", + "components/thing.test.tsx", + "lib/ws/handlers/tasks.test-helpers.ts", + "lib/state/dockview-panel-actions.test-utils.ts", + "global.d.ts", + ]) { + expect(isExcluded(file)).toBe(true); + } + }); + + it("does not drop a file merely because a path segment starts with an excluded name", () => { + // `dist` vs `distribution`, `e2e` vs `e2e-helpers`: prefix matching without + // the separator would silently unguard a real directory. + expect(isExcluded("lib/distribution/plan.ts")).toBe(false); + expect(isExcluded("components/scriptsy/panel.tsx")).toBe(false); + }); +}); + +describe("the default scope is the whole source tree", () => { + it("scans thousands of files, not the guard allowlist", () => { + const files = scanned(); + // The allowlist matched ~1420 files when this check was scoped to it. The + // assertion is deliberately a floor well above that: it fails if anyone + // narrows the scope back toward the allowlist, and does not churn as the + // tree grows. + expect(files.size).toBeGreaterThan(2000); + }); + + it("covers files that are not on the guard allowlist", () => { + const allowlisted = new Set<string>(); + for (const entry of i18nGuardFiles) { + for (const match of globFs.globSync(entry, { cwd: WEB_ROOT })) allowlisted.add(match); + } + const files = scanned(); + const offAllowlist = [...files].filter((file) => !allowlisted.has(file)); + + expect(offAllowlist.length).toBeGreaterThan(500); + }); + + it("excludes e2e, scripts and the locale catalogs from the default scan", () => { + const files = [...scanned()]; + + expect(files.some((file) => file.startsWith(`e2e${path.sep}`))).toBe(false); + expect(files.some((file) => file.startsWith(`scripts${path.sep}`))).toBe(false); + expect(files.some((file) => file.includes(path.join("src", "locales")))).toBe(false); + expect(files.some((file) => /\.test\.tsx?$/.test(file))).toBe(false); + }); + + it("still honours explicit globs, so the work-list use keeps working", () => { + const files = collectScannedFiles(globFs, WEB_ROOT, ["lib/utils/*.ts"]); + + expect(files.size).toBeGreaterThan(0); + expect([...files].every((file) => file.startsWith(path.join("lib", "utils")))).toBe(true); + }); +}); diff --git a/apps/web/src/locales/_verbatim.json b/apps/web/src/locales/_verbatim.json new file mode 100644 index 0000000000..f70e6fc92c --- /dev/null +++ b/apps/web/src/locales/_verbatim.json @@ -0,0 +1,161 @@ +{ + "agents:flag": "Technical term kept in English across localized developer tooling.", + "agents:terminal": "Technical term kept in English across localized developer tooling.", + "automations:summaryWebhook": "Technical term kept in English across localized developer tooling.", + "automations:triggerCategoryWebhook": "Technical term kept in English across localized developer tooling.", + "automations:triggerLabelGithubCi": "Brand or product name, spelled the same in every language.", + "automations:triggerLabelGithubPr": "Brand or product name, spelled the same in every language.", + "automations:triggerLabelWebhook": "Technical term kept in English across localized developer tooling.", + "automations:webhookUrlLabel": "Technical term kept in English across localized developer tooling.", + "azuredevops:prompt": "Technical term kept in English across localized developer tooling.", + "azuredevops:pullRequestKindLower": "Code-host term kept in English across localized developer tooling.", + "azuredevops:pullRequestKindTitle": "Code-host term kept in English across localized developer tooling.", + "azuredevops:pullRequests": "Code-host term kept in English across localized developer tooling.", + "azuredevops:pullRequestsTab": "Code-host term kept in English across localized developer tooling.", + "chat:attachmentDeliveryPrompt": "Technical term kept in English across localized developer tooling.", + "chat:chatTabName": "Nothing but interpolated values and punctuation; there is no prose to translate.", + "common:commandGroupWorkspace": "Technical term kept in English across localized developer tooling.", + "common:commandPreviewPull": "Git command name; the user types it and reads it in git's own output.", + "common:executor": "Technical term kept in English across localized developer tooling.", + "common:gitOpCommit": "Git command name; the user types it and reads it in git's own output.", + "common:gitOpMerge": "Git command name; the user types it and reads it in git's own output.", + "common:gitOpPull": "Git command name; the user types it and reads it in git's own output.", + "common:gitOpPush": "Git command name; the user types it and reads it in git's own output.", + "common:gitOpRebase": "Git command name; the user types it and reads it in git's own output.", + "common:gitOperationScoped": "Nothing but interpolated values and punctuation; there is no prose to translate.", + "common:plugins": "Technical term kept in English across localized developer tooling.", + "common:prompts": "Technical term kept in English across localized developer tooling.", + "common:runtimeGitWorktree": "Technical term kept in English across localized developer tooling.", + "common:runtimeLocal": "Technical term kept in English across localized developer tooling.", + "common:scopeTitleWithShortcut": "Nothing but interpolated values and punctuation; there is no prose to translate.", + "common:script": "Technical term kept in English across localized developer tooling.", + "common:shell": "Technical term kept in English across localized developer tooling.", + "common:terminal": "Technical term kept in English across localized developer tooling.", + "common:terminalNumbered": "Technical term kept in English across localized developer tooling.", + "executors:dockerfile": "Brand or product name, spelled the same in every language.", + "executors:janeDeveloper": "Example/sample content, not product copy.", + "executors:sshIdentitySourceAgent": "Nothing but interpolated values and punctuation; there is no prose to translate.", + "executors:typeLocal": "Technical term kept in English across localized developer tooling.", + "executors:typeWorktree": "Technical term kept in English across localized developer tooling.", + "github:githubCli": "Brand or product name, spelled the same in every language.", + "github:issue": "Code-host term kept in English across localized developer tooling.", + "github:issues": "Code-host term kept in English across localized developer tooling.", + "github:kindPullRequest": "Code-host term kept in English across localized developer tooling.", + "github:mergeMethodRebaseShort": "Git command name; the user types it and reads it in git's own output.", + "github:mergeMethodSquashShort": "Git command name; the user types it and reads it in git's own output.", + "github:prTaskStatusNumber": "Code-host term kept in English across localized developer tooling.", + "github:prsOnDay_one": "Code-host term kept in English across localized developer tooling.", + "github:prsOnDay_other": "Code-host term kept in English across localized developer tooling.", + "github:pullRequestCount_one": "Code-host term kept in English across localized developer tooling.", + "github:pullRequestCount_other": "Code-host term kept in English across localized developer tooling.", + "github:pullRequestNumbered": "Code-host term kept in English across localized developer tooling.", + "github:pullRequests": "Code-host term kept in English across localized developer tooling.", + "github:titleIssues": "Code-host term kept in English across localized developer tooling.", + "github:titlePullRequests": "Code-host term kept in English across localized developer tooling.", + "github:webhook": "Technical term kept in English across localized developer tooling.", + "gitlab:bugBackend": "Example/sample content, not product copy.", + "gitlab:commits": "Git command name; the user types it and reads it in git's own output.", + "gitlab:glabCli": "Brand or product name, spelled the same in every language.", + "gitlab:issues": "Code-host term kept in English across localized developer tooling.", + "gitlab:kindMergeRequest": "Code-host term kept in English across localized developer tooling.", + "gitlab:mergeRequests": "Code-host term kept in English across localized developer tooling.", + "gitlab:pipeline": "Brand or product name, spelled the same in every language.", + "gitlab:titleIssues": "Code-host term kept in English across localized developer tooling.", + "gitlab:titleMergeRequests": "Code-host term kept in English across localized developer tooling.", + "integrations:commit": "Git command name; the user types it and reads it in git's own output.", + "integrations:merge": "Git command name; the user types it and reads it in git's own output.", + "integrations:pull": "Git command name; the user types it and reads it in git's own output.", + "integrations:pullRequest": "Code-host term kept in English across localized developer tooling.", + "integrations:pullRequestCount_one": "Code-host term kept in English across localized developer tooling.", + "integrations:pullRequestCount_other": "Code-host term kept in English across localized developer tooling.", + "integrations:pullRequestNumber": "Code-host term kept in English across localized developer tooling.", + "integrations:pullRequestShortCount_one": "Code-host term kept in English across localized developer tooling.", + "integrations:pullRequestShortCount_other": "Code-host term kept in English across localized developer tooling.", + "integrations:push": "Git command name; the user types it and reads it in git's own output.", + "integrations:rebase": "Git command name; the user types it and reads it in git's own output.", + "jira:atlassianCloud": "Brand or product name, spelled the same in every language.", + "jira:serverDataCenter": "Brand or product name, spelled the same in every language.", + "kanban:kanban": "Brand or product name, spelled the same in every language.", + "kanban:pipeline": "Brand or product name, spelled the same in every language.", + "lsp:elapsedMinutes_one": "Technical term kept in English across localized developer tooling.", + "lsp:elapsedMinutes_other": "Technical term kept in English across localized developer tooling.", + "lsp:progressPercentage": "Nothing but interpolated values and punctuation; there is no prose to translate.", + "office:branch": "Technical term kept in English across localized developer tooling.", + "office:cron": "Name of a command-line tool or binary, typed verbatim by the user.", + "office:discord": "Brand or product name, spelled the same in every language.", + "office:executor": "Technical term kept in English across localized developer tooling.", + "office:prompt": "Technical term kept in English across localized developer tooling.", + "office:push": "Technical term kept in English across localized developer tooling.", + "office:runtime": "Technical term kept in English across localized developer tooling.", + "office:skillSourceLocal": "Technical term kept in English across localized developer tooling.", + "office:statusBacklog": "Technical term kept in English across localized developer tooling.", + "office:telegram": "Brand or product name, spelled the same in every language.", + "office:wakeHeartbeat": "Technical term kept in English across localized developer tooling.", + "office:webhook": "Technical term kept in English across localized developer tooling.", + "plugins:manifestWebhooks": "Technical term kept in English across localized developer tooling.", + "plugins:repo": "Technical term kept in English across localized developer tooling.", + "settings:destinationGlobal": "Technical term kept in English across localized developer tooling.", + "settings:externalMcpSse": "Protocol or format name, fixed by its specification.", + "settings:externalMcpStreamableHttp": "Protocol or format name, fixed by its specification.", + "settings:globalScope": "Technical term kept in English across localized developer tooling.", + "settings:lspLanguageExperimental": "Nothing but interpolated values and punctuation; there is no prose to translate.", + "settings:nerdFonts": "Brand or product name, spelled the same in every language.", + "settings:soundPresetChime": "Brand or product name, spelled the same in every language.", + "settings:soundPresetDing": "Brand or product name, spelled the same in every language.", + "settings:soundPresetPlim": "Brand or product name, spelled the same in every language.", + "settings:terminal": "Technical term kept in English across localized developer tooling.", + "settings:tokens": "Technical term kept in English across localized developer tooling.", + "settings:updateChannelNightly": "Kandev domain term; the product uses the English word in every locale.", + "settings:vsCodeRemoteSsh": "Brand or product name, spelled the same in every language.", + "sidebar:kanban": "Brand or product name, spelled the same in every language.", + "sidebar:quickChatTerminalTab_one": "Technical term kept in English across localized developer tooling.", + "sidebar:quickChatTerminalTab_other": "Technical term kept in English across localized developer tooling.", + "stats:repoMessagesCount_one": "Technical term kept in English across localized developer tooling.", + "stats:repoMessagesCount_other": "Technical term kept in English across localized developer tooling.", + "system:aboutCommit": "Git command name; the user types it and reads it in git's own output.", + "system:databaseDriver": "Technical term kept in English across localized developer tooling.", + "system:databaseVacuum": "Name of a command-line tool or binary, typed verbatim by the user.", + "system:diskRowWorktrees": "Technical term kept in English across localized developer tooling.", + "task:agentctl": "Brand or product name, spelled the same in every language.", + "task:branch": "Technical term kept in English across localized developer tooling.", + "task:chat": "Technical term kept in English across localized developer tooling.", + "task:commit": "Git command name; the user types it and reads it in git's own output.", + "task:commits": "Git command name; the user types it and reads it in git's own output.", + "task:control": "Names a physical keyboard key, as printed on the key.", + "task:diff": "Git command name; the user types it and reads it in git's own output.", + "task:executor2": "Technical term kept in English across localized developer tooling.", + "task:executorTypeLocalWorktree": "Technical term kept in English across localized developer tooling.", + "task:filterStateBacklog": "Technical term kept in English across localized developer tooling.", + "task:issue": "Code-host term kept in English across localized developer tooling.", + "task:keyPageDown": "Names a physical keyboard key, as printed on the key.", + "task:keyPageUp": "Names a physical keyboard key, as printed on the key.", + "task:merge": "Git command name; the user types it and reads it in git's own output.", + "task:mergeRequestLabel": "Code-host term kept in English across localized developer tooling.", + "task:modelSelectionExecutor": "Technical term kept in English across localized developer tooling.", + "task:panelTerminal": "Technical term kept in English across localized developer tooling.", + "task:prompt": "Technical term kept in English across localized developer tooling.", + "task:proxy": "Technical term kept in English across localized developer tooling.", + "task:pull": "Git command name; the user types it and reads it in git's own output.", + "task:pullRequest": "Code-host term kept in English across localized developer tooling.", + "task:pullRequest2": "Code-host term kept in English across localized developer tooling.", + "task:pullRequests": "Code-host term kept in English across localized developer tooling.", + "task:push": "Git command name; the user types it and reads it in git's own output.", + "task:rebase": "Git command name; the user types it and reads it in git's own output.", + "task:repo": "Technical term kept in English across localized developer tooling.", + "task:scripts": "Technical term kept in English across localized developer tooling.", + "task:sprite": "Brand or product name, spelled the same in every language.", + "task:statusBacklog": "Technical term kept in English across localized developer tooling.", + "task:terminal": "Technical term kept in English across localized developer tooling.", + "task:terminalCount_one": "Technical term kept in English across localized developer tooling.", + "task:terminalWithSeq": "Technical term kept in English across localized developer tooling.", + "task:ticket": "Code-host term kept in English across localized developer tooling.", + "task:tokenCount_one": "Technical term kept in English across localized developer tooling.", + "task:tokenCount_other": "Technical term kept in English across localized developer tooling.", + "task:vsCode": "Brand or product name, spelled the same in every language.", + "task:vsCodeServer": "Brand or product name, spelled the same in every language.", + "task:worktree": "Technical term kept in English across localized developer tooling.", + "task:worktreeNumbered": "Technical term kept in English across localized developer tooling.", + "workflows:branchLabel": "Technical term kept in English across localized developer tooling.", + "workspaces:globalSecretScope": "Technical term kept in English across localized developer tooling.", + "workspaces:sourceLocal": "Technical term kept in English across localized developer tooling." +} diff --git a/apps/web/src/locales/en/automations.json b/apps/web/src/locales/en/automations.json index 4ee6bfa841..140cc7b0d4 100644 --- a/apps/web/src/locales/en/automations.json +++ b/apps/web/src/locales/en/automations.json @@ -220,5 +220,15 @@ "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" + "scheduleAtSeparator": "at", + "failedToLoadAutomationActivity": "Failed to load automation activity", + "failedToLoadAutomations": "Failed to load automations", + "failedToLoadRuns": "Failed to load runs", + "failedToLoadThisAutomation": "Failed to load this automation", + "nextFiringConcurrencyPaused": "Paused - a run is still open (max {{max}} at a time)", + "nextFiringNoSchedule": "No schedule - only runs when you trigger it", + "nextFiringScheduleOff": "Schedule trigger is switched off", + "nextFiringSwitchedOff": "Switched off - it will not fire", + "runNoReportRecorded": "No report recorded.", + "runStillRunningNoReport": "Still running - no report yet." } diff --git a/apps/web/src/locales/en/chat.json b/apps/web/src/locales/en/chat.json index 05149ddfd8..ee048b17f4 100644 --- a/apps/web/src/locales/en/chat.json +++ b/apps/web/src/locales/en/chat.json @@ -141,5 +141,9 @@ "composerSteerPlaceholder": "Send now; delivered to the running turn", "unknownError": "Unknown error", "useWorkspaceDefault": "Use workspace default...", - "useWorkspaceDefaultOption": "Use workspace default" + "useWorkspaceDefaultOption": "Use workspace default", + "emptyTurnKnownCommand": "`/{{command}}` ran but produced no output. {{hint}}", + "emptyTurnNoOutput": "The agent finished without producing any output.", + "emptyTurnResendHint": "Try resending your request as a normal message, without the leading slash.", + "emptyTurnUnknownCommand": "`/{{command}}` isn't a command this agent recognizes, so it returned no output. {{hint}}" } diff --git a/apps/web/src/locales/en/common.json b/apps/web/src/locales/en/common.json index 738cb9562f..7cfd47baa7 100644 --- a/apps/web/src/locales/en/common.json +++ b/apps/web/src/locales/en/common.json @@ -482,5 +482,30 @@ "weekdayWednesday": "Wednesday", "weekdayThursday": "Thursday", "weekdayFriday": "Friday", - "weekdaySaturday": "Saturday" + "weekdaySaturday": "Saturday", + "checking": "Checking", + "configured": "Configured", + "connected": "Connected", + "debug": "Debug", + "failedToLoadRoute": "Failed to load route", + "improveStepImprove": "Agent reads the report, explores the codebase, and implements the change with TDD. Runs make fmt, typecheck, test, lint, then commits.", + "improveStepOpenIssue": "Agent gathers every required field from the repository template, confirms the public draft with you, and publishes the GitHub issue.", + "improveStepPr": "Agent runs the pr skill: pushes the branch and opens a pull request to kdlbs/kandev for the maintainers to review.", + "improveStepTest": "Agent boots a secondary kandev instance with make dev and tells you what to verify. You confirm the change works in the running app.", + "noOptionFound": "No option found.", + "searchPlaceholder": "Search...", + "selectOption": "Select option...", + "terminalNumbered": "Terminal {{seq}}", + "preciseDated": "{{date}}, {{time}}", + "preciseToday": "Today, {{time}}", + "preciseYesterday": "Yesterday, {{time}}", + "relativeDaysAgo_one": "{{count}}d ago", + "relativeDaysAgo_other": "{{count}}d ago", + "relativeHoursAgo_one": "{{count}}h ago", + "relativeHoursAgo_other": "{{count}}h ago", + "relativeMinutesAgo_one": "{{count}}m ago", + "relativeMinutesAgo_other": "{{count}}m ago", + "relativeSecondsAgo_one": "{{count}}s ago", + "relativeSecondsAgo_other": "{{count}}s ago", + "relativeYesterday": "yesterday" } diff --git a/apps/web/src/locales/en/diff.json b/apps/web/src/locales/en/diff.json index 6afa211df7..c6cea3dacb 100644 --- a/apps/web/src/locales/en/diff.json +++ b/apps/web/src/locales/en/diff.json @@ -46,5 +46,11 @@ "walkthrough": "Walkthrough", "walkthroughNoteAdded": "Walkthrough note added", "walkthroughNoteQueued": "Walkthrough note queued", - "walkthroughNoteSent": "Walkthrough note sent" + "walkthroughNoteSent": "Walkthrough note sent", + "fileStatusAdded": "Added", + "fileStatusDeleted": "Deleted", + "fileStatusModified": "Modified", + "fileStatusMoved": "Moved", + "fileStatusMovedFrom": "Moved from {{oldPath}}", + "fileStatusUntracked": "Untracked" } diff --git a/apps/web/src/locales/en/github.json b/apps/web/src/locales/en/github.json index 520a795c6c..7ce5b52b73 100644 --- a/apps/web/src/locales/en/github.json +++ b/apps/web/src/locales/en/github.json @@ -581,5 +581,12 @@ "timeAgoMonths_one": "{{count}}mo ago", "timeAgoMonths_other": "{{count}}mo ago", "timeAgoYears_one": "{{count}}y ago", - "timeAgoYears_other": "{{count}}y ago" + "timeAgoYears_other": "{{count}}y ago", + "appRegistrationsUnavailable": "GitHub App registrations are unavailable", + "failedToFetchPrCommits": "Failed to fetch PR commits", + "failedToFetchPrFeedback": "Failed to fetch PR feedback", + "failedToFetchPrFiles": "Failed to fetch PR files", + "failedToLoadCiAutomationOptions": "Failed to load CI automation options.", + "failedToSearchGitHub": "Failed to search GitHub", + "pullRequestNumbered": "Pull Request #{{number}}" } diff --git a/apps/web/src/locales/en/gitlab.json b/apps/web/src/locales/en/gitlab.json index b9a06ea11a..a9d50a70ef 100644 --- a/apps/web/src/locales/en/gitlab.json +++ b/apps/web/src/locales/en/gitlab.json @@ -351,5 +351,13 @@ "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}}", - "memberSearchFailed": "Member search failed" + "memberSearchFailed": "Member search failed", + "addedMrFeedbackToTaskContext": "Added merge request feedback to task context", + "failedToLoadMergeRequest": "Failed to load merge request", + "failedToLoadMrAutomationOptions": "Failed to load MR automation options.", + "failedToSearchGitLab": "Failed to search GitLab", + "presetAssigned": "Assigned", + "presetAuthored": "Authored", + "presetCreated": "Created", + "presetReviewRequested": "Review requested" } diff --git a/apps/web/src/locales/en/integrations.json b/apps/web/src/locales/en/integrations.json index 68d5c60ff3..2ab16eb92e 100644 --- a/apps/web/src/locales/en/integrations.json +++ b/apps/web/src/locales/en/integrations.json @@ -189,5 +189,6 @@ "waitingForTheNextBackendHealth": "Waiting for the next backend health check…", "cursorPaginationSummary_one": "Page {{page}} · {{count}} result", "cursorPaginationSummary_other": "Page {{page}} · {{count}} results", - "allRepositories": "All repositories" + "allRepositories": "All repositories", + "changeRequestCreationFailed": "Change-request creation failed" } diff --git a/apps/web/src/locales/en/jira.json b/apps/web/src/locales/en/jira.json index 0ae54d73ad..09c3079bee 100644 --- a/apps/web/src/locales/en/jira.json +++ b/apps/web/src/locales/en/jira.json @@ -208,5 +208,7 @@ "watcherUpdated": "Watcher updated", "workflow": "Workflow", "workflowStep": "Workflow Step", - "yourJiraSessionExpiredOrNeeds": "Your Jira session expired or needs step-up authentication. Reconnect to view this ticket." + "yourJiraSessionExpiredOrNeeds": "Your Jira session expired or needs step-up authentication. Reconnect to view this ticket.", + "builtinViewAssignedToMe": "Assigned to me", + "builtinViewUnassigned": "Unassigned" } diff --git a/apps/web/src/locales/en/kanban.json b/apps/web/src/locales/en/kanban.json index da868030ee..4df6617965 100644 --- a/apps/web/src/locales/en/kanban.json +++ b/apps/web/src/locales/en/kanban.json @@ -100,5 +100,10 @@ "workflowStepFallback": "workflow step {{stepId}}", "columns": "Columns", "columnsForWorkflow": "Columns for {{name}}", - "needsReassignment": "Needs Reassignment" + "needsReassignment": "Needs Reassignment", + "noTasksYet": "No tasks yet", + "noWorkflowsAvailableYet": "No workflows available yet.", + "pullToRefreshPull": "Pull to refresh", + "pullToRefreshRefreshing": "Refreshing tasks", + "pullToRefreshRelease": "Release to refresh" } diff --git a/apps/web/src/locales/en/office.json b/apps/web/src/locales/en/office.json index 54f447fd9a..a91d3b1044 100644 --- a/apps/web/src/locales/en/office.json +++ b/apps/web/src/locales/en/office.json @@ -894,5 +894,10 @@ "writeTheDatabaseStateToOn": "Write the database state to on-disk YAML files.", "yourModel": "your model", "zoomIn": "Zoom in", - "zoomOut": "Zoom out" + "zoomOut": "Zoom out", + "failedToLoadAgentRoute": "Failed to load agent route", + "failedToLoadProviderHealth": "Failed to load provider health", + "failedToLoadRouteAttempts": "Failed to load route attempts", + "failedToLoadRoutingConfig": "Failed to load routing config", + "failedToLoadRoutingPreview": "Failed to load routing preview" } diff --git a/apps/web/src/locales/en/review.json b/apps/web/src/locales/en/review.json index ca5c97126f..847620fe32 100644 --- a/apps/web/src/locales/en/review.json +++ b/apps/web/src/locales/en/review.json @@ -89,5 +89,9 @@ "findingSentToAgent": "Finding sent to the agent", "walkthroughRequestQueued": "Walkthrough request queued", "walkthroughRequestSent": "Walkthrough request sent", - "couldNotStartReview": "Could not start the review" + "couldNotStartReview": "Could not start the review", + "severityBlocker": "Blocker", + "severityMajor": "Major", + "severityMinor": "Minor", + "severityNit": "Nit" } diff --git a/apps/web/src/locales/en/settings.json b/apps/web/src/locales/en/settings.json index 88aea56662..509f98c6b6 100644 --- a/apps/web/src/locales/en/settings.json +++ b/apps/web/src/locales/en/settings.json @@ -727,5 +727,8 @@ "noValidDestinations": "No valid destinations available.", "transferFailed": "Could not copy or move the secret.", "destinationGlobal": "Global", - "secretNameTooLong": "Name must be at most 100 bytes." + "secretNameTooLong": "Name must be at most 100 bytes.", + "keyValueAddItem": "Add Item", + "keyValueKeyPlaceholder": "Key", + "keyValueValuePlaceholder": "Value" } diff --git a/apps/web/src/locales/en/sidebar.json b/apps/web/src/locales/en/sidebar.json index 3b9ae83705..30e6838209 100644 --- a/apps/web/src/locales/en/sidebar.json +++ b/apps/web/src/locales/en/sidebar.json @@ -110,5 +110,10 @@ "work": "Work", "workspaceFallback": "Workspace", "taskPreferences": "Sidebar task preferences", - "views": "Sidebar views" + "views": "Sidebar views", + "failedToLoadArchivedTasks": "Failed to load archived tasks", + "failedToSyncSidebarTaskPrefs": "Failed to sync sidebar task prefs", + "failedToSyncSidebarViews": "Failed to sync sidebar views", + "newViewNumbered": "New view {{suffix}}", + "untitledView": "Untitled view" } diff --git a/apps/web/src/locales/en/stats.json b/apps/web/src/locales/en/stats.json index 9a31ebfed5..9eadee3154 100644 --- a/apps/web/src/locales/en/stats.json +++ b/apps/web/src/locales/en/stats.json @@ -79,5 +79,25 @@ "turnsPerTask": "Turns per task", "userMsgs": "User msgs", "userShare": "User share", - "workload": "Workload" + "workload": "Workload", + "failedToLoadSection": "Failed to load section", + "failedToLoadStats": "Failed to load stats", + "loadingStats": "Loading stats…", + "noGitActivity": "no git activity", + "subtitleReady": "{{tasks}} · {{sessions}} · {{duration}}", + "subtitleSessionCount_one": "{{count}} session", + "subtitleSessionCount_other": "{{count}} sessions", + "subtitleTaskCount_one": "{{count}} task", + "subtitleTaskCount_other": "{{count}} tasks", + "summaryCompletedInRange": "- Completed ({{range}}): {{completed}}", + "summaryGit": "- Git: {{git}}", + "summaryGitActivity_one": "{{count}} commit, +{{insertions}}/-{{deletions}}", + "summaryGitActivity_other": "{{count}} commits, +{{insertions}}/-{{deletions}}", + "summaryHeading": "*Kandev Stats - {{range}}*", + "summaryRepos_one": "- Repos: {{count}} tracked · Top repo: {{topRepo}}", + "summaryRepos_other": "- Repos: {{count}} tracked · Top repo: {{topRepo}}", + "summaryTasks": "- Tasks: {{total}} total ({{done}} done, {{inProgress}} in progress) · {{completion}} completion", + "summaryTime": "- Time: {{total}} total · {{average}} avg/task", + "summaryTopRepo_one": "{{repository}} ({{count}} task)", + "summaryTopRepo_other": "{{repository}} ({{count}} tasks)" } diff --git a/apps/web/src/locales/en/task.json b/apps/web/src/locales/en/task.json index 6db4a71aee..407b7e82b4 100644 --- a/apps/web/src/locales/en/task.json +++ b/apps/web/src/locales/en/task.json @@ -1644,5 +1644,64 @@ "failedToLoadFileContent": "Failed to load file content", "openFileInProvider": "Open file in {{provider}}", "failedToResumeAndRestore": "Failed to resume session - workspace restore also unavailable", - "sessionStatusGenerating": "Generating" + "sessionStatusGenerating": "Generating", + "branchDisabledLocalExecutor": "The local executor uses your repository's current checkout, so the branch can't change here. Toggle 'Fork a new branch' to pick a different base.", + "branchDisabledNoBranches": "No branches available for this repository.", + "branchDisabledSelectRepositoryFirst": "Select a repository first.", + "branchPrefixCurrent": "current: ", + "branchPrefixFrom": "from: ", + "branchPrefixWillSwitchTo": "will switch to: ", + "branchTooltipCurrent": "Your repository's current checkout. The agent runs against it as-is, no git operations.", + "branchTooltipFrom": "Base branch. A new branch (or worktree) is forked from here and the agent runs there.", + "branchTooltipGeneric": "Branch the agent will run against.", + "branchTooltipWillSwitchTo": "The backend will run `git checkout` to switch your repository to this branch before the agent starts.", + "cleanupAgentSessionsStopped": "Any running agent sessions will be stopped.", + "cleanupBulkLocal_one": "{{count}} local task - your repo folders won't be touched.", + "cleanupBulkLocal_other": "{{count}} local tasks - your repo folders won't be touched.", + "cleanupBulkLocalDocker_one": "{{count}} Docker container will be removed.", + "cleanupBulkLocalDocker_other": "{{count}} Docker containers will be removed.", + "cleanupBulkRemoteDocker_one": "{{count}} remote Docker container will be removed.", + "cleanupBulkRemoteDocker_other": "{{count}} remote Docker containers will be removed.", + "cleanupBulkSprites_one": "{{count}} Sprites sandbox will be destroyed (uncommitted work lost).", + "cleanupBulkSprites_other": "{{count}} Sprites sandboxes will be destroyed (uncommitted work lost).", + "cleanupBulkSsh_one": "{{count}} remote task directory will be removed (best-effort).", + "cleanupBulkSsh_other": "{{count}} remote task directories will be removed (best-effort).", + "cleanupBulkWorktree_one": "{{count}} worktree and its branch will be deleted.", + "cleanupBulkWorktree_other": "{{count}} worktrees and their branches will be deleted.", + "cleanupSingleLocal": "The agent ran directly in your repo - your files, branch, and folder are not touched.", + "cleanupSingleLocalDocker": "The Docker container running this task will be stopped and removed. Your host repo is not touched.", + "cleanupSingleRemoteDocker": "The remote Docker container running this task will be stopped and removed.", + "cleanupSingleSprites": "The Sprites cloud sandbox for this task will be destroyed. Any uncommitted work inside it will be lost.", + "cleanupSingleSsh": "The task directory on the remote host will be removed (best-effort). Your local repo is not touched.", + "cleanupSingleWorktree": "The task's git worktree and its branch will be deleted. Your main repo and other branches are not affected.", + "enhancedPromptCopiedToClipboard": "Enhanced prompt copied to clipboard.", + "enhancedPromptCouldNotBeCopied": "Enhanced prompt could not be copied.", + "enhancedPromptCouldNotBeInserted": "Enhanced prompt was generated but could not be inserted.", + "environmentNotCreated": "not created", + "failedToCreateSubtask": "Failed to create subtask", + "failedToDetachTask": "Failed to detach task", + "failedToFetchCumulativeDiff": "Failed to fetch cumulative diff", + "failedToLoadFileTree": "Failed to load file tree", + "gitOperationFailedGeneric": "Operation failed", + "invalidRegex": "Invalid regex", + "messageFetchLimitHint": "The fetch limit may be too small for this session's last turn - user prompt and agent replies live further back. Paginate or raise the limit to see them.", + "noBranchesShort": "no branches", + "referenceSearchFailedRetry": "Reference search failed. Try again.", + "sessionFailedUnexpectedly": "Session failed unexpectedly", + "sessionNotAvailableForInput": "The selected session is not available for input.", + "sessionStatusStarting": "Starting", + "sessionStatusTurnFinished": "Turn Finished", + "summarizeCouldNotGenerate": "Could not generate a summary. Check that the summarize utility agent is configured and enabled in settings.", + "summarizeFailed": "Summarize failed", + "taskClosed": "Task closed", + "taskClosedAutomatically": "This task was closed automatically.", + "taskClosedIssueClosed": "Its issue was closed, so this task was closed automatically.", + "taskClosedPrApproved": "Its pull request was approved, so this review task was closed automatically.", + "taskClosedPrMergedOrClosed": "Its pull request was merged or closed, so this review task was closed automatically.", + "taskFailedToStart": "Task failed to start", + "taskTitledWasClosed": "\"{{title}}\" was closed", + "updateFailed": "Update failed", + "workspaceRoot": "Workspace root", + "worktreeExecutorRequiresRepository": "Worktree executor requires a repository.", + "worktreeNumbered": "Worktree {{index}}" } diff --git a/apps/web/src/locales/en/workflows.json b/apps/web/src/locales/en/workflows.json index e9daab441f..918b0c8a3a 100644 --- a/apps/web/src/locales/en/workflows.json +++ b/apps/web/src/locales/en/workflows.json @@ -262,5 +262,8 @@ "workflows": "Workflows", "workflowsSectionDescription": "Create autonomous pipelines with automated transitions or manual workflows where you move tasks yourself", "workflowSteps": "Workflow Steps", - "workflowSyncCompleted": "Workflow sync completed" + "workflowSyncCompleted": "Workflow sync completed", + "loadingSteps": "Loading steps…", + "noStepsInThisWorkflow": "No steps in this workflow", + "selectAWorkflowFirst": "Select a workflow first" } diff --git a/apps/web/src/locales/en/workspaces.json b/apps/web/src/locales/en/workspaces.json index da0a7f5749..5eb90566b4 100644 --- a/apps/web/src/locales/en/workspaces.json +++ b/apps/web/src/locales/en/workspaces.json @@ -121,5 +121,10 @@ "worktreeSync": "Worktree Sync", "enterRepositoryName": "Enter a repository name.", "chooseDifferentRepositoryName": "Choose a different repository name.", - "repositoryNameMustBeOneFolder": "The repository name must be one folder name." + "repositoryNameMustBeOneFolder": "The repository name must be one folder name.", + "sourceChooseAFolder": "Choose a folder.", + "sourceChooseRepoAndBranch": "Choose a repository and base branch.", + "sourceEnterValidRemoteUrl": "Enter a valid remote repository URL and base branch.", + "sourceFolderAlreadySelected": "This folder is already selected.", + "sourceRepoBranchAlreadySelected": "This repository and branch are already selected." } diff --git a/apps/web/src/locales/pseudo/automations.json b/apps/web/src/locales/pseudo/automations.json index 42c15cc211..bcfa3cf62b 100644 --- a/apps/web/src/locales/pseudo/automations.json +++ b/apps/web/src/locales/pseudo/automations.json @@ -220,5 +220,15 @@ "frequencyEveryWeek": "ēvēŕŷ ŵēēķ", "frequencyCustom": "à ćũśţōḿ śćĥēďũĺē", "scheduleUnparseable": "Ţĥàţ'ś ńōţ à śćĥēďũĺē ŵē ćàń ŕēàď. Ţŕŷ à 5-ƒĩēĺď ćŕōń ĺĩķē 0 9 * * 1-5.", - "scheduleAtSeparator": "àţ" + "scheduleAtSeparator": "àţ", + "failedToLoadAutomationActivity": "Ƒàĩĺēď ţō ĺōàď àũţōḿàţĩōń àćţĩvĩţŷ", + "failedToLoadAutomations": "Ƒàĩĺēď ţō ĺōàď àũţōḿàţĩōńś", + "failedToLoadRuns": "Ƒàĩĺēď ţō ĺōàď ŕũńś", + "failedToLoadThisAutomation": "Ƒàĩĺēď ţō ĺōàď ţĥĩś àũţōḿàţĩōń", + "nextFiringConcurrencyPaused": "Ƥàũśēď - à ŕũń ĩś śţĩĺĺ ōƥēń (ḿàx {{max}} àţ à ţĩḿē)", + "nextFiringNoSchedule": "Ńō śćĥēďũĺē - ōńĺŷ ŕũńś ŵĥēń ŷōũ ţŕĩĝĝēŕ ĩţ", + "nextFiringScheduleOff": "Śćĥēďũĺē ţŕĩĝĝēŕ ĩś śŵĩţćĥēď ōƒƒ", + "nextFiringSwitchedOff": "Śŵĩţćĥēď ōƒƒ - ĩţ ŵĩĺĺ ńōţ ƒĩŕē", + "runNoReportRecorded": "Ńō ŕēƥōŕţ ŕēćōŕďēď.", + "runStillRunningNoReport": "Śţĩĺĺ ŕũńńĩńĝ - ńō ŕēƥōŕţ ŷēţ." } diff --git a/apps/web/src/locales/pseudo/chat.json b/apps/web/src/locales/pseudo/chat.json index 993b661708..9a148a3f3f 100644 --- a/apps/web/src/locales/pseudo/chat.json +++ b/apps/web/src/locales/pseudo/chat.json @@ -131,7 +131,7 @@ "sortable": "śōŕţàƀĺē", "technicalDetails": "Ţēćĥńĩćàĺ ďēţàĩĺś", "managedRuntimeNpmTitle": "ńƥḿ ćōũĺď ńōţ ƥŕēƥàŕē ţĥē ŕũńţĩḿē", - "managedRuntimeNpmBody": "Ķàńďēvē ƒŕēśĥēď ƥàćķàĝē ďàţà àńď ŕēţŕĩēď ţĥē śàḿē ŕũńţĩḿē ōńćē.", + "managedRuntimeNpmBody": "Ķàńďēv ŕēƒŕēśĥēď ƥàćķàĝē ďàţà àńď ŕēţŕĩēď ţĥē śàḿē ŕũńţĩḿē ōńćē.", "managedRuntimeRetry": "Ŕēţŕŷ ŕũńţĩḿē", "theAssociatedWorktree": "Ţĥē àśśōćĩàţēď ŵōŕķţŕēē", "theTaskAndItsData": "Ţĥē ţàśķ àńď ĩţś ďàţà", @@ -141,5 +141,9 @@ "composerSteerPlaceholder": "Śēńď ńōŵ; ďēĺĩvēŕēď ţō ţĥē ŕũńńĩńĝ ţũŕń", "unknownError": "Ũńķńōŵń ēŕŕōŕ", "useWorkspaceDefault": "Ũśē ŵōŕķśƥàćē ďēƒàũĺţ...", - "useWorkspaceDefaultOption": "Ũśē ŵōŕķśƥàćē ďēƒàũĺţ" + "useWorkspaceDefaultOption": "Ũśē ŵōŕķśƥàćē ďēƒàũĺţ", + "emptyTurnKnownCommand": "`/{{command}}` ŕàń ƀũţ ƥŕōďũćēď ńō ōũţƥũţ. {{hint}}", + "emptyTurnNoOutput": "Ţĥē àĝēńţ ƒĩńĩśĥēď ŵĩţĥōũţ ƥŕōďũćĩńĝ àńŷ ōũţƥũţ.", + "emptyTurnResendHint": "Ţŕŷ ŕēśēńďĩńĝ ŷōũŕ ŕēqũēśţ àś à ńōŕḿàĺ ḿēśśàĝē, ŵĩţĥōũţ ţĥē ĺēàďĩńĝ śĺàśĥ.", + "emptyTurnUnknownCommand": "`/{{command}}` ĩśń'ţ à ćōḿḿàńď ţĥĩś àĝēńţ ŕēćōĝńĩźēś, śō ĩţ ŕēţũŕńēď ńō ōũţƥũţ. {{hint}}" } diff --git a/apps/web/src/locales/pseudo/common.json b/apps/web/src/locales/pseudo/common.json index 5862790b6e..0e2c50ec88 100644 --- a/apps/web/src/locales/pseudo/common.json +++ b/apps/web/src/locales/pseudo/common.json @@ -482,5 +482,30 @@ "weekdayWednesday": "Ŵēďńēśďàŷ", "weekdayThursday": "Ţĥũŕśďàŷ", "weekdayFriday": "Ƒŕĩďàŷ", - "weekdaySaturday": "Śàţũŕďàŷ" + "weekdaySaturday": "Śàţũŕďàŷ", + "checking": "Ćĥēćķĩńĝ", + "configured": "Ćōńƒĩĝũŕēď", + "connected": "Ćōńńēćţēď", + "debug": "Ďēƀũĝ", + "failedToLoadRoute": "Ƒàĩĺēď ţō ĺōàď ŕōũţē", + "improveStepImprove": "Àĝēńţ ŕēàďś ţĥē ŕēƥōŕţ, ēxƥĺōŕēś ţĥē ćōďēƀàśē, àńď ĩḿƥĺēḿēńţś ţĥē ćĥàńĝē ŵĩţĥ ŢĎĎ. Ŕũńś ḿàķē ƒḿţ, ţŷƥēćĥēćķ, ţēśţ, ĺĩńţ, ţĥēń ćōḿḿĩţś.", + "improveStepOpenIssue": "Àĝēńţ ĝàţĥēŕś ēvēŕŷ ŕēqũĩŕēď ƒĩēĺď ƒŕōḿ ţĥē ŕēƥōśĩţōŕŷ ţēḿƥĺàţē, ćōńƒĩŕḿś ţĥē ƥũƀĺĩć ďŕàƒţ ŵĩţĥ ŷōũ, àńď ƥũƀĺĩśĥēś ţĥē ĜĩţĤũƀ ĩśśũē.", + "improveStepPr": "Àĝēńţ ŕũńś ţĥē ƥŕ śķĩĺĺ: ƥũśĥēś ţĥē ƀŕàńćĥ àńď ōƥēńś à ƥũĺĺ ŕēqũēśţ ţō ķďĺƀś/ķàńďēv ƒōŕ ţĥē ḿàĩńţàĩńēŕś ţō ŕēvĩēŵ.", + "improveStepTest": "Àĝēńţ ƀōōţś à śēćōńďàŕŷ ķàńďēv ĩńśţàńćē ŵĩţĥ ḿàķē ďēv àńď ţēĺĺś ŷōũ ŵĥàţ ţō vēŕĩƒŷ. Ŷōũ ćōńƒĩŕḿ ţĥē ćĥàńĝē ŵōŕķś ĩń ţĥē ŕũńńĩńĝ àƥƥ.", + "noOptionFound": "Ńō ōƥţĩōń ƒōũńď.", + "searchPlaceholder": "Śēàŕćĥ...", + "selectOption": "Śēĺēćţ ōƥţĩōń...", + "terminalNumbered": "Ţēŕḿĩńàĺ {{seq}}", + "preciseDated": "{{date}}, {{time}}", + "preciseToday": "Ţōďàŷ, {{time}}", + "preciseYesterday": "Ŷēśţēŕďàŷ, {{time}}", + "relativeDaysAgo_one": "{{count}}ď àĝō", + "relativeDaysAgo_other": "{{count}}ď àĝō", + "relativeHoursAgo_one": "{{count}}ĥ àĝō", + "relativeHoursAgo_other": "{{count}}ĥ àĝō", + "relativeMinutesAgo_one": "{{count}}ḿ àĝō", + "relativeMinutesAgo_other": "{{count}}ḿ àĝō", + "relativeSecondsAgo_one": "{{count}}ś àĝō", + "relativeSecondsAgo_other": "{{count}}ś àĝō", + "relativeYesterday": "ŷēśţēŕďàŷ" } diff --git a/apps/web/src/locales/pseudo/diff.json b/apps/web/src/locales/pseudo/diff.json index adadbad4f7..a9a65cd2e1 100644 --- a/apps/web/src/locales/pseudo/diff.json +++ b/apps/web/src/locales/pseudo/diff.json @@ -46,5 +46,11 @@ "walkthrough": "Ŵàĺķţĥŕōũĝĥ", "walkthroughNoteAdded": "Ŵàĺķţĥŕōũĝĥ ńōţē àďďēď", "walkthroughNoteQueued": "Ŵàĺķţĥŕōũĝĥ ńōţē qũēũēď", - "walkthroughNoteSent": "Ŵàĺķţĥŕōũĝĥ ńōţē śēńţ" + "walkthroughNoteSent": "Ŵàĺķţĥŕōũĝĥ ńōţē śēńţ", + "fileStatusAdded": "Àďďēď", + "fileStatusDeleted": "Ďēĺēţēď", + "fileStatusModified": "Ḿōďĩƒĩēď", + "fileStatusMoved": "Ḿōvēď", + "fileStatusMovedFrom": "Ḿōvēď ƒŕōḿ {{oldPath}}", + "fileStatusUntracked": "Ũńţŕàćķēď" } diff --git a/apps/web/src/locales/pseudo/github.json b/apps/web/src/locales/pseudo/github.json index 85f0135d41..5dfbb0cb92 100644 --- a/apps/web/src/locales/pseudo/github.json +++ b/apps/web/src/locales/pseudo/github.json @@ -581,5 +581,12 @@ "timeAgoMonths_one": "{{count}}ḿō àĝō", "timeAgoMonths_other": "{{count}}ḿō àĝō", "timeAgoYears_one": "{{count}}ŷ àĝō", - "timeAgoYears_other": "{{count}}ŷ àĝō" + "timeAgoYears_other": "{{count}}ŷ àĝō", + "appRegistrationsUnavailable": "ĜĩţĤũƀ Àƥƥ ŕēĝĩśţŕàţĩōńś àŕē ũńàvàĩĺàƀĺē", + "failedToFetchPrCommits": "Ƒàĩĺēď ţō ƒēţćĥ ƤŔ ćōḿḿĩţś", + "failedToFetchPrFeedback": "Ƒàĩĺēď ţō ƒēţćĥ ƤŔ ƒēēďƀàćķ", + "failedToFetchPrFiles": "Ƒàĩĺēď ţō ƒēţćĥ ƤŔ ƒĩĺēś", + "failedToLoadCiAutomationOptions": "Ƒàĩĺēď ţō ĺōàď ĆĨ àũţōḿàţĩōń ōƥţĩōńś.", + "failedToSearchGitHub": "Ƒàĩĺēď ţō śēàŕćĥ ĜĩţĤũƀ", + "pullRequestNumbered": "Ƥũĺĺ Ŕēqũēśţ #{{number}}" } diff --git a/apps/web/src/locales/pseudo/gitlab.json b/apps/web/src/locales/pseudo/gitlab.json index 49d1c0d6d0..ba03981bc5 100644 --- a/apps/web/src/locales/pseudo/gitlab.json +++ b/apps/web/src/locales/pseudo/gitlab.json @@ -351,5 +351,13 @@ "gitlabRejectedAssociation": "ĜĩţĹàƀ ŕēĵēćţēď ţĥē àśśōćĩàţĩōń.", "taskCreatedMrNotLinked": "Ţàśķ ćŕēàţēď, ƀũţ ḿēŕĝē ŕēqũēśţ ŵàś ńōţ ĺĩńķēď", "taskCreatedMrNotLinkedDescription": "Ōƥēń ţĥē ţàśķ àńď ũśē Ĺĩńķ ĜĩţĹàƀ ḿēŕĝē ŕēqũēśţ ţō ŕēţŕŷ. {{error}}", - "memberSearchFailed": "Ḿēḿƀēŕ śēàŕćĥ ƒàĩĺēď" + "memberSearchFailed": "Ḿēḿƀēŕ śēàŕćĥ ƒàĩĺēď", + "addedMrFeedbackToTaskContext": "Àďďēď ḿēŕĝē ŕēqũēśţ ƒēēďƀàćķ ţō ţàśķ ćōńţēxţ", + "failedToLoadMergeRequest": "Ƒàĩĺēď ţō ĺōàď ḿēŕĝē ŕēqũēśţ", + "failedToLoadMrAutomationOptions": "Ƒàĩĺēď ţō ĺōàď ḾŔ àũţōḿàţĩōń ōƥţĩōńś.", + "failedToSearchGitLab": "Ƒàĩĺēď ţō śēàŕćĥ ĜĩţĹàƀ", + "presetAssigned": "Àśśĩĝńēď", + "presetAuthored": "Àũţĥōŕēď", + "presetCreated": "Ćŕēàţēď", + "presetReviewRequested": "Ŕēvĩēŵ ŕēqũēśţēď" } diff --git a/apps/web/src/locales/pseudo/integrations.json b/apps/web/src/locales/pseudo/integrations.json index 9f32d1c5e3..08cd2fb650 100644 --- a/apps/web/src/locales/pseudo/integrations.json +++ b/apps/web/src/locales/pseudo/integrations.json @@ -189,5 +189,6 @@ "waitingForTheNextBackendHealth": "Ŵàĩţĩńĝ ƒōŕ ţĥē ńēxţ ƀàćķēńď ĥēàĺţĥ ćĥēćķ…", "cursorPaginationSummary_one": "Ƥàĝē {{page}} · {{count}} ŕēśũĺţ", "cursorPaginationSummary_other": "Ƥàĝē {{page}} · {{count}} ŕēśũĺţś", - "allRepositories": "Àĺĺ ŕēƥōśĩţōŕĩēś" + "allRepositories": "Àĺĺ ŕēƥōśĩţōŕĩēś", + "changeRequestCreationFailed": "Ćĥàńĝē-ŕēqũēśţ ćŕēàţĩōń ƒàĩĺēď" } diff --git a/apps/web/src/locales/pseudo/jira.json b/apps/web/src/locales/pseudo/jira.json index 28b3b45ba2..d3e6930858 100644 --- a/apps/web/src/locales/pseudo/jira.json +++ b/apps/web/src/locales/pseudo/jira.json @@ -208,5 +208,7 @@ "watcherUpdated": "Ŵàţćĥēŕ ũƥďàţēď", "workflow": "Ŵōŕķƒĺōŵ", "workflowStep": "Ŵōŕķƒĺōŵ Śţēƥ", - "yourJiraSessionExpiredOrNeeds": "Ŷōũŕ Ĵĩŕà śēśśĩōń ēxƥĩŕēď ōŕ ńēēďś śţēƥ-ũƥ àũţĥēńţĩćàţĩōń. Ŕēćōńńēćţ ţō vĩēŵ ţĥĩś ţĩćķēţ." + "yourJiraSessionExpiredOrNeeds": "Ŷōũŕ Ĵĩŕà śēśśĩōń ēxƥĩŕēď ōŕ ńēēďś śţēƥ-ũƥ àũţĥēńţĩćàţĩōń. Ŕēćōńńēćţ ţō vĩēŵ ţĥĩś ţĩćķēţ.", + "builtinViewAssignedToMe": "Àśśĩĝńēď ţō ḿē", + "builtinViewUnassigned": "Ũńàśśĩĝńēď" } diff --git a/apps/web/src/locales/pseudo/kanban.json b/apps/web/src/locales/pseudo/kanban.json index 2cfbe851c9..f039f8b316 100644 --- a/apps/web/src/locales/pseudo/kanban.json +++ b/apps/web/src/locales/pseudo/kanban.json @@ -100,5 +100,10 @@ "workflowStepFallback": "ŵōŕķƒĺōŵ śţēƥ {{stepId}}", "columns": "Ćōĺũḿńś", "columnsForWorkflow": "Ćōĺũḿńś ƒōŕ {{name}}", - "needsReassignment": "Ńēēďś Ŕēàśśĩĝńḿēńţ" + "needsReassignment": "Ńēēďś Ŕēàśśĩĝńḿēńţ", + "noTasksYet": "Ńō ţàśķś ŷēţ", + "noWorkflowsAvailableYet": "Ńō ŵōŕķƒĺōŵś àvàĩĺàƀĺē ŷēţ.", + "pullToRefreshPull": "Ƥũĺĺ ţō ŕēƒŕēśĥ", + "pullToRefreshRefreshing": "Ŕēƒŕēśĥĩńĝ ţàśķś", + "pullToRefreshRelease": "Ŕēĺēàśē ţō ŕēƒŕēśĥ" } diff --git a/apps/web/src/locales/pseudo/office.json b/apps/web/src/locales/pseudo/office.json index 3d048c75b3..ceae002d19 100644 --- a/apps/web/src/locales/pseudo/office.json +++ b/apps/web/src/locales/pseudo/office.json @@ -894,5 +894,10 @@ "writeTheDatabaseStateToOn": "Ŵŕĩţē ţĥē ďàţàƀàśē śţàţē ţō ōń-ďĩśķ ŶÀḾĹ ƒĩĺēś.", "yourModel": "ŷōũŕ ḿōďēĺ", "zoomIn": "Źōōḿ ĩń", - "zoomOut": "Źōōḿ ōũţ" + "zoomOut": "Źōōḿ ōũţ", + "failedToLoadAgentRoute": "Ƒàĩĺēď ţō ĺōàď àĝēńţ ŕōũţē", + "failedToLoadProviderHealth": "Ƒàĩĺēď ţō ĺōàď ƥŕōvĩďēŕ ĥēàĺţĥ", + "failedToLoadRouteAttempts": "Ƒàĩĺēď ţō ĺōàď ŕōũţē àţţēḿƥţś", + "failedToLoadRoutingConfig": "Ƒàĩĺēď ţō ĺōàď ŕōũţĩńĝ ćōńƒĩĝ", + "failedToLoadRoutingPreview": "Ƒàĩĺēď ţō ĺōàď ŕōũţĩńĝ ƥŕēvĩēŵ" } diff --git a/apps/web/src/locales/pseudo/review.json b/apps/web/src/locales/pseudo/review.json index aa8ae02f11..629b74aedf 100644 --- a/apps/web/src/locales/pseudo/review.json +++ b/apps/web/src/locales/pseudo/review.json @@ -89,5 +89,9 @@ "findingSentToAgent": "Ƒĩńďĩńĝ śēńţ ţō ţĥē àĝēńţ", "walkthroughRequestQueued": "Ŵàĺķţĥŕōũĝĥ ŕēqũēśţ qũēũēď", "walkthroughRequestSent": "Ŵàĺķţĥŕōũĝĥ ŕēqũēśţ śēńţ", - "couldNotStartReview": "Ćōũĺď ńōţ śţàŕţ ţĥē ŕēvĩēŵ" + "couldNotStartReview": "Ćōũĺď ńōţ śţàŕţ ţĥē ŕēvĩēŵ", + "severityBlocker": "Ɓĺōćķēŕ", + "severityMajor": "Ḿàĵōŕ", + "severityMinor": "Ḿĩńōŕ", + "severityNit": "Ńĩţ" } diff --git a/apps/web/src/locales/pseudo/settings.json b/apps/web/src/locales/pseudo/settings.json index 2ae02fb440..c423a72ee1 100644 --- a/apps/web/src/locales/pseudo/settings.json +++ b/apps/web/src/locales/pseudo/settings.json @@ -727,5 +727,8 @@ "noValidDestinations": "Ńō vàĺĩď ďēśţĩńàţĩōńś àvàĩĺàƀĺē.", "transferFailed": "Ćōũĺď ńōţ ćōƥŷ ōŕ ḿōvē ţĥē śēćŕēţ.", "destinationGlobal": "Ĝĺōƀàĺ", - "secretNameTooLong": "Ńàḿē ḿũśţ ƀē àţ ḿōśţ 100 ƀŷţēś." + "secretNameTooLong": "Ńàḿē ḿũśţ ƀē àţ ḿōśţ 100 ƀŷţēś.", + "keyValueAddItem": "Àďď Ĩţēḿ", + "keyValueKeyPlaceholder": "Ķēŷ", + "keyValueValuePlaceholder": "Vàĺũē" } diff --git a/apps/web/src/locales/pseudo/sidebar.json b/apps/web/src/locales/pseudo/sidebar.json index 73d178245a..02f8b1f342 100644 --- a/apps/web/src/locales/pseudo/sidebar.json +++ b/apps/web/src/locales/pseudo/sidebar.json @@ -110,5 +110,10 @@ "work": "Ŵōŕķ", "workspaceFallback": "Ŵōŕķśƥàćē", "taskPreferences": "Śĩďēƀàŕ ţàśķ ƥŕēƒēŕēńćēś", - "views": "Śĩďēƀàŕ vĩēŵś" + "views": "Śĩďēƀàŕ vĩēŵś", + "failedToLoadArchivedTasks": "Ƒàĩĺēď ţō ĺōàď àŕćĥĩvēď ţàśķś", + "failedToSyncSidebarTaskPrefs": "Ƒàĩĺēď ţō śŷńć śĩďēƀàŕ ţàśķ ƥŕēƒś", + "failedToSyncSidebarViews": "Ƒàĩĺēď ţō śŷńć śĩďēƀàŕ vĩēŵś", + "newViewNumbered": "Ńēŵ vĩēŵ {{suffix}}", + "untitledView": "Ũńţĩţĺēď vĩēŵ" } diff --git a/apps/web/src/locales/pseudo/stats.json b/apps/web/src/locales/pseudo/stats.json index 37fa322bce..a007574f60 100644 --- a/apps/web/src/locales/pseudo/stats.json +++ b/apps/web/src/locales/pseudo/stats.json @@ -79,5 +79,25 @@ "turnsPerTask": "Ţũŕńś ƥēŕ ţàśķ", "userMsgs": "Ũśēŕ ḿśĝś", "userShare": "Ũśēŕ śĥàŕē", - "workload": "Ŵōŕķĺōàď" + "workload": "Ŵōŕķĺōàď", + "failedToLoadSection": "Ƒàĩĺēď ţō ĺōàď śēćţĩōń", + "failedToLoadStats": "Ƒàĩĺēď ţō ĺōàď śţàţś", + "loadingStats": "Ĺōàďĩńĝ śţàţś…", + "noGitActivity": "ńō ĝĩţ àćţĩvĩţŷ", + "subtitleReady": "{{tasks}} · {{sessions}} · {{duration}}", + "subtitleSessionCount_one": "{{count}} śēśśĩōń", + "subtitleSessionCount_other": "{{count}} śēśśĩōńś", + "subtitleTaskCount_one": "{{count}} ţàśķ", + "subtitleTaskCount_other": "{{count}} ţàśķś", + "summaryCompletedInRange": "- Ćōḿƥĺēţēď ({{range}}): {{completed}}", + "summaryGit": "- Ĝĩţ: {{git}}", + "summaryGitActivity_one": "{{count}} ćōḿḿĩţ, +{{insertions}}/-{{deletions}}", + "summaryGitActivity_other": "{{count}} ćōḿḿĩţś, +{{insertions}}/-{{deletions}}", + "summaryHeading": "*Ķàńďēv Śţàţś - {{range}}*", + "summaryRepos_one": "- Ŕēƥōś: {{count}} ţŕàćķēď · Ţōƥ ŕēƥō: {{topRepo}}", + "summaryRepos_other": "- Ŕēƥōś: {{count}} ţŕàćķēď · Ţōƥ ŕēƥō: {{topRepo}}", + "summaryTasks": "- Ţàśķś: {{total}} ţōţàĺ ({{done}} ďōńē, {{inProgress}} ĩń ƥŕōĝŕēśś) · {{completion}} ćōḿƥĺēţĩōń", + "summaryTime": "- Ţĩḿē: {{total}} ţōţàĺ · {{average}} àvĝ/ţàśķ", + "summaryTopRepo_one": "{{repository}} ({{count}} ţàśķ)", + "summaryTopRepo_other": "{{repository}} ({{count}} ţàśķś)" } diff --git a/apps/web/src/locales/pseudo/task.json b/apps/web/src/locales/pseudo/task.json index 211ee5984f..09bdb80e82 100644 --- a/apps/web/src/locales/pseudo/task.json +++ b/apps/web/src/locales/pseudo/task.json @@ -1644,5 +1644,64 @@ "failedToLoadFileContent": "Ƒàĩĺēď ţō ĺōàď ƒĩĺē ćōńţēńţ", "openFileInProvider": "Ōƥēń ƒĩĺē ĩń {{provider}}", "failedToResumeAndRestore": "Ƒàĩĺēď ţō ŕēśũḿē śēśśĩōń - ŵōŕķśƥàćē ŕēśţōŕē àĺśō ũńàvàĩĺàƀĺē", - "sessionStatusGenerating": "Ĝēńēŕàţĩńĝ" + "sessionStatusGenerating": "Ĝēńēŕàţĩńĝ", + "branchDisabledLocalExecutor": "Ţĥē ĺōćàĺ ēxēćũţōŕ ũśēś ŷōũŕ ŕēƥōśĩţōŕŷ'ś ćũŕŕēńţ ćĥēćķōũţ, śō ţĥē ƀŕàńćĥ ćàń'ţ ćĥàńĝē ĥēŕē. Ţōĝĝĺē 'Ƒōŕķ à ńēŵ ƀŕàńćĥ' ţō ƥĩćķ à ďĩƒƒēŕēńţ ƀàśē.", + "branchDisabledNoBranches": "Ńō ƀŕàńćĥēś àvàĩĺàƀĺē ƒōŕ ţĥĩś ŕēƥōśĩţōŕŷ.", + "branchDisabledSelectRepositoryFirst": "Śēĺēćţ à ŕēƥōśĩţōŕŷ ƒĩŕśţ.", + "branchPrefixCurrent": "ćũŕŕēńţ: ", + "branchPrefixFrom": "ƒŕōḿ: ", + "branchPrefixWillSwitchTo": "ŵĩĺĺ śŵĩţćĥ ţō: ", + "branchTooltipCurrent": "Ŷōũŕ ŕēƥōśĩţōŕŷ'ś ćũŕŕēńţ ćĥēćķōũţ. Ţĥē àĝēńţ ŕũńś àĝàĩńśţ ĩţ àś-ĩś, ńō ĝĩţ ōƥēŕàţĩōńś.", + "branchTooltipFrom": "Ɓàśē ƀŕàńćĥ. À ńēŵ ƀŕàńćĥ (ōŕ ŵōŕķţŕēē) ĩś ƒōŕķēď ƒŕōḿ ĥēŕē àńď ţĥē àĝēńţ ŕũńś ţĥēŕē.", + "branchTooltipGeneric": "Ɓŕàńćĥ ţĥē àĝēńţ ŵĩĺĺ ŕũń àĝàĩńśţ.", + "branchTooltipWillSwitchTo": "Ţĥē ƀàćķēńď ŵĩĺĺ ŕũń `ĝĩţ ćĥēćķōũţ` ţō śŵĩţćĥ ŷōũŕ ŕēƥōśĩţōŕŷ ţō ţĥĩś ƀŕàńćĥ ƀēƒōŕē ţĥē àĝēńţ śţàŕţś.", + "cleanupAgentSessionsStopped": "Àńŷ ŕũńńĩńĝ àĝēńţ śēśśĩōńś ŵĩĺĺ ƀē śţōƥƥēď.", + "cleanupBulkLocal_one": "{{count}} ĺōćàĺ ţàśķ - ŷōũŕ ŕēƥō ƒōĺďēŕś ŵōń'ţ ƀē ţōũćĥēď.", + "cleanupBulkLocal_other": "{{count}} ĺōćàĺ ţàśķś - ŷōũŕ ŕēƥō ƒōĺďēŕś ŵōń'ţ ƀē ţōũćĥēď.", + "cleanupBulkLocalDocker_one": "{{count}} Ďōćķēŕ ćōńţàĩńēŕ ŵĩĺĺ ƀē ŕēḿōvēď.", + "cleanupBulkLocalDocker_other": "{{count}} Ďōćķēŕ ćōńţàĩńēŕś ŵĩĺĺ ƀē ŕēḿōvēď.", + "cleanupBulkRemoteDocker_one": "{{count}} ŕēḿōţē Ďōćķēŕ ćōńţàĩńēŕ ŵĩĺĺ ƀē ŕēḿōvēď.", + "cleanupBulkRemoteDocker_other": "{{count}} ŕēḿōţē Ďōćķēŕ ćōńţàĩńēŕś ŵĩĺĺ ƀē ŕēḿōvēď.", + "cleanupBulkSprites_one": "{{count}} Śƥŕĩţēś śàńďƀōx ŵĩĺĺ ƀē ďēśţŕōŷēď (ũńćōḿḿĩţţēď ŵōŕķ ĺōśţ).", + "cleanupBulkSprites_other": "{{count}} Śƥŕĩţēś śàńďƀōxēś ŵĩĺĺ ƀē ďēśţŕōŷēď (ũńćōḿḿĩţţēď ŵōŕķ ĺōśţ).", + "cleanupBulkSsh_one": "{{count}} ŕēḿōţē ţàśķ ďĩŕēćţōŕŷ ŵĩĺĺ ƀē ŕēḿōvēď (ƀēśţ-ēƒƒōŕţ).", + "cleanupBulkSsh_other": "{{count}} ŕēḿōţē ţàśķ ďĩŕēćţōŕĩēś ŵĩĺĺ ƀē ŕēḿōvēď (ƀēśţ-ēƒƒōŕţ).", + "cleanupBulkWorktree_one": "{{count}} ŵōŕķţŕēē àńď ĩţś ƀŕàńćĥ ŵĩĺĺ ƀē ďēĺēţēď.", + "cleanupBulkWorktree_other": "{{count}} ŵōŕķţŕēēś àńď ţĥēĩŕ ƀŕàńćĥēś ŵĩĺĺ ƀē ďēĺēţēď.", + "cleanupSingleLocal": "Ţĥē àĝēńţ ŕàń ďĩŕēćţĺŷ ĩń ŷōũŕ ŕēƥō - ŷōũŕ ƒĩĺēś, ƀŕàńćĥ, àńď ƒōĺďēŕ àŕē ńōţ ţōũćĥēď.", + "cleanupSingleLocalDocker": "Ţĥē Ďōćķēŕ ćōńţàĩńēŕ ŕũńńĩńĝ ţĥĩś ţàśķ ŵĩĺĺ ƀē śţōƥƥēď àńď ŕēḿōvēď. Ŷōũŕ ĥōśţ ŕēƥō ĩś ńōţ ţōũćĥēď.", + "cleanupSingleRemoteDocker": "Ţĥē ŕēḿōţē Ďōćķēŕ ćōńţàĩńēŕ ŕũńńĩńĝ ţĥĩś ţàśķ ŵĩĺĺ ƀē śţōƥƥēď àńď ŕēḿōvēď.", + "cleanupSingleSprites": "Ţĥē Śƥŕĩţēś ćĺōũď śàńďƀōx ƒōŕ ţĥĩś ţàśķ ŵĩĺĺ ƀē ďēśţŕōŷēď. Àńŷ ũńćōḿḿĩţţēď ŵōŕķ ĩńśĩďē ĩţ ŵĩĺĺ ƀē ĺōśţ.", + "cleanupSingleSsh": "Ţĥē ţàśķ ďĩŕēćţōŕŷ ōń ţĥē ŕēḿōţē ĥōśţ ŵĩĺĺ ƀē ŕēḿōvēď (ƀēśţ-ēƒƒōŕţ). Ŷōũŕ ĺōćàĺ ŕēƥō ĩś ńōţ ţōũćĥēď.", + "cleanupSingleWorktree": "Ţĥē ţàśķ'ś ĝĩţ ŵōŕķţŕēē àńď ĩţś ƀŕàńćĥ ŵĩĺĺ ƀē ďēĺēţēď. Ŷōũŕ ḿàĩń ŕēƥō àńď ōţĥēŕ ƀŕàńćĥēś àŕē ńōţ àƒƒēćţēď.", + "enhancedPromptCopiedToClipboard": "Ēńĥàńćēď ƥŕōḿƥţ ćōƥĩēď ţō ćĺĩƥƀōàŕď.", + "enhancedPromptCouldNotBeCopied": "Ēńĥàńćēď ƥŕōḿƥţ ćōũĺď ńōţ ƀē ćōƥĩēď.", + "enhancedPromptCouldNotBeInserted": "Ēńĥàńćēď ƥŕōḿƥţ ŵàś ĝēńēŕàţēď ƀũţ ćōũĺď ńōţ ƀē ĩńśēŕţēď.", + "environmentNotCreated": "ńōţ ćŕēàţēď", + "failedToCreateSubtask": "Ƒàĩĺēď ţō ćŕēàţē śũƀţàśķ", + "failedToDetachTask": "Ƒàĩĺēď ţō ďēţàćĥ ţàśķ", + "failedToFetchCumulativeDiff": "Ƒàĩĺēď ţō ƒēţćĥ ćũḿũĺàţĩvē ďĩƒƒ", + "failedToLoadFileTree": "Ƒàĩĺēď ţō ĺōàď ƒĩĺē ţŕēē", + "gitOperationFailedGeneric": "Ōƥēŕàţĩōń ƒàĩĺēď", + "invalidRegex": "Ĩńvàĺĩď ŕēĝēx", + "messageFetchLimitHint": "Ţĥē ƒēţćĥ ĺĩḿĩţ ḿàŷ ƀē ţōō śḿàĺĺ ƒōŕ ţĥĩś śēśśĩōń'ś ĺàśţ ţũŕń - ũśēŕ ƥŕōḿƥţ àńď àĝēńţ ŕēƥĺĩēś ĺĩvē ƒũŕţĥēŕ ƀàćķ. Ƥàĝĩńàţē ōŕ ŕàĩśē ţĥē ĺĩḿĩţ ţō śēē ţĥēḿ.", + "noBranchesShort": "ńō ƀŕàńćĥēś", + "referenceSearchFailedRetry": "Ŕēƒēŕēńćē śēàŕćĥ ƒàĩĺēď. Ţŕŷ àĝàĩń.", + "sessionFailedUnexpectedly": "Śēśśĩōń ƒàĩĺēď ũńēxƥēćţēďĺŷ", + "sessionNotAvailableForInput": "Ţĥē śēĺēćţēď śēśśĩōń ĩś ńōţ àvàĩĺàƀĺē ƒōŕ ĩńƥũţ.", + "sessionStatusStarting": "Śţàŕţĩńĝ", + "sessionStatusTurnFinished": "Ţũŕń Ƒĩńĩśĥēď", + "summarizeCouldNotGenerate": "Ćōũĺď ńōţ ĝēńēŕàţē à śũḿḿàŕŷ. Ćĥēćķ ţĥàţ ţĥē śũḿḿàŕĩźē ũţĩĺĩţŷ àĝēńţ ĩś ćōńƒĩĝũŕēď àńď ēńàƀĺēď ĩń śēţţĩńĝś.", + "summarizeFailed": "Śũḿḿàŕĩźē ƒàĩĺēď", + "taskClosed": "Ţàśķ ćĺōśēď", + "taskClosedAutomatically": "Ţĥĩś ţàśķ ŵàś ćĺōśēď àũţōḿàţĩćàĺĺŷ.", + "taskClosedIssueClosed": "Ĩţś ĩśśũē ŵàś ćĺōśēď, śō ţĥĩś ţàśķ ŵàś ćĺōśēď àũţōḿàţĩćàĺĺŷ.", + "taskClosedPrApproved": "Ĩţś ƥũĺĺ ŕēqũēśţ ŵàś àƥƥŕōvēď, śō ţĥĩś ŕēvĩēŵ ţàśķ ŵàś ćĺōśēď àũţōḿàţĩćàĺĺŷ.", + "taskClosedPrMergedOrClosed": "Ĩţś ƥũĺĺ ŕēqũēśţ ŵàś ḿēŕĝēď ōŕ ćĺōśēď, śō ţĥĩś ŕēvĩēŵ ţàśķ ŵàś ćĺōśēď àũţōḿàţĩćàĺĺŷ.", + "taskFailedToStart": "Ţàśķ ƒàĩĺēď ţō śţàŕţ", + "taskTitledWasClosed": "\"{{title}}\" ŵàś ćĺōśēď", + "updateFailed": "Ũƥďàţē ƒàĩĺēď", + "workspaceRoot": "Ŵōŕķśƥàćē ŕōōţ", + "worktreeExecutorRequiresRepository": "Ŵōŕķţŕēē ēxēćũţōŕ ŕēqũĩŕēś à ŕēƥōśĩţōŕŷ.", + "worktreeNumbered": "Ŵōŕķţŕēē {{index}}" } diff --git a/apps/web/src/locales/pseudo/workflows.json b/apps/web/src/locales/pseudo/workflows.json index 8f0827a9b8..b40dc98a3b 100644 --- a/apps/web/src/locales/pseudo/workflows.json +++ b/apps/web/src/locales/pseudo/workflows.json @@ -262,5 +262,8 @@ "workflows": "Ŵōŕķƒĺōŵś", "workflowsSectionDescription": "Ćŕēàţē àũţōńōḿōũś ƥĩƥēĺĩńēś ŵĩţĥ àũţōḿàţēď ţŕàńśĩţĩōńś ōŕ ḿàńũàĺ ŵōŕķƒĺōŵś ŵĥēŕē ŷōũ ḿōvē ţàśķś ŷōũŕśēĺƒ", "workflowSteps": "Ŵōŕķƒĺōŵ Śţēƥś", - "workflowSyncCompleted": "Ŵōŕķƒĺōŵ śŷńć ćōḿƥĺēţēď" + "workflowSyncCompleted": "Ŵōŕķƒĺōŵ śŷńć ćōḿƥĺēţēď", + "loadingSteps": "Ĺōàďĩńĝ śţēƥś…", + "noStepsInThisWorkflow": "Ńō śţēƥś ĩń ţĥĩś ŵōŕķƒĺōŵ", + "selectAWorkflowFirst": "Śēĺēćţ à ŵōŕķƒĺōŵ ƒĩŕśţ" } diff --git a/apps/web/src/locales/pseudo/workspaces.json b/apps/web/src/locales/pseudo/workspaces.json index 423cf25808..4a6069cacd 100644 --- a/apps/web/src/locales/pseudo/workspaces.json +++ b/apps/web/src/locales/pseudo/workspaces.json @@ -121,5 +121,10 @@ "worktreeSync": "Ŵōŕķţŕēē Śŷńć", "enterRepositoryName": "Ēńţēŕ à ŕēƥōśĩţōŕŷ ńàḿē.", "chooseDifferentRepositoryName": "Ćĥōōśē à ďĩƒƒēŕēńţ ŕēƥōśĩţōŕŷ ńàḿē.", - "repositoryNameMustBeOneFolder": "Ţĥē ŕēƥōśĩţōŕŷ ńàḿē ḿũśţ ƀē ōńē ƒōĺďēŕ ńàḿē." + "repositoryNameMustBeOneFolder": "Ţĥē ŕēƥōśĩţōŕŷ ńàḿē ḿũśţ ƀē ōńē ƒōĺďēŕ ńàḿē.", + "sourceChooseAFolder": "Ćĥōōśē à ƒōĺďēŕ.", + "sourceChooseRepoAndBranch": "Ćĥōōśē à ŕēƥōśĩţōŕŷ àńď ƀàśē ƀŕàńćĥ.", + "sourceEnterValidRemoteUrl": "Ēńţēŕ à vàĺĩď ŕēḿōţē ŕēƥōśĩţōŕŷ ŨŔĹ àńď ƀàśē ƀŕàńćĥ.", + "sourceFolderAlreadySelected": "Ţĥĩś ƒōĺďēŕ ĩś àĺŕēàďŷ śēĺēćţēď.", + "sourceRepoBranchAlreadySelected": "Ţĥĩś ŕēƥōśĩţōŕŷ àńď ƀŕàńćĥ àŕē àĺŕēàďŷ śēĺēćţēď." } diff --git a/apps/web/src/locales/pt-pt/_verbatim.json b/apps/web/src/locales/pt-pt/_verbatim.json new file mode 100644 index 0000000000..bdc5cc9a7f --- /dev/null +++ b/apps/web/src/locales/pt-pt/_verbatim.json @@ -0,0 +1,10 @@ +{ + "common:menu": "\"Menu\" is the ordinary Portuguese word; translating it would change nothing.", + "kanban:menu": "\"Menu\" is the ordinary Portuguese word; translating it would change nothing.", + "office:desc": "\"Desc\" is the same abbreviation for descending order in Portuguese.", + "office:total": "\"Total\" is the ordinary Portuguese word; translating it would change nothing.", + "stats:summaryGit": "The only word is the tool name Git; the rest of the line is the interpolated value.", + "system:diskTotal": "\"Total\" is the ordinary Portuguese word; translating it would change nothing.", + "system:featureToggleExperimental": "\"Experimental\" is spelled identically in Portuguese.", + "task:manual": "\"Manual\" is the ordinary Portuguese word for a non-automatic action." +} diff --git a/apps/web/src/locales/pt-pt/agents.json b/apps/web/src/locales/pt-pt/agents.json index f3c99ec548..3e23cfb1ba 100644 --- a/apps/web/src/locales/pt-pt/agents.json +++ b/apps/web/src/locales/pt-pt/agents.json @@ -221,5 +221,22 @@ "watcherKindLinear": "Linear", "collapseAgentProfiles": "Recolher perfis de {{name}}", "expandAgentProfiles": "Expandir perfis de {{name}}", - "noProfilesYetShort": "Sem perfis ainda" + "noProfilesYetShort": "Sem perfis ainda", + "browseAvailableAgents": "Ver agentes disponíveis", + "createProfile": "Criar perfil", + "creatingProfile": "A criar...", + "duplicate": "Duplicar", + "duplicateProfileNamed": "Duplicar {{name}}", + "duplicateProfileSuccess": "Perfil duplicado", + "duplicateSaveBlocked": "As definições ainda estão a ser guardadas. Tente novamente daqui a pouco.", + "everythingInstalled": "Todos os agentes e ferramentas disponíveis já estão instalados.", + "failedToDuplicateProfile": "Falha ao duplicar o perfil", + "installAgents": "Instalar agentes", + "newProfile": "Novo perfil", + "noProfilesYet": "Ainda sem perfis. Crie um a partir de um agente instalado.", + "profileActions": "Ações do perfil", + "profileCount_one": "{{count}} perfil", + "profileCount_other": "{{count}} perfis", + "saveProfile": "Guardar perfil", + "savingProfile": "A guardar..." } diff --git a/apps/web/src/locales/pt-pt/auth.json b/apps/web/src/locales/pt-pt/auth.json index 484c149f2d..daedfdd1a1 100644 --- a/apps/web/src/locales/pt-pt/auth.json +++ b/apps/web/src/locales/pt-pt/auth.json @@ -16,5 +16,14 @@ "signIn": "Iniciar sessão", "signingIn": "A iniciar sessão...", "signInToYourKandevAccount": "Inicie sessão na sua conta Kandev para continuar.", - "youVeBeenInvitedAs": "Foi convidado como {{role}}." + "youVeBeenInvitedAs": "Foi convidado como {{role}}.", + "couldNotAcceptInvite": "Não foi possível aceitar este convite.", + "couldNotLoadInvite": "Não foi possível carregar este convite. Tente novamente.", + "invalidEmailOrPassword": "E-mail ou palavra-passe inválidos.", + "inviteLinkInvalidOrExpired": "Este link de convite é inválido ou expirou. Peça um novo a um administrador.", + "inviteLinkMissingToken": "Falta o token a este link de convite.", + "setupFailed": "A configuração falhou. Tente novamente.", + "setupNoLongerAvailable": "A configuração já não está disponível - já existe uma conta.", + "somethingWentWrong": "Algo correu mal. Tente novamente.", + "tooManyAttempts": "Demasiadas tentativas. Aguarde um momento e tente novamente." } diff --git a/apps/web/src/locales/pt-pt/automations.json b/apps/web/src/locales/pt-pt/automations.json index 5cc411b508..f273ffe513 100644 --- a/apps/web/src/locales/pt-pt/automations.json +++ b/apps/web/src/locales/pt-pt/automations.json @@ -198,5 +198,37 @@ "workflowLabel": "Fluxo de trabalho", "workflowLoadError": "Não foi possível carregar os fluxos de trabalho.", "workflowStepHelpNoWorkflow": "Selecione um fluxo de trabalho antes de escolher um passo.", - "workflowStepLabel": "Passo do fluxo de trabalho" + "workflowStepLabel": "Passo do fluxo de trabalho", + "failedToLoadAutomationActivity": "Falha ao carregar a atividade das automações", + "failedToLoadAutomations": "Falha ao carregar as automações", + "failedToLoadRuns": "Falha ao carregar as execuções", + "failedToLoadThisAutomation": "Falha ao carregar esta automação", + "frequencyCustom": "um agendamento personalizado", + "frequencyEvery15Minutes": "a cada 15 minutos", + "frequencyEvery30Minutes": "a cada 30 minutos", + "frequencyEvery5Minutes": "a cada 5 minutos", + "frequencyEvery6Hours": "a cada 6 horas", + "frequencyEveryDay": "todos os dias", + "frequencyEveryHour": "a cada hora", + "frequencyEveryWeek": "todas as semanas", + "nextFiringConcurrencyPaused": "Em pausa - ainda há uma execução aberta (máx. {{max}} de cada vez)", + "nextFiringNoSchedule": "Sem agendamento - só corre quando a acionar", + "nextFiringScheduleOff": "O acionador agendado está desligado", + "nextFiringSwitchedOff": "Desligada - não vai disparar", + "prMergedDeadConfigWarning": "Nenhum repositório selecionado; este acionador não vai disparar. Ative 'Todos os repositórios' ou adicione pelo menos um repositório.", + "runNoReportRecorded": "Nenhum relatório registado.", + "runStillRunningNoReport": "Ainda em execução - sem relatório por agora.", + "scheduleAtSeparator": "às", + "scheduleEveryDayAt": "Todos os dias às {{time}} {{zone}}", + "scheduleEveryHourAt": "A cada hora aos :{{minute}}", + "scheduleEveryWeekdayAt": "Todas as {{weekday}} às {{time}} {{zone}}", + "scheduleIntervalEvery15Minutes": "A cada 15 minutos", + "scheduleIntervalEvery30Minutes": "A cada 30 minutos", + "scheduleIntervalEvery5Minutes": "A cada 5 minutos", + "scheduleIntervalEvery6Hours": "A cada 6 horas", + "scheduleNone": "Sem agendamento", + "scheduleUnparseable": "Não conseguimos ler esse agendamento. Experimente um cron de 5 campos, como 0 9 * * 1-5.", + "summaryPrMerged": "PR integrado", + "triggerInfoGithubPrMerged": "Dispara quando o verificador de PRs do Kandev deteta que um PR associado foi integrado. A deteção pode demorar até um minuto.", + "triggerLabelGithubPrMerged": "PR do GitHub integrado" } diff --git a/apps/web/src/locales/pt-pt/chat.json b/apps/web/src/locales/pt-pt/chat.json index 16c202bab0..471a68bc12 100644 --- a/apps/web/src/locales/pt-pt/chat.json +++ b/apps/web/src/locales/pt-pt/chat.json @@ -141,5 +141,9 @@ "transientRetryProviderModel": "{{provider}} · {{model}}", "transientRetryProvider": "Fornecedor: {{provider}}", "transientRetryModel": "Modelo: {{model}}", - "unpinQueuedMessages": "Desafixar fila de mensagens" + "unpinQueuedMessages": "Desafixar fila de mensagens", + "emptyTurnKnownCommand": "`/{{command}}` correu mas não produziu qualquer resultado. {{hint}}", + "emptyTurnNoOutput": "O agente terminou sem produzir qualquer resultado.", + "emptyTurnResendHint": "Experimente reenviar o seu pedido como mensagem normal, sem a barra inicial.", + "emptyTurnUnknownCommand": "`/{{command}}` não é um comando que este agente reconheça, por isso não devolveu nada. {{hint}}" } diff --git a/apps/web/src/locales/pt-pt/common.json b/apps/web/src/locales/pt-pt/common.json index 52e318e543..d37aff1ca3 100644 --- a/apps/web/src/locales/pt-pt/common.json +++ b/apps/web/src/locales/pt-pt/common.json @@ -162,7 +162,7 @@ "failedToLoadDirectory": "Não foi possível carregar o diretório", "failedToLoadRoutine": "Não foi possível carregar a rotina", "failedToLoadSetup": "Não foi possível carregar a configuração", - "failedToRenderDiagram": "Não foi possível apresentar o diagrama", + "failedToRenderDiagram": "Falha ao renderizar o diagrama", "featureRequest": "Pedido de funcionalidade", "files": "Ficheiros", "folderName": "Nome da pasta", @@ -438,7 +438,6 @@ "youCanUseTheNavigationOr": "Pode usar a navegação ou recarregar para tentar novamente.", "yourAgentImplementsItInThe": "O seu agente implementa-a no repositório do kandev, com testes", "youVerifyAndTestTheChange": "Verifica e testa a alteração numa segunda instância do kandev", - "failedToRenderDiagram": "Falha ao renderizar o diagrama", "demoAgentMessagesDescription": "Inspeção visual da normalização de mensagens do agente. Cada cartão mostra o resultado renderizado junto ao JSON original.", "demoAgentMessagesQa": "QA de mensagens do agente", "demoErrorLoading": "Erro ao carregar {{mode}}", @@ -466,5 +465,47 @@ "demoShowingMessages_one": "A mostrar {{count}} de {{total}} mensagem", "demoShowingMessages_other": "A mostrar {{count}} de {{total}} mensagens", "demoTestFixtures": "Dados de teste", - "demoToolType": "Tipo de ferramenta:" + "demoToolType": "Tipo de ferramenta:", + "automation": "Automação", + "checking": "A verificar", + "configured": "Configurado", + "connected": "Ligado", + "debug": "Depuração", + "executor": "Executor", + "failedToLoadRoute": "Falha ao carregar a rota", + "healthIssues": "Problemas de saúde", + "improveStepImprove": "O agente lê o relatório, explora o código e implementa a alteração com TDD. Corre make fmt, typecheck, test, lint e depois faz commit.", + "improveStepOpenIssue": "O agente reúne todos os campos exigidos pelo modelo do repositório, confirma consigo o rascunho público e publica a issue no GitHub.", + "improveStepPr": "O agente corre a skill pr: envia o ramo e abre um pull request para kdlbs/kandev para os responsáveis reverem.", + "improveStepTest": "O agente arranca uma segunda instância do kandev com make dev e diz-lhe o que verificar. Confirma que a alteração funciona na aplicação em execução.", + "menu": "Menu", + "noOptionFound": "Nenhuma opção encontrada.", + "openNavigationMenu": "Abrir menu de navegação", + "openNavigationMenuWithConnectionIssue": "{{description}} Abrir menu de navegação", + "profile": "Perfil", + "searchPlaceholder": "Pesquisar...", + "selectOption": "Selecionar opção...", + "showMoreBreadcrumbs": "Mostrar mais migalhas", + "statusWithConnectionIssue": "{{description}} Estado", + "terminalNumbered": "Terminal {{seq}}", + "utilities": "Utilitários", + "weekdayFriday": "sexta-feira", + "weekdayMonday": "segunda-feira", + "weekdaySaturday": "sábado", + "weekdaySunday": "domingo", + "weekdayThursday": "quinta-feira", + "weekdayTuesday": "terça-feira", + "weekdayWednesday": "quarta-feira", + "preciseDated": "{{date}}, {{time}}", + "preciseToday": "Hoje, {{time}}", + "preciseYesterday": "Ontem, {{time}}", + "relativeDaysAgo_one": "há {{count}}d", + "relativeDaysAgo_other": "há {{count}}d", + "relativeHoursAgo_one": "há {{count}}h", + "relativeHoursAgo_other": "há {{count}}h", + "relativeMinutesAgo_one": "há {{count}}m", + "relativeMinutesAgo_other": "há {{count}}m", + "relativeSecondsAgo_one": "há {{count}}s", + "relativeSecondsAgo_other": "há {{count}}s", + "relativeYesterday": "ontem" } diff --git a/apps/web/src/locales/pt-pt/diff.json b/apps/web/src/locales/pt-pt/diff.json index c8c1d3e14c..94cddc8fa1 100644 --- a/apps/web/src/locales/pt-pt/diff.json +++ b/apps/web/src/locales/pt-pt/diff.json @@ -46,5 +46,11 @@ "walkthrough": "Passo a passo", "walkthroughNoteAdded": "Nota do passo a passo adicionada", "walkthroughNoteQueued": "Nota do passo a passo em fila", - "walkthroughNoteSent": "Nota do passo a passo enviada" + "walkthroughNoteSent": "Nota do passo a passo enviada", + "fileStatusAdded": "Adicionado", + "fileStatusDeleted": "Eliminado", + "fileStatusModified": "Modificado", + "fileStatusMoved": "Movido", + "fileStatusMovedFrom": "Movido de {{oldPath}}", + "fileStatusUntracked": "Não rastreado" } diff --git a/apps/web/src/locales/pt-pt/executors.json b/apps/web/src/locales/pt-pt/executors.json index 44b7c324f2..103d938a72 100644 --- a/apps/web/src/locales/pt-pt/executors.json +++ b/apps/web/src/locales/pt-pt/executors.json @@ -258,5 +258,23 @@ "user": "Utilizador", "usesTheLocalDockerDaemonOn": "Usa o daemon local do Docker nesta máquina.", "value": "Valor", - "verifyingConnection": "A verificar a ligação..." + "verifyingConnection": "A verificar a ligação...", + "profileNameExample": "Produção, Desenvolvimento", + "portableConfigAvailable": "Disponível", + "portableConfigBundleClaudeSettings": "Copiar definições do Claude", + "portableConfigBundleCodexConfig": "Copiar configuração do Codex", + "portableConfigBundleGeneric": "Configuração do agente ({{id}})", + "portableConfigBundleMockSettings": "Copiar definições do agente simulado", + "portableConfigBundleOpenCodeConfig": "Copiar configuração do OpenCode", + "portableConfigDescription": "Copie as definições do agente selecionadas, que podem conter segredos ou comandos, para ambientes acabados de aprovisionar ou reiniciados.", + "portableConfigFileMapping": "{{source}} para {{target}}", + "portableConfigInfoCopyTiming": "O Kandev copia os ficheiros selecionados sem alterações apenas durante o aprovisionamento de raiz ou a reinicialização do ambiente. Uma cópia nova pode substituir um ficheiro de destino existente. As retomas a quente mantêm o ambiente existente.", + "portableConfigInfoErrors": "Ficheiros opcionais em falta ou ilegíveis são ignorados com um aviso e não impedem o arranque. Os ficheiros podem conter segredos ou valores de ambiente.", + "portableConfigInfoLabel": "Detalhes da configuração portátil do agente", + "portableConfigInfoScope": "Apenas os ficheiros da lista de permissões são copiados. Um ficheiro pode adicionar hooks ou comandos, alterar modelos, permissões, servidores MCP ou endpoints, e conter caminhos do anfitrião que não funcionam no executor. Os ficheiros de autenticação e os pacotes de configuração são escolhas separadas.", + "portableConfigInfoSsh": "Em SSH, o destino é a pasta pessoal partilhada do utilizador remoto configurado e pode substituir configuração usada por outros processos nesse anfitrião.", + "portableConfigInfoTitle": "Configuração portátil do agente", + "portableConfigNotFound": "Não encontrado", + "portableConfigNotFoundHint": "Isto é uma sugestão de edição. Pode guardar o perfil e adicionar o ficheiro mais tarde.", + "portableConfigTitle": "Configuração do agente" } diff --git a/apps/web/src/locales/pt-pt/github.json b/apps/web/src/locales/pt-pt/github.json index e897e183a6..2d41b0b591 100644 --- a/apps/web/src/locales/pt-pt/github.json +++ b/apps/web/src/locales/pt-pt/github.json @@ -546,5 +546,47 @@ "workspaceGithubAccess": "Acesso ao GitHub da área de trabalho", "workspaceGithubConnectionRemoved": "Ligação ao GitHub da área de trabalho removida", "workspaceGithubIdentity": "Identidade do GitHub da área de trabalho", - "yourReviewIsRequested": "A sua revisão foi pedida" + "yourReviewIsRequested": "A sua revisão foi pedida", + "appRegistrationsUnavailable": "Os registos de GitHub App estão indisponíveis", + "autoFixEnabledAria": "correção automática ativada", + "autoFixEnabledWithRoundsAria": "correção automática ativada, {{current}} de {{max}} rondas usadas", + "automationForPr": "Aplica-se apenas ao PR #{{number}}", + "autoMergeEnabledAria": "integração automática ativada", + "closed": "Fechado", + "failedToFetchPrCommits": "Falha ao obter os commits do PR", + "failedToFetchPrFeedback": "Falha ao obter o feedback do PR", + "failedToFetchPrFiles": "Falha ao obter os ficheiros do PR", + "failedToLoadCiAutomationOptions": "Falha ao carregar as opções de automação de CI.", + "failedToSearchGitHub": "Falha ao pesquisar no GitHub", + "mergeable": "Integrável", + "prTaskStatusCi": "CI", + "prTaskStatusMerge": "Integração", + "prTaskStatusNumber": "PR #{{number}}", + "prTaskStatusReview": "Revisão", + "prTaskStatusState": "Estado", + "pullRequestCiStatusAria": "Estado de CI do pull request #{{number}}{{automation}}", + "pullRequestCountCiStatusAria_one": "Estado de CI de {{count}} pull request{{automation}}", + "pullRequestCountCiStatusAria_other": "Estado de CI de {{count}} pull requests{{automation}}", + "pullRequestNumbered": "Pull Request #{{number}}", + "pullRequestStatus": "Estado do pull request #{{number}}", + "pullRequestStatuses_one": "{{count}} estado de pull request", + "pullRequestStatuses_other": "{{count}} estados de pull request", + "readyToMerge": "Pronto a integrar", + "reviewFollowUp": "Seguimento da revisão", + "timeAgoDays_one": "há {{count}}d", + "timeAgoDays_other": "há {{count}}d", + "timeAgoHours_one": "há {{count}}h", + "timeAgoHours_other": "há {{count}}h", + "timeAgoJustNow": "agora mesmo", + "timeAgoMinutes_one": "há {{count}}m", + "timeAgoMinutes_other": "há {{count}}m", + "timeAgoMonths_one": "há {{count}}mes", + "timeAgoMonths_other": "há {{count}}mes", + "timeAgoWeeks_one": "há {{count}}sem", + "timeAgoWeeks_other": "há {{count}}sem", + "timeAgoYears_one": "há {{count}}a", + "timeAgoYears_other": "há {{count}}a", + "toggleReviewFollowUpAutomation": "Alternar a automação de seguimento da revisão", + "wakeAgentOnReviewRequest": "Acordar o agente para qualquer novo pedido, incluindo nova revisão após alterações.", + "wakeAgentOnTerminalPrState": "Acordar o agente quando o trabalho de revisão terminar. Escolha um dos resultados ou ambos." } diff --git a/apps/web/src/locales/pt-pt/gitlab.json b/apps/web/src/locales/pt-pt/gitlab.json index 0bd53740e3..a7375542fb 100644 --- a/apps/web/src/locales/pt-pt/gitlab.json +++ b/apps/web/src/locales/pt-pt/gitlab.json @@ -334,5 +334,30 @@ "mrTooltipReadyToMerge": "Pronto para merge", "gitlabRejectedAssociation": "O GitLab rejeitou a associação.", "taskCreatedMrNotLinked": "Tarefa criada, mas o merge request não foi associado", - "taskCreatedMrNotLinkedDescription": "Abra a tarefa e use Associar merge request do GitLab para tentar novamente. {{error}}" + "taskCreatedMrNotLinkedDescription": "Abra a tarefa e use Associar merge request do GitLab para tentar novamente. {{error}}", + "addedMrFeedbackToTaskContext": "Feedback do merge request adicionado ao contexto da tarefa", + "failedToLoadMergeRequest": "Falha ao carregar o merge request", + "failedToLoadMrAutomationOptions": "Falha ao carregar as opções de automação de MR.", + "failedToSearchGitLab": "Falha ao pesquisar no GitLab", + "memberSearchFailed": "A pesquisa de membros falhou", + "mrChipAriaLabelMulti_one": "{{count}} merge request, {{status}}", + "mrChipAriaLabelMulti_other": "{{count}} merge requests, {{status}}", + "mrChipAriaLabelSingle": "Merge request !{{mriid}}, {{status}}", + "mrChipAutoFix": "Correção automática", + "mrChipAutoMerge": "Integração automática", + "mrChipDrawerClose": "Fechar o estado do merge request", + "mrChipDrawerDescription": "Estado da pipeline, estado da revisão e ações do merge request.", + "mrChipDrawerTitle": "Merge request !{{mriid}}", + "mrChipStatusAwaitingApproval": "a aguardar aprovação", + "mrChipStatusClosed": "fechado", + "mrChipStatusDraft": "rascunho", + "mrChipStatusFailed": "pipeline falhou", + "mrChipStatusMerged": "integrado", + "mrChipStatusNeutral": "sem pipeline", + "mrChipStatusReady": "pronto a integrar", + "mrChipStatusRunning": "pipeline em execução", + "presetAssigned": "Atribuídos", + "presetAuthored": "Da minha autoria", + "presetCreated": "Criados", + "presetReviewRequested": "Revisão pedida" } diff --git a/apps/web/src/locales/pt-pt/integrations.json b/apps/web/src/locales/pt-pt/integrations.json index e60add4639..b193b952cf 100644 --- a/apps/web/src/locales/pt-pt/integrations.json +++ b/apps/web/src/locales/pt-pt/integrations.json @@ -105,5 +105,90 @@ "saveQuery": "Guardar consulta", "waitingForTheNextBackendHealth": "A aguardar a próxima verificação de saúde do backend…", "cursorPaginationSummary_one": "Página {{page}} · {{count}} resultado", - "cursorPaginationSummary_other": "Página {{page}} · {{count}} resultados" + "cursorPaginationSummary_other": "Página {{page}} · {{count}} resultados", + "actionFromReviewer": "{{action}} de {{name}}", + "actionRequired": "ação necessária", + "addAllCommentsToChatContext": "Adicionar todos os comentários ao contexto da conversa", + "addAllFailedChecksToChatContext": "Adicionar todas as verificações falhadas ao contexto da conversa", + "addAllReviewsToChatContext": "Adicionar todas as revisões ao contexto da conversa", + "addFailuresToChatContext": "Adicionar as falhas de {{name}} ao contexto da conversa", + "addReviewComment": "Adicionar comentário de revisão", + "addWholeThreadToChatContext": "Adicionar todo o tópico ao contexto da conversa", + "allRepositories": "Todos os repositórios", + "approved": "Aprovado", + "approvedCount_one": "{{count}} aprovada", + "approvedCount_other": "{{count}} aprovadas", + "awaitingReview": "A aguardar revisão", + "botComments_one": "Comentário de bot ({{count}})", + "botComments_other": "Comentários de bots ({{count}})", + "by": "por", + "changeRequestCreationFailed": "Falha ao criar o pedido de alteração", + "changesRequested": "Alterações pedidas", + "changesRequestedCount_one": "{{count}} alteração pedida", + "changesRequestedCount_other": "{{count}} alterações pedidas", + "chooseReview": "Escolher revisão", + "chooseReviewToOpen": "Escolha uma revisão para a abrir.", + "ciChecks": "Verificações de CI{{summary}}", + "closedRelative": "fechado {{relative}}", + "closePullRequestStatus": "Fechar estado do PR", + "commentCount_one": "{{count}} comentário", + "commentCount_other": "{{count}} comentários", + "commented": "Comentou", + "comments": "Comentários", + "disabled": "Desativado", + "dismissed": "Descartado", + "failed": "Falhou", + "failedCount_one": "{{count}} falhada", + "failedCount_other": "{{count}} falhadas", + "general": "(geral)", + "inProgress": "Em curso", + "lastSynced": "Última sincronização {{relative}}", + "loadingChangeRequest": "A carregar pedido de alteração", + "mergedRelative": "integrado {{relative}}", + "noChecks": "Sem verificações", + "noChecksHaveStarted": "Nenhuma verificação começou", + "noCommentsYet": "Ainda sem comentários", + "noReviewsYet": "Ainda sem revisões", + "openDetails": "Abrir detalhes de {{title}}", + "openedRelative": "aberto {{relative}}", + "openReview": "Abrir revisão", + "openReviewNumber": "Abrir revisão #{{number}}", + "other": "Outro", + "otherCount_one": "{{count}} outro", + "otherCount_other": "{{count}} outros", + "overall": "Geral", + "passed": "Passou", + "passedCount_one": "{{count}} passada", + "passedCount_other": "{{count}} passadas", + "passRate": "Taxa de sucesso", + "pending": "Pendente", + "pendingCount_one": "{{count}} pendente", + "pendingCount_other": "{{count}} pendentes", + "pendingReview": "Revisão pendente", + "pullRequest": "Pull request", + "pullRequestCount_one": "{{count}} pull request", + "pullRequestCount_other": "{{count}} pull requests", + "pullRequestNumber": "PR #{{number}}", + "pullRequestShortCount_one": "{{count}} PR", + "pullRequestShortCount_other": "{{count}} PRs", + "pullRequestStatusSummary": "Resumo do estado de CI, revisões e verificações do pull request.", + "refresh": "Atualizar", + "replyToThread": "Responder ao tópico {{threadId}}", + "requestedCount_one": "{{count}} pedida", + "requestedCount_other": "{{count}} pedidas", + "retry": "Tentar novamente", + "reviewCount_one": "{{count}} revisão", + "reviewCount_other": "{{count}} revisões", + "reviewerTeamName": "{{name}} (equipa)", + "reviews": "Revisões{{summary}}", + "reviewUnavailable": "Revisão indisponível.", + "running": "em execução", + "saving": "A guardar", + "timedOut": "excedeu o tempo limite", + "unresolvedComments_one": "{{count}} comentário por resolver", + "unresolvedComments_other": "{{count}} comentários por resolver", + "untitledPullRequest": "Pull request sem título", + "updatedAgo": "atualizado há {{elapsed}}", + "updatedJustNow": "atualizado agora mesmo", + "viewPullRequestExternally": "Ver pull request #{{number}} externamente" } diff --git a/apps/web/src/locales/pt-pt/jira.json b/apps/web/src/locales/pt-pt/jira.json index 609397fcfc..c633edefbe 100644 --- a/apps/web/src/locales/pt-pt/jira.json +++ b/apps/web/src/locales/pt-pt/jira.json @@ -119,7 +119,7 @@ "pause": "Pausar", "paused": "Em pausa", "pauseOrResumePolling": "Pausar ou retomar a sondagem.", - "personalAccessToken": "Personal Access Token", + "personalAccessToken": "Token de acesso pessoal", "placeholderAssigneeDisplayName": "Nome do responsável", "placeholderIssueType": "Tipo de issue", "placeholderPriority": "Prioridade", @@ -208,5 +208,7 @@ "watcherUpdated": "Vigilância atualizada", "workflow": "Fluxo de trabalho", "workflowStep": "Passo do fluxo de trabalho", - "yourJiraSessionExpiredOrNeeds": "A sua sessão do Jira expirou ou precisa de autenticação reforçada. Volte a ligar para ver este ticket." + "yourJiraSessionExpiredOrNeeds": "A sua sessão do Jira expirou ou precisa de autenticação reforçada. Volte a ligar para ver este ticket.", + "builtinViewAssignedToMe": "Atribuídos a mim", + "builtinViewUnassigned": "Sem responsável" } diff --git a/apps/web/src/locales/pt-pt/kanban.json b/apps/web/src/locales/pt-pt/kanban.json index 1efd4dc780..b7101ee78a 100644 --- a/apps/web/src/locales/pt-pt/kanban.json +++ b/apps/web/src/locales/pt-pt/kanban.json @@ -93,5 +93,17 @@ "wipTaskCount": "Tarefas em {{label}}", "workflow": "Fluxo de trabalho", "workflowStepFallback": "passo do fluxo de trabalho {{stepId}}", - "needsReassignment": "Precisa de reatribuição" + "needsReassignment": "Precisa de reatribuição", + "blockedByCount_one": "Bloqueada por {{count}}", + "blockedByCount_other": "Bloqueada por {{count}}", + "blockedByTasksTitle": "À espera de: {{tasks}}", + "blockedFailed": "O bloqueio falhou", + "blockedPredecessorFailed": "Uma tarefa de que esta depende falhou: {{tasks}}", + "columns": "Colunas", + "columnsForWorkflow": "Colunas de {{name}}", + "noTasksYet": "Ainda sem tarefas", + "noWorkflowsAvailableYet": "Ainda não há fluxos de trabalho disponíveis.", + "pullToRefreshPull": "Puxe para atualizar", + "pullToRefreshRefreshing": "A atualizar tarefas", + "pullToRefreshRelease": "Largue para atualizar" } diff --git a/apps/web/src/locales/pt-pt/office.json b/apps/web/src/locales/pt-pt/office.json index 4515d16391..aefcf44bb7 100644 --- a/apps/web/src/locales/pt-pt/office.json +++ b/apps/web/src/locales/pt-pt/office.json @@ -811,17 +811,17 @@ "thisWillPermanentlyDeleteAllMemory": "Isto elimina permanentemente todas as entradas de memória deste agente. Esta ação não pode ser anulada.", "thisWillPermanentlyDeleteThisInstruction": "Isto elimina permanentemente este ficheiro de instruções. Esta ação não pode ser anulada.", "tier": "Escalão", - "tierBalanced": "Balanced", + "tierBalanced": "Equilibrado", "tierBalancedDescription": "Usado nos agentes trabalhadores gerais, quando o coordenador atribui o escalão Balanced.", "tierBalancedHint": "Capacidade padrão", "tierBalancedQualified": "Balanced (padrão)", "tierBalancedUsage": "Utilização do escalão Balanced", - "tierEconomy": "Economy", + "tierEconomy": "Económico", "tierEconomyDescription": "Usado nos agentes de QA, de rotina e de menor custo, quando o coordenador atribui o escalão Economy.", "tierEconomyHint": "Modelo viável mais barato", "tierEconomyQualified": "Economy (mais barato)", "tierEconomyUsage": "Utilização do escalão Economy", - "tierFrontier": "Frontier", + "tierFrontier": "Fronteira", "tierFrontierDescription": "Usado quando o coordenador cria agentes para o trabalho de maior capacidade ou atribui o escalão Frontier.", "tierFrontierHint": "Melhor capacidade por fornecedor", "tierFrontierQualified": "Frontier (melhor capacidade)", @@ -894,5 +894,10 @@ "writeTheDatabaseStateToOn": "Escrever o estado da base de dados em ficheiros YAML no disco.", "yourModel": "o seu modelo", "zoomIn": "Aproximar", - "zoomOut": "Afastar" + "zoomOut": "Afastar", + "failedToLoadAgentRoute": "Falha ao carregar a rota do agente", + "failedToLoadProviderHealth": "Falha ao carregar a saúde do fornecedor", + "failedToLoadRouteAttempts": "Falha ao carregar as tentativas de rota", + "failedToLoadRoutingConfig": "Falha ao carregar a configuração de encaminhamento", + "failedToLoadRoutingPreview": "Falha ao carregar a pré-visualização do encaminhamento" } diff --git a/apps/web/src/locales/pt-pt/plugins.json b/apps/web/src/locales/pt-pt/plugins.json index 00834ec74d..98cf2348f0 100644 --- a/apps/web/src/locales/pt-pt/plugins.json +++ b/apps/web/src/locales/pt-pt/plugins.json @@ -115,5 +115,6 @@ "uploadFile": "Carregar ficheiro", "utilityAgentsUnavailable": "Agentes utilitários indisponíveis", "viewRaw": "Ver o original", - "yes": "sim" + "yes": "sim", + "pluginDialog": "Janela do plugin" } diff --git a/apps/web/src/locales/pt-pt/review.json b/apps/web/src/locales/pt-pt/review.json index 5184401829..7586b95761 100644 --- a/apps/web/src/locales/pt-pt/review.json +++ b/apps/web/src/locales/pt-pt/review.json @@ -88,5 +88,10 @@ "findingQueuedForAgent": "Constatação adicionada à fila do agente", "findingSentToAgent": "Constatação enviada ao agente", "walkthroughRequestQueued": "Pedido de passo a passo em fila", - "walkthroughRequestSent": "Pedido de passo a passo enviado" + "walkthroughRequestSent": "Pedido de passo a passo enviado", + "couldNotStartReview": "Não foi possível iniciar a revisão", + "severityBlocker": "Bloqueador", + "severityMajor": "Grave", + "severityMinor": "Menor", + "severityNit": "Detalhe" } diff --git a/apps/web/src/locales/pt-pt/settings.json b/apps/web/src/locales/pt-pt/settings.json index 14e261be3b..3cffd32354 100644 --- a/apps/web/src/locales/pt-pt/settings.json +++ b/apps/web/src/locales/pt-pt/settings.json @@ -91,10 +91,8 @@ "connectKandevToThirdPartyServices": "Ligue o Kandev a serviços de terceiros. O âmbito da ligação e as definições disponíveis são mostrados na página de cada integração.", "connected": "Ligado", "connection": "Ligação", - "connectionLostDescription": "Ligação perdida há pelo menos 10 segundos. As atualizações em direto podem estar desatualizadas.", "connectionTestFailed": "O teste de ligação falhou", "connectionTestPassed": "O teste de ligação foi bem-sucedido", - "connectionUnstableDescription": "Ligação instável. A voltar a ligar ao Kandev.", "continueEditing": "Continuar a editar", "couldnTSave": "<0></0> Não foi possível guardar", "couldnTSave2": "Não foi possível guardar", @@ -300,7 +298,6 @@ "fontSize": "Tamanho da letra", "frequencySeconds": "Frequência (segundos)", "health": "Saúde", - "home": "Início", "hostedUrl": "URL alojado", "icons": "Ícones", "increaseHeight": "Aumentar a altura", @@ -462,8 +459,6 @@ "notifyWhenThisEventOccurs": "Notificar quando este evento ocorrer.", "openKandevTo": "Abrir o Kandev em", "openLinksIn": "Abrir as ligações em", - "openSettingsMenu": "Abrir o menu de definições", - "openSettingsMenuWithConnectionIssue": "{{description}} Abrir o menu de definições", "opensAVsCodeRemoteSsh": "Abre um URL de VS Code Remote SSH usando o esquema (exemplo: {{example}}).", "optionalScheme": "esquema opcional ({{schemes}})", "optionalUsername": "nome de utilizador opcional", @@ -590,7 +585,6 @@ "spritesDevProvidesEphemeralCloudSandboxes": "O Sprites.dev disponibiliza sandboxes efémeras na nuvem para executar agentes remotamente.", "startOnYourSavedKanbanPipelineOrList": "Começar na sua vista Kanban, Pipeline ou Lista guardada.", "startupPage": "Página de arranque", - "statusWithConnectionIssue": "Estado. {{description}}", "submitShortcut": "Atalho de envio", "supportedEditors": "Editores suportados", "supportsPlaceholders": "Aceita os marcadores: {{placeholders}}", @@ -711,5 +705,30 @@ "transferFailed": "Não foi possível copiar ou mover o segredo.", "globalSecrets": "Segredos globais", "destinationGlobal": "Global", - "secretNameTooLong": "O nome não pode exceder 100 bytes." + "secretNameTooLong": "O nome não pode exceder 100 bytes.", + "accessControl": "Controlo de acesso", + "chooseHowDeepTheSettingsMenuGoes": "Escolha se o menu de definições lista apenas páginas, ou se expande Workspaces, Agentes e Executores no que eles contêm.", + "configureStatusBarVisibility": "Escolha se o Kandev mostra a sua superfície de estado", + "discoveryAliasesSettingsMenu": "barra lateral,navegação,árvore,acordeão,menu", + "discoveryAliasesStatusBar": "barra inferior,gaveta de estado,estado da ligação,aparência", + "keyValueAddItem": "Adicionar item", + "keyValueKeyPlaceholder": "Chave", + "keyValueValuePlaceholder": "Valor", + "preferences": "Preferências", + "settingsMenu": "Menu de definições", + "settingsMenuAccordion": "Árvore em acordeão", + "settingsMenuAccordionDescription": "Workspaces, Agentes e Executores expandem-se no que contêm: um workspace até uma única integração. Abrir um ramo fecha os outros, por isso o menu mostra apenas o caminho para a página onde está.", + "settingsMenuFlat": "Lista simples", + "settingsMenuFlatDescription": "Uma linha por página de definições, nunca mais do que isso. Cada workspace, agente e executor é alcançado a partir da sua página. É a opção predefinida e o menu mais curto.", + "settingsMenuPersistent": "Árvore persistente", + "settingsMenuPersistentDescription": "Os mesmos ramos, mas vários ficam abertos ao mesmo tempo e continuam abertos depois de recarregar. Escolha isto para manter alguns registos lado a lado; com muitos workspaces ou agentes o menu fica comprido.", + "settingsMenuShape": "Forma do menu", + "settingsMenuShapePerDevice": "Aplica-se apenas a este dispositivo. Os seus outros dispositivos e navegadores mantêm a sua própria escolha.", + "showStatusBar": "Mostrar barra de estado", + "showStatusBarDescription": "Mostrar detalhes de estado ao longo do fundo do Kandev. Nos telemóveis, aparecem na gaveta Estado. Os avisos de ligação continuam visíveis quando isto está desligado.", + "statusBar": "Barra de estado", + "taskBehavior": "Comportamento das tarefas", + "terminalAndEditors": "Terminal e editores", + "workspacesAndAccess": "Workspaces e acesso", + "profileStartModelNotAdvertisedOnHost": "A sondagem do anfitrião não anunciou {{model}}. O executor selecionado decidirá o modelo no arranque." } diff --git a/apps/web/src/locales/pt-pt/sidebar.json b/apps/web/src/locales/pt-pt/sidebar.json index be0a6bcc47..03893f0f65 100644 --- a/apps/web/src/locales/pt-pt/sidebar.json +++ b/apps/web/src/locales/pt-pt/sidebar.json @@ -107,5 +107,13 @@ "work": "Trabalho", "workspaceFallback": "Área de trabalho", "taskPreferences": "Preferências de tarefas da barra lateral", - "views": "Vistas da barra lateral" + "views": "Vistas da barra lateral", + "collapseAllBranches": "Fechar tudo", + "failedToLoadArchivedTasks": "Falha ao carregar as tarefas arquivadas", + "failedToSyncSidebarTaskPrefs": "Falha ao sincronizar as preferências de tarefas da barra lateral", + "failedToSyncSidebarViews": "Falha ao sincronizar as vistas da barra lateral", + "morePluginItems": "Mais itens de plugins", + "newViewNumbered": "Nova vista {{suffix}}", + "orgChart": "Organograma", + "untitledView": "Vista sem título" } diff --git a/apps/web/src/locales/pt-pt/stats.json b/apps/web/src/locales/pt-pt/stats.json index 5e5080436e..7e6ea1bd88 100644 --- a/apps/web/src/locales/pt-pt/stats.json +++ b/apps/web/src/locales/pt-pt/stats.json @@ -79,5 +79,25 @@ "turnsPerTask": "Turnos por tarefa", "userMsgs": "Msgs do utilizador", "userShare": "Quota do utilizador", - "workload": "Carga de trabalho" + "workload": "Carga de trabalho", + "failedToLoadSection": "Falha ao carregar a secção", + "failedToLoadStats": "Falha ao carregar as estatísticas", + "loadingStats": "A carregar estatísticas…", + "noGitActivity": "sem atividade git", + "subtitleReady": "{{tasks}} · {{sessions}} · {{duration}}", + "subtitleSessionCount_one": "{{count}} sessão", + "subtitleSessionCount_other": "{{count}} sessões", + "subtitleTaskCount_one": "{{count}} tarefa", + "subtitleTaskCount_other": "{{count}} tarefas", + "summaryCompletedInRange": "- Concluídas ({{range}}): {{completed}}", + "summaryGit": "- Git: {{git}}", + "summaryGitActivity_one": "{{count}} commit, +{{insertions}}/-{{deletions}}", + "summaryGitActivity_other": "{{count}} commits, +{{insertions}}/-{{deletions}}", + "summaryHeading": "*Estatísticas do Kandev - {{range}}*", + "summaryRepos_one": "- Repositórios: {{count}} monitorizado · Repositório principal: {{topRepo}}", + "summaryRepos_other": "- Repositórios: {{count}} monitorizados · Repositório principal: {{topRepo}}", + "summaryTasks": "- Tarefas: {{total}} no total ({{done}} concluídas, {{inProgress}} em curso) · {{completion}} de conclusão", + "summaryTime": "- Tempo: {{total}} no total · {{average}} em média por tarefa", + "summaryTopRepo_one": "{{repository}} ({{count}} tarefa)", + "summaryTopRepo_other": "{{repository}} ({{count}} tarefas)" } diff --git a/apps/web/src/locales/pt-pt/system.json b/apps/web/src/locales/pt-pt/system.json index d8d1068ac5..4a75293f3f 100644 --- a/apps/web/src/locales/pt-pt/system.json +++ b/apps/web/src/locales/pt-pt/system.json @@ -507,5 +507,10 @@ "versionCurrent": "Atual", "versionLatest": "Mais recente", "versionTitle": "Versão", - "versionViewUpdates": "Ver as atualizações" + "versionViewUpdates": "Ver as atualizações", + "dataStoragePageDescription": "Estatísticas da base de dados, cópias de segurança, limpeza de disco e registos do servidor.", + "messageQueueAutoMergeDescription": "Junta mensagens consecutivas compatíveis em fila da mesma origem num único turno antes da entrega.", + "messageQueueAutoMergeNotice": "Se uma mensagem tiver outra origem ou tarefa, modelo, modo, metadados ou limites partilhados incompatíveis, permanece como mensagem em fila separada. Isto aplica-se apenas a novas admissões e não altera o controlo de junção manual.", + "messageQueueAutoMergeToggleLabel": "Juntar automaticamente mensagens consecutivas", + "navDataStorage": "Dados e registos" } diff --git a/apps/web/src/locales/pt-pt/task.json b/apps/web/src/locales/pt-pt/task.json index de038b133f..24346f15d2 100644 --- a/apps/web/src/locales/pt-pt/task.json +++ b/apps/web/src/locales/pt-pt/task.json @@ -462,7 +462,7 @@ "failedToPublishShare": "Não foi possível publicar a partilha.", "failedToRead": "Não foi possível ler {{name}}", "failedToRenameBranch": "Não foi possível mudar o nome do branch", - "failedToRenameItem": "Não foi possível mudar o nome do item", + "failedToRenameItem": "Falha ao mudar o nome do item", "failedToRenameTask": "Não foi possível mudar o nome da tarefa", "failedToRestorePlan": "Não foi possível restaurar o plano", "failedToResumeSession": "Não foi possível retomar a sessão", @@ -1533,7 +1533,6 @@ "failedToDownloadFile": "Falha ao transferir o ficheiro", "failedToFetchPlan": "Falha ao obter o plano", "failedToLoadPlanRevisions": "Falha ao carregar revisões", - "failedToRenameItem": "Falha ao mudar o nome do item", "failedToRevertPlan": "Falha ao reverter o plano", "failedToSavePlan": "Falha ao guardar o plano", "failedToStartTaskPlanMode": "Falha ao iniciar a tarefa no modo de plano", @@ -1565,5 +1564,144 @@ "sessionActionResume": "A retomar sessão", "sessionActionSetPrimary": "Definir como principal", "sessionActionStop": "A parar sessão", - "sessionActionSuccessful": "{{action}} concluído" + "sessionActionSuccessful": "{{action}} concluído", + "addDependency": "Adicionar dependência…", + "advancedSettings": "Definições avançadas", + "agentHasStopped": "Este agente parou.", + "agentResponseChangedSelectAgain": "A resposta do agente mudou. Selecione o texto novamente.", + "agentStillBeingSetUp": "O agente ainda está a ser preparado.", + "attachmentUploadFailed": "Falha no carregamento", + "branchDisabledLocalExecutor": "O executor local usa o checkout atual do seu repositório, por isso o ramo não pode mudar aqui. Ative 'Criar um novo ramo' para escolher outra base.", + "branchDisabledNoBranches": "Não há ramos disponíveis para este repositório.", + "branchDisabledSelectRepositoryFirst": "Selecione primeiro um repositório.", + "branchPrefixCurrent": "atual: ", + "branchPrefixFrom": "a partir de: ", + "branchPrefixWillSwitchTo": "vai mudar para: ", + "branchTooltipCurrent": "O checkout atual do seu repositório. O agente trabalha sobre ele tal como está, sem operações git.", + "branchTooltipFrom": "Ramo base. É criado a partir daqui um novo ramo (ou worktree) e o agente trabalha nele.", + "branchTooltipGeneric": "Ramo em que o agente vai trabalhar.", + "branchTooltipWillSwitchTo": "O backend vai executar `git checkout` para mudar o seu repositório para este ramo antes de o agente arrancar.", + "clarificationNoSelection": "Sem seleção", + "cleanupAgentSessionsStopped": "Todas as sessões de agente em execução serão paradas.", + "cleanupBulkLocal_one": "{{count}} tarefa local - as pastas do seu repositório não serão tocadas.", + "cleanupBulkLocal_other": "{{count}} tarefas locais - as pastas do seu repositório não serão tocadas.", + "cleanupBulkLocalDocker_one": "{{count}} contentor Docker será removido.", + "cleanupBulkLocalDocker_other": "{{count}} contentores Docker serão removidos.", + "cleanupBulkRemoteDocker_one": "{{count}} contentor Docker remoto será removido.", + "cleanupBulkRemoteDocker_other": "{{count}} contentores Docker remotos serão removidos.", + "cleanupBulkSprites_one": "{{count}} sandbox Sprites será destruída (trabalho não commitado perdido).", + "cleanupBulkSprites_other": "{{count}} sandboxes Sprites serão destruídas (trabalho não commitado perdido).", + "cleanupBulkSsh_one": "{{count}} diretório de tarefa remoto será removido (na medida do possível).", + "cleanupBulkSsh_other": "{{count}} diretórios de tarefa remotos serão removidos (na medida do possível).", + "cleanupBulkWorktree_one": "{{count}} worktree e o seu ramo serão eliminados.", + "cleanupBulkWorktree_other": "{{count}} worktrees e os seus ramos serão eliminados.", + "cleanupSingleLocal": "O agente correu diretamente no seu repositório - os seus ficheiros, ramo e pasta não são tocados.", + "cleanupSingleLocalDocker": "O contentor Docker que corre esta tarefa será parado e removido. O repositório no seu anfitrião não é tocado.", + "cleanupSingleRemoteDocker": "O contentor Docker remoto que corre esta tarefa será parado e removido.", + "cleanupSingleSprites": "A sandbox na cloud Sprites desta tarefa será destruída. Todo o trabalho não commitado lá dentro será perdido.", + "cleanupSingleSsh": "O diretório da tarefa no anfitrião remoto será removido (na medida do possível). O seu repositório local não é tocado.", + "cleanupSingleWorktree": "O worktree git da tarefa e o seu ramo serão eliminados. O seu repositório principal e os outros ramos não são afetados.", + "composerPlaceholderBusy": "Colocar instruções na fila para o agente...", + "composerPlaceholderClarification": "Colocar instruções na fila enquanto a pergunta está pendente...", + "composerPlaceholderFile": "Continuar a trabalhar no ficheiro...", + "composerPlaceholderPlan": "Continuar a trabalhar no plano...", + "composerPlaceholderRecovery": "Escolha acima uma opção de recuperação para continuar...", + "composerPlaceholderSwitchingAgent": "A mudar de agente...", + "composerPlaceholderTask": "Continuar a trabalhar na tarefa...", + "couldNotGenerateSummary": "Não foi possível gerar um resumo", + "credentialExplicitToken": "Um GH_TOKEN ou GITHUB_TOKEN explícito", + "credentialSourceExecutor": "Credenciais do executor", + "credentialSourceExecutorProfile": "Token do perfil de executor", + "credentialSourceWorkspace": "Identidade gerida do workspace", + "credentialTransportExecutorSelected": "credenciais Git e gh do executor", + "credentialTransportManagedFallback": "credenciais de tarefa geridas pelo Kandev", + "credentialTransportManagedHttps": "HTTPS do GitHub e gh geridos pelo Kandev", + "credentialTransportProfileToken": "token do perfil de executor", + "credentialWorkspaceFallback": "credenciais GitHub do workspace", + "credentialWorkspaceGhCli": "conta do GitHub CLI do workspace", + "credentialWorkspaceGithubApp": "GitHub App do workspace", + "credentialWorkspacePat": "token de acesso pessoal do workspace", + "dependencies": "Dependências", + "dependencyBlockedBy": "Bloqueada por", + "dependencyBlocks": "Bloqueia", + "dependencyChainHalted": "Uma tarefa de que esta depende falhou. A cadeia fica parada até a repetir ou remover a dependência.", + "dependencyChipLabel": "Dependências da tarefa", + "dependencyCount_one": "{{count}} dependência", + "dependencyCount_other": "{{count}} dependências", + "dependencyCountBlockedBy_one": "{{count}} bloqueio", + "dependencyCountBlockedBy_other": "{{count}} bloqueios", + "dependencyCountBlocks_one": "bloqueia {{count}}", + "dependencyCountBlocks_other": "bloqueia {{count}}", + "dependencyInfo": "Esta tarefa espera até que todas as tarefas selecionadas terminem com sucesso. Se iniciar um agente, ele arranca automaticamente quando todas as dependências estiverem resolvidas.", + "dependencyInfoLabel": "Sobre dependências de tarefas", + "dependsOn": "Depende de", + "dependsOnWillAutoStart": "Esta tarefa espera pelas tarefas acima. Se iniciar um agente, ele corre automaticamente assim que todas elas terminarem com sucesso.", + "enhancedPromptCopiedToClipboard": "Prompt melhorado copiado para a área de transferência.", + "enhancedPromptCouldNotBeCopied": "Não foi possível copiar o prompt melhorado.", + "enhancedPromptCouldNotBeInserted": "O prompt melhorado foi gerado mas não pôde ser inserido.", + "environmentNotCreated": "não criado", + "failedToCreateSubtask": "Falha ao criar a subtarefa", + "failedToDetachTask": "Falha ao destacar a tarefa", + "failedToFetchCumulativeDiff": "Falha ao obter o diff cumulativo", + "failedToLoadFileContent": "Falha ao carregar o conteúdo do ficheiro", + "failedToLoadFileTree": "Falha ao carregar a árvore de ficheiros", + "failedToResumeAndRestore": "Falha ao retomar a sessão - o restauro do workspace também está indisponível", + "gitOperationFailedGeneric": "A operação falhou", + "invalidGitHubUrl": "URL do GitHub inválido - esperava-se github.com/owner/repo ou .../pull/123", + "invalidRegex": "Expressão regular inválida", + "messageFetchLimitHint": "O limite de obtenção pode ser pequeno demais para o último turno desta sessão - o prompt do utilizador e as respostas do agente ficam mais atrás. Pagine ou aumente o limite para os ver.", + "messageMetadataEntries": "Entradas de metadados da mensagem", + "monitorStatusEnded": "terminado", + "monitorStatusEndedSessionRestart": "terminado (reinício da sessão)", + "monitorStatusWatching": "a observar", + "noBranchesShort": "sem ramos", + "noDependencies": "Sem dependências", + "noDependency": "Sem dependência", + "openFileInProvider": "Abrir ficheiro em {{provider}}", + "referenceSearchFailedRetry": "A pesquisa de referências falhou. Tente novamente.", + "removeDependency": "Remover dependência", + "renameFailed": "Falha ao mudar o nome", + "resuming": "A retomar...", + "sessionFailedUnexpectedly": "A sessão falhou inesperadamente", + "sessionNotAvailableForInput": "A sessão selecionada não está disponível para entrada.", + "sessionStatusGenerating": "A gerar", + "sessionStatusStarting": "A iniciar", + "sessionStatusTurnFinished": "Turno terminado", + "submitTooltipQueueMessage": "Colocar mensagem na fila", + "submitTooltipRequestPlanChanges": "Pedir alterações ao plano", + "summarizeCouldNotGenerate": "Não foi possível gerar um resumo. Verifique se o agente utilitário de resumo está configurado e ativo nas definições.", + "summarizeFailed": "Falha ao resumir", + "taskClosed": "Tarefa fechada", + "taskClosedAutomatically": "Esta tarefa foi fechada automaticamente.", + "taskClosedIssueClosed": "A sua issue foi fechada, por isso esta tarefa foi fechada automaticamente.", + "taskClosedPrApproved": "O seu pull request foi aprovado, por isso esta tarefa de revisão foi fechada automaticamente.", + "taskClosedPrMergedOrClosed": "O seu pull request foi integrado ou fechado, por isso esta tarefa de revisão foi fechada automaticamente.", + "taskFailedToStart": "A tarefa não conseguiu arrancar", + "taskTitledWasClosed": "\"{{title}}\" foi fechada", + "updateAvailableToast": "O Kandev {{version}} está disponível. Abra Definições > Sistema > Atualizações para o rever.", + "updateFailed": "Falha na atualização", + "workspaceRoot": "Raiz do workspace", + "workspaceUnavailableArchived": "Workspace indisponível - esta tarefa está arquivada", + "worktreeExecutorRequiresRepository": "O executor worktree requer um repositório.", + "worktreeNumbered": "Worktree {{index}}", + "modelSelectionAgent": "Agente", + "modelSelectionEffective": "Modelo efetivo", + "modelSelectionExecutor": "Executor", + "modelSelectionExecutorProfile": "Perfil de executor", + "modelSelectionFallback": "Alternativa anunciada", + "modelSelectionProviderDefault": "Predefinição do agente", + "modelSelectionReason": "Motivo", + "modelSelectionReasonAutoFallback": "Não foi possível aplicar o modelo anunciado, por isso a alternativa automática continuou com a predefinição do agente.", + "modelSelectionReasonCatalogEmpty": "O executor não anunciou um catálogo de modelos.", + "modelSelectionReasonFallbackNotAdvertised": "O modelo alternativo guardado não foi anunciado pelo executor.", + "modelSelectionReasonRequestedNotAdvertised": "O modelo guardado não foi anunciado pelo executor.", + "modelSelectionReasonUnknown": "{{reason}}", + "modelSelectionReasonUnsupported": "O executor não suporta seleção de modelo.", + "modelSelectionRemediationAgentVersion": "Verifique a versão do agente no executor.", + "modelSelectionRemediationConfiguration": "Verifique a configuração do agente copiada.", + "modelSelectionRemediationCredentials": "Verifique as credenciais do executor.", + "modelSelectionRemediationTitle": "Verifique estes itens", + "modelSelectionRemediationUnknown": "Verifique a configuração do executor.", + "modelSelectionRequested": "Modelo pedido", + "modelSelectionWarning": "O executor não conseguiu usar a seleção de modelo guardada." } diff --git a/apps/web/src/locales/pt-pt/workflows.json b/apps/web/src/locales/pt-pt/workflows.json index d1184a3a5f..caac0b87f4 100644 --- a/apps/web/src/locales/pt-pt/workflows.json +++ b/apps/web/src/locales/pt-pt/workflows.json @@ -99,7 +99,6 @@ "lastAttempt": "última tentativa {{when}}", "lastSynced": "última sincronização {{when}}", "loadingWorkflowSteps": "A carregar os passos do fluxo de trabalho...", - "manageWorkflowsForThisWorkspace": "Faça a gestão dos fluxos de trabalho desta área de trabalho.", "migrateAndDelete": "Migrar e eliminar", "migrateAndDeleteStep": "Migrar e eliminar o passo", "migrating": "A migrar...", @@ -258,5 +257,13 @@ "workflowsSectionDescription": "Crie pipelines autónomas com transições automáticas ou fluxos de trabalho manuais em que é o utilizador a mover as tarefas", "workflowSteps": "Passos do fluxo de trabalho", "workflowSyncCompleted": "Sincronização de fluxos de trabalho concluída", - "workspaceSettings": "Definições da área de trabalho" + "duplicateUnavailableInImproveKandev": "Os fluxos de trabalho deste workspace não podem ser duplicados.", + "duplicateWorkflow": "Duplicar", + "duplicatingWorkflow": "A duplicar...", + "failedToDuplicateWorkflow": "Falha ao duplicar o fluxo de trabalho", + "loadingSteps": "A carregar passos…", + "noStepsInThisWorkflow": "Este fluxo de trabalho não tem passos", + "saveBeforeDuplicating": "Guarde o fluxo de trabalho antes de o duplicar.", + "selectAWorkflowFirst": "Selecione primeiro um fluxo de trabalho", + "workflowMutationInProgress": "Aguarde que a alteração atual do fluxo de trabalho termine." } diff --git a/apps/web/src/locales/pt-pt/workspaces.json b/apps/web/src/locales/pt-pt/workspaces.json index 06afab05c4..3a20ea40b0 100644 --- a/apps/web/src/locales/pt-pt/workspaces.json +++ b/apps/web/src/locales/pt-pt/workspaces.json @@ -114,7 +114,17 @@ "workspaceNamePlaceholder": "A minha área de trabalho", "workspaceNotFound": "Área de trabalho não encontrada", "workspaceSettings": "Definições da área de trabalho", - "workspaceSettingsLink": "Definições da área de trabalho", "worktreeBranchTemplate": "Modelo de branch do worktree", - "worktreeSync": "Sincronização de worktree" + "worktreeSync": "Sincronização de worktree", + "chooseDifferentRepositoryName": "Escolha outro nome de repositório.", + "enterRepositoryName": "Introduza um nome de repositório.", + "overview": "Visão geral", + "repositoryNameMustBeOneFolder": "O nome do repositório tem de ser o nome de uma única pasta.", + "resourceCount_one": "{{count}} recurso", + "resourceCount_other": "{{count}} recursos", + "sourceChooseAFolder": "Escolha uma pasta.", + "sourceChooseRepoAndBranch": "Escolha um repositório e um ramo base.", + "sourceEnterValidRemoteUrl": "Introduza um URL de repositório remoto e um ramo base válidos.", + "sourceFolderAlreadySelected": "Esta pasta já está selecionada.", + "sourceRepoBranchAlreadySelected": "Este repositório e ramo já estão selecionados." } diff --git a/apps/web/src/locales/zh-cn/agents.json b/apps/web/src/locales/zh-cn/agents.json index a9b5704fc5..1296e1a0d3 100644 --- a/apps/web/src/locales/zh-cn/agents.json +++ b/apps/web/src/locales/zh-cn/agents.json @@ -234,5 +234,9 @@ "duplicateProfileSuccess": "智能体配置已复制", "duplicateSaveBlocked": "设置仍在保存,请稍后重试。", "failedToDuplicateProfile": "复制智能体配置失败", - "noProfilesYetShort": "暂无配置" + "noProfilesYetShort": "暂无配置", + "createProfile": "创建配置", + "creatingProfile": "正在创建…", + "saveProfile": "保存配置", + "savingProfile": "正在保存…" } diff --git a/apps/web/src/locales/zh-cn/auth.json b/apps/web/src/locales/zh-cn/auth.json index 440b51b535..5d56e027c0 100644 --- a/apps/web/src/locales/zh-cn/auth.json +++ b/apps/web/src/locales/zh-cn/auth.json @@ -16,5 +16,14 @@ "signIn": "登录", "signingIn": "正在登录…", "signInToYourKandevAccount": "登录你的 Kandev 账户以继续。", - "youVeBeenInvitedAs": "你已被邀请为 {{role}}。" + "youVeBeenInvitedAs": "你已被邀请为 {{role}}。", + "couldNotAcceptInvite": "无法接受此邀请。", + "couldNotLoadInvite": "无法加载此邀请。请重试。", + "invalidEmailOrPassword": "邮箱或密码不正确。", + "inviteLinkInvalidOrExpired": "此邀请链接无效或已过期。请向管理员索取新的链接。", + "inviteLinkMissingToken": "此邀请链接缺少令牌。", + "setupFailed": "初始化失败。请重试。", + "setupNoLongerAvailable": "初始化已不可用 - 账号已存在。", + "somethingWentWrong": "出了点问题。请重试。", + "tooManyAttempts": "尝试次数过多。请稍候片刻再试。" } diff --git a/apps/web/src/locales/zh-cn/automations.json b/apps/web/src/locales/zh-cn/automations.json index 7656bfd3a6..2acc745288 100644 --- a/apps/web/src/locales/zh-cn/automations.json +++ b/apps/web/src/locales/zh-cn/automations.json @@ -202,5 +202,33 @@ "workflowLabel": "工作流", "workflowLoadError": "无法加载工作流。", "workflowStepHelpNoWorkflow": "请先选择工作流,再选择步骤。", - "workflowStepLabel": "工作流步骤" + "workflowStepLabel": "工作流步骤", + "failedToLoadAutomationActivity": "加载自动化活动失败", + "failedToLoadAutomations": "加载自动化失败", + "failedToLoadRuns": "加载运行记录失败", + "failedToLoadThisAutomation": "加载此自动化失败", + "frequencyCustom": "自定义计划", + "frequencyEvery15Minutes": "每 15 分钟", + "frequencyEvery30Minutes": "每 30 分钟", + "frequencyEvery5Minutes": "每 5 分钟", + "frequencyEvery6Hours": "每 6 小时", + "frequencyEveryDay": "每天", + "frequencyEveryHour": "每小时", + "frequencyEveryWeek": "每周", + "nextFiringConcurrencyPaused": "已暂停 - 仍有一次运行未结束(同时最多 {{max}} 次)", + "nextFiringNoSchedule": "无计划 - 只有你手动触发时才运行", + "nextFiringScheduleOff": "计划触发器已关闭", + "nextFiringSwitchedOff": "已关闭 - 不会触发", + "runNoReportRecorded": "未记录任何报告。", + "runStillRunningNoReport": "仍在运行 - 暂无报告。", + "scheduleAtSeparator": "于", + "scheduleEveryDayAt": "每天 {{time}} {{zone}}", + "scheduleEveryHourAt": "每小时的第 :{{minute}} 分", + "scheduleEveryWeekdayAt": "每{{weekday}} {{time}} {{zone}}", + "scheduleIntervalEvery15Minutes": "每 15 分钟", + "scheduleIntervalEvery30Minutes": "每 30 分钟", + "scheduleIntervalEvery5Minutes": "每 5 分钟", + "scheduleIntervalEvery6Hours": "每 6 小时", + "scheduleNone": "无计划", + "scheduleUnparseable": "我们无法解析这个计划。请尝试 5 字段的 cron,例如 0 9 * * 1-5。" } diff --git a/apps/web/src/locales/zh-cn/chat.json b/apps/web/src/locales/zh-cn/chat.json index 7a113eb9b2..acdf5f04e5 100644 --- a/apps/web/src/locales/zh-cn/chat.json +++ b/apps/web/src/locales/zh-cn/chat.json @@ -141,5 +141,9 @@ "transientRetryProviderModel": "{{provider}} · {{model}}", "transientRetryProvider": "服务商:{{provider}}", "transientRetryModel": "模型:{{model}}", - "unpinQueuedMessages": "取消固定消息队列" + "unpinQueuedMessages": "取消固定消息队列", + "emptyTurnKnownCommand": "`/{{command}}` 已运行,但没有产生任何输出。{{hint}}", + "emptyTurnNoOutput": "智能体已结束,但没有产生任何输出。", + "emptyTurnResendHint": "请尝试作为普通消息重新发送你的请求,不要带开头的斜杠。", + "emptyTurnUnknownCommand": "`/{{command}}` 不是该智能体识别的命令,因此没有返回任何内容。{{hint}}" } diff --git a/apps/web/src/locales/zh-cn/common.json b/apps/web/src/locales/zh-cn/common.json index 4c00a8f164..d699552c52 100644 --- a/apps/web/src/locales/zh-cn/common.json +++ b/apps/web/src/locales/zh-cn/common.json @@ -164,7 +164,7 @@ "failedToLoadDirectory": "加载目录失败", "failedToLoadRoutine": "加载例程失败", "failedToLoadSetup": "加载设置失败", - "failedToRenderDiagram": "渲染图表失败", + "failedToRenderDiagram": "图表渲染失败", "featureRequest": "功能请求", "files": "文件", "folderName": "文件夹名称", @@ -442,7 +442,6 @@ "youCanUseTheNavigationOr": "你可以使用导航或重新加载以重试。", "yourAgentImplementsItInThe": "你的智能体在 Kandev 仓库中实现它,并附带测试", "youVerifyAndTestTheChange": "你在第二个 Kandev 实例中验证并测试该改动", - "failedToRenderDiagram": "图表渲染失败", "demoAgentMessagesDescription": "目视检查代理消息规范化。每张卡片同时显示渲染结果和原始 JSON。", "demoAgentMessagesQa": "代理消息质量检查", "demoErrorLoading": "加载{{mode}}时出错", @@ -470,5 +469,43 @@ "demoShowingMessages_one": "显示 {{count}} / {{total}} 条消息", "demoShowingMessages_other": "显示 {{count}} / {{total}} 条消息", "demoTestFixtures": "测试数据", - "demoToolType": "工具类型:" + "demoToolType": "工具类型:", + "checking": "检查中", + "configured": "已配置", + "connected": "已连接", + "debug": "调试", + "failedToLoadRoute": "加载路由失败", + "improveKandevDirectPushAccess": "你对 kdlbs/kandev 拥有写入权限,因此智能体会直接推送到上游仓库的分支。", + "improveKandevExecutorForkAtPr": "智能体会在 PR 步骤中使用执行器的凭据准备一个 fork,并从中开启 pull request。", + "improveKandevExistingForkPrepared": "Kandev 为该任务找到了一个可写的 fork。智能体会把分支推送到那里,并向 kdlbs/kandev 开启 pull request。", + "improveKandevIssueNoForkAccess": "开启 issue 不需要 fork 或推送权限。", + "improveKandevManagedForkPrepared": "Kandev 会在智能体启动前为该任务准备一个可写的 fork。智能体会把分支推送到那里,并向 kdlbs/kandev 开启 pull request。", + "improveStepImprove": "智能体阅读报告、探索代码库,并以 TDD 方式实现变更。运行 make fmt、typecheck、test、lint,然后提交。", + "improveStepOpenIssue": "智能体从仓库模板收集所有必填字段,与你确认公开的草稿,然后发布 GitHub issue。", + "improveStepPr": "智能体运行 pr 技能:推送分支并向 kdlbs/kandev 开启 pull request,交由维护者评审。", + "improveStepTest": "智能体用 make dev 启动第二个 kandev 实例,并告诉你需要验证什么。你在运行中的应用里确认变更有效。", + "managedGithubForkUnavailable": "受管的 GitHub 访问无法为该任务准备已验证的 fork。请检查工作区的 GitHub 连接,或改为开启一个 issue。", + "noOptionFound": "未找到选项。", + "searchPlaceholder": "搜索…", + "selectOption": "选择选项…", + "terminalNumbered": "终端 {{seq}}", + "weekdayFriday": "星期五", + "weekdayMonday": "星期一", + "weekdaySaturday": "星期六", + "weekdaySunday": "星期日", + "weekdayThursday": "星期四", + "weekdayTuesday": "星期二", + "weekdayWednesday": "星期三", + "preciseDated": "{{date}} {{time}}", + "preciseToday": "今天 {{time}}", + "preciseYesterday": "昨天 {{time}}", + "relativeDaysAgo_one": "{{count}} 天前", + "relativeDaysAgo_other": "{{count}} 天前", + "relativeHoursAgo_one": "{{count}} 小时前", + "relativeHoursAgo_other": "{{count}} 小时前", + "relativeMinutesAgo_one": "{{count}} 分钟前", + "relativeMinutesAgo_other": "{{count}} 分钟前", + "relativeSecondsAgo_one": "{{count}} 秒前", + "relativeSecondsAgo_other": "{{count}} 秒前", + "relativeYesterday": "昨天" } diff --git a/apps/web/src/locales/zh-cn/diff.json b/apps/web/src/locales/zh-cn/diff.json index b88fb0fd07..67cf5e9284 100644 --- a/apps/web/src/locales/zh-cn/diff.json +++ b/apps/web/src/locales/zh-cn/diff.json @@ -46,5 +46,11 @@ "walkthrough": "导览", "walkthroughNoteAdded": "已添加导览备注", "walkthroughNoteQueued": "导览备注已排队", - "walkthroughNoteSent": "导览备注已发送" + "walkthroughNoteSent": "导览备注已发送", + "fileStatusAdded": "已添加", + "fileStatusDeleted": "已删除", + "fileStatusModified": "已修改", + "fileStatusMoved": "已移动", + "fileStatusMovedFrom": "从 {{oldPath}} 移动而来", + "fileStatusUntracked": "未跟踪" } diff --git a/apps/web/src/locales/zh-cn/executors.json b/apps/web/src/locales/zh-cn/executors.json index 8becedc3b9..b483d0d457 100644 --- a/apps/web/src/locales/zh-cn/executors.json +++ b/apps/web/src/locales/zh-cn/executors.json @@ -258,5 +258,23 @@ "user": "用户", "usesTheLocalDockerDaemonOn": "使用本机上的本地 Docker 守护进程。", "value": "值", - "verifyingConnection": "正在验证连接…" + "verifyingConnection": "正在验证连接…", + "profileNameExample": "生产、开发", + "portableConfigAvailable": "可用", + "portableConfigBundleClaudeSettings": "复制 Claude 设置", + "portableConfigBundleCodexConfig": "复制 Codex 配置", + "portableConfigBundleGeneric": "智能体配置({{id}})", + "portableConfigBundleMockSettings": "复制模拟智能体设置", + "portableConfigBundleOpenCodeConfig": "复制 OpenCode 配置", + "portableConfigDescription": "把选定的智能体设置复制到新建或重置的环境中;这些设置可能包含密钥或命令。", + "portableConfigFileMapping": "{{source}} 到 {{target}}", + "portableConfigInfoCopyTiming": "Kandev 只在全新建置或环境重置时原样复制选定的文件。新的复制可能覆盖已存在的目标文件。热恢复会保留现有环境。", + "portableConfigInfoErrors": "缺失或无法读取的可选文件会被跳过并给出警告,不会阻止启动。这些文件可能包含密钥或环境变量值。", + "portableConfigInfoLabel": "可迁移智能体配置详情", + "portableConfigInfoScope": "只有在允许列表中的文件会被复制。一个文件可能添加钩子或命令,改变模型、权限、MCP 服务器或接入点,也可能包含在执行器中无效的主机路径。认证文件与配置包是各自独立的选项。", + "portableConfigInfoSsh": "在 SSH 上,目标是所配置远程用户的共享主目录,可能覆盖该主机上其他进程正在使用的配置。", + "portableConfigInfoTitle": "可迁移的智能体配置", + "portableConfigNotFound": "未找到", + "portableConfigNotFoundHint": "这只是编辑提示。你可以先保存配置,稍后再添加该文件。", + "portableConfigTitle": "智能体配置" } diff --git a/apps/web/src/locales/zh-cn/github.json b/apps/web/src/locales/zh-cn/github.json index b90ad403d8..601e5ea8e9 100644 --- a/apps/web/src/locales/zh-cn/github.json +++ b/apps/web/src/locales/zh-cn/github.json @@ -568,5 +568,25 @@ "workspaceGithubAccess": "工作区 GitHub 访问", "workspaceGithubConnectionRemoved": "工作区 GitHub 连接已移除", "workspaceGithubIdentity": "工作区 GitHub 身份", - "yourReviewIsRequested": "已请求你的审阅" + "yourReviewIsRequested": "已请求你的审阅", + "appRegistrationsUnavailable": "GitHub App 注册信息不可用", + "failedToFetchPrCommits": "获取 PR 提交失败", + "failedToFetchPrFeedback": "获取 PR 反馈失败", + "failedToFetchPrFiles": "获取 PR 文件失败", + "failedToLoadCiAutomationOptions": "加载 CI 自动化选项失败。", + "failedToSearchGitHub": "在 GitHub 上搜索失败", + "pullRequestNumbered": "Pull Request #{{number}}", + "timeAgoDays_one": "{{count}} 天前", + "timeAgoDays_other": "{{count}} 天前", + "timeAgoHours_one": "{{count}} 小时前", + "timeAgoHours_other": "{{count}} 小时前", + "timeAgoJustNow": "刚刚", + "timeAgoMinutes_one": "{{count}} 分钟前", + "timeAgoMinutes_other": "{{count}} 分钟前", + "timeAgoMonths_one": "{{count}} 个月前", + "timeAgoMonths_other": "{{count}} 个月前", + "timeAgoWeeks_one": "{{count}} 周前", + "timeAgoWeeks_other": "{{count}} 周前", + "timeAgoYears_one": "{{count}} 年前", + "timeAgoYears_other": "{{count}} 年前" } diff --git a/apps/web/src/locales/zh-cn/gitlab.json b/apps/web/src/locales/zh-cn/gitlab.json index 5cce4799c9..904ad251ff 100644 --- a/apps/web/src/locales/zh-cn/gitlab.json +++ b/apps/web/src/locales/zh-cn/gitlab.json @@ -350,5 +350,14 @@ "mrChipDrawerClose": "关闭合并请求状态面板", "gitlabRejectedAssociation": "GitLab 拒绝了关联。", "taskCreatedMrNotLinked": "任务已创建,但未关联合并请求", - "taskCreatedMrNotLinkedDescription": "打开任务并使用“关联 GitLab 合并请求”重试。{{error}}" + "taskCreatedMrNotLinkedDescription": "打开任务并使用“关联 GitLab 合并请求”重试。{{error}}", + "addedMrFeedbackToTaskContext": "已将 merge request 反馈加入任务上下文", + "failedToLoadMergeRequest": "加载 merge request 失败", + "failedToLoadMrAutomationOptions": "加载 MR 自动化选项失败。", + "failedToSearchGitLab": "在 GitLab 上搜索失败", + "memberSearchFailed": "成员搜索失败", + "presetAssigned": "指派给我", + "presetAuthored": "我创建的", + "presetCreated": "我创建的", + "presetReviewRequested": "请求我评审" } diff --git a/apps/web/src/locales/zh-cn/integrations.json b/apps/web/src/locales/zh-cn/integrations.json index 9793ced75d..458489ad97 100644 --- a/apps/web/src/locales/zh-cn/integrations.json +++ b/apps/web/src/locales/zh-cn/integrations.json @@ -105,5 +105,90 @@ "saveQuery": "保存查询", "waitingForTheNextBackendHealth": "正在等待下一次后端健康检查…", "cursorPaginationSummary_one": "第 {{page}} 页 · {{count}} 个结果", - "cursorPaginationSummary_other": "第 {{page}} 页 · {{count}} 个结果" + "cursorPaginationSummary_other": "第 {{page}} 页 · {{count}} 个结果", + "actionFromReviewer": "{{name}} {{action}}", + "actionRequired": "需要处理", + "addAllCommentsToChatContext": "将所有评论加入对话上下文", + "addAllFailedChecksToChatContext": "将所有失败的检查加入对话上下文", + "addAllReviewsToChatContext": "将所有评审加入对话上下文", + "addFailuresToChatContext": "将 {{name}} 的失败加入对话上下文", + "addReviewComment": "添加评审意见", + "addWholeThreadToChatContext": "将整个讨论串加入对话上下文", + "allRepositories": "所有仓库", + "approved": "已批准", + "approvedCount_one": "{{count}} 项已批准", + "approvedCount_other": "{{count}} 项已批准", + "awaitingReview": "等待评审", + "botComments_one": "机器人评论({{count}})", + "botComments_other": "机器人评论({{count}})", + "by": "作者", + "changeRequestCreationFailed": "创建变更请求失败", + "changesRequested": "要求修改", + "changesRequestedCount_one": "{{count}} 项要求修改", + "changesRequestedCount_other": "{{count}} 项要求修改", + "chooseReview": "选择评审", + "chooseReviewToOpen": "选择一个评审以打开它。", + "ciChecks": "CI 检查{{summary}}", + "closedRelative": "{{relative}}关闭", + "closePullRequestStatus": "关闭 PR 状态", + "commentCount_one": "{{count}} 条评论", + "commentCount_other": "{{count}} 条评论", + "commented": "已评论", + "comments": "评论", + "disabled": "已禁用", + "dismissed": "已忽略", + "failed": "失败", + "failedCount_one": "{{count}} 项失败", + "failedCount_other": "{{count}} 项失败", + "general": "(总体)", + "inProgress": "进行中", + "lastSynced": "上次同步于{{relative}}", + "loadingChangeRequest": "正在加载变更请求", + "mergedRelative": "{{relative}}合并", + "noChecks": "无检查", + "noChecksHaveStarted": "尚未开始任何检查", + "noCommentsYet": "暂无评论", + "noReviewsYet": "暂无评审", + "openDetails": "打开 {{title}} 详情", + "openedRelative": "{{relative}}打开", + "openReview": "打开评审", + "openReviewNumber": "打开评审 #{{number}}", + "other": "其他", + "otherCount_one": "{{count}} 项其他", + "otherCount_other": "{{count}} 项其他", + "overall": "总体", + "passed": "通过", + "passedCount_one": "{{count}} 项通过", + "passedCount_other": "{{count}} 项通过", + "passRate": "通过率", + "pending": "待处理", + "pendingCount_one": "{{count}} 项待处理", + "pendingCount_other": "{{count}} 项待处理", + "pendingReview": "待评审", + "pullRequest": "Pull request", + "pullRequestCount_one": "{{count}} 个 pull request", + "pullRequestCount_other": "{{count}} 个 pull request", + "pullRequestNumber": "PR #{{number}}", + "pullRequestShortCount_one": "{{count}} 个 PR", + "pullRequestShortCount_other": "{{count}} 个 PR", + "pullRequestStatusSummary": "Pull request 的 CI 状态、评审与检查摘要。", + "refresh": "刷新", + "replyToThread": "回复讨论串 {{threadId}}", + "requestedCount_one": "{{count}} 项已请求", + "requestedCount_other": "{{count}} 项已请求", + "retry": "重试", + "reviewCount_one": "{{count}} 条评审", + "reviewCount_other": "{{count}} 条评审", + "reviewerTeamName": "{{name}}(团队)", + "reviews": "评审{{summary}}", + "reviewUnavailable": "评审不可用。", + "running": "运行中", + "saving": "正在保存", + "timedOut": "已超时", + "unresolvedComments_one": "{{count}} 条未解决的评论", + "unresolvedComments_other": "{{count}} 条未解决的评论", + "untitledPullRequest": "未命名的 pull request", + "updatedAgo": "{{elapsed}}前更新", + "updatedJustNow": "刚刚更新", + "viewPullRequestExternally": "在外部查看 pull request #{{number}}" } diff --git a/apps/web/src/locales/zh-cn/jira.json b/apps/web/src/locales/zh-cn/jira.json index e7be45d0dc..e354c69447 100644 --- a/apps/web/src/locales/zh-cn/jira.json +++ b/apps/web/src/locales/zh-cn/jira.json @@ -208,5 +208,7 @@ "watcherUpdated": "已更新监控", "workflow": "工作流", "workflowStep": "工作流步骤", - "yourJiraSessionExpiredOrNeeds": "你的 Jira 会话已过期或需要二次验证。请重新连接以查看此工单。" + "yourJiraSessionExpiredOrNeeds": "你的 Jira 会话已过期或需要二次验证。请重新连接以查看此工单。", + "builtinViewAssignedToMe": "指派给我", + "builtinViewUnassigned": "未指派" } diff --git a/apps/web/src/locales/zh-cn/kanban.json b/apps/web/src/locales/zh-cn/kanban.json index d09e6b6e95..5a5df39c6f 100644 --- a/apps/web/src/locales/zh-cn/kanban.json +++ b/apps/web/src/locales/zh-cn/kanban.json @@ -95,5 +95,15 @@ "workflowStepFallback": "工作流步骤 {{stepId}}", "columns": "列", "columnsForWorkflow": "{{name}} 的列", - "needsReassignment": "需要重新分配" + "needsReassignment": "需要重新分配", + "blockedByCount_one": "被 {{count}} 项阻塞", + "blockedByCount_other": "被 {{count}} 项阻塞", + "blockedByTasksTitle": "正在等待:{{tasks}}", + "blockedFailed": "阻塞项失败", + "blockedPredecessorFailed": "该任务依赖的一个任务失败了:{{tasks}}", + "noTasksYet": "暂无任务", + "noWorkflowsAvailableYet": "暂无可用的工作流。", + "pullToRefreshPull": "下拉刷新", + "pullToRefreshRefreshing": "正在刷新任务", + "pullToRefreshRelease": "松开即可刷新" } diff --git a/apps/web/src/locales/zh-cn/office.json b/apps/web/src/locales/zh-cn/office.json index 1e8542f988..0119049a95 100644 --- a/apps/web/src/locales/zh-cn/office.json +++ b/apps/web/src/locales/zh-cn/office.json @@ -811,17 +811,17 @@ "thisWillPermanentlyDeleteAllMemory": "将永久删除此智能体的全部记忆条目。此操作无法撤销。", "thisWillPermanentlyDeleteThisInstruction": "将永久删除此说明文件。此操作无法撤销。", "tier": "层级", - "tierBalanced": "Balanced", + "tierBalanced": "均衡", "tierBalancedDescription": "当协调智能体分配 Balanced 层级时,用于一般工作者智能体。", "tierBalancedHint": "标准能力", "tierBalancedQualified": "Balanced(标准)", "tierBalancedUsage": "Balanced 层级用量", - "tierEconomy": "Economy", + "tierEconomy": "经济", "tierEconomyDescription": "当协调智能体分配 Economy 层级时,用于 QA、例行与低成本智能体。", "tierEconomyHint": "最便宜的可用模型", "tierEconomyQualified": "Economy(最便宜)", "tierEconomyUsage": "Economy 层级用量", - "tierFrontier": "Frontier", + "tierFrontier": "前沿", "tierFrontierDescription": "当协调智能体创建最高能力工作的智能体或分配 Frontier 层级时使用。", "tierFrontierHint": "各提供商的最强能力", "tierFrontierQualified": "Frontier(最强能力)", @@ -894,5 +894,10 @@ "writeTheDatabaseStateToOn": "将数据库状态写入磁盘上的 YAML 文件。", "yourModel": "你的模型", "zoomIn": "放大", - "zoomOut": "缩小" + "zoomOut": "缩小", + "failedToLoadAgentRoute": "加载智能体路由失败", + "failedToLoadProviderHealth": "加载服务商健康状态失败", + "failedToLoadRouteAttempts": "加载路由尝试记录失败", + "failedToLoadRoutingConfig": "加载路由配置失败", + "failedToLoadRoutingPreview": "加载路由预览失败" } diff --git a/apps/web/src/locales/zh-cn/plugins.json b/apps/web/src/locales/zh-cn/plugins.json index 7c8c79fd94..3e9dde8cbc 100644 --- a/apps/web/src/locales/zh-cn/plugins.json +++ b/apps/web/src/locales/zh-cn/plugins.json @@ -115,5 +115,6 @@ "uploadFile": "上传文件", "utilityAgentsUnavailable": "辅助智能体不可用", "viewRaw": "查看原始内容", - "yes": "是" + "yes": "是", + "pluginDialog": "插件对话框" } diff --git a/apps/web/src/locales/zh-cn/review.json b/apps/web/src/locales/zh-cn/review.json index 8fd40cf944..38c72ef045 100644 --- a/apps/web/src/locales/zh-cn/review.json +++ b/apps/web/src/locales/zh-cn/review.json @@ -88,5 +88,10 @@ "findingQueuedForAgent": "发现项已加入代理队列", "findingSentToAgent": "发现项已发送给代理", "walkthroughRequestQueued": "导览请求已排队", - "walkthroughRequestSent": "导览请求已发送" + "walkthroughRequestSent": "导览请求已发送", + "couldNotStartReview": "无法开始评审", + "severityBlocker": "阻断", + "severityMajor": "严重", + "severityMinor": "次要", + "severityNit": "细节" } diff --git a/apps/web/src/locales/zh-cn/settings.json b/apps/web/src/locales/zh-cn/settings.json index e82a9da32a..2d47ac6dfd 100644 --- a/apps/web/src/locales/zh-cn/settings.json +++ b/apps/web/src/locales/zh-cn/settings.json @@ -709,12 +709,6 @@ "shortcutTaskContentSearch": "搜索任务内容", "shortcutTogglePlanMode": "切换计划模式", "shortcutToggleSidebar": "切换侧边栏", - "connectionLostDescription": "连接已丢失至少 10 秒,实时更新可能已过期。", - "connectionUnstableDescription": "连接不稳定,正在重新连接 Kandev。", - "home": "主页", - "openSettingsMenu": "打开设置菜单", - "openSettingsMenuWithConnectionIssue": "{{description}} 打开设置菜单", - "statusWithConnectionIssue": "状态。{{description}}", "copyMove": "复制/移动", "copyMoveSecretNamed": "复制或移动{{name}}", "copyMoveDialogHint": "将密钥复制或移动到其他范围。值在服务端传输,绝不会显示。", @@ -732,5 +726,9 @@ "noValidDestinations": "没有可用的有效目标。", "transferFailed": "无法复制或移动密钥。", "destinationGlobal": "全局", - "secretNameTooLong": "名称不能超过 100 字节。" + "secretNameTooLong": "名称不能超过 100 字节。", + "keyValueAddItem": "添加条目", + "keyValueKeyPlaceholder": "键", + "keyValueValuePlaceholder": "值", + "profileStartModelNotAdvertisedOnHost": "主机探测未公布 {{model}}。所选执行器将在启动时决定使用的模型。" } diff --git a/apps/web/src/locales/zh-cn/sidebar.json b/apps/web/src/locales/zh-cn/sidebar.json index 5ab1d5b267..234bc1d606 100644 --- a/apps/web/src/locales/zh-cn/sidebar.json +++ b/apps/web/src/locales/zh-cn/sidebar.json @@ -109,5 +109,11 @@ "work": "工作", "workspaceFallback": "工作区", "taskPreferences": "侧边栏任务偏好设置", - "views": "侧边栏视图" + "views": "侧边栏视图", + "failedToLoadArchivedTasks": "加载已归档任务失败", + "failedToSyncSidebarTaskPrefs": "同步侧边栏任务偏好失败", + "failedToSyncSidebarViews": "同步侧边栏视图失败", + "morePluginItems": "更多插件项", + "newViewNumbered": "新视图 {{suffix}}", + "untitledView": "未命名视图" } diff --git a/apps/web/src/locales/zh-cn/stats.json b/apps/web/src/locales/zh-cn/stats.json index bebae358d2..b698611a79 100644 --- a/apps/web/src/locales/zh-cn/stats.json +++ b/apps/web/src/locales/zh-cn/stats.json @@ -79,5 +79,25 @@ "turnsPerTask": "每任务回合数", "userMsgs": "用户消息", "userShare": "用户占比", - "workload": "工作负载" + "workload": "工作负载", + "failedToLoadSection": "加载该部分失败", + "failedToLoadStats": "加载统计数据失败", + "loadingStats": "正在加载统计数据…", + "noGitActivity": "无 git 活动", + "subtitleReady": "{{tasks}} · {{sessions}} · {{duration}}", + "subtitleSessionCount_one": "{{count}} 个会话", + "subtitleSessionCount_other": "{{count}} 个会话", + "subtitleTaskCount_one": "{{count}} 个任务", + "subtitleTaskCount_other": "{{count}} 个任务", + "summaryCompletedInRange": "- 已完成({{range}}):{{completed}}", + "summaryGit": "- Git:{{git}}", + "summaryGitActivity_one": "{{count}} 次提交,+{{insertions}}/-{{deletions}}", + "summaryGitActivity_other": "{{count}} 次提交,+{{insertions}}/-{{deletions}}", + "summaryHeading": "*Kandev 统计 - {{range}}*", + "summaryRepos_one": "- 仓库:已跟踪 {{count}} 个 · 主要仓库:{{topRepo}}", + "summaryRepos_other": "- 仓库:已跟踪 {{count}} 个 · 主要仓库:{{topRepo}}", + "summaryTasks": "- 任务:共 {{total}} 个({{done}} 个已完成,{{inProgress}} 个进行中)· 完成率 {{completion}}", + "summaryTime": "- 时间:共 {{total}} · 每个任务平均 {{average}}", + "summaryTopRepo_one": "{{repository}}({{count}} 个任务)", + "summaryTopRepo_other": "{{repository}}({{count}} 个任务)" } diff --git a/apps/web/src/locales/zh-cn/system.json b/apps/web/src/locales/zh-cn/system.json index dc6597404c..d170e79b21 100644 --- a/apps/web/src/locales/zh-cn/system.json +++ b/apps/web/src/locales/zh-cn/system.json @@ -509,5 +509,8 @@ "versionCurrent": "当前", "versionLatest": "最新", "versionTitle": "版本", - "versionViewUpdates": "查看更新" + "versionViewUpdates": "查看更新", + "messageQueueAutoMergeDescription": "在投递前,把来自同一来源的连续且兼容的排队消息合并为一轮。", + "messageQueueAutoMergeNotice": "如果某条消息的来源不同,或任务、模型、模式、元数据或共享限制不兼容,它会保持为单独的排队消息。这只适用于新接收的消息,不会改变手动合并控制。", + "messageQueueAutoMergeToggleLabel": "自动合并连续消息" } diff --git a/apps/web/src/locales/zh-cn/task.json b/apps/web/src/locales/zh-cn/task.json index c9e2e17492..d3a765b86c 100644 --- a/apps/web/src/locales/zh-cn/task.json +++ b/apps/web/src/locales/zh-cn/task.json @@ -937,7 +937,6 @@ "providerAheadPushDisabled": "提供商分支较新。无法执行推送。", "providerAheadPullRequiresUpstream": "此检出必须配置上游后才能拉取。", "divergedActionsUnavailable": "贡献历史已分叉。请从 Changes 操作中选择一个版本。", - "providerHistoryUnavailable": "当前 PR 历史不可用。显示检出历史,但不假定发生了重写。", "proxy": "代理服务器", "publishARedactedSnapshotOfThis": "将此已完成任务的脱敏快照发布为你账户上的私密 GitHub Gist。", "publishToGithubGist": "发布到 GitHub Gist", @@ -1565,5 +1564,144 @@ "sessionActionResume": "恢复会话中", "sessionActionSetPrimary": "设为主要会话", "sessionActionStop": "停止会话中", - "sessionActionSuccessful": "{{action}}成功" + "sessionActionSuccessful": "{{action}}成功", + "addDependency": "添加依赖…", + "advancedSettings": "高级设置", + "agentHasStopped": "该智能体已停止。", + "agentResponseChangedSelectAgain": "智能体的回复已变化。请重新选择文本。", + "agentStillBeingSetUp": "智能体仍在准备中。", + "attachmentUploadFailed": "上传失败", + "branchDisabledLocalExecutor": "本地执行器使用你仓库当前的检出,因此这里无法更改分支。打开“创建新分支”即可选择其他基线。", + "branchDisabledNoBranches": "该仓库没有可用的分支。", + "branchDisabledSelectRepositoryFirst": "请先选择一个仓库。", + "branchPrefixCurrent": "当前:", + "branchPrefixFrom": "来自:", + "branchPrefixWillSwitchTo": "将切换到:", + "branchTooltipCurrent": "你仓库当前的检出。智能体按原样在其上运行,不做任何 git 操作。", + "branchTooltipFrom": "基线分支。将从这里派生出新分支(或 worktree),智能体在其中运行。", + "branchTooltipGeneric": "智能体将要运行的分支。", + "branchTooltipWillSwitchTo": "在智能体启动前,后端会执行 `git checkout` 将你的仓库切换到该分支。", + "clarificationNoSelection": "未选择", + "cleanupAgentSessionsStopped": "所有正在运行的智能体会话都将被停止。", + "cleanupBulkLocal_one": "{{count}} 个本地任务 - 你的仓库目录不会被改动。", + "cleanupBulkLocal_other": "{{count}} 个本地任务 - 你的仓库目录不会被改动。", + "cleanupBulkLocalDocker_one": "将移除 {{count}} 个 Docker 容器。", + "cleanupBulkLocalDocker_other": "将移除 {{count}} 个 Docker 容器。", + "cleanupBulkRemoteDocker_one": "将移除 {{count}} 个远程 Docker 容器。", + "cleanupBulkRemoteDocker_other": "将移除 {{count}} 个远程 Docker 容器。", + "cleanupBulkSprites_one": "将销毁 {{count}} 个 Sprites 沙箱(未提交的工作将丢失)。", + "cleanupBulkSprites_other": "将销毁 {{count}} 个 Sprites 沙箱(未提交的工作将丢失)。", + "cleanupBulkSsh_one": "将尽力移除 {{count}} 个远程任务目录。", + "cleanupBulkSsh_other": "将尽力移除 {{count}} 个远程任务目录。", + "cleanupBulkWorktree_one": "将删除 {{count}} 个 worktree 及其分支。", + "cleanupBulkWorktree_other": "将删除 {{count}} 个 worktree 及其分支。", + "cleanupSingleLocal": "智能体直接在你的仓库中运行 - 你的文件、分支和目录都不会被改动。", + "cleanupSingleLocalDocker": "运行该任务的 Docker 容器将被停止并移除。你主机上的仓库不会被改动。", + "cleanupSingleRemoteDocker": "运行该任务的远程 Docker 容器将被停止并移除。", + "cleanupSingleSprites": "该任务的 Sprites 云沙箱将被销毁。其中所有未提交的工作都会丢失。", + "cleanupSingleSsh": "远程主机上的任务目录将被尽力移除。你的本地仓库不会被改动。", + "cleanupSingleWorktree": "该任务的 git worktree 及其分支将被删除。你的主仓库和其他分支不受影响。", + "composerPlaceholderBusy": "为智能体排队指令…", + "composerPlaceholderClarification": "在问题待答期间排队指令…", + "composerPlaceholderFile": "继续处理该文件…", + "composerPlaceholderPlan": "继续完善计划…", + "composerPlaceholderRecovery": "请在上方选择一个恢复选项以继续…", + "composerPlaceholderSwitchingAgent": "正在切换智能体…", + "composerPlaceholderTask": "继续处理该任务…", + "couldNotGenerateSummary": "无法生成摘要", + "credentialExplicitToken": "显式的 GH_TOKEN 或 GITHUB_TOKEN", + "credentialSourceExecutor": "执行器凭据", + "credentialSourceExecutorProfile": "执行器配置的令牌", + "credentialSourceWorkspace": "受管的工作区身份", + "credentialTransportExecutorSelected": "执行器的 Git 与 gh 凭据", + "credentialTransportManagedFallback": "由 Kandev 管理的任务凭据", + "credentialTransportManagedHttps": "由 Kandev 管理的 GitHub HTTPS 与 gh", + "credentialTransportProfileToken": "执行器配置的令牌", + "credentialWorkspaceFallback": "工作区的 GitHub 凭据", + "credentialWorkspaceGhCli": "工作区的 GitHub CLI 账号", + "credentialWorkspaceGithubApp": "工作区的 GitHub App", + "credentialWorkspacePat": "工作区的个人访问令牌", + "dependencies": "依赖", + "dependencyBlockedBy": "被阻塞于", + "dependencyBlocks": "阻塞", + "dependencyChainHalted": "该任务依赖的某个任务失败了。在你重试或移除该依赖之前,整条链都会停住。", + "dependencyChipLabel": "任务依赖", + "dependencyCount_one": "{{count}} 个依赖", + "dependencyCount_other": "{{count}} 个依赖", + "dependencyCountBlockedBy_one": "{{count}} 个阻塞项", + "dependencyCountBlockedBy_other": "{{count}} 个阻塞项", + "dependencyCountBlocks_one": "阻塞 {{count}} 项", + "dependencyCountBlocks_other": "阻塞 {{count}} 项", + "dependencyInfo": "该任务会一直等待,直到所选的每个任务都成功完成。如果你启动了智能体,它会在所有依赖解决后自动开始。", + "dependencyInfoLabel": "关于任务依赖", + "dependsOn": "依赖于", + "dependsOnWillAutoStart": "该任务会等待上面的任务。如果你启动了智能体,它会在这些任务全部成功完成后自动运行。", + "enhancedPromptCopiedToClipboard": "已将优化后的提示词复制到剪贴板。", + "enhancedPromptCouldNotBeCopied": "无法复制优化后的提示词。", + "enhancedPromptCouldNotBeInserted": "已生成优化后的提示词,但无法插入。", + "environmentNotCreated": "未创建", + "failedToCreateSubtask": "创建子任务失败", + "failedToDetachTask": "分离任务失败", + "failedToFetchCumulativeDiff": "获取累计差异失败", + "failedToLoadFileContent": "加载文件内容失败", + "failedToLoadFileTree": "加载文件树失败", + "failedToResumeAndRestore": "恢复会话失败 - 工作区还原同样不可用", + "gitOperationFailedGeneric": "操作失败", + "invalidGitHubUrl": "GitHub 链接无效 - 应为 github.com/owner/repo 或 .../pull/123", + "invalidRegex": "正则表达式无效", + "messageFetchLimitHint": "对于该会话的最后一轮,获取上限可能太小 - 用户提示词和智能体回复位于更靠前的位置。请翻页或提高上限以查看它们。", + "messageMetadataEntries": "消息元数据条目", + "monitorStatusEnded": "已结束", + "monitorStatusEndedSessionRestart": "已结束(会话重启)", + "monitorStatusWatching": "监视中", + "noBranchesShort": "无分支", + "noDependencies": "无依赖", + "noDependency": "无依赖", + "openFileInProvider": "在 {{provider}} 中打开文件", + "referenceSearchFailedRetry": "引用搜索失败。请重试。", + "removeDependency": "移除依赖", + "renameFailed": "重命名失败", + "resuming": "正在恢复…", + "sessionFailedUnexpectedly": "会话意外失败", + "sessionNotAvailableForInput": "所选会话无法接收输入。", + "sessionStatusGenerating": "生成中", + "sessionStatusStarting": "启动中", + "sessionStatusTurnFinished": "本轮已结束", + "submitTooltipQueueMessage": "将消息排队", + "submitTooltipRequestPlanChanges": "请求修改计划", + "summarizeCouldNotGenerate": "无法生成摘要。请检查摘要工具智能体是否已在设置中配置并启用。", + "summarizeFailed": "生成摘要失败", + "taskClosed": "任务已关闭", + "taskClosedAutomatically": "该任务已被自动关闭。", + "taskClosedIssueClosed": "其 issue 已关闭,因此该任务被自动关闭。", + "taskClosedPrApproved": "其 pull request 已获批准,因此该评审任务被自动关闭。", + "taskClosedPrMergedOrClosed": "其 pull request 已合并或关闭,因此该评审任务被自动关闭。", + "taskFailedToStart": "任务启动失败", + "taskTitledWasClosed": "“{{title}}”已关闭", + "updateAvailableToast": "Kandev {{version}} 已发布。请打开“设置 > 系统 > 更新”查看。", + "updateFailed": "更新失败", + "workspaceRoot": "工作区根目录", + "workspaceUnavailableArchived": "工作区不可用 - 该任务已归档", + "worktreeExecutorRequiresRepository": "Worktree 执行器需要一个仓库。", + "worktreeNumbered": "Worktree {{index}}", + "modelSelectionAgent": "智能体", + "modelSelectionEffective": "实际使用的模型", + "modelSelectionExecutor": "执行器", + "modelSelectionExecutorProfile": "执行器配置", + "modelSelectionFallback": "已公布的备选模型", + "modelSelectionProviderDefault": "智能体默认模型", + "modelSelectionReason": "原因", + "modelSelectionReasonAutoFallback": "无法应用已公布的模型,因此自动回退并继续使用智能体的默认模型。", + "modelSelectionReasonCatalogEmpty": "执行器未公布模型目录。", + "modelSelectionReasonFallbackNotAdvertised": "执行器未公布已保存的备选模型。", + "modelSelectionReasonRequestedNotAdvertised": "执行器未公布已保存的模型。", + "modelSelectionReasonUnknown": "{{reason}}", + "modelSelectionReasonUnsupported": "该执行器不支持选择模型。", + "modelSelectionRemediationAgentVersion": "请检查执行器中的智能体版本。", + "modelSelectionRemediationConfiguration": "请检查已复制的智能体配置。", + "modelSelectionRemediationCredentials": "请检查执行器凭据。", + "modelSelectionRemediationTitle": "请检查以下各项", + "modelSelectionRemediationUnknown": "请检查执行器配置。", + "modelSelectionRequested": "请求的模型", + "modelSelectionWarning": "执行器无法使用已保存的模型选择。" } diff --git a/apps/web/src/locales/zh-cn/workflows.json b/apps/web/src/locales/zh-cn/workflows.json index 99567b862e..f3818b96dc 100644 --- a/apps/web/src/locales/zh-cn/workflows.json +++ b/apps/web/src/locales/zh-cn/workflows.json @@ -262,5 +262,8 @@ "workflows": "工作流", "workflowsSectionDescription": "创建带自动流转的自主流水线,或由你手动移动任务的手动工作流", "workflowSteps": "工作流步骤", - "workflowSyncCompleted": "工作流同步完成" + "workflowSyncCompleted": "工作流同步完成", + "loadingSteps": "正在加载步骤…", + "noStepsInThisWorkflow": "该工作流没有步骤", + "selectAWorkflowFirst": "请先选择一个工作流" } diff --git a/apps/web/src/locales/zh-cn/workspaces.json b/apps/web/src/locales/zh-cn/workspaces.json index 1cc4add6c2..901d5bb1e3 100644 --- a/apps/web/src/locales/zh-cn/workspaces.json +++ b/apps/web/src/locales/zh-cn/workspaces.json @@ -118,5 +118,13 @@ "workspaceNotFound": "未找到工作区", "workspaceSettings": "工作区设置", "worktreeBranchTemplate": "工作树分支模板", - "worktreeSync": "工作树同步" + "worktreeSync": "工作树同步", + "chooseDifferentRepositoryName": "请另选一个仓库名称。", + "enterRepositoryName": "请输入仓库名称。", + "repositoryNameMustBeOneFolder": "仓库名称必须是单个目录名。", + "sourceChooseAFolder": "请选择一个目录。", + "sourceChooseRepoAndBranch": "请选择一个仓库和基线分支。", + "sourceEnterValidRemoteUrl": "请输入有效的远程仓库 URL 和基线分支。", + "sourceFolderAlreadySelected": "该目录已被选择。", + "sourceRepoBranchAlreadySelected": "该仓库和分支已被选择。" } diff --git a/apps/web/src/locales/zh-hk/agents.json b/apps/web/src/locales/zh-hk/agents.json index 80e463620e..4d515a9254 100644 --- a/apps/web/src/locales/zh-hk/agents.json +++ b/apps/web/src/locales/zh-hk/agents.json @@ -132,7 +132,7 @@ "mcpServersHelp": "MCP 定義存儲在數據庫中,並在執行時按執行器解析。這不會覆蓋你的本地代理程式設定檔。", "mcpServersJson": "MCP 伺服器(JSON)", "mcpStrategy": "Kandev MCP 工具", - "mcpStrategyHelp": "Kandev 可以為這個代理程式提供自己的工具(設定任務標題、向使用者提問、更新計畫、標記步驟完成)。為此,它必須以你的 CLI 能讀取的格式寫入 MCP 設定,因此請選擇相符的格式。選錯格式會靜默失敗:設定已寫入,但 CLI 會忽略它,而且不會顯示任何錯誤。如果該命令不是支援 MCP 的程式設計代理程式,請保持關閉。", + "mcpStrategyHelp": "Kandev 可以為該代理提供自己的工具(設定任務標題、向用戶提問、更新計劃、標記步驟完成)。為此,它必須以你的 CLI 能讀取的格式寫入 MCP 設定,因此請選擇匹配的格式。選錯格式會靜默失敗:設定已寫入,但 CLI 會忽略它,並且不會顯示任何錯誤。如果該命令不是支援 MCP 的編碼代理,請保持關閉。", "mcpStrategyNone": "關閉:不提供 Kandev 工具", "mcpStrategyOption": "{{name}}:{{mechanism}}", "model": "模型", @@ -221,5 +221,22 @@ "watcherKindGithubIssue": "GitHub 議題", "watcherKindGithubReview": "GitHub PR 審查", "watcherKindJira": "Jira", - "watcherKindLinear": "Linear" + "watcherKindLinear": "Linear", + "activeRuntimeVersion": "活動版本:{{version}}", + "activeRuntimeVersionMarker": "活動", + "latestRuntimeVersion": "最新", + "runtimeVersionMarkerGroup": "({{markers}})", + "runtimeVersionOption": "{{version}} {{markers}}", + "selectRuntimeVersion": "要激活的版本", + "updateRuntime": "更新執行時", + "rollBackRuntime": "回滾執行時", + "repairRuntime": "修復執行時", + "upToDateRuntime": "已是最新", + "collapseAgentProfiles": "摺疊 {{name}} 設定", + "expandAgentProfiles": "展開 {{name}} 設定", + "noProfilesYetShort": "暫無設定", + "createProfile": "建立設定", + "creatingProfile": "正在建立…", + "saveProfile": "儲存設定", + "savingProfile": "正在儲存…" } diff --git a/apps/web/src/locales/zh-hk/auth.json b/apps/web/src/locales/zh-hk/auth.json index cf3a4ff038..5132b82b2c 100644 --- a/apps/web/src/locales/zh-hk/auth.json +++ b/apps/web/src/locales/zh-hk/auth.json @@ -16,5 +16,14 @@ "signIn": "登入", "signingIn": "正在登入…", "signInToYourKandevAccount": "登入你的 Kandev 帳戶以繼續。", - "youVeBeenInvitedAs": "你已被邀請為 {{role}}。" + "youVeBeenInvitedAs": "你已被邀請為 {{role}}。", + "couldNotAcceptInvite": "無法接受此邀請。", + "couldNotLoadInvite": "無法載入此邀請。請重試。", + "invalidEmailOrPassword": "電郵地址或密碼不正確。", + "inviteLinkInvalidOrExpired": "此邀請連結無效或已過期。請向管理員索取新的連結。", + "inviteLinkMissingToken": "此邀請連結缺少權杖。", + "setupFailed": "初始化失敗。請重試。", + "setupNoLongerAvailable": "初始化已不可用 - 帳戶已存在。", + "somethingWentWrong": "出了點問題。請重試。", + "tooManyAttempts": "嘗試次數過多。請稍候片刻再試。" } diff --git a/apps/web/src/locales/zh-hk/automations.json b/apps/web/src/locales/zh-hk/automations.json index 770ef805bf..07b85a4ffd 100644 --- a/apps/web/src/locales/zh-hk/automations.json +++ b/apps/web/src/locales/zh-hk/automations.json @@ -202,5 +202,33 @@ "workflowLabel": "工作流程", "workflowLoadError": "無法載入工作流程。", "workflowStepHelpNoWorkflow": "請先選擇工作流程,再選擇步驟。", - "workflowStepLabel": "工作流程步驟" + "workflowStepLabel": "工作流程步驟", + "failedToLoadAutomationActivity": "載入自動化活動失敗", + "failedToLoadAutomations": "載入自動化失敗", + "failedToLoadRuns": "載入執行記錄失敗", + "failedToLoadThisAutomation": "載入此自動化失敗", + "frequencyCustom": "自訂計劃", + "frequencyEvery15Minutes": "每 15 分鐘", + "frequencyEvery30Minutes": "每 30 分鐘", + "frequencyEvery5Minutes": "每 5 分鐘", + "frequencyEvery6Hours": "每 6 小時", + "frequencyEveryDay": "每天", + "frequencyEveryHour": "每小時", + "frequencyEveryWeek": "每週", + "nextFiringConcurrencyPaused": "已暫停 - 仍有一次執行未結束(同時最多 {{max}} 次)", + "nextFiringNoSchedule": "無計劃 - 只有你手動觸發時才執行", + "nextFiringScheduleOff": "計劃觸發器已關閉", + "nextFiringSwitchedOff": "已關閉 - 不會觸發", + "runNoReportRecorded": "未記錄任何報告。", + "runStillRunningNoReport": "仍在執行 - 暫無報告。", + "scheduleAtSeparator": "於", + "scheduleEveryDayAt": "每天 {{time}} {{zone}}", + "scheduleEveryHourAt": "每小時的第 :{{minute}} 分", + "scheduleEveryWeekdayAt": "每{{weekday}} {{time}} {{zone}}", + "scheduleIntervalEvery15Minutes": "每 15 分鐘", + "scheduleIntervalEvery30Minutes": "每 30 分鐘", + "scheduleIntervalEvery5Minutes": "每 5 分鐘", + "scheduleIntervalEvery6Hours": "每 6 小時", + "scheduleNone": "無計劃", + "scheduleUnparseable": "我們無法解析這個計劃。請嘗試 5 欄位的 cron,例如 0 9 * * 1-5。" } diff --git a/apps/web/src/locales/zh-hk/azuredevops.json b/apps/web/src/locales/zh-hk/azuredevops.json index dffb210098..03d6bb5844 100644 --- a/apps/web/src/locales/zh-hk/azuredevops.json +++ b/apps/web/src/locales/zh-hk/azuredevops.json @@ -253,5 +253,34 @@ "workItemUnavailable": "工作項不可用。", "workItemWatches": "工作項監控", "workItemWatchesDescription": "根據匹配的 Azure DevOps 工作項查詢自動建立任務。", - "workItemWatchFieldsRequired": "項目、WIQL、儲存庫、工作流程、步驟、代理程式和執行器均為必填項。" + "workItemWatchFieldsRequired": "項目、WIQL、儲存庫、工作流程、步驟、代理程式和執行器均為必填項。", + "prStatusAbandoned": "已放棄", + "prStatusActive": "活動", + "prStatusChangesRequested": "已請求更改", + "prStatusCompleted": "已完成", + "prStatusDraft": "草稿", + "prStatusPolicyFailed": "策略失敗", + "prStatusPolicyRunning": "策略執行中", + "prStatusReady": "就緒", + "prStatusWaitingForReview": "等待審查", + "pullRequestStatusAria": "Azure PR {{id}}:{{status}}{{suffix}}", + "defaultActionAddressFeedback": "處理反饋", + "defaultActionAddressFeedbackHint": "應用審查意見", + "defaultActionFixCi": "修復 CI", + "defaultActionFixCiHint": "診斷失敗的檢查", + "defaultActionImplement": "實施", + "defaultActionImplementHint": "構建並開啟 PR", + "defaultActionInvestigate": "調查", + "defaultActionInvestigateHint": "查找根本原因", + "defaultActionReproduce": "復現", + "defaultActionReproduceHint": "記錄復現步驟", + "defaultActionReview": "審查", + "defaultActionReviewHint": "閲讀差異並標記問題", + "defaultQueryActive": "活動", + "defaultQueryAssignedToMe": "分配給我", + "defaultQueryCompleted": "已完成", + "defaultQueryCreatedByMe": "由我建立", + "defaultQueryOpen": "開啟", + "defaultQueryRecentlyUpdated": "最近更新", + "defaultQueryReviewRequested": "已請求審查" } diff --git a/apps/web/src/locales/zh-hk/chat.json b/apps/web/src/locales/zh-hk/chat.json index 4383d3a40b..01b43f53cd 100644 --- a/apps/web/src/locales/zh-hk/chat.json +++ b/apps/web/src/locales/zh-hk/chat.json @@ -56,9 +56,9 @@ "reorderQueuedMessage": "重新排序佇列訊息", "sortable": "可排序", "technicalDetails": "技術詳情", - "managedRuntimeNpmTitle": "npm 無法準備執行環境", - "managedRuntimeNpmBody": "Kandev 已重新整理套件資料,並重試相同的執行環境一次。", - "managedRuntimeRetry": "重試執行環境", + "managedRuntimeNpmTitle": "npm 無法準備執行時", + "managedRuntimeNpmBody": "Kandev 已刷新軟件包數據,並重試了同一執行時一次。", + "managedRuntimeRetry": "重試執行時", "providerQuotaTitle": "{{provider}} 使用額度已達上限", "newChatTab": "新建聊天", "newQuickChat": "新建快速聊天", @@ -131,7 +131,7 @@ "transientRetryReasonProviderOverloaded": "服務商過載", "transientRetryReasonProviderUnavailable": "服務商暫時不可用", "transientRetryReasonRateLimited": "請求受限", - "transientRetryReasonAgentTransportLost": "代理連線已中斷", + "transientRetryReasonAgentTransportLost": "代理連接已斷開", "transientRetryReasonGeneric": "服務商錯誤", "transientRetryIn": "將在 {{countdown}} 後重試", "transientRetryNow": "正在重試…", @@ -139,5 +139,11 @@ "transientRetryAttempt": "第 {{attempt}} 次,共 {{maxAttempts}} 次", "transientRetryProviderModel": "{{provider}} · {{model}}", "transientRetryProvider": "服務商:{{provider}}", - "transientRetryModel": "模型:{{model}}" + "transientRetryModel": "模型:{{model}}", + "pinQueuedMessages": "固定訊息佇列", + "unpinQueuedMessages": "取消固定訊息佇列", + "emptyTurnKnownCommand": "`/{{command}}` 已執行,但沒有產生任何輸出。{{hint}}", + "emptyTurnNoOutput": "代理程式已結束,但沒有產生任何輸出。", + "emptyTurnResendHint": "請嘗試作為普通訊息重新傳送你的請求,不要帶開頭的斜槓。", + "emptyTurnUnknownCommand": "`/{{command}}` 不是該代理程式識別的命令,因此沒有返回任何內容。{{hint}}" } diff --git a/apps/web/src/locales/zh-hk/common.json b/apps/web/src/locales/zh-hk/common.json index 253e3eed8f..04fc5b8399 100644 --- a/apps/web/src/locales/zh-hk/common.json +++ b/apps/web/src/locales/zh-hk/common.json @@ -164,7 +164,7 @@ "failedToLoadDirectory": "載入目錄失敗", "failedToLoadRoutine": "載入例程失敗", "failedToLoadSetup": "載入設定失敗", - "failedToRenderDiagram": "渲染圖表失敗", + "failedToRenderDiagram": "圖表渲染失敗", "featureRequest": "功能請求", "files": "檔案", "folderName": "資料夾名稱", @@ -441,5 +441,71 @@ "you": "您", "youCanUseTheNavigationOr": "你可以使用導航或重新載入以重試。", "yourAgentImplementsItInThe": "你的代理程式在 Kandev 儲存庫中實現它,並附帶測試", - "youVerifyAndTestTheChange": "你在第二個 Kandev 實例中驗證並測試該改動" + "youVerifyAndTestTheChange": "你在第二個 Kandev 實例中驗證並測試該改動", + "demoAgentMessagesDescription": "目視檢查代理訊息規範化。每張卡片同時顯示渲染結果和原始 JSON。", + "demoAgentMessagesQa": "代理訊息品質檢查", + "demoErrorLoading": "載入{{mode}}時出錯", + "demoEventFile": "事件檔案:", + "demoEvents": "事件", + "demoFailedToLoadFiles": "載入檔案失敗", + "demoFailedToLoadFixtures": "載入測試數據失敗", + "demoFailedToLoadMessages": "載入訊息失敗", + "demoFileOption_one": "{{protocol}} - {{agent}}({{count}} 條訊息)", + "demoFileOption_other": "{{protocol}} - {{agent}}({{count}} 條訊息)", + "demoFixtureFile": "測試數據檔案:", + "demoFixtures": "測試數據", + "demoMessageData": "訊息數據", + "demoMessageTypes": "訊息類型演示", + "demoMessageTypesDescription": "呈現所有訊息類型以供樣式設計和測試", + "demoNoFilesFound": "未找到檔案", + "demoNoFixtures": "未找到測試數據。請從上方下拉列表中選擇檔案。", + "demoNoNormalizedEvents": "未找到規範化事件檔案。請使用 KANDEV_DEBUG_AGENT_MESSAGES=1 執行代理以產生事件日誌。", + "demoNormalizedEvents": "規範化事件", + "demoNormalizedPayload": "規範化內容", + "demoRawInput": "原始輸入", + "demoRenderedOutput": "渲染結果:", + "demoShowingFixtures_one": "顯示 {{count}} / {{total}} 個測試數據", + "demoShowingFixtures_other": "顯示 {{count}} / {{total}} 個測試數據", + "demoShowingMessages_one": "顯示 {{count}} / {{total}} 條訊息", + "demoShowingMessages_other": "顯示 {{count}} / {{total}} 條訊息", + "demoTestFixtures": "測試數據", + "demoToolType": "工具類型:", + "checking": "檢查中", + "configured": "已設定", + "connected": "已連接", + "debug": "調試", + "failedToLoadRoute": "載入路由失敗", + "improveKandevDirectPushAccess": "你對 kdlbs/kandev 擁有寫入權限,因此代理程式會直接推送到上游儲存庫的分支。", + "improveKandevExecutorForkAtPr": "代理程式會在 PR 步驟中使用執行器的認證資料準備一個 fork,並從中開啓 pull request。", + "improveKandevExistingForkPrepared": "Kandev 為該任務找到了一個可寫的 fork。代理程式會把分支推送到那裏,並向 kdlbs/kandev 開啓 pull request。", + "improveKandevIssueNoForkAccess": "開啓 issue 不需要 fork 或推送權限。", + "improveKandevManagedForkPrepared": "Kandev 會在代理程式啓動前為該任務準備一個可寫的 fork。代理程式會把分支推送到那裏,並向 kdlbs/kandev 開啓 pull request。", + "improveStepImprove": "代理程式閲讀報告、探索程式碼庫,並以 TDD 方式實現變更。執行 make fmt、typecheck、test、lint,然後提交。", + "improveStepOpenIssue": "代理程式從儲存庫模板收集所有必填欄位,與你確認公開的草稿,然後發佈 GitHub issue。", + "improveStepPr": "代理程式執行 pr 技能:推送分支並向 kdlbs/kandev 開啓 pull request,交由維護者審查。", + "improveStepTest": "代理程式用 make dev 啓動第二個 kandev 實例,並告訴你需要驗證什麼。你在執行中的應用裏確認變更有效。", + "managedGithubForkUnavailable": "受管的 GitHub 存取無法為該任務準備已驗證的 fork。請檢查工作區的 GitHub 連接,或改為開啓一個 issue。", + "noOptionFound": "未找到選項。", + "searchPlaceholder": "搜尋…", + "selectOption": "選擇選項…", + "terminalNumbered": "終端 {{seq}}", + "weekdayFriday": "星期五", + "weekdayMonday": "星期一", + "weekdaySaturday": "星期六", + "weekdaySunday": "星期日", + "weekdayThursday": "星期四", + "weekdayTuesday": "星期二", + "weekdayWednesday": "星期三", + "preciseDated": "{{date}} {{time}}", + "preciseToday": "今天 {{time}}", + "preciseYesterday": "昨天 {{time}}", + "relativeDaysAgo_one": "{{count}} 天前", + "relativeDaysAgo_other": "{{count}} 天前", + "relativeHoursAgo_one": "{{count}} 小時前", + "relativeHoursAgo_other": "{{count}} 小時前", + "relativeMinutesAgo_one": "{{count}} 分鐘前", + "relativeMinutesAgo_other": "{{count}} 分鐘前", + "relativeSecondsAgo_one": "{{count}} 秒前", + "relativeSecondsAgo_other": "{{count}} 秒前", + "relativeYesterday": "昨天" } diff --git a/apps/web/src/locales/zh-hk/diff.json b/apps/web/src/locales/zh-hk/diff.json index 3e7ef82306..6132479b43 100644 --- a/apps/web/src/locales/zh-hk/diff.json +++ b/apps/web/src/locales/zh-hk/diff.json @@ -46,5 +46,11 @@ "walkthrough": "導覽", "walkthroughNoteAdded": "已新增導覽備註", "walkthroughNoteQueued": "導覽備註已排隊", - "walkthroughNoteSent": "導覽備註已傳送" + "walkthroughNoteSent": "導覽備註已傳送", + "fileStatusAdded": "已新增", + "fileStatusDeleted": "已刪除", + "fileStatusModified": "已修改", + "fileStatusMoved": "已移動", + "fileStatusMovedFrom": "從 {{oldPath}} 移動而來", + "fileStatusUntracked": "未跟蹤" } diff --git a/apps/web/src/locales/zh-hk/editors.json b/apps/web/src/locales/zh-hk/editors.json index 8c498812da..30ef2aebc1 100644 --- a/apps/web/src/locales/zh-hk/editors.json +++ b/apps/web/src/locales/zh-hk/editors.json @@ -85,5 +85,6 @@ "switchToUnifiedView": "切換到統一視圖", "toggleCode": "切換程式碼", "toggleWordWrap": "切換自動換行", - "underlineCmdU": "下劃線(Cmd+U)" + "underlineCmdU": "下劃線(Cmd+U)", + "failedToOpenEditor": "開啟編輯器失敗" } diff --git a/apps/web/src/locales/zh-hk/executors.json b/apps/web/src/locales/zh-hk/executors.json index def9b7f12a..bd782b6fb2 100644 --- a/apps/web/src/locales/zh-hk/executors.json +++ b/apps/web/src/locales/zh-hk/executors.json @@ -258,5 +258,23 @@ "user": "用戶", "usesTheLocalDockerDaemonOn": "使用本機上的本地 Docker 守護程序。", "value": "值", - "verifyingConnection": "正在驗證連接…" + "verifyingConnection": "正在驗證連接…", + "profileNameExample": "生產、開發", + "portableConfigAvailable": "可用", + "portableConfigBundleClaudeSettings": "複製 Claude 設定", + "portableConfigBundleCodexConfig": "複製 Codex 設定", + "portableConfigBundleGeneric": "代理程式設定檔({{id}})", + "portableConfigBundleMockSettings": "複製模擬代理程式設定", + "portableConfigBundleOpenCodeConfig": "複製 OpenCode 設定", + "portableConfigDescription": "把選定的代理程式設定複製到新建或重置的環境中;這些設定可能包含密鑰或命令。", + "portableConfigFileMapping": "{{source}} 到 {{target}}", + "portableConfigInfoCopyTiming": "Kandev 只在全新建置或環境重置時原樣複製選定的檔案。新的複製可能覆蓋已存在的目標檔案。熱恢復會保留現有環境。", + "portableConfigInfoErrors": "缺失或無法讀取的可選檔案會被跳過並給出警告,不會阻止啓動。這些檔案可能包含密鑰或環境變量值。", + "portableConfigInfoLabel": "可遷移代理程式設定檔詳情", + "portableConfigInfoScope": "只有在允許列表中的檔案會被複制。一個檔案可能新增鈎子或命令,改變模型、權限、MCP 伺服器或接入點,也可能包含在執行器中無效的主機路徑。驗證檔案與設定包是各自獨立的選項。", + "portableConfigInfoSsh": "在 SSH 上,目標是所設定遠端用戶的共享主目錄,可能覆蓋該主機上其他程序正在使用的設定。", + "portableConfigInfoTitle": "可遷移的代理程式設定檔", + "portableConfigNotFound": "未找到", + "portableConfigNotFoundHint": "這只是編輯提示。你可以先儲存設定,稍後再新增該檔案。", + "portableConfigTitle": "代理程式設定檔" } diff --git a/apps/web/src/locales/zh-hk/github.json b/apps/web/src/locales/zh-hk/github.json index 8683e9ac65..c969d8e7de 100644 --- a/apps/web/src/locales/zh-hk/github.json +++ b/apps/web/src/locales/zh-hk/github.json @@ -568,5 +568,25 @@ "workspaceGithubAccess": "工作區 GitHub 存取", "workspaceGithubConnectionRemoved": "工作區 GitHub 連接已移除", "workspaceGithubIdentity": "工作區 GitHub 身份", - "yourReviewIsRequested": "已請求你的審閱" + "yourReviewIsRequested": "已請求你的審閱", + "appRegistrationsUnavailable": "GitHub App 註冊資訊不可用", + "failedToFetchPrCommits": "擷取 PR 提交失敗", + "failedToFetchPrFeedback": "擷取 PR 反饋失敗", + "failedToFetchPrFiles": "擷取 PR 檔案失敗", + "failedToLoadCiAutomationOptions": "載入 CI 自動化選項失敗。", + "failedToSearchGitHub": "在 GitHub 上搜尋失敗", + "pullRequestNumbered": "Pull Request #{{number}}", + "timeAgoDays_one": "{{count}} 天前", + "timeAgoDays_other": "{{count}} 天前", + "timeAgoHours_one": "{{count}} 小時前", + "timeAgoHours_other": "{{count}} 小時前", + "timeAgoJustNow": "剛剛", + "timeAgoMinutes_one": "{{count}} 分鐘前", + "timeAgoMinutes_other": "{{count}} 分鐘前", + "timeAgoMonths_one": "{{count}} 個月前", + "timeAgoMonths_other": "{{count}} 個月前", + "timeAgoWeeks_one": "{{count}} 周前", + "timeAgoWeeks_other": "{{count}} 周前", + "timeAgoYears_one": "{{count}} 年前", + "timeAgoYears_other": "{{count}} 年前" } diff --git a/apps/web/src/locales/zh-hk/gitlab.json b/apps/web/src/locales/zh-hk/gitlab.json index 7cde6fc580..851d603217 100644 --- a/apps/web/src/locales/zh-hk/gitlab.json +++ b/apps/web/src/locales/zh-hk/gitlab.json @@ -347,5 +347,17 @@ "mrChipAutoMerge": "自動合併", "mrChipDrawerTitle": "合併請求 !{{mriid}}", "mrChipDrawerDescription": "流水線狀態、審閱狀態和合並請求操作。", - "mrChipDrawerClose": "關閉合並請求狀態面板" + "mrChipDrawerClose": "關閉合並請求狀態面板", + "gitlabRejectedAssociation": "GitLab 拒絕了關聯。", + "taskCreatedMrNotLinked": "任務已建立,但未關聯合並請求", + "taskCreatedMrNotLinkedDescription": "開啟任務並使用「關聯 GitLab 合併請求」重試。{{error}}", + "addedMrFeedbackToTaskContext": "已將 merge request 反饋加入任務上下文", + "failedToLoadMergeRequest": "載入 merge request 失敗", + "failedToLoadMrAutomationOptions": "載入 MR 自動化選項失敗。", + "failedToSearchGitLab": "在 GitLab 上搜尋失敗", + "memberSearchFailed": "成員搜尋失敗", + "presetAssigned": "指派給我", + "presetAuthored": "我建立的", + "presetCreated": "我建立的", + "presetReviewRequested": "請求我審查" } diff --git a/apps/web/src/locales/zh-hk/integrations.json b/apps/web/src/locales/zh-hk/integrations.json index 4eee4da1c5..2ce25ca3d1 100644 --- a/apps/web/src/locales/zh-hk/integrations.json +++ b/apps/web/src/locales/zh-hk/integrations.json @@ -87,5 +87,108 @@ "stageAllChangesBeforeCommitting": "提交前暫存所有更改", "targetWorkspace": "目標工作區", "unknownError": "未知錯誤", - "waitingForTheNextBackendHealth": "正在等待下一次後端健康檢查…" + "waitingForTheNextBackendHealth": "正在等待下一次後端健康檢查…", + "createdLinkNeedsAttention": "{{shortName}} 已建立;任務連結需要處理", + "associationRetryViaTaskMenu": "{{message}}。請在任務菜單中使用「連結」重試。", + "taskAssociationCouldNotBeSaved": "無法儲存任務關聯。", + "registeredProvider": "已註冊提供者", + "failedToUnlinkPullRequest": "無法取消關聯拉取要求", + "pullRequestUnlinked": "已取消關聯拉取要求", + "linkedProviderPullRequests_one": "已關聯 {{count}} 個 {{provider}} 拉取要求", + "linkedProviderPullRequests_other": "已關聯 {{count}} 個 {{provider}} 拉取要求", + "unlinkPullRequestNumber": "取消關聯拉取要求 #{{number}}", + "defaultRepository": "預設儲存庫", + "filterRepositories": "篩選儲存庫…", + "noRepositoriesFound": "未找到儲存庫", + "query": "查詢", + "queryName": "名稱", + "queryNamePlaceholder": "例如:需要我的審查", + "saveQuery": "儲存查詢", + "cursorPaginationSummary_one": "第 {{page}} 頁 · {{count}} 個結果", + "cursorPaginationSummary_other": "第 {{page}} 頁 · {{count}} 個結果", + "actionFromReviewer": "{{name}} {{action}}", + "actionRequired": "需要處理", + "addAllCommentsToChatContext": "將所有評論加入對話上下文", + "addAllFailedChecksToChatContext": "將所有失敗的檢查加入對話上下文", + "addAllReviewsToChatContext": "將所有審查加入對話上下文", + "addFailuresToChatContext": "將 {{name}} 的失敗加入對話上下文", + "addReviewComment": "新增審查意見", + "addWholeThreadToChatContext": "將整個討論串加入對話上下文", + "allRepositories": "所有儲存庫", + "approved": "已批准", + "approvedCount_one": "{{count}} 項已批准", + "approvedCount_other": "{{count}} 項已批准", + "awaitingReview": "等待審查", + "botComments_one": "機器人評論({{count}})", + "botComments_other": "機器人評論({{count}})", + "by": "作者", + "changeRequestCreationFailed": "建立變更請求失敗", + "changesRequested": "要求修改", + "changesRequestedCount_one": "{{count}} 項要求修改", + "changesRequestedCount_other": "{{count}} 項要求修改", + "chooseReview": "選擇審查", + "chooseReviewToOpen": "選擇一個審查以開啟它。", + "ciChecks": "CI 檢查{{summary}}", + "closedRelative": "{{relative}}關閉", + "closePullRequestStatus": "關閉 PR 狀態", + "commentCount_one": "{{count}} 條評論", + "commentCount_other": "{{count}} 條評論", + "commented": "已評論", + "comments": "評論", + "disabled": "已停用", + "dismissed": "已忽略", + "failed": "失敗", + "failedCount_one": "{{count}} 項失敗", + "failedCount_other": "{{count}} 項失敗", + "general": "(總體)", + "inProgress": "進行中", + "lastSynced": "上次同步於{{relative}}", + "loadingChangeRequest": "正在載入變更請求", + "mergedRelative": "{{relative}}合併", + "noChecks": "無檢查", + "noChecksHaveStarted": "尚未開始任何檢查", + "noCommentsYet": "暫無評論", + "noReviewsYet": "暫無審查", + "openDetails": "開啟 {{title}} 詳情", + "openedRelative": "{{relative}}開啟", + "openReview": "開啟審查", + "openReviewNumber": "開啟審查 #{{number}}", + "other": "其他", + "otherCount_one": "{{count}} 項其他", + "otherCount_other": "{{count}} 項其他", + "overall": "總體", + "passed": "通過", + "passedCount_one": "{{count}} 項通過", + "passedCount_other": "{{count}} 項通過", + "passRate": "通過率", + "pending": "待處理", + "pendingCount_one": "{{count}} 項待處理", + "pendingCount_other": "{{count}} 項待處理", + "pendingReview": "待審查", + "pullRequest": "Pull request", + "pullRequestCount_one": "{{count}} 個 pull request", + "pullRequestCount_other": "{{count}} 個 pull request", + "pullRequestNumber": "PR #{{number}}", + "pullRequestShortCount_one": "{{count}} 個 PR", + "pullRequestShortCount_other": "{{count}} 個 PR", + "pullRequestStatusSummary": "Pull request 的 CI 狀態、審查與檢查摘要。", + "refresh": "刷新", + "replyToThread": "回覆討論串 {{threadId}}", + "requestedCount_one": "{{count}} 項已請求", + "requestedCount_other": "{{count}} 項已請求", + "retry": "重試", + "reviewCount_one": "{{count}} 條審查", + "reviewCount_other": "{{count}} 條審查", + "reviewerTeamName": "{{name}}(團隊)", + "reviews": "審查{{summary}}", + "reviewUnavailable": "審查不可用。", + "running": "執行中", + "saving": "正在儲存", + "timedOut": "已超時", + "unresolvedComments_one": "{{count}} 條未解決的評論", + "unresolvedComments_other": "{{count}} 條未解決的評論", + "untitledPullRequest": "未命名的 pull request", + "updatedAgo": "{{elapsed}}前更新", + "updatedJustNow": "剛剛更新", + "viewPullRequestExternally": "在外部查看 pull request #{{number}}" } diff --git a/apps/web/src/locales/zh-hk/jira.json b/apps/web/src/locales/zh-hk/jira.json index a09ca8b27d..33d4a71378 100644 --- a/apps/web/src/locales/zh-hk/jira.json +++ b/apps/web/src/locales/zh-hk/jira.json @@ -208,5 +208,7 @@ "watcherUpdated": "已更新監控", "workflow": "工作流程", "workflowStep": "工作流程步驟", - "yourJiraSessionExpiredOrNeeds": "你的 Jira 工作階段已過期或需要二次驗證。請重新連接以查看此工單。" + "yourJiraSessionExpiredOrNeeds": "你的 Jira 工作階段已過期或需要二次驗證。請重新連接以查看此工單。", + "builtinViewAssignedToMe": "指派給我", + "builtinViewUnassigned": "未指派" } diff --git a/apps/web/src/locales/zh-hk/kanban.json b/apps/web/src/locales/zh-hk/kanban.json index 50771d4591..1ea0598ecf 100644 --- a/apps/web/src/locales/zh-hk/kanban.json +++ b/apps/web/src/locales/zh-hk/kanban.json @@ -91,5 +91,19 @@ "workflow": "工作流程", "workflowStepFallback": "工作流程步驟 {{stepId}}", "columns": "列", - "columnsForWorkflow": "{{name}} 的列" + "columnsForWorkflow": "{{name}} 的列", + "queuedCount_one": "{{count}} 個排隊任務", + "queuedCount_other": "{{count}} 個排隊任務", + "queuedSection": "排隊中", + "needsReassignment": "需要重新分配", + "blockedByCount_one": "被 {{count}} 項阻塞", + "blockedByCount_other": "被 {{count}} 項阻塞", + "blockedByTasksTitle": "正在等待:{{tasks}}", + "blockedFailed": "阻塞項失敗", + "blockedPredecessorFailed": "該任務依賴的一個任務失敗了:{{tasks}}", + "noTasksYet": "暫無任務", + "noWorkflowsAvailableYet": "暫無可用的工作流程。", + "pullToRefreshPull": "下拉刷新", + "pullToRefreshRefreshing": "正在刷新任務", + "pullToRefreshRelease": "鬆開即可刷新" } diff --git a/apps/web/src/locales/zh-hk/office.json b/apps/web/src/locales/zh-hk/office.json index 53eabac495..002fc31414 100644 --- a/apps/web/src/locales/zh-hk/office.json +++ b/apps/web/src/locales/zh-hk/office.json @@ -811,17 +811,17 @@ "thisWillPermanentlyDeleteAllMemory": "將永久刪除此代理程式的全部記憶條目。此操作無法撤銷。", "thisWillPermanentlyDeleteThisInstruction": "將永久刪除此說明檔案。此操作無法撤銷。", "tier": "層級", - "tierBalanced": "Balanced", + "tierBalanced": "均衡", "tierBalancedDescription": "當協調代理程式分配 Balanced 層級時,用於一般工作者代理程式。", "tierBalancedHint": "標準能力", "tierBalancedQualified": "Balanced(標準)", "tierBalancedUsage": "Balanced 層級用量", - "tierEconomy": "Economy", + "tierEconomy": "經濟", "tierEconomyDescription": "當協調代理程式分配 Economy 層級時,用於 QA、例行與低成本代理程式。", "tierEconomyHint": "最便宜的可用模型", "tierEconomyQualified": "Economy(最便宜)", "tierEconomyUsage": "Economy 層級用量", - "tierFrontier": "Frontier", + "tierFrontier": "前沿", "tierFrontierDescription": "當協調代理程式建立最高能力工作的代理程式或分配 Frontier 層級時使用。", "tierFrontierHint": "各提供者的最強能力", "tierFrontierQualified": "Frontier(最強能力)", @@ -894,5 +894,10 @@ "writeTheDatabaseStateToOn": "將數據庫狀態寫入磁碟上的 YAML 檔案。", "yourModel": "你的模型", "zoomIn": "放大", - "zoomOut": "縮小" + "zoomOut": "縮小", + "failedToLoadAgentRoute": "載入代理程式路由失敗", + "failedToLoadProviderHealth": "載入服務商健康狀態失敗", + "failedToLoadRouteAttempts": "載入路由嘗試記錄失敗", + "failedToLoadRoutingConfig": "載入路由設定失敗", + "failedToLoadRoutingPreview": "載入路由預覽失敗" } diff --git a/apps/web/src/locales/zh-hk/plugins.json b/apps/web/src/locales/zh-hk/plugins.json index 4b3ad12545..a16e280275 100644 --- a/apps/web/src/locales/zh-hk/plugins.json +++ b/apps/web/src/locales/zh-hk/plugins.json @@ -115,5 +115,6 @@ "uploadFile": "上傳檔案", "utilityAgentsUnavailable": "輔助代理程式不可用", "viewRaw": "查看原始內容", - "yes": "是" + "yes": "是", + "pluginDialog": "外掛程式對話框" } diff --git a/apps/web/src/locales/zh-hk/review.json b/apps/web/src/locales/zh-hk/review.json index 670a88f9a8..0a95a643d6 100644 --- a/apps/web/src/locales/zh-hk/review.json +++ b/apps/web/src/locales/zh-hk/review.json @@ -78,5 +78,20 @@ "walkMeThroughTheseReviewChanges": "帶我瀏覽這些審閱更改", "walkthrough": "導覽", "walkthroughDiscarded": "導覽已丟棄", - "wrapLongLines": "長行自動換行" + "wrapLongLines": "長行自動換行", + "changesDiscarded": "更改已丟棄", + "changesStillLoading": "更改仍在載入中", + "couldNotSendFinding": "無法傳送發現項", + "couldNotUpdateFinding": "無法更新發現項", + "discardFailed": "丟棄失敗", + "failedToRequestWalkthrough": "請求導覽失敗", + "findingQueuedForAgent": "發現項已加入代理佇列", + "findingSentToAgent": "發現項已傳送給代理", + "walkthroughRequestQueued": "導覽請求已排隊", + "walkthroughRequestSent": "導覽請求已傳送", + "couldNotStartReview": "無法開始審查", + "severityBlocker": "阻斷", + "severityMajor": "嚴重", + "severityMinor": "次要", + "severityNit": "細節" } diff --git a/apps/web/src/locales/zh-hk/settings.json b/apps/web/src/locales/zh-hk/settings.json index 4577490748..38647a953e 100644 --- a/apps/web/src/locales/zh-hk/settings.json +++ b/apps/web/src/locales/zh-hk/settings.json @@ -401,12 +401,12 @@ "manualInstallRequired": "需要手動安裝", "markMessagesThatArrivedWhileA": "標記任務不可見期間收到的訊息。立即生效。", "mcpAffectedToolsHelp": "<0>create_task_kandev</0> 會建立單獨任務。<3>spawn_session_kandev</3> 會向目前任務新增工作階段,因此不使用此偏好設定。", - "mcpCreateTaskScope": "<0>create_task_kandev</0> 會建立新任務和子任務。該設定僅在呼叫時未提供 <4>agent_profile_id</4> 時生效。", - "mcpSpawnSessionScope": "<0>spawn_session_kandev</0> 和你自行建立的任務不受影響。明確選擇的設定始終優先。", - "mcpTaskProfileCurrentTask": "目前任務設定", - "mcpTaskProfileCurrentTaskDescription": "新任務使用建立它的任務所用的相同設定。若後續工作需要相同模型和代理程式設定,請選擇此項。這可能會複用費用更高的設定。", - "mcpTaskProfileWorkspaceDefault": "工作區預設代理程式設定檔", - "mcpTaskProfileWorkspaceDefaultDescription": "設定了工作流程代理程式設定檔時,新任務會使用該設定;否則使用接收任務的工作區預設代理程式設定檔。選擇此項可讓代理程式建立的任務使用標準工作區模型,避免意外複用費用較高的代理程式設定檔。", + "mcpCreateTaskScope": "<0>create_task_kandev</0> 會建立新任務和子任務。該設定僅在呼叫時未提供 <4>agent_profile_id</4> 時生效。在實時任務呼叫中,建立工作階段設定會複製已驗證工作階段的有效模型、模式和選項。", + "mcpSpawnSessionScope": "<0>spawn_session_kandev</0> 和你自行建立的任務不受影響。工作流程選擇的設定始終優先。否則,明確選擇的設定優先;工作區預設設定會跳過工作階段和任務設定。外部呼叫沒有建立工作階段,current_task 使用父任務後備設定。", + "mcpTaskProfileCurrentTask": "建立工作階段設定", + "mcpTaskProfileCurrentTaskDescription": "對於實時任務呼叫,新任務使用已驗證建立工作階段的設定,以及該工作階段的有效模型、模式和動態選項,包括工作階段期間的更改。工作流程啓動設定優先。沒有該設定時,使用建立工作階段;沒有已驗證的工作階段上下文時,依次後備到源任務或父任務設定,再後備到工作流程和工作區預設設定。這可能會複用成本更高的設定。", + "mcpTaskProfileWorkspaceDefault": "工作區預設設定", + "mcpTaskProfileWorkspaceDefaultDescription": "新任務會跳過建立工作階段以及源任務或父任務設定。它先使用工作流程啓動設定,再使用目標工作區預設設定。它不會複製建立工作階段的模型、模式或動態選項。選擇此項可保持一致的成本策略。如果工作流程和工作區都沒有設定,建立會失敗。", "memory": "記憶體 %", "mergeThisSplitIntoTheSplit": "將此拆分區域合併到包含 {{target}} 的拆分區域。", "mergeWithTarget": "與 {{target}} 合併", @@ -692,5 +692,43 @@ "visualEditorUnavailable": "可視化編輯器不可用", "vsCodeRemoteSsh": "VS Code Remote SSH", "whenEnabledInstallYourProjectS": "啓用後,會通過儲存庫設定腳本安裝項目依賴(例如執行 <2>{{command}}</2>),以避免缺少類型錯誤。", - "externalMcpToolListTaskSessions": "按最近啓動的順序列出任務的所有代理程式工作階段,以便其他工具通過 ID 定位工作階段。" + "externalMcpToolListTaskSessions": "按最近啓動的順序列出任務的所有代理程式工作階段,以便其他工具通過 ID 定位工作階段。", + "shortcutBottomTerminal": "切換底部終端", + "shortcutCommandPanel": "命令面板", + "shortcutCommandPanelAlt": "命令面板(備用)", + "shortcutFileSearch": "檔案搜尋", + "shortcutFocusChatInput": "聚焦聊天輸入框", + "shortcutFocusCliChatInput": "聚焦 CLI 聊天輸入框", + "shortcutNewTask": "新建任務", + "shortcutOpenTaskPullRequest": "開啟任務拉取要求", + "shortcutOpenWorkspacePicker": "開啟工作區選擇器", + "shortcutQuickChat": "快速聊天", + "shortcutRecentTaskSwitcher": "最近任務切換器", + "shortcutRecentTaskSwitcherBackward": "最近任務切換器(向後)", + "shortcutReverseChatSearch": "反向聊天搜尋", + "shortcutTaskContentSearch": "搜尋任務內容", + "shortcutTogglePlanMode": "切換計劃模式", + "shortcutToggleSidebar": "切換側邊欄", + "copyMove": "複製/移動", + "copyMoveSecretNamed": "複製或移動{{name}}", + "copyMoveDialogHint": "將密鑰複製或移動到其他範圍。值在服務端傳輸,絕不會顯示。", + "copyMoveMode": "複製或移動", + "copySecretAction": "複製", + "moveSecretAction": "移動", + "copyModeDescription": "在目標位置建立副本。原密鑰保持不變。", + "moveModeWarning": "原密鑰將從{{origin}}中移除。", + "destination": "目標", + "secretTargetName": "名稱", + "secretNameConflictInDestination": "目標位置已存在名為{{name}}的密鑰。", + "destinationsLoading": "正在載入目標", + "destinationsLoadFailed": "無法載入目標。", + "retryDestinations": "重試", + "noValidDestinations": "沒有可用的有效目標。", + "transferFailed": "無法複製或移動密鑰。", + "destinationGlobal": "全局", + "secretNameTooLong": "名稱不能超過 100 字節。", + "keyValueAddItem": "新增條目", + "keyValueKeyPlaceholder": "鍵", + "keyValueValuePlaceholder": "值", + "profileStartModelNotAdvertisedOnHost": "主機探測未公佈 {{model}}。所選執行器將在啓動時決定使用的模型。" } diff --git a/apps/web/src/locales/zh-hk/sidebar.json b/apps/web/src/locales/zh-hk/sidebar.json index 8d2d6219d2..61c3c2077c 100644 --- a/apps/web/src/locales/zh-hk/sidebar.json +++ b/apps/web/src/locales/zh-hk/sidebar.json @@ -106,5 +106,14 @@ "viewLabel": "視圖:{{name}}", "whatsNew": "新功能", "work": "工作", - "workspaceFallback": "工作區" + "workspaceFallback": "工作區", + "wipQueuePosition": "{{step}} 佇列中的第 {{position}} 項,共 {{total}} 項", + "taskPreferences": "側邊欄任務偏好設定", + "views": "側邊欄視圖", + "failedToLoadArchivedTasks": "載入已歸檔任務失敗", + "failedToSyncSidebarTaskPrefs": "同步側邊欄任務偏好失敗", + "failedToSyncSidebarViews": "同步側邊欄視圖失敗", + "morePluginItems": "更多外掛程式項", + "newViewNumbered": "新視圖 {{suffix}}", + "untitledView": "未命名視圖" } diff --git a/apps/web/src/locales/zh-hk/stats.json b/apps/web/src/locales/zh-hk/stats.json index ecd54b516a..7a34d1fd4c 100644 --- a/apps/web/src/locales/zh-hk/stats.json +++ b/apps/web/src/locales/zh-hk/stats.json @@ -79,5 +79,25 @@ "turnsPerTask": "每任務回合數", "userMsgs": "用戶訊息", "userShare": "用戶佔比", - "workload": "工作負載" + "workload": "工作負載", + "failedToLoadSection": "載入該部分失敗", + "failedToLoadStats": "載入統計數據失敗", + "loadingStats": "正在載入統計數據…", + "noGitActivity": "無 git 活動", + "subtitleReady": "{{tasks}} · {{sessions}} · {{duration}}", + "subtitleSessionCount_one": "{{count}} 個工作階段", + "subtitleSessionCount_other": "{{count}} 個工作階段", + "subtitleTaskCount_one": "{{count}} 個任務", + "subtitleTaskCount_other": "{{count}} 個任務", + "summaryCompletedInRange": "- 已完成({{range}}):{{completed}}", + "summaryGit": "- Git:{{git}}", + "summaryGitActivity_one": "{{count}} 次提交,+{{insertions}}/-{{deletions}}", + "summaryGitActivity_other": "{{count}} 次提交,+{{insertions}}/-{{deletions}}", + "summaryHeading": "*Kandev 統計 - {{range}}*", + "summaryRepos_one": "- 儲存庫:已跟蹤 {{count}} 個 · 主要儲存庫:{{topRepo}}", + "summaryRepos_other": "- 儲存庫:已跟蹤 {{count}} 個 · 主要儲存庫:{{topRepo}}", + "summaryTasks": "- 任務:共 {{total}} 個({{done}} 個已完成,{{inProgress}} 個進行中)· 完成率 {{completion}}", + "summaryTime": "- 時間:共 {{total}} · 每個任務平均 {{average}}", + "summaryTopRepo_one": "{{repository}}({{count}} 個任務)", + "summaryTopRepo_other": "{{repository}}({{count}} 個任務)" } diff --git a/apps/web/src/locales/zh-hk/system.json b/apps/web/src/locales/zh-hk/system.json index 4bb044e98f..9183f89e68 100644 --- a/apps/web/src/locales/zh-hk/system.json +++ b/apps/web/src/locales/zh-hk/system.json @@ -509,5 +509,8 @@ "versionCurrent": "目前", "versionLatest": "最新", "versionTitle": "版本", - "versionViewUpdates": "查看更新" + "versionViewUpdates": "查看更新", + "messageQueueAutoMergeDescription": "在投遞前,把來自同一來源的連續且兼容的排隊訊息合併為一輪。", + "messageQueueAutoMergeNotice": "如果某條訊息的來源不同,或任務、模型、模式、元數據或共享限制不兼容,它會保持為單獨的排隊訊息。這隻適用於新接收的訊息,不會改變手動合併控制。", + "messageQueueAutoMergeToggleLabel": "自動合併連續訊息" } diff --git a/apps/web/src/locales/zh-hk/task.json b/apps/web/src/locales/zh-hk/task.json index 27c204be91..964ed4202a 100644 --- a/apps/web/src/locales/zh-hk/task.json +++ b/apps/web/src/locales/zh-hk/task.json @@ -935,7 +935,6 @@ "promptFieldLabel": "提示詞", "providerPrivateContextThatKandevDid": "Kandev 未記錄的提供者私有上下文可能不會保留。已開啟的終端、開發伺服器及其他工作區程序會停止,包括任務編輯器伺服器。", "providerUnavailable": "提供者不可用", - "providerHistoryUnavailable": "目前 PR 歷史不可用。顯示檢出歷史,但不假定發生了重寫。", "proxy": "代理伺服器", "publishARedactedSnapshotOfThis": "將此已完成任務的脱敏快照發布為你帳戶上的私密 GitHub Gist。", "publishToGithubGist": "發佈到 GitHub Gist", @@ -1514,5 +1513,195 @@ "autopilotDescription": "代理程式會自主工作,只有在關鍵決策阻礙進展時才會向父任務詢問,然後等待答覆再繼續。", "autopilotInfoLabel": "了解自動駕駛模式", "autopilotSidebarDescription": "自動駕駛任務", - "autopilotChatDescription": "自動駕駛已啓用。代理程式只會向父任務詢問關鍵決策。" + "autopilotChatDescription": "自動駕駛已啓用。代理程式只會向父任務詢問關鍵決策。", + "providerAheadPushDisabled": "提供者分支較新。無法執行推送。", + "providerAheadPullRequiresUpstream": "此檢出必須設定上游後才能拉取。", + "divergedActionsUnavailable": "貢獻歷史已分叉。請從 Changes 操作中選擇一個版本。", + "reviewsCount_one": "審閱({{count}})", + "reviewsCount_other": "審閱({{count}})", + "composerStartAgentHint": "傳送訊息將自動啓動代理程式。", + "duplicateRepository": "儲存庫重複", + "duplicateRepositoryDescription": "{{repository}} 被新增了多次。請刪除重複行。", + "repositoryStillBeingVerified": "仍在驗證儲存庫", + "repositoryProviderVerificationFailed": "Kandev 無法驗證此提供者 URL。請在儲存庫行中使用重試。", + "repositoryProviderVerificationPending": "請等待提供者檢查完成後再建立任務。", + "failedToCreateFile": "建立檔案失敗", + "failedToCreateSession": "建立工作階段失敗", + "failedToCreateTask": "建立任務失敗", + "failedToDeleteItem": "刪除項目失敗", + "failedToDeletePlan": "刪除計劃失敗", + "failedToDownloadFile": "下載檔案失敗", + "failedToFetchPlan": "擷取計劃失敗", + "failedToLoadPlanRevisions": "載入修訂版本失敗", + "failedToRevertPlan": "還原計劃失敗", + "failedToSavePlan": "儲存計劃失敗", + "failedToStartTaskPlanMode": "以計劃模式啓動任務失敗", + "failedToUpdateTask": "更新任務失敗", + "noPullOrMergeRequestLinked": "此任務未關聯拉取要求或合併請求", + "sessionEndedCreateNew": "工作階段已結束。請建立新工作階段以繼續。", + "sessionUnavailableForInput": "工作階段不可用於輸入", + "taskQueued": "任務已排隊", + "taskQueuedWipLimit": "工作流程步驟已達到 WIP 限制;此任務將在有可用容量時啓動。", + "failedToCheckRepositoryStatus": "檢查本地儲存庫狀態失敗", + "failedToDiscoverRepositories": "發現儲存庫失敗", + "failedToLoadCommitDiff": "載入提交差異失敗", + "failedToNestTask": "嵌套任務失敗", + "failedToProceedToNextStep": "進入下一步失敗", + "failedToStartImplementingPlan": "開始實施計劃失敗", + "failedToStartImplementationSession": "啓動實施工作階段失敗", + "failedToMoveTask": "移動任務失敗", + "movedTasksStepDescription_one": "任務現已位於所選步驟中。", + "movedTasksStepDescription_other": "任務現已位於所選步驟中。", + "movedTasksToStep_one": "已將任務移動到步驟", + "movedTasksToStep_other": "已將 {{count}} 個任務移動到步驟", + "movedTasksToWorkflow_one": "已將任務移動到工作流程", + "movedTasksToWorkflow_other": "已將 {{count}} 個任務移動到工作流程", + "movedTasksWorkflowDescription_one": "切換到目標工作流程即可查看。", + "movedTasksWorkflowDescription_other": "切換到目標工作流程即可查看。", + "sessionActionDelete": "刪除工作階段中", + "sessionActionFailed": "{{action}}失敗", + "sessionActionProgress": "正在{{action}}...", + "sessionActionResume": "恢復工作階段中", + "sessionActionSetPrimary": "設為主要工作階段", + "sessionActionStop": "停止工作階段中", + "sessionActionSuccessful": "{{action}}成功", + "addDependency": "新增依賴…", + "advancedSettings": "高級設定", + "agentHasStopped": "該代理程式已停止。", + "agentResponseChangedSelectAgain": "代理程式的回覆已變化。請重新選擇文字。", + "agentStillBeingSetUp": "代理程式仍在準備中。", + "attachmentUploadFailed": "上傳失敗", + "branchDisabledLocalExecutor": "本地執行器使用你儲存庫目前的檢出,因此這裏無法更改分支。開啟「建立新分支」即可選擇其他基線。", + "branchDisabledNoBranches": "該儲存庫沒有可用的分支。", + "branchDisabledSelectRepositoryFirst": "請先選擇一個儲存庫。", + "branchPrefixCurrent": "目前:", + "branchPrefixFrom": "來自:", + "branchPrefixWillSwitchTo": "將切換到:", + "branchTooltipCurrent": "你儲存庫目前的檢出。代理程式按原樣在其上執行,不做任何 git 操作。", + "branchTooltipFrom": "基線分支。將從這裏派生出新分支(或 worktree),代理程式在其中執行。", + "branchTooltipGeneric": "代理程式將要執行的分支。", + "branchTooltipWillSwitchTo": "在代理程式啓動前,後端會執行 `git checkout` 將你的儲存庫切換到該分支。", + "clarificationNoSelection": "未選擇", + "cleanupAgentSessionsStopped": "所有正在執行的代理程式工作階段都將被停止。", + "cleanupBulkLocal_one": "{{count}} 個本地任務 - 你的儲存庫目錄不會被改動。", + "cleanupBulkLocal_other": "{{count}} 個本地任務 - 你的儲存庫目錄不會被改動。", + "cleanupBulkLocalDocker_one": "將移除 {{count}} 個 Docker 容器。", + "cleanupBulkLocalDocker_other": "將移除 {{count}} 個 Docker 容器。", + "cleanupBulkRemoteDocker_one": "將移除 {{count}} 個遠端 Docker 容器。", + "cleanupBulkRemoteDocker_other": "將移除 {{count}} 個遠端 Docker 容器。", + "cleanupBulkSprites_one": "將銷燬 {{count}} 個 Sprites 沙箱(未提交的工作將丟失)。", + "cleanupBulkSprites_other": "將銷燬 {{count}} 個 Sprites 沙箱(未提交的工作將丟失)。", + "cleanupBulkSsh_one": "將盡力移除 {{count}} 個遠端任務目錄。", + "cleanupBulkSsh_other": "將盡力移除 {{count}} 個遠端任務目錄。", + "cleanupBulkWorktree_one": "將刪除 {{count}} 個 worktree 及其分支。", + "cleanupBulkWorktree_other": "將刪除 {{count}} 個 worktree 及其分支。", + "cleanupSingleLocal": "代理程式直接在你的儲存庫中執行 - 你的檔案、分支和目錄都不會被改動。", + "cleanupSingleLocalDocker": "執行該任務的 Docker 容器將被停止並移除。你主機上的儲存庫不會被改動。", + "cleanupSingleRemoteDocker": "執行該任務的遠端 Docker 容器將被停止並移除。", + "cleanupSingleSprites": "該任務的 Sprites 雲沙箱將被銷燬。其中所有未提交的工作都會丟失。", + "cleanupSingleSsh": "遠端主機上的任務目錄將被盡力移除。你的本地儲存庫不會被改動。", + "cleanupSingleWorktree": "該任務的 git worktree 及其分支將被刪除。你的主儲存庫和其他分支不受影響。", + "composerPlaceholderBusy": "為代理程式排隊指令…", + "composerPlaceholderClarification": "在問題待答期間排隊指令…", + "composerPlaceholderFile": "繼續處理該檔案…", + "composerPlaceholderPlan": "繼續完善計劃…", + "composerPlaceholderRecovery": "請在上方選擇一個恢復選項以繼續…", + "composerPlaceholderSwitchingAgent": "正在切換代理程式…", + "composerPlaceholderTask": "繼續處理該任務…", + "couldNotGenerateSummary": "無法產生摘要", + "credentialExplicitToken": "明確指定的的 GH_TOKEN 或 GITHUB_TOKEN", + "credentialSourceExecutor": "執行器認證資料", + "credentialSourceExecutorProfile": "執行器設定檔的權杖", + "credentialSourceWorkspace": "受管的工作區身份", + "credentialTransportExecutorSelected": "執行器的 Git 與 gh 認證資料", + "credentialTransportManagedFallback": "由 Kandev 管理的任務認證資料", + "credentialTransportManagedHttps": "由 Kandev 管理的 GitHub HTTPS 與 gh", + "credentialTransportProfileToken": "執行器設定檔的權杖", + "credentialWorkspaceFallback": "工作區的 GitHub 認證資料", + "credentialWorkspaceGhCli": "工作區的 GitHub CLI 帳戶", + "credentialWorkspaceGithubApp": "工作區的 GitHub App", + "credentialWorkspacePat": "工作區的個人存取權杖", + "dependencies": "依賴", + "dependencyBlockedBy": "被阻塞於", + "dependencyBlocks": "阻塞", + "dependencyChainHalted": "該任務依賴的某個任務失敗了。在你重試或移除該依賴之前,整條鏈都會停住。", + "dependencyChipLabel": "任務依賴", + "dependencyCount_one": "{{count}} 個依賴", + "dependencyCount_other": "{{count}} 個依賴", + "dependencyCountBlockedBy_one": "{{count}} 個阻塞項", + "dependencyCountBlockedBy_other": "{{count}} 個阻塞項", + "dependencyCountBlocks_one": "阻塞 {{count}} 項", + "dependencyCountBlocks_other": "阻塞 {{count}} 項", + "dependencyInfo": "該任務會一直等待,直到所選的每個任務都成功完成。如果你啓動了代理程式,它會在所有依賴解決後自動開始。", + "dependencyInfoLabel": "關於任務依賴", + "dependsOn": "依賴於", + "dependsOnWillAutoStart": "該任務會等待上面的任務。如果你啓動了代理程式,它會在這些任務全部成功完成後自動執行。", + "enhancedPromptCopiedToClipboard": "已將優化後的提示詞複製到剪貼板。", + "enhancedPromptCouldNotBeCopied": "無法複製優化後的提示詞。", + "enhancedPromptCouldNotBeInserted": "已產生優化後的提示詞,但無法插入。", + "environmentNotCreated": "未建立", + "failedToCreateSubtask": "建立子任務失敗", + "failedToDetachTask": "分離任務失敗", + "failedToFetchCumulativeDiff": "擷取累計差異失敗", + "failedToLoadFileContent": "載入檔案內容失敗", + "failedToLoadFileTree": "載入檔案樹失敗", + "failedToResumeAndRestore": "恢復工作階段失敗 - 工作區還原同樣不可用", + "gitOperationFailedGeneric": "操作失敗", + "invalidGitHubUrl": "GitHub 連結無效 - 應為 github.com/owner/repo 或 .../pull/123", + "invalidRegex": "正則表達式無效", + "messageFetchLimitHint": "對於該工作階段的最後一輪,擷取上限可能太小 - 用戶提示詞和代理程式回覆位於更靠前的位置。請翻頁或提高上限以查看它們。", + "messageMetadataEntries": "訊息元數據條目", + "monitorStatusEnded": "已結束", + "monitorStatusEndedSessionRestart": "已結束(工作階段重啓)", + "monitorStatusWatching": "監視中", + "noBranchesShort": "無分支", + "noDependencies": "無依賴", + "noDependency": "無依賴", + "openFileInProvider": "在 {{provider}} 中開啟檔案", + "referenceSearchFailedRetry": "引用搜尋失敗。請重試。", + "removeDependency": "移除依賴", + "renameFailed": "重命名失敗", + "resuming": "正在恢復…", + "sessionFailedUnexpectedly": "工作階段意外失敗", + "sessionNotAvailableForInput": "所選工作階段無法接收輸入。", + "sessionStatusGenerating": "產生中", + "sessionStatusStarting": "啓動中", + "sessionStatusTurnFinished": "本輪已結束", + "submitTooltipQueueMessage": "將訊息排隊", + "submitTooltipRequestPlanChanges": "請求修改計劃", + "summarizeCouldNotGenerate": "無法產生摘要。請檢查摘要工具代理程式是否已在設定中設定並啓用。", + "summarizeFailed": "產生摘要失敗", + "taskClosed": "任務已關閉", + "taskClosedAutomatically": "該任務已被自動關閉。", + "taskClosedIssueClosed": "其 issue 已關閉,因此該任務被自動關閉。", + "taskClosedPrApproved": "其 pull request 已獲批准,因此該審查任務被自動關閉。", + "taskClosedPrMergedOrClosed": "其 pull request 已合併或關閉,因此該審查任務被自動關閉。", + "taskFailedToStart": "任務啓動失敗", + "taskTitledWasClosed": "「{{title}}」已關閉", + "updateAvailableToast": "Kandev {{version}} 已發佈。請開啟「設定 > 系統 > 更新」查看。", + "updateFailed": "更新失敗", + "workspaceRoot": "工作區根目錄", + "workspaceUnavailableArchived": "工作區不可用 - 該任務已歸檔", + "worktreeExecutorRequiresRepository": "Worktree 執行器需要一個儲存庫。", + "worktreeNumbered": "Worktree {{index}}", + "modelSelectionAgent": "代理程式", + "modelSelectionEffective": "實際使用的模型", + "modelSelectionExecutor": "執行器", + "modelSelectionExecutorProfile": "執行器設定檔", + "modelSelectionFallback": "已公佈的備選模型", + "modelSelectionProviderDefault": "代理程式預設模型", + "modelSelectionReason": "原因", + "modelSelectionReasonAutoFallback": "無法應用已公佈的模型,因此自動後備並繼續使用代理程式的預設模型。", + "modelSelectionReasonCatalogEmpty": "執行器未公佈模型目錄。", + "modelSelectionReasonFallbackNotAdvertised": "執行器未公佈已儲存的備選模型。", + "modelSelectionReasonRequestedNotAdvertised": "執行器未公佈已儲存的模型。", + "modelSelectionReasonUnknown": "{{reason}}", + "modelSelectionReasonUnsupported": "該執行器不支援選擇模型。", + "modelSelectionRemediationAgentVersion": "請檢查執行器中的代理程式版本。", + "modelSelectionRemediationConfiguration": "請檢查已複製的代理程式設定檔。", + "modelSelectionRemediationCredentials": "請檢查執行器認證資料。", + "modelSelectionRemediationTitle": "請檢查以下各項", + "modelSelectionRemediationUnknown": "請檢查執行器設定檔。", + "modelSelectionRequested": "請求的模型", + "modelSelectionWarning": "執行器無法使用已儲存的模型選擇。" } diff --git a/apps/web/src/locales/zh-hk/workflows.json b/apps/web/src/locales/zh-hk/workflows.json index dcf13cee23..c8555cf09d 100644 --- a/apps/web/src/locales/zh-hk/workflows.json +++ b/apps/web/src/locales/zh-hk/workflows.json @@ -260,5 +260,10 @@ "workflows": "工作流程", "workflowsSectionDescription": "建立帶自動流轉的自主流水線,或由你手動移動任務的手動工作流程", "workflowSteps": "工作流程步驟", - "workflowSyncCompleted": "工作流程同步完成" + "workflowSyncCompleted": "工作流程同步完成", + "pullFromGuidanceWithFeeder": "可選的自動饋送入口。目標佇列中的任務優先獲得准入;有容量時,Kandev 再從 {{feeder}} 拉取符合條件的工作。直接移動和自動轉換會在目標步驟排隊,不需要饋送步驟。新任務在目標步驟已滿時使用此饋送步驟。", + "pullFromGuidanceWithoutFeeder": "未選擇饋送步驟。目標步驟已滿時,直接移動和自動轉換會在此目標佇列中排隊。新任務也會留在此佇列中,直到有可用容量。", + "loadingSteps": "正在載入步驟…", + "noStepsInThisWorkflow": "該工作流程沒有步驟", + "selectAWorkflowFirst": "請先選擇一個工作流程" } diff --git a/apps/web/src/locales/zh-hk/workspaces.json b/apps/web/src/locales/zh-hk/workspaces.json index ff83a15533..af3bbc2e15 100644 --- a/apps/web/src/locales/zh-hk/workspaces.json +++ b/apps/web/src/locales/zh-hk/workspaces.json @@ -118,5 +118,13 @@ "workspaceNotFound": "未找到工作區", "workspaceSettings": "工作區設定", "worktreeBranchTemplate": "工作樹分支模板", - "worktreeSync": "工作樹同步" + "worktreeSync": "工作樹同步", + "chooseDifferentRepositoryName": "請另選一個儲存庫名稱。", + "enterRepositoryName": "請輸入儲存庫名稱。", + "repositoryNameMustBeOneFolder": "儲存庫名稱必須是單個目錄名。", + "sourceChooseAFolder": "請選擇一個目錄。", + "sourceChooseRepoAndBranch": "請選擇一個儲存庫和基線分支。", + "sourceEnterValidRemoteUrl": "請輸入有效的遠端儲存庫 URL 和基線分支。", + "sourceFolderAlreadySelected": "該目錄已被選擇。", + "sourceRepoBranchAlreadySelected": "該儲存庫和分支已被選擇。" } diff --git a/apps/web/src/locales/zh-tw/agents.json b/apps/web/src/locales/zh-tw/agents.json index b180cf618d..cfa8e07808 100644 --- a/apps/web/src/locales/zh-tw/agents.json +++ b/apps/web/src/locales/zh-tw/agents.json @@ -132,7 +132,7 @@ "mcpServersHelp": "MCP 定義儲存在資料庫中,並在執行時按執行器解析。這不會覆蓋你的本地代理程式設定檔。", "mcpServersJson": "MCP 伺服器(JSON)", "mcpStrategy": "Kandev MCP 工具", - "mcpStrategyHelp": "Kandev 可以為這個代理程式提供自己的工具(設定任務標題、向使用者提問、更新計畫、標記步驟完成)。為此,它必須以你的 CLI 能讀取的格式寫入 MCP 設定,因此請選擇相符的格式。選錯格式會靜默失敗:設定已寫入,但 CLI 會忽略它,而且不會顯示任何錯誤。如果該命令不是支援 MCP 的程式設計代理程式,請保持關閉。", + "mcpStrategyHelp": "Kandev 可以為該代理提供自己的工具(設定任務標題、向使用者提問、更新計劃、標記步驟完成)。為此,它必須以你的 CLI 能讀取的格式寫入 MCP 設定,因此請選擇匹配的格式。選錯格式會靜默失敗:設定已寫入,但 CLI 會忽略它,並且不會顯示任何錯誤。如果該命令不是支援 MCP 的編碼代理,請保持關閉。", "mcpStrategyNone": "關閉:不提供 Kandev 工具", "mcpStrategyOption": "{{name}}:{{mechanism}}", "model": "模型", @@ -221,5 +221,22 @@ "watcherKindGithubIssue": "GitHub 議題", "watcherKindGithubReview": "GitHub PR 審查", "watcherKindJira": "Jira", - "watcherKindLinear": "Linear" + "watcherKindLinear": "Linear", + "activeRuntimeVersion": "活動版本:{{version}}", + "activeRuntimeVersionMarker": "活動", + "latestRuntimeVersion": "最新", + "runtimeVersionMarkerGroup": "({{markers}})", + "runtimeVersionOption": "{{version}} {{markers}}", + "selectRuntimeVersion": "要啟用的版本", + "updateRuntime": "更新執行時", + "rollBackRuntime": "回滾執行時", + "repairRuntime": "修復執行時", + "upToDateRuntime": "已是最新", + "collapseAgentProfiles": "摺疊 {{name}} 設定", + "expandAgentProfiles": "展開 {{name}} 設定", + "noProfilesYetShort": "暫無設定", + "createProfile": "建立設定", + "creatingProfile": "正在建立…", + "saveProfile": "儲存設定", + "savingProfile": "正在儲存…" } diff --git a/apps/web/src/locales/zh-tw/auth.json b/apps/web/src/locales/zh-tw/auth.json index 4fbd55aa98..d8d3b3dd58 100644 --- a/apps/web/src/locales/zh-tw/auth.json +++ b/apps/web/src/locales/zh-tw/auth.json @@ -16,5 +16,14 @@ "signIn": "登入", "signingIn": "正在登入…", "signInToYourKandevAccount": "登入你的 Kandev 帳戶以繼續。", - "youVeBeenInvitedAs": "你已被邀請為 {{role}}。" + "youVeBeenInvitedAs": "你已被邀請為 {{role}}。", + "couldNotAcceptInvite": "無法接受此邀請。", + "couldNotLoadInvite": "無法載入此邀請。請重試。", + "invalidEmailOrPassword": "電子郵件地址或密碼不正確。", + "inviteLinkInvalidOrExpired": "此邀請連結無效或已過期。請向管理員索取新的連結。", + "inviteLinkMissingToken": "此邀請連結缺少權杖。", + "setupFailed": "初始化失敗。請重試。", + "setupNoLongerAvailable": "初始化已不可用 - 帳戶已存在。", + "somethingWentWrong": "出了點問題。請重試。", + "tooManyAttempts": "嘗試次數過多。請稍候片刻再試。" } diff --git a/apps/web/src/locales/zh-tw/automations.json b/apps/web/src/locales/zh-tw/automations.json index cfb0d45564..ebba63bda6 100644 --- a/apps/web/src/locales/zh-tw/automations.json +++ b/apps/web/src/locales/zh-tw/automations.json @@ -202,5 +202,33 @@ "workflowLabel": "工作流程", "workflowLoadError": "無法載入工作流程。", "workflowStepHelpNoWorkflow": "請先選擇工作流程,再選擇步驟。", - "workflowStepLabel": "工作流程步驟" + "workflowStepLabel": "工作流程步驟", + "failedToLoadAutomationActivity": "載入自動化活動失敗", + "failedToLoadAutomations": "載入自動化失敗", + "failedToLoadRuns": "載入執行記錄失敗", + "failedToLoadThisAutomation": "載入此自動化失敗", + "frequencyCustom": "自訂計劃", + "frequencyEvery15Minutes": "每 15 分鐘", + "frequencyEvery30Minutes": "每 30 分鐘", + "frequencyEvery5Minutes": "每 5 分鐘", + "frequencyEvery6Hours": "每 6 小時", + "frequencyEveryDay": "每天", + "frequencyEveryHour": "每小時", + "frequencyEveryWeek": "每週", + "nextFiringConcurrencyPaused": "已暫停 - 仍有一次執行未結束(同時最多 {{max}} 次)", + "nextFiringNoSchedule": "無計劃 - 只有你手動觸發時才執行", + "nextFiringScheduleOff": "計劃觸發器已關閉", + "nextFiringSwitchedOff": "已關閉 - 不會觸發", + "runNoReportRecorded": "未記錄任何報告。", + "runStillRunningNoReport": "仍在執行 - 暫無報告。", + "scheduleAtSeparator": "於", + "scheduleEveryDayAt": "每天 {{time}} {{zone}}", + "scheduleEveryHourAt": "每小時的第 :{{minute}} 分", + "scheduleEveryWeekdayAt": "每{{weekday}} {{time}} {{zone}}", + "scheduleIntervalEvery15Minutes": "每 15 分鐘", + "scheduleIntervalEvery30Minutes": "每 30 分鐘", + "scheduleIntervalEvery5Minutes": "每 5 分鐘", + "scheduleIntervalEvery6Hours": "每 6 小時", + "scheduleNone": "無計劃", + "scheduleUnparseable": "我們無法解析這個計劃。請嘗試 5 欄位的 cron,例如 0 9 * * 1-5。" } diff --git a/apps/web/src/locales/zh-tw/azuredevops.json b/apps/web/src/locales/zh-tw/azuredevops.json index 09ac9aafb3..d42b8c234b 100644 --- a/apps/web/src/locales/zh-tw/azuredevops.json +++ b/apps/web/src/locales/zh-tw/azuredevops.json @@ -253,5 +253,34 @@ "workItemUnavailable": "工作項不可用。", "workItemWatches": "工作項監控", "workItemWatchesDescription": "根據匹配的 Azure DevOps 工作項查詢自動建立任務。", - "workItemWatchFieldsRequired": "專案、WIQL、儲存庫、工作流程、步驟、代理程式和執行器均為必填項。" + "workItemWatchFieldsRequired": "專案、WIQL、儲存庫、工作流程、步驟、代理程式和執行器均為必填項。", + "prStatusAbandoned": "已放棄", + "prStatusActive": "活動", + "prStatusChangesRequested": "已請求更改", + "prStatusCompleted": "已完成", + "prStatusDraft": "草稿", + "prStatusPolicyFailed": "策略失敗", + "prStatusPolicyRunning": "策略執行中", + "prStatusReady": "就緒", + "prStatusWaitingForReview": "等待審查", + "pullRequestStatusAria": "Azure PR {{id}}:{{status}}{{suffix}}", + "defaultActionAddressFeedback": "處理意見回饋", + "defaultActionAddressFeedbackHint": "應用審查意見", + "defaultActionFixCi": "修復 CI", + "defaultActionFixCiHint": "診斷失敗的檢查", + "defaultActionImplement": "實施", + "defaultActionImplementHint": "構建並開啟 PR", + "defaultActionInvestigate": "調查", + "defaultActionInvestigateHint": "查詢根本原因", + "defaultActionReproduce": "復現", + "defaultActionReproduceHint": "記錄復現步驟", + "defaultActionReview": "審查", + "defaultActionReviewHint": "閱讀差異並標記問題", + "defaultQueryActive": "活動", + "defaultQueryAssignedToMe": "分配給我", + "defaultQueryCompleted": "已完成", + "defaultQueryCreatedByMe": "由我建立", + "defaultQueryOpen": "開啟", + "defaultQueryRecentlyUpdated": "最近更新", + "defaultQueryReviewRequested": "已請求審查" } diff --git a/apps/web/src/locales/zh-tw/chat.json b/apps/web/src/locales/zh-tw/chat.json index 2cc80ae15c..52dbe706ac 100644 --- a/apps/web/src/locales/zh-tw/chat.json +++ b/apps/web/src/locales/zh-tw/chat.json @@ -56,9 +56,9 @@ "reorderQueuedMessage": "重新排序佇列訊息", "sortable": "可排序", "technicalDetails": "技術詳情", - "managedRuntimeNpmTitle": "npm 無法準備執行環境", - "managedRuntimeNpmBody": "Kandev 已重新整理套件資料,並重試相同的執行環境一次。", - "managedRuntimeRetry": "重試執行環境", + "managedRuntimeNpmTitle": "npm 無法準備執行時", + "managedRuntimeNpmBody": "Kandev 已重新整理軟體包資料,並重試了同一執行時一次。", + "managedRuntimeRetry": "重試執行時", "providerQuotaTitle": "{{provider}} 使用額度已達上限", "newChatTab": "新建聊天", "newQuickChat": "新建快速聊天", @@ -131,7 +131,7 @@ "transientRetryReasonProviderOverloaded": "服務商過載", "transientRetryReasonProviderUnavailable": "服務商暫時不可用", "transientRetryReasonRateLimited": "請求受限", - "transientRetryReasonAgentTransportLost": "代理連線已中斷", + "transientRetryReasonAgentTransportLost": "代理連線已斷開", "transientRetryReasonGeneric": "服務商錯誤", "transientRetryIn": "將在 {{countdown}} 後重試", "transientRetryNow": "正在重試…", @@ -139,5 +139,11 @@ "transientRetryAttempt": "第 {{attempt}} 次,共 {{maxAttempts}} 次", "transientRetryProviderModel": "{{provider}} · {{model}}", "transientRetryProvider": "服務商:{{provider}}", - "transientRetryModel": "模型:{{model}}" + "transientRetryModel": "模型:{{model}}", + "pinQueuedMessages": "固定訊息佇列", + "unpinQueuedMessages": "取消固定訊息佇列", + "emptyTurnKnownCommand": "`/{{command}}` 已執行,但沒有產生任何輸出。{{hint}}", + "emptyTurnNoOutput": "代理程式已結束,但沒有產生任何輸出。", + "emptyTurnResendHint": "請嘗試作為普通訊息重新傳送你的請求,不要帶開頭的斜槓。", + "emptyTurnUnknownCommand": "`/{{command}}` 不是該代理程式識別的命令,因此沒有返回任何內容。{{hint}}" } diff --git a/apps/web/src/locales/zh-tw/common.json b/apps/web/src/locales/zh-tw/common.json index 7f7743794f..dc9454757b 100644 --- a/apps/web/src/locales/zh-tw/common.json +++ b/apps/web/src/locales/zh-tw/common.json @@ -164,7 +164,7 @@ "failedToLoadDirectory": "載入目錄失敗", "failedToLoadRoutine": "載入例程失敗", "failedToLoadSetup": "載入設定失敗", - "failedToRenderDiagram": "渲染圖表失敗", + "failedToRenderDiagram": "圖表渲染失敗", "featureRequest": "功能請求", "files": "檔案", "folderName": "資料夾名稱", @@ -441,5 +441,71 @@ "you": "您", "youCanUseTheNavigationOr": "你可以使用導覽或重新載入以重試。", "yourAgentImplementsItInThe": "你的代理程式在 Kandev 儲存庫中實現它,並附帶測試", - "youVerifyAndTestTheChange": "你在第二個 Kandev 實例中驗證並測試該改動" + "youVerifyAndTestTheChange": "你在第二個 Kandev 實例中驗證並測試該改動", + "demoAgentMessagesDescription": "目視檢查代理訊息規範化。每張卡片同時顯示渲染結果和原始 JSON。", + "demoAgentMessagesQa": "代理訊息品質檢查", + "demoErrorLoading": "載入{{mode}}時出錯", + "demoEventFile": "事件檔案:", + "demoEvents": "事件", + "demoFailedToLoadFiles": "載入檔案失敗", + "demoFailedToLoadFixtures": "載入測試資料失敗", + "demoFailedToLoadMessages": "載入訊息失敗", + "demoFileOption_one": "{{protocol}} - {{agent}}({{count}} 條訊息)", + "demoFileOption_other": "{{protocol}} - {{agent}}({{count}} 條訊息)", + "demoFixtureFile": "測試資料檔案:", + "demoFixtures": "測試資料", + "demoMessageData": "訊息資料", + "demoMessageTypes": "訊息型別演示", + "demoMessageTypesDescription": "呈現所有訊息型別以供樣式設計和測試", + "demoNoFilesFound": "未找到檔案", + "demoNoFixtures": "未找到測試資料。請從上方下拉式清單中選擇檔案。", + "demoNoNormalizedEvents": "未找到規範化事件檔案。請使用 KANDEV_DEBUG_AGENT_MESSAGES=1 執行代理以產生事件日誌。", + "demoNormalizedEvents": "規範化事件", + "demoNormalizedPayload": "規範化內容", + "demoRawInput": "原始輸入", + "demoRenderedOutput": "渲染結果:", + "demoShowingFixtures_one": "顯示 {{count}} / {{total}} 個測試資料", + "demoShowingFixtures_other": "顯示 {{count}} / {{total}} 個測試資料", + "demoShowingMessages_one": "顯示 {{count}} / {{total}} 條訊息", + "demoShowingMessages_other": "顯示 {{count}} / {{total}} 條訊息", + "demoTestFixtures": "測試資料", + "demoToolType": "工具型別:", + "checking": "檢查中", + "configured": "已設定", + "connected": "已連線", + "debug": "除錯", + "failedToLoadRoute": "載入路由失敗", + "improveKandevDirectPushAccess": "你對 kdlbs/kandev 擁有寫入權限,因此代理程式會直接推送到上游儲存庫的分支。", + "improveKandevExecutorForkAtPr": "代理程式會在 PR 步驟中使用執行器的認證資訊準備一個 fork,並從中開啟 pull request。", + "improveKandevExistingForkPrepared": "Kandev 為該任務找到了一個可寫的 fork。代理程式會把分支推送到那裡,並向 kdlbs/kandev 開啟 pull request。", + "improveKandevIssueNoForkAccess": "開啟 issue 不需要 fork 或推送權限。", + "improveKandevManagedForkPrepared": "Kandev 會在代理程式啟動前為該任務準備一個可寫的 fork。代理程式會把分支推送到那裡,並向 kdlbs/kandev 開啟 pull request。", + "improveStepImprove": "代理程式閱讀報告、探索程式碼庫,並以 TDD 方式實現變更。執行 make fmt、typecheck、test、lint,然後提交。", + "improveStepOpenIssue": "代理程式從儲存庫模板收集所有必填欄位,與你確認公開的草稿,然後釋出 GitHub issue。", + "improveStepPr": "代理程式執行 pr 技能:推送分支並向 kdlbs/kandev 開啟 pull request,交由維護者審查。", + "improveStepTest": "代理程式用 make dev 啟動第二個 kandev 實例,並告訴你需要驗證什麼。你在執行中的應用裡確認變更有效。", + "managedGithubForkUnavailable": "受管的 GitHub 存取無法為該任務準備已驗證的 fork。請檢查工作區的 GitHub 連線,或改為開啟一個 issue。", + "noOptionFound": "未找到選項。", + "searchPlaceholder": "搜尋…", + "selectOption": "選擇選項…", + "terminalNumbered": "終端 {{seq}}", + "weekdayFriday": "星期五", + "weekdayMonday": "星期一", + "weekdaySaturday": "星期六", + "weekdaySunday": "星期日", + "weekdayThursday": "星期四", + "weekdayTuesday": "星期二", + "weekdayWednesday": "星期三", + "preciseDated": "{{date}} {{time}}", + "preciseToday": "今天 {{time}}", + "preciseYesterday": "昨天 {{time}}", + "relativeDaysAgo_one": "{{count}} 天前", + "relativeDaysAgo_other": "{{count}} 天前", + "relativeHoursAgo_one": "{{count}} 小時前", + "relativeHoursAgo_other": "{{count}} 小時前", + "relativeMinutesAgo_one": "{{count}} 分鐘前", + "relativeMinutesAgo_other": "{{count}} 分鐘前", + "relativeSecondsAgo_one": "{{count}} 秒前", + "relativeSecondsAgo_other": "{{count}} 秒前", + "relativeYesterday": "昨天" } diff --git a/apps/web/src/locales/zh-tw/diff.json b/apps/web/src/locales/zh-tw/diff.json index a06603f542..1271e7fa8f 100644 --- a/apps/web/src/locales/zh-tw/diff.json +++ b/apps/web/src/locales/zh-tw/diff.json @@ -46,5 +46,11 @@ "walkthrough": "導覽", "walkthroughNoteAdded": "已新增導覽備註", "walkthroughNoteQueued": "導覽備註已排隊", - "walkthroughNoteSent": "導覽備註已傳送" + "walkthroughNoteSent": "導覽備註已傳送", + "fileStatusAdded": "已新增", + "fileStatusDeleted": "已刪除", + "fileStatusModified": "已修改", + "fileStatusMoved": "已移動", + "fileStatusMovedFrom": "從 {{oldPath}} 移動而來", + "fileStatusUntracked": "未跟蹤" } diff --git a/apps/web/src/locales/zh-tw/editors.json b/apps/web/src/locales/zh-tw/editors.json index 3f8e313061..f78baa3f64 100644 --- a/apps/web/src/locales/zh-tw/editors.json +++ b/apps/web/src/locales/zh-tw/editors.json @@ -85,5 +85,6 @@ "switchToUnifiedView": "切換到統一檢視", "toggleCode": "切換程式碼", "toggleWordWrap": "切換自動換行", - "underlineCmdU": "下劃線(Cmd+U)" + "underlineCmdU": "下劃線(Cmd+U)", + "failedToOpenEditor": "開啟編輯器失敗" } diff --git a/apps/web/src/locales/zh-tw/executors.json b/apps/web/src/locales/zh-tw/executors.json index 274a3f8f14..9966054538 100644 --- a/apps/web/src/locales/zh-tw/executors.json +++ b/apps/web/src/locales/zh-tw/executors.json @@ -258,5 +258,23 @@ "user": "使用者", "usesTheLocalDockerDaemonOn": "使用本機上的本地 Docker 守護處理程序。", "value": "值", - "verifyingConnection": "正在驗證連線…" + "verifyingConnection": "正在驗證連線…", + "profileNameExample": "生產、開發", + "portableConfigAvailable": "可用", + "portableConfigBundleClaudeSettings": "複製 Claude 設定", + "portableConfigBundleCodexConfig": "複製 Codex 設定", + "portableConfigBundleGeneric": "代理程式設定檔({{id}})", + "portableConfigBundleMockSettings": "複製模擬代理程式設定", + "portableConfigBundleOpenCodeConfig": "複製 OpenCode 設定", + "portableConfigDescription": "把選定的代理程式設定複製到新建或重置的環境中;這些設定可能包含金鑰或命令。", + "portableConfigFileMapping": "{{source}} 到 {{target}}", + "portableConfigInfoCopyTiming": "Kandev 只在全新建置或環境重置時原樣複製選定的檔案。新的複製可能覆蓋已存在的目標檔案。熱恢復會保留現有環境。", + "portableConfigInfoErrors": "缺失或無法讀取的可選檔案會被跳過並給出警告,不會阻止啟動。這些檔案可能包含金鑰或環境變數值。", + "portableConfigInfoLabel": "可遷移代理程式設定檔詳情", + "portableConfigInfoScope": "只有在允許清單中的檔案會被複制。一個檔案可能新增鉤子或命令,改變模型、權限、MCP 伺服器或接入點,也可能包含在執行器中無效的主機路徑。驗證檔案與設定包是各自獨立的選項。", + "portableConfigInfoSsh": "在 SSH 上,目標是所設定遠端使用者的共享主目錄,可能覆蓋該主機上其他處理程序正在使用的設定。", + "portableConfigInfoTitle": "可遷移的代理程式設定檔", + "portableConfigNotFound": "未找到", + "portableConfigNotFoundHint": "這只是編輯提示。你可以先儲存設定,稍後再新增該檔案。", + "portableConfigTitle": "代理程式設定檔" } diff --git a/apps/web/src/locales/zh-tw/github.json b/apps/web/src/locales/zh-tw/github.json index e64a5e4af2..9ae9744a3b 100644 --- a/apps/web/src/locales/zh-tw/github.json +++ b/apps/web/src/locales/zh-tw/github.json @@ -568,5 +568,25 @@ "workspaceGithubAccess": "工作區 GitHub 存取", "workspaceGithubConnectionRemoved": "工作區 GitHub 連線已移除", "workspaceGithubIdentity": "工作區 GitHub 身分", - "yourReviewIsRequested": "已請求你的審閱" + "yourReviewIsRequested": "已請求你的審閱", + "appRegistrationsUnavailable": "GitHub App 註冊資訊不可用", + "failedToFetchPrCommits": "擷取 PR 提交失敗", + "failedToFetchPrFeedback": "擷取 PR 意見回饋失敗", + "failedToFetchPrFiles": "擷取 PR 檔案失敗", + "failedToLoadCiAutomationOptions": "載入 CI 自動化選項失敗。", + "failedToSearchGitHub": "在 GitHub 上搜尋失敗", + "pullRequestNumbered": "Pull Request #{{number}}", + "timeAgoDays_one": "{{count}} 天前", + "timeAgoDays_other": "{{count}} 天前", + "timeAgoHours_one": "{{count}} 小時前", + "timeAgoHours_other": "{{count}} 小時前", + "timeAgoJustNow": "剛剛", + "timeAgoMinutes_one": "{{count}} 分鐘前", + "timeAgoMinutes_other": "{{count}} 分鐘前", + "timeAgoMonths_one": "{{count}} 個月前", + "timeAgoMonths_other": "{{count}} 個月前", + "timeAgoWeeks_one": "{{count}} 周前", + "timeAgoWeeks_other": "{{count}} 周前", + "timeAgoYears_one": "{{count}} 年前", + "timeAgoYears_other": "{{count}} 年前" } diff --git a/apps/web/src/locales/zh-tw/gitlab.json b/apps/web/src/locales/zh-tw/gitlab.json index 93a3ec548d..1fe3ec6213 100644 --- a/apps/web/src/locales/zh-tw/gitlab.json +++ b/apps/web/src/locales/zh-tw/gitlab.json @@ -347,5 +347,17 @@ "mrChipAutoMerge": "自動合併", "mrChipDrawerTitle": "合併請求 !{{mriid}}", "mrChipDrawerDescription": "流水線狀態、審閱狀態和合並請求操作。", - "mrChipDrawerClose": "關閉合並請求狀態面板" + "mrChipDrawerClose": "關閉合並請求狀態面板", + "gitlabRejectedAssociation": "GitLab 拒絕了關聯。", + "taskCreatedMrNotLinked": "任務已建立,但未關聯合並請求", + "taskCreatedMrNotLinkedDescription": "開啟任務並使用「關聯 GitLab 合併請求」重試。{{error}}", + "addedMrFeedbackToTaskContext": "已將 merge request 意見回饋加入任務上下文", + "failedToLoadMergeRequest": "載入 merge request 失敗", + "failedToLoadMrAutomationOptions": "載入 MR 自動化選項失敗。", + "failedToSearchGitLab": "在 GitLab 上搜尋失敗", + "memberSearchFailed": "成員搜尋失敗", + "presetAssigned": "指派給我", + "presetAuthored": "我建立的", + "presetCreated": "我建立的", + "presetReviewRequested": "請求我審查" } diff --git a/apps/web/src/locales/zh-tw/integrations.json b/apps/web/src/locales/zh-tw/integrations.json index e4e5de7e31..fd480b7c70 100644 --- a/apps/web/src/locales/zh-tw/integrations.json +++ b/apps/web/src/locales/zh-tw/integrations.json @@ -87,5 +87,108 @@ "stageAllChangesBeforeCommitting": "提交前暫存所有更改", "targetWorkspace": "目標工作區", "unknownError": "未知錯誤", - "waitingForTheNextBackendHealth": "正在等待下一次後端健康檢查…" + "waitingForTheNextBackendHealth": "正在等待下一次後端健康檢查…", + "createdLinkNeedsAttention": "{{shortName}} 已建立;任務連結需要處理", + "associationRetryViaTaskMenu": "{{message}}。請在任務選單中使用「連結」重試。", + "taskAssociationCouldNotBeSaved": "無法儲存任務關聯。", + "registeredProvider": "已註冊提供者", + "failedToUnlinkPullRequest": "無法取消關聯提取要求", + "pullRequestUnlinked": "已取消關聯提取要求", + "linkedProviderPullRequests_one": "已關聯 {{count}} 個 {{provider}} 提取要求", + "linkedProviderPullRequests_other": "已關聯 {{count}} 個 {{provider}} 提取要求", + "unlinkPullRequestNumber": "取消關聯提取要求 #{{number}}", + "defaultRepository": "預設儲存庫", + "filterRepositories": "篩選儲存庫…", + "noRepositoriesFound": "未找到儲存庫", + "query": "查詢", + "queryName": "名稱", + "queryNamePlaceholder": "例如:需要我的審查", + "saveQuery": "儲存查詢", + "cursorPaginationSummary_one": "第 {{page}} 頁 · {{count}} 個結果", + "cursorPaginationSummary_other": "第 {{page}} 頁 · {{count}} 個結果", + "actionFromReviewer": "{{name}} {{action}}", + "actionRequired": "需要處理", + "addAllCommentsToChatContext": "將所有評論加入對話上下文", + "addAllFailedChecksToChatContext": "將所有失敗的檢查加入對話上下文", + "addAllReviewsToChatContext": "將所有審查加入對話上下文", + "addFailuresToChatContext": "將 {{name}} 的失敗加入對話上下文", + "addReviewComment": "新增審查意見", + "addWholeThreadToChatContext": "將整個討論串加入對話上下文", + "allRepositories": "所有儲存庫", + "approved": "已批准", + "approvedCount_one": "{{count}} 項已批准", + "approvedCount_other": "{{count}} 項已批准", + "awaitingReview": "等待審查", + "botComments_one": "機器人評論({{count}})", + "botComments_other": "機器人評論({{count}})", + "by": "作者", + "changeRequestCreationFailed": "建立變更請求失敗", + "changesRequested": "要求修改", + "changesRequestedCount_one": "{{count}} 項要求修改", + "changesRequestedCount_other": "{{count}} 項要求修改", + "chooseReview": "選擇審查", + "chooseReviewToOpen": "選擇一個審查以開啟它。", + "ciChecks": "CI 檢查{{summary}}", + "closedRelative": "{{relative}}關閉", + "closePullRequestStatus": "關閉 PR 狀態", + "commentCount_one": "{{count}} 條評論", + "commentCount_other": "{{count}} 條評論", + "commented": "已評論", + "comments": "評論", + "disabled": "已停用", + "dismissed": "已忽略", + "failed": "失敗", + "failedCount_one": "{{count}} 項失敗", + "failedCount_other": "{{count}} 項失敗", + "general": "(總體)", + "inProgress": "進行中", + "lastSynced": "上次同步於{{relative}}", + "loadingChangeRequest": "正在載入變更請求", + "mergedRelative": "{{relative}}合併", + "noChecks": "無檢查", + "noChecksHaveStarted": "尚未開始任何檢查", + "noCommentsYet": "暫無評論", + "noReviewsYet": "暫無審查", + "openDetails": "開啟 {{title}} 詳情", + "openedRelative": "{{relative}}開啟", + "openReview": "開啟審查", + "openReviewNumber": "開啟審查 #{{number}}", + "other": "其他", + "otherCount_one": "{{count}} 項其他", + "otherCount_other": "{{count}} 項其他", + "overall": "總體", + "passed": "通過", + "passedCount_one": "{{count}} 項通過", + "passedCount_other": "{{count}} 項通過", + "passRate": "通過率", + "pending": "待處理", + "pendingCount_one": "{{count}} 項待處理", + "pendingCount_other": "{{count}} 項待處理", + "pendingReview": "待審查", + "pullRequest": "Pull request", + "pullRequestCount_one": "{{count}} 個 pull request", + "pullRequestCount_other": "{{count}} 個 pull request", + "pullRequestNumber": "PR #{{number}}", + "pullRequestShortCount_one": "{{count}} 個 PR", + "pullRequestShortCount_other": "{{count}} 個 PR", + "pullRequestStatusSummary": "Pull request 的 CI 狀態、審查與檢查摘要。", + "refresh": "重新整理", + "replyToThread": "回覆討論串 {{threadId}}", + "requestedCount_one": "{{count}} 項已請求", + "requestedCount_other": "{{count}} 項已請求", + "retry": "重試", + "reviewCount_one": "{{count}} 條審查", + "reviewCount_other": "{{count}} 條審查", + "reviewerTeamName": "{{name}}(團隊)", + "reviews": "審查{{summary}}", + "reviewUnavailable": "審查不可用。", + "running": "執行中", + "saving": "正在儲存", + "timedOut": "已超時", + "unresolvedComments_one": "{{count}} 條未解決的評論", + "unresolvedComments_other": "{{count}} 條未解決的評論", + "untitledPullRequest": "未命名的 pull request", + "updatedAgo": "{{elapsed}}前更新", + "updatedJustNow": "剛剛更新", + "viewPullRequestExternally": "在外部檢視 pull request #{{number}}" } diff --git a/apps/web/src/locales/zh-tw/jira.json b/apps/web/src/locales/zh-tw/jira.json index b581004702..6e65365d45 100644 --- a/apps/web/src/locales/zh-tw/jira.json +++ b/apps/web/src/locales/zh-tw/jira.json @@ -208,5 +208,7 @@ "watcherUpdated": "已更新監控", "workflow": "工作流程", "workflowStep": "工作流程步驟", - "yourJiraSessionExpiredOrNeeds": "你的 Jira 工作階段已過期或需要二次驗證。請重新連線以檢視此工單。" + "yourJiraSessionExpiredOrNeeds": "你的 Jira 工作階段已過期或需要二次驗證。請重新連線以檢視此工單。", + "builtinViewAssignedToMe": "指派給我", + "builtinViewUnassigned": "未指派" } diff --git a/apps/web/src/locales/zh-tw/kanban.json b/apps/web/src/locales/zh-tw/kanban.json index 730d08b804..15785b85af 100644 --- a/apps/web/src/locales/zh-tw/kanban.json +++ b/apps/web/src/locales/zh-tw/kanban.json @@ -91,5 +91,19 @@ "workflow": "工作流程", "workflowStepFallback": "工作流程步驟 {{stepId}}", "columns": "列", - "columnsForWorkflow": "{{name}} 的列" + "columnsForWorkflow": "{{name}} 的列", + "queuedCount_one": "{{count}} 個排隊任務", + "queuedCount_other": "{{count}} 個排隊任務", + "queuedSection": "排隊中", + "needsReassignment": "需要重新分配", + "blockedByCount_one": "被 {{count}} 項阻塞", + "blockedByCount_other": "被 {{count}} 項阻塞", + "blockedByTasksTitle": "正在等待:{{tasks}}", + "blockedFailed": "阻塞項失敗", + "blockedPredecessorFailed": "該任務依賴的一個任務失敗了:{{tasks}}", + "noTasksYet": "暫無任務", + "noWorkflowsAvailableYet": "暫無可用的工作流程。", + "pullToRefreshPull": "下拉重新整理", + "pullToRefreshRefreshing": "正在重新整理任務", + "pullToRefreshRelease": "鬆開即可重新整理" } diff --git a/apps/web/src/locales/zh-tw/office.json b/apps/web/src/locales/zh-tw/office.json index d93ce1d5f5..2020857924 100644 --- a/apps/web/src/locales/zh-tw/office.json +++ b/apps/web/src/locales/zh-tw/office.json @@ -811,17 +811,17 @@ "thisWillPermanentlyDeleteAllMemory": "將永久刪除此代理程式的全部記憶條目。此操作無法撤銷。", "thisWillPermanentlyDeleteThisInstruction": "將永久刪除此說明檔案。此操作無法撤銷。", "tier": "層級", - "tierBalanced": "Balanced", + "tierBalanced": "均衡", "tierBalancedDescription": "當協調代理程式分配 Balanced 層級時,用於一般工作者代理程式。", "tierBalancedHint": "標準能力", "tierBalancedQualified": "Balanced(標準)", "tierBalancedUsage": "Balanced 層級用量", - "tierEconomy": "Economy", + "tierEconomy": "經濟", "tierEconomyDescription": "當協調代理程式分配 Economy 層級時,用於 QA、例行與低成本代理程式。", "tierEconomyHint": "最便宜的可用模型", "tierEconomyQualified": "Economy(最便宜)", "tierEconomyUsage": "Economy 層級用量", - "tierFrontier": "Frontier", + "tierFrontier": "前沿", "tierFrontierDescription": "當協調代理程式建立最高能力工作的代理程式或分配 Frontier 層級時使用。", "tierFrontierHint": "各提供者的最強能力", "tierFrontierQualified": "Frontier(最強能力)", @@ -894,5 +894,10 @@ "writeTheDatabaseStateToOn": "將資料庫狀態寫入磁碟上的 YAML 檔案。", "yourModel": "你的模型", "zoomIn": "放大", - "zoomOut": "縮小" + "zoomOut": "縮小", + "failedToLoadAgentRoute": "載入代理程式路由失敗", + "failedToLoadProviderHealth": "載入服務商健康狀態失敗", + "failedToLoadRouteAttempts": "載入路由嘗試記錄失敗", + "failedToLoadRoutingConfig": "載入路由設定失敗", + "failedToLoadRoutingPreview": "載入路由預覽失敗" } diff --git a/apps/web/src/locales/zh-tw/plugins.json b/apps/web/src/locales/zh-tw/plugins.json index 06b6fe94c6..0a174fc164 100644 --- a/apps/web/src/locales/zh-tw/plugins.json +++ b/apps/web/src/locales/zh-tw/plugins.json @@ -115,5 +115,6 @@ "uploadFile": "上傳檔案", "utilityAgentsUnavailable": "輔助代理程式不可用", "viewRaw": "檢視原始內容", - "yes": "是" + "yes": "是", + "pluginDialog": "外掛對話方塊" } diff --git a/apps/web/src/locales/zh-tw/review.json b/apps/web/src/locales/zh-tw/review.json index c9a35ba26f..1484c5155a 100644 --- a/apps/web/src/locales/zh-tw/review.json +++ b/apps/web/src/locales/zh-tw/review.json @@ -78,5 +78,20 @@ "walkMeThroughTheseReviewChanges": "帶我瀏覽這些審閱更改", "walkthrough": "導覽", "walkthroughDiscarded": "導覽已丟棄", - "wrapLongLines": "長行自動換行" + "wrapLongLines": "長行自動換行", + "changesDiscarded": "更改已丟棄", + "changesStillLoading": "更改仍在載入中", + "couldNotSendFinding": "無法傳送發現項", + "couldNotUpdateFinding": "無法更新發現項", + "discardFailed": "丟棄失敗", + "failedToRequestWalkthrough": "請求導覽失敗", + "findingQueuedForAgent": "發現項已加入代理佇列", + "findingSentToAgent": "發現項已傳送給代理", + "walkthroughRequestQueued": "導覽請求已排隊", + "walkthroughRequestSent": "導覽請求已傳送", + "couldNotStartReview": "無法開始審查", + "severityBlocker": "阻斷", + "severityMajor": "嚴重", + "severityMinor": "次要", + "severityNit": "細節" } diff --git a/apps/web/src/locales/zh-tw/settings.json b/apps/web/src/locales/zh-tw/settings.json index 62b825bfdd..9952a45f34 100644 --- a/apps/web/src/locales/zh-tw/settings.json +++ b/apps/web/src/locales/zh-tw/settings.json @@ -401,12 +401,12 @@ "manualInstallRequired": "需要手動安裝", "markMessagesThatArrivedWhileA": "標記任務不可見期間收到的訊息。立即生效。", "mcpAffectedToolsHelp": "<0>create_task_kandev</0> 會建立單獨任務。<3>spawn_session_kandev</3> 會向目前任務新增工作階段,因此不使用此偏好設定。", - "mcpCreateTaskScope": "<0>create_task_kandev</0> 會建立新任務和子任務。該設定僅在呼叫時未提供 <4>agent_profile_id</4> 時生效。", - "mcpSpawnSessionScope": "<0>spawn_session_kandev</0> 和你自行建立的任務不受影響。明確選擇的設定始終優先。", - "mcpTaskProfileCurrentTask": "目前任務設定", - "mcpTaskProfileCurrentTaskDescription": "新任務使用建立它的任務所用的相同設定。若後續工作需要相同模型和代理程式設定,請選擇此項。這可能會複用費用更高的設定。", - "mcpTaskProfileWorkspaceDefault": "工作區預設代理程式設定檔", - "mcpTaskProfileWorkspaceDefaultDescription": "設定了工作流程代理程式設定檔時,新任務會使用該設定;否則使用接收任務的工作區預設代理程式設定檔。選擇此項可讓代理程式建立的任務使用標準工作區模型,避免意外複用費用較高的代理程式設定檔。", + "mcpCreateTaskScope": "<0>create_task_kandev</0> 會建立新任務和子任務。該設定僅在呼叫時未提供 <4>agent_profile_id</4> 時生效。在即時任務呼叫中,建立工作階段設定會複製已驗證工作階段的有效模型、模式和選項。", + "mcpSpawnSessionScope": "<0>spawn_session_kandev</0> 和你自行建立的任務不受影響。工作流程選擇的設定始終優先。否則,明確選擇的設定優先;工作區預設設定會跳過工作階段和任務設定。外部呼叫沒有建立工作階段,current_task 使用父任務備援設定。", + "mcpTaskProfileCurrentTask": "建立工作階段設定", + "mcpTaskProfileCurrentTaskDescription": "對於即時任務呼叫,新任務使用已驗證建立工作階段的設定,以及該工作階段的有效模型、模式和動態選項,包括工作階段期間的更改。工作流程啟動設定優先。沒有該設定時,使用建立工作階段;沒有已驗證的工作階段上下文時,依次備援到源任務或父任務設定,再備援到工作流程和工作區預設設定。這可能會複用成本更高的設定。", + "mcpTaskProfileWorkspaceDefault": "工作區預設設定", + "mcpTaskProfileWorkspaceDefaultDescription": "新任務會跳過建立工作階段以及源任務或父任務設定。它先使用工作流程啟動設定,再使用目標工作區預設設定。它不會複製建立工作階段的模型、模式或動態選項。選擇此項可保持一致的成本策略。如果工作流程和工作區都沒有設定,建立會失敗。", "memory": "記憶體 %", "mergeThisSplitIntoTheSplit": "將此拆分割槽域合併到包含 {{target}} 的拆分割槽域。", "mergeWithTarget": "與 {{target}} 合併", @@ -692,5 +692,43 @@ "visualEditorUnavailable": "視覺化編輯器不可用", "vsCodeRemoteSsh": "VS Code Remote SSH", "whenEnabledInstallYourProjectS": "啟用後,會通過儲存庫設定指令碼安裝專案依賴(例如執行 <2>{{command}}</2>),以避免缺少型別錯誤。", - "externalMcpToolListTaskSessions": "按最近啟動的順序列出任務的所有代理程式工作階段,以便其他工具通過 ID 定位工作階段。" + "externalMcpToolListTaskSessions": "按最近啟動的順序列出任務的所有代理程式工作階段,以便其他工具通過 ID 定位工作階段。", + "shortcutBottomTerminal": "切換底部終端", + "shortcutCommandPanel": "命令面板", + "shortcutCommandPanelAlt": "命令面板(備用)", + "shortcutFileSearch": "檔案搜尋", + "shortcutFocusChatInput": "聚焦聊天輸入框", + "shortcutFocusCliChatInput": "聚焦 CLI 聊天輸入框", + "shortcutNewTask": "新建任務", + "shortcutOpenTaskPullRequest": "開啟任務提取要求", + "shortcutOpenWorkspacePicker": "開啟工作區選擇器", + "shortcutQuickChat": "快速聊天", + "shortcutRecentTaskSwitcher": "最近任務切換器", + "shortcutRecentTaskSwitcherBackward": "最近任務切換器(向後)", + "shortcutReverseChatSearch": "反向聊天搜尋", + "shortcutTaskContentSearch": "搜尋任務內容", + "shortcutTogglePlanMode": "切換計劃模式", + "shortcutToggleSidebar": "切換側邊欄", + "copyMove": "複製/移動", + "copyMoveSecretNamed": "複製或移動{{name}}", + "copyMoveDialogHint": "將金鑰複製或移動到其他範圍。值在服務端傳輸,絕不會顯示。", + "copyMoveMode": "複製或移動", + "copySecretAction": "複製", + "moveSecretAction": "移動", + "copyModeDescription": "在目標位置建立副本。原金鑰保持不變。", + "moveModeWarning": "原金鑰將從{{origin}}中移除。", + "destination": "目標", + "secretTargetName": "名稱", + "secretNameConflictInDestination": "目標位置已存在名為{{name}}的金鑰。", + "destinationsLoading": "正在載入目標", + "destinationsLoadFailed": "無法載入目標。", + "retryDestinations": "重試", + "noValidDestinations": "沒有可用的有效目標。", + "transferFailed": "無法複製或移動金鑰。", + "destinationGlobal": "全域", + "secretNameTooLong": "名稱不能超過 100 位元組。", + "keyValueAddItem": "新增條目", + "keyValueKeyPlaceholder": "鍵", + "keyValueValuePlaceholder": "值", + "profileStartModelNotAdvertisedOnHost": "主機探測未公佈 {{model}}。所選執行器將在啟動時決定使用的模型。" } diff --git a/apps/web/src/locales/zh-tw/sidebar.json b/apps/web/src/locales/zh-tw/sidebar.json index e3ec49bdfc..25898e1d4f 100644 --- a/apps/web/src/locales/zh-tw/sidebar.json +++ b/apps/web/src/locales/zh-tw/sidebar.json @@ -106,5 +106,14 @@ "viewLabel": "檢視:{{name}}", "whatsNew": "新功能", "work": "工作", - "workspaceFallback": "工作區" + "workspaceFallback": "工作區", + "wipQueuePosition": "{{step}} 佇列中的第 {{position}} 項,共 {{total}} 項", + "taskPreferences": "側邊欄任務偏好設定", + "views": "側邊欄檢視", + "failedToLoadArchivedTasks": "載入已歸檔任務失敗", + "failedToSyncSidebarTaskPrefs": "同步側邊欄任務偏好失敗", + "failedToSyncSidebarViews": "同步側邊欄檢視失敗", + "morePluginItems": "更多外掛項", + "newViewNumbered": "新檢視 {{suffix}}", + "untitledView": "未命名檢視" } diff --git a/apps/web/src/locales/zh-tw/stats.json b/apps/web/src/locales/zh-tw/stats.json index b3d08acadd..e228fef344 100644 --- a/apps/web/src/locales/zh-tw/stats.json +++ b/apps/web/src/locales/zh-tw/stats.json @@ -79,5 +79,25 @@ "turnsPerTask": "每任務回合數", "userMsgs": "使用者訊息", "userShare": "使用者佔比", - "workload": "工作負載" + "workload": "工作負載", + "failedToLoadSection": "載入該部分失敗", + "failedToLoadStats": "載入統計資料失敗", + "loadingStats": "正在載入統計資料…", + "noGitActivity": "無 git 活動", + "subtitleReady": "{{tasks}} · {{sessions}} · {{duration}}", + "subtitleSessionCount_one": "{{count}} 個工作階段", + "subtitleSessionCount_other": "{{count}} 個工作階段", + "subtitleTaskCount_one": "{{count}} 個任務", + "subtitleTaskCount_other": "{{count}} 個任務", + "summaryCompletedInRange": "- 已完成({{range}}):{{completed}}", + "summaryGit": "- Git:{{git}}", + "summaryGitActivity_one": "{{count}} 次提交,+{{insertions}}/-{{deletions}}", + "summaryGitActivity_other": "{{count}} 次提交,+{{insertions}}/-{{deletions}}", + "summaryHeading": "*Kandev 統計 - {{range}}*", + "summaryRepos_one": "- 儲存庫:已跟蹤 {{count}} 個 · 主要儲存庫:{{topRepo}}", + "summaryRepos_other": "- 儲存庫:已跟蹤 {{count}} 個 · 主要儲存庫:{{topRepo}}", + "summaryTasks": "- 任務:共 {{total}} 個({{done}} 個已完成,{{inProgress}} 個進行中)· 完成率 {{completion}}", + "summaryTime": "- 時間:共 {{total}} · 每個任務平均 {{average}}", + "summaryTopRepo_one": "{{repository}}({{count}} 個任務)", + "summaryTopRepo_other": "{{repository}}({{count}} 個任務)" } diff --git a/apps/web/src/locales/zh-tw/system.json b/apps/web/src/locales/zh-tw/system.json index ac28975350..14d9176558 100644 --- a/apps/web/src/locales/zh-tw/system.json +++ b/apps/web/src/locales/zh-tw/system.json @@ -509,5 +509,8 @@ "versionCurrent": "目前", "versionLatest": "最新", "versionTitle": "版本", - "versionViewUpdates": "檢視更新" + "versionViewUpdates": "檢視更新", + "messageQueueAutoMergeDescription": "在投遞前,把來自同一來源的連續且相容的排隊訊息合併為一輪。", + "messageQueueAutoMergeNotice": "如果某條訊息的來源不同,或任務、模型、模式、後設資料或共享限制不相容,它會保持為單獨的排隊訊息。這隻適用於新接收的訊息,不會改變手動合併控制。", + "messageQueueAutoMergeToggleLabel": "自動合併連續訊息" } diff --git a/apps/web/src/locales/zh-tw/task.json b/apps/web/src/locales/zh-tw/task.json index 7bf121ab18..8567662c78 100644 --- a/apps/web/src/locales/zh-tw/task.json +++ b/apps/web/src/locales/zh-tw/task.json @@ -935,7 +935,6 @@ "promptFieldLabel": "提示詞", "providerPrivateContextThatKandevDid": "Kandev 未記錄的提供者私有上下文可能不會保留。已開啟的終端、開發伺服器及其他工作區處理程序會停止,包括任務編輯器伺服器。", "providerUnavailable": "提供者不可用", - "providerHistoryUnavailable": "目前 PR 歷史不可用。顯示檢出歷史,但不假定發生了重寫。", "proxy": "代理伺服器", "publishARedactedSnapshotOfThis": "將此已完成任務的脫敏快照發布為你帳戶上的私密 GitHub Gist。", "publishToGithubGist": "釋出到 GitHub Gist", @@ -1514,5 +1513,195 @@ "autopilotDescription": "代理程式會自主工作,只有在關鍵決策阻礙進展時才會向父任務詢問,然後等待答覆再繼續。", "autopilotInfoLabel": "了解自動駕駛模式", "autopilotSidebarDescription": "自動駕駛任務", - "autopilotChatDescription": "自動駕駛已啟用。代理程式只會向父任務詢問關鍵決策。" + "autopilotChatDescription": "自動駕駛已啟用。代理程式只會向父任務詢問關鍵決策。", + "providerAheadPushDisabled": "提供者分支較新。無法執行推送。", + "providerAheadPullRequiresUpstream": "此檢出必須設定上游後才能拉取。", + "divergedActionsUnavailable": "貢獻歷史已分叉。請從 Changes 操作中選擇一個版本。", + "reviewsCount_one": "審閱({{count}})", + "reviewsCount_other": "審閱({{count}})", + "composerStartAgentHint": "傳送訊息將自動啟動代理程式。", + "duplicateRepository": "儲存庫重複", + "duplicateRepositoryDescription": "{{repository}} 被新增了多次。請刪除重複行。", + "repositoryStillBeingVerified": "仍在驗證儲存庫", + "repositoryProviderVerificationFailed": "Kandev 無法驗證此提供者 URL。請在儲存庫行中使用重試。", + "repositoryProviderVerificationPending": "請等待提供者檢查完成後再建立任務。", + "failedToCreateFile": "建立檔案失敗", + "failedToCreateSession": "建立工作階段失敗", + "failedToCreateTask": "建立任務失敗", + "failedToDeleteItem": "刪除專案失敗", + "failedToDeletePlan": "刪除計劃失敗", + "failedToDownloadFile": "下載檔案失敗", + "failedToFetchPlan": "擷取計劃失敗", + "failedToLoadPlanRevisions": "載入修訂版本失敗", + "failedToRevertPlan": "還原計劃失敗", + "failedToSavePlan": "儲存計劃失敗", + "failedToStartTaskPlanMode": "以計劃模式啟動任務失敗", + "failedToUpdateTask": "更新任務失敗", + "noPullOrMergeRequestLinked": "此任務未關聯提取要求或合併請求", + "sessionEndedCreateNew": "工作階段已結束。請建立新工作階段以繼續。", + "sessionUnavailableForInput": "工作階段不可用於輸入", + "taskQueued": "任務已排隊", + "taskQueuedWipLimit": "工作流程步驟已達到 WIP 限制;此任務將在有可用容量時啟動。", + "failedToCheckRepositoryStatus": "檢查本地儲存庫狀態失敗", + "failedToDiscoverRepositories": "發現儲存庫失敗", + "failedToLoadCommitDiff": "載入提交差異失敗", + "failedToNestTask": "巢狀任務失敗", + "failedToProceedToNextStep": "進入下一步失敗", + "failedToStartImplementingPlan": "開始實施計劃失敗", + "failedToStartImplementationSession": "啟動實施工作階段失敗", + "failedToMoveTask": "移動任務失敗", + "movedTasksStepDescription_one": "任務現已位於所選步驟中。", + "movedTasksStepDescription_other": "任務現已位於所選步驟中。", + "movedTasksToStep_one": "已將任務移動到步驟", + "movedTasksToStep_other": "已將 {{count}} 個任務移動到步驟", + "movedTasksToWorkflow_one": "已將任務移動到工作流程", + "movedTasksToWorkflow_other": "已將 {{count}} 個任務移動到工作流程", + "movedTasksWorkflowDescription_one": "切換到目標工作流程即可檢視。", + "movedTasksWorkflowDescription_other": "切換到目標工作流程即可檢視。", + "sessionActionDelete": "刪除工作階段中", + "sessionActionFailed": "{{action}}失敗", + "sessionActionProgress": "正在{{action}}...", + "sessionActionResume": "恢復工作階段中", + "sessionActionSetPrimary": "設為主要工作階段", + "sessionActionStop": "停止工作階段中", + "sessionActionSuccessful": "{{action}}成功", + "addDependency": "新增依賴…", + "advancedSettings": "進階設定", + "agentHasStopped": "該代理程式已停止。", + "agentResponseChangedSelectAgain": "代理程式的回覆已變化。請重新選擇文字。", + "agentStillBeingSetUp": "代理程式仍在準備中。", + "attachmentUploadFailed": "上傳失敗", + "branchDisabledLocalExecutor": "本地執行器使用你儲存庫目前的檢出,因此這裡無法更改分支。開啟「建立新分支」即可選擇其他基線。", + "branchDisabledNoBranches": "該儲存庫沒有可用的分支。", + "branchDisabledSelectRepositoryFirst": "請先選擇一個儲存庫。", + "branchPrefixCurrent": "目前:", + "branchPrefixFrom": "來自:", + "branchPrefixWillSwitchTo": "將切換到:", + "branchTooltipCurrent": "你儲存庫目前的檢出。代理程式按原樣在其上執行,不做任何 git 操作。", + "branchTooltipFrom": "基線分支。將從這裡派生出新分支(或 worktree),代理程式在其中執行。", + "branchTooltipGeneric": "代理程式將要執行的分支。", + "branchTooltipWillSwitchTo": "在代理程式啟動前,後端會執行 `git checkout` 將你的儲存庫切換到該分支。", + "clarificationNoSelection": "未選擇", + "cleanupAgentSessionsStopped": "所有正在執行的代理程式工作階段都將被停止。", + "cleanupBulkLocal_one": "{{count}} 個本地任務 - 你的儲存庫目錄不會被改動。", + "cleanupBulkLocal_other": "{{count}} 個本地任務 - 你的儲存庫目錄不會被改動。", + "cleanupBulkLocalDocker_one": "將移除 {{count}} 個 Docker 容器。", + "cleanupBulkLocalDocker_other": "將移除 {{count}} 個 Docker 容器。", + "cleanupBulkRemoteDocker_one": "將移除 {{count}} 個遠端 Docker 容器。", + "cleanupBulkRemoteDocker_other": "將移除 {{count}} 個遠端 Docker 容器。", + "cleanupBulkSprites_one": "將銷燬 {{count}} 個 Sprites 沙箱(未提交的工作將丟失)。", + "cleanupBulkSprites_other": "將銷燬 {{count}} 個 Sprites 沙箱(未提交的工作將丟失)。", + "cleanupBulkSsh_one": "將盡力移除 {{count}} 個遠端任務目錄。", + "cleanupBulkSsh_other": "將盡力移除 {{count}} 個遠端任務目錄。", + "cleanupBulkWorktree_one": "將刪除 {{count}} 個 worktree 及其分支。", + "cleanupBulkWorktree_other": "將刪除 {{count}} 個 worktree 及其分支。", + "cleanupSingleLocal": "代理程式直接在你的儲存庫中執行 - 你的檔案、分支和目錄都不會被改動。", + "cleanupSingleLocalDocker": "執行該任務的 Docker 容器將被停止並移除。你主機上的儲存庫不會被改動。", + "cleanupSingleRemoteDocker": "執行該任務的遠端 Docker 容器將被停止並移除。", + "cleanupSingleSprites": "該任務的 Sprites 雲沙箱將被銷燬。其中所有未提交的工作都會丟失。", + "cleanupSingleSsh": "遠端主機上的任務目錄將被盡力移除。你的本地儲存庫不會被改動。", + "cleanupSingleWorktree": "該任務的 git worktree 及其分支將被刪除。你的主儲存庫和其他分支不受影響。", + "composerPlaceholderBusy": "為代理程式排隊指令…", + "composerPlaceholderClarification": "在問題待答期間排隊指令…", + "composerPlaceholderFile": "繼續處理該檔案…", + "composerPlaceholderPlan": "繼續完善計劃…", + "composerPlaceholderRecovery": "請在上方選擇一個恢復選項以繼續…", + "composerPlaceholderSwitchingAgent": "正在切換代理程式…", + "composerPlaceholderTask": "繼續處理該任務…", + "couldNotGenerateSummary": "無法產生摘要", + "credentialExplicitToken": "明確指定的的 GH_TOKEN 或 GITHUB_TOKEN", + "credentialSourceExecutor": "執行器認證資訊", + "credentialSourceExecutorProfile": "執行器設定檔的權杖", + "credentialSourceWorkspace": "受管的工作區身分", + "credentialTransportExecutorSelected": "執行器的 Git 與 gh 認證資訊", + "credentialTransportManagedFallback": "由 Kandev 管理的任務認證資訊", + "credentialTransportManagedHttps": "由 Kandev 管理的 GitHub HTTPS 與 gh", + "credentialTransportProfileToken": "執行器設定檔的權杖", + "credentialWorkspaceFallback": "工作區的 GitHub 認證資訊", + "credentialWorkspaceGhCli": "工作區的 GitHub CLI 帳戶", + "credentialWorkspaceGithubApp": "工作區的 GitHub App", + "credentialWorkspacePat": "工作區的個人存取權杖", + "dependencies": "依賴", + "dependencyBlockedBy": "被阻塞於", + "dependencyBlocks": "阻塞", + "dependencyChainHalted": "該任務依賴的某個任務失敗了。在你重試或移除該依賴之前,整條鏈都會停住。", + "dependencyChipLabel": "任務依賴", + "dependencyCount_one": "{{count}} 個依賴", + "dependencyCount_other": "{{count}} 個依賴", + "dependencyCountBlockedBy_one": "{{count}} 個阻塞項", + "dependencyCountBlockedBy_other": "{{count}} 個阻塞項", + "dependencyCountBlocks_one": "阻塞 {{count}} 項", + "dependencyCountBlocks_other": "阻塞 {{count}} 項", + "dependencyInfo": "該任務會一直等待,直到所選的每個任務都成功完成。如果你啟動了代理程式,它會在所有依賴解決後自動開始。", + "dependencyInfoLabel": "關於任務依賴", + "dependsOn": "依賴於", + "dependsOnWillAutoStart": "該任務會等待上面的任務。如果你啟動了代理程式,它會在這些任務全部成功完成後自動執行。", + "enhancedPromptCopiedToClipboard": "已將最佳化後的提示詞複製到剪貼簿。", + "enhancedPromptCouldNotBeCopied": "無法複製最佳化後的提示詞。", + "enhancedPromptCouldNotBeInserted": "已產生最佳化後的提示詞,但無法插入。", + "environmentNotCreated": "未建立", + "failedToCreateSubtask": "建立子任務失敗", + "failedToDetachTask": "分離任務失敗", + "failedToFetchCumulativeDiff": "擷取累計差異失敗", + "failedToLoadFileContent": "載入檔案內容失敗", + "failedToLoadFileTree": "載入檔案樹失敗", + "failedToResumeAndRestore": "恢復工作階段失敗 - 工作區還原同樣不可用", + "gitOperationFailedGeneric": "操作失敗", + "invalidGitHubUrl": "GitHub 連結無效 - 應為 github.com/owner/repo 或 .../pull/123", + "invalidRegex": "正規表示式無效", + "messageFetchLimitHint": "對於該工作階段的最後一輪,擷取上限可能太小 - 使用者提示詞和代理程式回覆位於更靠前的位置。請翻頁或提高上限以檢視它們。", + "messageMetadataEntries": "訊息後設資料條目", + "monitorStatusEnded": "已結束", + "monitorStatusEndedSessionRestart": "已結束(工作階段重啟)", + "monitorStatusWatching": "監視中", + "noBranchesShort": "無分支", + "noDependencies": "無依賴", + "noDependency": "無依賴", + "openFileInProvider": "在 {{provider}} 中開啟檔案", + "referenceSearchFailedRetry": "引用搜尋失敗。請重試。", + "removeDependency": "移除依賴", + "renameFailed": "重新命名失敗", + "resuming": "正在恢復…", + "sessionFailedUnexpectedly": "工作階段意外失敗", + "sessionNotAvailableForInput": "所選工作階段無法接收輸入。", + "sessionStatusGenerating": "產生中", + "sessionStatusStarting": "啟動中", + "sessionStatusTurnFinished": "本輪已結束", + "submitTooltipQueueMessage": "將訊息排隊", + "submitTooltipRequestPlanChanges": "請求修改計劃", + "summarizeCouldNotGenerate": "無法產生摘要。請檢查摘要工具代理程式是否已在設定中設定並啟用。", + "summarizeFailed": "產生摘要失敗", + "taskClosed": "任務已關閉", + "taskClosedAutomatically": "該任務已被自動關閉。", + "taskClosedIssueClosed": "其 issue 已關閉,因此該任務被自動關閉。", + "taskClosedPrApproved": "其 pull request 已獲批准,因此該審查任務被自動關閉。", + "taskClosedPrMergedOrClosed": "其 pull request 已合併或關閉,因此該審查任務被自動關閉。", + "taskFailedToStart": "任務啟動失敗", + "taskTitledWasClosed": "「{{title}}」已關閉", + "updateAvailableToast": "Kandev {{version}} 已釋出。請開啟「設定 > 系統 > 更新」檢視。", + "updateFailed": "更新失敗", + "workspaceRoot": "工作區根目錄", + "workspaceUnavailableArchived": "工作區不可用 - 該任務已歸檔", + "worktreeExecutorRequiresRepository": "Worktree 執行器需要一個儲存庫。", + "worktreeNumbered": "Worktree {{index}}", + "modelSelectionAgent": "代理程式", + "modelSelectionEffective": "實際使用的模型", + "modelSelectionExecutor": "執行器", + "modelSelectionExecutorProfile": "執行器設定檔", + "modelSelectionFallback": "已公佈的備選模型", + "modelSelectionProviderDefault": "代理程式預設模型", + "modelSelectionReason": "原因", + "modelSelectionReasonAutoFallback": "無法應用已公佈的模型,因此自動備援並繼續使用代理程式的預設模型。", + "modelSelectionReasonCatalogEmpty": "執行器未公佈模型目錄。", + "modelSelectionReasonFallbackNotAdvertised": "執行器未公佈已儲存的備選模型。", + "modelSelectionReasonRequestedNotAdvertised": "執行器未公佈已儲存的模型。", + "modelSelectionReasonUnknown": "{{reason}}", + "modelSelectionReasonUnsupported": "該執行器不支援選擇模型。", + "modelSelectionRemediationAgentVersion": "請檢查執行器中的代理程式版本。", + "modelSelectionRemediationConfiguration": "請檢查已複製的代理程式設定檔。", + "modelSelectionRemediationCredentials": "請檢查執行器認證資訊。", + "modelSelectionRemediationTitle": "請檢查以下各項", + "modelSelectionRemediationUnknown": "請檢查執行器設定檔。", + "modelSelectionRequested": "請求的模型", + "modelSelectionWarning": "執行器無法使用已儲存的模型選擇。" } diff --git a/apps/web/src/locales/zh-tw/workflows.json b/apps/web/src/locales/zh-tw/workflows.json index caa7121083..ef4b67e191 100644 --- a/apps/web/src/locales/zh-tw/workflows.json +++ b/apps/web/src/locales/zh-tw/workflows.json @@ -260,5 +260,10 @@ "workflows": "工作流程", "workflowsSectionDescription": "建立帶自動流轉的自主流水線,或由你手動移動任務的手動工作流程", "workflowSteps": "工作流程步驟", - "workflowSyncCompleted": "工作流程同步完成" + "workflowSyncCompleted": "工作流程同步完成", + "pullFromGuidanceWithFeeder": "可選的自動饋送入口。目標佇列中的任務優先獲得准入;有容量時,Kandev 再從 {{feeder}} 拉取符合條件的工作。直接移動和自動轉換會在目標步驟排隊,不需要饋送步驟。新任務在目標步驟已滿時使用此饋送步驟。", + "pullFromGuidanceWithoutFeeder": "未選擇饋送步驟。目標步驟已滿時,直接移動和自動轉換會在此目標佇列中排隊。新任務也會留在此佇列中,直到有可用容量。", + "loadingSteps": "正在載入步驟…", + "noStepsInThisWorkflow": "該工作流程沒有步驟", + "selectAWorkflowFirst": "請先選擇一個工作流程" } diff --git a/apps/web/src/locales/zh-tw/workspaces.json b/apps/web/src/locales/zh-tw/workspaces.json index ad40d57226..695a56c75d 100644 --- a/apps/web/src/locales/zh-tw/workspaces.json +++ b/apps/web/src/locales/zh-tw/workspaces.json @@ -118,5 +118,13 @@ "workspaceNotFound": "未找到工作區", "workspaceSettings": "工作區設定", "worktreeBranchTemplate": "工作樹分支模板", - "worktreeSync": "工作樹同步" + "worktreeSync": "工作樹同步", + "chooseDifferentRepositoryName": "請另選一個儲存庫名稱。", + "enterRepositoryName": "請輸入儲存庫名稱。", + "repositoryNameMustBeOneFolder": "儲存庫名稱必須是單個目錄名。", + "sourceChooseAFolder": "請選擇一個目錄。", + "sourceChooseRepoAndBranch": "請選擇一個儲存庫和基線分支。", + "sourceEnterValidRemoteUrl": "請輸入有效的遠端儲存庫 URL 和基線分支。", + "sourceFolderAlreadySelected": "該目錄已被選擇。", + "sourceRepoBranchAlreadySelected": "該儲存庫和分支已被選擇。" } diff --git a/docs/i18n.md b/docs/i18n.md index e17b256361..ad399878a8 100644 --- a/docs/i18n.md +++ b/docs/i18n.md @@ -21,11 +21,15 @@ Two enforcement layers, and it matters which is which: - **New code is guarded everywhere.** Copy you add — in a new file, or on a line you changed — must go through `t()` / `<Trans>` whatever directory it is in. See [the new-code ratchet](#but-new-code-is-guarded-everywhere). -- **Whole files are guarded only once listed.** The eslint literal rule errors - across an entire file only for the paths in `i18nGuardFiles`, so you are never - asked to migrate lines you did not touch. See +- **Whole files are guarded everywhere.** The eslint literal rule is an error on + every `.ts`/`.tsx` file (tests and `e2e/` excluded), and `check-nonjsx-copy.mjs` + scans the whole tree by exclusion. Both were scoped to `i18nGuardFiles` while + the migration ran; that list is now the migration record, not the bound. See [The guard is scoped](#the-guard-is-scoped-on-purpose) and [Externalizing a path](#externalizing-a-path). +- **The four real locales gate the build.** `pt-pt`, `zh-cn`, `zh-hk` and `zh-tw` + are complete, and adding an `en` key without translating it fails + `pnpm run i18n:check`. See [Real locales](#real-locale-catalogs-gate). ## TL;DR for adding a string @@ -181,11 +185,11 @@ active into the user's saved record, where it stays after a switch. Three places in the app are like this, and all three use the same split — a canonical English value for storage beside a catalog key for display: -| Module | Persisted | Displayed | -| --- | --- | --- | -| `lib/state/layout-manager/constants.ts` | `PANEL_REGISTRY[id].title` | `titleKey` via `panelTitle()` | -| `lib/layout/layout-profiles.ts` | `BUILT_IN_LAYOUT_PROFILES[i].name` | `nameKey` via `builtInLayoutProfileName()` | -| `lib/state/slices/ui/sidebar-view-builtins.ts` | `DEFAULT_VIEW.name` | `sidebarViewName()` | +| Module | Persisted | Displayed | +| ---------------------------------------------- | ---------------------------------- | ------------------------------------------ | +| `lib/state/layout-manager/constants.ts` | `PANEL_REGISTRY[id].title` | `titleKey` via `panelTitle()` | +| `lib/layout/layout-profiles.ts` | `BUILT_IN_LAYOUT_PROFILES[i].name` | `nameKey` via `builtInLayoutProfileName()` | +| `lib/state/slices/ui/sidebar-view-builtins.ts` | `DEFAULT_VIEW.name` | `sidebarViewName()` | Get the **direction** right, and write a test that pins it. Dockview panel titles pass through the serializer twice: `toSerializedDockview` feeds @@ -223,16 +227,17 @@ node scripts/fix-trans-indices.mjs --write components app ``` `lint:i18n` uses `eslint.i18n.config.mjs`, which applies the guard to whatever -paths you pass, ignoring the allowlist. It is the work-list generator for a -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. +paths you pass. It predates the repo-wide rule and survives as a way to run the +guard over one directory in isolation; it never gates a merge. The real gate is +`pnpm lint`, where the rule is an **error on every source file**. -Unlike the eslint guard, `i18n:check` is **not** scoped to the allowlist — its -checks scan `components`, `app`, `hooks`, `lib`, and published Markdown in -`docs/public` repo-wide, and it runs in -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 +`i18n:check` is not scoped either — its checks scan `components`, `app`, `hooks`, +`lib`, `src`, and published Markdown in `docs/public` repo-wide, and it runs in +CI (Frontend Tests → _Run i18n checks_). `check-nonjsx-copy.mjs` is no longer the +exception it used to be: it defaulted to `i18nGuardFiles`, which left 1141 of +2564 source files judged by neither gate, and the non-JSX positions it exists to +find are exactly what lives in the plain `.ts` modules that rarely made the list. +It now scans the whole tree by exclusion. That is deliberate: each check 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 @@ -276,7 +281,7 @@ overlap.** Catalogs are fetched on demand (see [Catalog loading](#catalog-loading-and-why-en-is-the-exception)), so pseudo would be a chunk nobody requests in production — but an unrequested chunk is still built, still deployed, and still reachable by anyone who can set the -`kandev_locale` cookie. The build-time constant is folded into the *lazy* glob +`kandev_locale` cookie. The build-time constant is folded into the _lazy_ glob for exactly that reason: on a plain `vite build` no pseudo chunk is emitted at all. Nothing asserts on that absence, so check it by hand when either mechanism changes — `grep -rl "Ŕēţŕŷ" dist/assets/*.js` must return nothing. @@ -495,34 +500,59 @@ 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 | +| 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. +It scans the **whole tree**, by exclusion (`node_modules`, `dist`, `e2e`, +`scripts`, `src/locales`, `*.test.*`, `*.test-helpers.*`, `*.test-utils.*`, +`*.d.ts`) rather than by allowlist, so a new directory is covered the day it is +created. It used to default to `i18nGuardFiles`, for the same treadmill reason +the eslint rule was scoped — but the arithmetic did not work out: 1141 of 2564 +source files were judged by neither gate, and non-JSX positions are precisely +what lives in the plain `.ts` modules that rarely made the list. A run over that +missing scope found 325 findings. + +Unlike a lint rule, this check has no per-PR-file cost — it is one script over +the tree — so there was never much reason to scope it. + +The exemption marker must be a `//` LINE comment. `exemptionsFor` matches +`/i18n-exempt:?(.*)$/` without the `m` flag, so `$` binds to the end of the +comment's whole text: a marker on the first line of a `/** */` block never +matches and is silently ignored. + +`check-new-code-i18n.mjs` runs the same detector over added files and changed +lines everywhere, which is what guards a directory before anyone thinks about it. 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. +That rule has a known blind spot, and it is worth naming because it is not +obvious from the outside: a template whose static chunks are each individually +too small still reads as non-copy even when the sentence plainly is copy. +`` `${n} tasks · ${m} sessions · ${d}` `` is the worked example — its longest +chunk is `" tasks · "`, a single lowercase word, which `looksLikeCopy` rejects. +Judging the chunks joined would fix this one and break `` `${w}px ${h}em` ``, +so the floor stays where it is. What catches this class is a human reading the +diff, and the pseudo-locale. + #### 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" }]; +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. +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 @@ -645,6 +675,7 @@ back when it was still hypothetical. `<html lang>` alone would not have done: production bundle that an English boot fetches no other catalog, that activating one fetches exactly its chunk, and that a cold `zh-cn` boot renders translated rather than key names. + - **Shared UI**: `@kandev/ui` primitives carry their own hardcoded accessibility labels ("Close", "Toggle Sidebar", "Loading", "Go to next page"). The package must not import the app's i18n runtime — it has to stay usable standalone — so @@ -817,20 +848,20 @@ pins accent detection once against `#language-select`'s known-migrated positive control. **But note exactly what that control establishes, because it is less than it -looks.** `inspectedAttributes > 0` proves the pass *inspected something*. It does -not prove it inspected *the screen under test* — the sidebar, topbar and status +looks.** `inspectedAttributes > 0` proves the pass _inspected something_. It does +not prove it inspected _the screen under test_ — the sidebar, topbar and status bar contribute over a hundred attributes on every route, and they are accented long before a `SCREENS` route mounts out of its lazy chunk (`settings-routes` is 2.8 MB; Office has its own). For a while the only thing separating "route rendered" from "scan the DOM" was a `waitForTimeout(1_000)`, and with both chunks -blocked so that *no screen rendered at all*, 13-16 of the 21 tests still reported +blocked so that _no screen rendered at all_, 13-16 of the 21 tests still reported clean — varying run to run, because it was a race the settle could not see. That is the worst direction for a -gate to fail in: under CI load a slower render makes a green result *more* likely, +gate to fail in: under CI load a slower render makes a green result _more_ likely, so the check is most permissive exactly when the tree is most likely to be broken. Each `SCREENS` entry now carries a required `anchor` — a selector that cannot match until that route has rendered — and the walk does not start until it -matches *and* the matched element's text is accented. Settings screens anchor on +matches _and_ the matched element's text is accented. Settings screens anchor on a `data-testid` their own component renders; Office screens anchor on `main[data-office-route="<path>"]`, the route outlet stamped with the resolved path, because the SPA mounts Office pages with empty collections and any anchor @@ -839,7 +870,7 @@ means adding its anchor, and the anchor is the part to think about. **Generalize the lesson rather than the fix: a positive control asserting that a check inspected something does not establish that it inspected the thing under -test.** When you add a control, ask what the *weakest* state of the system is +test.** When you add a control, ask what the _weakest_ state of the system is that still satisfies it — here, "the app shell painted" satisfied it, and that state is reached on every route whether or not the route ever arrives. @@ -1156,59 +1187,92 @@ See `docs/plans/traditional-chinese-locales/glossary.md`. tests for canonicalization, cookie and `Accept-Language` negotiation, message lookup, exact catalog parity, and the first shell response. 5. Run `pnpm run i18n:check` from `apps/web`. It discovers every real locale - except `en` and generated `pseudo`, and **warns** on any missing or extra - namespace or key — advisory, not a build failure; see below. Regenerate - `pseudo` only from English with `pnpm run i18n:pseudo`; never copy a human - translation into it. + except `en` and generated `pseudo`, and **fails** on any missing or extra + namespace or key, a dropped placeholder or tag, an empty value, or a value + left identical to English; see below. Regenerate `pseudo` only from English + with `pnpm run i18n:pseudo`; never copy a human translation into it. 6. In a development or E2E build, select the new language under **Settings > General > Appearance**, reload, and switch back to English. Confirm translated copy, the locale cookie, `<html lang>`, interpolation, layout, and then use `pseudo` to find English literals on already migrated surfaces. -Kandev's migration remains incremental. A complete locale catalog guarantees -that every currently externalized message has a translation; it does not mean -that every screen has been externalized. Unmigrated surfaces continue to render -English until their directory joins `i18nGuardFiles`. +A complete locale catalog guarantees that every externalized message has a +translation. Externalization itself is complete: the literal rule is repo-wide. -### Real locales warn, they do not gate +### Real-locale catalogs gate -`en` and `pseudo` gate. `en` is authored in the same PR as the code and `pseudo` -is **generated** from it, so a mismatch between them is always the author's to -fix in the change that caused it. Real-locale parity is a **warning**. +`en` and `pseudo` have always gated. Real-locale parity used to be a **warning**, +and the reasoning was sound at the time: translation happened out of band, so +gating on it made an ordinary English-only PR fail CI for work nobody in the +merge path could do. It was not hypothetical — #2243 introduced `zh-cn` together +with the parity gate, #2261 added 13 English `chat` keys twenty minutes later, +and `main` went red because the two could not have been ordered to avoid it. -That is a deliberate policy, not an oversight. Translation happens out of band, -on a third party's cadence, so gating on it makes an ordinary English-only PR -fail CI for work nobody in the merge path can do. It is not hypothetical: #2243 -introduced `zh-cn` together with the parity gate, #2261 added 13 English `chat` -keys twenty minutes later, and `main` went red — not because either change was -wrong, but because the two could not have been ordered to avoid it. The gate -fired exactly as designed and blocked the wrong people. +The cost of that policy came due. Measured before this change, against 8317 `en` +entries: -So: **add the keys to every real locale when you can, and never let a missing -one stop you merging.** The warning is addressed to whoever owns the -translation. +| locale | missing keys | values identical to English | extra keys | +| ------ | -----------: | --------------------------: | ---------: | +| pt-pt | 312 | 232 | 9 | +| zh-cn | 217 | 82 | 7 | +| zh-hk | 415 | 81 | 1 | +| zh-tw | 415 | 81 | 1 | -#### It is a structural check, not a translation check +None of it visible in a green build. The four catalogs are now complete, so the +failure mode has flipped: the risk is no longer "a PR is blocked on a translator" +but "a PR silently re-opens the gap". So the pass **gates**, and the cost is that +adding user-facing copy means adding it in five languages. That is the real cost +of shipping five languages; it was previously being deferred rather than paid. -Worth stating plainly, because assuming otherwise has already produced two wrong -conclusions: a green run does **not** mean "translated". The pass compares -shapes. Measured, one mutation at a time: +For `zh-tw` and `zh-hk`, do not hand-translate: run `pnpm run i18n:zh-hant`, +which converts `zh-cn` through OpenCC plus the product glossary and emits proper +regional variants (`裝置` for TW, `設備` for HK). Review the output. -| mutation | result | -| ------------------------------------- | -------------- | -| value identical to English | **not caught** | -| whole catalog copy-pasted from `en` | **not caught** | -| swapped `_one` / `_other` | **not caught** | -| empty / whitespace / non-string value | caught | -| missing key, extra key | caught | -| missing namespace, extra namespace | caught | -| dropped `{{placeholder}}` | caught | -| dropped or renumbered `<n>` tag | caught | +#### Identical-to-English, and the verbatim registry -The identical-to-English tolerance is deliberate and cannot be tightened -naively: brand nouns and technical literals must stay verbatim (`automations` -alone has seven — `GitHub`, `Webhook`, `Webhook URL`, …), so `value === en` would -report each of them. Closing it needs a per-key exemption list or a -whole-file proportion threshold. Reviewing a translation is a human job; -`scripts/check-i18n-keys.test.ts` pins both the caught and the uncaught cases so -this stays honest. +A catalog copy-pasted wholesale from `en` passes every structural check, because +they all compare shapes. The identical-to-English check closes that, in two tiers +so the exemption mechanism never becomes a shrinking allowlist: + +1. **Rule.** If the English value is not copy by `looksLikeCopy` — a brand noun, + an acronym, a wire value, a frame of nothing but `{{placeholders}}` — every + locale is expected to keep it verbatim and nothing is declared. That is the + same predicate `i18next/no-literal-string` and `check-nonjsx-copy.mjs` use, so + the three cannot disagree about what a translatable string is. +2. **Declaration.** What survives tier 1 is prose. Prose that reads the same in + the target language is a judgement someone made, so it is declared with a + reason: `src/locales/_verbatim.json` for values every locale keeps (git verbs, + `pull request`, product names), `src/locales/<locale>/_verbatim.json` for the + language-specific coincidences (`Total`, `Manual`, `Menu` in pt-PT). + +Both files are self-cleaning. A reasonless entry fails. An entry for a key that +no longer exists fails. A per-locale entry whose locale has since translated the +value fails, and a shared entry fails once **no** locale keeps it verbatim. And +deleting an entry does not make anything pass — it makes the identical value +fatal again. That is what distinguishes it from an allowlist you can shrink. + +#### What the check does and does not decide + +A green run still does **not** mean "well translated" — it means the catalog is +structurally complete and nobody left English in a translatable slot. Measured, +one mutation at a time: + +| mutation | result | +| ------------------------------------- | ------------------------------ | +| value identical to English | caught (unless declared) | +| whole catalog copy-pasted from `en` | caught | +| empty / whitespace / non-string value | caught | +| missing key, extra key | caught | +| missing namespace, extra namespace | caught | +| dropped `{{placeholder}}` | caught | +| dropped or renumbered `<n>` tag | caught | +| swapped `_one` / `_other` | **not caught** | +| a fluent but wrong translation | **not caught** — a human's job | + +The identical-to-English row used to read "not caught", and the reasoning was +that brand nouns and technical literals must stay verbatim (`automations` alone +has several — `GitHub`, `Webhook`, `Webhook URL`), so a naive `value === en` +would report each of them. That is true of a naive check; the two-tier form above +does not have the problem, because `looksLikeCopy` already knows a brand noun +from a sentence. `scripts/check-i18n-keys.test.ts` pins both the caught and the +uncaught cases so this stays honest.