fix(web): externalize copy the jsx-only i18n guard cannot see, and gate it (#2711)

Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
João Salavisa
2026-08-16 12:08:59 +01:00
committed by GitHub
parent 38c94e7d00
commit 63c21686ff
140 changed files with 1534 additions and 233 deletions
+9 -5
View File
@@ -104,11 +104,15 @@ never call `t()` at module scope (it freezes at the boot locale and the
pseudo-locale cannot detect it); never pass an English plural ending as a value — pseudo-locale cannot detect it); never pass an English plural ending as a value —
use `count` with `_one`/`_other`. `pnpm run i18n:check` enforces the last two. use `count` with `_one`/`_other`. `pnpm run i18n:check` enforces the last two.
A clean lint is not proof a file is done. The rule only sees literals in JSX, and A clean lint is not proof a file is done: the rule only sees literals in JSX. The
it **skips anything assigned to a SCREAMING_CASE identifier** — so a positions it cannot inspect — SCREAMING_CASE config tables, plain `.ts` helpers,
`const ROWS = [{ label: "Disk usage" }]` config table passes silently. Review parameter defaults, toast and setter arguments — are gated by
those by eye. The pseudo-locale (Settings → General → Appearance, dev/e2e builds) `scripts/check-nonjsx-copy.mjs`, which runs inside `pnpm run i18n:check` and the
is the completeness check. Full guide: [`docs/i18n.md`](docs/i18n.md). new-code ratchet. Mark a genuine non-string (a persisted value, an agent-facing
prompt, a `===` token) with `// i18n-exempt: <reason>`; an unexplained marker
fails. The pseudo-locale (Settings → General → Appearance, dev/e2e builds) is
still the completeness check for copy no literal scan can see. Full guide:
[`docs/i18n.md`](docs/i18n.md).
UI punctuation is plain and intentional: do not use the Unicode em dash (U+2014) UI punctuation is plain and intentional: do not use the Unicode em dash (U+2014)
in user-facing copy or locale values. Use a period, colon, comma, semicolon, or in user-facing copy or locale values. Use a period, colon, comma, semicolon, or
+9 -9
View File
@@ -234,19 +234,19 @@ stays an allowlist because a repo-wide error breaks every unrelated PR that adds
a label — what made the first attempt unmergeable. **Append a path in the same PR 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). 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 Never delete an entry to make a build pass; `check-guard-allowlist.mjs` rejects
that unless the file is gone. Use `pnpm run lint:i18n <path>` to preview the that unless the file is gone. `pnpm run lint:i18n <path>` previews the guard on a
guard on a path not yet on the list. path not yet listed.
`pnpm run i18n:ratchet` guards all new/changed lines independently of that `pnpm run i18n:ratchet` guards all new/changed lines independently of that
allowlist; untouched literals are not reported. allowlist; untouched literals are not reported.
The rule sees JSX literals but misses `confirm()` and plain `.ts` helper copy; it The rule sees only JSX literals. What it cannot inspect — SCREAMING_CASE tables,
also skips SCREAMING_CASE constants, so review config tables by eye. `i18n:check` plain `.ts` helpers, parameter defaults, toast/setter arguments — is gated by
gates key/catalog drift, `<Trans>` indices, inline plurals, module-scope `t()`, and `scripts/check-nonjsx-copy.mjs` on the same allowlist, run by `i18n:check` and
the **pseudo-locale** (Settings → General → Appearance) completeness check. It the ratchet; silence a legitimate one with `// i18n-exempt: <reason>` (required).
needs **Node 24**. Full guide: `i18n:check` also gates key/catalog drift, `<Trans>` indices, inline plurals,
[`docs/i18n.md`](../../docs/i18n.md); spec: module-scope `t()`, and the **pseudo-locale** check. Needs **Node 24**. Guide:
[`docs/specs/platform/i18n.md`](../../docs/specs/platform/i18n.md). [`docs/i18n.md`](../../docs/i18n.md); spec [`docs/specs/platform/i18n.md`](../../docs/specs/platform/i18n.md).
## Markdown safety ## Markdown safety
+6 -5
View File
@@ -14,13 +14,14 @@ type InvitePageProps = {
}; };
function useInvitePreview(token: string | undefined) { function useInvitePreview(token: string | undefined) {
const { t } = useTranslation();
const [preview, setPreview] = useState<InvitePreview | null>(null); const [preview, setPreview] = useState<InvitePreview | null>(null);
const [previewError, setPreviewError] = useState<string | null>(null); const [previewError, setPreviewError] = useState<string | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
useEffect(() => { useEffect(() => {
if (!token) { if (!token) {
setPreviewError("This invite link is missing its token."); setPreviewError(t("auth:inviteLinkMissingToken"));
setLoading(false); setLoading(false);
return; return;
} }
@@ -35,8 +36,8 @@ function useInvitePreview(token: string | undefined) {
if (cancelled) return; if (cancelled) return;
setPreviewError( setPreviewError(
err instanceof ApiError err instanceof ApiError
? "This invite link is invalid or has expired. Ask an admin for a new one." ? t("auth:inviteLinkInvalidOrExpired")
: "Could not load this invite. Please try again.", : t("auth:couldNotLoadInvite"),
); );
}) })
.finally(() => { .finally(() => {
@@ -163,8 +164,8 @@ export function InvitePage({ token }: InvitePageProps) {
setSubmitting(false); setSubmitting(false);
setError( setError(
err instanceof ApiError err instanceof ApiError
? (err.message ?? "Could not accept this invite.") ? (err.message ?? t("auth:couldNotAcceptInvite"))
: "Something went wrong. Please try again.", : t("auth:somethingWentWrong"),
); );
} }
} }
+2 -6
View File
@@ -61,14 +61,10 @@ export function LoginPage() {
} catch (err) { } catch (err) {
setSubmitting(false); setSubmitting(false);
if (err instanceof ApiError) { if (err instanceof ApiError) {
setError( setError(err.status === 429 ? t("auth:tooManyAttempts") : t("auth:invalidEmailOrPassword"));
err.status === 429
? "Too many attempts. Please wait a moment and try again."
: "Invalid email or password.",
);
return; return;
} }
setError("Something went wrong. Please try again."); setError(t("auth:somethingWentWrong"));
} }
} }
+3 -3
View File
@@ -116,12 +116,12 @@ export function SetupWizard() {
if (err instanceof ApiError) { if (err instanceof ApiError) {
setError( setError(
err.status === 409 err.status === 409
? "Setup is no longer available - an account already exists." ? t("auth:setupNoLongerAvailable")
: (err.message ?? "Setup failed. Please try again."), : (err.message ?? t("auth:setupFailed")),
); );
return; return;
} }
setError("Something went wrong. Please try again."); setError(t("auth:somethingWentWrong"));
} }
} }
@@ -322,6 +322,7 @@ function useGitHubPageState(workspaceId: string | null, enabled: boolean) {
}); });
const canSaveCurrent = customQuery.trim().length > 0 || repoFilter.length > 0; const canSaveCurrent = customQuery.trim().length > 0 || repoFilter.length > 0;
// i18n-exempt: persisted as the saved query's name, so it must not depend on the creating locale.
const suggestedLabel = customQuery.trim() || (repoFilter ? `In ${repoFilter}` : "Saved query"); const suggestedLabel = customQuery.trim() || (repoFilter ? `In ${repoFilter}` : "Saved query");
const onOpenSaveDialog = () => canSaveCurrent && setSaveDialogOpen(true); const onOpenSaveDialog = () => canSaveCurrent && setSaveDialogOpen(true);
@@ -213,6 +213,7 @@ function useGitLabPageState(searchEnabled: boolean, workspaceId?: string) {
// Use committedQuery (not the unflushed draft) so the saved preset always // Use committedQuery (not the unflushed draft) so the saved preset always
// matches what is currently displayed in the list. // matches what is currently displayed in the list.
const canSaveCurrent = committedQuery.trim().length > 0 || projectFilter.length > 0; const canSaveCurrent = committedQuery.trim().length > 0 || projectFilter.length > 0;
// i18n-exempt: persisted as the saved query's name, so it must not depend on the creating locale.
const suggestedLabel = const suggestedLabel =
committedQuery.trim() || (projectFilter ? `In ${projectFilter}` : "Saved query"); committedQuery.trim() || (projectFilter ? `In ${projectFilter}` : "Saved query");
const onOpenSaveDialog = () => { const onOpenSaveDialog = () => {
@@ -56,6 +56,7 @@ function tintFor(name: string): string {
* elements (tabs, dots, badges) when a full avatar is overkill. * elements (tabs, dots, badges) when a full avatar is overkill.
*/ */
export function agentTint(name: string | null | undefined): string { export function agentTint(name: string | null | undefined): string {
// i18n-exempt: hash seed for the tint, never rendered.
return tintFor((name ?? "").trim() || "Agent"); return tintFor((name ?? "").trim() || "Agent");
} }
@@ -6,8 +6,10 @@
* translating either would change stored data and the instructions the model * translating either would change stored data and the instructions the model
* receives, which is a bug rather than a missing translation. * receives, which is a bug rather than a missing translation.
*/ */
// i18n-exempt: persisted as the task's own title. See the file header.
export const DEFAULT_ONBOARDING_TASK_TITLE = "Setup Workspace"; export const DEFAULT_ONBOARDING_TASK_TITLE = "Setup Workspace";
// i18n-exempt: sent verbatim to the coordinator agent as its prompt. See the file header.
export const DEFAULT_ONBOARDING_TASK_DESCRIPTION = `Investigate the project in https://github.com/org/repo. Replace this URL with each repository that should be managed in this workspace. export const DEFAULT_ONBOARDING_TASK_DESCRIPTION = `Investigate the project in https://github.com/org/repo. Replace this URL with each repository that should be managed in this workspace.
As the CEO agent: As the CEO agent:
@@ -132,6 +132,7 @@ export function providerLabel(id: string): string {
return PROVIDER_LABELS[id] ?? id; return PROVIDER_LABELS[id] ?? id;
} }
// i18n-exempt: agent product names.
const PROVIDER_LABELS: Record<string, string> = { const PROVIDER_LABELS: Record<string, string> = {
"claude-acp": "Claude (ACP)", "claude-acp": "Claude (ACP)",
"codex-acp": "Codex (ACP)", "codex-acp": "Codex (ACP)",
@@ -102,6 +102,7 @@ export function GitSection() {
() => () =>
runOp( runOp(
async () => { async () => {
// i18n-exempt: becomes the git commit message. See the comment below.
await officeApi.gitPush(activeWorkspaceId, { await officeApi.gitPush(activeWorkspaceId, {
// Literal: this becomes the git COMMIT MESSAGE, so it is persisted // Literal: this becomes the git COMMIT MESSAGE, so it is persisted
// content rather than UI copy. The input placeholder that shows it // content rather than UI copy. The input placeholder that shows it
@@ -45,6 +45,7 @@ type ProfileMcpConfigCardProps = {
onToastError: (error: unknown) => void; onToastError: (error: unknown) => void;
}; };
// i18n-exempt: MCP server config the user copies verbatim, including the example header value.
const POPULAR_SERVERS: Record<string, Record<string, unknown>> = { const POPULAR_SERVERS: Record<string, Record<string, unknown>> = {
playwright: { playwright: {
type: "stdio", type: "stdio",
@@ -76,11 +77,14 @@ const POPULAR_SERVERS: Record<string, Record<string, unknown>> = {
// Tool names and MCP-server product names are identifiers, not copy: they are // Tool names and MCP-server product names are identifiers, not copy: they are
// interpolated as values so the pseudo-locale cannot turn them into something // interpolated as values so the pseudo-locale cannot turn them into something
// the user cannot type or look up. // the user cannot type or look up.
// i18n-exempt: server name and tool ids are config values the user copies verbatim into an agent.
const KANDEV_MCP_NAME = "Kandev MCP"; const KANDEV_MCP_NAME = "Kandev MCP";
// i18n-exempt: server name and tool ids are config values the user copies verbatim into an agent.
const KANDEV_TOOL_NAMES = const KANDEV_TOOL_NAMES =
"list_workspaces, list_boards, list_workflow_steps, list_tasks, create_task, update_task"; "list_workspaces, list_boards, list_workflow_steps, list_tasks, create_task, update_task";
// i18n-exempt: MCP server product names.
const POPULAR_SERVER_NAMES: Record<string, string> = { const POPULAR_SERVER_NAMES: Record<string, string> = {
playwright: "Playwright MCP", playwright: "Playwright MCP",
"chrome-devtools": "Chrome DevTools MCP", "chrome-devtools": "Chrome DevTools MCP",
@@ -231,6 +231,7 @@ function ExecutorCreatePageContent() {
// own. Translating it would make an executor's stored name depend on the // own. Translating it would make an executor's stored name depend on the
// locale it happened to be created in. The Select labels beside it are copy // locale it happened to be created in. The Select labels beside it are copy
// and do go through `t()`. // and do go through `t()`.
// i18n-exempt: seeded executor name is persisted user data. See the comment above.
const [name, setName] = useState(() => const [name, setName] = useState(() =>
initialType === "remote_docker" ? "Remote Docker" : "Local Docker", initialType === "remote_docker" ? "Remote Docker" : "Local Docker",
); );
@@ -18,6 +18,7 @@ import { createWorkflowDuplication } from "./workflow-duplication";
// These names are PERSISTED as the workflow's step names, so they deliberately // These names are PERSISTED as the workflow's step names, so they deliberately
// stay English — translating them would write localized values into the // stay English — translating them would write localized values into the
// database. // database.
// i18n-exempt: persisted workflow step names. See the comment above.
export const DEFAULT_CUSTOM_STEPS: StepDefinition[] = [ export const DEFAULT_CUSTOM_STEPS: StepDefinition[] = [
{ name: "Todo", position: 0, color: "bg-slate-500" }, { name: "Todo", position: 0, color: "bg-slate-500" },
{ name: "In Progress", position: 1, color: "bg-blue-500" }, { name: "In Progress", position: 1, color: "bg-blue-500" },
@@ -118,6 +119,7 @@ export function useWorkflowCreation({
? workflowTemplates.find((item) => item.id === selectedTemplateId) ? workflowTemplates.find((item) => item.id === selectedTemplateId)
: undefined; : undefined;
const tempId = newClientId("temp-workflow"); const tempId = newClientId("temp-workflow");
// i18n-exempt: persisted workflow name. See the comment below.
const workflow: Workflow = { const workflow: Workflow = {
id: toWorkflowId(tempId), id: toWorkflowId(tempId),
workspace_id: toWorkspaceId(workspace.id), workspace_id: toWorkspaceId(workspace.id),
@@ -59,8 +59,10 @@ function buildDraftRepo(
manualRepoPath: string, manualRepoPath: string,
): RepositoryItem { ): RepositoryItem {
const path = selectedRepo?.path ?? manualValidation.path ?? manualRepoPath.trim(); const path = selectedRepo?.path ?? manualValidation.path ?? manualRepoPath.trim();
// i18n-exempt: persisted repository name. See the comment below.
// "New Repository" is PERSISTED as the repository's name, not rendered copy — // "New Repository" is PERSISTED as the repository's name, not rendered copy —
// a stored name must not depend on the locale it was created in. // a stored name must not depend on the locale it was created in.
// i18n-exempt: persisted repository name. See the comment above.
const name = const name =
selectedRepo?.name ?? path.split("/").filter(Boolean).slice(-1)[0] ?? "New Repository"; selectedRepo?.name ?? path.split("/").filter(Boolean).slice(-1)[0] ?? "New Repository";
return { return {
@@ -108,6 +110,7 @@ async function saveNewRepository(
) { ) {
const created = await createRepositoryAction({ const created = await createRepositoryAction({
workspace_id: workspace?.id ?? repo.workspace_id, workspace_id: workspace?.id ?? repo.workspace_id,
// i18n-exempt: persisted repository name, same contract as buildDraftRepo above.
name: repo.name.trim() || "New Repository", name: repo.name.trim() || "New Repository",
source_type: repo.source_type || "local", source_type: repo.source_type || "local",
local_path: repo.local_path, local_path: repo.local_path,
@@ -129,6 +132,7 @@ async function saveNewRepository(
// Like the repository name above, the seeded script name and command are // Like the repository name above, the seeded script name and command are
// PERSISTED and sent to a shell verbatim, so both stay in English. // PERSISTED and sent to a shell verbatim, so both stay in English.
const scripts = await Promise.all( const scripts = await Promise.all(
// i18n-exempt: persisted script name, sent to a shell verbatim. See the comment above.
repo.scripts.map((script, index) => repo.scripts.map((script, index) =>
createRepositoryScriptAction({ createRepositoryScriptAction({
repository_id: created.id, repository_id: created.id,
@@ -195,6 +199,7 @@ async function saveExistingRepository({
if (script.id.startsWith("temp-script-")) if (script.id.startsWith("temp-script-"))
return createRepositoryScriptAction({ return createRepositoryScriptAction({
repository_id: repoId, repository_id: repoId,
// i18n-exempt: persisted script name, sent to a shell verbatim.
name: script.name.trim() || "New Script", name: script.name.trim() || "New Script",
command: script.command.trim() || 'echo ""', command: script.command.trim() || 'echo ""',
position: script.position ?? index, position: script.position ?? index,
@@ -24,6 +24,7 @@ import {
buildDefaultPermissions, buildDefaultPermissions,
PERMISSION_APPLY_AGENTCTL_AUTO_APPROVE, PERMISSION_APPLY_AGENTCTL_AUTO_APPROVE,
} from "@/lib/agent-permissions"; } from "@/lib/agent-permissions";
import { t } from "@/lib/i18n";
export type CliProfileEditorMode = "create" | "edit"; export type CliProfileEditorMode = "create" | "edit";
@@ -235,8 +236,8 @@ export function CliProfileEditor({
} }
function buttonLabel(mode: CliProfileEditorMode, saving: boolean): string { function buttonLabel(mode: CliProfileEditorMode, saving: boolean): string {
if (saving) return mode === "edit" ? "Saving..." : "Creating..."; if (saving) return mode === "edit" ? t("agents:savingProfile") : t("agents:creatingProfile");
return mode === "edit" ? "Save profile" : "Create profile"; return mode === "edit" ? t("agents:saveProfile") : t("agents:createProfile");
} }
function EditorFooter({ function EditorFooter({
@@ -51,6 +51,7 @@ type AutomationEditorProps = {
// verbatim, and it is compared with `===` in useAutoPromptUpdate to decide // verbatim, and it is compared with `===` in useAutoPromptUpdate to decide
// whether the user has edited it. Both make it protocol, not copy — the same // whether the user has edited it. Both make it protocol, not copy — the same
// call the Jira (#2177), Linear (#2179) and Sentry (#2182) migrations made. // call the Jira (#2177), Linear (#2179) and Sentry (#2182) migrations made.
// i18n-exempt: persisted prompt, sent to the agent and compared with ===. See the comment above.
const DEFAULT_PROMPT = "Run scheduled automation.\n\nTrigger: {{trigger.type}}"; const DEFAULT_PROMPT = "Run scheduled automation.\n\nTrigger: {{trigger.type}}";
const defaultForm: FormState = { const defaultForm: FormState = {
@@ -121,6 +121,7 @@ export function buildCreatePayload(
repositoryIds: string[], repositoryIds: string[],
pending: PendingTrigger[], pending: PendingTrigger[],
): CreateAutomationRequest { ): CreateAutomationRequest {
// i18n-exempt: persisted automation name. See the comment below.
return { return {
workspace_id: workspaceId, workspace_id: workspaceId,
// Persisted as the automation's name — user data, so it stays English // Persisted as the automation's name — user data, so it stays English
@@ -66,6 +66,7 @@ export function pickSelectionFromOptionId(
if (optionId.startsWith(DISCOVERED_PREFIX)) { if (optionId.startsWith(DISCOVERED_PREFIX)) {
const path = optionId.slice(DISCOVERED_PREFIX.length); const path = optionId.slice(DISCOVERED_PREFIX.length);
const match = discoveredRepos.find((r) => r.path === path); const match = discoveredRepos.find((r) => r.path === path);
// i18n-exempt: persisted repository name. See the comment below.
return { return {
kind: "discovered", kind: "discovered",
path, path,
@@ -14,6 +14,7 @@
* schedule composer edits, and computes the next firing so the editor can show * schedule composer edits, and computes the next firing so the editor can show
* the resolved instant instead of leaving it implicit. * the resolved instant instead of leaving it implicit.
*/ */
import { t } from "@/lib/i18n";
export type Frequency = export type Frequency =
| "every-5m" | "every-5m"
@@ -420,21 +421,24 @@ export function timeZoneAbbreviation(timeZone: string, at: Date = new Date()): s
} }
} }
const WEEKDAY_NAMES = [ // Catalog KEYS, not copy: this array is built once at module load, so resolving
"Sunday", // it here would freeze the weekday names at the boot locale (docs/i18n.md,
"Monday", // "no module-scope t()"). `describeSchedule` resolves them per call instead.
"Tuesday", const WEEKDAY_KEYS = [
"Wednesday", "common:weekdaySunday",
"Thursday", "common:weekdayMonday",
"Friday", "common:weekdayTuesday",
"Saturday", "common:weekdayWednesday",
"common:weekdayThursday",
"common:weekdayFriday",
"common:weekdaySaturday",
]; ];
const INTERVAL_LABELS: Partial<Record<Frequency, string>> = { const INTERVAL_LABEL_KEYS: Partial<Record<Frequency, string>> = {
"every-5m": "Every 5 minutes", "every-5m": "automations:scheduleIntervalEvery5Minutes",
"every-15m": "Every 15 minutes", "every-15m": "automations:scheduleIntervalEvery15Minutes",
"every-30m": "Every 30 minutes", "every-30m": "automations:scheduleIntervalEvery30Minutes",
"every-6h": "Every 6 hours", "every-6h": "automations:scheduleIntervalEvery6Hours",
}; };
const pad = (value: number) => String(value).padStart(2, "0"); const pad = (value: number) => String(value).padStart(2, "0");
@@ -445,11 +449,11 @@ const pad = (value: number) => String(value).padStart(2, "0");
*/ */
export function describeSchedule(expression: string, timeZone: string): string { export function describeSchedule(expression: string, timeZone: string): string {
const trimmed = expression.trim(); const trimmed = expression.trim();
if (!trimmed) return "No schedule"; if (!trimmed) return t("automations:scheduleNone");
const spec = parseExpression(trimmed); const spec = parseExpression(trimmed);
const interval = INTERVAL_LABELS[spec.frequency]; const intervalKey = INTERVAL_LABEL_KEYS[spec.frequency];
if (interval) return interval; if (intervalKey) return t(intervalKey);
const zone = timeZone || "UTC"; const zone = timeZone || "UTC";
const zoneLabel = timeZoneAbbreviation(zone); const zoneLabel = timeZoneAbbreviation(zone);
@@ -457,11 +461,15 @@ export function describeSchedule(expression: string, timeZone: string): string {
switch (spec.frequency) { switch (spec.frequency) {
case "hourly": case "hourly":
return `Every hour at :${pad(spec.minute)}`; return t("automations:scheduleEveryHourAt", { minute: pad(spec.minute) });
case "daily": case "daily":
return `Every day at ${at} ${zoneLabel}`; return t("automations:scheduleEveryDayAt", { time: at, zone: zoneLabel });
case "weekly": case "weekly":
return `Every ${WEEKDAY_NAMES[spec.weekday]} at ${at} ${zoneLabel}`; return t("automations:scheduleEveryWeekdayAt", {
weekday: t(WEEKDAY_KEYS[spec.weekday]),
time: at,
zone: zoneLabel,
});
default: default:
return `${trimmed} · ${zoneLabel}`; return `${trimmed} · ${zoneLabel}`;
} }
@@ -29,25 +29,28 @@ type ScheduleSelectorProps = {
onChange: (config: Record<string, unknown>) => void; onChange: (config: Record<string, unknown>) => void;
}; };
const FREQUENCY_OPTIONS: { value: Frequency; label: string }[] = [ // Catalog keys rather than copy: both tables are built at module load, so a
{ value: "every-5m", label: "every 5 minutes" }, // `t()` here would freeze at the boot locale (docs/i18n.md). Each render
{ value: "every-15m", label: "every 15 minutes" }, // resolves them.
{ value: "every-30m", label: "every 30 minutes" }, const FREQUENCY_OPTIONS: { value: Frequency; labelKey: string }[] = [
{ value: "hourly", label: "every hour" }, { value: "every-5m", labelKey: "automations:frequencyEvery5Minutes" },
{ value: "every-6h", label: "every 6 hours" }, { value: "every-15m", labelKey: "automations:frequencyEvery15Minutes" },
{ value: "daily", label: "every day" }, { value: "every-30m", labelKey: "automations:frequencyEvery30Minutes" },
{ value: "weekly", label: "every week" }, { value: "hourly", labelKey: "automations:frequencyEveryHour" },
{ value: "custom", label: "a custom schedule" }, { value: "every-6h", labelKey: "automations:frequencyEvery6Hours" },
{ value: "daily", labelKey: "automations:frequencyEveryDay" },
{ value: "weekly", labelKey: "automations:frequencyEveryWeek" },
{ value: "custom", labelKey: "automations:frequencyCustom" },
]; ];
const WEEKDAYS = [ const WEEKDAY_KEYS = [
"Sunday", "common:weekdaySunday",
"Monday", "common:weekdayMonday",
"Tuesday", "common:weekdayTuesday",
"Wednesday", "common:weekdayWednesday",
"Thursday", "common:weekdayThursday",
"Friday", "common:weekdayFriday",
"Saturday", "common:weekdaySaturday",
] as const; ] as const;
const DESCRIPTORS = new Set([ const DESCRIPTORS = new Set([
@@ -211,7 +214,7 @@ export function ScheduleSelector({ config, isDirty = false, onChange }: Schedule
const handleCustomBlur = () => { const handleCustomBlur = () => {
const trimmed = customDraft.trim(); const trimmed = customDraft.trim();
if (!isValidExpression(trimmed)) { if (!isValidExpression(trimmed)) {
setCustomError("That's not a schedule we can read. Try a 5-field cron like 0 9 * * 1-5."); setCustomError(t("automations:scheduleUnparseable"));
return; return;
} }
setCustomError(null); setCustomError(null);
@@ -269,6 +272,7 @@ function FrequencySelect({
value: Frequency; value: Frequency;
onChange: (value: Frequency) => void; onChange: (value: Frequency) => void;
}) { }) {
const { t } = useTranslation();
return ( return (
<Select value={value} onValueChange={(next) => onChange(next as Frequency)}> <Select value={value} onValueChange={(next) => onChange(next as Frequency)}>
<SelectTrigger className="w-[170px] h-8" data-testid="schedule-frequency"> <SelectTrigger className="w-[170px] h-8" data-testid="schedule-frequency">
@@ -277,7 +281,7 @@ function FrequencySelect({
<SelectContent> <SelectContent>
{FREQUENCY_OPTIONS.map((option) => ( {FREQUENCY_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}> <SelectItem key={option.value} value={option.value}>
{option.label} {t(option.labelKey)}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@@ -357,16 +361,16 @@ function FrequencyDetail({
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{WEEKDAYS.map((day, index) => ( {WEEKDAY_KEYS.map((dayKey, index) => (
<SelectItem key={day} value={String(index)}> <SelectItem key={dayKey} value={String(index)}>
{day} {t(dayKey)}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</> </>
)} )}
<span className="text-muted-foreground">at</span> <span className="text-muted-foreground">{t("automations:scheduleAtSeparator")}</span>
<Input <Input
type="time" type="time"
value={`${pad(spec.hour)}:${pad(spec.minute)}`} value={`${pad(spec.hour)}:${pad(spec.minute)}`}
@@ -21,6 +21,7 @@ const PRESETS = [
// Shown inside the help text as an example; interpolated so the pseudo-locale // Shown inside the help text as an example; interpolated so the pseudo-locale
// cannot accent it into syntax that no longer parses. // cannot accent it into syntax that no longer parses.
// i18n-exempt: cron shortcut syntax shown as an example. See the comment above.
const CRON_SHORTCUT_EXAMPLES = "@hourly, @daily, @weekly, @every 5m"; const CRON_SHORTCUT_EXAMPLES = "@hourly, @daily, @weekly, @every 5m";
export function ScheduledConfig({ config, onUpdate }: ScheduledConfigProps) { export function ScheduledConfig({ config, onUpdate }: ScheduledConfigProps) {
@@ -53,6 +53,7 @@ const AZURE_DEVOPS_ROUTE = "/azure-devops";
// `newPreset` in components/github/default-queries-section.tsx. The seeded WIQL // `newPreset` in components/github/default-queries-section.tsx. The seeded WIQL
// is Azure DevOps' query language, not copy. // is Azure DevOps' query language, not copy.
function newQuery(kind: QueryKind): AzureDevOpsQueryPreset { function newQuery(kind: QueryKind): AzureDevOpsQueryPreset {
// i18n-exempt: persisted, editable preset label. See the comment above.
return { return {
id: `query_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`, id: `query_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`,
label: "New query", label: "New query",
@@ -91,6 +91,7 @@ const PROMPT_TITLE_TOKEN = "{{title}}";
// the row below, so it must stay locale-neutral — the same contract as // the row below, so it must stay locale-neutral — the same contract as
// `newPreset` in components/github/action-presets-section.tsx. // `newPreset` in components/github/action-presets-section.tsx.
function newAction(): AzureDevOpsActionPreset { function newAction(): AzureDevOpsActionPreset {
// i18n-exempt: persisted, editable preset label. See the comment above.
return { return {
id: `preset_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`, id: `preset_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`,
label: "New action", label: "New action",
@@ -275,7 +275,9 @@ type ProjectsState = ReturnType<typeof useAzureDevOpsProjects>;
// Azure DevOps names these two PAT scope groups in its own token UI. They are // Azure DevOps names these two PAT scope groups in its own token UI. They are
// pointers into that screen, not copy, so they are interpolated as values — the // pointers into that screen, not copy, so they are interpolated as values — the
// pseudo-locale must not transliterate a label the user has to find verbatim. // pseudo-locale must not transliterate a label the user has to find verbatim.
// i18n-exempt: Azure DevOps' own PAT scope names; the user picks these exact entries in Azure's UI.
const PAT_SCOPE_WORK_ITEMS = "Work Items"; const PAT_SCOPE_WORK_ITEMS = "Work Items";
// i18n-exempt: Azure DevOps' own PAT scope names; the user picks these exact entries in Azure's UI.
const PAT_SCOPE_CODE = "Code"; const PAT_SCOPE_CODE = "Code";
function PATSetupHelp({ organizationUrl }: { organizationUrl: string }) { function PATSetupHelp({ organizationUrl }: { organizationUrl: string }) {
@@ -66,6 +66,7 @@ function launchText(payload: AzureDevOpsLaunchPayload) {
"", "",
plainText(pullRequest.description), plainText(pullRequest.description),
].join("\n"); ].join("\n");
// i18n-exempt: persisted as the created task's title.
return { return {
title: truncateRemoteTaskTitle( title: truncateRemoteTaskTitle(
payload.action payload.action
@@ -26,6 +26,7 @@ import type { AzureDevOpsActionPreset, AzureDevOpsQueryPreset } from "@/lib/type
type Translate = (key: string) => string; type Translate = (key: string) => string;
// i18n-exempt: WIQL is Azure DevOps' query language, not prose. See the file header.
const WIQL_START = "SELECT [System.Id] FROM WorkItems WHERE [System.TeamProject] = @project"; const WIQL_START = "SELECT [System.Id] FROM WorkItems WHERE [System.TeamProject] = @project";
const WIQL_ORDER = " ORDER BY [System.ChangedDate] DESC"; const WIQL_ORDER = " ORDER BY [System.ChangedDate] DESC";
@@ -33,6 +34,7 @@ function wiql(condition?: string): string {
return `${WIQL_START}${condition ? ` AND ${condition}` : ""}${WIQL_ORDER}`; return `${WIQL_START}${condition ? ` AND ${condition}` : ""}${WIQL_ORDER}`;
} }
// i18n-exempt: persisted preset seed, and must match the server-side defaults. See the file header.
export const DEFAULT_AZURE_WORK_ITEM_QUERIES: AzureDevOpsQueryPreset[] = [ export const DEFAULT_AZURE_WORK_ITEM_QUERIES: AzureDevOpsQueryPreset[] = [
{ {
id: "recent", id: "recent",
@@ -63,6 +65,7 @@ export const DEFAULT_AZURE_WORK_ITEM_QUERIES: AzureDevOpsQueryPreset[] = [
}, },
]; ];
// i18n-exempt: persisted preset seed, and must match the server-side defaults. See the file header.
export const DEFAULT_AZURE_PULL_REQUEST_QUERIES: AzureDevOpsQueryPreset[] = [ export const DEFAULT_AZURE_PULL_REQUEST_QUERIES: AzureDevOpsQueryPreset[] = [
{ {
id: "review-requested", id: "review-requested",
@@ -90,6 +93,7 @@ export const DEFAULT_AZURE_PULL_REQUEST_QUERIES: AzureDevOpsQueryPreset[] = [
}, },
]; ];
// i18n-exempt: persisted preset seed, and must match the server-side defaults. See the file header.
export const DEFAULT_AZURE_WORK_ITEM_ACTIONS: AzureDevOpsActionPreset[] = [ export const DEFAULT_AZURE_WORK_ITEM_ACTIONS: AzureDevOpsActionPreset[] = [
{ {
id: "implement", id: "implement",
@@ -117,6 +121,7 @@ export const DEFAULT_AZURE_WORK_ITEM_ACTIONS: AzureDevOpsActionPreset[] = [
}, },
]; ];
// i18n-exempt: persisted preset seed, and must match the server-side defaults. See the file header.
export const DEFAULT_AZURE_PULL_REQUEST_ACTIONS: AzureDevOpsActionPreset[] = [ export const DEFAULT_AZURE_PULL_REQUEST_ACTIONS: AzureDevOpsActionPreset[] = [
{ {
id: "review", id: "review",
@@ -112,6 +112,7 @@ function registerStartedSession({
// locale-dependent value that then renders unchanged after a locale switch, // locale-dependent value that then renders unchanged after a locale switch,
// on surfaces this directory does not own. Same call as the built-in layout // on surfaces this directory does not own. Same call as the built-in layout
// profile names and the seeded workflow step names. // profile names and the seeded workflow step names.
// i18n-exempt: persisted as the task title. See the comment above.
const derivedName = prompt.slice(0, 40) || "Config Chat"; const derivedName = prompt.slice(0, 40) || "Config Chat";
store.renameQuickChatSession(response.session_id, derivedName); store.renameQuickChatSession(response.session_id, derivedName);
void persistQuickChatRename(response.session_id, response.task_id, derivedName).catch( void persistQuickChatRename(response.session_id, response.task_id, derivedName).catch(
@@ -32,16 +32,17 @@ import { createDirectory } from "@/lib/api/domains/fs-api";
import type { Repository } from "@/lib/types/http"; import type { Repository } from "@/lib/types/http";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next"; import type { TFunction } from "i18next";
import { t } from "@/lib/i18n";
// The returned strings are never rendered: the only caller uses this as a // The returned strings are never rendered: the only caller uses this as a
// boolean gate (`!nameError`), so they stay English rather than becoming dead // boolean gate (`!nameError`), so they stay English rather than becoming dead
// catalog entries. // catalog entries.
export function validateLocalRepositoryName(name: string): string | null { export function validateLocalRepositoryName(name: string): string | null {
const trimmed = name.trim(); const trimmed = name.trim();
if (!trimmed) return "Enter a repository name."; if (!trimmed) return t("workspaces:enterRepositoryName");
if (trimmed === "." || trimmed === "..") return "Choose a different repository name."; if (trimmed === "." || trimmed === "..") return t("workspaces:chooseDifferentRepositoryName");
if (trimmed.includes("/") || trimmed.includes("\\") || trimmed.includes("\0")) { if (trimmed.includes("/") || trimmed.includes("\\") || trimmed.includes("\0")) {
return "The repository name must be one folder name."; return t("workspaces:repositoryNameMustBeOneFolder");
} }
return null; return null;
} }
@@ -2,6 +2,7 @@ import { useState, useCallback, useMemo, useEffect, useRef } from "react";
import { processFile, type FileDiffMetadata } from "@pierre/diffs"; import { processFile, type FileDiffMetadata } from "@pierre/diffs";
import { getWebSocketClient } from "@/lib/ws/connection"; import { getWebSocketClient } from "@/lib/ws/connection";
import { requestFileContent, requestFileContentAtRef } from "@/lib/ws/workspace-files"; import { requestFileContent, requestFileContentAtRef } from "@/lib/ws/workspace-files";
import { t } from "@/lib/i18n";
type UseExpandableDiffOptions = { type UseExpandableDiffOptions = {
sessionId: string | undefined; sessionId: string | undefined;
@@ -222,7 +223,7 @@ export function useExpandableDiff({
); );
if (version === requestVersionRef.current) setLoadedContent(content); if (version === requestVersionRef.current) setLoadedContent(content);
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : "Failed to load file content"; const msg = err instanceof Error ? err.message : t("task:failedToLoadFileContent");
console.error("[useExpandableDiff]", msg); console.error("[useExpandableDiff]", msg);
if (version === requestVersionRef.current) setError(msg); if (version === requestVersionRef.current) setError(msg);
} finally { } finally {
@@ -12,6 +12,7 @@ import {
useSessionGitStatusByRepo, useSessionGitStatusByRepo,
} from "@/hooks/domains/session/use-session-git-status"; } from "@/hooks/domains/session/use-session-git-status";
import type { ExternalVcsProvider } from "@/lib/utils/external-vcs-file-url"; import type { ExternalVcsProvider } from "@/lib/utils/external-vcs-file-url";
import { t } from "@/lib/i18n";
export type ExternalVcsFileLinkProps = UseExternalVcsFileLinkInput & { export type ExternalVcsFileLinkProps = UseExternalVcsFileLinkInput & {
size?: "xs" | "sm" | "touch"; size?: "xs" | "sm" | "touch";
@@ -66,7 +67,7 @@ export function useExternalVcsFileStatus(
} }
function externalLinkLabel(provider: ExternalVcsProvider): string { function externalLinkLabel(provider: ExternalVcsProvider): string {
return `Open file in ${providerNames[provider]}`; return t("task:openFileInProvider", { provider: providerNames[provider] });
} }
export function ExternalVcsFileLink({ size = "xs", ...input }: ExternalVcsFileLinkProps) { export function ExternalVcsFileLink({ size = "xs", ...input }: ExternalVcsFileLinkProps) {
@@ -77,6 +77,7 @@ function initMermaid(theme: "dark" | "light" = "dark") {
} }
/** SVG icon markup for the code toggle button (Tabler IconCode). */ /** SVG icon markup for the code toggle button (Tabler IconCode). */
// i18n-exempt: inline SVG markup.
const CODE_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>`; const CODE_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>`;
/** Mermaid keywords regex for syntax highlighting. */ /** Mermaid keywords regex for syntax highlighting. */
@@ -52,6 +52,7 @@ function actionPromptPlaceholders(t: TFunction): ScriptPlaceholder[] {
// preset seeded in one locale and saved unedited would keep that locale's text // preset seeded in one locale and saved unedited would keep that locale's text
// forever. Same contract as DEFAULT_PR_PRESETS in my-github/action-presets.ts. // forever. Same contract as DEFAULT_PR_PRESETS in my-github/action-presets.ts.
function newPreset(): GitHubActionPreset { function newPreset(): GitHubActionPreset {
// i18n-exempt: persisted, editable preset label. See the comment above.
return { return {
id: `preset_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`, id: `preset_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`,
label: "New action", label: "New action",
@@ -26,6 +26,7 @@ import { useTranslation } from "react-i18next";
// and is editable in the row below, so it must stay locale-neutral — see // and is editable in the row below, so it must stay locale-neutral — see
// `newPreset` in action-presets-section.tsx for the same contract. // `newPreset` in action-presets-section.tsx for the same contract.
function newPreset(): StoredQueryPreset { function newPreset(): StoredQueryPreset {
// i18n-exempt: persisted, editable preset label. See the comment above.
return { return {
value: `q_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`, value: `q_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`,
label: "New query", label: "New query",
@@ -74,7 +74,9 @@ type FormState = {
cleanupPolicy: CleanupPolicy; cleanupPolicy: CleanupPolicy;
}; };
// i18n-exempt: GitHub search syntax and label ids, shown as examples.
const DEFAULT_QUERY = "type:issue state:open"; const DEFAULT_QUERY = "type:issue state:open";
// i18n-exempt: GitHub label ids, shown as examples.
const ISSUE_LABEL_EXAMPLES = "bug, enhancement, priority:high"; const ISSUE_LABEL_EXAMPLES = "bug, enhancement, priority:high";
function makeDefaultForm(workspaceId: string): FormState { function makeDefaultForm(workspaceId: string): FormState {
@@ -56,6 +56,7 @@ export function issueWatchPlaceholders(t: TFunction): ScriptPlaceholder[] {
* Seeded into the prompt field, then persisted and sent to the agent verbatim. * Seeded into the prompt field, then persisted and sent to the agent verbatim.
* Deliberately NOT translated — see `DEFAULT_REVIEW_WATCH_PROMPT`. * Deliberately NOT translated — see `DEFAULT_REVIEW_WATCH_PROMPT`.
*/ */
// i18n-exempt: default watch prompt, persisted and sent verbatim to the agent.
export const DEFAULT_ISSUE_WATCH_PROMPT = `You have been assigned a GitHub issue to work on. export const DEFAULT_ISSUE_WATCH_PROMPT = `You have been assigned a GitHub issue to work on.
**Issue:** {{issue.link}} **Issue:** {{issue.link}}
@@ -68,6 +68,7 @@ export function toTaskPreset(stored: GitHubActionPreset): TaskPreset {
// into a user's saved presets and leave them there after a locale switch. // into a user's saved presets and leave them there after a locale switch.
// `prompt_template` is additionally sent to the agent verbatim. Localizing these // `prompt_template` is additionally sent to the agent verbatim. Localizing these
// needs a key/persisted-value split, the same open item as PR_PRESETS. // needs a key/persisted-value split, the same open item as PR_PRESETS.
// i18n-exempt: persisted preset seed. Localizing needs a key/value split in the preset type.
export const DEFAULT_PR_PRESETS: GitHubActionPreset[] = [ export const DEFAULT_PR_PRESETS: GitHubActionPreset[] = [
{ {
id: "review", id: "review",
@@ -95,6 +96,7 @@ export const DEFAULT_PR_PRESETS: GitHubActionPreset[] = [
}, },
]; ];
// i18n-exempt: persisted preset seed. Localizing needs a key/value split in the preset type.
export const DEFAULT_ISSUE_PRESETS: GitHubActionPreset[] = [ export const DEFAULT_ISSUE_PRESETS: GitHubActionPreset[] = [
{ {
id: "implement", id: "implement",
@@ -27,6 +27,7 @@ export type PresetOption = {
// them there after a locale switch. Localizing these needs a key/persisted-value // them there after a locale switch. Localizing these needs a key/persisted-value
// split in this module, the same open item as the built-in layout profile names. // split in this module, the same open item as the built-in layout profile names.
// `value`, `filter` and `group` are wire values and a GitHub search query. // `value`, `filter` and `group` are wire values and a GitHub search query.
// i18n-exempt: persisted saved-search seed, and the query beside it is GitHub search syntax.
export const PR_PRESETS: PresetOption[] = [ export const PR_PRESETS: PresetOption[] = [
{ {
value: "review_requested", value: "review_requested",
@@ -65,6 +66,7 @@ export const PR_PRESETS: PresetOption[] = [
}, },
]; ];
// i18n-exempt: persisted saved-search seed, and the query beside it is GitHub search syntax.
export const ISSUE_PRESETS: PresetOption[] = [ export const ISSUE_PRESETS: PresetOption[] = [
{ {
value: "assigned", value: "assigned",
@@ -4,6 +4,7 @@ import type { PRComment } from "@/lib/types/github";
import { CollapsibleSection, AddToContextButton, FeedbackItemRow } from "./pr-shared"; import { CollapsibleSection, AddToContextButton, FeedbackItemRow } from "./pr-shared";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
// i18n-exempt: builds the message body sent to the agent, not UI copy.
function buildCommentMessage(comment: PRComment, prUrl: string): string { function buildCommentMessage(comment: PRComment, prUrl: string): string {
const location = comment.path const location = comment.path
? `\`${comment.path}${comment.line > 0 ? `:${comment.line}` : ""}\`` ? `\`${comment.path}${comment.line > 0 ? `:${comment.line}` : ""}\``
+9 -8
View File
@@ -7,22 +7,23 @@ import { Button } from "@kandev/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { markdownComponents, remarkPlugins } from "@/components/shared/markdown-components"; import { markdownComponents, remarkPlugins } from "@/components/shared/markdown-components";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { t } from "@/lib/i18n";
export function formatTimeAgo(dateStr: string): string { export function formatTimeAgo(dateStr: string): string {
const date = new Date(dateStr); const date = new Date(dateStr);
if (isNaN(date.getTime())) return ""; if (isNaN(date.getTime())) return "";
const diff = Date.now() - date.getTime(); const diff = Date.now() - date.getTime();
if (diff < 0) return "just now"; if (diff < 0) return t("github:timeAgoJustNow");
const minutes = Math.floor(diff / 60000); const minutes = Math.floor(diff / 60000);
if (minutes < 1) return "just now"; if (minutes < 1) return t("github:timeAgoJustNow");
if (minutes < 60) return `${minutes}m ago`; if (minutes < 60) return t("github:timeAgoMinutes", { count: minutes });
const hours = Math.floor(minutes / 60); const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`; if (hours < 24) return t("github:timeAgoHours", { count: hours });
const days = Math.floor(hours / 24); const days = Math.floor(hours / 24);
if (days < 7) return `${days}d ago`; if (days < 7) return t("github:timeAgoDays", { count: days });
if (days < 30) return `${Math.floor(days / 7)}w ago`; if (days < 30) return t("github:timeAgoWeeks", { count: Math.floor(days / 7) });
if (days < 365) return `${Math.floor(days / 30)}mo ago`; if (days < 365) return t("github:timeAgoMonths", { count: Math.floor(days / 30) });
return `${Math.floor(days / 365)}y ago`; return t("github:timeAgoYears", { count: Math.floor(days / 365) });
} }
function formatMs(ms: number): string { function formatMs(ms: number): string {
@@ -61,6 +61,7 @@ export function reviewWatchPlaceholders(t: TFunction): ScriptPlaceholder[] {
* Deliberately NOT translated: it is agent instructions and stored data, so * Deliberately NOT translated: it is agent instructions and stored data, so
* translating it would write locale-dependent text into a user's saved watch. * translating it would write locale-dependent text into a user's saved watch.
*/ */
// i18n-exempt: default watch prompt, persisted and sent verbatim to the agent.
export const DEFAULT_REVIEW_WATCH_PROMPT = `Review Pull Request #{{pr.number}}: {{pr.title}} export const DEFAULT_REVIEW_WATCH_PROMPT = `Review Pull Request #{{pr.number}}: {{pr.title}}
Repository: {{pr.repo}} Repository: {{pr.repo}}
PR: {{pr.link}} PR: {{pr.link}}
@@ -16,7 +16,9 @@ import { useMemo } from "react";
// than written into the catalog: they contain a literal `{{task_prompt}}` token, // than written into the catalog: they contain a literal `{{task_prompt}}` token,
// which i18next would read as an interpolation and substitute away, and the // which i18next would read as an interpolation and substitute away, and the
// pseudo-locale would transliterate the URL. // pseudo-locale would transliterate the URL.
// i18n-exempt: example prompt text, shown as a sample of what is sent verbatim to the agent.
const STEP_PROMPT_EXAMPLE = "Analyze the task: {{task_prompt}}"; const STEP_PROMPT_EXAMPLE = "Analyze the task: {{task_prompt}}";
// i18n-exempt: example prompt text, shown as a sample of what is sent verbatim to the agent.
const FINAL_PROMPT_EXAMPLE = "Analyze the task: Pull Request ready for review: https://..."; const FINAL_PROMPT_EXAMPLE = "Analyze the task: Pull Request ready for review: https://...";
// Pulled out of review-watch-dialog.tsx to keep that file under the 600-line // Pulled out of review-watch-dialog.tsx to keep that file under the 600-line
@@ -20,6 +20,7 @@ import { useTranslation } from "react-i18next";
// seeded in one locale and saved unedited would keep that locale's text forever. // seeded in one locale and saved unedited would keep that locale's text forever.
// Same contract as `newPreset` in components/github/action-presets-section.tsx. // Same contract as `newPreset` in components/github/action-presets-section.tsx.
function newPreset(): GitLabActionPreset { function newPreset(): GitLabActionPreset {
// i18n-exempt: persisted, editable preset label. See the comment above.
return { return {
id: `preset_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`, id: `preset_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`,
label: "New action", label: "New action",
@@ -482,7 +482,10 @@ export function MRDetailPanelComponent({
const workspaceId = useAppStore((state) => state.workspaces.activeId); const workspaceId = useAppStore((state) => state.workspaces.activeId);
const sessionId = useAppStore((state) => state.tasks.activeSessionId); const sessionId = useAppStore((state) => state.tasks.activeSessionId);
const { status, loading } = useGitLabStatus(); const { status, loading } = useGitLabStatus();
useEffect(() => setPanelTitle(panelId, mr ? `MR !${mr.mr_iid}` : "Merge Request"), [mr, panelId]); useEffect(
() => setPanelTitle(panelId, mr ? `MR !${mr.mr_iid}` : t("task:panelMergeRequest")),
[mr, panelId, t],
);
if (!mr || !workspaceId || !sessionId) if (!mr || !workspaceId || !sessionId)
return ( return (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground"> <div className="flex h-full items-center justify-center text-sm text-muted-foreground">
@@ -67,6 +67,7 @@ function useProjectMemberSearch({
host, host,
project, project,
}: Pick<Props, "workspaceId" | "host" | "project">) { }: Pick<Props, "workspaceId" | "host" | "project">) {
const { t } = useTranslation();
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [members, setMembers] = useState<GitLabProjectMember[]>([]); const [members, setMembers] = useState<GitLabProjectMember[]>([]);
const [searching, setSearching] = useState(false); const [searching, setSearching] = useState(false);
@@ -101,7 +102,9 @@ function useProjectMemberSearch({
if (isActive(generation, requestIdentity)) setMembers(result); if (isActive(generation, requestIdentity)) setMembers(result);
} catch (searchError) { } catch (searchError) {
if (isActive(generation, requestIdentity)) { if (isActive(generation, requestIdentity)) {
setError(searchError instanceof Error ? searchError.message : "Member search failed"); setError(
searchError instanceof Error ? searchError.message : t("gitlab:memberSearchFailed"),
);
} }
} finally { } finally {
if (isActive(generation, requestIdentity)) setSearching(false); if (isActive(generation, requestIdentity)) setSearching(false);
+2
View File
@@ -29,8 +29,10 @@ export type GitLabWatchForm = {
// locale would keep that locale's prompt forever, and the agent reads it as an // locale would keep that locale's prompt forever, and the agent reads it as an
// instruction rather than as UI copy. Same contract as // instruction rather than as UI copy. Same contract as
// DEFAULT_ISSUE_WATCH_PROMPT in components/github/issue-watch-placeholders.ts. // DEFAULT_ISSUE_WATCH_PROMPT in components/github/issue-watch-placeholders.ts.
// i18n-exempt: default prompt, persisted and sent verbatim to the agent.
const DEFAULT_REVIEW_PROMPT = const DEFAULT_REVIEW_PROMPT =
"Review GitLab merge request {{mr.url}}. Summarize risks and leave actionable feedback."; "Review GitLab merge request {{mr.url}}. Summarize risks and leave actionable feedback.";
// i18n-exempt: default prompt, persisted and sent verbatim to the agent.
const DEFAULT_ISSUE_PROMPT = const DEFAULT_ISSUE_PROMPT =
"Investigate GitLab issue {{issue.url}} and implement the requested change."; "Investigate GitLab issue {{issue.url}} and implement the requested change.";
@@ -56,6 +56,7 @@ function checkSummary(checks: ChangeRequestDetailCheck[]) {
].filter(Boolean); ].filter(Boolean);
} }
// i18n-exempt: builds the message body sent to the agent, not UI copy.
function buildCheckContext(check: ChangeRequestDetailCheck) { function buildCheckContext(check: ChangeRequestDetailCheck) {
return [ return [
`CI check **${check.name}** failed (${checkConclusionLabel(check)}).`, `CI check **${check.name}** failed (${checkConclusionLabel(check)}).`,
@@ -25,7 +25,10 @@ describe("IntegrationRepositoryFilter", () => {
); );
fireEvent.click(screen.getByText("All repositories")); fireEvent.click(screen.getByText("All repositories"));
fireEvent.change(await screen.findByPlaceholderText("Filter repositories..."), { // The unset props fall back to the `integrations:*` catalog, which spells the
// placeholder with a real ellipsis. Both app consumers pass all three props,
// so this default is only ever rendered here.
fireEvent.change(await screen.findByPlaceholderText("Filter repositories…"), {
target: { value: "web" }, target: { value: "web" },
}); });
fireEvent.click(await screen.findByRole("option", { name: "acme/web" })); fireEvent.click(await screen.findByRole("option", { name: "acme/web" }));
@@ -2,6 +2,7 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { Combobox, type ComboboxOption } from "@/components/combobox"; import { Combobox, type ComboboxOption } from "@/components/combobox";
import { useTranslation } from "react-i18next";
const ALL_REPOSITORIES = "__all_repositories__"; const ALL_REPOSITORIES = "__all_repositories__";
@@ -30,19 +31,25 @@ export function IntegrationRepositoryFilter({
onValueChange, onValueChange,
options, options,
ariaLabel, ariaLabel,
allLabel = "All repositories", allLabel,
searchPlaceholder = "Filter repositories...", searchPlaceholder,
emptyMessage = "No repositories found.", emptyMessage,
testId, testId,
dropdownTestId, dropdownTestId,
triggerClassName, triggerClassName,
className, className,
}: IntegrationRepositoryFilterProps) { }: IntegrationRepositoryFilterProps) {
const { t } = useTranslation();
// Resolved here rather than as parameter defaults: a destructuring default is
// evaluated before the component body runs, so `t` is not yet in scope there.
const allRepositoriesLabel = allLabel ?? t("integrations:allRepositories");
const searchPlaceholderText = searchPlaceholder ?? t("integrations:filterRepositories");
const emptyMessageText = emptyMessage ?? t("integrations:noRepositoriesFound");
const comboboxOptions = useMemo<ComboboxOption[]>( const comboboxOptions = useMemo<ComboboxOption[]>(
() => [ () => [
{ {
value: ALL_REPOSITORIES, value: ALL_REPOSITORIES,
label: allLabel, label: allRepositoriesLabel,
keywords: ["all", "repositories", "repos"], keywords: ["all", "repositories", "repos"],
}, },
...options.map((option) => ({ ...options.map((option) => ({
@@ -51,7 +58,7 @@ export function IntegrationRepositoryFilter({
keywords: option.keywords ?? [option.label], keywords: option.keywords ?? [option.label],
})), })),
], ],
[allLabel, options], [allRepositoriesLabel, options],
); );
return ( return (
@@ -64,9 +71,9 @@ export function IntegrationRepositoryFilter({
}} }}
options={comboboxOptions} options={comboboxOptions}
ariaLabel={ariaLabel} ariaLabel={ariaLabel}
placeholder={allLabel} placeholder={allRepositoriesLabel}
searchPlaceholder={searchPlaceholder} searchPlaceholder={searchPlaceholderText}
emptyMessage={emptyMessage} emptyMessage={emptyMessageText}
triggerClassName={triggerClassName} triggerClassName={triggerClassName}
className={className} className={className}
testId={testId} testId={testId}
@@ -94,6 +94,7 @@ function parseMaxInflightTasks(raw: string): number | null | "invalid" {
// `status`, `created`), operators and a project key — and the string is // `status`, `created`), operators and a project key — and the string is
// persisted on the watch and sent upstream verbatim. Translating any of it would // persisted on the watch and sent upstream verbatim. Translating any of it would
// produce a query Jira rejects. // produce a query Jira rejects.
// i18n-exempt: JQL is Jira's query language, not prose.
const DEFAULT_JQL = `project = PROJ AND status = "Open" ORDER BY created DESC`; const DEFAULT_JQL = `project = PROJ AND status = "Open" ORDER BY created DESC`;
function makeEmptyForm(workspaceId: string): FormState { function makeEmptyForm(workspaceId: string): FormState {
@@ -85,6 +85,7 @@ export function jiraIssueWatchPlaceholders(t: TFunction): ScriptPlaceholder[] {
* instruction rather than as UI copy. Same contract as * instruction rather than as UI copy. Same contract as
* `DEFAULT_ISSUE_WATCH_PROMPT` in components/github/issue-watch-placeholders.ts. * `DEFAULT_ISSUE_WATCH_PROMPT` in components/github/issue-watch-placeholders.ts.
*/ */
// i18n-exempt: default watch prompt, persisted and sent verbatim to the agent.
export const DEFAULT_JIRA_ISSUE_WATCH_PROMPT = `You have been assigned a JIRA ticket to work on. export const DEFAULT_JIRA_ISSUE_WATCH_PROMPT = `You have been assigned a JIRA ticket to work on.
**Ticket:** {{issue.url}} **Ticket:** {{issue.url}}
@@ -21,6 +21,7 @@ import type { JiraAuthMethod } from "@/lib/types/jira";
const SESSION_COOKIE_NAME = "cloud.session.token"; const SESSION_COOKIE_NAME = "cloud.session.token";
const SSO_SESSION_COOKIE_NAME = "tenant.session.token"; const SSO_SESSION_COOKIE_NAME = "tenant.session.token";
const ATLASSIAN_COOKIE_HOST = "https://*.atlassian.net"; const ATLASSIAN_COOKIE_HOST = "https://*.atlassian.net";
// i18n-exempt: keyboard shortcut, interpolated so no locale rewrites it. See the comment above.
const DEVTOOLS_SHORTCUT = "Cmd+Opt+I / Ctrl+Shift+I"; const DEVTOOLS_SHORTCUT = "Cmd+Opt+I / Ctrl+Shift+I";
// Atlassian's API-token page. The visible link text is the URL itself, so it is // Atlassian's API-token page. The visible link text is the URL itself, so it is
// an identifier in both roles and never reaches the catalog. // an identifier in both roles and never reaches the catalog.
@@ -107,6 +107,7 @@ export function toTaskPreset(stored: JiraStoredPreset): JiraTaskPreset {
// into user settings on the first save, so a locale-dependent value would be // into user settings on the first save, so a locale-dependent value would be
// written into the user's data and stay there after a locale switch. The prompt // written into the user's data and stay there after a locale switch. The prompt
// templates are also sent to the agent as instructions rather than shown as copy. // templates are also sent to the agent as instructions rather than shown as copy.
// i18n-exempt: persisted preset seed. Localizing needs a key/value split in the preset type.
export const DEFAULT_JIRA_PRESETS: JiraStoredPreset[] = [ export const DEFAULT_JIRA_PRESETS: JiraStoredPreset[] = [
{ {
id: "implement", id: "implement",
@@ -65,6 +65,7 @@ const MY_JIRA_ROUTE = "/jira";
// seeded in one locale and saved unedited would keep that locale's text forever. // seeded in one locale and saved unedited would keep that locale's text forever.
// Same contract as `newPreset` in components/github/action-presets-section.tsx. // Same contract as `newPreset` in components/github/action-presets-section.tsx.
function newPreset(): JiraStoredPreset { function newPreset(): JiraStoredPreset {
// i18n-exempt: persisted, editable preset label. See the comment above.
return { return {
id: `preset_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`, id: `preset_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`,
label: "New action", label: "New action",
@@ -4,7 +4,8 @@ import { StateProvider } from "@/components/state-provider";
import type { Task } from "@/components/kanban-card"; import type { Task } from "@/components/kanban-card";
import type { WorkflowStep } from "@/components/kanban-column"; import type { WorkflowStep } from "@/components/kanban-column";
import type { ForegroundActivity, TaskPendingAction } from "@/lib/types/http"; import type { ForegroundActivity, TaskPendingAction } from "@/lib/types/http";
import { SwimlaneGraphContent } from "./swimlane-graph-content"; import { i18n, t } from "@/lib/i18n";
import { SwimlaneGraphContent, moveTaskAcrossSwimlaneSteps } from "./swimlane-graph-content";
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
@@ -98,3 +99,65 @@ describe("SwimlaneGraphContent — waiting-for-input variants", () => {
expect(container.querySelector(ICON_LOADER2)).toBeNull(); expect(container.querySelector(ICON_LOADER2)).toBeNull();
}); });
}); });
describe("moveTaskAcrossSwimlaneSteps — move failure reporting", () => {
const WORKFLOW_ID = "wf-1";
const TASK_ID = "task-1";
function harness() {
const snapshot = {
tasks: [{ id: TASK_ID, workflowStepId: "step-1", position: 0 }],
};
const snapshots: Record<string, typeof snapshot> = { [WORKFLOW_ID]: snapshot };
const state = {
kanbanMulti: { snapshots },
setWorkflowSnapshot: (id: string, next: typeof snapshot) => {
snapshots[id] = next;
},
};
return { getState: () => state } as never;
}
async function move(rejection: unknown) {
const errors: { message: string }[] = [];
await moveTaskAcrossSwimlaneSteps({
task: { id: TASK_ID, primarySessionId: null } as never,
taskId: TASK_ID,
targetColumnId: "step-2",
workflowId: WORKFLOW_ID,
store: harness(),
moveTaskById: (() => Promise.reject(rejection)) as never,
onMoveError: (error) => errors.push(error),
});
return errors;
}
it("reports the localized fallback when the rejection is not an Error", async () => {
// Asserted under PSEUDO, not English. `toBe(t(key))` in English cannot tell a
// `t()` call from the identical hardcoded literal — a mutation putting
// "Failed to move task" back survived that version of this test. Under a
// locale that transliterates, only a real catalog lookup comes out accented.
await i18n.changeLanguage("pseudo");
try {
const [error] = await move("plain string rejection");
expect(error.message).toBe(t("task:failedToMoveTask"));
expect(error.message).toMatch(/[^\x20-\x7E]/);
} finally {
await i18n.changeLanguage("en");
}
});
it("reports the fallback with the task identity attached", async () => {
expect(await move("plain string rejection")).toEqual([
{ message: t("task:failedToMoveTask"), taskId: TASK_ID, sessionId: null },
]);
});
it("preserves a real Error's message instead of replacing it with copy", async () => {
// A server diagnostic is not copy: swallowing it would leave the user with a
// generic sentence and no way to tell two failures apart.
expect(await move(new Error("step is at its WIP limit"))).toEqual([
{ message: "step is at its WIP limit", taskId: TASK_ID, sessionId: null },
]);
});
});
@@ -26,6 +26,7 @@ import type { KanbanState } from "@/lib/state/slices/kanban/types";
import { useTaskPendingInput } from "@/hooks/use-task-pending-input"; import { useTaskPendingInput } from "@/hooks/use-task-pending-input";
import { compareTasksByCreatedDesc } from "@/lib/kanban/task-order"; import { compareTasksByCreatedDesc } from "@/lib/kanban/task-order";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { t } from "@/lib/i18n";
export type SwimlaneGraphContentProps = { export type SwimlaneGraphContentProps = {
workflowId: string; workflowId: string;
@@ -146,7 +147,15 @@ type SwimlaneGraphDndOptions = {
onMoveError?: (error: MoveTaskError) => void; onMoveError?: (error: MoveTaskError) => void;
}; };
async function moveTaskAcrossSwimlaneSteps({ /**
* Optimistically moves a task, rolling the snapshot back and reporting a message
* when the backend refuses.
*
* Exported for tests: the localized fallback on the non-`Error` branch is the
* kind of copy no rendering test reaches, since it is handed to `onMoveError`
* rather than to the DOM.
*/
export async function moveTaskAcrossSwimlaneSteps({
task, task,
taskId, taskId,
targetColumnId, targetColumnId,
@@ -199,7 +208,7 @@ async function moveTaskAcrossSwimlaneSteps({
.getState() .getState()
.setWorkflowSnapshot(workflowId, { ...currentSnapshot, tasks: originalTasks }); .setWorkflowSnapshot(workflowId, { ...currentSnapshot, tasks: originalTasks });
} }
const message = error instanceof Error ? error.message : "Failed to move task"; const message = error instanceof Error ? error.message : t("task:failedToMoveTask");
onMoveError?.({ message, taskId, sessionId: task.primarySessionId ?? null }); onMoveError?.({ message, taskId, sessionId: task.primarySessionId ?? null });
} }
} }
@@ -33,6 +33,7 @@ import {
useKanbanExternalLinkAvailability, useKanbanExternalLinkAvailability,
} from "@/components/kanban-external-link-availability"; } from "@/components/kanban-external-link-availability";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { t } from "@/lib/i18n";
/** /**
* Sentinel step ID used to collect tasks whose workflow_step_id no longer * Sentinel step ID used to collect tasks whose workflow_step_id no longer
@@ -139,7 +140,7 @@ function useSwimlaneKanbanDnd({ tasks, workflowId, onMoveError }: SwimlaneKanban
.getState() .getState()
.setWorkflowSnapshot(workflowId, { ...currentSnapshot, tasks: originalTasks }); .setWorkflowSnapshot(workflowId, { ...currentSnapshot, tasks: originalTasks });
} }
const message = error instanceof Error ? error.message : "Failed to move task"; const message = error instanceof Error ? error.message : t("task:failedToMoveTask");
onMoveError?.({ message, taskId, sessionId: task.primarySessionId ?? null }); onMoveError?.({ message, taskId, sessionId: task.primarySessionId ?? null });
} }
}, },
@@ -86,6 +86,7 @@ export function linearIssueWatchPlaceholders(t: TFunction): ScriptPlaceholder[]
// would keep that locale's prompt forever, and the agent reads it as an // would keep that locale's prompt forever, and the agent reads it as an
// instruction rather than as UI copy — it also has to keep matching the backend // instruction rather than as UI copy — it also has to keep matching the backend
// .md, which has no locale at all. // .md, which has no locale at all.
// i18n-exempt: default watch prompt, persisted and sent verbatim to the agent.
export const DEFAULT_LINEAR_ISSUE_WATCH_PROMPT = `You have been assigned a Linear issue to work on. export const DEFAULT_LINEAR_ISSUE_WATCH_PROMPT = `You have been assigned a Linear issue to work on.
**Issue:** {{issue.url}} **Issue:** {{issue.url}}
@@ -168,6 +168,7 @@ async function startQuickChatForAgent(
(session.kind ?? "chat") === "chat" && (session.kind ?? "chat") === "chat" &&
!isQuickChatSetupSessionId(session.sessionId), !isQuickChatSetupSessionId(session.sessionId),
).length + 1; ).length + 1;
// i18n-exempt: persisted as the quick-chat task title, same contract as use-config-chat.ts.
const initialName = `${agent?.label || "Agent"} - Chat ${sessionCount}`; const initialName = `${agent?.label || "Agent"} - Chat ${sessionCount}`;
const response = await startQuickChat(workspaceId, { const response = await startQuickChat(workspaceId, {
agent_profile_id: agentId, agent_profile_id: agentId,
@@ -51,6 +51,7 @@ function RunNotice({ code, message }: { code: string; message: string }) {
} }
function useRunReview(taskId: string | null | undefined, sessionId?: string | null) { function useRunReview(taskId: string | null | undefined, sessionId?: string | null) {
const { t } = useTranslation();
const [notice, setNotice] = useState<{ code: string; message: string } | null>(null); const [notice, setNotice] = useState<{ code: string; message: string } | null>(null);
const [starting, setStarting] = useState(false); const [starting, setStarting] = useState(false);
@@ -62,7 +63,7 @@ function useRunReview(taskId: string | null | undefined, sessionId?: string | nu
await runTaskReview({ taskId, sessionId: sessionId ?? undefined }); await runTaskReview({ taskId, sessionId: sessionId ?? undefined });
} catch (error) { } catch (error) {
const code = (error as { code?: string })?.code ?? ""; const code = (error as { code?: string })?.code ?? "";
const message = error instanceof Error ? error.message : "Could not start the review"; const message = error instanceof Error ? error.message : t("review:couldNotStartReview");
setNotice({ code, message }); setNotice({ code, message });
} finally { } finally {
setStarting(false); setStarting(false);
@@ -103,6 +103,7 @@ export function sentryIssueWatchPlaceholders(t: TFunction): ScriptPlaceholder[]
// would keep that locale's prompt forever, and the agent reads it as an // would keep that locale's prompt forever, and the agent reads it as an
// instruction rather than as UI copy — it also has to keep matching the backend // instruction rather than as UI copy — it also has to keep matching the backend
// .md, which has no locale at all. // .md, which has no locale at all.
// i18n-exempt: default watch prompt, persisted and sent verbatim to the agent.
export const DEFAULT_SENTRY_ISSUE_WATCH_PROMPT = `You have been assigned a Sentry issue to triage and fix. export const DEFAULT_SENTRY_ISSUE_WATCH_PROMPT = `You have been assigned a Sentry issue to triage and fix.
**Issue:** {{issue.url}} **Issue:** {{issue.url}}
@@ -40,10 +40,12 @@ const CUSTOM_KIND_OPTIONS: Array<{ value: CustomKind; labelKey: string }> = [
/** Placeholder tokens the launcher substitutes — identifiers, not copy. */ /** Placeholder tokens the launcher substitutes — identifiers, not copy. */
const PLACEHOLDER_HINT = "{cwd} {file} {rel} {line} {column}"; const PLACEHOLDER_HINT = "{cwd} {file} {rel} {line} {column}";
/** Example shell command shown in the field; a value the user types verbatim. */ /** Example shell command shown in the field; a value the user types verbatim. */
// i18n-exempt: shell command example the user copies verbatim.
const COMMAND_EXAMPLE = "code --goto {file}:{line}"; const COMMAND_EXAMPLE = "code --goto {file}:{line}";
/** Example of the URL this kind builds — a URL, not translatable copy. */ /** Example of the URL this kind builds — a URL, not translatable copy. */
const REMOTE_SSH_EXAMPLE = "vscode://vscode-remote/ssh-remote+user@host:/path/file:line"; const REMOTE_SSH_EXAMPLE = "vscode://vscode-remote/ssh-remote+user@host:/path/file:line";
/** Accepted URL schemes. The user types one of these verbatim into the field. */ /** Accepted URL schemes. The user types one of these verbatim into the field. */
// i18n-exempt: editor URL scheme identifiers, not prose.
const EDITOR_SCHEME_EXAMPLES = "vscode, cursor"; const EDITOR_SCHEME_EXAMPLES = "vscode, cursor";
export function getCustomKindLabel(t: TranslateKey, kind: string) { export function getCustomKindLabel(t: TranslateKey, kind: string) {
@@ -16,8 +16,6 @@ import { createExecutorProfile } from "@/lib/api/domains/settings-api";
import type { ExecutorProfile } from "@/lib/types/http"; import type { ExecutorProfile } from "@/lib/types/http";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
const PROFILE_NAME_EXAMPLE = "Production, Development";
type ExecutorProfileDialogProps = { type ExecutorProfileDialogProps = {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
@@ -76,7 +74,7 @@ export function ExecutorProfileDialog({
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
placeholder={t("executors:eGProductionDevelopment", { placeholder={t("executors:eGProductionDevelopment", {
example: PROFILE_NAME_EXAMPLE, example: t("executors:profileNameExample"),
})} })}
autoFocus autoFocus
/> />
@@ -171,6 +171,7 @@ function ToolsPreview() {
* locale — the surrounding sentence is the only copy, and it interpolates them * locale — the surrounding sentence is the only copy, and it interpolates them
* so the pseudo-locale cannot turn them into dead pointers. * so the pseudo-locale cannot turn them into dead pointers.
*/ */
// i18n-exempt: third-party product names.
const SNIPPET_CARDS: Array<{ const SNIPPET_CARDS: Array<{
title: string; title: string;
build: (streamableUrl: string) => string; build: (streamableUrl: string) => string;
@@ -189,6 +190,7 @@ const SNIPPET_CARDS: Array<{
build: buildClaudeCodeConfig, build: buildClaudeCodeConfig,
buildCli: buildClaudeCodeCliCommand, buildCli: buildClaudeCodeCliCommand,
cliTarget: "~/.claude.json", cliTarget: "~/.claude.json",
// i18n-exempt: third-party product names.
}, },
{ title: "Cursor", path: "~/.cursor/mcp.json", build: buildCursorConfig }, { title: "Cursor", path: "~/.cursor/mcp.json", build: buildCursorConfig },
{ {
@@ -198,6 +200,7 @@ const SNIPPET_CARDS: Array<{
buildCli: buildCodexCliCommand, buildCli: buildCodexCliCommand,
cliTarget: "~/.codex/config.toml", cliTarget: "~/.codex/config.toml",
}, },
// i18n-exempt: third-party product names.
{ {
title: "Auggie CLI", title: "Auggie CLI",
path: "~/.augment/settings.json", path: "~/.augment/settings.json",
@@ -213,6 +216,7 @@ const SNIPPET_CARDS: Array<{
globalPath: "~/.config/opencode/opencode.json", globalPath: "~/.config/opencode/opencode.json",
}, },
build: buildOpenCodeConfig, build: buildOpenCodeConfig,
// i18n-exempt: third-party product names.
}, },
{ title: "GitHub Copilot CLI", path: "~/.copilot/mcp-config.json", build: buildCopilotCliConfig }, { title: "GitHub Copilot CLI", path: "~/.copilot/mcp-config.json", build: buildCopilotCliConfig },
]; ];
@@ -46,6 +46,7 @@ export function lspAutoInstallConfigurable(
return language.autoInstallSupported && preferenceLanguages.includes(language.id); return language.autoInstallSupported && preferenceLanguages.includes(language.id);
} }
// i18n-exempt: programming language names are not translated.
export const LSP_LANGUAGE_OPTIONS: LspLanguageOption[] = [ export const LSP_LANGUAGE_OPTIONS: LspLanguageOption[] = [
{ {
id: "typescript", id: "typescript",
@@ -28,6 +28,7 @@ const DEFAULT_IMAGE_TAG = "kandev/multi-agent:latest";
// //
// The kandev backend mounts the agentctl binary into /usr/local/bin/agentctl // The kandev backend mounts the agentctl binary into /usr/local/bin/agentctl
// at container creation time, so users do NOT need to bake it in here. // at container creation time, so users do NOT need to bake it in here.
// i18n-exempt: Dockerfile contents, not prose.
const DEFAULT_DOCKERFILE = `FROM node:22-slim const DEFAULT_DOCKERFILE = `FROM node:22-slim
RUN apt-get update \\ RUN apt-get update \\
@@ -498,6 +498,7 @@ export function SecretsSettings({
workspaceId, workspaceId,
initialItems, initialItems,
}: SecretsSettingsProps) { }: SecretsSettingsProps) {
const { t } = useTranslation();
const state = useSecretsState(scope, workspaceId, initialItems); const state = useSecretsState(scope, workspaceId, initialItems);
const actions = useSecretsActions(state); const actions = useSecretsActions(state);
const globalAdd = useAppStore((s) => s.addSecret); const globalAdd = useAppStore((s) => s.addSecret);
@@ -509,7 +510,7 @@ export function SecretsSettings({
? (workspaceNames.find((workspace) => workspace.id === secret.workspace_id)?.name ?? ? (workspaceNames.find((workspace) => workspace.id === secret.workspace_id)?.name ??
secret.workspace_id ?? secret.workspace_id ??
"workspace") "workspace")
: "Global"; : t("settings:globalScope");
// Route the transfer result by the RETURNED item's scope, never the page // Route the transfer result by the RETURNED item's scope, never the page
// scope: Global targets always land in the Global store (from any page), // scope: Global targets always land in the Global store (from any page),
@@ -6,6 +6,7 @@ import { StorageSettingHelp } from "./storage-setting-help";
import { SettingRow } from "./storage-policy-fields"; import { SettingRow } from "./storage-policy-fields";
// Keep this display snapshot in sync with backend DependencyDirectoryAllowlist. // Keep this display snapshot in sync with backend DependencyDirectoryAllowlist.
// i18n-exempt: dependency directory name on disk.
const DEPENDENCY_DIRECTORIES = [ const DEPENDENCY_DIRECTORIES = [
"node_modules", "node_modules",
"bower_components", "bower_components",
@@ -79,6 +79,7 @@ function openStepDeleteDialog({
// `name` becomes the step's stored name and `color` its Tailwind class — so // `name` becomes the step's stored name and `color` its Tailwind class — so
// neither is translated; translating the name would write a localized string // neither is translated; translating the name would write a localized string
// into the database. // into the database.
// i18n-exempt: persisted workflow step name, same contract as DEFAULT_CUSTOM_STEPS.
const NEW_STEP_DEFAULTS = { name: "New Step", color: "bg-slate-500" } as const; const NEW_STEP_DEFAULTS = { name: "New Step", color: "bg-slate-500" } as const;
function createDraftStep(workflow: Workflow, position: number): WorkflowStep { function createDraftStep(workflow: Workflow, position: number): WorkflowStep {
@@ -71,6 +71,7 @@ export const STEP_COLORS = [
// The `prompt` bodies are deliberately NOT translated: clicking a template // The `prompt` bodies are deliberately NOT translated: clicking a template
// writes the text into `WorkflowStep.prompt`, which is persisted and sent to // writes the text into `WorkflowStep.prompt`, which is persisted and sent to
// the agent verbatim. Only the button `labelKey` is copy. // the agent verbatim. Only the button `labelKey` is copy.
// i18n-exempt: seeded step prompts, persisted and sent verbatim to the agent.
export const PROMPT_TEMPLATES = [ export const PROMPT_TEMPLATES = [
{ {
labelKey: "workflows:templatePlan", labelKey: "workflows:templatePlan",
@@ -6,6 +6,7 @@ import { createWorkflowStepAction, updateWorkflowStepAction } from "@/app/action
// See `workflow-card-actions.ts`: both fields are persisted verbatim, so the // See `workflow-card-actions.ts`: both fields are persisted verbatim, so the
// seeded step name deliberately stays English. // seeded step name deliberately stays English.
// i18n-exempt: persisted workflow step name, same contract as DEFAULT_CUSTOM_STEPS.
const NEW_STEP_DEFAULTS = { name: "New Step", color: "bg-slate-500" } as const; const NEW_STEP_DEFAULTS = { name: "New Step", color: "bg-slate-500" } as const;
function fallbackErrorMessage(error: unknown): string { function fallbackErrorMessage(error: unknown): string {
@@ -554,7 +554,7 @@ export function useGitHubUrlErrorEffect(fs: DialogFormState, open: boolean) {
} }
const parsed = parseGitHubAnyUrl(trimmed); const parsed = parseGitHubAnyUrl(trimmed);
if (!parsed) { if (!parsed) {
setGitHubUrlError("Invalid GitHub URL - expected github.com/owner/repo or .../pull/123"); setGitHubUrlError(t("task:invalidGitHubUrl"));
return; return;
} }
setGitHubUrlError(null); setGitHubUrlError(null);
+12 -7
View File
@@ -13,6 +13,7 @@
import { Badge } from "@kandev/ui/badge"; import { Badge } from "@kandev/ui/badge";
import type { ForegroundActivity } from "@/lib/types/http"; import type { ForegroundActivity } from "@/lib/types/http";
import { useTranslation } from "react-i18next";
export type TaskHeaderProps = { export type TaskHeaderProps = {
identifier?: string | null; identifier?: string | null;
@@ -51,12 +52,12 @@ function resolveBadgeLabel(
hasPendingClarification: boolean, hasPendingClarification: boolean,
hasPendingPermission: boolean, hasPendingPermission: boolean,
): string | null { ): string | null {
if (hasPendingPermission) return "Permission requested"; if (hasPendingPermission) return "task:sessionStatusPermissionRequested";
if (hasPendingClarification) return "Waiting for input"; if (hasPendingClarification) return "task:sessionStatusWaitingForInput";
if (foregroundActivity === "generating") return "Generating"; if (foregroundActivity === "generating") return "task:sessionStatusGenerating";
if (foregroundActivity === "background") return "Background running"; if (foregroundActivity === "background") return "task:sessionStatusBackgroundRunning";
if (state === "WAITING_FOR_INPUT") return "Waiting for input"; if (state === "WAITING_FOR_INPUT") return "task:sessionStatusWaitingForInput";
return state ?? null; return null;
} }
export function TaskHeader({ export function TaskHeader({
@@ -69,12 +70,16 @@ export function TaskHeader({
hasPendingClarification = false, hasPendingClarification = false,
hasPendingPermission = false, hasPendingPermission = false,
}: TaskHeaderProps) { }: TaskHeaderProps) {
const badgeLabel = resolveBadgeLabel( const { t } = useTranslation();
const badgeKey = resolveBadgeLabel(
state, state,
foregroundActivity, foregroundActivity,
hasPendingClarification, hasPendingClarification,
hasPendingPermission, hasPendingPermission,
); );
// `state` is a workflow-step name the user chose, so it is data and is
// rendered verbatim; only the derived activity badges are copy.
const badgeLabel = badgeKey ? t(badgeKey) : (state ?? null);
return ( return (
<div className="flex items-center gap-3 min-w-0"> <div className="flex items-center gap-3 min-w-0">
{identifier && ( {identifier && (
@@ -12,18 +12,24 @@ type GitCredentialSnapshot = {
transport?: unknown; transport?: unknown;
}; };
const workspaceMethodLabels: Record<string, string> = { // Catalog keys, resolved per call: this module is imported once, so a `t()`
pat: "workspace personal access token", // here would freeze at the boot locale (docs/i18n.md).
gh_cli: "workspace GitHub CLI account", const workspaceMethodKeys: Record<string, string> = {
github_app_installation: "workspace GitHub App", pat: "task:credentialWorkspacePat",
gh_cli: "task:credentialWorkspaceGhCli",
github_app_installation: "task:credentialWorkspaceGithubApp",
}; };
const transportLabels: Record<string, string> = { const transportKeys: Record<string, string> = {
managed_https: "Kandev-managed GitHub HTTPS and gh", managed_https: "task:credentialTransportManagedHttps",
profile_token: "executor-profile token", profile_token: "task:credentialTransportProfileToken",
executor_selected: "executor Git and gh credentials", executor_selected: "task:credentialTransportExecutorSelected",
}; };
function label(keys: Record<string, string>, id: string, fallbackKey: string): string {
return t(keys[id] ?? fallbackKey);
}
function readSnapshot( function readSnapshot(
metadata: Record<string, unknown> | null | undefined, metadata: Record<string, unknown> | null | undefined,
): GitCredentialSnapshot | null { ): GitCredentialSnapshot | null {
@@ -45,21 +51,21 @@ export function getGitCredentialDisplay(
switch (snapshot.source) { switch (snapshot.source) {
case "workspace": case "workspace":
return { return {
source: "Managed workspace identity", source: t("task:credentialSourceWorkspace"),
detail: workspaceMethodLabels[workspaceMethod] ?? "workspace GitHub credentials", detail: label(workspaceMethodKeys, workspaceMethod, "task:credentialWorkspaceFallback"),
transport: transportLabels[transport] ?? "Kandev-managed task credentials", transport: label(transportKeys, transport, "task:credentialTransportManagedFallback"),
}; };
case "executor_profile": case "executor_profile":
return { return {
source: "Executor-profile token", source: t("task:credentialSourceExecutorProfile"),
detail: actor || "An explicit GH_TOKEN or GITHUB_TOKEN", detail: actor || t("task:credentialExplicitToken"),
transport: transportLabels[transport] ?? "executor-profile token", transport: label(transportKeys, transport, "task:credentialTransportProfileToken"),
}; };
case "executor": case "executor":
return { return {
source: "Executor credentials", source: t("task:credentialSourceExecutor"),
detail: actor || t("task:credentialsAvailableInExecutor"), detail: actor || t("task:credentialsAvailableInExecutor"),
transport: transportLabels[transport] ?? "executor Git and gh credentials", transport: label(transportKeys, transport, "task:credentialTransportExecutorSelected"),
}; };
default: default:
return null; return null;
@@ -42,6 +42,7 @@ import {
} from "./changes-panel-helpers"; } from "./changes-panel-helpers";
import type { CommitDetailTarget, OpenDiffOptions } from "./changes-diff-target"; import type { CommitDetailTarget, OpenDiffOptions } from "./changes-diff-target";
import type { PRDiffFile, TaskPR } from "@/lib/types/github"; import type { PRDiffFile, TaskPR } from "@/lib/types/github";
import { gitOperationLabel } from "@/hooks/use-git-with-feedback";
import { getGitCredentialDisplay } from "./changes-git-credential-display"; import { getGitCredentialDisplay } from "./changes-git-credential-display";
import type { RemoteContributionRelation } from "@/hooks/domains/session/remote-contribution-relation"; import type { RemoteContributionRelation } from "@/hooks/domains/session/remote-contribution-relation";
import { import {
@@ -56,6 +57,7 @@ import { useRemoteContributionResolution } from "./use-remote-contribution-resol
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
function useChangesPanelStoreData() { function useChangesPanelStoreData() {
const { t } = useTranslation();
const activeTaskId = useAppStore((state) => state.tasks.activeTaskId); const activeTaskId = useAppStore((state) => state.tasks.activeTaskId);
const activeSessionId = useEnvironmentSessionId(); const activeSessionId = useEnvironmentSessionId();
const taskTitle = useAppStore((state) => { const taskTitle = useAppStore((state) => {
@@ -68,9 +70,12 @@ function useChangesPanelStoreData() {
const activeSessionMetadata = useAppStore((state) => const activeSessionMetadata = useAppStore((state) =>
activeSessionId ? state.taskSessions.items[activeSessionId]?.metadata : undefined, activeSessionId ? state.taskSessions.items[activeSessionId]?.metadata : undefined,
); );
// `t` is a dependency even though the helper resolves through the module-level
// translator: without it the memo returns the labels built under the previous
// locale and the panel never restates them.
const gitCredentialDisplay = useMemo( const gitCredentialDisplay = useMemo(
() => getGitCredentialDisplay(activeSessionMetadata), () => getGitCredentialDisplay(activeSessionMetadata),
[activeSessionMetadata], [activeSessionMetadata, t],
); );
return { return {
activeTaskId, activeTaskId,
@@ -155,18 +160,19 @@ function usePerRepoCallbacks(
vcsDialogs: ReturnType<typeof useVcsDialogs>, vcsDialogs: ReturnType<typeof useVcsDialogs>,
gitHandlers: ReturnType<typeof useChangesGitHandlers>, gitHandlers: ReturnType<typeof useChangesGitHandlers>,
) { ) {
const { t } = useTranslation();
return useMemo( return useMemo(
() => ({ () => ({
onRepoStageAll: (repo: string) => { onRepoStageAll: (repo: string) => {
gitHandlers.handleGitOperation( gitHandlers.handleGitOperation(
() => git.stage(undefined, repo), () => git.stage(undefined, repo),
repo ? `Stage all (${repo})` : "Stage all", gitOperationLabel(t, "task:stageAll", repo),
); );
}, },
onRepoUnstageAll: (repo: string) => { onRepoUnstageAll: (repo: string) => {
gitHandlers.handleGitOperation( gitHandlers.handleGitOperation(
() => git.unstage(undefined, repo), () => git.unstage(undefined, repo),
repo ? `Unstage all (${repo})` : "Unstage all", gitOperationLabel(t, "task:unstageAll", repo),
); );
}, },
onRepoCommit: (repo: string) => vcsDialogs.openCommitDialog(repo), onRepoCommit: (repo: string) => vcsDialogs.openCommitDialog(repo),
@@ -75,7 +75,7 @@ function buildPlanContextItem(params: BuildContextItemsParams): ContextItem | nu
return { return {
kind: "plan", kind: "plan",
id: PLAN_CONTEXT_PATH, id: PLAN_CONTEXT_PATH,
label: "Plan", label: t("task:panelPlan"),
taskId: taskId ?? undefined, taskId: taskId ?? undefined,
pinned: planFile?.pinned, pinned: planFile?.pinned,
onRemove: sid ? () => removeContextFile(sid, PLAN_CONTEXT_PATH) : undefined, onRemove: sid ? () => removeContextFile(sid, PLAN_CONTEXT_PATH) : undefined,
@@ -86,12 +86,12 @@ export function resolveInputPlaceholder(
hasClarification: boolean, hasClarification: boolean,
needsRecovery: boolean, needsRecovery: boolean,
): string { ): string {
if (needsRecovery) return "Choose a recovery option above to continue..."; if (needsRecovery) return t("task:composerPlaceholderRecovery");
if (hasClarification) return "Queue instructions while the question is pending..."; if (hasClarification) return t("task:composerPlaceholderClarification");
if (isAgentBusy) return "Queue instructions to the agent..."; if (isAgentBusy) return t("task:composerPlaceholderBusy");
if (activeDocumentType === "file") return "Continue working on the file..."; if (activeDocumentType === "file") return t("task:composerPlaceholderFile");
if (planModeEnabled) return "Continue working on the plan..."; if (planModeEnabled) return t("task:composerPlaceholderPlan");
return "Continue working on the task..."; return t("task:composerPlaceholderTask");
} }
type PlaceholderArgs = { type PlaceholderArgs = {
@@ -107,7 +107,7 @@ type PlaceholderArgs = {
/** Picks the composer placeholder: an explicit override wins, then the /** Picks the composer placeholder: an explicit override wins, then the
* "switching agent" state, then {@link resolveInputPlaceholder}. */ * "switching agent" state, then {@link resolveInputPlaceholder}. */
function pickInputPlaceholder(a: PlaceholderArgs): string { function pickInputPlaceholder(a: PlaceholderArgs): string {
if (a.isMoving) return "Switching agent..."; if (a.isMoving) return t("task:composerPlaceholderSwitchingAgent");
// Preserve the prior `??` semantics: an explicit "" override (caller wants // Preserve the prior `??` semantics: an explicit "" override (caller wants
// no placeholder text) must NOT fall through to the resolver default. // no placeholder text) must NOT fall through to the resolver default.
if (a.override !== undefined) return a.override; if (a.override !== undefined) return a.override;
@@ -142,10 +142,10 @@ function FailedSessionBanner({
taskId, taskId,
sessionId, sessionId,
workspaceId, workspaceId,
message = "This agent has stopped.", message,
detail, detail,
resumeLabel = "Resume", resumeLabel,
resumingLabel = "Resuming...", resumingLabel,
}: { }: {
showDialog: boolean; showDialog: boolean;
onShowDialog: (open: boolean) => void; onShowDialog: (open: boolean) => void;
@@ -158,6 +158,11 @@ function FailedSessionBanner({
resumingLabel?: string; resumingLabel?: string;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
// Resolved in the body, not as parameter defaults: those are evaluated before
// the component body, where `t` does not yet exist (docs/i18n.md).
const bannerMessage = message ?? t("task:agentHasStopped");
const resumeText = resumeLabel ?? t("task:resume");
const resumingText = resumingLabel ?? t("task:resuming");
const [isResuming, setIsResuming] = useState(false); const [isResuming, setIsResuming] = useState(false);
const [isStartingFresh, setIsStartingFresh] = useState(false); const [isStartingFresh, setIsStartingFresh] = useState(false);
@@ -196,7 +201,7 @@ function FailedSessionBanner({
<div className="flex items-center justify-between gap-3 px-4 py-3"> <div className="flex items-center justify-between gap-3 px-4 py-3">
<div className="flex min-w-0 items-center gap-2 text-sm text-muted-foreground"> <div className="flex min-w-0 items-center gap-2 text-sm text-muted-foreground">
<IconAlertTriangle className="h-4 w-4 text-orange-500 shrink-0" /> <IconAlertTriangle className="h-4 w-4 text-orange-500 shrink-0" />
<span className="truncate">{message}</span> <span className="truncate">{bannerMessage}</span>
{detail && <span className="shrink-0 text-xs text-muted-foreground">({detail})</span>} {detail && <span className="shrink-0 text-xs text-muted-foreground">({detail})</span>}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -213,7 +218,7 @@ function FailedSessionBanner({
disabled={isResuming || !profileExists} disabled={isResuming || !profileExists}
> >
<IconPlayerPlay className="h-3.5 w-3.5" /> <IconPlayerPlay className="h-3.5 w-3.5" />
{isResuming ? resumingLabel : resumeLabel} {isResuming ? resumingText : resumeText}
</Button> </Button>
</span> </span>
</TooltipTrigger> </TooltipTrigger>
@@ -23,6 +23,7 @@ import { formatShortcut } from "@/lib/keyboard/utils";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { MCPAttachmentHistory } from "@/lib/state/slices/session-runtime/types"; import type { MCPAttachmentHistory } from "@/lib/state/slices/session-runtime/types";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { t } from "@/lib/i18n";
type SubmitButtonProps = { type SubmitButtonProps = {
isAgentBusy: boolean; isAgentBusy: boolean;
@@ -51,8 +52,8 @@ function submitTooltipDescription(
submitDisabledReason?: string, submitDisabledReason?: string,
) { ) {
if (submitDisabledReason) return submitDisabledReason; if (submitDisabledReason) return submitDisabledReason;
if (isAgentBusy) return "Queue message"; if (isAgentBusy) return t("task:submitTooltipQueueMessage");
if (planModeEnabled) return "Request plan changes"; if (planModeEnabled) return t("task:submitTooltipRequestPlanChanges");
return undefined; return undefined;
} }
@@ -11,6 +11,7 @@ import { useCustomPrompts } from "@/hooks/domains/settings/use-custom-prompts";
import { isDirectory, getFileName } from "@/lib/utils/file-path"; import { isDirectory, getFileName } from "@/lib/utils/file-path";
import type { ContextFile } from "@/lib/state/context-files-store"; import type { ContextFile } from "@/lib/state/context-files-store";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { t } from "@/lib/i18n";
type ContextPopoverProps = { type ContextPopoverProps = {
open: boolean; open: boolean;
@@ -25,7 +26,12 @@ type ContextPopoverProps = {
const FILE_SEARCH_DEBOUNCE = 300; const FILE_SEARCH_DEBOUNCE = 300;
const PLAN_CONTEXT_FILE: ContextFile = { path: "plan:context", name: "Plan", pinned: true }; // `name` is display copy; it is resolved per call rather than at module load.
const planContextFile = (): ContextFile => ({
path: "plan:context",
name: t("task:panelPlan"),
pinned: true,
});
function FileResultsList({ function FileResultsList({
isLoading, isLoading,
@@ -228,11 +234,11 @@ function ContextPopoverList({
{!query && ( {!query && (
<div <div
className="flex items-center gap-2 px-3 py-1.5 hover:bg-muted/50 cursor-pointer" className="flex items-center gap-2 px-3 py-1.5 hover:bg-muted/50 cursor-pointer"
onClick={() => onToggleFile(PLAN_CONTEXT_FILE)} onClick={() => onToggleFile(planContextFile())}
> >
<Checkbox <Checkbox
checked={planContextEnabled} checked={planContextEnabled}
onCheckedChange={() => onToggleFile(PLAN_CONTEXT_FILE)} onCheckedChange={() => onToggleFile(planContextFile())}
className="h-3.5 w-3.5" className="h-3.5 w-3.5"
/> />
<IconListCheck className="h-4 w-4 text-muted-foreground shrink-0" /> <IconListCheck className="h-4 w-4 text-muted-foreground shrink-0" />
@@ -21,6 +21,7 @@ export const ENVIRONMENT_SETUP_FAILED_KEY = "task:environmentSetupFailed";
// backend: `environment preparation failed:` (manager_launch.go), the launch // backend: `environment preparation failed:` (manager_launch.go), the launch
// race ("already has an agent running" / "race resolved during register"), // race ("already has an agent running" / "race resolved during register"),
// container launch failures, and branch/fresh-branch prep errors. // container launch failures, and branch/fresh-branch prep errors.
// i18n-exempt: substrings matched against backend error text, not copy.
const SETUP_FAILURE_SIGNATURES = [ const SETUP_FAILURE_SIGNATURES = [
"environment preparation failed", "environment preparation failed",
"failed to launch container", "failed to launch container",
@@ -45,7 +45,7 @@ export function ClarificationRequestMessage({ comment }: ClarificationRequestMes
// Get the answer summary for display // Get the answer summary for display
const getAnswerSummary = () => { const getAnswerSummary = () => {
const response = metadata.response; const response = metadata.response;
if (!response) return "No selection"; if (!response) return t("task:clarificationNoSelection");
const parts: string[] = []; const parts: string[] = [];
@@ -64,7 +64,7 @@ export function ClarificationRequestMessage({ comment }: ClarificationRequestMes
parts.push(`"${response.custom_text}"`); parts.push(`"${response.custom_text}"`);
} }
return parts.length > 0 ? parts.join(", ") : "No selection"; return parts.length > 0 ? parts.join(", ") : t("task:clarificationNoSelection");
}; };
return ( return (
@@ -5,8 +5,9 @@ import { IdChip, KandevBody, KandevRow, KeyValueRow, ListItemRow, SummaryDot } f
import { pickArray, pickNumber, pickString } from "./parse"; import { pickArray, pickNumber, pickString } from "./parse";
import type { KandevRenderer } from "./types"; import type { KandevRenderer } from "./types";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { t } from "@/lib/i18n";
const DEFAULT_TITLE = "Walkthrough"; // i18n-exempt: prefix matched against the agent's raw tool result, not copy.
const RESULT_JSON_PREFIX = "Walkthrough saved:"; const RESULT_JSON_PREFIX = "Walkthrough saved:";
type WalkthroughStepLike = Record<string, unknown>; type WalkthroughStepLike = Record<string, unknown>;
@@ -39,7 +40,7 @@ function resultPayload(result: unknown): unknown {
function titleFrom(args: unknown, result: unknown): string { function titleFrom(args: unknown, result: unknown): string {
const payload = resultPayload(result); const payload = resultPayload(result);
return pickString(payload, "title") ?? pickString(args, "title") ?? DEFAULT_TITLE; return pickString(payload, "title") ?? pickString(args, "title") ?? t("common:walkthrough");
} }
function stepsFrom(args: unknown, result: unknown): WalkthroughStepLike[] { function stepsFrom(args: unknown, result: unknown): WalkthroughStepLike[] {
@@ -430,6 +430,7 @@ function useMessageCommentActions({
rootRef: RefObject<HTMLDivElement | null>; rootRef: RefObject<HTMLDivElement | null>;
close: () => void; close: () => void;
}) { }) {
const { t } = useTranslation();
const addComment = useCommentsStore((state) => state.addComment); const addComment = useCommentsStore((state) => state.addComment);
const updateComment = useCommentsStore((state) => state.updateComment); const updateComment = useCommentsStore((state) => state.updateComment);
const removeComment = useCommentsStore((state) => state.removeComment); const removeComment = useCommentsStore((state) => state.removeComment);
@@ -449,7 +450,7 @@ function useMessageCommentActions({
const renderedText = rootRef.current?.textContent ?? ""; const renderedText = rootRef.current?.textContent ?? "";
const comment = commentFromTarget(message, sessionId, target, renderedText, feedback); const comment = commentFromTarget(message, sessionId, target, renderedText, feedback);
if (!comment) { if (!comment) {
setSaveError("The agent response changed. Select the text again."); setSaveError(t("task:agentResponseChangedSelectAgain"));
return null; return null;
} }
setSaveError(null); setSaveError(null);
@@ -29,9 +29,10 @@ function formatStatusLabel(
ended: boolean, ended: boolean,
endReason: string | undefined, endReason: string | undefined,
) { ) {
if (!ended) return "watching"; if (!ended) return t("task:monitorStatusWatching");
if (status === "cancelled" || endReason === "session_restart") return "ended (session restart)"; if (status === "cancelled" || endReason === "session_restart")
return "ended"; return t("task:monitorStatusEndedSessionRestart");
return t("task:monitorStatusEnded");
} }
type MonitorViewModel = { type MonitorViewModel = {
@@ -10,6 +10,7 @@ import { cn } from "@/lib/utils";
import { isTerminalToolCallStatus, normalizeToolCallStatus } from "@/lib/utils/tool-call-status"; import { isTerminalToolCallStatus, normalizeToolCallStatus } from "@/lib/utils/tool-call-status";
import type { ShellExecOutputSummary } from "../types"; import type { ShellExecOutputSummary } from "../types";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { t } from "@/lib/i18n";
type ShellOutputDisclosureProps = { type ShellOutputDisclosureProps = {
sessionId: string; sessionId: string;
@@ -20,9 +21,9 @@ type ShellOutputDisclosureProps = {
function formatOutputSize(summary: ShellExecOutputSummary | undefined) { function formatOutputSize(summary: ShellExecOutputSummary | undefined) {
const bytes = (summary?.stdout_bytes ?? 0) + (summary?.stderr_bytes ?? 0); const bytes = (summary?.stdout_bytes ?? 0) + (summary?.stderr_bytes ?? 0);
if (bytes === 0) return "Output"; if (bytes === 0) return t("task:output");
if (bytes < 1024) return `Output · ${bytes} B`; if (bytes < 1024) return `${t("task:output")} · ${bytes} B`;
return `Output · ${(bytes / 1024).toFixed(bytes < 10_240 ? 1 : 0)} KB`; return `${t("task:output")} · ${(bytes / 1024).toFixed(bytes < 10_240 ? 1 : 0)} KB`;
} }
function OutputTranscript({ output }: { output: ShellCommandOutput }) { function OutputTranscript({ output }: { output: ShellCommandOutput }) {
@@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next";
// `value` is the highlight.js language id and the language names are proper // `value` is the highlight.js language id and the language names are proper
// nouns, so both stay untranslated. Only the "no language" option is copy, and // nouns, so both stay untranslated. Only the "no language" option is copy, and
// it travels as a catalog key because this table is built at module load. // it travels as a catalog key because this table is built at module load.
// i18n-exempt: programming language names are not translated.
const CODE_LANGUAGES: Array<{ value: string; label?: string; labelKey?: string }> = [ const CODE_LANGUAGES: Array<{ value: string; label?: string; labelKey?: string }> = [
{ value: "", labelKey: "task:codeLanguagePlain" }, { value: "", labelKey: "task:codeLanguagePlain" },
{ value: "javascript", label: "JavaScript" }, { value: "javascript", label: "JavaScript" },
@@ -155,7 +155,7 @@ function computeDerivedState(params: {
// transition for local quick-chat sessions doesn't deserve its own // transition for local quick-chat sessions doesn't deserve its own
// tooltip — the editor is disabled, that's the signal. // tooltip — the editor is disabled, that's the signal.
const submitDisabledReason = const submitDisabledReason =
isDisabled && params.isPreparingEnvironment ? "The agent is still being set up." : undefined; isDisabled && params.isPreparingEnvironment ? t("task:agentStillBeingSetUp") : undefined;
const hasPendingComments = !!( const hasPendingComments = !!(
params.pendingCommentsByFile && Object.keys(params.pendingCommentsByFile).length > 0 params.pendingCommentsByFile && Object.keys(params.pendingCommentsByFile).length > 0
); );
@@ -38,6 +38,7 @@ import type { ImagePasteIssue } from "./clipboard-attachments";
import { deleteAttachment, uploadAttachment } from "@/lib/api/domains/attachment-api"; import { deleteAttachment, uploadAttachment } from "@/lib/api/domains/attachment-api";
import { ApiError } from "@/lib/api/client"; import { ApiError } from "@/lib/api/client";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { t } from "@/lib/i18n";
type UseChatInputStateProps = { type UseChatInputStateProps = {
sessionId: string | null; sessionId: string | null;
@@ -278,7 +279,7 @@ function useAttachments(sessionId: string | null, workspaceId?: string | null) {
} catch (error) { } catch (error) {
updateAttachment(attachment.id, { updateAttachment(attachment.id, {
uploadStatus: "failed", uploadStatus: "failed",
uploadError: error instanceof ApiError ? error.message : "Upload failed", uploadError: error instanceof ApiError ? error.message : t("task:attachmentUploadFailed"),
}); });
} }
}, },
@@ -1,5 +1,6 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { CHAT_PANEL_FALLBACK_LABEL, resolveChatPanelTitle } from "./dockview-panel-content"; import { t } from "@/lib/i18n";
import { resolveChatPanelTitle } from "./dockview-panel-content";
/** /**
* Regression: the generic "chat" placeholder dockview panel used to fall back * Regression: the generic "chat" placeholder dockview panel used to fall back
@@ -8,27 +9,34 @@ import { CHAT_PANEL_FALLBACK_LABEL, resolveChatPanelTitle } from "./dockview-pan
* gate inside `useChatSessionTitle` that suppressed the agent label for the * gate inside `useChatSessionTitle` that suppressed the agent label for the
* non-session-scoped placeholder. The pure resolver imported here is the place * non-session-scoped placeholder. The pure resolver imported here is the place
* the gate would have to be re-introduced, so this test pins the behavior. * the gate would have to be re-introduced, so this test pins the behavior.
*
* The fallback is asserted through the catalog rather than as the literal
* "Agent": what this test is about is WHICH value is chosen, not what that
* value's English happens to be, and pinning the copy here would make an
* ordinary wording change fail four unrelated assertions.
*/ */
const FALLBACK = () => t("task:panelAgent");
describe("resolveChatPanelTitle", () => { describe("resolveChatPanelTitle", () => {
it("returns the agent label when one is provided", () => { it("returns the agent label when one is provided", () => {
expect(resolveChatPanelTitle("Opus")).toBe("Opus"); expect(resolveChatPanelTitle("Opus", t)).toBe("Opus");
}); });
it("falls back to the generic 'Agent' label when null", () => { it("falls back to the generic 'Agent' label when null", () => {
expect(resolveChatPanelTitle(null)).toBe(CHAT_PANEL_FALLBACK_LABEL); expect(resolveChatPanelTitle(null, t)).toBe(FALLBACK());
}); });
it("falls back to the generic 'Agent' label when undefined", () => { it("falls back to the generic 'Agent' label when undefined", () => {
expect(resolveChatPanelTitle(undefined)).toBe(CHAT_PANEL_FALLBACK_LABEL); expect(resolveChatPanelTitle(undefined, t)).toBe(FALLBACK());
}); });
it("falls back to the generic 'Agent' label when the agent label is empty", () => { it("falls back to the generic 'Agent' label when the agent label is empty", () => {
expect(resolveChatPanelTitle("")).toBe(CHAT_PANEL_FALLBACK_LABEL); expect(resolveChatPanelTitle("", t)).toBe(FALLBACK());
}); });
it("uses the agent label verbatim — does not coerce or relabel valid names", () => { it("uses the agent label verbatim — does not coerce or relabel valid names", () => {
for (const name of ["Mock", "Claude Code", "GPT-5", "amp"]) { for (const name of ["Mock", "Claude Code", "GPT-5", "amp"]) {
expect(resolveChatPanelTitle(name)).toBe(name); expect(resolveChatPanelTitle(name, t)).toBe(name);
} }
}); });
}); });
@@ -27,14 +27,17 @@ import { TaskPlanPanel } from "./task-plan-panel";
import { TerminalPanel } from "./terminal-panel"; import { TerminalPanel } from "./terminal-panel";
import { TodosContent } from "./todos-panel-content"; import { TodosContent } from "./todos-panel-content";
import { VscodePanel } from "./vscode-panel"; import { VscodePanel } from "./vscode-panel";
import { useTranslation } from "react-i18next";
export const CHAT_PANEL_FALLBACK_LABEL = "Agent"; export function resolveChatPanelTitle(
agentLabel: string | null | undefined,
export function resolveChatPanelTitle(agentLabel: string | null | undefined): string { translate: (key: string) => string,
return agentLabel || CHAT_PANEL_FALLBACK_LABEL; ): string {
return agentLabel || translate("task:panelAgent");
} }
function useChatSessionTitle(panelId: string, sessionId: string | null) { function useChatSessionTitle(panelId: string, sessionId: string | null) {
const { t } = useTranslation();
const agentLabel = useAppStore((state) => { const agentLabel = useAppStore((state) => {
if (!sessionId) return null; if (!sessionId) return null;
const session = state.taskSessions.items[sessionId]; const session = state.taskSessions.items[sessionId];
@@ -49,9 +52,11 @@ function useChatSessionTitle(panelId: string, sessionId: string | null) {
const parts = profile.label.split(" \u2022 "); const parts = profile.label.split(" \u2022 ");
return parts[1] || parts[0] || profile.label; return parts[1] || parts[0] || profile.label;
}); });
// `t` is a dependency: a locale switch changes the title with no change to
// the panel or the label, and without it the tab keeps the old language.
useEffect(() => { useEffect(() => {
setPanelTitle(panelId, resolveChatPanelTitle(agentLabel)); setPanelTitle(panelId, resolveChatPanelTitle(agentLabel, t));
}, [panelId, agentLabel]); }, [panelId, agentLabel, t]);
} }
function ChatContent({ panelId, params }: { panelId: string; params: Record<string, unknown> }) { function ChatContent({ panelId, params }: { panelId: string; params: Record<string, unknown> }) {
@@ -127,6 +132,7 @@ function DiffViewerContent({
} }
function ChangesContent({ panelId }: { panelId: string }) { function ChangesContent({ panelId }: { panelId: string }) {
const { t } = useTranslation();
const addDiffViewerPanel = useDockviewStore((s) => s.addDiffViewerPanel); const addDiffViewerPanel = useDockviewStore((s) => s.addDiffViewerPanel);
const addFileDiffPanel = useDockviewStore((s) => s.addFileDiffPanel); const addFileDiffPanel = useDockviewStore((s) => s.addFileDiffPanel);
const addCommitDetailPanel = useDockviewStore((s) => s.addCommitDetailPanel); const addCommitDetailPanel = useDockviewStore((s) => s.addCommitDetailPanel);
@@ -138,9 +144,10 @@ function ChangesContent({ panelId }: { panelId: string }) {
const totalCount = useSessionChangesCount(activeSessionId); const totalCount = useSessionChangesCount(activeSessionId);
useEffect(() => { useEffect(() => {
const title = totalCount > 0 ? `Changes (${totalCount})` : "Changes"; const title =
totalCount > 0 ? `${t("task:panelChanges")} (${totalCount})` : t("task:panelChanges");
setPanelTitle(panelId, title); setPanelTitle(panelId, title);
}, [totalCount, panelId]); }, [totalCount, panelId, t]);
const handleEditFile = useCallback( const handleEditFile = useCallback(
(path: string, repo?: string) => openFile(path, repo), (path: string, repo?: string) => openFile(path, repo),
+5 -2
View File
@@ -46,6 +46,7 @@ import { setPanelTitle, panelPortalManager } from "@/lib/layout/panel-portal-man
import { getWebSocketClient } from "@/lib/ws/connection"; import { getWebSocketClient } from "@/lib/ws/connection";
import { usePortalSlot } from "@/lib/layout/panel-portal-host"; import { usePortalSlot } from "@/lib/layout/panel-portal-host";
import { ENV_SCOPED_DOCKVIEW_COMPONENTS } from "@/lib/state/dockview-env-scoped-components"; import { ENV_SCOPED_DOCKVIEW_COMPONENTS } from "@/lib/state/dockview-env-scoped-components";
import { useTranslation } from "react-i18next";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// PORTAL SLOT — generic dockview component that adopts a persistent portal // PORTAL SLOT — generic dockview component that adopts a persistent portal
@@ -304,6 +305,7 @@ function DiffViewerContent({
} }
function ChangesContent({ panelId }: { panelId: string }) { function ChangesContent({ panelId }: { panelId: string }) {
const { t } = useTranslation();
const addDiffViewerPanel = useDockviewStore((s) => s.addDiffViewerPanel); const addDiffViewerPanel = useDockviewStore((s) => s.addDiffViewerPanel);
const addFileDiffPanel = useDockviewStore((s) => s.addFileDiffPanel); const addFileDiffPanel = useDockviewStore((s) => s.addFileDiffPanel);
const addCommitDetailPanel = useDockviewStore((s) => s.addCommitDetailPanel); const addCommitDetailPanel = useDockviewStore((s) => s.addCommitDetailPanel);
@@ -318,9 +320,10 @@ function ChangesContent({ panelId }: { panelId: string }) {
const totalCount = fileCount + commits.length; const totalCount = fileCount + commits.length;
useEffect(() => { useEffect(() => {
const title = totalCount > 0 ? `Changes (${totalCount})` : "Changes"; const title =
totalCount > 0 ? `${t("task:panelChanges")} (${totalCount})` : t("task:panelChanges");
setPanelTitle(panelId, title); setPanelTitle(panelId, title);
}, [totalCount, panelId]); }, [totalCount, panelId, t]);
const handleEditFile = useCallback( const handleEditFile = useCallback(
(path: string, repo?: string) => openFile(path, repo), (path: string, repo?: string) => openFile(path, repo),
@@ -16,6 +16,7 @@ export type EnsureSessionErrorInfo = {
}; };
// Matches the backend's exact validation message in task_http_handlers.go / task_ws_handlers.go. // Matches the backend's exact validation message in task_http_handlers.go / task_ws_handlers.go.
// i18n-exempt: matches the backend's exact validation message. See the comment above.
const AGENT_PROFILE_MISSING_HINT = "agent_profile_id is required"; const AGENT_PROFILE_MISSING_HINT = "agent_profile_id is required";
export function describeEnsureError( export function describeEnsureError(
@@ -132,6 +132,7 @@ function activateNewSession(
} }
function useSessionOptions(taskId: string) { function useSessionOptions(taskId: string) {
const { t } = useTranslation();
const { sessions, loadSessions } = useTaskSessions(taskId); const { sessions, loadSessions } = useTaskSessions(taskId);
const agentProfiles = useAppStore((s) => s.agentProfiles.items); const agentProfiles = useAppStore((s) => s.agentProfiles.items);
useEffect(() => { useEffect(() => {
@@ -144,10 +145,10 @@ function useSessionOptions(taskId: string) {
); );
return sorted.map((s, idx) => { return sorted.map((s, idx) => {
const profile = agentProfiles.find((p: { id: string }) => p.id === s.agent_profile_id); const profile = agentProfiles.find((p: { id: string }) => p.id === s.agent_profile_id);
const name = profile ? agentProfileDisplayLabel(profile) : "Agent"; const name = profile ? agentProfileDisplayLabel(profile) : t("task:panelAgent");
return { id: s.id, label: name, index: idx + 1, agentName: profile?.agent_name }; return { id: s.id, label: name, index: idx + 1, agentName: profile?.agent_name };
}); });
}, [sessions, agentProfiles]); }, [sessions, agentProfiles, t]);
} }
function isMissingCompatibleProfile( function isMissingCompatibleProfile(
@@ -170,6 +170,7 @@ function useSubtaskDialogState(parentTaskId: string, parentSessions: TaskSession
} }
function useSessionOptions(taskId: string) { function useSessionOptions(taskId: string) {
const { t } = useTranslation();
const { sessions, loadSessions } = useTaskSessions(taskId); const { sessions, loadSessions } = useTaskSessions(taskId);
const agentProfiles = useAppStore((s) => s.agentProfiles.items); const agentProfiles = useAppStore((s) => s.agentProfiles.items);
useEffect(() => { useEffect(() => {
@@ -183,10 +184,10 @@ function useSessionOptions(taskId: string) {
return sorted.map((s, idx) => { return sorted.map((s, idx) => {
const profile = agentProfiles.find((p: { id: string }) => p.id === s.agent_profile_id); const profile = agentProfiles.find((p: { id: string }) => p.id === s.agent_profile_id);
const parts = profile?.label.split(" \u2022 "); const parts = profile?.label.split(" \u2022 ");
const name = parts?.[1] || parts?.[0] || "Agent"; const name = parts?.[1] || parts?.[0] || t("task:panelAgent");
return { id: s.id, label: name, index: idx + 1, agentName: profile?.agent_name }; return { id: s.id, label: name, index: idx + 1, agentName: profile?.agent_name };
}); });
}, [sessions, agentProfiles]); }, [sessions, agentProfiles, t]);
} }
function useExecutorProfiles( function useExecutorProfiles(
+4 -2
View File
@@ -34,6 +34,7 @@ import { TabRenameInput } from "./tab-rename-input";
import { useTabMaximizeOnDoubleClick } from "./use-tab-maximize"; import { useTabMaximizeOnDoubleClick } from "./use-tab-maximize";
import { SessionTabCloseAction } from "./session-tab-close-action"; import { SessionTabCloseAction } from "./session-tab-close-action";
import { useSessionTabDelete } from "./use-session-tab-delete"; import { useSessionTabDelete } from "./use-session-tab-delete";
import { useTranslation } from "react-i18next";
function useSessionTabState(sessionId: string | undefined) { function useSessionTabState(sessionId: string | undefined) {
const isPrimary = useAppStore((state) => { const isPrimary = useAppStore((state) => {
@@ -137,6 +138,7 @@ function useSessionRenameCommitter(
currentName: string | null, currentName: string | null,
onDone: () => void, onDone: () => void,
) { ) {
const { t } = useTranslation();
const appStoreApi = useAppStoreApi(); const appStoreApi = useAppStoreApi();
const { toast } = useToast(); const { toast } = useToast();
return useCallback( return useCallback(
@@ -156,8 +158,8 @@ function useSessionRenameCommitter(
} catch (error) { } catch (error) {
console.error("rename session:", error); console.error("rename session:", error);
toast({ toast({
title: "Rename failed", title: t("task:renameFailed"),
description: error instanceof Error ? error.message : "Unknown error", description: error instanceof Error ? error.message : t("common:unknownError"),
variant: "error", variant: "error",
}); });
} }
@@ -69,6 +69,7 @@ function useTerminalInit({
disableStdin: isReadOnlyMode, disableStdin: isReadOnlyMode,
convertEol: isReadOnlyMode, convertEol: isReadOnlyMode,
fontSize: fontSize ?? (isReadOnlyMode ? 12 : 13), fontSize: fontSize ?? (isReadOnlyMode ? 12 : 13),
// i18n-exempt: CSS font stack.
fontFamily: fontFamily || 'Menlo, Monaco, "Courier New", monospace', fontFamily: fontFamily || 'Menlo, Monaco, "Courier New", monospace',
macOptionIsMeta: true, macOptionIsMeta: true,
theme: getTerminalTheme(terminalRef.current), theme: getTerminalTheme(terminalRef.current),
@@ -2,6 +2,7 @@
import { createContext, useContext, memo } from "react"; import { createContext, useContext, memo } from "react";
import { PanelRoot, PanelBody } from "./panel-primitives"; import { PanelRoot, PanelBody } from "./panel-primitives";
import { useTranslation } from "react-i18next";
type TaskArchivedState = { type TaskArchivedState = {
isArchived: boolean; isArchived: boolean;
@@ -24,15 +25,18 @@ export function useArchivedTaskState() {
} }
export const ArchivedPanelPlaceholder = memo(function ArchivedPanelPlaceholder({ export const ArchivedPanelPlaceholder = memo(function ArchivedPanelPlaceholder({
message = "Workspace not available - this task is archived", message,
}: { }: {
message?: string; message?: string;
}) { }) {
const { t } = useTranslation();
// Resolved in the body: a parameter default runs before the hook is in scope.
const text = message ?? t("task:workspaceUnavailableArchived");
return ( return (
<PanelRoot> <PanelRoot>
<PanelBody> <PanelBody>
<div className="flex items-center justify-center h-full text-muted-foreground text-xs"> <div className="flex items-center justify-center h-full text-muted-foreground text-xs">
{message} {text}
</div> </div>
</PanelBody> </PanelBody>
</PanelRoot> </PanelRoot>
@@ -184,6 +184,7 @@ function useMoveToStep(store: StoreApi) {
} }
function useArchiveActions(store: StoreApi) { function useArchiveActions(store: StoreApi) {
const { t } = useTranslation();
const archiveAndSwitch = useArchiveAndSwitchTask({ useLayoutSwitch: true }); const archiveAndSwitch = useArchiveAndSwitchTask({ useLayoutSwitch: true });
const [archivingTask, setArchivingTask] = useState<{ const [archivingTask, setArchivingTask] = useState<{
id: string; id: string;
@@ -199,7 +200,7 @@ function useArchiveActions(store: StoreApi) {
const task = findSidebarTask(state, taskId); const task = findSidebarTask(state, taskId);
setArchivingTask({ setArchivingTask({
id: taskId, id: taskId,
title: task?.title ?? "this task", title: task?.title ?? t("task:thisTask"),
executorType: task?.primaryExecutorType, executorType: task?.primaryExecutorType,
}); });
}, },
@@ -239,6 +240,7 @@ function useDeleteActions(
store: StoreApi, store: StoreApi,
removeTaskFromBoard: ReturnType<typeof useTaskRemoval>["removeTaskFromBoard"], removeTaskFromBoard: ReturnType<typeof useTaskRemoval>["removeTaskFromBoard"],
) { ) {
const { t } = useTranslation();
const { deleteTaskById } = useTaskActions(); const { deleteTaskById } = useTaskActions();
const [deletingTask, setDeletingTask] = useState<{ const [deletingTask, setDeletingTask] = useState<{
id: string; id: string;
@@ -253,7 +255,7 @@ function useDeleteActions(
const task = findSidebarTask(state, taskId); const task = findSidebarTask(state, taskId);
setDeletingTask({ setDeletingTask({
id: taskId, id: taskId,
title: task?.title ?? "this task", title: task?.title ?? t("task:thisTask"),
executorType: task?.primaryExecutorType, executorType: task?.primaryExecutorType,
}); });
}, },
+2 -1
View File
@@ -16,6 +16,7 @@ import { CloseTerminalConfirmDialog } from "./close-terminal-confirm-dialog";
import { TabRenameInput } from "./tab-rename-input"; import { TabRenameInput } from "./tab-rename-input";
import { markTerminalPanelTerminateClose } from "./dockview-layout-setup"; import { markTerminalPanelTerminateClose } from "./dockview-layout-setup";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { t } from "@/lib/i18n";
/** /**
* Custom dockview tab for terminal panels. * Custom dockview tab for terminal panels.
@@ -56,7 +57,7 @@ function pickDisplayName(
fallback: string, fallback: string,
): string { ): string {
if (shell?.customName && shell.customName !== "") return shell.customName; if (shell?.customName && shell.customName !== "") return shell.customName;
if (shell?.kind === "ordinary") return "Terminal"; if (shell?.kind === "ordinary") return t("task:panelTerminal");
if (shell?.label) return shell.label; if (shell?.label) return shell.label;
return fallback; return fallback;
} }

Some files were not shown because too many files have changed in this diff Show More