Files
fusion/packages/dashboard/vite.config.ts
gsxdsm 0863c0fb58 feat(dashboard): auto-translate foreign-language GitHub issues on import (#2141)
## Why

The Import Tasks panel routinely lists issues in languages the operator
cannot read. Translation already shipped in #2128, but deliberately
**opt-in and preview-only** — its header comment read *"Translation is
opt-in (never automatic) so import provenance stays faithful until the
operator asks."*

This reverses that decision **behind a default-off setting**, so
operators who never opt in keep byte-faithful import provenance. The
superseded comment is kept and annotated rather than deleted, so the
reason the rule changed stays in the code.

### The structural gap #2128 left

`POST /github/issues/import` accepts only `{owner, repo, issueNumber}`
and **re-fetches the issue server-side**. A translation held in React
state could never reach the created task, and the in-memory cache died
with the modal. That is why the cache here is server-side rather than in
the hook — it's what makes "imported issues carry the translated
version" actually true.

## What operators get

Auto-translate is **off by default**. When enabled:

- The **50 most recent OPEN** foreign-language issues translate on panel
load — **list titles**, not just the preview, so the list reads in your
language before you click anything.
- Translations show **by default**, with a toggle back to the original
(hover a translated list title to see the original).
- Translations **persist until the issue closes**, so re-opening the
panel neither waits nor re-bills.
- **Both single and batch import** carry the translation, so the created
task reads like the preview you approved.
- A **target language** setting (unset = follow the dashboard language)
and a dedicated **model lane**, so you can pin a cheap/fast model
without dragging the summarization lane onto it.

## Notable decisions

| Decision | Why |
|---|---|
| Detect **before** the model | An issue already in the target language
is never sent. Without this, an English repo with the setting on would
bill every issue to return its input unchanged. |
| Detection moved to `@fusion/core` | The panel and the server must not
disagree about which issues are foreign; two copies of a heuristic
drift. |
| Own rate-limit budget | Translation shared a 10/hour budget with
refine/goal-draft. Fanning out per-issue would fail partway **and**
starve refine for the hour. |
| Cache keyed on a **source hash** | An edited issue misses the cache
and re-translates instead of serving stale prose. |
| Import is **cache-read only** | A miss imports the original. Import
must never block on, or fail because of, translation. |
| `project_id` leads the cache PK + full RLS contract | All projects
share one flat `project` schema. `verification_cache`'s PK predates that
discipline; this table does not copy that mistake. |

## Verification

- ✅ `pnpm lint`, `@fusion/core` + `@fusion/dashboard` typecheck
- ✅ `pnpm verify:fast` — build + scoped typecheck + real boot smoke
(`/api/health`)
- ✅ `pnpm test:gate` — 479 tests
- ✅ 19 new tests covering the billing invariants
(off/closed/same-language ⇒ **no model call**), cache hit/miss-on-edit,
the 50 cap, and per-item fail-soft
- ✅ `schema-applier` real-Postgres suite (46 tests) exercises migration
`0010` and its isolation invariant

**Pre-existing failures NOT touched** (confirmed red on `HEAD` before
this branch): `AppearanceSection`'s task-popup test, and two PG-cutover
keys (`sqliteMigrationNotice`, `postgresMigrationInboxMessageSentAt`)
missing description mappings. I left the latter rather than guess an
allowlist entry that could mask a real coverage gap.

## Reviewer notes

- Short Latin-script prose (a one-line Spanish title) rates only
*medium* confidence and won't auto-translate — the existing heuristic is
deliberately conservative so English issues are never billed. CJK
detects regardless of length. The threshold is the knob if you'd rather
bias toward translating.
- The RLS/isolation contract in migration `0010` is the part most worth
a careful look.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 14:06:42 -07:00

228 lines
8.2 KiB
TypeScript

import { defineConfig, type Plugin } from "vite";
import react from "@vitejs/plugin-react";
import { dirname, resolve } from "node:path";
import { mkdirSync, writeFileSync, readFileSync, readdirSync, statSync } from "node:fs";
import { execSync } from "node:child_process";
import { createHash } from "node:crypto";
/**
* Generate a deterministic build version string.
*
* Uses the short git commit hash + a content hash of key files so the version
* only changes when the actual source (or uncommitted changes to those files)
* changes. Falls back to package.json version when git is unavailable.
*/
function computeBuildVersion(): string {
// Get git short hash or fall back to package.json version
let prefix: string;
try {
prefix = execSync("git rev-parse --short HEAD", { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
} catch {
try {
const pkg = JSON.parse(readFileSync(resolve(__dirname, "package.json"), "utf-8"));
prefix = typeof pkg.version === "string" ? pkg.version : "0.0.0";
} catch {
prefix = "0.0.0";
}
}
// Content hash of the entire app/ source tree + package.json. Hashing only
// a couple of entry files (the previous behavior) meant that edits to any
// other component or stylesheet produced an identical build version, so the
// dashboard's version-check poll never noticed the new bundle and the
// "reload available" prompt never fired (FN-3333 follow-up).
const hasher = createHash("sha1");
const appDir = resolve(__dirname, "app");
// Collect, sort, then hash so the order is stable across platforms and runs.
const files: string[] = [];
const walk = (dir: string): void => {
let entries: string[];
try {
entries = readdirSync(dir);
} catch {
return;
}
for (const entry of entries) {
if (entry === "node_modules" || entry === "__tests__" || entry.startsWith(".")) continue;
const full = resolve(dir, entry);
let info: ReturnType<typeof statSync>;
try {
info = statSync(full);
} catch {
continue;
}
if (info.isDirectory()) {
walk(full);
} else if (info.isFile()) {
files.push(full);
}
}
};
walk(appDir);
files.sort();
for (const f of files) {
try {
hasher.update(f.slice(appDir.length));
hasher.update(readFileSync(f));
} catch {
// file may have been deleted between readdir and read — skip
}
}
try {
hasher.update(readFileSync(resolve(__dirname, "package.json")));
} catch {
// ignore
}
const contentHash = hasher.digest("hex").slice(0, 8);
return `${prefix}-${contentHash}`;
}
const buildVersion = computeBuildVersion();
function emitVersionJson(): Plugin {
return {
name: "fusion-emit-version-json",
apply: "build",
closeBundle() {
const outFile = resolve(__dirname, "dist/client/version.json");
mkdirSync(dirname(outFile), { recursive: true });
writeFileSync(outFile, `${JSON.stringify({ version: buildVersion })}\n`);
console.log(`[fusion] build version: ${buildVersion}`);
},
};
}
function ensureThemeDataStylesheetOrder(): Plugin {
return {
name: "fusion-theme-data-link-order",
apply: "build",
enforce: "post",
transformIndexHtml(html) {
const headMatch = html.match(/<head>[\s\S]*?<\/head>/i);
if (!headMatch) return html;
const head = headMatch[0];
const themeLinkMatch = head.match(/<link[^>]*id=["']theme-data["'][^>]*>/i);
if (!themeLinkMatch) return html;
const themeLink = themeLinkMatch[0];
const headWithoutThemeLink = head.replace(themeLink, "");
const reorderedHead = headWithoutThemeLink.replace(/<\/head>$/i, `${themeLink}\n </head>`);
return html.replace(head, reorderedHead);
},
};
}
export default defineConfig({
root: "app",
plugins: [react(), ensureThemeDataStylesheetOrder(), emitVersionJson()],
define: {
__BUILD_VERSION__: JSON.stringify(buildVersion),
},
resolve: {
alias: {
/*
FNXC:GitHubImportTranslate 2026-07-15-09:30:
The browser bundle aliases `@fusion/core` to the leaf `types.ts` to keep Node-only deps out of the client, so anything the app imports from core must resolve to a browser-safe module.
Language detection is pure string logic shared with the server; alias its subpath explicitly rather than widening the `@fusion/core` alias, which would drag the full index (and its Node deps) into the bundle.
This alias MUST precede the `@fusion/core` entry — Vite matches aliases in order, so the broader key would otherwise swallow the subpath.
*/
"@fusion/core/detect-content-language": resolve(__dirname, "../core/src/detect-content-language.ts"),
"@fusion/core": resolve(__dirname, "../core/src/types.ts"),
"@fusion/dashboard/app/components/TaskCard": resolve(__dirname, "app/components/TaskCard.tsx"),
// FNXC:PluginBuild 2026-06-22-03:50: Bundled plugin source can import the dashboard's shared ViewHeader through the package export; Vite needs the same source alias during dashboard builds so plugin UI normalization does not fail only in CI merge builds.
"@fusion/dashboard/app/components/ViewHeader": resolve(__dirname, "app/components/ViewHeader.tsx"),
"@fusion/dashboard/app/plugins/types": resolve(__dirname, "app/plugins/types.ts"),
"@fusion/dashboard/app/utils/projectStorage": resolve(__dirname, "app/utils/projectStorage.ts"),
"@fusion/dashboard/app/utils/taskStuck": resolve(__dirname, "app/utils/taskStuck.ts"),
"@fusion-plugin-examples/compound-engineering/dashboard-view": resolve(
__dirname,
"../../plugins/fusion-plugin-compound-engineering/src/dashboard-view.tsx",
),
"@fusion-plugin-examples/compound-engineering": resolve(
__dirname,
"../../plugins/fusion-plugin-compound-engineering/src/index.ts",
),
"@fusion-plugin-examples/dependency-graph/dashboard-view": resolve(
__dirname,
"../../plugins/fusion-plugin-dependency-graph/src/dashboard-view.tsx",
),
"@fusion-plugin-examples/dependency-graph": resolve(
__dirname,
"../../plugins/fusion-plugin-dependency-graph/src/index.ts",
),
"@fusion-plugin-examples/linear-import/dashboard-view": resolve(
__dirname,
"../../plugins/fusion-plugin-linear-import/src/dashboard-view.tsx",
),
"@fusion-plugin-examples/linear-import": resolve(
__dirname,
"../../plugins/fusion-plugin-linear-import/src/index.ts",
),
},
},
optimizeDeps: {
include: [
"@xterm/xterm",
"@xterm/addon-fit",
"@xterm/addon-web-links",
"@xterm/addon-webgl",
],
},
build: {
outDir: "../dist/client",
emptyOutDir: true,
manifest: true,
target: "es2022",
cssCodeSplit: true,
sourcemap: false,
assetsInlineLimit: 4096,
rollupOptions: {
output: {
entryFileNames: "assets/[name]-[hash].js",
chunkFileNames: "assets/[name]-[hash].js",
assetFileNames: "assets/[name]-[hash][extname]",
manualChunks: (id) => {
if (id.includes("/node_modules/react/") || id.includes("/node_modules/react-dom/")) {
return "vendor-react";
}
if (id.includes("/node_modules/@xterm/xterm/")) {
return "vendor-xterm";
}
if (id.includes("/node_modules/@codemirror/")) {
return "vendor-codemirror";
}
if (
id.includes("/node_modules/i18next/") ||
id.includes("/node_modules/react-i18next/") ||
id.includes("/node_modules/i18next-browser-languagedetector/") ||
id.includes("/node_modules/i18next-resources-to-backend/")
) {
return "vendor-i18n";
}
if (id.includes("/node_modules/@xyflow/")) {
return "vendor-reactflow";
}
return undefined;
},
},
},
},
server: {
proxy: {
// Keep Vite source modules under app/api* on the dev server while proxying real API endpoints.
"^/api(?!/.*\\.[jt]sx?(?:\\?|$))(/|$)": {
target: `http://localhost:${process.env.FUSION_API_PORT ?? "4040"}`,
changeOrigin: true,
ws: true,
},
},
},
});