Skip to content
Open
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
17 changes: 17 additions & 0 deletions packages/app/src/i18n/parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,23 @@ describe("i18n parity", () => {
}
}
})

test("UI rich templates keep exactly one typed slot", async () => {
const expected = {
"ui.lineComment.label": "selection",
"ui.lineComment.editorLabel": "selection",
"ui.list.emptyWithFilter": "query",
} as const
const legacy = Object.keys(expected).flatMap((key) => [`${key}.prefix`, `${key}.suffix`])

for (const locale of ["en", ...appLocales]) {
const target = await dictionary(`../../../ui/src/i18n/${locale}.ts`)
for (const [key, slot] of Object.entries(expected)) {
expect({ locale, key, placeholders: placeholders(target[key]) }).toEqual({ locale, key, placeholders: [slot] })
}
expect({ locale, legacy: legacy.filter((key) => Object.hasOwn(target, key)) }).toEqual({ locale, legacy: [] })
}
})
})

describe("i18n plural parity", () => {
Expand Down
15 changes: 7 additions & 8 deletions packages/app/src/pages/session/usage-exceeded-dialogs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,7 @@ export function useUsageExceededDialogs() {
const sdk = useSDK()
const dialog = useDialog()
const { params } = useSessionLayout()
const { t, locale } = useI18n()
const isEnglish = () => locale() === "en"
const { tDynamic } = useI18n()

const [goUpsellState, setGoUpsellState] = persisted(
Persist.global("go-upsell"),
Expand Down Expand Up @@ -68,9 +67,9 @@ export function useUsageExceededDialogs() {
if (action.reason === "free_tier_limit") {
dialog.show(() => (
<DialogUsageExceeded
title={isEnglish() ? action.title : t("dialog.usageExceeded.freeTier.title")}
description={isEnglish() ? action.message : t("dialog.usageExceeded.freeTier.description")}
actionLabel={isEnglish() ? action.label : t("dialog.usageExceeded.freeTier.actionLabel")}
title={tDynamic("dialog.usageExceeded.freeTier.title", action.title)}
description={tDynamic("dialog.usageExceeded.freeTier.description", action.message)}
actionLabel={tDynamic("dialog.usageExceeded.freeTier.actionLabel", action.label)}
link={action.link}
onClose={(dontShowAgain) => {
setGoUpsellState(keys.lastSeenAt, Date.now())
Expand All @@ -88,9 +87,9 @@ export function useUsageExceededDialogs() {
} else if (action.reason === "account_rate_limit") {
dialog.show(() => (
<DialogUsageExceeded
title={isEnglish() ? action.title : t("dialog.usageExceeded.accountRateLimit.title")}
description={isEnglish() ? action.message : t("dialog.usageExceeded.accountRateLimit.description")}
actionLabel={isEnglish() ? action.label : t("dialog.usageExceeded.accountRateLimit.actionLabel")}
title={tDynamic("dialog.usageExceeded.accountRateLimit.title", action.title)}
description={tDynamic("dialog.usageExceeded.accountRateLimit.description", action.message)}
actionLabel={tDynamic("dialog.usageExceeded.accountRateLimit.actionLabel", action.label)}
link={action.link}
onClose={(dontShowAgain) => {
setGoUpsellState(keys.lastSeenAt, Date.now())
Expand Down
2 changes: 2 additions & 0 deletions packages/session-ui/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,7 @@
- NEVER hardcode user-visible English strings in production code. ALWAYS use an i18n key for visible copy, placeholders, accessible labels, tooltips, menus, dialogs, empty states, and displayed errors.
- When migrating existing copy to i18n, preserve the English text byte-for-byte unless the task explicitly requests a copy change.
- NEVER change existing English text or English keys to facilitate translation. English is intentional, designer-written source copy; adapt locale-specific translations and i18n mechanics around it.
- Keep locale and grammar logic behind the shared typed i18n API. Session components should use `t(...)`, `plural(...)`, `parts(...)`, or `pluralParts(...)`; they must not inspect locales, choose plural categories, construct category-suffixed keys, or assemble translated grammatical fragments.
- Prefer complete phrase templates with typed rich slots for styled values. If the current API cannot express a phrase, deepen the shared context instead of leaking locale mechanics into session components.
- Do not translate from model knowledge alone. Verify terminology and grammar with Unicode CLDR locale/plural data, Microsoft Localization Style Guides and terminology, Apple localization/style guidance and localized platform UI, Mozilla localization style guides, Mozilla Pontoon, and the Firefox localization corpus at `github.com/mozilla-l10n/firefox-l10n`.
- Also use the relevant language authority or official dictionary for the locale (for example RAE/Fundéu, FranceTerme, Duden, TDK, Kotus/Kielitoimiston sanakirja, Språkrådet/Bokmålsordboka, Rada Języka Polskiego/PWN, the Russian and Arabic language academies, the Ukrainian Orthography, Taiwan MOE dictionaries, or the Royal Society of Thailand). Treat the English dictionary as the semantic source of truth and preserve placeholders, code identifiers, product names, and keyboard labels.
10 changes: 2 additions & 8 deletions packages/session-ui/src/components/line-comment.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -146,11 +146,7 @@ export const LineComment = (props: LineCommentProps) => {
<div data-slot="line-comment-tools">{split.actions}</div>
</Show>
</div>
<div data-slot="line-comment-label">
{i18n.t("ui.lineComment.label.prefix")}
{split.selection}
{i18n.t("ui.lineComment.label.suffix")}
</div>
<div data-slot="line-comment-label">{i18n.parts("ui.lineComment.label", { selection: split.selection })}</div>
</div>
</LineCommentAnchor>
)
Expand Down Expand Up @@ -395,9 +391,7 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => {
</Show>
<div data-slot="line-comment-actions">
<div data-slot="line-comment-editor-label">
{i18n.t("ui.lineComment.editorLabel.prefix")}
{split.selection}
{i18n.t("ui.lineComment.editorLabel.suffix")}
{i18n.parts("ui.lineComment.editorLabel", { selection: split.selection })}
</div>
<Show
when={!props.inline}
Expand Down
53 changes: 1 addition & 52 deletions packages/session-ui/src/components/tool-count-label.css
Original file line number Diff line number Diff line change
@@ -1,57 +1,6 @@
[data-component="tool-count-label"] {
display: inline-flex;
align-items: baseline;
white-space: nowrap;
white-space: pre;
gap: 0;

[data-slot="tool-count-label-before"] {
display: inline-block;
white-space: pre;
line-height: inherit;
}

[data-slot="tool-count-label-word"] {
display: inline-flex;
align-items: baseline;
white-space: pre;
line-height: inherit;
}

[data-slot="tool-count-label-stem"] {
display: inline-block;
white-space: pre;
}

[data-slot="tool-count-label-suffix"] {
display: inline-grid;
grid-template-columns: 0fr;
opacity: 0;
filter: blur(calc(var(--tool-motion-blur, 2px) * 0.42));
overflow: hidden;
transform: translateX(-0.04em);
transition-property: grid-template-columns, opacity, filter, transform;
transition-duration: 250ms, 250ms, 250ms, 250ms;
transition-timing-function:
var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)), ease-out, ease-out,
var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1));
}

[data-slot="tool-count-label-suffix"][data-active="true"] {
grid-template-columns: 1fr;
opacity: 1;
filter: blur(0);
transform: translateX(0);
}

[data-slot="tool-count-label-suffix-inner"] {
min-width: 0;
overflow: hidden;
white-space: pre;
}
}

@media (prefers-reduced-motion: reduce) {
[data-component="tool-count-label"] [data-slot="tool-count-label-suffix"] {
transition-duration: 0ms;
}
}
55 changes: 6 additions & 49 deletions packages/session-ui/src/components/tool-count-label.tsx
Original file line number Diff line number Diff line change
@@ -1,61 +1,18 @@
import { createMemo } from "solid-js"
import { AnimatedNumber } from "@opencode-ai/ui/animated-number"
import { pluralCategory, pluralKey, useI18n, type UiI18nPluralKey } from "@opencode-ai/ui/context/i18n"

function split(text: string) {
const match = /{{\s*count\s*}}/.exec(text)
if (!match) return { before: "", after: text }
if (match.index === undefined) return { before: "", after: text }
return {
before: text.slice(0, match.index),
after: text.slice(match.index + match[0].length),
}
}

function common(one: string, other: string) {
const a = Array.from(one)
const b = Array.from(other)
let i = 0
while (i < a.length && i < b.length && a[i] === b[i]) i++
return {
stem: a.slice(0, i).join(""),
one: a.slice(i).join(""),
other: b.slice(i).join(""),
}
}
import { useI18n, type UiI18nPluralKey } from "@opencode-ai/ui/context/i18n"

export function AnimatedCountLabel(props: { count: number; plural: UiI18nPluralKey; class?: string }) {
const i18n = useI18n()
const category = createMemo(() => pluralCategory(i18n.locale(), Math.round(props.count)))
const one = createMemo(() => split(i18n.t(pluralKey(props.plural, "one"))))
const other = createMemo(() => split(i18n.t(pluralKey(props.plural, "other"))))
const active = createMemo(() => split(i18n.t(pluralKey(props.plural, category()))))
const suffix = createMemo(() => common(one().after, other().after))
const splitSuffix = createMemo(
() =>
(category() === "one" || category() === "other") &&
one().before === other().before &&
(one().after.startsWith(other().after) || other().after.startsWith(one().after)),
const parts = createMemo(() =>
i18n.pluralParts(props.plural, Math.round(props.count), {
count: <AnimatedNumber value={props.count} />,
}),
)
const before = createMemo(() => (splitSuffix() ? one().before : active().before))
const stem = createMemo(() => (splitSuffix() ? suffix().stem : active().after))
const tail = createMemo(() => {
if (!splitSuffix()) return ""
if (category() === "one") return suffix().one
return suffix().other
})
const showTail = createMemo(() => splitSuffix() && tail().length > 0)

return (
<span data-component="tool-count-label" class={props.class}>
<span data-slot="tool-count-label-before">{before()}</span>
<AnimatedNumber value={props.count} />
<span data-slot="tool-count-label-word">
<span data-slot="tool-count-label-stem">{stem()}</span>
<span data-slot="tool-count-label-suffix" data-active={showTail() ? "true" : "false"}>
<span data-slot="tool-count-label-suffix-inner">{tail()}</span>
</span>
</span>
{parts()}
</span>
)
}
2 changes: 2 additions & 0 deletions packages/ui/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
- NEVER hardcode user-visible English strings in production code. ALWAYS use an i18n key for component defaults, visible copy, placeholders, accessible labels, tooltips, dialogs, toasts, empty states, and displayed errors.
- When migrating existing copy to i18n, preserve the English text byte-for-byte unless the task explicitly requests a copy change.
- NEVER change existing English text or English keys to facilitate translation. English is intentional, designer-written source copy; adapt locale-specific translations and i18n mechanics around it.
- Keep locale and grammar logic behind the shared typed i18n API. Components should use `t(...)`, `plural(...)`, `parts(...)`, or `pluralParts(...)`; they must not inspect locales, choose plural categories, construct category-suffixed keys, or assemble translated grammatical fragments.
- Prefer complete phrase templates with typed rich slots for styled values. If the current API cannot express a phrase, deepen the shared context instead of leaking locale mechanics into components.
- Do not translate from model knowledge alone. Verify terminology and grammar with Unicode CLDR locale/plural data, Microsoft Localization Style Guides and terminology, Apple localization/style guidance and localized platform UI, Mozilla localization style guides, Mozilla Pontoon, and the Firefox localization corpus at `github.com/mozilla-l10n/firefox-l10n`.
- For developer-facing terminology, prefer established usage in the target language's developer community over literal translations. Cross-check maintained Firefox, KDE, and VS Code localizations, using at least two independent corpora when available. Keep established English loanwords and acronyms instead of inventing unfamiliar terms.
- Translate whole UI phrases in context rather than substituting glossary words. Audit recurring concepts for consistency and review every exact-English value; retain it only when it is an intentional product/provider/tool name, URL, code token, keyboard legend, acronym, asset name, or established borrowing.
Expand Down
13 changes: 3 additions & 10 deletions packages/ui/src/components/list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -247,16 +247,9 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
const query = filter()
if (!query) return i18n.t("ui.list.empty")

const suffix = i18n.t("ui.list.emptyWithFilter.suffix")
return (
<>
<span>{i18n.t("ui.list.emptyWithFilter.prefix")}</span>
<span data-slot="list-filter">&quot;{query}&quot;</span>
<Show when={suffix}>
<span>{suffix}</span>
</Show>
</>
)
return i18n.parts("ui.list.emptyWithFilter", {
query: <span data-slot="list-filter">&quot;{query}&quot;</span>,
})
}

return (
Expand Down
57 changes: 56 additions & 1 deletion packages/ui/src/context/i18n.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { pluralCategory } from "./i18n"
import { createUiI18n, pluralCategory, pluralKey, type UiI18nParams, type UiI18nSource } from "./i18n"

describe("pluralCategory", () => {
test.each([
Expand All @@ -22,3 +22,58 @@ describe("pluralCategory", () => {
expect(pluralCategory(locale, count)).toBe(expected)
})
})

function i18n(locale: string, messages: Record<string, string>) {
const source: UiI18nSource = {
locale: () => locale,
t: (key, params) => resolve(messages[key] ?? key, params),
plural: (key, count, params) =>
source.t(pluralKey(key, pluralCategory(source.locale(), count)), { ...params, count }),
}
return createUiI18n(source)
}

function resolve(template: string, params?: UiI18nParams) {
if (!params) return template
return template.replace(/{{\s*([^}]+?)\s*}}/g, (_, key) => String(params[String(key)] ?? ""))
}

describe("createUiI18n", () => {
test("keeps dynamic source copy for English locale tags", () => {
const value = i18n("en-US", { title: "Dictionary title" })
expect(value.tDynamic("title", "Runtime {{name}}", { name: "title" })).toBe("Runtime title")
})

test("uses dictionary copy for non-English locales", () => {
const value = i18n("fr", { title: "Titre traduit" })
expect(value.tDynamic("title", "Runtime title")).toBe("Titre traduit")
})

test("inserts rich values in dictionary order", () => {
const value = i18n("ja", {
"ui.lineComment.label": "{{selection}}へのコメント",
"ui.lineComment.editorLabel": "Commenting on {{selection}}",
"ui.list.emptyWithFilter": "没有关于{{query}}的结果",
})
const selection = { id: "selection" }
expect(value.parts("ui.lineComment.label", { selection })).toEqual(["", selection, "へのコメント"])
expect(value.parts("ui.list.emptyWithFilter", { query: "needle" })).toEqual(["没有关于", "needle", "的结果"])
})

test("rejects malformed rich templates", () => {
const value = i18n("en", { "ui.lineComment.label": "Comment" })
expect(() => value.parts("ui.lineComment.label", { selection: "path" })).toThrow()
})

test("selects plural copy before inserting the animated count", () => {
const value = i18n("ar", {
"ui.messagePart.context.read.two": "تمت قراءة ملفين: {{count}} في {{folder}}",
})
const count = { id: "animated-count" }
expect(value.pluralParts("ui.messagePart.context.read", 2, { count }, { folder: "src" })).toEqual([
"تمت قراءة ملفين: ",
count,
" في src",
])
})
})
Loading
Loading