Skip to content

perf: fix mobile render stalls at the source (Local Studio connect, typing, pickers, multi-turn) - #319

Merged
0xSero merged 29 commits into
mainfrom
perf/mobile-render-convergence
Aug 18, 2026
Merged

perf: fix mobile render stalls at the source (Local Studio connect, typing, pickers, multi-turn)#319
0xSero merged 29 commits into
mainfrom
perf/mobile-render-convergence

Conversation

@0xSero

@0xSero 0xSero commented Aug 16, 2026

Copy link
Copy Markdown
Owner

Purpose

The app was unusable in normal use: connecting to Local Studio and letting sessions load stalled the whole UI, and from there typing, opening the project picker, opening the model picker, and sending messages across multiple turns were all unacceptably slow.

A previous pass (PR #281) correctly identified that views were re-rendering per streamed token and removed many observation edges. That work is included here, rebased onto current main. But it did not fix the reported problem, because the dominant costs were not in the fan-out — they were paid on each update itself, before any view was involved. This PR fixes those.

Root causes found

Each of these was verified by reading the code, not inferred.

The instrumentation was itself a top cost. ef6d0230 ("add performance instrumentation") added PerfTracker.event("storeUpdate", ["type": "\(update)"]) to AppModel.handleStoreUpdate. PerfTracker.event took fields as a plain parameter with the #if DEBUG guard inside the body, so the argument was evaluated in every configuration including Release. AppStoreUpdateRecord has no CustomStringConvertible, making "\(update)" a Mirror-based reflective walk of the entire payload — for .threadUpserted, every hydrated conversation item — on the MainActor, once per store update.

Change detection cost more than the change. AppModel.snapshot's didSet ran oldValue != snapshot. AppSnapshotRecord transitively contains every conversation item of every hydrated thread, so this was a full deep structural comparison at each of ~20 assignment sites.

The cheap Rust accessors weren't cheap. AppStore::thread_snapshot(key) — the per-thread accessor Swift uses in 6 places specifically to avoid the full snapshot — was implemented as project_thread_snapshot(&self.inner.app_snapshot(), &key), deep-cloning all servers, threads, items and 64 KiB terminal buffers before discarding all but one thread. AppStore::active_terminal_id() did the same to read one Option<String>, and it is a synchronous export called from a SwiftUI ViewModifier body, once per rendered code block.

Coalescing was inverted into an amplifier. Rust's coalesce_ready_updates promoted any two of ServerChanged / PendingApprovalsChanged / etc. into FullResync. Swift debounces .serverChanged but handles .fullResync immediately, so coalescing made a connection blip more expensive, not less. A connect emits many ServerChanged events.

Work that scaled with session length. TranscriptTurn.build recomputed collapsed-turn preview text for the entire conversation on every ~8 fps tick — and with collapseTurns off by default nothing is ever collapsed, so every preview was computed and discarded. MessageRenderCache's LRU did an O(n) array scan with string comparisons on every cache hit, with n growing all session.

Uncached FFI in a keystroke path. AgentRuntimeKind.metadata is a synchronous FFI call with no Swift-side memo, and the model-list filter that calls it per-model was recomputed ~7 times per body evaluation — on every keystroke in the picker's search field.

Key changes

Rust (shared/rust-bridge/codex-mobile-client)

  • thread_snapshot / active_terminal_id project under the read lock instead of cloning global state
  • New ThreadItems newtype owns items plus an id→index map; it deliberately does not implement DerefMut, so the compiler forces every mutation through index-maintaining methods. Turns O(N) per-token id scans into O(1).
  • current_agent_directory_version: sort+hash of all threads per emit → order-independent fold, memoized behind a single write chokepoint
  • extract_conversation_activity: allocation-free walk; formats only the ≤8 log entries it keeps rather than building and discarding the rest
  • item_fingerprint: was serde-serializing whole items (200 KB of command output per emit) → bounded head+tail+length digest
  • Removed the FullResync promotion; same-kind events merge, unrelated ones stay distinct

iOS

  • PerfTracker.event takes @autoclosure; the store-update call site emits a literal label
  • snapshot didSet deep-compare removed, with cheap targeted guards at the two sites that could assign an unchanged value
  • All nine undebounced refreshSnapshot() paths funnel through a max-wait debounce (50 ms for approvals/user input, 75 ms otherwise)
  • Transcript previews derived lazily; MessageRenderCache LRU made O(1); containsMath scans only appended bytes; distanceFromBottom deleted (written in 5 places, read in none, rebuilt the whole turn list per scroll frame)
  • Composer selection moved off @State into a reference box (3 body invalidations per keystroke → 1); Home's presentation stack hoisted behind an @Observable; Home gained the popup fast-path guard the conversation composer already had
  • Agent metadata memoized per run-loop turn — deliberately not keyed on agentDirectoryVersion, which hashes session summaries rather than agent metadata and would have served stale agent names indefinitely
  • Remaining body-level snapshot reads removed, including two reached via the AppModel.shared singleton that a grep appModel. audit misses, and one in ResolvedChatImageView that cost an observation edge per inline image

Latent bug fixed along the way

ConversationItem.renderDigest omitted six fields: diff additions/deletions, computerUse, namespace, tool-call display metadata, widget appId, and image bytes. That digest already backed MessageRenderCache and row equality on main, so a tool call whose display title changed would not repaint. The gaps were closed before making the digest the basis of ConversationItem.==. The digest's content switch has no default: clause, so a future content case breaks the build rather than silently producing a stale UI.

Converged PRs

PR Closes Note
#310 #309 Android session row click target
#315 #313 Conversation back button (dismiss() was a no-op in a path-driven stack)
#316 #314 Stale dashboard blur leaving gray bars
#317 #318 Theme switching 2+s → instant
#307 (partial) #306 reconcile.rs only — see below

PR #307 is included only in part. Its reconcile.rs fix (honor include_turns=false, so a compatibility bridge returning a full archive can't replace a bounded page with hundreds of items) is here. Its repoint of all five alleycat-* crates from dnakov/alleycat to a personal fork (DatScreamer/alleycat @ eead5e5a) is not, and the upstream pin is unchanged. The reconcile.rs change is runtime-agnostic and stands alone.

Merging #317 surfaced a real interaction: it removes ThemeManager.themeVersion, which this PR's new MarkdownThemeCache keyed on. Cached themes bake in resolved colors, so a broken key would leave markdown painted in the previous theme. Rekeyed on the active ResolvedTheme.slug.

Recommend closing as superseded

Verification

  • make ios-sim-fast: BUILD SUCCEEDED, 0 errors
  • make rust-check: pass
  • make rust-test: 760 passed, 0 failed (11 tests added covering index/memo/cache invalidation; one test rewritten because it asserted exactly the FullResync anti-optimization being removed — no test was deleted or weakened)
  • iOS unit tests: see CI
  • Device: installed and exercised on iPhone 15 Pro Max

Not included

0xSero and others added 28 commits August 12, 2026 19:17
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>
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.
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
…der-convergence

# Conflicts:
#	apps/ios/Sources/Litter/LitterApp.swift
#	apps/ios/Sources/Litter/Views/ConversationTimelineView.swift
#	apps/ios/Sources/Litter/Views/ToolCallModels.swift
#	apps/ios/Tests/LitterTests/ConversationDisplayPreferenceTests.swift
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Session titles are rendered through LinkifiedText, which uses ClickableText
to open web links. ClickableText consumes taps across the whole span to
detect link hits, so a session row's combinedClickable only fired on the
leftmost status-dot region and the rest of the row did nothing. Fall back
to a plain Text when the title has no URLs so taps pass through to the
row's click handler.
The previous perf pass attacked observation fan-out but left the
per-update work itself intact, so the app stayed unusable once a
Local Studio connect pulled everything in.

AppModel:
- PerfTracker.event took `fields` as a plain parameter with the
  #if DEBUG guard inside the body, so `["type": "\(update)"]` at the
  store-update call site was evaluated in *every* configuration.
  AppStoreUpdateRecord has no CustomStringConvertible, making that a
  Mirror walk of the whole payload (every hydrated item, for
  .threadUpserted) on the MainActor, once per update. `fields` is now
  @autoclosure and the call site emits a literal label.
- `snapshot`'s didSet deep-compared the entire AppSnapshotRecord --
  every conversation item of every hydrated thread -- at ~20 assignment
  sites. Removed; revision bumps unconditionally, with cheap targeted
  guards at the two sites that could assign an unchanged value.
- sessionSummaries.sort() per ThreadUpserted -> verified-sorted binary
  insert (falls back to append+sort when unsorted, which it can be).
- mergingCachedThreadSnapshots was O(cached x (threads + summaries)).
- All nine undebounced refreshSnapshot() paths now funnel through a
  max-wait debounce; 50ms for approvals/user-input, 75ms otherwise.

Transcript (quadratic over a session):
- TranscriptTurn previews were rebuilt for the whole conversation every
  8fps tick and, with collapseTurns off by default, always discarded.
  Now derived lazily per collapsed card actually rendered.
- MessageRenderCache's LRU did an O(n) string-comparing array scan on
  every cache *hit*, n growing to 1024.
- containsMath re-parsed the whole live message over FFI per body eval;
  now scans only appended bytes (every math delimiter starts with $ or \).
- distanceFromBottom was written in 5 places and read in none, yet
  rebuilt the entire turn ForEach on every scroll frame. Deleted.

Composer: selection range moved off @State into a reference box
(3 invalidations per keystroke -> 1), Home's presentation stack hoisted
behind an @observable so it stops rebuilding 5 modifiers per character,
and Home gained the popup fast-path guard ConversationView already had.

Model picker: AgentRuntimeKind.metadata was an uncached sync FFI call
invoked ~7N times per body eval, i.e. per keystroke. Memoized per
run-loop turn -- deliberately NOT keyed on agentDirectoryVersion, which
hashes session summaries rather than agent metadata and would have gone
stale. ~430 FFI crossings per keystroke -> <=5 per run-loop turn.

Observation edges: removed the remaining body-level reads of
appModel.snapshot/snapshotRevision, including two reached via the
AppModel.shared singleton that a grep for `appModel.` misses, and one
in ResolvedChatImageView that cost an edge *per inline image*.
Adds AppSnapshotObserver, a body-free coalesced projection helper.

Also fixes a latent rendering bug: renderDigest omitted six fields
(diff counts, computerUse, namespace, tool-call display metadata,
widget appId, image bytes). It already backed MessageRenderCache and
row equality, so a tool call whose display title changed would not
repaint. Completed before making it the basis of ConversationItem.==.

Refs #318, #306, #189

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Memoize Highlightr's JS-based code tokenization (keyed by code+language+
themeName) so code blocks are no longer re-tokenized on every SwiftUI
re-evaluation; switching between same-family themes now hits the cache
and simply recolors through the @observable ThemeStore. Key the diff
renderer's task inputs on the resolved theme so diffs re-render on theme
change, and remove the themeVersion identity churn (.id() teardowns and
dead Equatable props). Theme switching drops from 2+ seconds to
perceptually instant, matching Android.
PR #317 removed ThemeManager.themeVersion in favour of an @observable
ThemeStore, but MarkdownThemeCache (added in c331fe1) keyed its
invalidation on it. Cached MarkdownThemes bake in resolved colors, so
without a working key a theme switch would leave markdown text painted
in the previous theme's colors.

Key on the active ResolvedTheme's slug instead, exposed as
LitterTheme.activeThemeSlug. It reads through ThemeStore, so a read from
a view body also registers the observation dependency #317 relies on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The mobile store paid O(entire app state) on paths that look cheap from
Swift, which is what made a Local Studio connect degrade the whole app.

Accessors that cloned the world:
- thread_snapshot(key) deep-cloned every server, thread, item and 64KiB
  terminal tail, then discarded all but one thread. Swift calls it from
  6 places. Now projects under the read lock.
- active_terminal_id() did the same to read one Option<String> -- and it
  is a *synchronous* export called from a SwiftUI ViewModifier body,
  once per rendered code block. Same fix; also write_to_active_terminal.

Per-event algorithmics:
- Item lookups were linear id-comparison scans over thread.items on
  every token. New ThreadItems newtype owns the Vec plus an id->index
  map and a revision. It deliberately does not implement DerefMut, so
  the compiler forces every mutation through index-maintaining methods
  rather than relying on discipline.
- current_agent_directory_version collected, sorted and hashed all
  threads per emit; now an order-independent fold, memoized behind a
  single write chokepoint.
- extract_conversation_activity rebuilt per emit with ~2 allocations per
  item, and formatted the full tool log before discarding all but 8.
  Now allocation-free and formats only the surviving entries.
- item_fingerprint serde-serialized the whole item -- 200KB of captured
  command output hashed per emit -- now a bounded head+tail+length
  digest.
- last_thread_item_upserts was keyed globally, so clearing one thread's
  entries was O(all items ever emitted). Now nested per thread.
- upsert_thread_snapshot cloned the existing thread to read a few
  preserved fields.
- project_hydrated_item cloned unconditionally; now returns Cow.

Coalescing was inverted:
- Rust promoted any two of ServerChanged/PendingApprovals/etc into
  FullResync. Swift debounces .serverChanged but not .fullResync, so
  coalescing made things *worse*. Removed the promotion; unrelated
  events stay distinct and same-kind events merge.

760 tests pass. 11 added covering index/memo/cache invalidation. One
test was rewritten rather than deleted: it asserted exactly the
FullResync promotion being removed.

The UniFFI surface is unchanged, so no binding regeneration is needed.

Refs #306, #189

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
From PR #307 (issue #306). Some compatibility bridges return a full
thread archive even for a metadata-only read; accepting it bypasses
bounded hydration and can replace a five-turn page with hundreds of UI
items, which is what makes large conversations render unbounded.

Takes the request contract as authoritative and drops turns the caller
did not ask for.

Deliberately excludes the rest of PR #307: that PR also repoints all
five alleycat-* crates from dnakov/alleycat to a personal fork
(DatScreamer/alleycat @ eead5e5a), which is a dependency-source change
to the agent bridge and unrelated to this fix. The reconcile change is
runtime-agnostic and stands alone.

Refs #306

Co-Authored-By: DatScreamer <micahbfriesen.mf@gmail.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Requested directly by the user for 2.1.0.

Composer:
- attachedImage: UIImage? -> attachedImages: [UIImage], capped at 4, with
  per-image removal and multi-select photo picking. Verified end to end
  that the send path transmits every image: AppRpcParams.swift:77 copies
  additionalInputs verbatim into AppStartTurnRequest.input, which is a
  Vec<AppUserInput> where each image is its own AppUserInput::Image
  variant (types/server_requests.rs:804, types/models.rs:969). No Rust or
  UniFFI change was needed.
- Selectable/copyable message text. The gap was not a missing modifier --
  .textSelection(.enabled) was already applied -- it was that a row-level
  .contextMenu on the user bubble swallowed the long press, so selection
  could never activate. Adds an explicit Copy / Select Text menu that
  joins segments on tap rather than per body eval.

Cleanup (each removal verified zero-reference before deleting):
- InlineVoiceButton, InlineVoiceStatusStrip, SessionReplySwipe (iOS-only
  rot; the Android twins of the first two are live), LaunchView,
  SupporterBadge, SessionPulsingDots, resetPinchBlurPeak, activeLevel.
- Corrected AGENTS.md/ARCHITECTURE.md/DEVELOPMENT.md/qa-matrix.md claims
  that no longer matched the code: SidebarOverlay, LitterAppShell and
  DefaultLitterAppState do not exist; the InjectionIII hot-reload section
  described wiring that is entirely absent; nearby-Mac pairing is
  DEBUG-only, not a shipping first-launch flow.
- Package.swift was flagged as unreferenced and deleted; restored. It is
  unreferenced by design -- its own header documents it as an alternate
  SPM entry point.

Issues folded in:
- #306: turn page size 5 -> 20, safe now that c153d1e makes
  include_turns=false authoritative.
- #305: themed back chevron on the new-thread hero, which had only a
  trailing Cancel and an invisible system chevron.
- #162: Android session timestamp no longer wraps one character per line.

Version 2.1.0 / build 210000001 on both platforms.

Refs #305, #306, #162

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The built-in agent catalog now seeds AgentMetadataStore, so a runtime it
knows about takes its label from the catalog's display_name rather than
the titlecase fallback. opencode brands itself lowercase, so the seeded
label legitimately differs from what the fallback produced.

Updates the assertion and adds an unknown-runtime case so the titlecase
fallback is still covered -- that path is what keeps a brand-new alleycat
agent renderable without a litter release.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every removal below was verified unreferenced before deleting; the two
that turned out NOT to be dead are called out at the end.

iOS proximity-pairing cluster (2,628 lines, 10 files): NearbyMacPairing,
PairBLE, PairBLEScanner, PairBLEAdvertiser, UltrasonicReader,
UltrasonicEmitter, ProximityHaptics, MacPairingHost, ProximityPairView,
UWBDebugView. BLE beacons, ultrasonic ranging and UWB direction-finding.
The cluster only referenced itself; its sole external entry points were
two NavigationLinks in ExperimentalFeaturesView, both inside #if DEBUG.
It compiled into every Release build and no user could reach it.
AGENTS.md described nearby-Mac pairing as a shipping architecture
feature -- it never was.

Android unreachable Sessions route (1,028 lines): SessionsScreen,
SessionsDerivation, SessionsUiState and their test. Route.Sessions had
exactly one reference -- the `when` handler that rendered it -- and was
never constructed, so nothing could navigate there. Also drops the route
case itself. DirectoryPickerSheet and SessionLaunchSupport live in the
same package and ARE live; they stay.

Android dead config loop: three buildConfigFields with zero BuildConfig
reads, feeding two manifestPlaceholders, feeding two <meta-data> tags
that nothing queries (no GET_META_DATA anywhere). RuntimeFlavorConfigTest
only asserted those constants agreed with each other.

Android dead work per recomposition: savedAppsByThread grouped and sorted
every saved app on each snapshot change to populate `sessionApps`, which
was assigned per session row and never read. HomeAppTakeoverRow was
declaration-only.

Two corrections to earlier claims:
- The pairing cluster is 2,628 lines, not the 1,886 first counted -- it
  pulls in four more files.
- The Android sessions package is not 1,720 dead lines. Only 1,028 were
  dead; DirectoryPickerSheet (664) is live from LitterApp.kt.

Also fixes #162 properly. The earlier fix went into SessionsScreen.kt,
which is on the unreachable route -- it would have shipped and changed
nothing. The live render sites are SessionCanvasRow and both
ThreadSearchResults rows, where the relative-time Text was the only
child in its row without maxLines, so a narrow row left it as the sole
element free to wrap -- one character per line. Fixed at all three.

make ios-sim-fast: BUILD SUCCEEDED, 0 errors.
Android cannot be built on this machine; CI is the proof.

Refs #162

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1,237 lines of Rust plus a 211-line golden fixture, with zero consumers
and zero uniffi derives -- so none of it could cross into Swift or
Kotlin even in principle. It is 25 pub types and 5 functions describing
a realtime-voice-over-Local-Studio schema for unbuilt work (#207).

Worth being precise about what this is NOT: the Local Studio connection
people actually use is local_studio.rs (445 lines, 9 consumers) and is
untouched. Realtime voice today goes to OpenAI, hardcoded, and is also
untouched. This file was only ever the contract for making Local Studio
a voice provider instead.

Note for #207's ledger: merging this schema was recorded as progress on
the issue, but it landed with nothing consuming it. The behavioral work
-- provider selection, broker client, device auth, phase/snapshot
extensions, reducer arms -- was never started. Removing it makes the
remaining scope honest rather than hiding it behind a merged file.

Recoverable from history if #207 resumes.

cargo test: 792 passed, 0 failed.

Refs #207

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>
shared-prep timed out at its 60-minute limit on three consecutive runs.
The job log ends with "Terminate orphan process: (codex_mobile_cl)" --
the test binary was still running when the runner killed it.

Cause: replace_pending_approvals is change-gated (reducer.rs:1211-1225).
On a fresh reducer the snapshot is already empty, so passing Vec::new()
leaves `changed` false and emits nothing. Both new subscription tests
then awaited an update that could never arrive, via a bare
block_on(next_update()) with no timeout -- so a missing emit became an
infinite hang rather than a failure.

Two fixes, because either alone is insufficient:
- Seed a real non-empty approval so the state actually changes and the
  emit happens. This is what makes the tests correct.
- Bound every next_update() await at 5s via a shared helper. This is
  what makes a future missing emit fail in seconds instead of burning
  an hour of CI and reporting as "cancelled" with no useful signal.

make rust-test: 783 passed, 0 failed.

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

DatScreamer commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Dependency on the unmerged alleycat fix is not covered by the reconcile.rs change alone.

#307 carries two coupled pieces: the reconcile.rs include_turns=false fix and the DatScreamer/alleycat fork fix that is not merged upstream in dnakov/alleycat.

The fork side is load-bearing, not cosmetic: upstream opencode-bridge ignores includeTurns:false/excludeTurns:true on thread/read/thread/resume (it embeds the whole archive) and returns nextCursor: null from thread/turns/list. The fork makes the bridge actually honor includeTurns:false and return bounded, cursor-driven pages via opencode's native message pagination. #307's client-side reconcile fix and #308's infinite scroll both depend on that server-side behavior — #308 has no cursor to advance without it.

Since this branch keeps the upstream dnakov/alleycat pin and drops the fork, the pagination it claims to bring in is not actually functional until that alleycat PR merges upstream. #308 (which explicitly depends on #307) therefore cannot land in a working state either. Requesting we either keep the fork pin here, or track the alleycat PR as an explicit blocker before treating #307's pagination as done.

@0xSero
0xSero merged commit 0824605 into main Aug 18, 2026
10 checks passed
DatScreamer added a commit to DatScreamer/litter that referenced this pull request Aug 24, 2026
…yping, pickers, multi-turn) (0xSero#319)

* ios: keep running tool calls expanded so results stream

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>

* bridge: normalize legacy item lifecycle notifications at json-line wire

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.

* perf: coalesce streaming deltas + remove per-token snapshot observation 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.

* perf: eliminate remaining per-token snapshot observation edges

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).

* perf: remove InlineHandoffView body-read of appModel.snapshot

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.

* perf: pass server snapshot to HeaderView/toolbar as param

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.

* perf: pass server snapshot to HomeModelChip via param

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.

* fix: address claude code review — coalescer edge cases + overlay

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.

* perf: eliminate SubagentCardView per-row appModel.snapshot reads

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.

* chore: remove 12 dead code items identified by systematic audit

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.

* perf: fix scroll-path re-renders + cache formatter allocations

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.

* perf: add performance instrumentation + measurement test suites

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

* fix: close GlassMorphContainer brace lost in merge resolution

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

* android: only attach link handling to titles that contain links

Session titles are rendered through LinkifiedText, which uses ClickableText
to open web links. ClickableText consumes taps across the whole span to
detect link hits, so a session row's combinedClickable only fired on the
leftmost status-dot region and the rest of the row did nothing. Fall back
to a plain Text when the title has no URLs so taps pass through to the
row's click handler.

* ui: fix stale dashboard blur after navigation

* ui: fix conversation back button popping path-driven stack

* ui: fix conversation back button hit target

* perf(ios): remove per-update main-thread costs across the app

The previous perf pass attacked observation fan-out but left the
per-update work itself intact, so the app stayed unusable once a
Local Studio connect pulled everything in.

AppModel:
- PerfTracker.event took `fields` as a plain parameter with the
  #if DEBUG guard inside the body, so `["type": "\(update)"]` at the
  store-update call site was evaluated in *every* configuration.
  AppStoreUpdateRecord has no CustomStringConvertible, making that a
  Mirror walk of the whole payload (every hydrated item, for
  .threadUpserted) on the MainActor, once per update. `fields` is now
  @autoclosure and the call site emits a literal label.
- `snapshot`'s didSet deep-compared the entire AppSnapshotRecord --
  every conversation item of every hydrated thread -- at ~20 assignment
  sites. Removed; revision bumps unconditionally, with cheap targeted
  guards at the two sites that could assign an unchanged value.
- sessionSummaries.sort() per ThreadUpserted -> verified-sorted binary
  insert (falls back to append+sort when unsorted, which it can be).
- mergingCachedThreadSnapshots was O(cached x (threads + summaries)).
- All nine undebounced refreshSnapshot() paths now funnel through a
  max-wait debounce; 50ms for approvals/user-input, 75ms otherwise.

Transcript (quadratic over a session):
- TranscriptTurn previews were rebuilt for the whole conversation every
  8fps tick and, with collapseTurns off by default, always discarded.
  Now derived lazily per collapsed card actually rendered.
- MessageRenderCache's LRU did an O(n) string-comparing array scan on
  every cache *hit*, n growing to 1024.
- containsMath re-parsed the whole live message over FFI per body eval;
  now scans only appended bytes (every math delimiter starts with $ or \).
- distanceFromBottom was written in 5 places and read in none, yet
  rebuilt the entire turn ForEach on every scroll frame. Deleted.

Composer: selection range moved off @State into a reference box
(3 invalidations per keystroke -> 1), Home's presentation stack hoisted
behind an @observable so it stops rebuilding 5 modifiers per character,
and Home gained the popup fast-path guard ConversationView already had.

Model picker: AgentRuntimeKind.metadata was an uncached sync FFI call
invoked ~7N times per body eval, i.e. per keystroke. Memoized per
run-loop turn -- deliberately NOT keyed on agentDirectoryVersion, which
hashes session summaries rather than agent metadata and would have gone
stale. ~430 FFI crossings per keystroke -> <=5 per run-loop turn.

Observation edges: removed the remaining body-level reads of
appModel.snapshot/snapshotRevision, including two reached via the
AppModel.shared singleton that a grep for `appModel.` misses, and one
in ResolvedChatImageView that cost an edge *per inline image*.
Adds AppSnapshotObserver, a body-free coalesced projection helper.

Also fixes a latent rendering bug: renderDigest omitted six fields
(diff counts, computerUse, namespace, tool-call display metadata,
widget appId, image bytes). It already backed MessageRenderCache and
row equality, so a tool call whose display title changed would not
repaint. Completed before making it the basis of ConversationItem.==.

Refs 0xSero#318, 0xSero#306, 0xSero#189

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

* perf(ios): make theme switching perceptually instant

Memoize Highlightr's JS-based code tokenization (keyed by code+language+
themeName) so code blocks are no longer re-tokenized on every SwiftUI
re-evaluation; switching between same-family themes now hits the cache
and simply recolors through the @observable ThemeStore. Key the diff
renderer's task inputs on the resolved theme so diffs re-render on theme
change, and remove the themeVersion identity churn (.id() teardowns and
dead Equatable props). Theme switching drops from 2+ seconds to
perceptually instant, matching Android.

* perf(ios): rekey markdown theme cache after themeVersion removal

PR 0xSero#317 removed ThemeManager.themeVersion in favour of an @observable
ThemeStore, but MarkdownThemeCache (added in c331fe1) keyed its
invalidation on it. Cached MarkdownThemes bake in resolved colors, so
without a working key a theme switch would leave markdown text painted
in the previous theme's colors.

Key on the active ResolvedTheme's slug instead, exposed as
LitterTheme.activeThemeSlug. It reads through ThemeStore, so a read from
a view body also registers the observation dependency 0xSero#317 relies on.

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

* perf(rust): make the store boundary cheap per event

The mobile store paid O(entire app state) on paths that look cheap from
Swift, which is what made a Local Studio connect degrade the whole app.

Accessors that cloned the world:
- thread_snapshot(key) deep-cloned every server, thread, item and 64KiB
  terminal tail, then discarded all but one thread. Swift calls it from
  6 places. Now projects under the read lock.
- active_terminal_id() did the same to read one Option<String> -- and it
  is a *synchronous* export called from a SwiftUI ViewModifier body,
  once per rendered code block. Same fix; also write_to_active_terminal.

Per-event algorithmics:
- Item lookups were linear id-comparison scans over thread.items on
  every token. New ThreadItems newtype owns the Vec plus an id->index
  map and a revision. It deliberately does not implement DerefMut, so
  the compiler forces every mutation through index-maintaining methods
  rather than relying on discipline.
- current_agent_directory_version collected, sorted and hashed all
  threads per emit; now an order-independent fold, memoized behind a
  single write chokepoint.
- extract_conversation_activity rebuilt per emit with ~2 allocations per
  item, and formatted the full tool log before discarding all but 8.
  Now allocation-free and formats only the surviving entries.
- item_fingerprint serde-serialized the whole item -- 200KB of captured
  command output hashed per emit -- now a bounded head+tail+length
  digest.
- last_thread_item_upserts was keyed globally, so clearing one thread's
  entries was O(all items ever emitted). Now nested per thread.
- upsert_thread_snapshot cloned the existing thread to read a few
  preserved fields.
- project_hydrated_item cloned unconditionally; now returns Cow.

Coalescing was inverted:
- Rust promoted any two of ServerChanged/PendingApprovals/etc into
  FullResync. Swift debounces .serverChanged but not .fullResync, so
  coalescing made things *worse*. Removed the promotion; unrelated
  events stay distinct and same-kind events merge.

760 tests pass. 11 added covering index/memo/cache invalidation. One
test was rewritten rather than deleted: it asserted exactly the
FullResync promotion being removed.

The UniFFI surface is unchanged, so no binding regeneration is needed.

Refs 0xSero#306, 0xSero#189

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

* fix(thread): honor include_turns=false on metadata-only reads

From PR 0xSero#307 (issue 0xSero#306). Some compatibility bridges return a full
thread archive even for a metadata-only read; accepting it bypasses
bounded hydration and can replace a five-turn page with hundreds of UI
items, which is what makes large conversations render unbounded.

Takes the request contract as authoritative and drops turns the caller
did not ask for.

Deliberately excludes the rest of PR 0xSero#307: that PR also repoints all
five alleycat-* crates from dnakov/alleycat to a personal fork
(DatScreamer/alleycat @ eead5e5a), which is a dependency-source change
to the agent bridge and unrelated to this fix. The reconcile change is
runtime-agnostic and stands alone.

Refs 0xSero#306

Co-Authored-By: DatScreamer <micahbfriesen.mf@gmail.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat: multi-image attachments, selectable text, all providers, 2.1.0

Requested directly by the user for 2.1.0.

Composer:
- attachedImage: UIImage? -> attachedImages: [UIImage], capped at 4, with
  per-image removal and multi-select photo picking. Verified end to end
  that the send path transmits every image: AppRpcParams.swift:77 copies
  additionalInputs verbatim into AppStartTurnRequest.input, which is a
  Vec<AppUserInput> where each image is its own AppUserInput::Image
  variant (types/server_requests.rs:804, types/models.rs:969). No Rust or
  UniFFI change was needed.
- Selectable/copyable message text. The gap was not a missing modifier --
  .textSelection(.enabled) was already applied -- it was that a row-level
  .contextMenu on the user bubble swallowed the long press, so selection
  could never activate. Adds an explicit Copy / Select Text menu that
  joins segments on tap rather than per body eval.

Cleanup (each removal verified zero-reference before deleting):
- InlineVoiceButton, InlineVoiceStatusStrip, SessionReplySwipe (iOS-only
  rot; the Android twins of the first two are live), LaunchView,
  SupporterBadge, SessionPulsingDots, resetPinchBlurPeak, activeLevel.
- Corrected AGENTS.md/ARCHITECTURE.md/DEVELOPMENT.md/qa-matrix.md claims
  that no longer matched the code: SidebarOverlay, LitterAppShell and
  DefaultLitterAppState do not exist; the InjectionIII hot-reload section
  described wiring that is entirely absent; nearby-Mac pairing is
  DEBUG-only, not a shipping first-launch flow.
- Package.swift was flagged as unreferenced and deleted; restored. It is
  unreferenced by design -- its own header documents it as an alternate
  SPM entry point.

Issues folded in:
- 0xSero#306: turn page size 5 -> 20, safe now that c153d1e makes
  include_turns=false authoritative.
- 0xSero#305: themed back chevron on the new-thread hero, which had only a
  trailing Cancel and an invisible system chevron.
- 0xSero#162: Android session timestamp no longer wraps one character per line.

Version 2.1.0 / build 210000001 on both platforms.

Refs 0xSero#305, 0xSero#306, 0xSero#162

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

* test: assert catalog-supplied agent label, keep fallback covered

The built-in agent catalog now seeds AgentMetadataStore, so a runtime it
knows about takes its label from the catalog's display_name rather than
the titlecase fallback. opencode brands itself lowercase, so the seeded
label legitimately differs from what the fallback produced.

Updates the assertion and adds an unknown-runtime case so the titlecase
fallback is still covered -- that path is what keeps a brand-new alleycat
agent renderable without a litter release.

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

* chore: delete ~4k lines of unreachable code

Every removal below was verified unreferenced before deleting; the two
that turned out NOT to be dead are called out at the end.

iOS proximity-pairing cluster (2,628 lines, 10 files): NearbyMacPairing,
PairBLE, PairBLEScanner, PairBLEAdvertiser, UltrasonicReader,
UltrasonicEmitter, ProximityHaptics, MacPairingHost, ProximityPairView,
UWBDebugView. BLE beacons, ultrasonic ranging and UWB direction-finding.
The cluster only referenced itself; its sole external entry points were
two NavigationLinks in ExperimentalFeaturesView, both inside #if DEBUG.
It compiled into every Release build and no user could reach it.
AGENTS.md described nearby-Mac pairing as a shipping architecture
feature -- it never was.

Android unreachable Sessions route (1,028 lines): SessionsScreen,
SessionsDerivation, SessionsUiState and their test. Route.Sessions had
exactly one reference -- the `when` handler that rendered it -- and was
never constructed, so nothing could navigate there. Also drops the route
case itself. DirectoryPickerSheet and SessionLaunchSupport live in the
same package and ARE live; they stay.

Android dead config loop: three buildConfigFields with zero BuildConfig
reads, feeding two manifestPlaceholders, feeding two <meta-data> tags
that nothing queries (no GET_META_DATA anywhere). RuntimeFlavorConfigTest
only asserted those constants agreed with each other.

Android dead work per recomposition: savedAppsByThread grouped and sorted
every saved app on each snapshot change to populate `sessionApps`, which
was assigned per session row and never read. HomeAppTakeoverRow was
declaration-only.

Two corrections to earlier claims:
- The pairing cluster is 2,628 lines, not the 1,886 first counted -- it
  pulls in four more files.
- The Android sessions package is not 1,720 dead lines. Only 1,028 were
  dead; DirectoryPickerSheet (664) is live from LitterApp.kt.

Also fixes 0xSero#162 properly. The earlier fix went into SessionsScreen.kt,
which is on the unreachable route -- it would have shipped and changed
nothing. The live render sites are SessionCanvasRow and both
ThreadSearchResults rows, where the relative-time Text was the only
child in its row without maxLines, so a narrow row left it as the sole
element free to wrap -- one character per line. Fixed at all three.

make ios-sim-fast: BUILD SUCCEEDED, 0 errors.
Android cannot be built on this machine; CI is the proof.

Refs 0xSero#162

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

* chore: remove inert local_studio_realtime contract

1,237 lines of Rust plus a 211-line golden fixture, with zero consumers
and zero uniffi derives -- so none of it could cross into Swift or
Kotlin even in principle. It is 25 pub types and 5 functions describing
a realtime-voice-over-Local-Studio schema for unbuilt work (0xSero#207).

Worth being precise about what this is NOT: the Local Studio connection
people actually use is local_studio.rs (445 lines, 9 consumers) and is
untouched. Realtime voice today goes to OpenAI, hardcoded, and is also
untouched. This file was only ever the contract for making Local Studio
a voice provider instead.

Note for 0xSero#207's ledger: merging this schema was recorded as progress on
the issue, but it landed with nothing consuming it. The behavioral work
-- provider selection, broker client, device auth, phase/snapshot
extensions, reducer arms -- was never started. Removing it makes the
remaining scope honest rather than hiding it behind a merged file.

Recoverable from history if 0xSero#207 resumes.

cargo test: 792 passed, 0 failed.

Refs 0xSero#207

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

* release: 2.1.0 TestFlight notes

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>

* fix(test): stop subscription tests hanging CI for an hour

shared-prep timed out at its 60-minute limit on three consecutive runs.
The job log ends with "Terminate orphan process: (codex_mobile_cl)" --
the test binary was still running when the runner killed it.

Cause: replace_pending_approvals is change-gated (reducer.rs:1211-1225).
On a fresh reducer the snapshot is already empty, so passing Vec::new()
leaves `changed` false and emits nothing. Both new subscription tests
then awaited an update that could never arrive, via a bare
block_on(next_update()) with no timeout -- so a missing emit became an
infinite hang rather than a failure.

Two fixes, because either alone is insufficient:
- Seed a real non-empty approval so the state actually changes and the
  emit happens. This is what makes the tests correct.
- Bound every next_update() await at 5s via a shared helper. This is
  what makes a future missing emit fail in seconds instead of burning
  an hour of CI and reporting as "cancelled" with no useful signal.

make rust-test: 783 passed, 0 failed.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: DatScreamer <micahbfriesen.mf@gmail.com>
Co-authored-by: DatScreamer <17242089+DatScreamer@users.noreply.github.com>
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.

2 participants