diff --git a/packages/ui/src/components/image-preview.tsx b/packages/ui/src/components/image-preview.tsx index 900abc7253..88bf389806 100644 --- a/packages/ui/src/components/image-preview.tsx +++ b/packages/ui/src/components/image-preview.tsx @@ -1,4 +1,5 @@ import { Dialog as Kobalte } from "@kobalte/core/dialog" +import { useI18n } from "../context/i18n" import { IconButton } from "./icon-button" export interface ImagePreviewProps { @@ -7,6 +8,7 @@ export interface ImagePreviewProps { } export function ImagePreview(props: ImagePreviewProps) { + const i18n = useI18n() return (
@@ -15,7 +17,7 @@ export function ImagePreview(props: ImagePreviewProps) {
- {props.alt + {props.alt
diff --git a/packages/ui/src/components/list.tsx b/packages/ui/src/components/list.tsx index d086c4a2a7..d81440c11b 100644 --- a/packages/ui/src/components/list.tsx +++ b/packages/ui/src/components/list.tsx @@ -1,6 +1,7 @@ import { type FilteredListProps, useFilteredList } from "@opencode-ai/ui/hooks" import { createEffect, createSignal, For, onCleanup, type JSX, on, Show } from "solid-js" import { createStore } from "solid-js/store" +import { useI18n } from "../context/i18n" import { Icon, type IconProps } from "./icon" import { IconButton } from "./icon-button" import { TextField } from "./text-field" @@ -30,6 +31,7 @@ export interface ListRef { } export function List(props: ListProps & { ref?: (ref: ListRef) => void }) { + const i18n = useI18n() const [scrollRef, setScrollRef] = createSignal(undefined) const [internalFilter, setInternalFilter] = createSignal("") const [store, setStore] = createStore({ @@ -174,6 +176,25 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) ) } + const emptyMessage = () => { + if (grouped.loading) return props.loadingMessage ?? i18n.t("ui.list.loading") + if (props.emptyMessage) return props.emptyMessage + + const query = filter() + if (!query) return i18n.t("ui.list.empty") + + const suffix = i18n.t("ui.list.emptyWithFilter.suffix") + return ( + <> + {i18n.t("ui.list.emptyWithFilter.prefix")} + "{query}" + + {suffix} + + + ) + } + return (
@@ -208,10 +229,7 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) fallback={
- {grouped.loading ? props.loadingMessage ?? "Loading" : props.emptyMessage ?? "No results"} - - {" "}for "{filter()}" - + {emptyMessage()}
} diff --git a/packages/ui/src/components/message-nav.tsx b/packages/ui/src/components/message-nav.tsx index 0dd7c42b02..d151633faa 100644 --- a/packages/ui/src/components/message-nav.tsx +++ b/packages/ui/src/components/message-nav.tsx @@ -2,6 +2,7 @@ import { UserMessage } from "@opencode-ai/sdk/v2" import { ComponentProps, For, Match, Show, splitProps, Switch } from "solid-js" import { DiffChanges } from "./diff-changes" import { Tooltip } from "@kobalte/core/tooltip" +import { useI18n } from "../context/i18n" export function MessageNav( props: ComponentProps<"ul"> & { @@ -12,6 +13,7 @@ export function MessageNav( getLabel?: (message: UserMessage) => string | undefined }, ) { + const i18n = useI18n() const [local, others] = splitProps(props, ["messages", "current", "size", "onMessageSelect", "getLabel"]) const content = () => ( @@ -48,7 +50,10 @@ export function MessageNav( data-slot="message-nav-title-preview" data-active={message.id === local.current?.id || undefined} > - + {local.getLabel?.(message) ?? message.summary?.title}
diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 4087705d17..8e2a36885a 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -32,6 +32,7 @@ import { useData } from "../context" import { useDiffComponent } from "../context/diff" import { useCodeComponent } from "../context/code" import { useDialog } from "../context/dialog" +import { useI18n } from "../context/i18n" import { BasicTool } from "./basic-tool" import { GenericTool } from "./basic-tool" import { Button } from "./button" @@ -67,13 +68,14 @@ function getDiagnostics( } function DiagnosticsDisplay(props: { diagnostics: Diagnostic[] }): JSX.Element { + const i18n = useI18n() return ( 0}>
{(diagnostic) => (
- Error + {i18n.t("ui.messagePart.diagnostic.error")} [{diagnostic.range.start.line + 1}:{diagnostic.range.start.character + 1}] @@ -179,81 +181,84 @@ export type ToolInfo = { } export function getToolInfo(tool: string, input: any = {}): ToolInfo { + const i18n = useI18n() switch (tool) { case "read": return { icon: "glasses", - title: "Read", + title: i18n.t("ui.tool.read"), subtitle: input.filePath ? getFilename(input.filePath) : undefined, } case "list": return { icon: "bullet-list", - title: "List", + title: i18n.t("ui.tool.list"), subtitle: input.path ? getFilename(input.path) : undefined, } case "glob": return { icon: "magnifying-glass-menu", - title: "Glob", + title: i18n.t("ui.tool.glob"), subtitle: input.pattern, } case "grep": return { icon: "magnifying-glass-menu", - title: "Grep", + title: i18n.t("ui.tool.grep"), subtitle: input.pattern, } case "webfetch": return { icon: "window-cursor", - title: "Webfetch", + title: i18n.t("ui.tool.webfetch"), subtitle: input.url, } case "task": return { icon: "task", - title: `${input.subagent_type || "task"} Agent`, + title: i18n.t("ui.tool.agent", { type: input.subagent_type || "task" }), subtitle: input.description, } case "bash": return { icon: "console", - title: "Shell", + title: i18n.t("ui.tool.shell"), subtitle: input.description, } case "edit": return { icon: "code-lines", - title: "Edit", + title: i18n.t("ui.messagePart.title.edit"), subtitle: input.filePath ? getFilename(input.filePath) : undefined, } case "write": return { icon: "code-lines", - title: "Write", + title: i18n.t("ui.messagePart.title.write"), subtitle: input.filePath ? getFilename(input.filePath) : undefined, } case "apply_patch": return { icon: "code-lines", - title: "Patch", - subtitle: input.files?.length ? `${input.files.length} file${input.files.length > 1 ? "s" : ""}` : undefined, + title: i18n.t("ui.tool.patch"), + subtitle: input.files?.length + ? `${input.files.length} ${i18n.t(input.files.length > 1 ? "ui.common.file.other" : "ui.common.file.one")}` + : undefined, } case "todowrite": return { icon: "checklist", - title: "To-dos", + title: i18n.t("ui.tool.todos"), } case "todoread": return { icon: "checklist", - title: "Read to-dos", + title: i18n.t("ui.tool.todos.read"), } case "question": return { icon: "bubble-5", - title: "Questions", + title: i18n.t("ui.tool.questions"), } default: return { @@ -297,6 +302,7 @@ export function AssistantMessageDisplay(props: { message: AssistantMessage; part export function UserMessageDisplay(props: { message: UserMessage; parts: PartType[] }) { const dialog = useDialog() + const i18n = useI18n() const [copied, setCopied] = createSignal(false) const [expanded, setExpanded] = createSignal(false) const [canExpand, setCanExpand] = createSignal(false) @@ -385,7 +391,11 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
} > - {file.filename + {file.filename
)} @@ -398,7 +408,7 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
- + { @@ -639,13 +650,13 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
@@ -687,6 +698,7 @@ PART_MAPPING["reasoning"] = function ReasoningPartDisplay(props) { ToolRegistry.register({ name: "read", render(props) { + const i18n = useI18n() const args: string[] = [] if (props.input.offset) args.push("offset=" + props.input.offset) if (props.input.limit) args.push("limit=" + props.input.limit) @@ -695,7 +707,7 @@ ToolRegistry.register({ {...props} icon="glasses" trigger={{ - title: "Read", + title: i18n.t("ui.tool.read"), subtitle: props.input.filePath ? getFilename(props.input.filePath) : "", args, }} @@ -707,11 +719,12 @@ ToolRegistry.register({ ToolRegistry.register({ name: "list", render(props) { + const i18n = useI18n() return ( {(output) => ( @@ -728,12 +741,13 @@ ToolRegistry.register({ ToolRegistry.register({ name: "glob", render(props) { + const i18n = useI18n() return ( (props.metadata.summary ?? []) as { id: string; tool: string; state: { status: string; title?: string } }[] @@ -899,7 +916,7 @@ ToolRegistry.register({ icon="task" defaultOpen={true} trigger={{ - title: `${props.input.subagent_type || props.tool} Agent`, + title: i18n.t("ui.tool.agent", { type: props.input.subagent_type || props.tool }), titleClass: "capitalize", subtitle: props.input.description, }} @@ -912,13 +929,13 @@ ToolRegistry.register({
@@ -929,7 +946,7 @@ ToolRegistry.register({ icon="task" defaultOpen={true} trigger={{ - title: `${props.input.subagent_type || props.tool} Agent`, + title: i18n.t("ui.tool.agent", { type: props.input.subagent_type || props.tool }), titleClass: "capitalize", subtitle: props.input.description, }} @@ -969,12 +986,13 @@ ToolRegistry.register({ ToolRegistry.register({ name: "bash", render(props) { + const i18n = useI18n() return ( @@ -991,6 +1009,7 @@ ToolRegistry.register({ ToolRegistry.register({ name: "edit", render(props) { + const i18n = useI18n() const diffComponent = useDiffComponent() const diagnostics = createMemo(() => getDiagnostics(props.metadata.diagnostics, props.input.filePath)) const filename = () => getFilename(props.input.filePath ?? "") @@ -1001,7 +1020,9 @@ ToolRegistry.register({ trigger={
-
Edit {filename()}
+
+ {i18n.t("ui.messagePart.title.edit")} {filename()} +
{getDirectory(props.input.filePath!)} @@ -1040,6 +1061,7 @@ ToolRegistry.register({ ToolRegistry.register({ name: "write", render(props) { + const i18n = useI18n() const codeComponent = useCodeComponent() const diagnostics = createMemo(() => getDiagnostics(props.metadata.diagnostics, props.input.filePath)) const filename = () => getFilename(props.input.filePath ?? "") @@ -1050,7 +1072,9 @@ ToolRegistry.register({ trigger={
-
Write {filename()}
+
+ {i18n.t("ui.messagePart.title.write")} {filename()} +
{getDirectory(props.input.filePath!)} @@ -1095,13 +1119,14 @@ interface ApplyPatchFile { ToolRegistry.register({ name: "apply_patch", render(props) { + const i18n = useI18n() const diffComponent = useDiffComponent() const files = createMemo(() => (props.metadata.files ?? []) as ApplyPatchFile[]) const subtitle = createMemo(() => { const count = files().length if (count === 0) return "" - return `${count} file${count > 1 ? "s" : ""}` + return `${count} ${i18n.t(count > 1 ? "ui.common.file.other" : "ui.common.file.one")}` }) return ( @@ -1109,7 +1134,7 @@ ToolRegistry.register({ {...props} icon="code-lines" trigger={{ - title: "Patch", + title: i18n.t("ui.tool.patch"), subtitle: subtitle(), }} > @@ -1122,22 +1147,22 @@ ToolRegistry.register({ - Deleted + {i18n.t("ui.patch.action.deleted")} - Created + {i18n.t("ui.patch.action.created")} - Moved + {i18n.t("ui.patch.action.moved")} - Patched + {i18n.t("ui.patch.action.patched")} @@ -1171,6 +1196,7 @@ ToolRegistry.register({ ToolRegistry.register({ name: "todowrite", render(props) { + const i18n = useI18n() const todos = createMemo(() => { const meta = props.metadata?.todos if (Array.isArray(meta)) return meta @@ -1193,7 +1219,7 @@ ToolRegistry.register({ defaultOpen icon="checklist" trigger={{ - title: "To-dos", + title: i18n.t("ui.tool.todos"), subtitle: subtitle(), }} > @@ -1218,6 +1244,7 @@ ToolRegistry.register({ ToolRegistry.register({ name: "question", render(props) { + const i18n = useI18n() const questions = createMemo(() => (props.input.questions ?? []) as QuestionInfo[]) const answers = createMemo(() => (props.metadata.answers ?? []) as QuestionAnswer[]) const completed = createMemo(() => answers().length > 0) @@ -1225,8 +1252,8 @@ ToolRegistry.register({ const subtitle = createMemo(() => { const count = questions().length if (count === 0) return "" - if (completed()) return `${count} answered` - return `${count} question${count > 1 ? "s" : ""}` + if (completed()) return i18n.t("ui.question.subtitle.answered", { count }) + return `${count} ${i18n.t(count > 1 ? "ui.common.question.other" : "ui.common.question.one")}` }) return ( @@ -1235,7 +1262,7 @@ ToolRegistry.register({ defaultOpen={completed()} icon="bubble-5" trigger={{ - title: "Questions", + title: i18n.t("ui.tool.questions"), subtitle: subtitle(), }} > @@ -1247,7 +1274,7 @@ ToolRegistry.register({ return (
{q.question}
-
{answer().join(", ") || "(no answer)"}
+
{answer().join(", ") || i18n.t("ui.question.answer.none")}
) }} @@ -1261,6 +1288,7 @@ ToolRegistry.register({ function QuestionPrompt(props: { request: QuestionRequest }) { const data = useData() + const i18n = useI18n() const questions = createMemo(() => props.request.questions) const single = createMemo(() => questions().length === 1 && questions()[0]?.multiple !== true) @@ -1387,7 +1415,7 @@ function QuestionPrompt(props: { request: QuestionRequest }) { }}
@@ -1396,7 +1424,7 @@ function QuestionPrompt(props: { request: QuestionRequest }) {
{question()?.question} - {multi() ? " (select all that apply)" : ""} + {multi() ? " " + i18n.t("ui.question.multiHint") : ""}
@@ -1420,7 +1448,7 @@ function QuestionPrompt(props: { request: QuestionRequest }) { data-picked={customPicked()} onClick={() => selectOption(options().length)} > - Type your own answer + {i18n.t("ui.messagePart.option.typeOwnAnswer")} {input()} @@ -1434,7 +1462,7 @@ function QuestionPrompt(props: { request: QuestionRequest }) { ref={(el) => setTimeout(() => el.focus(), 0)} type="text" data-slot="custom-input" - placeholder="Type your answer..." + placeholder={i18n.t("ui.question.custom.placeholder")} value={input()} onInput={(e) => { const inputs = [...store.custom] @@ -1443,10 +1471,10 @@ function QuestionPrompt(props: { request: QuestionRequest }) { }} /> @@ -1456,7 +1484,7 @@ function QuestionPrompt(props: { request: QuestionRequest }) {
-
Review your answers
+
{i18n.t("ui.messagePart.review.title")}
{(q, index) => { const value = () => store.answers[index()]?.join(", ") ?? "" @@ -1465,7 +1493,7 @@ function QuestionPrompt(props: { request: QuestionRequest }) {
{q.question} - {answered() ? value() : "(not answered)"} + {answered() ? value() : i18n.t("ui.question.review.notAnswered")}
) @@ -1476,12 +1504,12 @@ function QuestionPrompt(props: { request: QuestionRequest }) {
@@ -1491,7 +1519,7 @@ function QuestionPrompt(props: { request: QuestionRequest }) { onClick={() => selectTab(store.tab + 1)} disabled={(store.answers[store.tab]?.length ?? 0) === 0} > - Next + {i18n.t("ui.common.next")} diff --git a/packages/ui/src/components/text-field.tsx b/packages/ui/src/components/text-field.tsx index ed3d13fe35..a4eafa561c 100644 --- a/packages/ui/src/components/text-field.tsx +++ b/packages/ui/src/components/text-field.tsx @@ -1,6 +1,7 @@ import { TextField as Kobalte } from "@kobalte/core/text-field" import { createSignal, Show, splitProps } from "solid-js" import type { ComponentProps } from "solid-js" +import { useI18n } from "../context/i18n" import { IconButton } from "./icon-button" import { Tooltip } from "./tooltip" @@ -30,6 +31,7 @@ export interface TextFieldProps } export function TextField(props: TextFieldProps) { + const i18n = useI18n() const [local, others] = splitProps(props, [ "name", "defaultValue", @@ -90,7 +92,11 @@ export function TextField(props: TextFieldProps) { - + > diff --git a/specs/07-ui-i18n-audit.md b/specs/07-ui-i18n-audit.md index f6f67db738..26bc552bb4 100644 --- a/specs/07-ui-i18n-audit.md +++ b/specs/07-ui-i18n-audit.md @@ -120,6 +120,22 @@ Examples (non-exhaustive): - `Type your own answer` - `Review your answers` +### 4) Additional Hardcoded Strings (Full Audit) + +Found during a full `packages/ui/src/components` + `packages/ui/src/context` sweep: + +- `packages/ui/src/components/list.tsx` + - `Loading` + - `No results` + - `No results for "{{filter}}"` +- `packages/ui/src/components/message-nav.tsx` + - `New message` +- `packages/ui/src/components/text-field.tsx` + - `Copied` + - `Copy to clipboard` +- `packages/ui/src/components/image-preview.tsx` + - `Image preview` (alt text) + ## Prioritized Implementation Plan 1. Completed (2026-01-20): Add `@opencode-ai/ui` i18n context (`packages/ui/src/context/i18n.tsx`) + export it. @@ -128,8 +144,8 @@ Examples (non-exhaustive): - `packages/app/src/app.tsx` - `packages/enterprise/src/app.tsx` 4. Completed (2026-01-20): Convert `packages/ui/src/components/session-review.tsx` and `packages/ui/src/components/session-turn.tsx` to use `useI18n().t(...)`. -5. Convert `packages/ui/src/components/message-part.tsx`. -6. Do a full `packages/ui/src/components` + `packages/ui/src/context` audit for additional hardcoded copy. +5. Completed (2026-01-20): Convert `packages/ui/src/components/message-part.tsx`. +6. Completed (2026-01-20): Do a full `packages/ui/src/components` + `packages/ui/src/context` audit for additional hardcoded copy. ## Notes / Risks