diff --git a/apps/web/app/settings/workspace/workspace-edit-client.tsx b/apps/web/app/settings/workspace/workspace-edit-client.tsx index 754d988c83..358ac697d2 100644 --- a/apps/web/app/settings/workspace/workspace-edit-client.tsx +++ b/apps/web/app/settings/workspace/workspace-edit-client.tsx @@ -1,6 +1,7 @@ "use client"; import { useState } from "react"; +import { Trans, useTranslation } from "react-i18next"; import Link from "@/components/routing/app-link"; import { useRouter } from "@/lib/routing/client-router"; import { runWithNavigationBlockerBypassed } from "@/lib/routing/navigation-guard"; @@ -20,6 +21,7 @@ import { DialogTitle, } from "@kandev/ui/dialog"; import { updateWorkspaceAction, deleteWorkspaceAction } from "@/app/actions/workspaces"; +import type { TFunction } from "i18next"; import type { Executor } from "@/lib/types/http"; import type { AgentProfileOption, WorkspaceState } from "@/lib/state/slices"; @@ -38,15 +40,16 @@ export function WorkspaceEditClient({ workspaceId }: WorkspaceEditClientProps) { const workspace = useAppStore( (state) => state.workspaces.items.find((item: Workspace) => item.id === workspaceId) ?? null, ); + const { t } = useTranslation(); if (!workspace) { return (
-

Workspace not found

+

{t("workspaces:workspaceNotFound")}

@@ -63,6 +66,11 @@ type WorkspaceEditFormProps = { type SelectFieldProps = { label: string; + // The placeholder was `Select ${label.toLowerCase()}`. Lowercasing a translated + // label is an English-only transformation, and building the sentence at the + // call site leaves a translator with no way to reorder it — so it is its own + // message, passed in by the caller. + placeholder: string; value: string; isDirty: boolean; onChange: (v: string) => void; @@ -73,6 +81,7 @@ type SelectFieldProps = { function SelectField({ label, + placeholder, value, isDirty, onChange, @@ -80,15 +89,16 @@ function SelectField({ emptyLabel, emptyValue, }: SelectFieldProps) { + const { t } = useTranslation(); return (
@@ -184,23 +197,24 @@ type WorkspaceLinksCardProps = { }; function WorkspaceLinksCard({ workspaceId }: WorkspaceLinksCardProps) { + const { t } = useTranslation(); return ( - Workspace Links + {t("workspaces:workspaceLinks")}
@@ -226,17 +240,20 @@ function DeleteWorkspaceCard({ setDeleteConfirmText, onDelete, }: DeleteWorkspaceCardProps) { + const { t } = useTranslation(); return ( <> - Delete Workspace + {t("workspaces:deleteWorkspace")}
-

Delete this workspace

-

This action cannot be undone.

+

{t("workspaces:deleteThisWorkspace")}

+

+ {t("workspaces:thisActionCannotBeUndone")} +

@@ -254,14 +271,21 @@ function DeleteWorkspaceCard({ - Delete Workspace + {t("workspaces:deleteWorkspace")} - Type the workspace name {workspaceName} to - confirm deletion. This action cannot be undone. + {/* The workspace name is user data and is what the input is compared + against, so it travels as an interpolated value — never as part + of the message a translator edits. */} + + +
- + setDeleteDialogOpen(false)} className="cursor-pointer" > - Cancel + {t("common:cancel")} @@ -334,6 +358,7 @@ type WorkspaceSaveHandlerOptions = { setWorkspaces: (items: Workspace[]) => void; saveWorkspaceRequest: SaveRequestLike; toast: ReturnType["toast"]; + t: TFunction; }; function buildSaveHandler({ @@ -347,6 +372,7 @@ function buildSaveHandler({ setWorkspaces, saveWorkspaceRequest, toast, + t, }: WorkspaceSaveHandlerOptions) { return async () => { if (!isDirty) return; @@ -381,8 +407,8 @@ function buildSaveHandler({ ); } catch (error) { toast({ - title: "Failed to save workspace", - description: error instanceof Error ? error.message : "Request failed", + title: t("workspaces:failedToSaveWorkspace"), + description: error instanceof Error ? error.message : t("common:requestFailed"), variant: "error", }); throw error; @@ -393,6 +419,7 @@ function buildSaveHandler({ function useWorkspaceEditForm(workspace: Workspace) { const router = useRouter(); const { toast } = useToast(); + const { t } = useTranslation(); const [currentWorkspace, setCurrentWorkspace] = useState(workspace); const [workspaceNameDraft, setWorkspaceNameDraft] = useState(workspace.name ?? ""); const [defaultExecutorId, setDefaultExecutorId] = useState(workspace.default_executor_id ?? ""); @@ -432,6 +459,7 @@ function useWorkspaceEditForm(workspace: Workspace) { setWorkspaces, saveWorkspaceRequest, toast, + t, }); const handleDeleteWorkspace = async () => { @@ -442,8 +470,8 @@ function useWorkspaceEditForm(workspace: Workspace) { runWithNavigationBlockerBypassed(() => router.push("/settings/workspace")); } catch (error) { toast({ - title: "Failed to delete workspace", - description: error instanceof Error ? error.message : "Request failed", + title: t("workspaces:failedToDeleteWorkspace"), + description: error instanceof Error ? error.message : t("common:requestFailed"), variant: "error", }); } @@ -506,6 +534,7 @@ function WorkspaceEditForm({ workspace }: WorkspaceEditFormProps) { handleDiscard, handleDeleteWorkspace, } = useWorkspaceEditForm(workspace); + const { t } = useTranslation(); useSettingsSaveContributor({ id: `workspace:${currentWorkspace.id}`, @@ -516,7 +545,7 @@ function WorkspaceEditForm({ workspace }: WorkspaceEditFormProps) { }), isDirty, canSave: Boolean(workspaceNameDraft.trim()), - invalidReason: workspaceNameDraft.trim() ? undefined : "Workspace name is required.", + invalidReason: workspaceNameDraft.trim() ? undefined : t("workspaces:workspaceNameIsRequired"), save: handleSave, discard: handleDiscard, }); @@ -526,7 +555,7 @@ function WorkspaceEditForm({ workspace }: WorkspaceEditFormProps) {

{currentWorkspace.name}

- Manage workspace details and jump into workflows or repositories. + {t("workspaces:manageWorkspaceDetails")}

diff --git a/apps/web/app/settings/workspace/workspace-not-found-card.tsx b/apps/web/app/settings/workspace/workspace-not-found-card.tsx index 30bf74f05a..d128a74667 100644 --- a/apps/web/app/settings/workspace/workspace-not-found-card.tsx +++ b/apps/web/app/settings/workspace/workspace-not-found-card.tsx @@ -1,16 +1,18 @@ "use client"; +import { useTranslation } from "react-i18next"; import { Button } from "@kandev/ui/button"; import { Card, CardContent } from "@kandev/ui/card"; export function WorkspaceNotFoundCard({ onBack }: { onBack: () => void }) { + const { t } = useTranslation(); return (
-

Workspace not found

+

{t("workspaces:workspaceNotFound")}

diff --git a/apps/web/app/settings/workspace/workspace-repositories-client.tsx b/apps/web/app/settings/workspace/workspace-repositories-client.tsx index bfdbad8520..e20affd0b6 100644 --- a/apps/web/app/settings/workspace/workspace-repositories-client.tsx +++ b/apps/web/app/settings/workspace/workspace-repositories-client.tsx @@ -1,6 +1,8 @@ "use client"; import { useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import type { TFunction } from "i18next"; import Link from "@/components/routing/app-link"; import { useRouter } from "@/lib/routing/client-router"; import { IconGitBranch } from "@tabler/icons-react"; @@ -57,6 +59,8 @@ function buildDraftRepo( manualRepoPath: string, ): RepositoryItem { const path = selectedRepo?.path ?? manualValidation.path ?? manualRepoPath.trim(); + // "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. const name = selectedRepo?.name ?? path.split("/").filter(Boolean).slice(-1)[0] ?? "New Repository"; return { @@ -120,6 +124,8 @@ async function saveNewRepository( dev_script: repo.dev_script, copy_files: repo.copy_files, }); + // Like the repository name above, the seeded script name and command are + // PERSISTED and sent to a shell verbatim, so both stay in English. const scripts = await Promise.all( repo.scripts.map((script, index) => createRepositoryScriptAction({ @@ -305,6 +311,7 @@ function useRepositoryHandlers({ function useDiscoverDialog( workspace: Workspace | null, toast: ReturnType["toast"], + t: TFunction, ) { const [localRepoDialogOpen, setLocalRepoDialogOpen] = useState(false); const [discoveredRepositories, setDiscoveredRepositories] = useState([]); @@ -330,8 +337,8 @@ function useDiscoverDialog( setDiscoveredRepositories(result.repositories); } catch (error) { toast({ - title: "Failed to discover repositories", - description: error instanceof Error ? error.message : "Request failed", + title: t("workspaces:failedToDiscoverRepositories"), + description: error instanceof Error ? error.message : t("common:requestFailed"), variant: "error", }); } @@ -355,21 +362,23 @@ function useDiscoverDialog( setManualValidation({ status: "success", isValid: true, - message: "Valid git repository", + message: t("workspaces:validGitRepository"), path: result.path, }); else + // `result.message` is the backend's diagnostic and stays English by + // design; only the fallback for a missing payload is copy. setManualValidation({ status: "error", isValid: false, - message: result.message || "Invalid repository path", + message: result.message || t("workspaces:invalidRepositoryPath"), path: result.path, }); } catch (error) { setManualValidation({ status: "error", isValid: false, - message: error instanceof Error ? error.message : "Request failed", + message: error instanceof Error ? error.message : t("common:requestFailed"), }); } }; @@ -413,6 +422,7 @@ function useWorkspaceRepositoriesPage( ) { const router = useRouter(); const { toast } = useToast(); + const { t } = useTranslation(); const clearRepositoryScripts = useAppStore((state) => state.clearRepositoryScripts); const [repositoryItems, setRepositoryItems] = useState(repositories); const [savedRepositoryItems, setSavedRepositoryItems] = @@ -439,7 +449,7 @@ function useWorkspaceRepositoriesPage( handleDeleteRepository, } = handlers; - const discover = useDiscoverDialog(workspace, toast); + const discover = useDiscoverDialog(workspace, toast, t); const { localRepoDialogOpen, setLocalRepoDialogOpen, @@ -507,6 +517,7 @@ export function WorkspaceRepositoriesClient({ repositories, }: WorkspaceRepositoriesClientProps) { const state = useWorkspaceRepositoriesPage(workspace, repositories); + const { t } = useTranslation(); const { router, repositoryItems, @@ -544,21 +555,23 @@ export function WorkspaceRepositoriesClient({

{workspace.name}

- Manage repositories connected to this workspace. + {t("workspaces:manageRepositoriesConnected")}

} - title="Repositories" - description="Repositories in this workspace" + title={t("workspaces:repositories")} + description={t("workspaces:repositoriesInThisWorkspace")} action={ } > diff --git a/apps/web/app/settings/workspace/workspace-repositories-dialog.tsx b/apps/web/app/settings/workspace/workspace-repositories-dialog.tsx index 458278e61f..f6c89ae39b 100644 --- a/apps/web/app/settings/workspace/workspace-repositories-dialog.tsx +++ b/apps/web/app/settings/workspace/workspace-repositories-dialog.tsx @@ -1,5 +1,6 @@ "use client"; +import { useTranslation } from "react-i18next"; import { IconLoader2 } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { Input } from "@kandev/ui/input"; @@ -50,15 +51,18 @@ function RepoListContent({ selectedRepoPath: string | null; onSelectRepoPath: (path: string) => void; }) { + const { t } = useTranslation(); if (isLoading) return (
- Scanning repositories... + {t("workspaces:scanningRepositories")}
); if (filteredRepositories.length === 0) - return
No repositories found.
; + return ( +
{t("workspaces:noRepositoriesFound")}
+ ); return ( <> {filteredRepositories.map((repo) => ( @@ -93,20 +97,19 @@ export function DiscoverRepoDialog({ canSave, onConfirm, }: DiscoverRepoDialogProps) { + const { t } = useTranslation(); return ( - Add Local Repository - - Select a discovered repository or provide an absolute path to validate. - + {t("workspaces:addLocalRepository")} + {t("workspaces:discoverRepositoryDescription")}
- + onRepoSearchChange(e.target.value)} /> @@ -120,8 +123,10 @@ export function DiscoverRepoDialog({
- +
+ {/* An absolute filesystem path is the value shape this field + expects, not prose — left in English deliberately. */} - {isValidating ? "Checking..." : "Validate"} + {isValidating ? t("workspaces:checking") : t("workspaces:validate")}
{manualValidation.status === "error" && ( @@ -146,10 +151,10 @@ export function DiscoverRepoDialog({
diff --git a/apps/web/app/settings/workspace/workspaces-page-client.tsx b/apps/web/app/settings/workspace/workspaces-page-client.tsx index 84e2f48573..37e1ce96b5 100644 --- a/apps/web/app/settings/workspace/workspaces-page-client.tsx +++ b/apps/web/app/settings/workspace/workspaces-page-client.tsx @@ -1,6 +1,7 @@ "use client"; import { useState } from "react"; +import { useTranslation } from "react-i18next"; import Link from "@/components/routing/app-link"; import { IconFolder, IconPlus, IconChevronRight } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; @@ -34,29 +35,30 @@ function AddWorkspaceForm({ isLoading, status, }: AddWorkspaceFormProps) { + const { t } = useTranslation(); return (
- + onNameChange(e.target.value)} - placeholder="My Workspace" + placeholder={t("workspaces:workspaceNamePlaceholder")} required autoFocus />
@@ -67,6 +69,7 @@ function AddWorkspaceForm({ } function WorkspaceListItem({ workspace }: { workspace: Workspace }) { + const { t } = useTranslation(); return ( @@ -79,7 +82,7 @@ function WorkspaceListItem({ workspace }: { workspace: Workspace }) {

{workspace.name}

- Workflows managed in this workspace + {t("workspaces:workflowsManagedInThisWorkspace")}
@@ -98,6 +101,7 @@ export function WorkspacesPageClient() { const [newWorkspaceName, setNewWorkspaceName] = useState(""); const createRequest = useRequest(createWorkspaceAction); const { toast } = useToast(); + const { t } = useTranslation(); const handleAddWorkspace = async (e: React.FormEvent) => { e.preventDefault(); @@ -132,8 +136,8 @@ export function WorkspacesPageClient() { setIsAdding(false); } catch (error) { toast({ - title: "Failed to create workspace", - description: error instanceof Error ? error.message : "Request failed", + title: t("workspaces:failedToCreateWorkspace"), + description: error instanceof Error ? error.message : t("common:requestFailed"), variant: "error", }); } @@ -143,12 +147,14 @@ export function WorkspacesPageClient() {
-

Workspaces

-

Manage your workspaces and workflows

+

{t("common:workspaces")}

+

+ {t("workspaces:manageYourWorkspacesAndWorkflows")} +

@@ -175,7 +181,7 @@ export function WorkspacesPageClient() {

- No workspaces configured. Add your first workspace to get started. + {t("workspaces:noWorkspacesConfigured")}

diff --git a/apps/web/components/settings/repository-branch-template-help.test.tsx b/apps/web/components/settings/repository-branch-template-help.test.tsx new file mode 100644 index 0000000000..1130312664 --- /dev/null +++ b/apps/web/components/settings/repository-branch-template-help.test.tsx @@ -0,0 +1,93 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; +import { i18n } from "@/lib/i18n"; +import { RepositoryBranchTemplateHelp } from "./repository-branch-template-help"; + +const TRIGGER_LABEL = "Branch template placeholders"; + +// Restoring the locale is load-bearing: `changeLanguage` mutates the shared +// instance `vitest.setup.ts` initializes, so leaving it on pseudo would leak +// into every test that runs after this file. +afterEach(async () => { + cleanup(); + await i18n.changeLanguage("en"); +}); + +/** + * The six placeholder tokens are substituted by exact string match in + * `RenderTaskBranchName` (apps/backend/internal/worktree/config.go), so they must + * stay in English while the description beside each one is translated. The + * description now arrives through a catalog key held in the table, which means a + * mistyped key renders the key itself and nothing fails — these assertions pin + * the token-to-description pairing and the interpolated example values. + */ +function openHelp() { + render(); + // HoverCard content is only mounted while open; the trigger is a button, so + // focus is enough in happy-dom. + screen.getByRole("button", { name: TRIGGER_LABEL }).focus(); +} + +/** + * The trigger is icon-only, so its `aria-label` is the ONLY copy a screen-reader + * user gets from it — and attribute copy has no second check. The pseudo-locale + * oracle walks text nodes (`docs/i18n.md`, "It cannot see copy that is not a + * text node"), so reverting this to an English literal leaves no trace on + * screen. The locale-switch assertion below covers the two failures lint cannot: + * a hardcoded literal, and a `t()` frozen at module scope. Both render the same + * English forever; only a switch tells them apart. + */ +describe("RepositoryBranchTemplateHelp", () => { + it("labels the trigger for screen readers", () => { + render(); + + expect(screen.getByRole("button", { name: TRIGGER_LABEL })).toBeTruthy(); + }); + + it("resolves the trigger label through the catalog on a locale switch", async () => { + render(); + await i18n.changeLanguage("pseudo"); + + const label = screen.getByRole("button").getAttribute("aria-label") ?? ""; + expect(label).not.toBe(TRIGGER_LABEL); + expect(label.length).toBeGreaterThan(0); + }); + + it("pairs each untranslated token with its description and example", async () => { + openHelp(); + + const term = await screen.findByText("{title}"); + const description = term.nextElementSibling; + expect(description?.textContent).toBe( + "Task title sanitized to lowercase ASCII, hyphen-separated, max 20 chars. Example: fix-login-flow.", + ); + }); + + it("keeps the ticket examples as interpolated values", async () => { + openHelp(); + + const term = await screen.findByText("{ticket}"); + expect(term.nextElementSibling?.textContent).toBe( + "Task identifier first; otherwise Jira, Linear, GitHub issue, or GitHub PR metadata. Examples: KAN-123, #42.", + ); + }); + + it("renders the alias description that carries no example", async () => { + openHelp(); + + const term = await screen.findByText("{issue_key}"); + expect(term.nextElementSibling?.textContent).toBe( + "Alias for ticket. Use whichever name reads better in your template.", + ); + }); + + it("keeps the literal-prefix example inside a code element", async () => { + openHelp(); + + const code = await screen.findByText("feature/{ticket}-{title}"); + expect(code.tagName).toBe("CODE"); + expect(code.parentElement?.textContent).toBe( + "Write literal prefixes directly, for example feature/{ticket}-{title}.", + ); + }); +}); diff --git a/apps/web/components/settings/repository-branch-template-help.tsx b/apps/web/components/settings/repository-branch-template-help.tsx index 87cc2133de..1dcd828a0a 100644 --- a/apps/web/components/settings/repository-branch-template-help.tsx +++ b/apps/web/components/settings/repository-branch-template-help.tsx @@ -1,39 +1,55 @@ "use client"; +import { Trans, useTranslation } from "react-i18next"; import { IconInfoCircle } from "@tabler/icons-react"; import { HoverCard, HoverCardContent, HoverCardTrigger } from "@kandev/ui/hover-card"; +/** + * The placeholder tokens are PROTOCOL, not copy: `RenderTaskBranchName` in + * `apps/backend/internal/worktree/config.go` substitutes each one by exact + * string match, so a translated `{title}` would be emitted into the branch name + * verbatim. Only the description beside each token is copy, and it lives in the + * catalog under `descriptionKey` so it resolves at render rather than at import. + * + * The example values are branch-name output — sanitized lowercase ASCII, a + * ticket key, a UUID — so they travel as interpolated VALUES rather than sitting + * inside the message a translator edits. + */ const branchTemplatePlaceholders = [ - [ - "{title}", - "Task title sanitized to lowercase ASCII, hyphen-separated, max 20 chars. Example: fix-login-flow.", - ], - [ - "{title_full}", - "Same sanitizing as title, but max 80 chars. Example: fix-login-flow-after-session-timeout.", - ], - [ - "{ticket}", - "Task identifier first; otherwise Jira, Linear, GitHub issue, or GitHub PR metadata. Examples: KAN-123, #42.", - ], - ["{issue_key}", "Alias for ticket. Use whichever name reads better in your template."], - [ - "{task_id}", - "Kandev task UUID, sanitized for branch names. Example: 1f1cf094-db3c-4f42-b425-2cc14a2f7c74.", - ], - [ - "{suffix}", - "Short random suffix. Optional, but recommended to avoid branch name clashes. Example: x7p9.", - ], + { + token: "{title}", + descriptionKey: "workspaces:branchTemplateTitle", + example: "fix-login-flow", + }, + { + token: "{title_full}", + descriptionKey: "workspaces:branchTemplateTitleFull", + example: "fix-login-flow-after-session-timeout", + }, + { + token: "{ticket}", + descriptionKey: "workspaces:branchTemplateTicket", + example: "KAN-123, #42", + }, + { token: "{issue_key}", descriptionKey: "workspaces:branchTemplateIssueKey", example: "" }, + { + token: "{task_id}", + descriptionKey: "workspaces:branchTemplateTaskId", + example: "1f1cf094-db3c-4f42-b425-2cc14a2f7c74", + }, + { token: "{suffix}", descriptionKey: "workspaces:branchTemplateSuffix", example: "x7p9" }, ] as const; +const LITERAL_PREFIX_EXAMPLE = "feature/{ticket}-{title}"; + export function RepositoryBranchTemplateHelp() { + const { t } = useTranslation(); return (