fix(i18n): address #2249 review findings (#2279)

Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
João Salavisa
2026-08-05 14:39:01 +02:00
committed by GitHub
parent f2510dfcb5
commit d54c2effef
9 changed files with 116 additions and 13 deletions
@@ -134,6 +134,10 @@ describe("create-page executor type registry", () => {
describe("getExecutorDescription", () => {
it.each([
// `local` is the SEEDED Executor.type and reached this helper unmapped
// before #2249's follow-up, so a local profile's header read "Custom
// executor." Both spellings must resolve to the same sentence.
["local", "Runs agents directly in the repository folder."],
["local_pc", "Runs agents directly in the repository folder."],
["worktree", "Creates git worktrees for isolated agent sessions."],
["local_docker", "Runs Docker containers on this machine."],
@@ -10,7 +10,10 @@ import type { ExecutorType } from "@/lib/types/http";
* create page — see the guard comment in eslint.i18n.options.mjs.
*/
export function getExecutorDescription(type: ExecutorType): string {
if (type === "local_pc") return t("executors:descriptionLocalPc");
// Both spellings reach this helper: `local` is the seeded `Executor.type`
// (task/repository/sqlite/defaults.go) and `local_pc` comes from the Office
// meta listing and the SQLite backfill — see lib/types/executor.ts.
if (type === "local" || type === "local_pc") return t("executors:descriptionLocalPc");
if (type === "worktree") return t("executors:descriptionWorktree");
if (type === "local_docker") return t("executors:descriptionLocalDocker");
if (type === "remote_docker") return t("executors:descriptionRemoteDocker");
@@ -34,7 +34,7 @@ export function PluginManifestCard({ plugin }: { plugin: PluginRecord }) {
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-2 text-sm">
{/* Row VALUES are manifest data — the plugin id, its version, its
author — and are never translated. Only the row labels are copy. */}
<ManifestRow label="ID" value={plugin.id} mono />
<ManifestRow label={t("plugins:manifestId")} value={plugin.id} mono />
<ManifestRow label={t("plugins:manifestVersion")} value={plugin.version} mono />
<ManifestRow label={t("plugins:manifestApiVersion")} value={String(plugin.api_version)} />
<ManifestRow label={t("plugins:manifestAuthor")} value={plugin.author || "—"} />
@@ -1,6 +1,7 @@
"use client";
import { useCallback, useMemo, useState, memo } from "react";
import { useTranslation } from "react-i18next";
import { IconMessageCircle, IconPlus } from "@tabler/icons-react";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@kandev/ui/sheet";
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from "@kandev/ui/drawer";
@@ -114,6 +115,8 @@ export function MobileTaskList({
const collapsedSubtaskParents = useAppStore((s) => s.collapsedSubtaskParents);
const toggleSubtaskCollapsed = useAppStore((s) => s.toggleSubtaskCollapsed);
const handleToggleGroup = useSidebarGroupToggle(view.id);
// See useGroupedSidebarView: the executorType group label is catalog-backed.
const { i18n } = useTranslation();
const grouped = useMemo(
() =>
applyView(tasks, view, {
@@ -121,7 +124,7 @@ export function MobileTaskList({
orderedTaskIds,
subtaskOrderByParentId,
}),
[tasks, view, pinnedTaskIds, orderedTaskIds, subtaskOrderByParentId],
[tasks, view, pinnedTaskIds, orderedTaskIds, subtaskOrderByParentId, i18n.language],
);
return (
<TaskSwitcher
@@ -0,0 +1,71 @@
import type { PropsWithChildren } from "react";
import { renderHook, act } from "@testing-library/react";
import { afterAll, describe, expect, it } from "vitest";
import { StateProvider, useAppStoreApi } from "@/components/state-provider";
import { activateLocale, i18n } from "@/lib/i18n";
import { useFilterValueOptions } from "./use-filter-value-options";
function wrapper() {
return function Wrapper({ children }: PropsWithChildren) {
return <StateProvider>{children}</StateProvider>;
};
}
/**
* `executorTypeOptions` labels its options through `getExecutorLabel`, which
* reads the catalog at call time. The hook memoizes on store data, so without
* the active language in its dependency array the labels would keep the
* previous locale until a task snapshot happened to change — invisible to the
* pseudo-locale, because the text IS translated, just stale.
*
* The store deliberately stays untouched across the switch: that is the whole
* point. If the only thing that changed is the locale and the label follows,
* the dependency is doing its job.
*/
describe("useFilterValueOptions — locale", () => {
afterAll(async () => {
if (i18n.language !== "en") await activateLocale("en");
});
it("recomputes executor-type labels on a locale switch with unchanged store data", async () => {
await activateLocale("en");
const { result } = renderHook(
() => {
const store = useAppStoreApi();
return { options: useFilterValueOptions("executorType"), store };
},
{ wrapper: wrapper() },
);
act(() => {
result.current.store.getState().hydrate({
kanbanMulti: {
snapshots: {
w1: {
workflowId: "w1",
workflowName: "W1",
steps: [],
tasks: [{ id: "t1", primaryExecutorType: "local" }],
},
},
},
} as never);
});
const before = result.current.options;
expect(before.map((o) => o.value)).toEqual(["local"]);
expect(before.map((o) => o.label)).toEqual(["Local"]);
await act(async () => {
await activateLocale("pseudo");
});
const after = result.current.options;
// Same option VALUE — it is the persisted executor type, never translated.
expect(after.map((o) => o.value)).toEqual(["local"]);
expect(after[0].label).not.toBe("Local");
expect(after[0].label).toBe(i18n.t("executors:typeLocal"));
});
});
@@ -1,6 +1,7 @@
"use client";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { applyView } from "@/lib/sidebar/apply-view";
import { useEffectiveSidebarView } from "@/hooks/domains/sidebar/use-effective-sidebar-view";
import { useSidebarTaskPrefs } from "@/hooks/domains/sidebar/use-sidebar-task-prefs";
@@ -10,6 +11,10 @@ export function useGroupedSidebarView(displayTasks: TaskSwitcherItem[]) {
const prefs = useSidebarTaskPrefs();
const effectiveView = useEffectiveSidebarView();
const { pinnedTaskIds, orderedTaskIds, subtaskOrderByParentId } = prefs;
// `applyGroup`'s executorType label comes from `getExecutorLabel`, which reads
// the catalog. Without the language in the deps the group heading keeps the
// previous locale until task data changes.
const { i18n } = useTranslation();
const grouped = useMemo(
() =>
applyView(displayTasks, effectiveView, {
@@ -17,7 +22,14 @@ export function useGroupedSidebarView(displayTasks: TaskSwitcherItem[]) {
orderedTaskIds,
subtaskOrderByParentId,
}),
[displayTasks, effectiveView, pinnedTaskIds, orderedTaskIds, subtaskOrderByParentId],
[
displayTasks,
effectiveView,
pinnedTaskIds,
orderedTaskIds,
subtaskOrderByParentId,
i18n.language,
],
);
return { grouped, effectiveView, prefs };
}
+15 -7
View File
@@ -1311,13 +1311,21 @@ export const i18nGuardFiles = [
//
// `getExecutorLabel` returns catalog text now, so a caller that does not
// subscribe to i18n renders the previous locale until something else makes it
// re-render. Every consumer this PR owns subscribes, and
// `use-filter-value-options.ts` (the task sidebar's executor filter) gained
// `i18n.language` in its memo deps for the same reason. One consumer is left:
// `lib/sidebar/apply-view.ts` builds a group label from it inside the sidebar
// view pipeline, which this PR does not own — its labels resolve correctly on
// load and lag a runtime locale switch until the view recomputes. It belongs
// to whoever migrates `lib/sidebar`.
// re-render. All three consumers are covered: `ProfileCard` subscribes,
// `use-filter-value-options.ts` (the task sidebar's executor filter) carries
// `i18n.language` in its memo deps, and both `applyView` consumers — the
// desktop sidebar and the mobile task-switcher sheet — do the same for
// `applyGroup`'s executorType heading in `lib/sidebar/apply-view.ts`.
//
// This bug class is invisible to BOTH the guard and the pseudo-locale: the
// text is translated, just frozen at the previous locale, so it renders
// accented and reads as done. `check-module-scope-t.mjs` does not fire
// either — the `t()` call sits inside a function, which is correct, since it
// does resolve at call time. Only a locale-switch test covers it, which is
// what `use-filter-value-options-locale.test.tsx` is for.
//
// Still English in `lib/sidebar/apply-view.ts` and belonging to whoever
// migrates `lib/sidebar`: `UNASSIGNED_LABEL` and `MULTI_REPO_LABEL`.
//
// One deliberate English change, the only one in this PR: the SSH
// running-sessions confirm read "This executor has 3 running session(s)."
+2 -1
View File
@@ -43,6 +43,7 @@
"manifestApiVersion": "API version",
"manifestAuthor": "Author",
"manifestCapabilities": "Capabilities",
"manifestId": "ID",
"manifestInstalled": "Installed",
"manifestSigned": "Signed",
"manifestVersion": "Version",
@@ -74,7 +75,7 @@
"selectPlaceholder": "Select...",
"selectedUtilityAgentUnavailable": "Selected utility agent unavailable",
"settings": "Settings",
"settingsDescription": "Browse the marketplace, then install, enable, disable, and uninstall kandev plugins.",
"settingsDescription": "Browse the marketplace, then install, enable, disable, and uninstall Kandev plugins.",
"settingsSaved": "Plugin settings saved",
"settingsSavedReloadFailed": "Settings saved, but reloading them failed — refresh to confirm.",
"sortMostStars": "Most stars",
+2 -1
View File
@@ -43,6 +43,7 @@
"manifestApiVersion": "ÀƤĨ vēŕśĩōń",
"manifestAuthor": "Àũţĥōŕ",
"manifestCapabilities": "Ćàƥàƀĩĺĩţĩēś",
"manifestId": "ĨĎ",
"manifestInstalled": "Ĩńśţàĺĺēď",
"manifestSigned": "Śĩĝńēď",
"manifestVersion": "Vēŕśĩōń",
@@ -74,7 +75,7 @@
"selectPlaceholder": "Śēĺēćţ...",
"selectedUtilityAgentUnavailable": "Śēĺēćţēď ũţĩĺĩţŷ àĝēńţ ũńàvàĩĺàƀĺē",
"settings": "Śēţţĩńĝś",
"settingsDescription": "Ɓŕōŵśē ţĥē ḿàŕķēţƥĺàćē, ţĥēń ĩńśţàĺĺ, ēńàƀĺē, ďĩśàƀĺē, àńď ũńĩńśţàĺĺ ķàńďēv ƥĺũĝĩńś.",
"settingsDescription": "Ɓŕōŵśē ţĥē ḿàŕķēţƥĺàćē, ţĥēń ĩńśţàĺĺ, ēńàƀĺē, ďĩśàƀĺē, àńď ũńĩńśţàĺĺ Ķàńďēv ƥĺũĝĩńś.",
"settingsSaved": "Ƥĺũĝĩń śēţţĩńĝś śàvēď",
"settingsSavedReloadFailed": "Śēţţĩńĝś śàvēď, ƀũţ ŕēĺōàďĩńĝ ţĥēḿ ƒàĩĺēď — ŕēƒŕēśĥ ţō ćōńƒĩŕḿ.",
"sortMostStars": "Ḿōśţ śţàŕś",