fix(frontend): replace unicode/emoji glyphs with inline SVG for iOS 26.3 - #327
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
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):
Also replaced in `TaskGroupedList.vue`:
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