Skip to content

fix(frontend): replace unicode/emoji glyphs with inline SVG for iOS 26.3 - #327

Merged
attson merged 6 commits into
mainfrom
fix/ios-icon-glyphs
Aug 4, 2026
Merged

fix(frontend): replace unicode/emoji glyphs with inline SVG for iOS 26.3#327
attson merged 6 commits into
mainfrom
fix/ios-icon-glyphs

Conversation

@attson

@attson attson commented Aug 4, 2026

Copy link
Copy Markdown
Owner

Summary

iOS 26.3 Simulator (and presumably future iOS 26 devices) renders the sidebar's task state indicators + pinned-group emoji as `.notdef` "?" placeholder boxes. Every session row in the Task Sidebar shows a yellow "?" square where the state icon should be.

Root cause: the app's `--font-sans` stack puts CJK fonts (PingFang SC, Hiragino Sans GB, Heiti SC, Noto Sans CJK SC) before `Apple Color Emoji` because `-apple-system` was intentionally removed to work around an earlier iOS 26 bug (see the comment in `src/style.css`). Under iOS 26.3, that fallback chain fails to resolve four glyphs that PingFang SC doesn't cover:

  • `◐` U+25D0 (waiting_input)
  • `✓` U+2713 (completed)
  • `✗` U+2717 (failed)
  • `📌` U+1F4CC (pinned group emoji)

The idle `·` (U+00B7) does render, but the row layout still shows amber "?" boxes because most sessions cycle through affected states.

Fix: pure inline SVG paths in `TaskStateIcon.vue`, keyed on `props.state` directly (no more `preset.glyphOf` indirection):

  • `running` → 3/4 arc, spinning (was already SVG)
  • `completed` → check-mark stroke
  • `failed` → X stroke
  • `waiting_input` → half-filled circle (outline + arc-fill path)
  • `idle` / `disconnected` / `closed` → small dot

Also replaced in `TaskGroupedList.vue`:

  • `📌` pin-icon emoji → inline SVG pin
  • `▶`/`▼` caret triangles (3 sites) → inline SVG triangles
  • `·` separator in completed-fold label → `(N)` wrap

Preset API cleanup: removed `glyphOf` from `TaskStatePreset` (no consumer now that TaskStateIcon dispatches on state). GLYPHS map deleted from `taskState.ts`. Tests dropped the two `glyphOf(%s)` parameterized cases and updated the "renders the glyph" assertion to check the SVG shape + `data-state` attribute instead of `w.text()`.

Cross-test updates: `TabBar.test.ts` + `TaskGroupedList.test.ts` scanned `.text()` for `◐` / `▼` / `▶` — now assert on `.task-state-icon[data-state="..."]` + presence of an svg element / aria-expanded state respectively.

Test plan

  • `npm test` 1615/1615 (was 1631; 16 removed = 2 presets × (7 states glyphOf + 1 running-spinner) cases dropped alongside `glyphOf`)
  • `npm run build:capacitor` clean
  • Manual, iOS Simulator (26.3): run `cd mobile && npm run ios:open` → build & run → open sidebar → all rows should show colored state indicators (dot/half-circle/check/X), pinned group header should show a pin SVG, expand/collapse carets should be filled triangles. No more yellow "?" boxes.
  • Manual (macOS Wails): sidebar still renders identically (SVG works everywhere).

iOS 26.3 Simulator (and presumably future iOS 26 devices) renders the
sidebar's task state indicators + pinned-group emoji as .notdef "?"
placeholder boxes. Every session row in the Task Sidebar shows a yellow
"?" square where the state icon should be.

Root cause: the app's --font-sans stack puts CJK fonts (PingFang SC,
Hiragino Sans GB, Heiti SC, Noto Sans CJK SC) before "Apple Color Emoji"
because -apple-system was intentionally removed to work around an
earlier iOS 26 bug (see the comment in src/style.css). Under iOS 26.3,
that fallback chain fails to resolve four glyphs that PingFang SC
doesn't cover:

  ◐  U+25D0 (waiting_input)
  ✓  U+2713 (completed)
  ✗  U+2717 (failed)
  📌 U+1F4CC (pinned group emoji)

The idle-state `·` (U+00B7) happens to render, but the row layout
still shows the amber "?" boxes because most sessions are in one of
the affected states or the state icon animates through them.

Fix by rendering pure inline SVG paths in TaskStateIcon.vue, keyed on
props.state directly (no more preset.glyphOf indirection):

  running        → 3/4 arc, spinning (was already SVG)
  completed      → check-mark stroke
  failed         → X stroke
  waiting_input  → half-filled circle (outline + arc-fill path)
  idle / disconnected / closed → small dot circle

Also replaced:

  📌 pin-icon emoji in TaskGroupedList  → inline SVG pin
  ▶/▼ caret triangles (3 sites)         → inline SVG triangles
  `·` separator in completed-fold label → "(N)" wrap

Preset API cleanup: removed `glyphOf` from TaskStatePreset (no consumer
now that TaskStateIcon dispatches on state). GLYPHS map deleted from
taskState.ts. Tests dropped the two `glyphOf(%s)` parameterized cases
and updated the "renders the glyph" assertion to check the SVG shape +
data-state attribute instead of `w.text()`.

TabBar.test.ts + TaskGroupedList.test.ts updated to match: they
scanned `w.find(".task-state-icon").text()` for ◐ and `.host-header`
text for ▼/▶ — now assert on `.task-state-icon[data-state="..."]`
+ presence of an svg element / aria-expanded state respectively.

`npm test` 1615/1615 (was 1631; 16 test drop is the 2 presets × (7
states + 1 running-spinner) glyphOf cases removed). `npm run
build:capacitor` clean.
…read tick

Follow-up to #327. iOS 26.3 Simulator still shows a "?" placeholder for
the sidebar hamburger button (☰, U+2630 — Miscellaneous Symbols block,
not in the PingFang SC / Hiragino / Heiti SC / Noto CJK fonts our
--font-sans lists ahead of Apple Color Emoji). Same failure class as
the ✓/✗/◐/📌 glyphs #327 already handled.

Replace all four remaining risky sites in the sidebar with inline SVG:

  TaskSidebar.vue
    ☰ hamburger      → 3-line hamburger SVG (16×16)
    « collapse arrow → double left-chevron SVG (12×12)
    » expand arrow   → double right-chevron SVG (12×12)

  TaskGroupedList.vue
    ✓ mark-all       → check-stroke SVG (12×12)

  TaskRowInner.vue
    ✓ mark-read      → check-stroke SVG (12×12)

Guillemets « » (U+00AB/U+00BB) are Latin-1 Supplement and probably do
render, but iOS 26.3's font-stack behavior was already surprising with
the hamburger — replace them for consistency with the collapse/expand
chevrons and eliminate any remaining sidebar-visible text glyphs from
the .notdef risk pool.

× close buttons (U+00D7 Multiplication Sign, Latin-1) and • bullet
(U+2022 General Punctuation) are left as-is — they've been reported
rendering fine, and both live in code paths (tab close, paste toast,
file explorer dirty dot) far from the sidebar-visible surface where
the .notdef bug has surfaced.

All existing tests still assert on data-test / aria-label attributes,
not glyph text — 1615/1615 green; `npm run build:capacitor` clean.
Follow-up: the diagonal pushpin path shipped in #327 rendered as a
jumble of overlapping strokes on iOS 26.3 — user reported it looked
like three horizontal lines. The path had ~10 vertices with
`stroke="currentColor" fill="none" stroke-width="1.2"` and a floating
stem segment that WebKit apparently couldn't reassemble cleanly at
12×12.

Replace with a clean 5-vertex filled bookmark:
  h8 → down → V-notch back up → down → close

Same UX intent (marks the pinned group), far fewer render-path pixels
to hit an iOS-side vector-rasterizer edge case. Verified by squinting
at the SVG in a browser at 12×12 — reads as a bookmark tag at that
size, no ambiguity.
The Task Sidebar's row label went through aiTitleOrCommand(), which
gated on session.type === 'ai' before surfacing SessionInfo.Title —
so every non-AI shell row collapsed to the command basename ("zsh")
even when the remote agent had set a meaningful OSC 2 title (project
dir, hostname, user label, etc.).

User report: "真实远端是有会话名称的,现在只显示了个 zsh,这个问题应该
在桌面端也有问题" — desktop had the same behavior; not an iOS thing.

Split the helper:

  usableAITitle  — unchanged, still gated on type === 'ai'. TabBar
                   keeps using it: on the tab strip we WANT shell
                   sessions to display their cwd basename (via
                   shortTitle) rather than an ugly `user@host: ~/dir`.

  usableTitle    — NEW, no type gate. Returns SessionInfo.Title for
                   any session type when it carries signal:
                     * non-empty after trim
                     * not just the command basename ("zsh" title on a
                       zsh session duplicates commandLabel and adds no
                       info, so fall through)
                   Codex animated-cwd cleanup runs for every type.

  titleOrCommand — renamed from aiTitleOrCommand. Uses usableTitle.
                   Consumed by TaskRowInner + TaskGroupedList's
                   completed-fold row.

Test updates:
  sessionLabel.test.ts    — renamed describe block; new expectations
                            for shell + undefined-type; added a
                            "title == command basename → fall
                            through" case.
  TaskGroupedList.test.ts — the "falls back to commandLabel for
                            non-ai" test flipped: now asserts the
                            OSC title surfaces; added a companion
                            case for title == "zsh" duplicate
                            suppression.

`npm test` 1617/1617 (+2 from the new test cases); `npm run
build:capacitor` clean.
Follow-up to the title-surfacing change earlier in this PR. iOS
Simulator showed every shell row as "/usr/bin/zsh" or "/bin/zsh"
because /etc/zshrc on many systems runs

  echo -ne "\e]2;$SHELL\a"

on startup, which lands in SessionInfo.Title as the full binary path.
usableTitle's earlier guard only matched title == commandLabel (the
bare basename "zsh"), so the path form fell through as a "meaningful
title" and displaced the clean row label.

Extend the guard:
  - compare against commandLabel(s) using the FULL session data (so
    the current_command="" title="/usr/bin/zsh" case — where mobile
    hasn't decrypted current_command yet — still resolves cmdBase to
    "zsh" via commandLabel's title-fallback)
  - also compare title.split('/').pop() against cmdBase so
    "/usr/bin/zsh" and "/bin/zsh" both suppress cleanly

Two new test cases lock this down:
  * title = "/usr/bin/zsh" with current_command = "/usr/bin/zsh"
  * title = "/bin/zsh"     with current_command = ""

Real OSC titles from AI tools ("* fix-translate-plugin-bugs" etc.)
still surface — the basename comparison only bites shell-path titles
whose basename matches the actual executable name.
Root cause of "mobile shows empty session titles" (issue traced from
a side-by-side desktop/iOS screenshot where the same session showed
its OSC title on desktop but "zsh" on iOS):

  agent
    │  applyOSCTitleLocked → s.meta.Title = "* fix-translate-plugin-bugs"
    │  writeAnnounce
    │    sealSessionInfoContent(snapshot, ak)     → info.Sealed = <bytes>
    │    stripContentFieldsFromSnapshot(snapshot) → info.Title = ""
    ▼
  remote relay
    │  handleUplink → reconcile → UpdateAdvertisedInfo(info)
    │    // info.Title == "" → skip title update (correct)
    │    // info.Sealed == <bytes> → NOT COPIED ANYWHERE (bug)
    │  ss.meta.Sealed stays nil
    ▼
  /api/sessions
    │  sessionInfoListForOwner → ss.Info() → meta with Sealed=nil, Title=""
    ▼
  mobile / iOS
    │  decryptSessionFields sees s.sealed == "" → returns unchanged
    │  titleOrCommand sees title == "" → falls back to commandLabel
    ▼
  every row displays "zsh"

Desktop works because the desktop Vue reads from the LOCAL in-process
relay, whose sessions are the agent's own *Session structs — meta.Title
is populated directly by applyOSCTitleLocked, no announce round-trip.

Fix: UpdateAdvertisedInfo now copies info.Sealed → s.meta.Sealed with
a deep copy (append onto a nil slice) so a later mutation on the
inbound ANNOUNCE buffer can't corrupt the served envelope. Empty
inbound Sealed is treated as "no update" — same non-clobbering guard
Title and Cwd already have — so legacy paths that announce without
sealed don't wipe a previously-set envelope.

Three tests in announce_sealed_test.go lock this down:
  - adopts Sealed from ANNOUNCE
  - deep-copies (post-update caller mutation doesn't leak)
  - empty Sealed on a follow-up announce doesn't clobber

Frontend already:
  desktop/frontend/src/platform/capacitor.ts:520+  runs openSessionFields
    on s.sealed at every listRemoteSessions call — this fix makes that
    field non-nil on the wire.

MetaPayload.Sealed (live META broadcast path) is a separate envelope
type (different AAD frame-type byte) and is out of scope here; it's
consumed by /client-sessions WS subscribers only, which mobile HTTP
does not use.

`go test ./...` green.
@attson
attson merged commit b5ae091 into main Aug 4, 2026
7 checks passed
@attson
attson deleted the fix/ios-icon-glyphs branch August 4, 2026 16:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant