Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 15 additions & 5 deletions .agent-maintainer/dashboard-source-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,21 @@
"nonblank": 424,
"physical": 450
},
"frontend/dashboard/src/app/zh-Hans/advancedPages.ts": {
"group": "source",
"nonblank": 456,
"physical": 468
},
"frontend/dashboard/src/app/zh-Hans/corePages.ts": {
"group": "source",
"nonblank": 716,
"physical": 733
},
"frontend/dashboard/src/app/zh-Hans/diagnosticsReports.ts": {
"group": "source",
"nonblank": 754,
"physical": 773
},
"frontend/dashboard/src/features/call-investigator/CallInvestigatorPage.tsx": {
"group": "source",
"nonblank": 762,
Expand All @@ -45,11 +60,6 @@
"nonblank": 639,
"physical": 683
},
"frontend/dashboard/src/features/shared/tables.tsx": {
"group": "source",
"nonblank": 487,
"physical": 509
},
"frontend/dashboard/src/styles/context-evidence.css": {
"group": "styles",
"nonblank": 352,
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## Unreleased

- Complete the Simplified Chinese dashboard experience across React views, visualization metadata, tables, accessibility text, CLI help, and dashboard lifecycle messages while preserving user-provided data verbatim.
- Improve the Overview token-flow chart at constrained desktop widths with a taller single-column layout, larger Sankey node gaps, and label-safe padding; keep virtualized evidence-table actions on one row.
- Apply official long-context input, cached-input, and output price multipliers to concrete calls above 272K input tokens when the selected OpenAI service tier publishes long-context rates, while keeping aggregate summaries from being incorrectly thresholded.

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ CODEX_USAGE_TRACKER_LANG=vi codex-usage-tracker serve-dashboard --open

The dashboard also includes a language selector. Browser selections are stored locally and can override the generated default for that browser.

Supported dashboard locales include English, Vietnamese, Spanish, French, German, Portuguese, Japanese, Simplified Chinese, Korean, Russian, Italian, and Arabic. This localizes dashboard UI text, not raw Codex log content, thread names, project names, paths, full CLI output, or data exports.
Supported dashboard locales include English, Vietnamese, Spanish, French, German, Portuguese, Japanese, Simplified Chinese, Korean, Russian, Italian, and Arabic. Simplified Chinese also localizes CLI help and dashboard lifecycle messages. Localization never rewrites raw Codex log content, thread names, project names, paths, or data exports.

### Adding A Dashboard Language

Expand Down
10 changes: 5 additions & 5 deletions frontend/dashboard/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { currentViewCsvExport } from './app/currentViewExport';
import { errorMessage, refreshProgressLabel, type RefreshOptions } from './app/dashboardRefresh';
import { historyScopeFromPayload, historyScopeStatusLabel } from './app/historyScope';
import { createShellI18n, initialDashboardLanguage, storeDashboardLanguage } from './app/i18n';
import { ShellI18nProvider } from './app/i18nContext';
import { LocalizedShellI18nProvider } from './app/DocumentLocalizationBridge';
import { modelWithLegacyShellFilters } from './app/legacyShellFilters';
import { navItems, secondaryNavItems, type ViewId } from './app/navigation';
import { RowLimitControl } from './app/RowLimitControl';
Expand Down Expand Up @@ -525,8 +525,8 @@ function clearInvestigationPreset() {
}

return (
<ShellI18nProvider value={shellI18n}>
<div className="app-shell">
<LocalizedShellI18nProvider value={shellI18n}>
<div className="app-shell" data-dashboard-localization-root>
<aside className="sidebar">
<div className="brand">
<div className="brand-mark">
Expand Down Expand Up @@ -597,7 +597,7 @@ placeholder={shellI18n.t('filter.search_placeholder', 'Search calls, threads, mo
{shellI18n.languages.length > 1 ? (
<label className="topbar-select">
<span>{shellI18n.t('language.label', 'Language')}</span>
<select aria-label={shellI18n.t('language.label', 'Language')} value={shellI18n.language} onChange={event => handleLanguageChange(event.target.value)}>
<select data-localization-skip="true" aria-label={shellI18n.t('language.label', 'Language')} value={shellI18n.language} onChange={event => handleLanguageChange(event.target.value)}>
{shellI18n.languages.map(languageOption => (
<option key={languageOption.code} value={languageOption.code}>
{languageOption.native_name || languageOption.english_name || languageOption.code}
Expand Down Expand Up @@ -730,7 +730,7 @@ aria-label="History scope"
</button>
) : null}
</div>
</ShellI18nProvider>
</LocalizedShellI18nProvider>
);

function onRefresh() {
Expand Down
82 changes: 82 additions & 0 deletions frontend/dashboard/src/app/DocumentLocalizationBridge.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { act, render, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it } from 'vitest';

import type { ShellI18n } from './i18n';
import { DocumentLocalizationBridge } from './DocumentLocalizationBridge';
import { ShellI18nProvider } from './i18nContext';

function testI18n(language: 'en' | 'zh-Hans'): ShellI18n {
const translateText = (value: string) => language === 'zh-Hans' && value === 'Overview' ? '概览' : value;
return {
language,
direction: 'ltr',
languages: [],
t: (_key, fallback) => fallback ?? _key,
translateText,
formatText: (template, values) => translateText(template).replace(
/\{(\w+)\}/gu,
(token, key) => String(values[key] ?? token),
),
navLabel: (_view, fallback) => fallback,
};
}

function fixture(language: 'en' | 'zh-Hans') {
return (
<ShellI18nProvider value={testI18n(language)}>
<DocumentLocalizationBridge />
<main data-dashboard-localization-root>
<button
data-testid="marked"
data-localization-attributes="aria-label title"
aria-label="Overview"
title="Overview"
>
Overview
</button>
<button data-testid="unmarked" aria-label="Overview" title="Overview">Overview</button>
<span data-testid="opaque" data-localization-skip="true">Overview</span>
</main>
</ShellI18nProvider>
);
}

afterEach(() => {
document.title = '';
});

describe('DocumentLocalizationBridge', () => {
it('translates only opted-in attributes and restores them when language changes', () => {
const rendered = render(fixture('zh-Hans'));
const marked = rendered.getByTestId('marked');
const unmarked = rendered.getByTestId('unmarked');
const opaque = rendered.getByTestId('opaque');

expect(marked).toHaveAttribute('aria-label', '概览');
expect(marked).toHaveAttribute('title', '概览');
expect(unmarked).toHaveAttribute('aria-label', 'Overview');
expect(unmarked).toHaveAttribute('title', 'Overview');
expect(opaque).toHaveTextContent('Overview');

rendered.rerender(fixture('en'));
expect(marked).toHaveAttribute('aria-label', 'Overview');
expect(marked).toHaveAttribute('title', 'Overview');
});

it('applies the same opt-in rule to dynamically added elements', async () => {
const rendered = render(fixture('zh-Hans'));
const root = rendered.container.querySelector('[data-dashboard-localization-root]');
const marked = document.createElement('button');
marked.dataset.localizationAttributes = 'aria-label';
marked.setAttribute('aria-label', 'Overview');
const unmarked = document.createElement('button');
unmarked.setAttribute('aria-label', 'Overview');

act(() => {
root?.append(marked, unmarked);
});

await waitFor(() => expect(marked).toHaveAttribute('aria-label', '概览'));
expect(unmarked).toHaveAttribute('aria-label', 'Overview');
});
});
173 changes: 173 additions & 0 deletions frontend/dashboard/src/app/DocumentLocalizationBridge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import { useLayoutEffect, type ReactNode } from 'react';

import type { ShellI18n } from './i18n';
import { ShellI18nProvider, useShellI18n } from './i18nContext';

const translatedAttributes = ['aria-description', 'aria-label', 'aria-valuetext', 'title', 'placeholder', 'alt'] as const;
const attributeOptIn = 'data-localization-attributes';
const skippedElementNames = new Set(['CODE', 'KBD', 'PRE', 'SAMP', 'SCRIPT', 'STYLE', 'TEXTAREA']);

type TextTranslationRecord = { source: string; translated: string };
type AttributeTranslationRecord = { source: string; translated: string };

const textTranslations = new WeakMap<Text, TextTranslationRecord>();
const attributeTranslations = new WeakMap<Element, Map<string, AttributeTranslationRecord>>();

export function LocalizedShellI18nProvider({
value,
children,
}: {
value: ShellI18n;
children: ReactNode;
}) {
return (
<ShellI18nProvider value={value}>
<DocumentLocalizationBridge />
{children}
</ShellI18nProvider>
);
}

export function DocumentLocalizationBridge() {
const i18n = useShellI18n();

useLayoutEffect(() => {
const root = document.querySelector<HTMLElement>('[data-dashboard-localization-root]');
if (!root) return undefined;

if (i18n.language !== 'zh-Hans') {
restoreTree(root);
document.title = 'Codex Usage Tracker React Dashboard';
return undefined;
}

document.title = 'Codex Usage Tracker · 用量仪表盘';
localizeTree(root, i18n.translateText);
const observer = new MutationObserver(records => {
for (const record of records) {
if (record.type === 'characterData' && record.target instanceof Text) {
localizeTextNode(record.target, i18n.translateText);
continue;
}
if (record.type === 'attributes' && record.target instanceof Element) {
localizeElementAttributes(record.target, i18n.translateText);
continue;
}
for (const node of record.addedNodes) {
if (node instanceof Text) {
localizeTextNode(node, i18n.translateText);
} else if (node instanceof Element) {
localizeTree(node, i18n.translateText);
}
}
}
});
observer.observe(root, {
attributes: true,
attributeFilter: [...translatedAttributes],
characterData: true,
childList: true,
subtree: true,
});
return () => observer.disconnect();
}, [i18n]);

return null;
}

function localizeTree(root: Element, translate: (value: string) => string) {
if (shouldSkipElement(root)) return;
localizeElementAttributes(root, translate);
const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT);
let node = walker.nextNode();
while (node) {
if (node instanceof Element) {
if (shouldSkipElement(node)) {
node = nextNodeAfterSubtree(walker, node, root);
continue;
}
localizeElementAttributes(node, translate);
} else if (node instanceof Text) {
localizeTextNode(node, translate);
}
node = walker.nextNode();
}
}

function nextNodeAfterSubtree(walker: TreeWalker, element: Element, root: Element): Node | null {
let current: Node | null = element;
while (current && current !== root) {
const sibling = walker.nextSibling();
if (sibling) return sibling;
current = current.parentNode;
if (current) walker.currentNode = current;
}
return null;
}

function localizeTextNode(node: Text, translate: (value: string) => string) {
const parent = node.parentElement;
if (!parent || shouldSkipElement(parent)) return;
const current = node.nodeValue ?? '';
const previous = textTranslations.get(node);
const source = previous && (current === previous.source || current === previous.translated)
? previous.source
: current;
const match = source.match(/^(\s*)([\s\S]*?)(\s*)$/u);
if (!match || !match[2]) return;
const translatedCore = translate(match[2]);
const translated = `${match[1]}${translatedCore}${match[3]}`;
textTranslations.set(node, { source, translated });
if (translated !== current) node.nodeValue = translated;
}

function localizeElementAttributes(element: Element, translate: (value: string) => string) {
if (shouldSkipElement(element)) return;
const enabledAttributes = new Set(
(element.getAttribute(attributeOptIn) ?? '').split(/[\s,]+/u).filter(Boolean),
);
if (!enabledAttributes.size) return;
const records = attributeTranslations.get(element) ?? new Map<string, AttributeTranslationRecord>();
for (const attribute of translatedAttributes) {
if (!enabledAttributes.has(attribute)) continue;
const current = element.getAttribute(attribute);
if (!current) continue;
const previous = records.get(attribute);
const source = previous && (current === previous.source || current === previous.translated)
? previous.source
: current;
const translated = translate(source);
records.set(attribute, { source, translated });
if (translated !== current) element.setAttribute(attribute, translated);
}
if (records.size) attributeTranslations.set(element, records);
}

function restoreTree(root: Element) {
const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT);
restoreElementAttributes(root);
let node = walker.nextNode();
while (node) {
if (node instanceof Text) {
const record = textTranslations.get(node);
if (record && node.nodeValue === record.translated) node.nodeValue = record.source;
} else if (node instanceof Element) {
restoreElementAttributes(node);
}
node = walker.nextNode();
}
}

function restoreElementAttributes(element: Element) {
const records = attributeTranslations.get(element);
if (!records) return;
for (const [attribute, record] of records) {
if (element.getAttribute(attribute) === record.translated) {
element.setAttribute(attribute, record.source);
}
}
}

function shouldSkipElement(element: Element): boolean {
return skippedElementNames.has(element.tagName) || Boolean(element.closest('[data-localization-skip="true"]'));
}
Loading
Loading