Ver código fonte

wip(app): i18n

Adam 3 semanas atrás
pai
commit
6037e88ddf

+ 3 - 1
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 (
     <div data-component="image-preview">
       <div data-slot="image-preview-container">
@@ -15,7 +17,7 @@ export function ImagePreview(props: ImagePreviewProps) {
             <Kobalte.CloseButton data-slot="image-preview-close" as={IconButton} icon="close" variant="ghost" />
           </div>
           <div data-slot="image-preview-body">
-            <img src={props.src} alt={props.alt ?? "Image preview"} data-slot="image-preview-image" />
+            <img src={props.src} alt={props.alt ?? i18n.t("ui.imagePreview.alt")} data-slot="image-preview-image" />
           </div>
         </Kobalte.Content>
       </div>

+ 22 - 4
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<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void }) {
+  const i18n = useI18n()
   const [scrollRef, setScrollRef] = createSignal<HTMLDivElement | undefined>(undefined)
   const [internalFilter, setInternalFilter] = createSignal("")
   const [store, setStore] = createStore({
@@ -174,6 +176,25 @@ export function List<T>(props: ListProps<T> & { 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 (
+      <>
+        <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 (
     <div data-component="list" classList={{ [props.class ?? ""]: !!props.class }}>
       <Show when={!!props.search}>
@@ -208,10 +229,7 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
           fallback={
             <div data-slot="list-empty-state">
               <div data-slot="list-message">
-                {grouped.loading ? props.loadingMessage ?? "Loading" : props.emptyMessage ?? "No results"}
-                <Show when={!props.emptyMessage && !props.loadingMessage && !!filter()}>
-                  {" "}for <span data-slot="list-filter">&quot;{filter()}&quot;</span>
-                </Show>
+                {emptyMessage()}
               </div>
             </div>
           }

+ 6 - 1
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}
                     >
-                      <Show when={local.getLabel?.(message) ?? message.summary?.title} fallback="New message">
+                      <Show
+                        when={local.getLabel?.(message) ?? message.summary?.title}
+                        fallback={i18n.t("ui.messageNav.newMessage")}
+                      >
                         {local.getLabel?.(message) ?? message.summary?.title}
                       </Show>
                     </div>

+ 84 - 56
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 (
     <Show when={props.diagnostics.length > 0}>
       <div data-component="diagnostics">
         <For each={props.diagnostics}>
           {(diagnostic) => (
             <div data-slot="diagnostic">
-              <span data-slot="diagnostic-label">Error</span>
+              <span data-slot="diagnostic-label">{i18n.t("ui.messagePart.diagnostic.error")}</span>
               <span data-slot="diagnostic-location">
                 [{diagnostic.range.start.line + 1}:{diagnostic.range.start.character + 1}]
               </span>
@@ -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
                     </div>
                   }
                 >
-                  <img data-slot="user-message-attachment-image" src={file.url} alt={file.filename ?? "attachment"} />
+                  <img
+                    data-slot="user-message-attachment-image"
+                    src={file.url}
+                    alt={file.filename ?? i18n.t("ui.message.attachment.alt")}
+                  />
                 </Show>
               </div>
             )}
@@ -398,7 +408,7 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
           <button
             data-slot="user-message-expand"
             type="button"
-            aria-label={expanded() ? "Collapse message" : "Expand message"}
+            aria-label={expanded() ? i18n.t("ui.message.collapse") : i18n.t("ui.message.expand")}
             onClick={(event) => {
               event.stopPropagation()
               toggleExpanded()
@@ -407,7 +417,7 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
             <Icon name="chevron-down" size="small" />
           </button>
           <div data-slot="user-message-copy-wrapper">
-            <Tooltip value={copied() ? "Copied!" : "Copy"} placement="top" gutter={8}>
+            <Tooltip value={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")} placement="top" gutter={8}>
               <IconButton
                 icon={copied() ? "check" : "copy"}
                 variant="secondary"
@@ -529,6 +539,7 @@ export const ToolRegistry = {
 
 PART_MAPPING["tool"] = function ToolPartDisplay(props) {
   const data = useData()
+  const i18n = useI18n()
   const part = props.part as ToolPart
 
   const permission = createMemo(() => {
@@ -639,13 +650,13 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
         <div data-component="permission-prompt">
           <div data-slot="permission-actions">
             <Button variant="ghost" size="small" onClick={() => respond("reject")}>
-              Deny
+              {i18n.t("ui.permission.deny")}
             </Button>
             <Button variant="secondary" size="small" onClick={() => respond("always")}>
-              Allow always
+              {i18n.t("ui.permission.allowAlways")}
             </Button>
             <Button variant="primary" size="small" onClick={() => respond("once")}>
-              Allow once
+              {i18n.t("ui.permission.allowOnce")}
             </Button>
           </div>
         </div>
@@ -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 (
       <BasicTool
         {...props}
         icon="bullet-list"
-        trigger={{ title: "List", subtitle: getDirectory(props.input.path || "/") }}
+        trigger={{ title: i18n.t("ui.tool.list"), subtitle: getDirectory(props.input.path || "/") }}
       >
         <Show when={props.output}>
           {(output) => (
@@ -728,12 +741,13 @@ ToolRegistry.register({
 ToolRegistry.register({
   name: "glob",
   render(props) {
+    const i18n = useI18n()
     return (
       <BasicTool
         {...props}
         icon="magnifying-glass-menu"
         trigger={{
-          title: "Glob",
+          title: i18n.t("ui.tool.glob"),
           subtitle: getDirectory(props.input.path || "/"),
           args: props.input.pattern ? ["pattern=" + props.input.pattern] : [],
         }}
@@ -753,6 +767,7 @@ ToolRegistry.register({
 ToolRegistry.register({
   name: "grep",
   render(props) {
+    const i18n = useI18n()
     const args: string[] = []
     if (props.input.pattern) args.push("pattern=" + props.input.pattern)
     if (props.input.include) args.push("include=" + props.input.include)
@@ -761,7 +776,7 @@ ToolRegistry.register({
         {...props}
         icon="magnifying-glass-menu"
         trigger={{
-          title: "Grep",
+          title: i18n.t("ui.tool.grep"),
           subtitle: getDirectory(props.input.path || "/"),
           args,
         }}
@@ -781,12 +796,13 @@ ToolRegistry.register({
 ToolRegistry.register({
   name: "webfetch",
   render(props) {
+    const i18n = useI18n()
     return (
       <BasicTool
         {...props}
         icon="window-cursor"
         trigger={{
-          title: "Webfetch",
+          title: i18n.t("ui.tool.webfetch"),
           subtitle: props.input.url || "",
           args: props.input.format ? ["format=" + props.input.format] : [],
           action: (
@@ -812,6 +828,7 @@ ToolRegistry.register({
   name: "task",
   render(props) {
     const data = useData()
+    const i18n = useI18n()
     const summary = () =>
       (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({
               <div data-component="permission-prompt">
                 <div data-slot="permission-actions">
                   <Button variant="ghost" size="small" onClick={() => respond("reject")}>
-                    Deny
+                    {i18n.t("ui.permission.deny")}
                   </Button>
                   <Button variant="secondary" size="small" onClick={() => respond("always")}>
-                    Allow always
+                    {i18n.t("ui.permission.allowAlways")}
                   </Button>
                   <Button variant="primary" size="small" onClick={() => respond("once")}>
-                    Allow once
+                    {i18n.t("ui.permission.allowOnce")}
                   </Button>
                 </div>
               </div>
@@ -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 (
       <BasicTool
         {...props}
         icon="console"
         trigger={{
-          title: "Shell",
+          title: i18n.t("ui.tool.shell"),
           subtitle: props.input.description,
         }}
       >
@@ -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={
           <div data-component="edit-trigger">
             <div data-slot="message-part-title-area">
-              <div data-slot="message-part-title">Edit {filename()}</div>
+              <div data-slot="message-part-title">
+                {i18n.t("ui.messagePart.title.edit")} {filename()}
+              </div>
               <Show when={props.input.filePath?.includes("/")}>
                 <div data-slot="message-part-path">
                   <span data-slot="message-part-directory">{getDirectory(props.input.filePath!)}</span>
@@ -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={
           <div data-component="write-trigger">
             <div data-slot="message-part-title-area">
-              <div data-slot="message-part-title">Write {filename()}</div>
+              <div data-slot="message-part-title">
+                {i18n.t("ui.messagePart.title.write")} {filename()}
+              </div>
               <Show when={props.input.filePath?.includes("/")}>
                 <div data-slot="message-part-path">
                   <span data-slot="message-part-directory">{getDirectory(props.input.filePath!)}</span>
@@ -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({
                     <Switch>
                       <Match when={file.type === "delete"}>
                         <span data-slot="apply-patch-file-action" data-type="delete">
-                          Deleted
+                          {i18n.t("ui.patch.action.deleted")}
                         </span>
                       </Match>
                       <Match when={file.type === "add"}>
                         <span data-slot="apply-patch-file-action" data-type="add">
-                          Created
+                          {i18n.t("ui.patch.action.created")}
                         </span>
                       </Match>
                       <Match when={file.type === "move"}>
                         <span data-slot="apply-patch-file-action" data-type="move">
-                          Moved
+                          {i18n.t("ui.patch.action.moved")}
                         </span>
                       </Match>
                       <Match when={file.type === "update"}>
                         <span data-slot="apply-patch-file-action" data-type="update">
-                          Patched
+                          {i18n.t("ui.patch.action.patched")}
                         </span>
                       </Match>
                     </Switch>
@@ -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 (
                   <div data-slot="question-answer-item">
                     <div data-slot="question-text">{q.question}</div>
-                    <div data-slot="answer-text">{answer().join(", ") || "(no answer)"}</div>
+                    <div data-slot="answer-text">{answer().join(", ") || i18n.t("ui.question.answer.none")}</div>
                   </div>
                 )
               }}
@@ -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 }) {
             }}
           </For>
           <button data-slot="question-tab" data-active={confirm()} onClick={() => selectTab(questions().length)}>
-            Confirm
+            {i18n.t("ui.common.confirm")}
           </button>
         </div>
       </Show>
@@ -1396,7 +1424,7 @@ function QuestionPrompt(props: { request: QuestionRequest }) {
         <div data-slot="question-content">
           <div data-slot="question-text">
             {question()?.question}
-            {multi() ? " (select all that apply)" : ""}
+            {multi() ? " " + i18n.t("ui.question.multiHint") : ""}
           </div>
           <div data-slot="question-options">
             <For each={options()}>
@@ -1420,7 +1448,7 @@ function QuestionPrompt(props: { request: QuestionRequest }) {
               data-picked={customPicked()}
               onClick={() => selectOption(options().length)}
             >
-              <span data-slot="option-label">Type your own answer</span>
+              <span data-slot="option-label">{i18n.t("ui.messagePart.option.typeOwnAnswer")}</span>
               <Show when={!store.editing && input()}>
                 <span data-slot="option-description">{input()}</span>
               </Show>
@@ -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 }) {
                   }}
                 />
                 <Button type="submit" variant="primary" size="small">
-                  {multi() ? "Add" : "Submit"}
+                  {multi() ? i18n.t("ui.common.add") : i18n.t("ui.common.submit")}
                 </Button>
                 <Button type="button" variant="ghost" size="small" onClick={() => setStore("editing", false)}>
-                  Cancel
+                  {i18n.t("ui.common.cancel")}
                 </Button>
               </form>
             </Show>
@@ -1456,7 +1484,7 @@ function QuestionPrompt(props: { request: QuestionRequest }) {
 
       <Show when={confirm()}>
         <div data-slot="question-review">
-          <div data-slot="review-title">Review your answers</div>
+          <div data-slot="review-title">{i18n.t("ui.messagePart.review.title")}</div>
           <For each={questions()}>
             {(q, index) => {
               const value = () => store.answers[index()]?.join(", ") ?? ""
@@ -1465,7 +1493,7 @@ function QuestionPrompt(props: { request: QuestionRequest }) {
                 <div data-slot="review-item">
                   <span data-slot="review-label">{q.question}</span>
                   <span data-slot="review-value" data-answered={answered()}>
-                    {answered() ? value() : "(not answered)"}
+                    {answered() ? value() : i18n.t("ui.question.review.notAnswered")}
                   </span>
                 </div>
               )
@@ -1476,12 +1504,12 @@ function QuestionPrompt(props: { request: QuestionRequest }) {
 
       <div data-slot="question-actions">
         <Button variant="ghost" size="small" onClick={reject}>
-          Dismiss
+          {i18n.t("ui.common.dismiss")}
         </Button>
         <Show when={!single()}>
           <Show when={confirm()}>
             <Button variant="primary" size="small" onClick={submit}>
-              Submit
+              {i18n.t("ui.common.submit")}
             </Button>
           </Show>
           <Show when={!confirm() && multi()}>
@@ -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")}
             </Button>
           </Show>
         </Show>

+ 7 - 1
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) {
           <Kobalte.TextArea {...others} autoResize data-slot="input-input" class={local.class} />
         </Show>
         <Show when={local.copyable}>
-          <Tooltip value={copied() ? "Copied" : "Copy to clipboard"} placement="top" gutter={8}>
+          <Tooltip
+            value={copied() ? i18n.t("ui.textField.copied") : i18n.t("ui.textField.copyToClipboard")}
+            placement="top"
+            gutter={8}
+          >
             <IconButton
               type="button"
               icon={copied() ? "check" : "copy"}

+ 57 - 0
packages/ui/src/i18n/en.ts

@@ -30,4 +30,61 @@ export const dict = {
   "ui.messagePart.title.write": "Write",
   "ui.messagePart.option.typeOwnAnswer": "Type your own answer",
   "ui.messagePart.review.title": "Review your answers",
+
+  "ui.list.loading": "Loading",
+  "ui.list.empty": "No results",
+  "ui.list.emptyWithFilter.prefix": "No results for",
+  "ui.list.emptyWithFilter.suffix": "",
+
+  "ui.messageNav.newMessage": "New message",
+
+  "ui.textField.copyToClipboard": "Copy to clipboard",
+  "ui.textField.copied": "Copied",
+
+  "ui.imagePreview.alt": "Image preview",
+
+  "ui.tool.read": "Read",
+  "ui.tool.list": "List",
+  "ui.tool.glob": "Glob",
+  "ui.tool.grep": "Grep",
+  "ui.tool.webfetch": "Webfetch",
+  "ui.tool.shell": "Shell",
+  "ui.tool.patch": "Patch",
+  "ui.tool.todos": "To-dos",
+  "ui.tool.todos.read": "Read to-dos",
+  "ui.tool.questions": "Questions",
+  "ui.tool.agent": "{{type}} Agent",
+
+  "ui.common.file.one": "file",
+  "ui.common.file.other": "files",
+  "ui.common.question.one": "question",
+  "ui.common.question.other": "questions",
+
+  "ui.common.add": "Add",
+  "ui.common.cancel": "Cancel",
+  "ui.common.confirm": "Confirm",
+  "ui.common.dismiss": "Dismiss",
+  "ui.common.next": "Next",
+  "ui.common.submit": "Submit",
+
+  "ui.permission.deny": "Deny",
+  "ui.permission.allowAlways": "Allow always",
+  "ui.permission.allowOnce": "Allow once",
+
+  "ui.message.expand": "Expand message",
+  "ui.message.collapse": "Collapse message",
+  "ui.message.copy": "Copy",
+  "ui.message.copied": "Copied!",
+  "ui.message.attachment.alt": "attachment",
+
+  "ui.patch.action.deleted": "Deleted",
+  "ui.patch.action.created": "Created",
+  "ui.patch.action.moved": "Moved",
+  "ui.patch.action.patched": "Patched",
+
+  "ui.question.subtitle.answered": "{{count}} answered",
+  "ui.question.answer.none": "(no answer)",
+  "ui.question.review.notAnswered": "(not answered)",
+  "ui.question.multiHint": "(select all that apply)",
+  "ui.question.custom.placeholder": "Type your answer...",
 }

+ 57 - 0
packages/ui/src/i18n/zh.ts

@@ -34,4 +34,61 @@ export const dict = {
   "ui.messagePart.title.write": "写入",
   "ui.messagePart.option.typeOwnAnswer": "输入自己的答案",
   "ui.messagePart.review.title": "检查你的答案",
+
+  "ui.list.loading": "加载中",
+  "ui.list.empty": "无结果",
+  "ui.list.emptyWithFilter.prefix": "没有关于",
+  "ui.list.emptyWithFilter.suffix": "的结果",
+
+  "ui.messageNav.newMessage": "新消息",
+
+  "ui.textField.copyToClipboard": "复制到剪贴板",
+  "ui.textField.copied": "已复制",
+
+  "ui.imagePreview.alt": "图片预览",
+
+  "ui.tool.read": "读取",
+  "ui.tool.list": "列表",
+  "ui.tool.glob": "Glob",
+  "ui.tool.grep": "Grep",
+  "ui.tool.webfetch": "Webfetch",
+  "ui.tool.shell": "Shell",
+  "ui.tool.patch": "补丁",
+  "ui.tool.todos": "待办",
+  "ui.tool.todos.read": "读取待办",
+  "ui.tool.questions": "问题",
+  "ui.tool.agent": "{{type}} 智能体",
+
+  "ui.common.file.one": "个文件",
+  "ui.common.file.other": "个文件",
+  "ui.common.question.one": "个问题",
+  "ui.common.question.other": "个问题",
+
+  "ui.common.add": "添加",
+  "ui.common.cancel": "取消",
+  "ui.common.confirm": "确认",
+  "ui.common.dismiss": "忽略",
+  "ui.common.next": "下一步",
+  "ui.common.submit": "提交",
+
+  "ui.permission.deny": "拒绝",
+  "ui.permission.allowAlways": "始终允许",
+  "ui.permission.allowOnce": "允许一次",
+
+  "ui.message.expand": "展开消息",
+  "ui.message.collapse": "收起消息",
+  "ui.message.copy": "复制",
+  "ui.message.copied": "已复制",
+  "ui.message.attachment.alt": "附件",
+
+  "ui.patch.action.deleted": "已删除",
+  "ui.patch.action.created": "已创建",
+  "ui.patch.action.moved": "已移动",
+  "ui.patch.action.patched": "已应用补丁",
+
+  "ui.question.subtitle.answered": "{{count}} 已回答",
+  "ui.question.answer.none": "(无答案)",
+  "ui.question.review.notAnswered": "(未回答)",
+  "ui.question.multiHint": "(可多选)",
+  "ui.question.custom.placeholder": "输入你的答案...",
 } satisfies Partial<Record<Keys, string>>

+ 18 - 2
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