fix(thread): keep metadata thread reads paginated - #307
Conversation
|
Server-side dependency: 0xSero/alleycat#49 (issue 0xSero/alleycat#48). The Cargo pin in this PR points at fork rev |
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).
4a52201 to
a26b3c5
Compare
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).
…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>
|
The Holding the dependency half. It repoints all five It also relaxes the pin guard in Keeping this open for the canonical revision to land upstream. |
…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>
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/readreconciliation now reads the request'sinclude_turnsflag and clears embedded turns for metadata-only reads, soapply_pagination_mergepreserves the existing paged items + cursor.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 recognizeddnakov/alleycat, so CI'scargo update --preciseoverrode 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
metadata_only_thread_read_ignores_bridge_embedded_history.Verification
make rust-test