Skip to content

fix(thread): keep metadata thread reads paginated - #307

Open
DatScreamer wants to merge 2 commits into
0xSero:mainfrom
DatScreamer:fix/thread-metadata-reads-paginated
Open

fix(thread): keep metadata thread reads paginated#307
DatScreamer wants to merge 2 commits into
0xSero:mainfrom
DatScreamer:fix/thread-metadata-reads-paginated

Conversation

@DatScreamer

@DatScreamer DatScreamer commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Closes part of #306

Problem

Metadata-only thread/read (include_turns=false) is issued in the shared multi-runtime resume/fallback path. Some bridges (incl. the opencode bridge) return the full turn archive regardless. The old reconcile handler (apply_thread_read_response) merged all embedded turns unconditionally, so a 5-turn page could be replaced by hundreds of UI items rendered at once — blocking the main thread until the watchdog killed the app (0x8BADF00D).

Changes

  • thread/read reconciliation now reads the request's include_turns flag and clears embedded turns for metadata-only reads, so apply_pagination_merge preserves the existing paged items + cursor.
  • Pin alleycat bridges to 55f78eca (aa391b5 + 6 commits: native-message cursor pagination, idle/active status, keep metadata reads paginated, project-aware session enumeration).
  • update-alleycat-main.sh: treat any rev-pinned git dep as pinned so the fork pin survives the CI shared-prep step (previously the script only recognized dnakov/alleycat, so CI's cargo update --precise overrode the fork pin with upstream main and broke the build).

Scope

Shared Rust, runtime-agnostic: benefits codex, claude, pi, local-studio, and opencode. The fork pin bumps all four bridge crates; only the opencode bridge's behavior differs.

Tests

  • New reconcile test: metadata_only_thread_read_ignores_bridge_embedded_history.

Verification

  • make rust-test
  • iOS emulator

@DatScreamer

Copy link
Copy Markdown
Contributor Author

Server-side dependency: 0xSero/alleycat#49 (issue 0xSero/alleycat#48). The Cargo pin in this PR points at fork rev 55f78eca, which is the head of that PR.

Honor include_turns=false on thread/read reconciliation instead of trusting
whatever the bridge returned. Some compatibility bridges (including the
opencode fork) return the full turn archive even for metadata-only reads,
which bypasses bounded pagination and can replace a five-turn page with
hundreds of UI items. Clear embedded turns when the request asked for
metadata only so apply_pagination_merge keeps the existing paged items and
cursor. Pin the alleycat fork to 55f78eca (aa391b5 + 6 pagination commits).
@DatScreamer
DatScreamer force-pushed the fix/thread-metadata-reads-paginated branch from 4a52201 to a26b3c5 Compare August 15, 2026 13:18
The opencode bridge emitted active_flags (snake_case) in thread/read and
thread/resume status, which the codex protocol declares as activeFlags
(camelCase). The mobile client cannot deserialize the snake_case field into
ThreadStatus::Active, so any thread with an in-progress turn failed to load.
Pin the fork to eead5e5a (55f78eca + the activeFlags emission fix).
0xSero added a commit that referenced this pull request Aug 18, 2026
…yping, pickers, multi-turn) (#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 #318, #306, #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 #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>

* 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 #306, #189

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

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

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>

* 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:
- #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>

* 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 #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>

* 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 (#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>

* 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>
@0xSero

0xSero commented Aug 18, 2026

Copy link
Copy Markdown
Owner

The reconcile.rs half of this landed in #319 (merged as 0824605d) as c153d1e5include_turns=false is now authoritative, so a metadata-only read cannot smuggle in a full turn archive. That change is runtime-agnostic and stands alone.

Holding the dependency half. It repoints all five alleycat-* crates from dnakov/alleycat@417f2a9f to DatScreamer/alleycat@eead5e5a. I checked whether the URL could just be rewritten to the canonical remote — it can't: dnakov/alleycat and 0xSero/alleycat are identical mirrors (same branches, same SHAs) and eead5e5a is the head of neither; it resolves only through fork-network object sharing.

It also relaxes the pin guard in tools/scripts/update-alleycat-main.sh from dnakov/alleycat\.git.*rev = to \.git.*rev = , which would let any future fork URL pass the "is pinned" check.

Keeping this open for the canonical revision to land upstream.

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