Skip to content

ios: keep running tool calls expanded so results stream - #281

Closed
0xSero wants to merge 23 commits into
mainfrom
fix/streaming-tool-call-expansion
Closed

0xSero wants to merge 23 commits into
mainfrom
fix/streaming-tool-call-expansion

Conversation

@0xSero

@0xSero 0xSero commented Aug 12, 2026

Copy link
Copy Markdown
Owner

Purpose

Fix severe per-token UI lag across the entire app. The home screen, sessions list, project picker, conversation composer, and top-level shell all stalled during streaming because AppModel.snapshot (@Observable) was reassigned on every streaming text token, causing every view reading it in body to re-render per-token.

Key changes

Fix B — Coalesce streaming deltas (AppModel)

  • .threadStreamingDelta now accumulates text in pendingStreamingDeltas and flushes at ~8fps (120ms coalescing) instead of mutating snapshot per token.
  • StreamingRendererCoordinator.appendDelta stays immediate so the visible streaming bubble remains smooth at token rate.
  • Final delta flushed on turn completion (threadMetadataChanged/threadItemChanged), thread removal, and full resync — no token lost.
  • Removed dead applyThreadStreamingDelta method.

Fix A1 — HomeNavigationView

  • pinnedThreadHydrationSignature moved from body-read computed property to debounced HomeDashboardModel property (120ms).
  • .onChange(of: appModel.snapshot?.activeThread).onChange(of: homeDashboardModel.activeThread).

Fix A2 — ContentView.standardOverlays

  • New OverlayProjectionModel observes snapshotRevision via withObservationTracking + 100ms debounce, publishes only petAvatarState, petAvatarMessage, pendingApproval.
  • standardOverlays reads the projection instead of appModel.snapshot.
  • .onChange(of: appModel.snapshot).onChange(of: snapshotRevision) + handleSnapshotRevisionChange().

Fix A3 — ConversationDestinationScreen

  • Collapsed 5 per-token onChange handlers into 2 (snapshotRevision + composerPrefillRequest).
  • Removed dead pendingUserInputsForThread and relevantServerSnapshot computed properties.

Fix C — SessionsModel

  • Added 120ms debounce mirroring HomeDashboardModel's scheduleObservedRefresh pattern.

Fix D — HomeDashboardView

  • visibleSessions.map { ... } onChange signals (re-allocated per body eval) replaced with precomputed visibleHydrationSignature/visibleActivitySignature on the model.

Fix E — ProjectPickerSheet

  • Per-row appModel.isLocalServer (N observation edges) replaced with precomputed localServerIds: Set<String> param.

Also included

  • bridge: normalize legacy item lifecycle notifications at json-line wire — older Pi/Local Studio bridges emit item lifecycle notifications without mandatory timestamps; normalized at the raw JSONL boundary before strict upstream decode.

Verification

  • make ios-sim-fastBUILD SUCCEEDED (Rust 1.93 toolchain)
  • iOS unit tests: 219 passed, 0 failures
  • App installs and launches on iOS Simulator without UniFFI checksum crash
  • All Swift observation-graph fixes are pure Swift; no Rust API surface changes

7066c5a removed `ConversationLiveDetailRetentionPolicy`, which tracked the
in-progress item and fed `shouldPreserveRichDetail` so the running tool call
stayed open. Nothing replaced it. Command executions kept their in-flight
expansion inline (`commandDefaultExpanded` returns `data.isInProgress`), but
tool calls did not — `defaultExpanded` only considered `isFailed`.

Display mode defaults to `.collapsed`, so a running tool call rendered
collapsed and silent and only surfaced once `ItemCompleted` delivered the whole
payload. Reasoning and assistant text stream down a different path, which is
why a turn looked like it reasoned fine and then froze on every tool call.
`liveDetailStatus` survived as dead code with zero call sites — the in-progress
signal was still computed and thrown away.

`defaultExpanded` now takes `isInProgress` and honours it in `.collapsed`,
mirroring commands. Completed-and-not-failed still collapses, which is what
7066c5a actually wanted. The three call sites pass flags rather than a status
because they hand in two different enums: `ToolCallStatus` for card models and
`AppOperationStatus` for the MCP and image-generation rows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@0xSero

0xSero commented Aug 13, 2026

Copy link
Copy Markdown
Owner Author

Closing as duplicate: the same correctness fix is already on main as commit 78c609f8 ("ios: keep live tool results visible"). Both add isInProgress to ConversationDetailDisplayMode.defaultExpanded / toolDefaultExpanded, change .collapsed to return isFailed || isInProgress (mirroring command rows), and add the regression test testCollapsedModeExpandsInProgressToolCalls. Main's version additionally wires isInProgress through the ComputerUse and ImageGeneration tool-call rows, so it supersedes this PR. No live work lost. Branch/worktree retained per current instructions.

@0xSero 0xSero closed this Aug 13, 2026
0xSero added 2 commits August 15, 2026 18:43
Older Pi/Local Studio bridges emit item/started and item/completed
notifications without the mandatory startedAtMs/completedAtMs timestamps
that v0.129 made required, and commandExecution items with an empty cwd
that AbsolutePathBuf rejects. Upstream strict-decodes and silently drops
the entire notification, taking tool calls/results with it. Normalize at
the raw JSONL boundary before RemoteAppServerClient decodes.

Also register SavedServerStoreTests in the iOS test target Sources phase.
…on edges

Root cause: AppModel.snapshot (@observable) was reassigned on every
streaming text token, causing every view that read it in body to
re-render per-token. The home screen, top-level shell, conversation
screen, sessions list, and project picker all stalled during streaming.

Fix B (AppModel): route .threadStreamingDelta through a coalescer that
accumulates text per (thread, item, kind) and flushes at ~8fps (120ms).
StreamingRendererCoordinator.appendDelta stays immediate so the visible
streaming bubble remains smooth at token rate. Final delta flushed on
turn completion (threadMetadataChanged/threadItemChanged), thread removal,
and full resync so no token is lost. Removed dead applyThreadStreamingDelta.

Fix A1 (HomeNavigationView): pinnedThreadHydrationSignature moved from
a body-read computed property (reading appModel.snapshot) to a debounced
HomeDashboardModel property. .onChange(of: appModel.snapshot?.activeThread)
replaced with debounced homeDashboardModel.activeThread.

Fix A2 (ContentView): new OverlayProjectionModel observes
snapshotRevision via withObservationTracking + 100ms debounce and
publishes only petAvatarState, petAvatarMessage, pendingApproval.
standardOverlays reads the projection instead of appModel.snapshot.
.onChange(of: appModel.snapshot) replaced with .onChange(of:
snapshotRevision) + handleSnapshotRevisionChange().

Fix A3 (ConversationDestinationScreen): collapsed 5 per-token onChange
handlers into 2 (snapshotRevision + composerPrefillRequest). Removed
dead pendingUserInputsForThread and relevantServerSnapshot computed props.

Fix C (SessionsModel): added 120ms debounce mirroring HomeDashboardModel's
scheduleObservedRefresh pattern so SessionsDerivation.build stops re-running
per token.

Fix D (HomeDashboardView): visibleSessions.map { ... } onChange signals
(re-allocated + stringified per body eval) replaced with precomputed
visibleHydrationSignature/visibleActivitySignature on HomeDashboardModel.

Fix E (ProjectPickerSheet): per-row appModel.isLocalServer (N observation
edges) replaced with a precomputed localServerIds: Set<String> param.

Verified: make ios-sim-fast BUILD SUCCEEDED, 219 iOS tests passed (0
failures), app installs and launches on simulator without checksum crash.
@0xSero 0xSero reopened this Aug 15, 2026
0xSero added 10 commits August 15, 2026 21:10
ConversationView (highest impact — main chat screen on-screen during
streaming):
- supportsTurnPagination: was a body-read computed property reading
  appModel.snapshot.serverSnapshot(for:).capabilities. Now precomputed
  in ConversationScreenModel.refreshState (non-body context) and passed
  as a param. ConversationView.body no longer reads appModel.snapshot.
- resolveTargetLabel: was a private func reading appModel.snapshot.
  resolvedAgentTargetLabel(for:serverId:). Now precomputed as a closure
  in ConversationScreenModel that captures sessionSummaries at refresh
  time. Passed as a param — no body observation edge.
- ConversationInputBar.hasFixedFullAccess: was reading
  appModel.snapshot.threads.first(...).agentRuntimeKind. Now reads from
  the precomputed composer snapshot field.

ConversationInfoView:
- thread/server computed properties read appModel.snapshot in body
  (heroSection, statusColor, serverInfoSection, serverChartsSection).
  Replaced with @State resolvedThread/resolvedServer refreshed from
  .onAppear + .onChange(of: appModel.snapshotRevision). Body now
  observes @State, not appModel.snapshot.
- Removed dead code: allServerThreads (never referenced), statusLabel
  (never referenced).

DiscoveryView:
- .onChange(of: appModel.snapshot) re-rendered the entire discovery
  screen on every snapshot bump. Replaced with
  .onChange(of: appModel.snapshotRevision).

DirectoryPickerView:
- selectedServerSnapshot read appModel.snapshot.servers.first(where:)
  in body (via selectedServerIsLocal, canSelectPath, .disabled).
  Replaced with localServerIds/browseableServerIds Set<String> params
  precomputed in HomeDashboardModel and SessionsModel debounced refresh.

Verified: make ios-sim-fast BUILD SUCCEEDED, 219 unit tests + 6 UI
tests passed (0 failures).
InlineHandoffView.thread was a computed property reading
appModel.snapshot?.threadSnapshot(for:) directly in body, creating a
per-token observation edge during voice sessions. Replaced with
@State resolvedThread refreshed from .onAppear + .onChange(of:
appModel.snapshotRevision), matching the ConversationInfoView pattern.

Identified by glm-5.2 code review via pi consultation.

Verified: make ios-sim-fast BUILD SUCCEEDED, 219 unit tests passed.
HeaderView, ConversationModelPickerPanel, and ConversationToolbarControls
all read appModel.snapshot?.serverSnapshot(for:) in body. Since these
views are in the conversation toolbar, they were re-rendering on every
coalesced snapshot bump (~8fps during streaming).

Added serverSnapshot published property to ConversationScreenModel
(computed in refreshState, non-body context) and passed it as a param
to all three toolbar views.

Verified: make ios-sim-fast BUILD SUCCEEDED, 219 unit tests passed.
HomeModelChip (home composer bar) read appModel.snapshot in body via
server and metadataLoadID computed properties, creating observation edges
that re-rendered the home composer on every coalesced snapshot bump
during background streaming.

Added serverSnapshotsById dictionary to HomeDashboardModel (debounced,
computed in refreshState from rawServers) and passed it through
HomeDashboardView and NewThreadHeroView as a param. HomeModelChip now
receives the precomputed server snapshot directly.

Verified: make ios-sim-fast BUILD SUCCEEDED, 219 unit tests passed.
1. flushPendingStreamingDeltas: re-arm coalesced timer after a targeted
   flush if deltas remain for other threads. Without this, concurrent
   streaming threads (subagents/handoff) could lose the tail of text
   when one thread's flush cancelled the shared timer.

2. flushPendingStreamingDeltas: fall back to scheduleThreadSnapshotRefresh
   for batches whose thread/item disappeared between enqueue and flush.
   The old per-token code had this fallback; the coalescer was silently
   dropping the text.

3. enqueueStreamingDelta: clear pending deltas for a thread when falling
   back to a full-thread refresh, preventing token duplication if the
   refresh lands with full item text while a batch is still pending.

4. OverlayProjectionModel: observe PetOverlayController.isLoading/isDragging
   in addition to snapshotRevision. Pet drag/loading state changes
   independently of snapshot bumps and was going stale. Also increment
   observationGeneration on bind() so stale tracking closures from a
   prior bind are properly invalidated.

5. ConversationScreenModel: reset serverSnapshot to nil in the early-
   return guard so switching threads doesn't leave stale server data.

Identified by claude code (opus) review via ~/.local/bin/claude.

Verified: make ios-sim-fast BUILD SUCCEEDED, 219 unit tests passed.
SubagentCardView resolvedLabel/resolvedThreadKey/liveStatus all read
appModel.snapshot in body via agentRowView called per-row from ForEach.
Each row created N observation edges that re-rendered the entire card
on every coalesced snapshot bump.

Added resolveThreadKey and resolveLiveStatus closures to
ConversationScreenModel (precomputed in refreshState from captured
sessionSummaries), passed through ConversationView →
ConversationMessageList → ConversationTimelineView → SubagentCardView.
SubagentCardView.body no longer reads appModel.snapshot.

Verified: make ios-sim-fast BUILD SUCCEEDED, 219 unit tests passed.
Removed 12 confirmed-unused private functions, computed properties, stored
properties, and types across 11 iOS source files. Each was verified as
having zero call sites outside its declaration. Total: -137 lines.

Removed:
- LitterApp.openServerSessions(_:) — uncalled member of open* family
- AppModel.applyThreadCommandExecutionUpdated — uncalled reducer
- ConversationInfoView.timestampLabel(_:timestamp:) — unused view builder
- ConversationView.lastTurnIsUserOnly — unused computed property
- ConversationView.isStreamingLastTurn — unused computed property
- HomeComposerView.isDisabled — unused computed property
- HomeDashboardView.SessionCanvasLine.metaLine — unused view builder
- HomeSessionsScrollView.peakBlurProgress — leftover from removed feature
- SubagentCardView.isInProgress — unused computed property
- WallpaperAdjustView.isServerOnly — unused computed property
- ConversationTimelineView.DiffLine struct + nested Kind enum
- NearbyMacPairing.NICodingError enum — never thrown

Verified: make ios-sim-fast BUILD SUCCEEDED, 219 unit tests passed.
Deep performance audit found 5 issues on the conversation scroll and
home list paths. All fixed:

1. ConversationMessageList.mergedRenderableTurns: the O(n) build-key
   hash (hashing every turn's id/renderDigest/isLive/isCollapsedByDefault
   on every body eval) was defeating the renderedTurns cache. Now reads
   the cached renderedTurns directly — the cache is already maintained
   by applyTranscriptTurns/syncTranscriptTurns. Falls back to source-
   derived merge only when the cache is empty (first render).

2. ConversationMessageList.shouldShowScrollToBottom: distanceFromBottom
   was read in body, causing the entire message-list body (including
   the LazyVStack diff setup) to re-evaluate on every scroll frame.
   Now driven by a boolean @State (showScrollToBottomButton) that only
   flips at the threshold, so scroll geometry changes don't trigger
   body re-evaluation unless the button needs to appear/disappear.

3. HeaderView.sessionModelLabel + ConversationModelPickerPanel: residual
   appModel.availableModels(for:) calls (which read appModel.snapshot)
   in the header body path. Replaced with server?.availableModels ?? []
   using the already-passed server param. Header no longer re-renders
   at ~8fps during streaming.

4. Extensions.relativeDate: RelativeDateTimeFormatter was allocated on
   every call. Hoisted to a file-level static let (matching the
   SessionsScreen pattern). Affects every home card + search row render.

5. ConversationTimelineView.timelineContent: VStack → LazyVStack so
   expanding a turn with many items doesn't eagerly materialize all rows.

Verified: make ios-sim-fast BUILD SUCCEEDED, 219 unit tests passed.
Added PerfTracker utility with os_signpost + LLog for timing critical
paths in DEBUG builds. Instrumented applySnapshot, flushStreamingDeltas,
handleStoreUpdate, startTurn, ConversationMessageList.body,
ConversationScreenModel.refreshState, and HomeDashboardModel.refreshState.

Added 14 XCTest measure{} performance tests across two suites:

PerformanceMeasurementTests (8 tests):
- TranscriptTurn.build: small (10 turns), large (200 turns), live stream
- Merge exploration turns (100 turns)
- StreamingAssistantRenderCache: 1000 tokens, stable-prefix reuse
- ConversationScreenModel projection (100 turns)
- relativeDate formatter (100 timestamps)

InteractionTimingTests (6 tests):
- Full render pipeline: 200 items, 1000 items
- Conversation projection: 800 hydrated items
- Streaming projection: 500 token increments
- TranscriptTurn.build: 2500 items (stress)
- relativeDate: 200 timestamps

All 233 tests pass (219 original + 14 new).

Live simulator perf logs show:
- applySnapshot: 1.47ms cold, 0.12ms avg steady-state
- flushStreamingDeltas: 0.00ms avg
- Streaming projection (500 tokens): 0.037ms per call avg
When sessions load on the home screen, each row's UIHostingController
height was measured synchronously via layoutIfNeeded() + sizeThatFits().
With 10+ sessions this caused 10+ synchronous SwiftUI layout passes
on the main thread, freezing keyboard input until all measurements
completed.

Now heightAnchor() returns cheap static fallback heights during the
initial layout pass and schedules the real measurement on the next
runloop via DispatchQueue.main.async. This lets the keyboard stay
responsive while session rows measure their natural heights in the
background. Pinch paths still measure synchronously for smooth tracking.
0xSero and others added 10 commits August 16, 2026 17:24
- Support up to 4 image attachments in the composer (PhotosPicker with
  maxSelectionCount, camera capture stacks instead of replacing)
- Lift home composer attachment state onto a stable @observable reference
  so the sheet/picker/importer/cover chain is not rebuilt per keystroke
- Add per-image thumbnail strip with remove buttons in the composer
- Enable text selection in assistant/system message bubbles
- Wire image rendering for http(s) sources referenced by the model
- Bump version to 2.1.0 (iOS + Android)
- Add built-in agent catalog seeding AgentMetadataStore
- Honor include_turns=false on metadata-only thread reads
- SSH bridge + reconnect hardening for mobile transports

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…mented bubbles

- UserBubble.imageRequest now accepts http(s):// sources, delegating
  the network fetch to Nuke's URLSession pipeline instead of silently
  dropping remote URLs
- Extract ExternalBrowserURLHandler so both LitterMarkdownView and
  AssistantBlocksBubble share the same open-in-Safari handler
- Add .environment(\.openURL) to AssistantBlocksBubble so links in
  segmented assistant messages are tappable

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The home composer only supported @plugin autocomplete; $skill mentions
were available in the conversation composer but not when starting a new
thread from home. This mirrors the conversation composer's pattern:

- Add skill state (skills, skillsLoading, activeDollarToken, showSkillPopup)
- Load skills via client.listSkills on first $ token activation
- Add $ token detection in refreshHomePopup alongside existing @ handling
- Add HomeSkillAutocompletePopup with fuzzy matching + loading state
- Collect SkillMentionSelections on send and pass as AppUserInput.skill
  additionalInputs alongside existing plugin mentions

Also promotes fuzzyScore, isMentionQueryValid, and extractMentionNames
from private to internal so the home composer can reuse them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…=false

Two fixes for Local Studio skill inheritance:

1. runtime_for_request: catalog RPCs (SkillsList, PluginList, etc.)
   fell through to a hard-coded "codex" default. On a server that only
   has the local-studio runtime, this sent the request to a non-existent
   "codex" channel, which silently fell back to the default transport
   that doesn't implement skills/list. Now mirrors the local-studio
   special case from runtime_for_thread_start.

2. apply_thread_read_response: now accepts include_turns from the caller.
   When include_turns=false, embedded turns in the response are treated
   as non-authoritative and the store's existing paged items + cursor
   are preserved, even if a bridge violates the contract by returning
   turns anyway. This prevents metadata-only thread reads from clobbering
   hydrated history.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
10 body-level appModel.snapshot reads across 4 views were creating
observation edges that triggered re-renders on every store update.
Each is now cached in @State and refreshed via .onChange(of:
appModel.snapshotRevision), matching the pattern already used in
ConversationInfoView and HomeDashboardModel.

- SettingsView: localServer + connectedServers (3 reads)
- PetSettingsView: connectedServers (1 read)
- RealtimeVoiceScreen: server used by shouldShowApiKeyPrompt (1 read)
- SubagentDetailSheet: threadSnapshot + title resolution (4 reads)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- MacCommands/SessionShortcutsMenu: cache sessionSummaries in @State
  refreshed via .onChange(of: appModel.snapshotRevision) — removes
  the last body-level appModel.snapshot read in the codebase
- RealtimeVoiceScreen: wrap transcript ForEach in LazyVStack so only
  visible transcript entries render during long voice sessions
- HeaderView: hoist modelProviderGroups(for:) into a local let in both
  InlineModelSelectorView and ModelSelectorSheet bodies so the
  Dictionary(grouping:) + sort runs once per body pass instead of
  re-evaluating inside the LazyVStack content builder on each keystroke
- AppsListView: move sortedApps from a body-evaluated computed
  property to @State refreshed on appear + store.apps change

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CI reads this file at release time; it still described the 2.0 Local
Studio identity work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…code

StreamingAssistantBubble.shouldUseSegmentedRenderer called
MessageContentBridge.containsMath(text) on every body evaluation, which
dispatches a full extractSegmentsTyped Rust FFI parse over the entire
streaming text. For long replies this is O(n) per token with FFI marshalling
overhead. Now routed through StreamingAssistantRenderCache.containsMath,
which memoizes the result keyed by itemId+text and shares the existing LRU
eviction — body re-evaluations that don't change the text (display-mode
toggles, layout passes) hit the cache instead of re-parsing.

UserBubble had no Equatable conformance, so SwiftUI couldn't skip its body
when the parent ConversationTimelineItemRow re-diffed with unchanged user
rows. Added == comparing text/images/compact/maxVisibleCharacters.

ResolvedChatImageView.svgAspectRatio compiled an NSRegularExpression from a
compile-time literal pattern on every invocation. Hoisted to a static let
so compilation happens once.

Removed dead code:
- ConversationTimelineItemRow.renderCache (stored but never read)
- ConversationItem.liveDetailStatus extension (defined, never called)
- InlineSelectableMarkdownMessage stored properties (markdown/style/bodySize/
  codeSize written at construction but never read in body)
- LitterMarkdownStyleVariant.cacheKey extension (defined, never called)

make ios-sim-fast: BUILD SUCCEEDED
xcodebuild test: 233 tests, 0 failures
…umbnails + memoize exploration entries + memoize tool-result decode

ConversationTimelineItemRow declared @Environment(ThemeManager.self) and read
themeManager.themeVersion inside assistantRow() (called from body) to pass
it as themeVersion: to StreamingAssistantBubble. But StreamingAssistantBubble
stores themeVersion and never reads it — the prop is dead. The @Environment
read created a ThemeManager observation edge on every visible assistant row,
so any themeVersion bump (theme switch, appearance change) re-evaluated all
assistant row bodies. Removed the @Environment declaration, the
themeManager.themeVersion read, and the dead themeVersion prop/init param
from StreamingAssistantBubble.

WallpaperManager.generateThumbnail had no cache (unlike generateWallpaper
which caches into imageCache). WallpaperSelectionView's ForEach over 85
themes called generateThumbnail per row on every body re-evaluation, so
each tap on a thumbnail triggered 85 synchronous Core Graphics renders.
Added an @ObservationIgnored thumbnailCache keyed by entry.slug.

ConversationExplorationGroupRow.body computed explorationEntries (a flatMap
over items) three times per body pass: once in body, once in
collapsedPreviewScrollSignature, and once in isActive. Hoisted all three
into local lets computed once in body, and removed the now-deed computed
properties (summaryText, collapsedPreviewScrollSignature, isActive).

CrossServerToolResultView.body called decode() on every evaluation, running
JSONSerialization + JSONDecoder on data.contentSummary each time. Moved
decoding into init so it runs once per view instance.

make ios-sim-fast: BUILD SUCCEEDED
xcodebuild test: 233 tests, 0 failures
LitterMarkdownView declared @State private var debugSettings =
DebugSettings.shared and read debugSettings.enabled &&
debugSettings.disableMarkdown in body. DebugSettings is @observable and
its computed properties read overrides (a non-@ObservationIgnored var), so
each read registered an observation edge from every visible message row to
the DebugSettings singleton — N edges scaling with visible message count.

Marked overrides as @ObservationIgnored (it's backed by UserDefaults and
never needs SwiftUI invalidation) and added a static isMarkdownDisabled
accessor that reads the ignored dict without creating an edge.
LitterMarkdownView.body now uses DebugSettings.isMarkdownDisabled instead
of the @State observable, removing the per-row edge entirely.

The debug settings UI still mutates enabled/disableMarkdown via the
observable computed setters; since overrides is now @ObservationIgnored,
those mutations won't trigger SwiftUI re-renders automatically. This is
acceptable — the debug-disable-markdown toggle is a developer-only feature
that requires restarting the view to take effect, and the existing
fontPreferenceObserver.revision invalidation already handles font/theme
changes that would re-render the markdown.

make ios-sim-fast: BUILD SUCCEEDED
xcodebuild test: 233 tests, 0 failures
@0xSero

0xSero commented Aug 18, 2026

Copy link
Copy Markdown
Owner Author

Landed in #319 (merged as 0824605d). Thanks @DatScreamer. Closing in favour of the convergence PR.

@0xSero 0xSero closed this Aug 18, 2026
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