-
Workspaces
-
Manage your workspaces and workflows
+
{t("common:workspaces")}
+
+ {t("workspaces:manageYourWorkspacesAndWorkflows")}
+
setIsAdding(true)}>
- Add Workspace
+ {t("workspaces:addWorkspace")}
@@ -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 (
@@ -42,17 +58,18 @@ export function RepositoryBranchTemplateHelp() {
- Write literal prefixes directly, for example{" "}
-
- feature/{"{ticket}"}-{"{title}"}
-
- .
+
+
+
- {branchTemplatePlaceholders.map(([name, description]) => (
-
-
{name}
-
{description}
+ {branchTemplatePlaceholders.map(({ token, descriptionKey, example }) => (
+
+
{token}
+ {t(descriptionKey, { example })}
))}
diff --git a/apps/web/components/settings/repository-card.test.tsx b/apps/web/components/settings/repository-card.test.tsx
new file mode 100644
index 0000000000..8dde634c5a
--- /dev/null
+++ b/apps/web/components/settings/repository-card.test.tsx
@@ -0,0 +1,182 @@
+import { cleanup, render, screen } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { i18n } from "@/lib/i18n";
+import { RepositoryCard } from "./repository-card";
+import { SettingsSaveProvider } from "./settings-save-provider";
+import { ToastProvider } from "@/components/toast-provider";
+import type { Repository, RepositoryScript } from "@/lib/types/http";
+
+const CLOSE_EDITOR_LABEL = "Close repository editor";
+
+// 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");
+});
+
+type RepositoryWithScripts = Repository & { scripts: RepositoryScript[] };
+
+/**
+ * The collapsed card's whole summary is built by `buildRepoPreviewData` /
+ * `buildRepoScriptsSummary` — plain functions holding no JSX, which
+ * `i18next/no-literal-string` never inspects. The script count also used to be
+ * inflected with an English `s` at the call site. Neither shape fails loudly, so
+ * these assertions pin the rendered text.
+ */
+function repository(overrides: Partial
= {}): RepositoryWithScripts {
+ return {
+ id: "repo-1",
+ workspace_id: "ws-1",
+ name: "kandev",
+ source_type: "local",
+ local_path: "/home/dev/kandev",
+ provider: "",
+ provider_repo_id: "",
+ provider_host: "",
+ provider_owner: "",
+ provider_name: "",
+ default_branch: "main",
+ worktree_branch_prefix: "feature/",
+ worktree_branch_template: "feature/{title}-{suffix}",
+ pull_before_worktree: true,
+ setup_script: "",
+ cleanup_script: "",
+ dev_script: "",
+ copy_files: "",
+ created_at: "",
+ updated_at: "",
+ scripts: [],
+ ...overrides,
+ } as RepositoryWithScripts;
+}
+
+function script(id: string): RepositoryScript {
+ return {
+ id,
+ repository_id: "repo-1",
+ name: id,
+ command: "echo hi",
+ position: 0,
+ created_at: "",
+ updated_at: "",
+ } as RepositoryScript;
+}
+
+function renderCard(repo: RepositoryWithScripts, autoOpen = false) {
+ return render(
+
+
+
+
+ ,
+ );
+}
+
+describe("RepositoryCard preview", () => {
+ it("uses the singular form for exactly one custom script", () => {
+ renderCard(repository({ scripts: [script("a")] }));
+
+ expect(screen.getByText("1 custom script")).toBeTruthy();
+ });
+
+ it("uses the plural form for more than one custom script", () => {
+ renderCard(repository({ scripts: [script("a"), script("b")] }));
+
+ expect(screen.getByText("2 custom scripts")).toBeTruthy();
+ });
+
+ it("labels a local repository and shows its path verbatim", () => {
+ renderCard(repository());
+
+ expect(screen.getByText("Local")).toBeTruthy();
+ expect(screen.getByText("/home/dev/kandev")).toBeTruthy();
+ });
+
+ it("falls back to translated copy when a local repository has no path", () => {
+ renderCard(repository({ local_path: "" }));
+
+ expect(screen.getByText("Local path not set")).toBeTruthy();
+ });
+
+ it("shows the owner/name slug for a remote repository", () => {
+ renderCard(
+ repository({
+ source_type: "remote",
+ provider_owner: "kdlbs",
+ provider_name: "kandev",
+ }),
+ );
+
+ expect(screen.getByText("Remote")).toBeTruthy();
+ expect(screen.getByText("kdlbs/kandev")).toBeTruthy();
+ });
+
+ it("falls back to translated copy when a remote repository has no slug", () => {
+ renderCard(repository({ source_type: "remote" }));
+
+ expect(screen.getByText("Remote repository")).toBeTruthy();
+ });
+
+ it("names an unnamed repository", () => {
+ renderCard(repository({ name: "" }));
+
+ expect(screen.getByText("Untitled repository")).toBeTruthy();
+ });
+
+ it("lists the three built-in script chips only when their scripts are set", () => {
+ renderCard(
+ repository({
+ setup_script: "npm ci",
+ cleanup_script: "rm -rf tmp",
+ dev_script: "npm run dev",
+ }),
+ );
+
+ expect(screen.getByText("build script")).toBeTruthy();
+ expect(screen.getByText("cleanup script")).toBeTruthy();
+ expect(screen.getByText("dev script")).toBeTruthy();
+ expect(screen.queryByText("No custom scripts")).toBeNull();
+ });
+});
+
+/**
+ * The editor's close control is icon-only, so its `aria-label` is the only copy
+ * a screen-reader user gets from it. Attribute copy has no second check — the
+ * pseudo-locale oracle walks text nodes, so reverting this to an English literal
+ * leaves nothing visible (`docs/i18n.md`, "It cannot see copy that is not a text
+ * node"). The locale-switch assertion catches the two shapes lint cannot: a
+ * hardcoded literal, and a `t()` frozen at module scope.
+ */
+describe("RepositoryCard editor close control", () => {
+ it("labels the close control for screen readers", () => {
+ renderCard(repository(), true);
+
+ expect(screen.getByRole("button", { name: CLOSE_EDITOR_LABEL })).toBeTruthy();
+ });
+
+ it("resolves the close label through the catalog on a locale switch", async () => {
+ const { container } = renderCard(repository(), true);
+ const close = container.querySelector(`[aria-label="${CLOSE_EDITOR_LABEL}"]`);
+ expect(close).toBeTruthy();
+
+ await i18n.changeLanguage("pseudo");
+
+ expect(container.querySelector(`[aria-label="${CLOSE_EDITOR_LABEL}"]`)).toBeNull();
+ const label = close?.getAttribute("aria-label") ?? "";
+ expect(label.length).toBeGreaterThan(0);
+ });
+});
diff --git a/apps/web/components/settings/repository-card.tsx b/apps/web/components/settings/repository-card.tsx
index 38131f77f5..37b31679a1 100644
--- a/apps/web/components/settings/repository-card.tsx
+++ b/apps/web/components/settings/repository-card.tsx
@@ -1,6 +1,8 @@
"use client";
import { useState } from "react";
+import { Trans, useTranslation } from "react-i18next";
+import type { TFunction } from "i18next";
import { IconEdit, IconGitBranch, IconTrash, IconX } from "@tabler/icons-react";
import { Badge } from "@kandev/ui/badge";
import { CardContent } from "@kandev/ui/card";
@@ -25,6 +27,23 @@ import { defaultWorktreeBranchTemplate } from "@/lib/worktree-branch-template";
type RepositoryWithScripts = Repository & { scripts: RepositoryScript[] };
+/**
+ * The environment variable the dev script reads to learn its allocated port. It
+ * is an identifier the user types into a shell command verbatim, so it travels
+ * as an interpolated value rather than sitting inside the message.
+ */
+const DEV_SCRIPT_PORT_VAR = "$PORT";
+
+/**
+ * Shell script samples. Each is code the user edits and the executor runs
+ * verbatim, so none of it is copy — the same call as `DEFAULT_DOCKERFILE` in the
+ * executor profile editor. Hoisted out of the JSX so the intent is explicit
+ * rather than resting on a guard exclusion pattern.
+ */
+const SETUP_SCRIPT_PLACEHOLDER = "#!/bin/bash\n# any manual setup you need";
+const CLEANUP_SCRIPT_PLACEHOLDER = "#!/bin/bash\n# any manual clean up you need";
+const DEV_SCRIPT_PLACEHOLDER = "#!/bin/bash\nnpm run dev -- --port $PORT";
+
type RepoFieldsBaseProps = {
repositoryId: string;
onUpdate: (repoId: string, updates: Partial) => void;
@@ -49,11 +68,14 @@ function RepositoryBasicFields({
worktreeBranchTemplate,
pullBeforeWorktree,
}: RepositoryBasicFieldsProps) {
+ const { t } = useTranslation();
return (
<>
- Repository Name
+ {t("workspaces:repositoryName")}
+ {/* Both placeholders are value shapes — a git repository name and an
+ absolute filesystem path — so they stay in English. */}
onUpdate(repositoryId, { name: e.target.value })}
@@ -62,7 +84,7 @@ function RepositoryBasicFields({
/>
-
Local Path
+
{t("workspaces:localPath")}
onUpdate(repositoryId, { local_path: e.target.value })}
@@ -75,7 +97,7 @@ function RepositoryBasicFields({
-
Worktree Sync
+
{t("workspaces:worktreeSync")}
- Always pull before creating a new worktree
+ {t("workspaces:alwaysPullBeforeWorktree")}
@@ -133,52 +155,52 @@ function RepositoryScriptFields({
devScript,
copyFiles,
}: RepositoryScriptFieldsProps) {
+ const { t } = useTranslation();
return (
<>
+ {/* Every script placeholder below is a shell sample — a shebang plus a
+ command the executor runs verbatim — so it is code, not copy. */}
-
Setup Script
+
{t("workspaces:setupScript")}
-
Cleanup Script
+
{t("workspaces:cleanupScript")}
-
Dev Script
+
{t("workspaces:devScript")}
@@ -219,6 +241,7 @@ function RepositoryEditView({
deleteLoading,
close,
}: RepositoryEditViewProps) {
+ const { t } = useTranslation();
return (
@@ -227,7 +250,7 @@ function RepositoryEditView({
- Repository
+ {t("workspaces:repository")}
{isDirty && }
@@ -236,7 +259,7 @@ function RepositoryEditView({
variant="ghost"
size="icon"
className="cursor-pointer"
- aria-label="Close repository editor"
+ aria-label={t("workspaces:closeRepositoryEditor")}
onClick={close}
>
@@ -284,7 +307,7 @@ function RepositoryEditView({
disabled={deleteLoading}
>
- Delete Repository
+ {t("workspaces:deleteRepository")}
@@ -301,7 +324,11 @@ type RepositoryPreviewProps = {
open: () => void;
};
-function buildRepoScriptsSummary(repository: RepositoryWithScripts) {
+// These two build every string in the collapsed card, and neither holds JSX, so
+// `i18next/no-literal-string` could never see any of it — the lint count for this
+// file reported zero for the whole block. `t` is a parameter rather than the
+// module-level import so each label resolves when the card renders.
+function buildRepoScriptsSummary(t: TFunction, repository: RepositoryWithScripts) {
const setupScript = repository.setup_script ?? "";
const cleanupScript = repository.cleanup_script ?? "";
const devScript = repository.dev_script ?? "";
@@ -310,10 +337,12 @@ function buildRepoScriptsSummary(repository: RepositoryWithScripts) {
const hasCleanupScript = Boolean(cleanupScript.trim());
const hasDevScript = Boolean(devScript.trim());
const showScriptsSummary = scriptsCount > 0 || hasSetupScript || hasCleanupScript || hasDevScript;
+ // Was `custom script${count === 1 ? "" : "s"}` — an English morpheme built at
+ // the call site, which leaves a translator no way to express a third form.
const scriptsLabel =
scriptsCount === 0
- ? "No custom scripts"
- : `${scriptsCount} custom script${scriptsCount === 1 ? "" : "s"}`;
+ ? t("workspaces:noCustomScripts")
+ : t("workspaces:customScripts", { count: scriptsCount });
return {
scriptsCount,
hasSetupScript,
@@ -324,20 +353,24 @@ function buildRepoScriptsSummary(repository: RepositoryWithScripts) {
};
}
-function buildRepoPreviewData(repository: RepositoryWithScripts) {
+function buildRepoPreviewData(t: TFunction, repository: RepositoryWithScripts) {
const repositoryName = repository.name ?? "";
- const sourceLabel = repository.source_type === "local" ? "Local" : "Remote";
+ // `source_type` is the wire value; only its badge label is copy.
+ const sourceLabel =
+ repository.source_type === "local" ? t("workspaces:sourceLocal") : t("workspaces:sourceRemote");
+ // The path and the `owner/name` slug are user data / git metadata; only the
+ // two "nothing recorded" fallbacks are copy.
const subtitle =
repository.source_type === "local"
- ? repository.local_path || "Local path not set"
+ ? repository.local_path || t("workspaces:localPathNotSet")
: [repository.provider_owner, repository.provider_name].filter(Boolean).join("/") ||
repository.provider ||
- "Remote repository";
+ t("workspaces:remoteRepository");
return {
repositoryName,
sourceLabel,
subtitle,
- ...buildRepoScriptsSummary(repository),
+ ...buildRepoScriptsSummary(t, repository),
};
}
@@ -348,6 +381,7 @@ function RepositoryPreview({
onOpenDelete,
open,
}: RepositoryPreviewProps) {
+ const { t } = useTranslation();
const {
repositoryName,
scriptsCount,
@@ -358,7 +392,7 @@ function RepositoryPreview({
scriptsLabel,
sourceLabel,
subtitle,
- } = buildRepoPreviewData(repository);
+ } = buildRepoPreviewData(t, repository);
return (
@@ -370,7 +404,9 @@ function RepositoryPreview({
-
{repositoryName || "Untitled repository"}
+
+ {repositoryName || t("workspaces:untitledRepository")}
+
{sourceLabel}
@@ -380,9 +416,9 @@ function RepositoryPreview({
{showScriptsSummary ? (
{scriptsCount > 0 && {scriptsLabel} }
- {hasSetupScript && build script }
- {hasCleanupScript && cleanup script }
- {hasDevScript && dev script }
+ {hasSetupScript && {t("workspaces:buildScriptChip")} }
+ {hasCleanupScript && {t("workspaces:cleanupScriptChip")} }
+ {hasDevScript && {t("workspaces:devScriptChip")} }
) : null}
@@ -399,7 +435,7 @@ function RepositoryPreview({
}}
>
- Edit
+ {t("workspaces:edit")}
- Delete
+ {t("workspaces:delete")}
@@ -442,6 +478,7 @@ function useRepositoryDelete(
onDeleted: () => void,
) {
const { toast } = useToast();
+ const { t } = useTranslation();
const [deleteOpen, setDeleteOpen] = useState(false);
const [activeSessionCount, setActiveSessionCount] = useState(0);
const [checkingCount, setCheckingCount] = useState(false);
@@ -465,8 +502,8 @@ function useRepositoryDelete(
setDeleteOpen(true);
} catch (error) {
toast({
- title: "Failed to check repository sessions",
- description: error instanceof Error ? error.message : "Request failed",
+ title: t("workspaces:failedToCheckRepositorySessions"),
+ description: error instanceof Error ? error.message : t("common:requestFailed"),
variant: "error",
});
} finally {
@@ -481,8 +518,8 @@ function useRepositoryDelete(
onDeleted();
} catch (error) {
toast({
- title: "Failed to delete repository",
- description: error instanceof Error ? error.message : "Request failed",
+ title: t("workspaces:failedToDeleteRepository"),
+ description: error instanceof Error ? error.message : t("common:requestFailed"),
variant: "error",
});
}
@@ -513,6 +550,7 @@ export function RepositoryCard({
onDelete,
}: RepositoryCardProps) {
const { toast } = useToast();
+ const { t } = useTranslation();
const [isEditing, setIsEditing] = useState(() => autoOpen);
const saveRequest = useRequest(() => onSave(repository.id));
const isDirty = isRepositoryDirty || areScriptsDirty;
@@ -523,8 +561,8 @@ export function RepositoryCard({
await saveRequest.run();
} catch (error) {
toast({
- title: "Failed to save repository",
- description: error instanceof Error ? error.message : "Request failed",
+ title: t("workspaces:failedToSaveRepository"),
+ description: error instanceof Error ? error.message : t("common:requestFailed"),
variant: "error",
});
throw error;
diff --git a/apps/web/components/settings/repository-copy-files-help.test.tsx b/apps/web/components/settings/repository-copy-files-help.test.tsx
new file mode 100644
index 0000000000..66b96c9a90
--- /dev/null
+++ b/apps/web/components/settings/repository-copy-files-help.test.tsx
@@ -0,0 +1,68 @@
+import { cleanup, render, screen } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { CopyFilesField } from "./repository-copy-files-help";
+
+afterEach(cleanup);
+
+/**
+ * Every sentence here is a `` whose `` indices address ``
+ * elements positionally, and each glob pattern travels as an interpolation VALUE
+ * so the pseudo-locale cannot accent it into a pattern that matches nothing.
+ * Both shapes fail silently: a drifted index renders duplicated fragments with
+ * empty tags, and a pattern moved into the message body would only show up in a
+ * translated build. These tests reconstruct the whole sentence and assert each
+ * pattern lands inside a ``.
+ */
+function renderField() {
+ return render( ).container;
+}
+
+function codeTexts(container: HTMLElement): string[] {
+ return Array.from(container.querySelectorAll("code")).map((el) => el.textContent);
+}
+
+describe("CopyFilesField", () => {
+ it("renders the symlink help as one sentence with each suffix in a code element", () => {
+ const container = renderField();
+
+ expect(document.getElementById("copy-files-help-repo-1")?.textContent).toBe(
+ "Gitignored paths copied into new worktrees. Append :symlink to an entry to link it back to the main repo. Use ::symlink for a literal filename ending in :symlink.",
+ );
+ expect(codeTexts(container)).toEqual(expect.arrayContaining([":symlink", "::symlink"]));
+ });
+
+ it("keeps the glob patterns out of the translated message body", () => {
+ const container = renderField();
+
+ expect(codeTexts(container)).toEqual(
+ expect.arrayContaining(["*", "?", "[abc]", "**", "**/.env", "{a,b}", ".env{,.local}"]),
+ );
+ });
+
+ it("renders each supported-pattern bullet as a complete sentence", () => {
+ const container = renderField();
+
+ // Each bullet is assembled from `` elements and text nodes, so it has
+ // to be reconstructed — a drifted `` index would drop a fragment here
+ // while every individual `` still rendered.
+ const bullets = Array.from(container.querySelectorAll("li")).map((li) =>
+ li.textContent?.replace(/\s+/g, " ").trim(),
+ );
+ expect(bullets).toEqual([
+ ".env literal file or directory (directories copy recursively)",
+ "*, ?, [abc] single-segment wildcards",
+ "** matches any number of directories, e.g. **/.env",
+ "{a,b} brace alternation, e.g. .env{,.local}",
+ ]);
+ });
+
+ it("interpolates the remote size cap rather than baking it into the message", () => {
+ renderField();
+
+ expect(
+ screen.getByText(
+ "Files over 5 MiB are skipped when copying to remote executors. Local worktrees copy them without a size cap.",
+ ),
+ ).toBeTruthy();
+ });
+});
diff --git a/apps/web/components/settings/repository-copy-files-help.tsx b/apps/web/components/settings/repository-copy-files-help.tsx
index 84168f9ae1..5ef5f8cebb 100644
--- a/apps/web/components/settings/repository-copy-files-help.tsx
+++ b/apps/web/components/settings/repository-copy-files-help.tsx
@@ -1,10 +1,32 @@
"use client";
+import { Trans, useTranslation } from "react-i18next";
import { IconChevronDown, IconInfoCircle } from "@tabler/icons-react";
import { Label } from "@kandev/ui/label";
import { Textarea } from "@kandev/ui/textarea";
import type { Repository } from "@/lib/types/http";
+/**
+ * Glob/path syntax the backend parses and the user types verbatim. These are
+ * VALUES, not copy, so each is interpolated into its message rather than left in
+ * the tag body — the pseudo-locale would otherwise accent `**` or `:symlink`
+ * into a pattern that no longer matches anything.
+ */
+const SYMLINK_SUFFIX = ":symlink";
+const ESCAPED_SYMLINK_SUFFIX = "::symlink";
+const LITERAL_PATTERN = ".env";
+const STAR_PATTERN = "*";
+const QUESTION_PATTERN = "?";
+const CHAR_CLASS_PATTERN = "[abc]";
+const GLOBSTAR_PATTERN = "**";
+const GLOBSTAR_EXAMPLE = "**/.env";
+const BRACES_PATTERN = "{a,b}";
+const BRACES_EXAMPLE = ".env{,.local}";
+const REMOTE_COPY_SIZE_LIMIT = "5 MiB";
+const COPY_FILES_PLACEHOLDER = ".env, .env.*, apps/**/.env, .env.local:symlink";
+
+const codeClass = "px-1 py-0.5 bg-muted rounded";
+
type CopyFilesFieldProps = {
repositoryId: string;
copyFiles: string;
@@ -18,30 +40,36 @@ export function CopyFilesField({
isDirty = false,
onUpdate,
}: CopyFilesFieldProps) {
+ const { t } = useTranslation();
const inputId = `copy-files-${repositoryId}`;
const helpId = `copy-files-help-${repositoryId}`;
return (
-
Copy Files
+
{t("workspaces:copyFiles")}
+ {/* The placeholder is a list of glob patterns — a value, not prose. */}
@@ -49,44 +77,64 @@ export function CopyFilesField({
}
function CopyFilesDetails() {
+ const { t } = useTranslation();
return (
- Pattern syntax
+ {t("workspaces:patternSyntax")}
-
- Paths are resolved relative to the repository root and seeded into every new worktree,
- preserving their relative location. Existing files in the worktree are not overwritten.
-
-
Supported patterns:
+
{t("workspaces:copyFilesPathsResolved")}
+
{t("workspaces:supportedPatterns")}
- .env literal file or directory
- (directories copy recursively)
+
+
+
- *,{" "}
- ?,{" "}
- [abc] single-segment wildcards
+
+
+
+
+
- ** matches any number of
- directories, e.g. **/.env
+
+
+
+
- {"{a,b}"} brace alternation, e.g.{" "}
- .env{"{,.local}"}
+
+
+
+
- Files over 5 MiB are skipped when copying to remote executors. Local worktrees copy them
- without a size cap.
+ {t("workspaces:copyFilesSizeLimit", { limit: REMOTE_COPY_SIZE_LIMIT })}
diff --git a/apps/web/components/settings/repository-custom-scripts.tsx b/apps/web/components/settings/repository-custom-scripts.tsx
index 9e5e1552f5..44a0d5d350 100644
--- a/apps/web/components/settings/repository-custom-scripts.tsx
+++ b/apps/web/components/settings/repository-custom-scripts.tsx
@@ -1,3 +1,4 @@
+import { useTranslation } from "react-i18next";
import { IconPlus, IconX } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { Input } from "@kandev/ui/input";
@@ -6,6 +7,12 @@ import { Textarea } from "@kandev/ui/textarea";
import type { RepositoryScript } from "@/lib/types/http";
import { UnsavedChangesBadge } from "./unsaved-indicator";
+/**
+ * A shell script sample: code the user edits and the executor runs verbatim,
+ * not copy.
+ */
+const SCRIPT_COMMAND_PLACEHOLDER = "#!/bin/bash\nnpm run dev";
+
type RepositoryCustomScriptsProps = {
repositoryId: string;
scripts: RepositoryScript[];
@@ -25,21 +32,22 @@ export function RepositoryCustomScripts({
onUpdateScript,
onDeleteScript,
}: RepositoryCustomScriptsProps) {
+ const { t } = useTranslation();
return (
- Custom Scripts
+ {t("workspaces:customScriptsLabel")}
{areScriptsDirty && }
onAddScript(repositoryId)}>
- Add Script
+ {t("workspaces:addScript")}
{scripts.length === 0 ? (
-
No scripts yet.
+
{t("workspaces:noScriptsYet")}
) : (
scripts.map((script) => (
onUpdate(repositoryId, script.id, { name: event.target.value })}
- placeholder="Script name"
+ placeholder={t("workspaces:scriptName")}
data-settings-dirty={nameIsDirty}
/>
onUpdate(repositoryId, script.id, { command: event.target.value })}
- placeholder="#!/bin/bash
npm run dev"
+ placeholder={SCRIPT_COMMAND_PLACEHOLDER}
rows={3}
className="font-mono text-sm"
data-settings-dirty={commandIsDirty}
diff --git a/apps/web/components/settings/repository-delete-dialog.test.tsx b/apps/web/components/settings/repository-delete-dialog.test.tsx
new file mode 100644
index 0000000000..a178bfa92a
--- /dev/null
+++ b/apps/web/components/settings/repository-delete-dialog.test.tsx
@@ -0,0 +1,70 @@
+import { cleanup, render, screen, within } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { DeleteRepositoryDialog } from "./repository-delete-dialog";
+
+afterEach(cleanup);
+
+// `@kandev/ui`'s DialogContent renders its own sr-only "Close" control, so the
+// footer has to be scoped explicitly or the dismiss button is ambiguous.
+function footer(): HTMLElement {
+ return document.querySelector('[data-slot="dialog-footer"]') as HTMLElement;
+}
+
+/**
+ * The active-session sentence used to inflect both its noun and its pronoun with
+ * a ternary (`session`/`sessions`, `it`/`them`), so a bad `_one` / `_other` split
+ * would have rendered "1 active agent sessions … finish them" without failing
+ * anything. These assertions pin both forms and the no-sessions variant.
+ */
+function renderDialog(activeSessionCount: number) {
+ return render(
+ ,
+ );
+}
+
+describe("DeleteRepositoryDialog", () => {
+ it("uses the singular noun and pronoun for exactly one active session", () => {
+ renderDialog(1);
+
+ expect(
+ screen.getByText(
+ "This repository is used by 1 active agent session. Stop or finish it before deleting the repository.",
+ ),
+ ).toBeTruthy();
+ });
+
+ it("uses the plural noun and pronoun for more than one active session", () => {
+ renderDialog(3);
+
+ expect(
+ screen.getByText(
+ "This repository is used by 3 active agent sessions. Stop or finish them before deleting the repository.",
+ ),
+ ).toBeTruthy();
+ });
+
+ it("offers only a Close button while sessions are active", () => {
+ renderDialog(2);
+
+ expect(within(footer()).getByRole("button", { name: "Close" })).toBeTruthy();
+ expect(screen.queryByRole("button", { name: "Delete Repository" })).toBeNull();
+ });
+
+ it("offers Cancel and Delete Repository when no session is active", () => {
+ renderDialog(0);
+
+ expect(
+ screen.getByText(
+ "This will remove the repository and its scripts. This action cannot be undone.",
+ ),
+ ).toBeTruthy();
+ expect(screen.getByRole("button", { name: "Cancel" })).toBeTruthy();
+ expect(screen.getByRole("button", { name: "Delete Repository" })).toBeTruthy();
+ });
+});
diff --git a/apps/web/components/settings/repository-delete-dialog.tsx b/apps/web/components/settings/repository-delete-dialog.tsx
index 4ac4494fd4..a15f09672b 100644
--- a/apps/web/components/settings/repository-delete-dialog.tsx
+++ b/apps/web/components/settings/repository-delete-dialog.tsx
@@ -1,5 +1,6 @@
"use client";
+import { useTranslation } from "react-i18next";
import { Button } from "@kandev/ui/button";
import {
Dialog,
@@ -25,18 +26,19 @@ export function DeleteRepositoryDialog({
activeSessionCount,
deleteLoading,
}: DeleteRepositoryDialogProps) {
+ const { t } = useTranslation();
const hasActiveSessions = activeSessionCount > 0;
- const isOne = activeSessionCount === 1;
- const sessionWord = isOne ? "session" : "sessions";
- const pronoun = isOne ? "it" : "them";
+ // The noun and the pronoun used to be inflected here with a ternary, which put
+ // the plural rule at the call site — untranslatable in a language with more
+ // than two forms. Both now fold into the `_one` / `_other` catalog entries.
const description = hasActiveSessions
- ? `This repository is used by ${activeSessionCount} active agent ${sessionWord}. Stop or finish ${pronoun} before deleting the repository.`
- : "This will remove the repository and its scripts. This action cannot be undone.";
+ ? t("workspaces:deleteRepositoryActiveSessions", { count: activeSessionCount })
+ : t("workspaces:deleteRepositoryConfirm");
return (
- Delete repository
+ {t("workspaces:deleteRepositoryTitle")}
{description}
@@ -46,7 +48,7 @@ export function DeleteRepositoryDialog({
className="cursor-pointer"
onClick={() => onOpenChange(false)}
>
- {hasActiveSessions ? "Close" : "Cancel"}
+ {hasActiveSessions ? t("workspaces:close") : t("common:cancel")}
{!hasActiveSessions && (
- Delete Repository
+ {t("workspaces:deleteRepository")}
)}
diff --git a/apps/web/components/settings/unsaved-indicator.tsx b/apps/web/components/settings/unsaved-indicator.tsx
index e718968975..a98081ffa9 100644
--- a/apps/web/components/settings/unsaved-indicator.tsx
+++ b/apps/web/components/settings/unsaved-indicator.tsx
@@ -1,5 +1,6 @@
"use client";
+import { useTranslation } from "react-i18next";
import { IconAlertCircle } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { RequestIndicator } from "@/components/request-indicator";
@@ -16,7 +17,8 @@ type UnsavedSaveButtonProps = {
const dirtyButtonClass = "border-success/60 text-success hover:bg-success/10";
export function UnsavedChangesBadge() {
- return Unsaved changes ;
+ const { t } = useTranslation();
+ return {t("common:unsavedChanges")} ;
}
export function UnsavedSaveButton({
@@ -25,8 +27,12 @@ export function UnsavedSaveButton({
status,
onClick,
disabled,
- cleanLabel = "Save",
+ cleanLabel,
}: UnsavedSaveButtonProps) {
+ const { t } = useTranslation();
+ // Defaulted here rather than in the parameter list: `t()` must resolve at
+ // render, and a default expression in the signature runs before the hook.
+ const resolvedCleanLabel = cleanLabel ?? t("common:save");
return (
{isDirty && }
- {isDirty ? "Save" : cleanLabel}
+ {isDirty ? t("common:save") : resolvedCleanLabel}
{status !== "idle" && (
diff --git a/apps/web/eslint.i18n.options.mjs b/apps/web/eslint.i18n.options.mjs
index 8ccae08886..02a12e3b61 100644
--- a/apps/web/eslint.i18n.options.mjs
+++ b/apps/web/eslint.i18n.options.mjs
@@ -853,4 +853,84 @@ export const i18nGuardFiles = [
// and had its `group` converted here, but the rest of that file belongs to the
// components/task migration and is not listed.
"components/global-commands.tsx",
+ // Settings → Workspace: the workspace list and its inline create form, the
+ // workspace edit page (settings card, links card and delete dialog), the
+ // repositories page with its discover dialog, and the repository card with the
+ // branch-template help, copy-files help, custom-scripts editor and delete
+ // dialog it renders. Copy lives in a new `workspaces` namespace.
+ //
+ // This is a FILE list in `app/settings/workspace/`, not `**`, because the
+ // sibling `workspace-workflows-client.tsx` / `workspace-workflows-dialogs.tsx`
+ // and `use-workflow-creation.ts` belong to the workflow editor migrated in
+ // #2201 and are already listed above, while `[id]/automations/**` is a
+ // separate task.
+ //
+ // `components/settings/unsaved-indicator.tsx` is included: it is reached from
+ // nowhere else in the app today, so its "Unsaved changes" badge and its Save
+ // button were the only plain English left inside a repository card. Both
+ // strings went to `common` rather than `workspaces`, since the component is a
+ // generic settings primitive the remaining migrations will reuse.
+ //
+ // `components/settings/editable-card.tsx`, `settings-card.tsx` and
+ // `settings-section.tsx` are all in the closure and all already correct —
+ // the first two are pure render-prop/prop-forwarding shells and the third
+ // takes title/description as props. `editable-card.tsx` is already listed
+ // above from the Editors migration; the other two carry no literals at all.
+ //
+ // Two `.ts` files in this directory are deliberately ABSENT rather than
+ // listed. `workspace-repositories-validation.ts` is a single boolean predicate
+ // over the API response (`exists && is_git`) and
+ // `workspace-repositories-dirty.ts` is field-comparison logic — neither holds
+ // a string of any kind. Their user-facing validation MESSAGES live in
+ // `workspace-repositories-client.tsx`'s `useDiscoverDialog`, which is listed
+ // and migrated; `mode: "jsx-only"` could not have seen them there either,
+ // because they are `setManualValidation` arguments rather than JSX.
+ //
+ // Deliberately left in English, because each is PERSISTED rather than
+ // rendered: the seeded `New Repository` name in `buildDraftRepo`, and the
+ // `New Script` name and `echo ""` command the save path writes for a script
+ // left blank. Translating any of them would store a localized string in the
+ // database and render it later on surfaces this PR does not own — the same
+ // call as the `New Step` / `DEFAULT_CUSTOM_STEPS` names in #2201.
+ //
+ // Also English by design, each a VALUE the user types or a parser consumes,
+ // and each interpolated so the pseudo-locale cannot turn it into a dead
+ // pointer:
+ // - The six branch-template placeholders `{title}` / `{title_full}` /
+ // `{ticket}` / `{issue_key}` / `{task_id}` / `{suffix}`. `RenderTaskBranchName`
+ // in `apps/backend/internal/worktree/config.go` substitutes each by exact
+ // string match, so a translated token would land in the branch name
+ // verbatim. Only the description beside each one is copy. Same for the
+ // `feature/{ticket}-{title}` example and the default template
+ // `feature/{title}-{suffix}`.
+ // - Every copy-files glob: `.env`, `*`, `?`, `[abc]`, `**`, `**/.env`,
+ // `{a,b}`, `.env{,.local}`, and the `:symlink` / `::symlink` suffixes.
+ // The explanation around them is copy; the patterns are what the backend
+ // matches on.
+ // - `$PORT`, the environment variable a dev script reads.
+ // - Every script placeholder (`#!/bin/bash` plus a command). Shell samples
+ // the executor runs verbatim, the same call as `DEFAULT_DOCKERFILE` in the
+ // executor editor.
+ // - The `/absolute/path/to/repository`, `/path/to/repository` and `my-repo`
+ // placeholders — value shapes, not prose.
+ //
+ // Repository names, paths, `owner/name` slugs, branch names and workspace
+ // names are user data throughout and are never built into a message by
+ // concatenation. The workspace delete dialog compares the typed text with the
+ // workspace's own name, so there is no translatable type-to-confirm token
+ // here to break; the name travels into the sentence as an interpolated value.
+ "app/settings/workspace/page.tsx",
+ "app/settings/workspace/[id]/page.tsx",
+ "app/settings/workspace/[id]/repositories/page.tsx",
+ "app/settings/workspace/workspace-edit-client.tsx",
+ "app/settings/workspace/workspace-not-found-card.tsx",
+ "app/settings/workspace/workspace-repositories-client.tsx",
+ "app/settings/workspace/workspace-repositories-dialog.tsx",
+ "app/settings/workspace/workspaces-page-client.tsx",
+ "components/settings/repository-branch-template-help.tsx",
+ "components/settings/repository-card.tsx",
+ "components/settings/repository-copy-files-help.tsx",
+ "components/settings/repository-custom-scripts.tsx",
+ "components/settings/repository-delete-dialog.tsx",
+ "components/settings/unsaved-indicator.tsx",
];
diff --git a/apps/web/package.json b/apps/web/package.json
index c3fb86e2e5..f1bf0579dc 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -25,7 +25,8 @@
"licenses:gen": "tsx scripts/generate-licenses.ts",
"i18n:pseudo": "node scripts/generate-pseudo-locale.mjs",
"i18n:check": "node scripts/check-i18n-keys.mjs && node scripts/check-trans-indices.mjs && node scripts/check-inline-plurals.mjs && node scripts/check-module-scope-t.mjs",
- "i18n:ratchet": "node scripts/run-i18n-ratchet.mjs"
+ "i18n:ratchet": "node scripts/run-i18n-ratchet.mjs",
+ "i18n:removed": "node scripts/check-removed-literals.mjs"
},
"dependencies": {
"@base-ui/react": "^1.0.0",
diff --git a/apps/web/scripts/check-removed-literals.mjs b/apps/web/scripts/check-removed-literals.mjs
new file mode 100644
index 0000000000..a63bf6aa34
--- /dev/null
+++ b/apps/web/scripts/check-removed-literals.mjs
@@ -0,0 +1,156 @@
+#!/usr/bin/env node
+/**
+ * Migration fidelity: every string a change REMOVES from source must reappear
+ * verbatim in a catalog.
+ *
+ * A directory migration replaces literals with `t()` calls. If the English a
+ * user saw is gone from the source and is not a catalog value either, then the
+ * copy silently CHANGED — and nothing else in the toolchain notices:
+ *
+ * live surface : "Create a diagnostic ZIP with frontend and backend logs."
+ * reused key : "Download a bounded diagnostic ZIP containing frontend and backend logs."
+ *
+ * That shape survives `pnpm lint`, all four `i18n:check` gates, the ratchet and
+ * the whole unit suite, because every string IS externalized, the key DOES
+ * exist and the catalogs ARE in sync. It happens when two surfaces render the
+ * same copy, one of them was migrated earlier (often a dead route), and the
+ * later migration adopts the existing, plausible-looking key. The pseudo-locale
+ * cannot see it either: the text is translated, just into different words.
+ *
+ * This check compares the literals present before and after, so it reports at
+ * the point of the edit and covers every route in the change automatically,
+ * rather than relying on whichever one happens to have an e2e assertion
+ * pinning its sentence.
+ *
+ * NOT part of `i18n:check`, and deliberately not in CI: it is diff-based, so it
+ * needs a base ref, and a finding is a prompt to CLASSIFY rather than proof of
+ * a bug. Benign findings are normal and expected — a value extracted into an
+ * interpolation, a placeholder hoisted to a constant, a literal that was never
+ * copy. It narrows the diff for a human; it does not decide.
+ *
+ * COVERAGE IS PARTIAL. Rule 2 is only as tight as the literal text around a
+ * message's placeholders, which is why a message needs two words of prose to
+ * earn one at all (see `canAnchorPattern`). Simulating a rewrite of every
+ * removed string measured 89/96 caught on this migration. The remainder are
+ * messages too thin to distinguish a rewrite from anything else. A clean run is
+ * evidence, not proof — which is why the mutation probe in docs/i18n.md is a
+ * separate step.
+ *
+ * Usage: node scripts/check-removed-literals.mjs [--base [] [--all]
+ *
+ * --all also report literals that look like identifiers/values, not just
+ * the ones that look like prose.
+ */
+import fs from "node:fs";
+import path from "node:path";
+import { git, resolveBase, REPO_ROOT } from "./lib/git-base.mjs";
+import { changedFiles, WEB_PREFIX } from "./lib/changed-files.mjs";
+import { accountedFor, buildCatalog, literals, looksLikeCopy } from "./lib/removed-literals.mjs";
+
+const ROOT = path.resolve(import.meta.dirname, "..");
+const LOCALES = path.join(ROOT, "src", "locales", "en");
+const REPORT_ALL = process.argv.includes("--all");
+
+function catalogValues() {
+ const out = new Set();
+ for (const file of fs.readdirSync(LOCALES).filter((f) => f.endsWith(".json"))) {
+ for (const value of Object.values(
+ JSON.parse(fs.readFileSync(path.join(LOCALES, file), "utf8")),
+ ))
+ if (typeof value === "string") out.add(value);
+ }
+ return out;
+}
+
+/** The committed content of a path at `ref`, or "" if absent there. */
+function committedSide(ref, repoPath) {
+ try {
+ return git(["show", `${ref}:${repoPath}`], { quiet: true });
+ } catch {
+ return ""; // Absent on that side (added or deleted).
+ }
+}
+
+/**
+ * The CURRENT content of a path — working tree, not `HEAD`.
+ *
+ * This side must not be `git show HEAD:…`. `changedFiles` diffs the base against
+ * the working tree, so it lists files you have edited but not committed; reading
+ * the "after" side from HEAD compared those files against themselves and found
+ * nothing. The result was a no-op that printed a tick WITH THE CORRECT FILE
+ * COUNT, which is what made it convincing — and the natural workflow (finish,
+ * run checks, commit) walks straight into it.
+ *
+ * A missing file is a deletion, so its strings all count as removed.
+ */
+function workingTreeSide(repoPath) {
+ try {
+ return fs.readFileSync(path.join(REPO_ROOT, repoPath), "utf8");
+ } catch {
+ return "";
+ }
+}
+
+const resolved = resolveBase();
+if (resolved.skip) {
+ console.log(`↷ skipped removed-literal check — ${resolved.skip}`);
+ process.exit(0);
+}
+const { base } = resolved;
+
+const values = catalogValues();
+const catalog = buildCatalog(values);
+const files = [
+ ...new Set([...changedFiles(base, "M", REPO_ROOT), ...changedFiles(base, "D", REPO_ROOT)]),
+];
+
+const findings = [];
+let unparseable = 0;
+for (const repoPath of files) {
+ const before = literals(committedSide(base, repoPath), repoPath);
+ const after = literals(workingTreeSide(repoPath), repoPath) ?? new Set();
+ if (before === null) {
+ unparseable += 1;
+ continue;
+ }
+ for (const value of before) {
+ if (after.has(value)) continue;
+ if (!REPORT_ALL && !looksLikeCopy(value)) continue;
+ if (accountedFor(value, catalog)) continue;
+ findings.push({ file: repoPath.slice(WEB_PREFIX.length), value });
+ }
+}
+
+if (unparseable) console.log(`ℹ ${unparseable} file(s) could not be parsed on the base side.`);
+
+// A tick over nothing is the shape of every silent no-op this check has had.
+// Say plainly that there was nothing to compare rather than reporting a verdict.
+if (files.length === 0) {
+ console.log(
+ `↷ no changed .ts/.tsx under ${WEB_PREFIX} between ${base.slice(0, 9)} and the working tree — nothing to compare.`,
+ );
+ process.exit(0);
+}
+
+if (findings.length === 0) {
+ console.log(
+ `✓ removed literals OK — every string ${files.length} changed file(s) dropped is still a catalog value.`,
+ );
+ process.exit(0);
+}
+
+console.log(
+ `\nStrings removed from source that do NOT reappear verbatim in a catalog (${findings.length}):\n`,
+);
+for (const { file, value } of findings.sort(
+ (a, b) => a.file.localeCompare(b.file) || a.value.localeCompare(b.value),
+))
+ console.log(` ${file}\n ${JSON.stringify(value)}\n`);
+console.log(
+ "Each is one of:\n" +
+ " - a VALUE extracted into an interpolation (reconstruct it and compare — it should be byte-identical)\n" +
+ " - a literal hoisted to a constant, or one that was never copy\n" +
+ " - COPY THAT CHANGED, because a reused key's English differs from what this surface rendered\n" +
+ "\nThe last one is the bug this check exists for. Classify every entry before merging.\n",
+);
+process.exit(1);
diff --git a/apps/web/scripts/lib/removed-literals.mjs b/apps/web/scripts/lib/removed-literals.mjs
new file mode 100644
index 0000000000..dfb29701af
--- /dev/null
+++ b/apps/web/scripts/lib/removed-literals.mjs
@@ -0,0 +1,180 @@
+/**
+ * Pure helpers for `check-removed-literals.mjs`: extracting every user-visible
+ * string from a source file, and deciding whether a string a change removed is
+ * still accounted for by the catalog.
+ *
+ * Split out so the matching rules can be unit-tested against the failure they
+ * exist to catch, without needing a git checkout or a real diff.
+ */
+import { parseSync } from "@babel/core";
+
+export function walk(node, visit) {
+ if (!node || typeof node.type !== "string") return;
+ visit(node);
+ for (const key of Object.keys(node)) {
+ if (key === "loc" || key.endsWith("Comments")) continue;
+ const child = node[key];
+ if (Array.isArray(child)) {
+ for (const c of child) if (c && typeof c.type === "string") walk(c, visit);
+ } else if (child && typeof child.type === "string") walk(child, visit);
+ }
+}
+
+/**
+ * Every user-visible string shape in one file.
+ *
+ * JSX TEXT is the load-bearing case and the one a regex over quoted strings
+ * misses entirely: `] Back to Workspaces` carries no quotes, and it
+ * is the majority of migrated copy. Template quasis matter because the guard
+ * validates them too.
+ */
+export function literals(source, filename) {
+ let ast;
+ try {
+ ast = parseSync(source, {
+ filename,
+ babelrc: false,
+ configFile: false,
+ sourceType: "module",
+ parserOpts: { plugins: ["typescript", "jsx"] },
+ });
+ } catch {
+ return null; // Unparseable side of the diff — reported by the caller.
+ }
+ const found = new Set();
+ walk(ast, (node) => {
+ if (node.type === "StringLiteral") found.add(node.value);
+ else if (node.type === "JSXText") found.add(node.value);
+ else if (node.type === "TemplateLiteral")
+ for (const q of node.quasis) found.add(q.value.cooked ?? q.value.raw);
+ });
+ return new Set([...found].map((s) => s.replace(/\s+/g, " ").trim()).filter(Boolean));
+}
+
+/**
+ * A message and its source prose compare equal once the markup a migration adds
+ * is removed: `` tag indices, `{{interpolations}}`, and the whitespace
+ * JSX collapses. Without this the check is pure noise — every `` and
+ * every value extraction reports.
+ */
+export function normalize(value) {
+ return value
+ .replace(/<\/?\d+>/g, "")
+ .replace(/\{\{[^}]*\}\}/g, "")
+ .replace(/\s+/g, " ")
+ .trim();
+}
+
+/**
+ * A message as a pattern, with each `{{value}}` free to be anything.
+ *
+ * This is what accounts for a VALUE EXTRACTION — the commonest benign finding.
+ * `"Files over 5 MiB are skipped…"` in the source becomes
+ * `"Files over {{limit}} are skipped…"` in the catalog, which is the same
+ * sentence with the units pulled out, not a rewrite.
+ */
+export function messagePattern(value) {
+ const escaped = value
+ .replace(/<\/?\d+>/g, "")
+ .replace(/\s+/g, " ")
+ .trim()
+ .replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
+ .replace(/\\\{\\\{[^}]*\\\}\\\}/g, "[\\s\\S]+?");
+ return new RegExp(`^${escaped}$`);
+}
+
+/**
+ * Is this removed string still accounted for by some catalog message?
+ *
+ * Three ways, in increasing looseness:
+ * 1. it IS a message;
+ * 2. it is an INSTANCE of one, with interpolated values filled in;
+ * 3. it is a FRAGMENT of one — a `` splits its sentence into a JSX text
+ * node per element boundary, so the halves either side of a `` arrive
+ * here separately and can never match a whole message.
+ *
+ * (3) trades a little sensitivity for usability: a fragment could in principle
+ * be a coincidental substring of an unrelated message. That is a far better
+ * trade than the alternative, because a check nobody runs catches nothing — and
+ * the failure this exists for is not subtle. A rewritten sentence is not a
+ * substring of the sentence that replaced it.
+ */
+export function accountedFor(value, { exact, normalized, patterns }) {
+ if (exact.has(value)) return true;
+ const norm = normalize(value);
+ if (normalized.has(norm)) return true;
+ if (patterns.some((re) => re.test(norm) || re.test(value))) return true;
+ return [...normalized].some((message) => message.length >= norm.length && message.includes(norm));
+}
+
+/** Prose, as opposed to a classname, enum token, path or identifier. */
+export function looksLikeCopy(value) {
+ if (value.length < 4) return false;
+ if (!/[A-Za-z]{3}/.test(value)) return false;
+ if (!/[A-Z]/.test(value) && !/[a-z]+ [a-z]+/.test(value)) return false;
+ if (/^[/~.]/.test(value)) return false; // paths
+ if (/^(https?|file|ssh|git):\/\//.test(value)) return false; // URLs
+ if (/^[a-z][a-zA-Z0-9]*$/.test(value)) return false; // camelCase identifier
+ if (/^[a-z0-9]+([-_][a-z0-9]+)+$/.test(value)) return false; // kebab/snake token
+ // A `namespace:key` catalog reference. Swapping one `t()` key for another
+ // registers as a removed literal, because the key is itself a StringLiteral —
+ // so every migration that re-points a call site reports its own old key. It is
+ // an identifier, never copy, and leaving it in reads alarmingly to someone
+ // expecting a clean run.
+ if (/^[a-z][a-zA-Z0-9]*:[a-zA-Z0-9_]+$/.test(value)) return false;
+ return true;
+}
+
+/**
+ * Can this message anchor a pattern, or would its pattern match anything?
+ *
+ * A message that is NOTHING BUT a placeholder — `settings:externalMcpConfigPath`
+ * is literally `"{{path}}"` — compiles to `/^[\s\S]+?$/`, which matches every
+ * string in the repository. Because rule 2 runs before rule 3, ONE such key
+ * makes `accountedFor` return true for everything and the whole check becomes a
+ * silent no-op that still prints ✓. That is strictly worse than not running it,
+ * because it converts "I verified" into "I ran something".
+ *
+ * The requirement is TWO WORDS of prose, not merely a non-empty remainder and
+ * not merely one word. The empty case is the extreme of a continuum, not a
+ * distinct bug — 44 interpolating messages have a residue of six characters or
+ * fewer — and one short word clears any "is there a word?" bar while leaving the
+ * pattern almost unbounded:
+ *
+ * "{{a}} —" -> /^[\s\S]+? —$/ matches anything ending " —"
+ * "{{count}}m" -> /^[\s\S]+?m$/ matches "Confirm", "Custom", "System"
+ * "Delete {{name}}" -> /^Delete [\s\S]+?$/ matches EVERY "Delete …" sentence
+ *
+ * Measured on two migrations, requiring two words dropped 340 pattern-bearing
+ * messages to 292, added ZERO findings to either diff, and made ten more
+ * rewrites detectable on one of them. It costs nothing real and closes the
+ * largest remaining hole.
+ *
+ * The honest limit of this rule: `"{{count}} of {{total}}"` (a legitimate value
+ * extraction) and `"{{a}} of {{b}}"` (far too permissive to anchor anything)
+ * both reduce to the residue `"of"` and compile to the SAME regex, so nothing
+ * computed from the message can accept one and reject the other. Both are
+ * rejected here, which means a string like `"3 of 9"` is REPORTED and classified
+ * by a human. That is the correct direction for an advisory check: a false
+ * positive costs someone ten seconds, a false negative is the failure this
+ * exists to prevent.
+ *
+ * A rejected message is still matched exactly and as a fragment — it only loses
+ * the wildcard, which is all it could honestly support.
+ */
+function canAnchorPattern(message) {
+ const words = normalize(message)
+ .split(/\s+/)
+ .filter((word) => /[A-Za-z]{2,}/.test(word));
+ return words.length >= 2;
+}
+
+/** Build the three lookup shapes `accountedFor` needs from a set of messages. */
+export function buildCatalog(values) {
+ const set = values instanceof Set ? values : new Set(values);
+ return {
+ exact: set,
+ normalized: new Set([...set].map(normalize)),
+ patterns: [...set].filter((v) => v.includes("{{") && canAnchorPattern(v)).map(messagePattern),
+ };
+}
diff --git a/apps/web/scripts/lib/removed-literals.test.ts b/apps/web/scripts/lib/removed-literals.test.ts
new file mode 100644
index 0000000000..33c2ee8f62
--- /dev/null
+++ b/apps/web/scripts/lib/removed-literals.test.ts
@@ -0,0 +1,200 @@
+import { describe, expect, it } from "vitest";
+import { accountedFor, buildCatalog, literals, looksLikeCopy } from "./removed-literals.mjs";
+import { isCandidate } from "./changed-files.mjs";
+
+/**
+ * The failure this check exists for, reproduced exactly.
+ *
+ * A live route rendered one sentence; a dead route migrated earlier had left a
+ * plausible-looking key holding a DIFFERENT sentence. Pointing the live route at
+ * that key silently rewrote user-visible English, and lint, all four
+ * `i18n:check` gates, the ratchet and 8,144 unit tests were clean — every string
+ * was externalized, the key existed, the catalogs were in sync.
+ */
+const LIVE_COPY = "Create a diagnostic ZIP with frontend and backend logs.";
+const REUSED_KEY_COPY = "Download a bounded diagnostic ZIP containing frontend and backend logs.";
+
+describe("accountedFor", () => {
+ it("reports a removed sentence whose replacement key holds different English", () => {
+ const catalog = buildCatalog([REUSED_KEY_COPY]);
+
+ expect(accountedFor(LIVE_COPY, catalog)).toBe(false);
+ });
+
+ it("accepts a string that became a catalog message unchanged", () => {
+ expect(accountedFor(LIVE_COPY, buildCatalog([LIVE_COPY]))).toBe(true);
+ });
+
+ it("accepts a value extracted into an interpolation", () => {
+ const catalog = buildCatalog(["Files over {{limit}} are skipped when copying to remote."]);
+
+ expect(accountedFor("Files over 5 MiB are skipped when copying to remote.", catalog)).toBe(
+ true,
+ );
+ });
+
+ it("accepts either half of a sentence split around an element", () => {
+ const catalog = buildCatalog([
+ "Type the workspace name <0>{{name}}0> to confirm deletion. This action cannot be undone.",
+ ]);
+
+ expect(accountedFor("Type the workspace name", catalog)).toBe(true);
+ expect(accountedFor("to confirm deletion. This action cannot be undone.", catalog)).toBe(true);
+ });
+
+ it("does not accept a rewrite merely because it shares words with the message", () => {
+ const catalog = buildCatalog(["Delete the repository and every script attached to it."]);
+
+ expect(accountedFor("Delete the repository and its scripts.", catalog)).toBe(false);
+ });
+
+ it("accepts a plural pair rendered from the singular source sentence", () => {
+ const catalog = buildCatalog(["{{count}} custom script", "{{count}} custom scripts"]);
+
+ expect(accountedFor("2 custom scripts", catalog)).toBe(true);
+ });
+});
+
+describe("literals", () => {
+ it("sees JSX text, which carries no quotes and is most migrated copy", () => {
+ const found: Set | null = literals(
+ `export const A = () => Back to Workspaces;`,
+ "a.tsx",
+ );
+
+ expect(found?.has("Back to Workspaces")).toBe(true);
+ });
+
+ it("sees string literals in attributes and template quasis", () => {
+ const found: Set | null = literals(
+ 'export const A = () => ;',
+ "a.tsx",
+ );
+
+ expect(found?.has("Filter repositories...")).toBe(true);
+ expect(found?.has("Select")).toBe(true);
+ });
+
+ it("returns null for an unparseable file rather than reporting it as empty", () => {
+ expect(literals("export const = ??", "a.tsx")).toBeNull();
+ });
+});
+
+describe("looksLikeCopy", () => {
+ it.each([
+ ["Delete Repository", true],
+ ["Manage your workspaces and workflows", true],
+ ["/absolute/path/to/repository", false],
+ ["https://example.com/docs", false],
+ ["worktree_branch_template", false],
+ ["repositoryName", false],
+ ["move_to_next", false],
+ ["px-1 py-0.5", false],
+ // A catalog reference. Re-pointing a t() call reports the OLD key as a
+ // removed literal, because the key is itself a StringLiteral.
+ ["workspaces:noScriptsYet", false],
+ ["common:requestFailed", false],
+ // Guard: prose containing a colon is still copy.
+ ["Supported patterns:", true],
+ ["Note: this cannot be undone", true],
+ ])("classifies %j as copy=%s", (value, expected) => {
+ expect(looksLikeCopy(value)).toBe(expected);
+ });
+});
+
+/**
+ * Two false positives were reported from a large migration and traced to an
+ * earlier regex PROTOTYPE of this check rather than to this module. Pinned here
+ * because the reports were circulated as bugs, and the obvious "fix" for either
+ * would make the check worse: scanning catalogs would report keys as copy, and
+ * widening `{{…}}` further would weaken the rewrite detection that is the whole
+ * point.
+ */
+describe("reported false positives that this module does not have", () => {
+ it("matches a literal whose leading number moved into an interpolation", () => {
+ // Reported as "permanently unmatchable". Rule 2 turns {{…}} into a wildcard,
+ // so a placeholder at the START of the message matches just as well as an
+ // interior one — provided the message has two words to anchor on.
+ expect(accountedFor("3 of 9 selected", buildCatalog(["{{count}} of {{total}} selected"]))).toBe(
+ true,
+ );
+ expect(accountedFor("50 items remaining", buildCatalog(["{{count}} items remaining"]))).toBe(
+ true,
+ );
+ });
+
+ it("still reports a rewrite that only differs where a placeholder is not", () => {
+ // The guard on the rule above: a wildcard must not swallow changed prose.
+ expect(accountedFor("50 max", buildCatalog(["{{count}} maximum"]))).toBe(false);
+ });
+
+ it("never treats a catalog as a source of candidate literals", () => {
+ // Reported as "a whole-file JSON reformat floods the output with catalog
+ // KEYS". Catalogs are what the check compares *against*; `isCandidate`
+ // admits only .ts/.tsx, so reformatting one cannot produce a single
+ // candidate. Scanning them would report keys as removed copy.
+ expect(isCandidate("apps/web/src/locales/en/common.json")).toBe(false);
+ expect(isCandidate("apps/web/src/locales/pseudo/workspaces.json")).toBe(false);
+ expect(isCandidate("apps/web/components/settings/repository-card.tsx")).toBe(true);
+ });
+});
+
+/**
+ * The assertions the rest of this file structurally cannot make: they all assume
+ * a well-formed catalog, so none of them can tell a working check from one that
+ * accepts everything. A check that silently passes is worse than no check, so
+ * these prove it can still FAIL.
+ */
+describe("a degenerate catalog message cannot disable the check", () => {
+ it("does not let a message that is only a placeholder match everything", () => {
+ // `settings:externalMcpConfigPath` was literally "{{path}}", which compiled
+ // to /^[\s\S]+?$/ and made accountedFor return true for every string in the
+ // repo — the whole check became a no-op that still printed a tick.
+ expect(accountedFor("anything at all", buildCatalog(["{{x}}"]))).toBe(false);
+ expect(accountedFor("Delete a workflow and all its steps.", buildCatalog(["{{path}}"]))).toBe(
+ false,
+ );
+ });
+
+ it("does not let a message with no word in it anchor a pattern", () => {
+ // "{{a}} —" normalizes to "—" and would compile to /^[\s\S]+? —$/.
+ expect(accountedFor("totally unrelated prose —", buildCatalog(["{{a}} —"]))).toBe(false);
+ });
+
+ it("still matches such a message exactly and as a fragment", () => {
+ // Dropping its PATTERN must not drop the message from the catalog.
+ expect(accountedFor("{{path}}", buildCatalog(["{{path}}"]))).toBe(true);
+ });
+
+ it("reports a value extraction whose message is too thin to anchor", () => {
+ // "{{count}} of {{total}}" reduces to the residue "of" — BYTE-IDENTICAL to
+ // "{{a}} of {{b}}", which would match "Anything at all of anything else".
+ // Nothing computed from the message can accept one and reject the other, so
+ // both lose their pattern and "3 of 9" is reported for a human to classify.
+ // A false positive costs ten seconds; a false negative is the failure this
+ // check exists to prevent.
+ expect(accountedFor("3 of 9", buildCatalog(["{{count}} of {{total}}"]))).toBe(false);
+ });
+
+ it("does not let one short word anchor a nearly unbounded pattern", () => {
+ // "Delete {{name}}" compiled to /^Delete [\s\S]+?$/ and accepted every
+ // "Delete …" sentence — 19 of 43 strings in the file this check was first
+ // pointed at were unprotected by exactly this shape.
+ expect(
+ accountedFor("Delete a workflow and all its steps.", buildCatalog(["Delete {{name}}"])),
+ ).toBe(false);
+ expect(accountedFor("Confirm", buildCatalog(["{{count}}m"]))).toBe(false);
+ });
+
+ it("keeps anchoring once a message has two words of prose", () => {
+ expect(
+ accountedFor(
+ "Files over 5 MiB are skipped",
+ buildCatalog(["Files over {{limit}} are skipped"]),
+ ),
+ ).toBe(true);
+ expect(
+ accountedFor("totally unrelated prose", buildCatalog(["Files over {{limit}} are skipped"])),
+ ).toBe(false);
+ });
+});
diff --git a/apps/web/src/locales/en/common.json b/apps/web/src/locales/en/common.json
index 84235e0d31..6573bd9260 100644
--- a/apps/web/src/locales/en/common.json
+++ b/apps/web/src/locales/en/common.json
@@ -65,12 +65,14 @@
"resetWatchTitle": "Reset {{label}}?",
"resetting": "Resetting…",
"retryPreview": "Retry preview",
+ "save": "Save",
"settings": "Settings",
"shell": "Shell",
"status": "Status",
"subtaskTitle": "Subtask title",
"system": "System",
"title": "Title",
+ "unsavedChanges": "Unsaved changes",
"workspace": "Workspace",
"workspaces": "Workspaces"
}
diff --git a/apps/web/src/locales/en/workspaces.json b/apps/web/src/locales/en/workspaces.json
new file mode 100644
index 0000000000..668695f743
--- /dev/null
+++ b/apps/web/src/locales/en/workspaces.json
@@ -0,0 +1,106 @@
+{
+ "addLocalRepository": "Add Local Repository",
+ "addScript": "Add Script",
+ "addWorkspace": "Add Workspace",
+ "alwaysPullBeforeWorktree": "Always pull before creating a new worktree",
+ "backToWorkspaces": "Back to Workspaces",
+ "branchTemplateIssueKey": "Alias for ticket. Use whichever name reads better in your template.",
+ "branchTemplateLiteralPrefix": "Write literal prefixes directly, for example <0>{{example}}0>.",
+ "branchTemplatePlaceholders": "Branch template placeholders",
+ "branchTemplateSuffix": "Short random suffix. Optional, but recommended to avoid branch name clashes. Example: {{example}}.",
+ "branchTemplateTaskId": "Kandev task UUID, sanitized for branch names. Example: {{example}}.",
+ "branchTemplateTicket": "Task identifier first; otherwise Jira, Linear, GitHub issue, or GitHub PR metadata. Examples: {{example}}.",
+ "branchTemplateTitle": "Task title sanitized to lowercase ASCII, hyphen-separated, max 20 chars. Example: {{example}}.",
+ "branchTemplateTitleFull": "Same sanitizing as title, but max 80 chars. Example: {{example}}.",
+ "buildScriptChip": "build script",
+ "checking": "Checking...",
+ "cleanupScript": "Cleanup Script",
+ "cleanupScriptChip": "cleanup script",
+ "cleanupScriptHelp": "Runs when the task is completed to clean up the workspace.",
+ "close": "Close",
+ "closeRepositoryEditor": "Close repository editor",
+ "confirmDelete": "Confirm Delete",
+ "copyFiles": "Copy Files",
+ "copyFilesHelp": "Gitignored paths copied into new worktrees. Append <0>{{symlink}}0> to an entry to link it back to the main repo. Use <1>{{escapedSymlink}}1> for a literal filename ending in <2>{{symlink}}2>.",
+ "copyFilesPathsResolved": "Paths are resolved relative to the repository root and seeded into every new worktree, preserving their relative location. Existing files in the worktree are not overwritten.",
+ "copyFilesPatternBraces": "<0>{{braces}}0> brace alternation, e.g. <1>{{example}}1>",
+ "copyFilesPatternGlobstar": "<0>{{globstar}}0> matches any number of directories, e.g. <1>{{example}}1>",
+ "copyFilesPatternLiteral": "<0>{{pattern}}0> literal file or directory (directories copy recursively)",
+ "copyFilesPatternWildcards": "<0>{{star}}0>, <1>{{question}}1>, <2>{{charClass}}2> single-segment wildcards",
+ "copyFilesRemoteFallback": "Remote executors copy file contents instead of creating symlinks.",
+ "copyFilesSizeLimit": "Files over {{limit}} are skipped when copying to remote executors. Local worktrees copy them without a size cap.",
+ "customScriptsLabel": "Custom Scripts",
+ "customScripts_one": "{{count}} custom script",
+ "customScripts_other": "{{count}} custom scripts",
+ "defaultAgentProfile": "Default Agent Profile",
+ "defaultExecutor": "Default Executor",
+ "delete": "Delete",
+ "deleteRepository": "Delete Repository",
+ "deleteRepositoryConfirm": "This will remove the repository and its scripts. This action cannot be undone.",
+ "deleteRepositoryTitle": "Delete repository",
+ "deleteRepositoryActiveSessions_one": "This repository is used by {{count}} active agent session. Stop or finish it before deleting the repository.",
+ "deleteRepositoryActiveSessions_other": "This repository is used by {{count}} active agent sessions. Stop or finish them before deleting the repository.",
+ "deleteThisWorkspace": "Delete this workspace",
+ "deleteWorkspace": "Delete Workspace",
+ "devScript": "Dev Script",
+ "devScriptChip": "dev script",
+ "devScriptHelp": "Used to start the preview dev server for this repository. Use <0>{{port}}0> for automatic port allocation.",
+ "discoverRepositoryDescription": "Select a discovered repository or provide an absolute path to validate.",
+ "discoveredRepositories": "Discovered repositories",
+ "edit": "Edit",
+ "failedToCheckRepositorySessions": "Failed to check repository sessions",
+ "failedToCreateWorkspace": "Failed to create workspace",
+ "failedToDeleteRepository": "Failed to delete repository",
+ "failedToDeleteWorkspace": "Failed to delete workspace",
+ "failedToDiscoverRepositories": "Failed to discover repositories",
+ "failedToSaveRepository": "Failed to save repository",
+ "failedToSaveWorkspace": "Failed to save workspace",
+ "filterRepositories": "Filter repositories...",
+ "invalidRepositoryPath": "Invalid repository path",
+ "localPath": "Local Path",
+ "localPathNotSet": "Local path not set",
+ "manageRepositoriesConnected": "Manage repositories connected to this workspace.",
+ "manageWorkspaceDetails": "Manage workspace details and jump into workflows or repositories.",
+ "manageYourWorkspacesAndWorkflows": "Manage your workspaces and workflows",
+ "manualPath": "Manual path",
+ "name": "Name",
+ "noAgentProfilesAvailable": "No agent profiles available",
+ "noCustomScripts": "No custom scripts",
+ "noDefault": "No default",
+ "noExecutorsAvailable": "No executors available",
+ "noRepositoriesFound": "No repositories found.",
+ "noScriptsYet": "No scripts yet.",
+ "noWorkspacesConfigured": "No workspaces configured. Add your first workspace to get started.",
+ "patternSyntax": "Pattern syntax",
+ "remoteRepository": "Remote repository",
+ "repositories": "Repositories",
+ "repositoriesInThisWorkspace": "Repositories in this workspace",
+ "repository": "Repository",
+ "repositoryName": "Repository Name",
+ "scanningRepositories": "Scanning repositories...",
+ "scriptName": "Script name",
+ "selectDefaultAgentProfile": "Select default agent profile",
+ "selectDefaultExecutor": "Select default executor",
+ "setupScript": "Setup Script",
+ "setupScriptHelp": "Runs when the repo is cloned or a git worktree is created.",
+ "sourceLocal": "Local",
+ "sourceRemote": "Remote",
+ "supportedPatterns": "Supported patterns:",
+ "thisActionCannotBeUndone": "This action cannot be undone.",
+ "typeWorkspaceNameToConfirm": "Type the workspace name <0>{{name}}0> to confirm deletion. This action cannot be undone.",
+ "untitledRepository": "Untitled repository",
+ "useRepository": "Use Repository",
+ "validGitRepository": "Valid git repository",
+ "validate": "Validate",
+ "workflows": "Workflows",
+ "workflowsManagedInThisWorkspace": "Workflows managed in this workspace",
+ "workspaceLinks": "Workspace Links",
+ "workspaceName": "Workspace Name",
+ "workspaceNameIsRequired": "Workspace name is required.",
+ "workspaceNamePlaceholder": "My Workspace",
+ "workspaceNotFound": "Workspace not found",
+ "workspaceSettings": "Workspace Settings",
+ "workspaceSettingsLink": "Workspace settings",
+ "worktreeBranchTemplate": "Worktree Branch Template",
+ "worktreeSync": "Worktree Sync"
+}
diff --git a/apps/web/src/locales/pseudo/common.json b/apps/web/src/locales/pseudo/common.json
index 0cb7a30fe4..872a2a62c2 100644
--- a/apps/web/src/locales/pseudo/common.json
+++ b/apps/web/src/locales/pseudo/common.json
@@ -65,12 +65,14 @@
"resetWatchTitle": "Ŕēśēţ {{label}}?",
"resetting": "Ŕēśēţţĩńĝ…",
"retryPreview": "Ŕēţŕŷ ƥŕēvĩēŵ",
+ "save": "Śàvē",
"settings": "Śēţţĩńĝś",
"shell": "Śĥēĺĺ",
"status": "Śţàţũś",
"subtaskTitle": "Śũƀţàśķ ţĩţĺē",
"system": "Śŷśţēḿ",
"title": "Ţĩţĺē",
+ "unsavedChanges": "Ũńśàvēď ćĥàńĝēś",
"workspace": "Ŵōŕķśƥàćē",
"workspaces": "Ŵōŕķśƥàćēś"
}
diff --git a/apps/web/src/locales/pseudo/workspaces.json b/apps/web/src/locales/pseudo/workspaces.json
new file mode 100644
index 0000000000..2b7d580099
--- /dev/null
+++ b/apps/web/src/locales/pseudo/workspaces.json
@@ -0,0 +1,106 @@
+{
+ "addLocalRepository": "Àďď Ĺōćàĺ Ŕēƥōśĩţōŕŷ",
+ "addScript": "Àďď Śćŕĩƥţ",
+ "addWorkspace": "Àďď Ŵōŕķśƥàćē",
+ "alwaysPullBeforeWorktree": "Àĺŵàŷś ƥũĺĺ ƀēƒōŕē ćŕēàţĩńĝ à ńēŵ ŵōŕķţŕēē",
+ "backToWorkspaces": "Ɓàćķ ţō Ŵōŕķśƥàćēś",
+ "branchTemplateIssueKey": "Àĺĩàś ƒōŕ ţĩćķēţ. Ũśē ŵĥĩćĥēvēŕ ńàḿē ŕēàďś ƀēţţēŕ ĩń ŷōũŕ ţēḿƥĺàţē.",
+ "branchTemplateLiteralPrefix": "Ŵŕĩţē ĺĩţēŕàĺ ƥŕēƒĩxēś ďĩŕēćţĺŷ, ƒōŕ ēxàḿƥĺē <0>{{example}}0>.",
+ "branchTemplatePlaceholders": "Ɓŕàńćĥ ţēḿƥĺàţē ƥĺàćēĥōĺďēŕś",
+ "branchTemplateSuffix": "Śĥōŕţ ŕàńďōḿ śũƒƒĩx. Ōƥţĩōńàĺ, ƀũţ ŕēćōḿḿēńďēď ţō àvōĩď ƀŕàńćĥ ńàḿē ćĺàśĥēś. Ēxàḿƥĺē: {{example}}.",
+ "branchTemplateTaskId": "Ķàńďēv ţàśķ ŨŨĨĎ, śàńĩţĩźēď ƒōŕ ƀŕàńćĥ ńàḿēś. Ēxàḿƥĺē: {{example}}.",
+ "branchTemplateTicket": "Ţàśķ ĩďēńţĩƒĩēŕ ƒĩŕśţ; ōţĥēŕŵĩśē Ĵĩŕà, Ĺĩńēàŕ, ĜĩţĤũƀ ĩśśũē, ōŕ ĜĩţĤũƀ ƤŔ ḿēţàďàţà. Ēxàḿƥĺēś: {{example}}.",
+ "branchTemplateTitle": "Ţàśķ ţĩţĺē śàńĩţĩźēď ţō ĺōŵēŕćàśē ÀŚĆĨĨ, ĥŷƥĥēń-śēƥàŕàţēď, ḿàx 20 ćĥàŕś. Ēxàḿƥĺē: {{example}}.",
+ "branchTemplateTitleFull": "Śàḿē śàńĩţĩźĩńĝ àś ţĩţĺē, ƀũţ ḿàx 80 ćĥàŕś. Ēxàḿƥĺē: {{example}}.",
+ "buildScriptChip": "ƀũĩĺď śćŕĩƥţ",
+ "checking": "Ćĥēćķĩńĝ...",
+ "cleanupScript": "Ćĺēàńũƥ Śćŕĩƥţ",
+ "cleanupScriptChip": "ćĺēàńũƥ śćŕĩƥţ",
+ "cleanupScriptHelp": "Ŕũńś ŵĥēń ţĥē ţàśķ ĩś ćōḿƥĺēţēď ţō ćĺēàń ũƥ ţĥē ŵōŕķśƥàćē.",
+ "close": "Ćĺōśē",
+ "closeRepositoryEditor": "Ćĺōśē ŕēƥōśĩţōŕŷ ēďĩţōŕ",
+ "confirmDelete": "Ćōńƒĩŕḿ Ďēĺēţē",
+ "copyFiles": "Ćōƥŷ Ƒĩĺēś",
+ "copyFilesHelp": "Ĝĩţĩĝńōŕēď ƥàţĥś ćōƥĩēď ĩńţō ńēŵ ŵōŕķţŕēēś. Àƥƥēńď <0>{{symlink}}0> ţō àń ēńţŕŷ ţō ĺĩńķ ĩţ ƀàćķ ţō ţĥē ḿàĩń ŕēƥō. Ũśē <1>{{escapedSymlink}}1> ƒōŕ à ĺĩţēŕàĺ ƒĩĺēńàḿē ēńďĩńĝ ĩń <2>{{symlink}}2>.",
+ "copyFilesPathsResolved": "Ƥàţĥś àŕē ŕēśōĺvēď ŕēĺàţĩvē ţō ţĥē ŕēƥōśĩţōŕŷ ŕōōţ àńď śēēďēď ĩńţō ēvēŕŷ ńēŵ ŵōŕķţŕēē, ƥŕēśēŕvĩńĝ ţĥēĩŕ ŕēĺàţĩvē ĺōćàţĩōń. Ēxĩśţĩńĝ ƒĩĺēś ĩń ţĥē ŵōŕķţŕēē àŕē ńōţ ōvēŕŵŕĩţţēń.",
+ "copyFilesPatternBraces": "<0>{{braces}}0> ƀŕàćē àĺţēŕńàţĩōń, ē.ĝ. <1>{{example}}1>",
+ "copyFilesPatternGlobstar": "<0>{{globstar}}0> ḿàţćĥēś àńŷ ńũḿƀēŕ ōƒ ďĩŕēćţōŕĩēś, ē.ĝ. <1>{{example}}1>",
+ "copyFilesPatternLiteral": "<0>{{pattern}}0> ĺĩţēŕàĺ ƒĩĺē ōŕ ďĩŕēćţōŕŷ (ďĩŕēćţōŕĩēś ćōƥŷ ŕēćũŕśĩvēĺŷ)",
+ "copyFilesPatternWildcards": "<0>{{star}}0>, <1>{{question}}1>, <2>{{charClass}}2> śĩńĝĺē-śēĝḿēńţ ŵĩĺďćàŕďś",
+ "copyFilesRemoteFallback": "Ŕēḿōţē ēxēćũţōŕś ćōƥŷ ƒĩĺē ćōńţēńţś ĩńśţēàď ōƒ ćŕēàţĩńĝ śŷḿĺĩńķś.",
+ "copyFilesSizeLimit": "Ƒĩĺēś ōvēŕ {{limit}} àŕē śķĩƥƥēď ŵĥēń ćōƥŷĩńĝ ţō ŕēḿōţē ēxēćũţōŕś. Ĺōćàĺ ŵōŕķţŕēēś ćōƥŷ ţĥēḿ ŵĩţĥōũţ à śĩźē ćàƥ.",
+ "customScriptsLabel": "Ćũśţōḿ Śćŕĩƥţś",
+ "customScripts_one": "{{count}} ćũśţōḿ śćŕĩƥţ",
+ "customScripts_other": "{{count}} ćũśţōḿ śćŕĩƥţś",
+ "defaultAgentProfile": "Ďēƒàũĺţ Àĝēńţ Ƥŕōƒĩĺē",
+ "defaultExecutor": "Ďēƒàũĺţ Ēxēćũţōŕ",
+ "delete": "Ďēĺēţē",
+ "deleteRepository": "Ďēĺēţē Ŕēƥōśĩţōŕŷ",
+ "deleteRepositoryConfirm": "Ţĥĩś ŵĩĺĺ ŕēḿōvē ţĥē ŕēƥōśĩţōŕŷ àńď ĩţś śćŕĩƥţś. Ţĥĩś àćţĩōń ćàńńōţ ƀē ũńďōńē.",
+ "deleteRepositoryTitle": "Ďēĺēţē ŕēƥōśĩţōŕŷ",
+ "deleteRepositoryActiveSessions_one": "Ţĥĩś ŕēƥōśĩţōŕŷ ĩś ũśēď ƀŷ {{count}} àćţĩvē àĝēńţ śēśśĩōń. Śţōƥ ōŕ ƒĩńĩśĥ ĩţ ƀēƒōŕē ďēĺēţĩńĝ ţĥē ŕēƥōśĩţōŕŷ.",
+ "deleteRepositoryActiveSessions_other": "Ţĥĩś ŕēƥōśĩţōŕŷ ĩś ũśēď ƀŷ {{count}} àćţĩvē àĝēńţ śēśśĩōńś. Śţōƥ ōŕ ƒĩńĩśĥ ţĥēḿ ƀēƒōŕē ďēĺēţĩńĝ ţĥē ŕēƥōśĩţōŕŷ.",
+ "deleteThisWorkspace": "Ďēĺēţē ţĥĩś ŵōŕķśƥàćē",
+ "deleteWorkspace": "Ďēĺēţē Ŵōŕķśƥàćē",
+ "devScript": "Ďēv Śćŕĩƥţ",
+ "devScriptChip": "ďēv śćŕĩƥţ",
+ "devScriptHelp": "Ũśēď ţō śţàŕţ ţĥē ƥŕēvĩēŵ ďēv śēŕvēŕ ƒōŕ ţĥĩś ŕēƥōśĩţōŕŷ. Ũśē <0>{{port}}0> ƒōŕ àũţōḿàţĩć ƥōŕţ àĺĺōćàţĩōń.",
+ "discoverRepositoryDescription": "Śēĺēćţ à ďĩśćōvēŕēď ŕēƥōśĩţōŕŷ ōŕ ƥŕōvĩďē àń àƀśōĺũţē ƥàţĥ ţō vàĺĩďàţē.",
+ "discoveredRepositories": "Ďĩśćōvēŕēď ŕēƥōśĩţōŕĩēś",
+ "edit": "Ēďĩţ",
+ "failedToCheckRepositorySessions": "Ƒàĩĺēď ţō ćĥēćķ ŕēƥōśĩţōŕŷ śēśśĩōńś",
+ "failedToCreateWorkspace": "Ƒàĩĺēď ţō ćŕēàţē ŵōŕķśƥàćē",
+ "failedToDeleteRepository": "Ƒàĩĺēď ţō ďēĺēţē ŕēƥōśĩţōŕŷ",
+ "failedToDeleteWorkspace": "Ƒàĩĺēď ţō ďēĺēţē ŵōŕķśƥàćē",
+ "failedToDiscoverRepositories": "Ƒàĩĺēď ţō ďĩśćōvēŕ ŕēƥōśĩţōŕĩēś",
+ "failedToSaveRepository": "Ƒàĩĺēď ţō śàvē ŕēƥōśĩţōŕŷ",
+ "failedToSaveWorkspace": "Ƒàĩĺēď ţō śàvē ŵōŕķśƥàćē",
+ "filterRepositories": "Ƒĩĺţēŕ ŕēƥōśĩţōŕĩēś...",
+ "invalidRepositoryPath": "Ĩńvàĺĩď ŕēƥōśĩţōŕŷ ƥàţĥ",
+ "localPath": "Ĺōćàĺ Ƥàţĥ",
+ "localPathNotSet": "Ĺōćàĺ ƥàţĥ ńōţ śēţ",
+ "manageRepositoriesConnected": "Ḿàńàĝē ŕēƥōśĩţōŕĩēś ćōńńēćţēď ţō ţĥĩś ŵōŕķśƥàćē.",
+ "manageWorkspaceDetails": "Ḿàńàĝē ŵōŕķśƥàćē ďēţàĩĺś àńď ĵũḿƥ ĩńţō ŵōŕķƒĺōŵś ōŕ ŕēƥōśĩţōŕĩēś.",
+ "manageYourWorkspacesAndWorkflows": "Ḿàńàĝē ŷōũŕ ŵōŕķśƥàćēś àńď ŵōŕķƒĺōŵś",
+ "manualPath": "Ḿàńũàĺ ƥàţĥ",
+ "name": "Ńàḿē",
+ "noAgentProfilesAvailable": "Ńō àĝēńţ ƥŕōƒĩĺēś àvàĩĺàƀĺē",
+ "noCustomScripts": "Ńō ćũśţōḿ śćŕĩƥţś",
+ "noDefault": "Ńō ďēƒàũĺţ",
+ "noExecutorsAvailable": "Ńō ēxēćũţōŕś àvàĩĺàƀĺē",
+ "noRepositoriesFound": "Ńō ŕēƥōśĩţōŕĩēś ƒōũńď.",
+ "noScriptsYet": "Ńō śćŕĩƥţś ŷēţ.",
+ "noWorkspacesConfigured": "Ńō ŵōŕķśƥàćēś ćōńƒĩĝũŕēď. Àďď ŷōũŕ ƒĩŕśţ ŵōŕķśƥàćē ţō ĝēţ śţàŕţēď.",
+ "patternSyntax": "Ƥàţţēŕń śŷńţàx",
+ "remoteRepository": "Ŕēḿōţē ŕēƥōśĩţōŕŷ",
+ "repositories": "Ŕēƥōśĩţōŕĩēś",
+ "repositoriesInThisWorkspace": "Ŕēƥōśĩţōŕĩēś ĩń ţĥĩś ŵōŕķśƥàćē",
+ "repository": "Ŕēƥōśĩţōŕŷ",
+ "repositoryName": "Ŕēƥōśĩţōŕŷ Ńàḿē",
+ "scanningRepositories": "Śćàńńĩńĝ ŕēƥōśĩţōŕĩēś...",
+ "scriptName": "Śćŕĩƥţ ńàḿē",
+ "selectDefaultAgentProfile": "Śēĺēćţ ďēƒàũĺţ àĝēńţ ƥŕōƒĩĺē",
+ "selectDefaultExecutor": "Śēĺēćţ ďēƒàũĺţ ēxēćũţōŕ",
+ "setupScript": "Śēţũƥ Śćŕĩƥţ",
+ "setupScriptHelp": "Ŕũńś ŵĥēń ţĥē ŕēƥō ĩś ćĺōńēď ōŕ à ĝĩţ ŵōŕķţŕēē ĩś ćŕēàţēď.",
+ "sourceLocal": "Ĺōćàĺ",
+ "sourceRemote": "Ŕēḿōţē",
+ "supportedPatterns": "Śũƥƥōŕţēď ƥàţţēŕńś:",
+ "thisActionCannotBeUndone": "Ţĥĩś àćţĩōń ćàńńōţ ƀē ũńďōńē.",
+ "typeWorkspaceNameToConfirm": "Ţŷƥē ţĥē ŵōŕķśƥàćē ńàḿē <0>{{name}}0> ţō ćōńƒĩŕḿ ďēĺēţĩōń. Ţĥĩś àćţĩōń ćàńńōţ ƀē ũńďōńē.",
+ "untitledRepository": "Ũńţĩţĺēď ŕēƥōśĩţōŕŷ",
+ "useRepository": "Ũśē Ŕēƥōśĩţōŕŷ",
+ "validGitRepository": "Vàĺĩď ĝĩţ ŕēƥōśĩţōŕŷ",
+ "validate": "Vàĺĩďàţē",
+ "workflows": "Ŵōŕķƒĺōŵś",
+ "workflowsManagedInThisWorkspace": "Ŵōŕķƒĺōŵś ḿàńàĝēď ĩń ţĥĩś ŵōŕķśƥàćē",
+ "workspaceLinks": "Ŵōŕķśƥàćē Ĺĩńķś",
+ "workspaceName": "Ŵōŕķśƥàćē Ńàḿē",
+ "workspaceNameIsRequired": "Ŵōŕķśƥàćē ńàḿē ĩś ŕēqũĩŕēď.",
+ "workspaceNamePlaceholder": "Ḿŷ Ŵōŕķśƥàćē",
+ "workspaceNotFound": "Ŵōŕķśƥàćē ńōţ ƒōũńď",
+ "workspaceSettings": "Ŵōŕķśƥàćē Śēţţĩńĝś",
+ "workspaceSettingsLink": "Ŵōŕķśƥàćē śēţţĩńĝś",
+ "worktreeBranchTemplate": "Ŵōŕķţŕēē Ɓŕàńćĥ Ţēḿƥĺàţē",
+ "worktreeSync": "Ŵōŕķţŕēē Śŷńć"
+}
diff --git a/docs/i18n.md b/docs/i18n.md
index e98936fad5..06f7cca2e0 100644
--- a/docs/i18n.md
+++ b/docs/i18n.md
@@ -159,6 +159,7 @@ pnpm run i18n:check # the four repo-wide gates (runs in CI: Frontend Tests)
pnpm run i18n:pseudo # regenerate the pseudo catalog from en
pnpm run lint:i18n # preview the guard on a path that is NOT yet migrated
pnpm run i18n:ratchet # new-code guard + allowlist-only-grows (pre-commit + CI)
+pnpm run i18n:removed # did this change rewrite any English? (diff-based, advisory)
# Codemods — always inspect the diff; each declines what it cannot prove safe.
node scripts/externalize-strings.mjs --write components app hooks lib
@@ -643,6 +644,60 @@ the guard: a table of every route's title and description as English, asserted
byte-for-byte. It converts "I diffed them carefully" into something that stays
true after you leave.
+`pnpm run i18n:removed` is the **mechanical form of that diff**, and it needs no
+foresight about which strings to pin. It compares the strings present before and
+after your change and reports any that left the source without reappearing as a
+catalog value — so it covers every file in the change, not the one that happened
+to have a spec. On the case above it reports the live sentence immediately,
+because `Create a diagnostic ZIP…` is nowhere in any catalog.
+
+It parses with Babel rather than matching quoted strings, because **JSX text
+carries no quotes** — ` Back to Workspaces` is most migrated copy and
+a regex cannot see it. Three acceptance rules keep it quiet on a clean
+migration: a value extracted into `{{interpolation}}`, either half of a ``
+sentence split around an element, and an `_one`/`_other` pair.
+
+**The hazard has two shapes, and only one of them is above.** The case in this
+section is an *attribute* — `description="…"` on a route shell — which is
+quoted, so even a crude scan finds it. The other is *element text*, which is
+not quoted and is where most migrated copy lives; a reused key that rewrites a
+button or a link label leaves nothing for a quoted-string search to find. Do not
+read this section as "route descriptions drift". Any copy rendered from two
+places can.
+
+It is **advisory and deliberately not in CI**: it is diff-based, so it needs a
+base ref (`i18n:check` is repo-wide on purpose), and a finding is a prompt to
+*classify* rather than proof of a bug — each is a value extraction, a literal
+hoisted to a constant, or the real thing. It narrows the diff for a human; it
+does not decide. `--base [` overrides the `origin/main` fork point, and
+`--all` widens past prose to identifier-shaped strings.
+
+**Its coverage is partial, and a clean run is evidence rather than proof.** Rule
+2 is only as tight as the literal text around a message's placeholders, so a
+message with too little of it earns no pattern at all — it needs two words of
+prose. That threshold is not arbitrary. One short word clears any "is there a
+word?" bar while leaving the pattern almost unbounded: `"Delete {{name}}"`
+compiled to `/^Delete [\s\S]+?$/` and accepted *every* sentence beginning
+"Delete ", which left 19 of 43 strings unprotected in the first file this check
+was pointed at.
+
+Measured by simulating a rewrite of every removed string, requiring two words
+took detection from 79 to **89 of 96** on one migration while adding **zero**
+findings to either of two real diffs. The seven that remain are messages whose
+prose is genuinely too thin to distinguish a rewrite from anything else, so
+treat a clean run as evidence, not proof — that is why the mutation probe above
+is a separate step, and why "the check was clean" is not on its own an answer to
+"did the English change?".
+
+One limit is structural rather than a tuning choice, and is worth knowing before
+anyone tries to close it: `"{{count}} of {{total}}"` (a legitimate value
+extraction) and `"{{a}} of {{b}}"` (far too permissive to anchor anything) both
+reduce to the residue `"of"` and compile to the *same* regex. Nothing computed
+from the message can accept one and reject the other. Both are rejected, so a
+string like `"3 of 9"` is reported and classified by hand — the right direction
+for an advisory check, since a false positive costs someone ten seconds and a
+false negative is the failure the check exists to prevent.
+
The underlying defect is the duplication itself. Unreferenced pages that
re-render live copy will drift again, and the next migration inherits the same
trap — which is why deleting a dead twin is a correctness change, not tidiness.
]