diff --git a/.changeset/github-import-auto-translate.md b/.changeset/github-import-auto-translate.md index 3b21940d15..00da33451d 100644 --- a/.changeset/github-import-auto-translate.md +++ b/.changeset/github-import-auto-translate.md @@ -4,4 +4,4 @@ summary: Auto-translate foreign-language GitHub issues in the Import Tasks panel, with a target language and model you choose. category: feature -dev: New project settings `githubImportAutoTranslate` (default false) and `importTranslateTargetLocale`, plus an `import-translate` model lane (project `importTranslateProvider`/`importTranslateModelId`, global `importTranslateGlobalProvider`/`importTranslateGlobalModelId`) resolved by `resolveImportTranslateSettingsModel`. Translations persist in the new `project.import_translation_cache` table (migration 0010) keyed by project+repo+issue+locale+source hash, and are pruned when an issue closes. `POST /api/github/issues/auto-translate` translates the 50 most recent open foreign issues per load on its own rate-limit budget; both single and batch import read the cache so imported tasks carry the translated title/body. Language detection moved from the dashboard app to `@fusion/core` so the panel and server share one heuristic. +dev: New project settings `githubImportAutoTranslate` (default false) and `importTranslateTargetLocale`, plus an `import-translate` model lane (project `importTranslateProvider`/`importTranslateModelId`, global `importTranslateGlobalProvider`/`importTranslateGlobalModelId`) resolved by `resolveImportTranslateSettingsModel`. Translations persist in the new `project.import_translation_cache` table (migration 0010) keyed by project+repo+issue+locale+source hash, and are pruned when an issue closes. `POST /api/github/issues/auto-translate` translates the 50 most recent open foreign issues per load on its own rate-limit budget; both single and batch import read the cache so imported tasks carry the translated title/body. Language detection moved from the dashboard app to `@fusion/core` so the panel and server share one heuristic. Auto-translation runs in the background and streams in chunks of 8 (`AUTO_TRANSLATE_CHUNK_SIZE`), so list titles fill in progressively and one failed chunk cannot discard the page; nothing in the panel awaits it. The two controls live in Settings -> Project General using that section's native checkbox/select markup, and Settings search advertises translation terms for General plus the Project/Global Models lane. diff --git a/packages/dashboard/app/components/GitHubImportTranslateControls.tsx b/packages/dashboard/app/components/GitHubImportTranslateControls.tsx index b87744d13c..40b840a5ab 100644 --- a/packages/dashboard/app/components/GitHubImportTranslateControls.tsx +++ b/packages/dashboard/app/components/GitHubImportTranslateControls.tsx @@ -10,7 +10,7 @@ Operators can translate title+body into the active UI locale, toggle original vs Translation is opt-in (never automatic) so import provenance stays faithful until the operator asks. [Superseded 2026-07-15 for the auto-translate path; still the behavior when the setting is off.] */ -import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { Languages, Loader2 } from "lucide-react"; import type { Locale } from "@fusion/core"; @@ -38,6 +38,40 @@ Closed issues are excluded outright: their translations are neither created nor */ export const AUTO_TRANSLATE_MAX_ISSUES = 50; +/* +FNXC:GitHubImportTranslate 2026-07-15-17:05: +Issues per background request. Small enough that the first translated titles appear quickly instead of after the whole page, large enough not to make 50 issues into 50 round-trips. Each chunk is an independent failure/retry unit. +*/ +export const AUTO_TRANSLATE_CHUNK_SIZE = 8; + +/* +FNXC:GitHubImportTranslate 2026-07-15-18:40: +Cheap djb2 digest of the items' prose, used ONLY to decide when the panel must re-request (an edited issue keeps its number but must not keep its old translation). +Deliberately not a crypto hash: it runs on every eligible issue and only needs to change when the text changes. The durable server cache keys on a real sha256 of the same content. +*/ +export function hashImportItemsForKey(items: AutoTranslateListItem[]): string { + let hash = 5381; + const feed = (text: string) => { + for (let i = 0; i < text.length; i++) { + hash = (((hash << 5) + hash) ^ text.charCodeAt(i)) >>> 0; + } + }; + for (const item of items) { + const title = item.title ?? ""; + const body = item.body ?? ""; + /* + FNXC:GitHubImportTranslate 2026-07-15-19:30: + LENGTH-PREFIXED, not delimiter-separated (PR #2147 review). Prose may itself contain the delimiter, which made the encoding ambiguous: `{title:"a|b", body:"c"}` and `{title:"a", body:"b|c"}` both rendered `1|a|b|c|`, so moving a `|` between fields produced an unchanged signature and the panel kept serving the OLD translation. + Prefixing each field with its length makes the encoding injective, so no edit can collide with its own previous content. + */ + feed(`${item.number}:${title.length}:`); + feed(title); + feed(`:${body.length}:`); + feed(body); + } + return hash.toString(36); +} + export interface AutoTranslateListItem { number: number; title: string; @@ -80,9 +114,13 @@ export function useGitHubImportAutoTranslate({ () => new Map(), ); const [loading, setLoading] = useState(false); - const [capped, setCapped] = useState(false); const [error, setError] = useState(null); + /* + FNXC:GitHubImportTranslate 2026-07-15-17:05: + `items` is a fresh ARRAY IDENTITY on most renders, so neither it nor anything derived from it may sit in the effect's dependency list: the effect calls setState, setState re-renders, the re-render mints a new array, and the effect fires again — an infinite render loop (caught as an OOM under renderHook). + Everything the effect depends on is therefore reduced to STRING keys (stable by value), and the live issue data is read from a ref at run time instead of being a dependency. + */ // Only the 50 most recent OPEN issues are eligible. GitHub returns issues // newest-first, so list order is already "most recent". const eligible = useMemo( @@ -90,69 +128,127 @@ export function useGitHubImportAutoTranslate({ [items], ); - /* Re-run only when the actual issue set changes — not on every list re-render, - which would re-request on unrelated state churn. */ + /** True when more open issues exist than a single load will translate. */ + const openCount = useMemo( + () => items.filter((item) => item.state !== "closed").length, + [items], + ); + + const eligibleRef = useRef(eligible); + eligibleRef.current = eligible; + + /* + FNXC:GitHubImportTranslate 2026-07-15-18:40: + The key covers issue CONTENT, not just issue numbers (PR #2147 review). Keying on numbers alone meant an edited issue — same number, new prose — produced an unchanged key, so the panel never re-requested and kept showing the translation of the OLD text. The server would have missed its own cache on `sourceHash` and re-translated, but the client never asked. + `contentSignature` is a cheap non-cryptographic digest: this only has to CHANGE when the prose changes, it is not a security or storage key (the durable cache uses a real sha256 server-side). + */ + const contentSignature = useMemo( + () => hashImportItemsForKey(eligible), + [eligible], + ); + + /* Stable-by-value key: re-runs only when the actual issue set / content / repo + / locale changes, not on unrelated list re-renders. */ const requestKey = useMemo( () => enabled && owner && repo && eligible.length > 0 - ? `${owner}/${repo}|${targetLocale}|${eligible.map((i) => i.number).join(",")}` + ? `${owner}/${repo}|${targetLocale}|${eligible.map((i) => i.number).join(",")}|${contentSignature}` : null, - [enabled, owner, repo, targetLocale, eligible], + [enabled, owner, repo, targetLocale, eligible, contentSignature], ); + /* + FNXC:GitHubImportTranslate 2026-07-15-20:15: + `capped` is DERIVED, not state (PR #2147 review). Holding it in state meant setting it from the translation effect, which forced `capExceeded` into that effect's dependency list — so a 51st open issue appearing (while the eligible first 50 were unchanged) re-ran the whole effect, cleared the translations, and re-requested all 50 purely to update a badge. + Deriving it removes the dependency, and with it the entire class of "cap indicator restarts translation" bug: there is nothing to keep in sync. + */ + const capExceeded = openCount > AUTO_TRANSLATE_MAX_ISSUES; + const capped = requestKey !== null && capExceeded; + useEffect(() => { if (!requestKey) { - setTranslations(new Map()); - setCapped(false); - setError(null); + // Identity-preserving resets: returning the previous value when there is + // nothing to clear avoids a state change (and therefore a re-render loop). + setTranslations((prev) => (prev.size > 0 ? new Map() : prev)); + setError((prev) => (prev ? null : prev)); + setLoading((prev) => (prev ? false : prev)); return; } + const pending = eligibleRef.current; let cancelled = false; setLoading(true); - setError(null); + setError((prev) => (prev ? null : prev)); + setTranslations((prev) => (prev.size > 0 ? new Map() : prev)); - autoTranslateImportIssues( - owner, - repo, - eligible.map((item) => ({ - number: item.number, - title: item.title ?? "", - body: item.body ?? null, - state: item.state === "closed" ? "closed" : "open", - })), - targetLocale, - projectId, - ) - .then((response) => { + /* + FNXC:GitHubImportTranslate 2026-07-15-17:05: + Translation runs in the BACKGROUND and streams in: the list renders immediately in the original language and each chunk's titles swap in as they land. Nothing here is awaited by the issue-list fetch, the preview, or Import. + Chunked rather than one 50-issue request because a single request only resolves once EVERY issue is translated — on a big page that is minutes of nothing happening, and one timeout would discard the whole page's work. Chunks make progress visible and make a failure cost one chunk instead of all 50. + Chunks are issued sequentially so a panel open cannot fan 50 model calls at the provider at once (the server already runs 4-way concurrency within a chunk); a cancelled/closed panel stops at the next chunk boundary. + */ + /* + FNXC:GitHubImportTranslate 2026-07-15-18:40: + try/finally, not a `setLoading(false)` after the loop: EVERY exit path must clear the spinner. The server-disabled early return skipped the post-loop clear and left the panel loading forever (PR #2147 review); a `finally` makes that unrepeatable for any future early return too. + A cancelled run deliberately does NOT touch state — the effect that superseded it owns `loading` now. + */ + void (async () => { + try { + for (let i = 0; i < pending.length; i += AUTO_TRANSLATE_CHUNK_SIZE) { if (cancelled) return; - const next = new Map(); - for (const [key, value] of Object.entries(response.translations ?? {})) { - const number = Number(key); - if (Number.isInteger(number)) { - next.set(number, { title: value.title, body: value.body }); + const chunk = pending.slice(i, i + AUTO_TRANSLATE_CHUNK_SIZE); + try { + const response = await autoTranslateImportIssues( + owner, + repo, + chunk.map((item) => ({ + number: item.number, + title: item.title ?? "", + body: item.body ?? null, + state: item.state === "closed" ? "closed" : "open", + })), + targetLocale, + projectId, + ); + if (cancelled) return; + + // The server is the authority on the setting: an "off" answer stops the run. + if (response.enabled === false) return; + + + const received = Object.entries(response.translations ?? {}); + if (received.length > 0) { + setTranslations((prev) => { + const next = new Map(prev); + for (const [key, value] of received) { + const number = Number(key); + if (Number.isInteger(number)) { + next.set(number, { title: value.title, body: value.body }); + } + } + return next; + }); } + } catch (err) { + if (cancelled) return; + // Fail soft: keep whatever already landed and surface the error; + // remaining chunks still get their chance. + setError(getTranslateErrorMessage(err)); } - setTranslations(next); - setCapped(Boolean(response.capped)); - }) - .catch((err) => { - if (cancelled) return; - // Fail soft: the list still renders in the original language. - setError(getTranslateErrorMessage(err)); - setTranslations(new Map()); - }) - .finally(() => { + } + } finally { if (!cancelled) setLoading(false); - }); + } + })(); return () => { cancelled = true; }; - // `requestKey` encodes every input that should retrigger the fetch (repo, - // locale, issue set); depending on `eligible` directly would refetch on any - // list re-render. - }, [requestKey, owner, repo, eligible, targetLocale, projectId]); + // Deps are STRING/scalar only. `requestKey` already encodes repo+locale+issue + // set; adding `items`/`eligible` (array identities) would re-fire the effect on + // every render and loop. Live data comes from `eligibleRef`. + // NOTE: `capExceeded` is deliberately absent — see the derived-`capped` note above. + }, [requestKey, owner, repo, targetLocale, projectId]); return { translations, loading, capped, error }; } diff --git a/packages/dashboard/app/components/SettingsModal.tsx b/packages/dashboard/app/components/SettingsModal.tsx index b20be8d0d9..0f61db7aff 100644 --- a/packages/dashboard/app/components/SettingsModal.tsx +++ b/packages/dashboard/app/components/SettingsModal.tsx @@ -422,7 +422,7 @@ export const SETTINGS_SECTIONS: SettingsSection[] = [ { id: "appearance", label: "Appearance", labelKey: "settings.nav.appearance", scope: "global", searchableText: ["theme", "color", "sidebar", "dock", "task popup", "task popups", "board list popups", "popup view attachment", "open tasks as popups", "quick chat"] }, { id: "notifications", label: "Notifications", labelKey: "settings.nav.notifications", scope: "global", searchableText: ["ntfy", "webhook", "events", "failure notifications", "sticky", "toast"] }, { id: "node-sync", label: "Node Sync", labelKey: "settings.nav.nodeSync", scope: "global", searchableText: ["sync", "node", "distributed", "heartbeat", "coordination"] }, - { id: "global-models", label: "Models", labelKey: "settings.nav.globalModels", scope: "global", searchableText: ["global models", "model presets", "favorite providers", "model pricing overrides", "LiteLLM pricing", "token pricing"] }, + { id: "global-models", label: "Models", labelKey: "settings.nav.globalModels", scope: "global", searchableText: ["global models", "model presets", "favorite providers", "model pricing overrides", "LiteLLM pricing", "token pricing", "translate", "translation model", "import translation model", "import auto-translation model"] }, { id: "global-mcp", label: "MCP Servers", labelKey: "settings.nav.globalMcp", scope: "global", searchableText: ["global MCP servers", "shared MCP", "user MCP", "tool servers"] }, { id: "cli-agents", @@ -473,7 +473,24 @@ export const SETTINGS_SECTIONS: SettingsSection[] = [ // Project group (specific to this project) { id: "__project_header", label: "Project", labelKey: "settings.nav.projectHeader", scope: undefined, isGroupHeader: true }, - { id: "general", label: "Project General", labelKey: "settings.nav.projectGeneral", scope: "project", searchableText: ["project general", "Completion Documentation Automation", "Quick Chat launcher", "ephemeral task-worker agents", "GitHub tracking", "GitLab integration", "chat rooms", "auto-cleanup old chats"] }, + { + id: "general", + label: "Project General", + labelKey: "settings.nav.projectGeneral", + scope: "project", + /* + FNXC:GitHubImportTranslate 2026-07-15-16:20: + Import auto-translation lives in Project General beside the other import-scoped GitHub settings, but operators look for it by what it DOES ("translate", "language", "auto translate issues"), not by the section it happens to live in. Settings search only matches curated terms plus advertised i18n keys, so without these the controls are effectively unfindable — the section name says nothing about translation. + */ + searchableText: ["project general", "Completion Documentation Automation", "Quick Chat launcher", "ephemeral task-worker agents", "GitHub tracking", "GitLab integration", "chat rooms", "auto-cleanup old chats", "translate", "translation", "auto translate", "auto-translate", "autotranslate", "auto translate issues", "translate issues", "translate imported issues", "githubImportAutoTranslate", "importTranslateTargetLocale", "target language", "translation target language", "translation language", "language", "foreign language issues", "import language", "localize", "localization"], + searchableKeys: [ + "settings.general.autoTranslateImportedIssues", + "settings.general.autoTranslateImportedIssuesHelp", + "settings.general.translationTargetLanguage", + "settings.general.translationTargetLanguageHelp", + "settings.general.followDashboardLanguage", + ], + }, { id: "commands", label: "Commands & Scripts", labelKey: "settings.nav.commands", scope: "project", searchableText: ["test command", "build command", "verification command", "workflow scripts", "commands"] }, { id: "worktrees", label: "Worktrees", labelKey: "settings.nav.worktrees", scope: "project", searchableText: ["worktree directory", "copy files", "recycle worktrees", "branch naming", "sibling branch rename"] }, { id: "scheduling", label: "Scheduling & Capacity", labelKey: "settings.nav.scheduling", scope: "project", searchableText: ["max concurrent", "capacity", "stuck tasks", "poll interval", "parallel steps", "scheduler"] }, @@ -526,6 +543,13 @@ export const SETTINGS_SECTIONS: SettingsSection[] = [ "chat agent", "prompt for model", "always use default", + // FNXC:GitHubImportTranslate 2026-07-15-16:20: the import-translate lane is picked here. + "translate", + "translation", + "translation model", + "import translation model", + "import auto-translation model", + "auto-translate model", ], searchableKeys: [ "settings.projectModels.chatHeading", diff --git a/packages/dashboard/app/components/__tests__/GitHubImportAutoTranslate.test.tsx b/packages/dashboard/app/components/__tests__/GitHubImportAutoTranslate.test.tsx new file mode 100644 index 0000000000..f209277fa6 --- /dev/null +++ b/packages/dashboard/app/components/__tests__/GitHubImportAutoTranslate.test.tsx @@ -0,0 +1,271 @@ +// @vitest-environment jsdom +/* +FNXC:GitHubImportTranslate 2026-07-15-17:05: +Auto-translation must be NON-BLOCKING and run in the background: the issue list renders immediately in +the original language, translated titles stream in per chunk as they land, and nothing in the panel +awaits the run. These pin that contract — a regression to a single all-or-nothing request would show up +as "no translations until every issue finishes", which is exactly what these detect. +*/ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { renderHook, waitFor, cleanup } from "@testing-library/react"; + +const { autoTranslateImportIssues } = vi.hoisted(() => ({ autoTranslateImportIssues: vi.fn() })); + +/* +FNXC:GitHubImportTranslate 2026-07-15-17:05: +Mock the api module WITHOUT importOriginal: `app/api/legacy.ts` is enormous and pulling the real module +into this worker exhausts the jsdom heap (OOM), for three functions the hook actually uses. +*/ +vi.mock("../../api", () => ({ + autoTranslateImportIssues, + translateImportContent: vi.fn(), + getTranslateErrorMessage: (err: unknown) => (err instanceof Error ? err.message : "error"), +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (_k: string, f?: string) => f ?? _k }), +})); + +import { + useGitHubImportAutoTranslate, + AUTO_TRANSLATE_CHUNK_SIZE, + hashImportItemsForKey, +} from "../GitHubImportTranslateControls"; + +function makeItems(n: number) { + return Array.from({ length: n }, (_, i) => ({ + number: i + 1, + title: `t${i + 1}`, + body: "b", + state: "open" as const, + })); +} + +function reply(items: { number: number }[]) { + return { + enabled: true, + targetLocale: "en", + capped: false, + translations: Object.fromEntries( + items.map((i) => [i.number, { title: `T${i.number}`, body: "B" }]), + ), + }; +} + +/* +FNXC:GitHubImportTranslate 2026-07-15-17:05: +Block body on purpose: `() => mock.mockReset()` implicitly RETURNS the mock, and vitest treats a +function returned from beforeEach as a teardown callback — it then invokes the mock with zero +arguments, which corrupts mock.calls and blows up any implementation that reads its args. +*/ +beforeEach(() => { + autoTranslateImportIssues.mockReset(); +}); +afterEach(() => { + cleanup(); +}); + +const base = { enabled: true, owner: "o", repo: "r", targetLocale: "en" as const }; + +describe("useGitHubImportAutoTranslate — background streaming", () => { + it("returns immediately with no translations (the list never waits on it)", () => { + autoTranslateImportIssues.mockReturnValue(new Promise(() => {})); // never resolves + const { result } = renderHook(() => useGitHubImportAutoTranslate({ ...base, items: makeItems(3) })); + // Synchronously after render: nothing translated, caller renders originals. + expect(result.current.translations.size).toBe(0); + }); + + it("streams each chunk in as it lands instead of waiting for the whole page", async () => { + const items = makeItems(AUTO_TRANSLATE_CHUNK_SIZE * 2); + let releaseSecond: (v: unknown) => void = () => {}; + const second = new Promise((res) => { releaseSecond = res; }); + + autoTranslateImportIssues + .mockImplementationOnce((_o, _r, chunk) => Promise.resolve(reply(chunk))) + .mockImplementationOnce((_o, _r, chunk) => second.then(() => reply(chunk))); + + const { result } = renderHook(() => useGitHubImportAutoTranslate({ ...base, items })); + + // First chunk's translations are visible while the second is still in flight. + await waitFor(() => expect(result.current.translations.size).toBe(AUTO_TRANSLATE_CHUNK_SIZE)); + expect(result.current.translations.get(1)?.title).toBe("T1"); + expect(result.current.loading).toBe(true); + + releaseSecond(null); + await waitFor(() => expect(result.current.translations.size).toBe(items.length)); + await waitFor(() => expect(result.current.loading).toBe(false)); + }); + + it("chunks the work rather than sending one request for the whole page", async () => { + const items = makeItems(AUTO_TRANSLATE_CHUNK_SIZE * 2); + autoTranslateImportIssues.mockImplementation((_o, _r, chunk) => Promise.resolve(reply(chunk))); + + const { result } = renderHook(() => useGitHubImportAutoTranslate({ ...base, items })); + await waitFor(() => expect(result.current.translations.size).toBe(items.length)); + + expect(autoTranslateImportIssues).toHaveBeenCalledTimes(2); + for (const call of autoTranslateImportIssues.mock.calls) { + expect(call[2].length).toBeLessThanOrEqual(AUTO_TRANSLATE_CHUNK_SIZE); + } + }); + + it("keeps earlier chunks when a later chunk fails (fail-soft, not all-or-nothing)", async () => { + const items = makeItems(AUTO_TRANSLATE_CHUNK_SIZE * 2); + autoTranslateImportIssues + .mockImplementationOnce((_o, _r, chunk) => Promise.resolve(reply(chunk))) + .mockImplementationOnce(() => Promise.reject(new Error("boom"))); + + const { result } = renderHook(() => useGitHubImportAutoTranslate({ ...base, items })); + await waitFor(() => expect(result.current.error).toBeTruthy()); + expect(result.current.translations.size).toBe(AUTO_TRANSLATE_CHUNK_SIZE); + }); + + it("stops the background run when the server reports the setting is off", async () => { + const items = makeItems(AUTO_TRANSLATE_CHUNK_SIZE * 2); + autoTranslateImportIssues.mockResolvedValue({ enabled: false, targetLocale: null, capped: false, translations: {} }); + + const { result } = renderHook(() => useGitHubImportAutoTranslate({ ...base, items })); + // Wait for the run to FINISH (loading clears) rather than sleeping: a real-time + // wait would make this negative assertion scheduler-dependent. + await waitFor(() => expect(result.current.loading).toBe(false)); + // Must not have marched through the remaining chunks. + expect(autoTranslateImportIssues).toHaveBeenCalledTimes(1); + }); + + it("never calls the server when auto-translate is disabled", async () => { + const { result } = renderHook(() => useGitHubImportAutoTranslate({ ...base, enabled: false, items: makeItems(5) })); + // Disabled means the effect never starts a run, so there is nothing to wait for. + expect(result.current.loading).toBe(false); + expect(autoTranslateImportIssues).not.toHaveBeenCalled(); + }); + + /* + FNXC:GitHubImportTranslate 2026-07-15-18:40: + Regression: PR #2147 review. A server "off" answer returned early and skipped the only + setLoading(false), so the panel span stayed in the loading state indefinitely. + */ + it("clears loading when the server reports the setting is off", async () => { + autoTranslateImportIssues.mockResolvedValue({ enabled: false, targetLocale: null, capped: false, translations: {} }); + const { result } = renderHook(() => useGitHubImportAutoTranslate({ ...base, items: makeItems(4) })); + await waitFor(() => expect(autoTranslateImportIssues).toHaveBeenCalled()); + await waitFor(() => expect(result.current.loading).toBe(false)); + }); + + it("clears loading even when every chunk fails", async () => { + autoTranslateImportIssues.mockRejectedValue(new Error("boom")); + const { result } = renderHook(() => useGitHubImportAutoTranslate({ ...base, items: makeItems(4) })); + await waitFor(() => expect(result.current.error).toBeTruthy()); + await waitFor(() => expect(result.current.loading).toBe(false)); + }); + + /* + FNXC:GitHubImportTranslate 2026-07-15-18:40: + Regression: PR #2147 review. Keying the request on issue NUMBERS alone meant an edited issue — + same number, new prose — produced an unchanged key, so the panel never re-requested and kept + showing the translation of the OLD text. + */ + it("re-requests when an issue's body is edited (same number, new prose)", async () => { + autoTranslateImportIssues.mockImplementation((_o, _r, chunk) => Promise.resolve(reply(chunk))); + const first = [{ number: 1, title: "t1", body: "original", state: "open" as const }]; + const { rerender, result } = renderHook( + ({ items }) => useGitHubImportAutoTranslate({ ...base, items }), + { initialProps: { items: first } }, + ); + await waitFor(() => expect(autoTranslateImportIssues).toHaveBeenCalledTimes(1)); + + // Same issue number, edited body -> must re-request. + rerender({ items: [{ number: 1, title: "t1", body: "EDITED", state: "open" as const }] }); + await waitFor(() => expect(autoTranslateImportIssues).toHaveBeenCalledTimes(2)); + expect(result.current.translations.get(1)?.title).toBe("T1"); + }); + + it("does NOT re-request when the same issue set re-renders unchanged", async () => { + autoTranslateImportIssues.mockImplementation((_o, _r, chunk) => Promise.resolve(reply(chunk))); + const items = [{ number: 1, title: "t1", body: "same", state: "open" as const }]; + const { rerender, result } = renderHook( + ({ items: i }) => useGitHubImportAutoTranslate({ ...base, items: i }), + { initialProps: { items } }, + ); + await waitFor(() => expect(result.current.loading).toBe(false)); + + // A fresh array identity with identical content must not re-bill. Settling on + // `loading` keeps this deterministic instead of racing a real-time sleep. + rerender({ items: [{ number: 1, title: "t1", body: "same", state: "open" as const }] }); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(autoTranslateImportIssues).toHaveBeenCalledTimes(1); + }); + + /* + FNXC:GitHubImportTranslate 2026-07-15-20:15: + Regression: PR #2147 review. `capped` used to be state written from the translation effect, which + forced `capExceeded` into that effect's deps — so a 51st open issue appearing (eligible first 50 + unchanged) cleared the translations and re-requested all 50 just to update a badge. + */ + it("does not restart translation when the open count crosses the cap", async () => { + autoTranslateImportIssues.mockImplementation((_o, _r, chunk) => Promise.resolve(reply(chunk))); + const fifty = makeItems(50); + const { rerender, result } = renderHook( + ({ items }) => useGitHubImportAutoTranslate({ ...base, items }), + { initialProps: { items: fifty } }, + ); + await waitFor(() => expect(result.current.loading).toBe(false)); + const callsAfterFirstRun = autoTranslateImportIssues.mock.calls.length; + expect(result.current.capped).toBe(false); + expect(result.current.translations.size).toBe(50); + + // A 51st open issue appears: the eligible first 50 are unchanged, so this must + // flip `capped` WITHOUT clearing translations or re-billing the page. + rerender({ items: [...fifty, { number: 51, title: "t51", body: "b", state: "open" as const }] }); + await waitFor(() => expect(result.current.capped).toBe(true)); + + expect(autoTranslateImportIssues.mock.calls.length).toBe(callsAfterFirstRun); + expect(result.current.translations.size).toBe(50); + }); + + it("never sends closed issues and caps the page at the 50 most recent open", async () => { + const items = [...makeItems(60), { number: 999, title: "x", body: "b", state: "closed" as const }]; + autoTranslateImportIssues.mockImplementation((_o, _r, chunk) => Promise.resolve(reply(chunk))); + + const { result } = renderHook(() => useGitHubImportAutoTranslate({ ...base, items })); + await waitFor(() => expect(result.current.loading).toBe(false)); + + const sent = autoTranslateImportIssues.mock.calls.flatMap((c) => c[2] as { number: number }[]); + expect(sent).toHaveLength(50); + expect(sent.some((i) => i.number === 999)).toBe(false); + expect(result.current.capped).toBe(true); + }); +}); + +/* +FNXC:GitHubImportTranslate 2026-07-15-19:30: +Regression: PR #2147 review. The signature delimiter was ambiguous because prose may contain it, so +moving a `|` between title and body produced an unchanged signature and the panel kept serving the +OLD translation. Length-prefixing makes the encoding injective. +*/ +describe("hashImportItemsForKey", () => { + it("distinguishes content that only differs in where a delimiter falls", () => { + const a = [{ number: 1, title: "a|b", body: "c", state: "open" as const }]; + const b = [{ number: 1, title: "a", body: "b|c", state: "open" as const }]; + expect(hashImportItemsForKey(a)).not.toBe(hashImportItemsForKey(b)); + }); + + it("distinguishes an edit that shifts text across the field boundary", () => { + const a = [{ number: 1, title: "ab", body: "c", state: "open" as const }]; + const b = [{ number: 1, title: "a", body: "bc", state: "open" as const }]; + expect(hashImportItemsForKey(a)).not.toBe(hashImportItemsForKey(b)); + }); + + it("is stable for identical content and changes when prose changes", () => { + const items = [{ number: 1, title: "t", body: "b", state: "open" as const }]; + expect(hashImportItemsForKey(items)).toBe(hashImportItemsForKey([{ ...items[0] }])); + expect(hashImportItemsForKey(items)).not.toBe( + hashImportItemsForKey([{ ...items[0], body: "b2" }]), + ); + }); + + it("distinguishes different issue numbers with identical prose", () => { + const a = [{ number: 1, title: "t", body: "b", state: "open" as const }]; + const b = [{ number: 2, title: "t", body: "b", state: "open" as const }]; + expect(hashImportItemsForKey(a)).not.toBe(hashImportItemsForKey(b)); + }); +}); diff --git a/packages/dashboard/app/components/settings/sections/GeneralSection.tsx b/packages/dashboard/app/components/settings/sections/GeneralSection.tsx index 8ba1b14c28..7346eff446 100644 --- a/packages/dashboard/app/components/settings/sections/GeneralSection.tsx +++ b/packages/dashboard/app/components/settings/sections/GeneralSection.tsx @@ -1,7 +1,5 @@ import { useEffect, useMemo, useState, type ReactNode } from "react"; import { DEPRECATED_BUILTIN_WORKFLOW_IDS, isLocale, SUPPORTED_LOCALES, type WorkflowDefinition } from "@fusion/core"; -import { SettingsToggleRow } from "../SettingsToggleRow"; -import { SettingsSelectRow } from "../SettingsSelectRow"; /* FNXC:GitHubImportTranslate 2026-07-15-09:30: Locale labels come from core's shared `localeDisplayName` (endonyms), NOT from the LanguageSelector component: importing a component module for a constant drags its i18n/react-i18next initialization into every consumer of this section, which breaks tests that mock react-i18next narrowly. @@ -372,6 +370,13 @@ export function GeneralSection({ scopeBanner, form, setForm, projectId, addToast {t("settings.general.whenEnabledImportedGitHubIssuesUseTheirSource", "When enabled, GitHub issue imports become tracked tasks that adopt the source issue. This does not turn GitHub tracking on for ordinary new tasks. Default: disabled.")} {/* + FNXC:GitHubImportTranslate 2026-07-15-16:35: + These use the section's native `form-group` + `checkbox-label` / `select` markup rather than the + SettingsToggleRow/SettingsSelectRow primitives. Those primitives render a right-aligned toggle + SWITCH, which read as a foreign control next to the plain left-of-text checkboxes every other + GitHub/import setting in this section uses. Matching the neighbours is the point: a settings + section with two different checkbox idioms looks broken regardless of which is nicer in isolation. + FNXC:GitHubImportTranslate 2026-07-15-09:30: Both controls live beside the other import-scoped GitHub settings because they only ever affect the Import Tasks panel, never ordinary task creation. @@ -383,33 +388,21 @@ export function GeneralSection({ scopeBanner, form, setForm, projectId, addToast dashboard language", so an operator who switches the dashboard to Korean gets Korean translations without touching this setting twice. */} - setForm((f) => ({ ...f, githubImportAutoTranslate: v ?? undefined }))} - /> - ({ value: locale, label: localeDisplayName(locale) })), - ], - }} - value={form.importTranslateTargetLocale ?? ""} - onChange={(v) => setForm((f) => ({ - ...f, - importTranslateTargetLocale: v && isLocale(v) ? v : undefined, - }))} - /> +
+ + {t("settings.general.autoTranslateImportedIssuesHelp", "When enabled, the Import Tasks panel automatically translates foreign-language issue titles and bodies into the target language below and shows the translation by default. You can always switch back to the original text, and imported tasks carry the translated text. Default: disabled.")} +
+
+ + + {t("settings.general.translationTargetLanguageHelp", "Language imported issues are translated into when auto-translation is enabled. No default — unset inherits the dashboard language.")} +
setForm((f) => ({ ...f, githubTrackingDefaultRepo: nextValue || undefined }))}/> diff --git a/packages/dashboard/app/components/settings/sections/__tests__/GeneralSection.importTranslate.test.tsx b/packages/dashboard/app/components/settings/sections/__tests__/GeneralSection.importTranslate.test.tsx new file mode 100644 index 0000000000..2283ae1e6f --- /dev/null +++ b/packages/dashboard/app/components/settings/sections/__tests__/GeneralSection.importTranslate.test.tsx @@ -0,0 +1,145 @@ +// @vitest-environment jsdom +/* +FNXC:GitHubImportTranslate 2026-07-15-16:35: +The import auto-translate controls must render with the SECTION'S native checkbox idiom — a plain +`checkbox-label` with the input BEFORE the text — not the right-aligned toggle-switch primitive that +SettingsToggleRow renders. Two different checkbox idioms in one settings section read as a bug, so +this pins the markup (and its parity with the neighbouring GitHub/import checkbox) rather than +trusting it to survive a refactor back onto the primitive. +*/ +import { useState } from "react"; +import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; +import { render, screen, fireEvent, cleanup } from "@testing-library/react"; + +import { GeneralSection } from "../GeneralSection"; +import type { SettingsFormState } from "../context"; +const { fetchWorkflows, fetchProjectDefaultWorkflow } = vi.hoisted(() => ({ + fetchWorkflows: vi.fn(), + fetchProjectDefaultWorkflow: vi.fn(), +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (_key: string, fallback?: string) => fallback ?? _key, + }), +})); + +/* +FNXC:GitHubImportTranslate 2026-07-15-20:15: +Mock the NARROW seam, not the whole module (PR #2147 review). `importOriginal()` pulls in the entire +`app/api/legacy.ts` implementation, which is what exhausts the jsdom heap in the sibling translation +test. GeneralSection imports exactly one thing from `../../../api` — `fetchWorkflows` — so there is +nothing else to preserve. +*/ +vi.mock("../../../../api", () => ({ + fetchWorkflows, + // Pulled in by WorkflowSelector, which GeneralSection renders. + fetchProjectDefaultWorkflow, +})); + +beforeEach(() => { + fetchWorkflows.mockReset(); + fetchWorkflows.mockResolvedValue([]); + fetchProjectDefaultWorkflow.mockReset(); + fetchProjectDefaultWorkflow.mockResolvedValue(null); +}); +afterEach(() => cleanup()); + +function GeneralHost({ initialForm, onSetForm }: { + initialForm: Partial; + onSetForm?: (next: SettingsFormState) => void; +}) { + const [form, setForm] = useState(initialForm as SettingsFormState); + return ( + { + setForm((prev) => { + const next = (typeof updater === "function" ? (updater as (f: SettingsFormState) => SettingsFormState)(prev) : updater); + onSetForm?.(next); + return next; + }); + }} + addToast={vi.fn()} + prefixError={null} + setPrefixError={vi.fn()} + projectTrackingRepoOptions={[]} + projectTrackingRepoLoading={false} + projectTrackingRepoError={null} + /> + ); +} + +describe("GeneralSection - import auto-translate controls", () => { + it("renders the auto-translate control as a checkbox using the section's checkbox-label idiom", () => { + render(); + const input = document.getElementById("githubImportAutoTranslate") as HTMLInputElement; + expect(input).not.toBeNull(); + expect(input.type).toBe("checkbox"); + expect(input.closest("label")?.className).toContain("checkbox-label"); + }); + + it("puts the checkbox BEFORE its text, like every other checkbox in the section", () => { + render(); + const input = document.getElementById("githubImportAutoTranslate")!; + const label = input.closest("label")!; + expect(label.firstElementChild).toBe(input); + expect(label.textContent).toContain("Auto-translate imported issues"); + }); + + it("matches the neighbouring imported-issue checkbox's structure exactly", () => { + render(); + const mine = document.getElementById("githubImportAutoTranslate")!.closest("label")!; + const neighbour = document.getElementById("githubLinkImportedIssuesToTracking")!.closest("label")!; + expect(mine.className).toBe(neighbour.className); + expect(mine.firstElementChild?.tagName).toBe(neighbour.firstElementChild?.tagName); + }); + + it("is unchecked by default and stores the opt-in when toggled", () => { + let latest: SettingsFormState | undefined; + render( { latest = f; }} />); + const input = document.getElementById("githubImportAutoTranslate") as HTMLInputElement; + expect(input.checked).toBe(false); + + fireEvent.click(input); + expect(latest?.githubImportAutoTranslate).toBe(true); + }); + + it("clears back to undefined (not false) when switched off, so it stays 'unset'", () => { + let latest: SettingsFormState | undefined; + render(} onSetForm={(f) => { latest = f; }} />); + const input = document.getElementById("githubImportAutoTranslate") as HTMLInputElement; + expect(input.checked).toBe(true); + + fireEvent.click(input); + expect(latest?.githubImportAutoTranslate).toBeUndefined(); + }); + + it("renders the target-language select with the section's select idiom and the inherit option", () => { + render(); + const select = screen.getByTestId("import-translate-target-locale-select") as HTMLSelectElement; + expect(select.className).toContain("select"); + expect(select.value).toBe(""); + expect([...select.options].map((o) => o.textContent)).toContain("Follow dashboard language"); + }); + + it("reflects and stores an explicit target locale", () => { + let latest: SettingsFormState | undefined; + render( { latest = f; }} />); + const select = screen.getByTestId("import-translate-target-locale-select") as HTMLSelectElement; + + fireEvent.change(select, { target: { value: "ko" } }); + expect(latest?.importTranslateTargetLocale).toBe("ko"); + }); + + it("stores undefined (inherit dashboard language) when the blank option is chosen", () => { + let latest: SettingsFormState | undefined; + render(} onSetForm={(f) => { latest = f; }} />); + const select = screen.getByTestId("import-translate-target-locale-select") as HTMLSelectElement; + expect(select.value).toBe("ko"); + + fireEvent.change(select, { target: { value: "" } }); + expect(latest?.importTranslateTargetLocale).toBeUndefined(); + }); +});