Repository navigation
app: TodoWrite crashes session timeline in non-English locales #51087
Description
Activity
I would like to work on this.
I verified the chain on the current
v2head and the mechanism is as you describe, with the missing key in one more place than the report names.packages/ui/src/i18n/en.tsonv2has noui.tool.todosentry, whileui.tool.questions,ui.tool.taskandui.tool.shellare all present. The merged base dictionary is built atpackages/app/src/runtime/i18n/language.tsx:88(const base = flatten({ ...en, ...dict }), app dict merged with the@opencode/uidict), and the per-locale overlay at:92cannot supply the key either, because the locale dictionaries omit it as well (checkedthin both sets). Sotranslator()resolves the path toundefinedin every locale, English included.- That value reaches the list at
packages/session-ui/src/tools/tool-renderer.tsx:516-534, which collectsgetToolInfo(...).titleintoi18n.list([...]); thetodowritecase at:387returnsi18n.t("ui.tool.todos"). createUiI18n().list(packages/ui/src/context/i18n.tsx:77-80) branches on locale: English usesitems.join(", "), every other locale usesnew Intl.ListFormat(locale, { style: "long", type: "conjunction" }).format(items), which rejects a non-string element. That is yourTypeError.
Two things worth adding to the report.
English is affected too, just less visibly.
Array.prototype.joinrendersundefinedas an empty string, so the group title reads likeRead, , Shell. Same cause as the crash, cosmetic instead of fatal.Nothing catches this at build time.
UiTranslateis declared to returnstring(i18n.tsx:15), butUiTranslationKey<Value>does not constrainValueto the dictionary key union, so callingt()with a key the dictionary does not contain type-checks cleanly. That is why it shipped rather than failing a type check.This reads as a v2 port omission rather than a new defect:
ui.tool.todosdoes exist ondev(packages/ui/src/i18n/en.ts:162, next toui.tool.todos.readandui.tool.questions), so theui.tool.*siblings were carried into v2 but this one was missed.Plan: add the missing key to
packages/ui/src/i18n/en.tsand to the locale dictionaries that carry its siblings, and makelist()degrade instead of throwing when an entry is not a string, with a test covering a missing translation in a non-English locale. If you would rather keep thelist()hardening out of this fix and land the dictionary entry alone, say so and I will scope it that way.Fix-size update: this is a one-line change, and I confirmed the fallback covers every locale rather than just English.
I simulated the real merge from
packages/app/src/runtime/i18n/language.tsxagainst the shipped files -base = { ...appEn, ...uiEn }(:88) and the per-locale overlay{ ...base, ...{ ...appLocale, ...uiLocale } }(:92):- as shipped:
base["ui.tool.todos"]andth["ui.tool.todos"]are bothundefined; - after adding the key to
packages/ui/src/i18n/en.tsonly:basegetsTo-dos, andthalso resolves toTo-dos, because the locale overlay does not carry that key, so the base value survives the spread.
So the English entry is what removes
undefinedeverywhere - a locale that has no translation shows the English string instead of crashing, and translations can follow separately. That also matches how the dictionaries are built:packages/ui/src/i18n/th.tsanden.tshold the same 248 keys today, both missing this one, so the omission came from the source entry rather than from any single translation.I reproduced both branches of
list()with the same input to be sure the diagnosis is exact:th -> TypeError: Iterable yielded undefined which is not a string en -> "Read, , Shell"The throw matches your report; the second line is the invisible half.
Two questions before I write it up:
- Scope - the dictionary entry alone, or the entry plus hardening
list()(packages/ui/src/context/i18n.tsx:77-80) so a non-string entry degrades instead of killing the whole timeline? The test for either belongs inpackages/ui/src/context/i18n.test.ts, which currently coverspluralCategoryandtDynamiconly. - Translations - I would add the English entry and the languages I can write accurately, and leave the rest to fall back to English rather than machine-translate 60 files. Tell me if you would rather have the key present in every dictionary, even untranslated.
- as shipped:
Thanks for confirming the diagnosis and for offering to work on a fix.
My preference is the robust version:
- Add the missing
ui.tool.todosEnglish fallback. - Harden
createUiI18n().list()soundefinedor other non-string entries cannot crash the session timeline, including malformed tool parts like the one described in desktop: renderer crash (Intl.ListFormat: Iterable yielded undefined) from missing ui.tool.todos key | 桌面端渲染进程崩溃:缺失翻译 key ui.tool.todos #51286. - Add regression tests for both a missing translation and a malformed or missing tool name.
For translations, I prefer adding the English fallback and adding other translations only when they can be verified, rather than machine-translating every locale file.
Please open a PR linked to #51087 and include the test results.
- Add the missing
Thanks for the clear direction. I will take the robust version, all three parts.
The plan, with the anchors verified on the current
v2head (14fc63ba9ec2):-
packages/ui/src/i18n/en.ts— add"ui.tool.todos": "To-dos"to the English dictionary. This is the canonical value already used ondev(packages/ui/src/i18n/en.ts:162, alongsideui.tool.todos.read), so the v2 port simply missed it. Placement next to the sibling tool labels (ui.tool.taskat:191). -
packages/ui/src/context/i18n.tsx— hardencreateUiI18n().list()(:77-80) so a non-string entry cannot reach the formatter. The type is declared(items: readonly string[]) => string, but the type is exactly what cannot catch this case, so the guard has to be a runtime one. I plan to drop non-string entries rather than coerce them, which means an unknown key contributes no label instead of an empty slot. One visible consequence worth flagging up front: English currently rendersbash, , read(theundefinedbecomes an empty string viajoin), and this would make itbash, read— the same shape the non-English path would then produce, so both paths agree. -
packages/ui/src/context/i18n.test.ts— regression tests for both paths. That file already has alist()group (:58-62) with ani18n(locale, t)helper, so the cases fit there: a missing translation key must not throw under a non-English locale, and a malformed/missing tool name must not throw either.
One note on scope, since you mentioned it: #51286 is the same defect from the desktop side and I have read it. Its audit of all 388 keys referenced by the renderer bundles found
ui.tool.todosto be the only genuinely missing one, which matches what I found reading the v2 dictionary and its siblings — so the key addition covers the whole class and thelist()guard is defence in depth. That issue is assigned to a maintainer; either fix resolves both, and I will link the PR to this issue either way.I will open the PR against
devwith the test output included.-
Opened #51693 with the fix, targeting
v2because the crash is in 2.0.x.Following the three points in your report:
packages/ui/src/i18n/en.tsdefinesui.tool.todosasTo-dos, the same valuedevuses. Every locale dictionary is merged over that base dictionary, so the label comes back for locales that do not translate the key themselves.createUiI18n().list()now drops entries that are not strings before formatting, so a value with no translation can no longer reachIntl.ListFormat.format().packages/ui/src/context/i18n.test.tsgets regression tests for both paths.
Test results:
cd packages/ui && bun test src/context/i18n.test.ts-> 22 pass, 0 fail (3 new tests)- failure reproduced first:
new Intl.ListFormat("th", { style: "long", type: "conjunction" }).format(["Read", undefined])throwsTypeError: Iterable passed to ListFormat includes non String(Bun/JSC wording of the same error on Electron/V8); the English path returnedRead, , Listinstead of throwing bun turbo typecheck --filter='...[HEAD^1]'-> 10 successful, 10 total;oxlintandprettier --checkclean
One behaviour change to be aware of: with the guard, an entry that has no translation is omitted from the list instead of crashing. Backporting the per-locale
ui.tool.todostranslations thatdevalready has would restore those labels; the guard makes that a translation gap rather than a renderer crash.Still reproducible on 2.0.24 (latest) — and it is not desktop-only
Adding a data point to #51087, since the fix in #51693 has not shipped yet and there is one scope detail that does not appear in the existing reports.
1. Still broken on the current stable. The desktop app auto-updated to 2.0.24 on 2026-10-06, and the crash reproduces there today (2026-10-08). The key is still absent from the shipped renderer:
ui.tool.todosoccurs 0 times inout/renderer/assets/main-Jci-kuOG.js(where every sibling key such asui.tool.shell,ui.tool.read,ui.tool.questionsandui.messagePart.title.writeis defined) and once inout/renderer/assets/route-Bn3ZoGRA.js, which is thetodowritebranch of the tool-badge descriptor. So #51693 is still needed.2. The server-served web UI is affected too, not just the Electron app. Running
opencode serveand loading the UI in a browser reproduces the identical failure:TypeError: Iterable yielded undefined which is not a string at ListFormat.format (<anonymous>) at Object.list (_assets/i18n-Bzrxd_XH.js:1:13246) at Object.fn (_assets/route--zBKBJA8.js:3:34081)ui.tool.todosis likewise present only inroute--zBKBJA8.js(the usage) and absent from bothi18n-Bzrxd_XH.js(base dictionary) and thezhtlocale chunk. This widens the blast radius to browser/server deployments, not only the packaged desktop app — worth noting for prioritisation, since a headlessopencode servebehind a proxy would fail for any non-English user too.3. Runtime capture of the offending list. Overriding
Intl.ListFormat.prototype.formatin the page to record its input instead of throwing, then opening an affected session, yields the same array on every render attempt (4/4 captures):["Shell", "寫入", undefined]
which maps cleanly onto the three branches that feed
t.list():ui.tool.shell→"Shell",ui.messagePart.title.write→"寫入", and theundefinedfrom the missingui.tool.todosfor thetodowritepart. This also confirms nothing else in the pipeline contributes a non-string.4. Scope on this install (for reference). 6 of 10 sessions in the local database contain at least one
todowritetool part; the session that first reproduced had 7. Session data itself is intact — a database-wide scan oftool/shellparts found zero with a missingname, matching #51286.Workaround used: UI language → English, which routes
t.list()throughjoin(', '). Confirmed the affected session renders normally and completely that way.(中文摘要:此問題在最新的 2.0.24 仍然可以重現,而且除了 Electron 桌面版之外,用
opencode serve提供的網頁 UI 也受影響,非英文語系的伺服器部署同樣會壞。修補用的 PR #51693 尚未合併,所以這個翻譯 key 目前仍然缺少。)
Description
OpenCode Desktop 2.0.16 fails to render a session when a
TodoWritetool call is displayed under a non-English locale (confirmed with Thai).The tool call itself can complete, but the session timeline throws this renderer error:
This appears to be a regression introduced by #50204:
getToolInfo()handlestodowritewithi18n.t("ui.tool.todos").ui.tool.todosis not defined in the translation dictionaries, so the translator returnsundefinedat runtime.createUiI18n().list()passes tool names directly toIntl.ListFormat.format().Intl.ListFormatrejectsundefined, producing the error above.The English path uses
Array.join()and therefore does not throw.Relevant source at the 2.0.16 release commit:
packages/session-ui/src/tools/tool-renderer.tsx(todowritecase):opencode/packages/session-ui/src/tools/tool-renderer.tsx
Lines 384 to 388 in 0bc8b8d
packages/ui/src/context/i18n.tsx(createUiI18n().list):opencode/packages/ui/src/context/i18n.tsx
Lines 75 to 80 in 0bc8b8d
Expected behavior:
TodoWriteshould render normally in every supported locale.Actual behavior: the session timeline renderer throws when
TodoWriteis grouped with other tool names under a non-English locale.Workaround: switch the application language to English before rendering the affected session.
Plugins
None
OpenCode version
2.0.16
Steps to reproduce
TodoWritetool call.TypeError: Iterable yielded undefined which is not a string.Screenshot and/or share link
No screenshot attached; the exact renderer stack trace is included in the description.
Operating System
Windows 11 (10.0.26200.9550)
Terminal
OpenCode Desktop 2.0.16 — Electron renderer (not a terminal)