Skip to content

app: TodoWrite crashes session timeline in non-English locales #51087

Description

@tammatatsky-ctrl

Description

OpenCode Desktop 2.0.16 fails to render a session when a TodoWrite tool 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:

TypeError: Iterable yielded undefined which is not a string
    at ListFormat.format (<anonymous>)

This appears to be a regression introduced by #50204:

  1. getToolInfo() handles todowrite with i18n.t("ui.tool.todos").
  2. The key ui.tool.todos is not defined in the translation dictionaries, so the translator returns undefined at runtime.
  3. For non-English locales, createUiI18n().list() passes tool names directly to Intl.ListFormat.format().
  4. Intl.ListFormat rejects undefined, producing the error above.

The English path uses Array.join() and therefore does not throw.

Relevant source at the 2.0.16 release commit:

Expected behavior: TodoWrite should render normally in every supported locale.

Actual behavior: the session timeline renderer throws when TodoWrite is 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

  1. Install or update to OpenCode Desktop 2.0.16.
  2. Set the application language to Thai or another non-English locale.
  3. Start or open a session.
  4. Ask the agent to create a todo list, causing a TodoWrite tool call.
  5. Wait for the tool call to be rendered in the session timeline.
  6. Observe the renderer error: TypeError: Iterable yielded undefined which is not a string.
  7. Change the application language to English and reopen the session.
  8. Confirm that the crash does not occur.

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)

Activity

  1. argszero commented on Sep 24, 2026

    @argszero
    Contributor

    I would like to work on this.

    I verified the chain on the current v2 head and the mechanism is as you describe, with the missing key in one more place than the report names.

    1. packages/ui/src/i18n/en.ts on v2 has no ui.tool.todos entry, while ui.tool.questions, ui.tool.task and ui.tool.shell are all present. The merged base dictionary is built at packages/app/src/runtime/i18n/language.tsx:88 (const base = flatten({ ...en, ...dict }), app dict merged with the @opencode/ui dict), and the per-locale overlay at :92 cannot supply the key either, because the locale dictionaries omit it as well (checked th in both sets). So translator() resolves the path to undefined in every locale, English included.
    2. That value reaches the list at packages/session-ui/src/tools/tool-renderer.tsx:516-534, which collects getToolInfo(...).title into i18n.list([...]); the todowrite case at :387 returns i18n.t("ui.tool.todos").
    3. createUiI18n().list (packages/ui/src/context/i18n.tsx:77-80) branches on locale: English uses items.join(", "), every other locale uses new Intl.ListFormat(locale, { style: "long", type: "conjunction" }).format(items), which rejects a non-string element. That is your TypeError.

    Two things worth adding to the report.

    English is affected too, just less visibly. Array.prototype.join renders undefined as an empty string, so the group title reads like Read, , Shell. Same cause as the crash, cosmetic instead of fatal.

    Nothing catches this at build time. UiTranslate is declared to return string (i18n.tsx:15), but UiTranslationKey<Value> does not constrain Value to the dictionary key union, so calling t() 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.todos does exist on dev (packages/ui/src/i18n/en.ts:162, next to ui.tool.todos.read and ui.tool.questions), so the ui.tool.* siblings were carried into v2 but this one was missed.

    Plan: add the missing key to packages/ui/src/i18n/en.ts and to the locale dictionaries that carry its siblings, and make list() 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 the list() hardening out of this fix and land the dictionary entry alone, say so and I will scope it that way.

  2. argszero commented on Sep 24, 2026

    @argszero
    Contributor

    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.tsx against the shipped files - base = { ...appEn, ...uiEn } (:88) and the per-locale overlay { ...base, ...{ ...appLocale, ...uiLocale } } (:92):

    • as shipped: base["ui.tool.todos"] and th["ui.tool.todos"] are both undefined;
    • after adding the key to packages/ui/src/i18n/en.ts only: base gets To-dos, and th also resolves to To-dos, because the locale overlay does not carry that key, so the base value survives the spread.

    So the English entry is what removes undefined everywhere - 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.ts and en.ts hold 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:

    1. 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 in packages/ui/src/context/i18n.test.ts, which currently covers pluralCategory and tDynamic only.
    2. 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.
  3. tammatatsky-ctrl commented on Sep 25, 2026

    @tammatatsky-ctrl
    Author

    Thanks for confirming the diagnosis and for offering to work on a fix.

    My preference is the robust version:

    1. Add the missing ui.tool.todos English fallback.
    2. Harden createUiI18n().list() so undefined or 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.
    3. 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.

  4. argszero commented on Sep 25, 2026

    @argszero
    Contributor

    Thanks for the clear direction. I will take the robust version, all three parts.

    The plan, with the anchors verified on the current v2 head (14fc63ba9ec2):

    1. packages/ui/src/i18n/en.ts — add "ui.tool.todos": "To-dos" to the English dictionary. This is the canonical value already used on dev (packages/ui/src/i18n/en.ts:162, alongside ui.tool.todos.read), so the v2 port simply missed it. Placement next to the sibling tool labels (ui.tool.task at :191).

    2. packages/ui/src/context/i18n.tsx — harden createUiI18n().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 renders bash, , read (the undefined becomes an empty string via join), and this would make it bash, read — the same shape the non-English path would then produce, so both paths agree.

    3. packages/ui/src/context/i18n.test.ts — regression tests for both paths. That file already has a list() group (:58-62) with an i18n(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.todos to 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 the list() 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 dev with the test output included.

  5. argszero commented on Sep 27, 2026

    @argszero
    Contributor

    Opened #51693 with the fix, targeting v2 because the crash is in 2.0.x.

    Following the three points in your report:

    • packages/ui/src/i18n/en.ts defines ui.tool.todos as To-dos, the same value dev uses. 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 reach Intl.ListFormat.format().
    • packages/ui/src/context/i18n.test.ts gets 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]) throws TypeError: Iterable passed to ListFormat includes non String (Bun/JSC wording of the same error on Electron/V8); the English path returned Read, , List instead of throwing
    • bun turbo typecheck --filter='...[HEAD^1]' -> 10 successful, 10 total; oxlint and prettier --check clean

    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.todos translations that dev already has would restore those labels; the guard makes that a translation gap rather than a renderer crash.

  6. yutbfrec-ops commented on Oct 7, 2026

    @yutbfrec-ops

    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.todos occurs 0 times in out/renderer/assets/main-Jci-kuOG.js (where every sibling key such as ui.tool.shell, ui.tool.read, ui.tool.questions and ui.messagePart.title.write is defined) and once in out/renderer/assets/route-Bn3ZoGRA.js, which is the todowrite branch 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 serve and 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.todos is likewise present only in route--zBKBJA8.js (the usage) and absent from both i18n-Bzrxd_XH.js (base dictionary) and the zht locale chunk. This widens the blast radius to browser/server deployments, not only the packaged desktop app — worth noting for prioritisation, since a headless opencode serve behind a proxy would fail for any non-English user too.

    3. Runtime capture of the offending list. Overriding Intl.ListFormat.prototype.format in 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 the undefined from the missing ui.tool.todos for the todowrite part. 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 todowrite tool part; the session that first reproduced had 7. Session data itself is intact — a database-wide scan of tool/shell parts found zero with a missing name, matching #51286.

    Workaround used: UI language → English, which routes t.list() through join(', '). Confirmed the affected session renders normally and completely that way.

    (中文摘要:此問題在最新的 2.0.24 仍然可以重現,而且除了 Electron 桌面版之外,用 opencode serve 提供的網頁 UI 也受影響,非英文語系的伺服器部署同樣會壞。修補用的 PR #51693 尚未合併,所以這個翻譯 key 目前仍然缺少。)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions