fix(chat): don't strip and refetch inactive conversation history on thread switch (LUM-1107)#27493
Conversation
|
Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits. |
10204b9 to
e54632b
Compare
There was a problem hiding this comment.
✦ APPROVE
Value: Directly fixes the thread-switch regression from standup — removes the trimForBackground() path that was (1) stripping cached tool-call/media content in-place and (2) resetting isHistoryLoaded, which forced a blocking /history round-trip on every re-activation.
Verified the safety argument:
The four remaining memory safeguards are untouched and sufficient:
- Daemon
mode: "light"— truncates payloads at source ✅ - VM LRU cache cap 10 (
scheduleEvictionIfNeeded) — evicts oldest VMs entirely ✅ trimOldMessagesIfNeededat 150 messages per VM — hard-deletes oldest, keeps 75 ✅MemoryPressureMonitor— OS-level signal → prunes active VMs ✅
The removed trim was the most aggressive AND most user-impactful of the five defenses — it ran on every switch and defeated the cache that the other four were designed to protect.
Code review:
- Removal is clean — both
activateConversationandselectConversationcall sites, the helper, and the method itself. No orphaned references. stripHeavyContent()correctly stays (still called bytrimOldMessagesIfNeededon messages immediately before removal).ChatMessage.isContentStrippedcorrectly stays (defensive guard in history dedup).- Updated comment in
populateFromHistoryaccurately reflects the remaining use case. - AGENTS.md rule is durable and well-worded.
Tests:
The three regression tests (preservesIsHistoryLoaded, doesNotStripMessageContent, rapidRoundTrip) directly encode the invariant. Good use of makeLoadedConversation helper to skip network bootstrap.
Re: remaining perceived slowness — this PR eliminates the data-layer regression (refetch + empty bubbles). Any remaining sluggishness on switch is likely the SwiftUI view-diff cost when displayedMessageCount has escalated to Int.max on conversations that were scrolled to the top (the LUM-1022 issue). That's a separate fix.
No concerns. Ship it.
…teFromHistory Addresses Devin review on #27493. With trim-on-switch removed, the only path that writes isContentStripped is trimOldMessagesIfNeeded, which strips and removes the same messages inside one batchUpdateMessages — stripped state never leaks to readers. The branch condition and refresh loop in populateFromHistory were defensive dead code; the equivalent logic with the always-false term elided is "any user message exists, keep current messages as-is," which is what this commit makes explicit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…hread switch (LUM-1107) Removes `trimPreviousConversationIfNeeded` from `ConversationManager`'s selection path. That helper ran `trimForBackground()` on the outgoing VM, which stripped heavy content from retained messages and cleared `isHistoryLoaded`, forcing a blocking `/history` refetch on re-activation. Client memory is already bounded by the daemon's truncated history mode, the 10-entry VM LRU cache, per-VM `trimOldMessagesIfNeeded` at >150 messages, and the shared `MemoryPressureMonitor` that prunes under real OS pressure. The eager trim-on-switch was redundant with those safeguards and defeated the on-demand `rehydrateMessage(id:)` rehydration path for truncated content. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…aths Encode the lesson from LUM-1107 as a durable rule in the Memory Management section: prefer on-demand rehydration over a blanket `isLoaded = false` reload when existing safeguards already bound memory. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…teFromHistory Addresses Devin review on #27493. With trim-on-switch removed, the only path that writes isContentStripped is trimOldMessagesIfNeeded, which strips and removes the same messages inside one batchUpdateMessages — stripped state never leaks to readers. The branch condition and refresh loop in populateFromHistory were defensive dead code; the equivalent logic with the always-false term elided is "any user message exists, keep current messages as-is," which is what this commit makes explicit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…(LUM-1107)
The sidebar conversation click handler called
`ConversationManager.selectConversation(id:)` synchronously after setting
`windowState.selection`. On a cache-miss the synchronous path builds a new
`ChatViewModel`, wires three Combine observers, and runs `performActivation` —
enough work that the main thread stayed busy for the entire window and the
sidebar highlight didn't paint until the conversation finished loading.
Defer the heavy call via `Task { @mainactor in ... }` so SwiftUI commits the
selection change on the current frame. The chat content swap lands one frame
later, giving the user instant click feedback.
Also adds a complementary AGENTS.md rule in Performance > Concurrency to keep
future event handlers from regressing this pattern.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
333288e to
531a3c2
Compare
…oser during load (LUM-1107) Addresses Devin review on #27493 and the follow-up UX concern about the composer disappearing during the loading skeleton. ## Selection yield — moved from sidebar to onChange handler The previous Task wrapper at the sidebar call-site was bypassed by the synchronous `.onChange(of: windowState.selection)` handler at `MainWindowView.body`, which ran `conversationManager.selectConversation(id:)` during the same SwiftUI view-update pass — before my Task had a chance to fire. The Task then no-op'd on `performActivation`'s guard (`conversationId != activeConversationId`). Fix: the onChange handler is the convergence point for all selection sources (sidebar, nav history, overlay dismissal, deep links), so the yield belongs there. Validation + `applySelectionCorrection` stay synchronous so an invalid selection never commits to a frame; the heavy `selectConversation` call and the dependent surface/dock sync move into a `Task { @mainactor in ... }`. The sidebar wrapper drops its now- redundant direct call to `conversationManager.selectConversation`. ## Composer stays put during skeleton `mainContentStack` previously branched between mutually-exclusive states (skeleton / empty / active) where only the non-skeleton branches rendered a composer. The input appeared to jump in from nowhere when the skeleton dismissed. Extract `composerSection(width:isInteractionEnabled:)` as a single helper and render it (disabled) below the skeleton. The composer now keeps a stable screen position across the load→active transition. The empty-state views still own their own inline composers; unifying those into the extracted helper is LUM-XXXX (follow-up). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
| // state so the layout stays put across the load → active | ||
| // transition — swapping between skeleton (no composer) and | ||
| // active (with composer) caused the input to appear/move. | ||
| composerSection(width: layoutMetrics.chatColumnWidth, isInteractionEnabled: false) |
There was a problem hiding this comment.
🟡 Skeleton branch shows composer for readonly conversations, causing layout jump on transition
In mainContentStack, the skeleton loading branch unconditionally renders composerSection(width: layoutMetrics.chatColumnWidth, isInteractionEnabled: false) at line 274. However, when the skeleton clears and activeConversationContent takes over, readonly conversations show a "Read-only conversation" banner instead of the composer (ChatView.swift:473). This means the skeleton→active transition for readonly conversations swaps a full-height composer for a single-line banner — producing the exact layout jump the PR aims to eliminate. The fix should check isReadonly in the skeleton branch and render the read-only banner (or matching-height placeholder) instead of the composer when the conversation is readonly.
Prompt for agents
In ChatView.mainContentStack, the skeleton branch at line 274 unconditionally renders composerSection(width:isInteractionEnabled:). But activeConversationContent (line 473) conditionally renders either the read-only banner (if isReadonly) or the composer. To keep layout stable across the skeleton-to-active transition for readonly conversations, the skeleton branch should mirror this conditional: show composerSection when !isReadonly, and show the same centeredChatColumn read-only banner when isReadonly. Look at lines 473-489 in activeConversationContent for the if/else pattern to replicate.
Was this helpful? React with 👍 or 👎 to provide feedback.
Why
Noa reported thread switching feeling clunky in v0.6.5. Three distinct issues producing a single user-visible symptom — all fixed here:
/historyrefetch, and cached tool-call / image bubbles rendered empty for several seconds while the fetch was in flight.Fix 1 — Don't strip and refetch cached conversations on switch
Root cause
ConversationManager.selectConversation/activateConversationrantrimPreviousConversationIfNeededon every switch, which calledChatViewModel.trimForBackground()on the outgoing VM:messagesto the latest 50stripHeavyContent()on each retained message — cleared tool-call results, attachment base64, and inline-surface payloads in place (text was preserved)isHistoryLoaded = falseStep 3 failed the
!viewModel.isHistoryLoadedguard inConversationRestorer.loadHistoryIfNeeded, firing a blocking/historyround-trip on re-activation. Step 2 meant the cached UI rendered empty tool/media bubbles until the fetch returned.Change
trimPreviousConversationIfNeededcall-sites inConversationManagerConversationSelectionStoretrimForBackgroundmethod inChatViewModelpopulateFromHistory's post-trim dedupe path to drop itsisContentStrippedbranches (they became unreachable oncetrimForBackgroundwas gone —trimOldMessagesIfNeededstrips and removes in the samebatchUpdateMessages, so stripped messages never persist to readers). Raised by Devin review on this PR.Fix 2 — Yield before heavy VM work on selection change
Root cause
The sidebar click handler set
windowState.selectionsynchronously and then calledconversationManager.selectConversation(id:)directly. On a cache-miss the second call builds a newChatViewModel, wires three Combine observers, and firesperformActivation. SwiftUI can't commit thewindowState.selectionchange until the synchronous handler returns — so the sidebar highlight stayed on the old thread for ~200–500 ms and appeared to update only when the content loaded.Change
Move the yield into
.onChange(of: windowState.selection)inMainWindowView.bodyrather than at the sidebar call-site. That handler is the convergence point for all selection sources (sidebar, nav-history replay, overlay dismissal, deep links), so the yield applies uniformly. Validation +applySelectionCorrectionstay synchronous so an invalid selection never commits to the current frame;conversationManager.selectConversation(id:)and the dependent surface/dock sync move into aTask { @MainActor in ... }. The sidebar's direct call toconversationManager.selectConversationis dropped — the onChange handler now owns the sync.An earlier version of this PR placed the
Task { @MainActor in ... }wrapper at the sidebar call-site. Devin review caught that it was a no-op: onChange ran synchronously during the same SwiftUI view-update pass and did all the heavy work before the enqueued Task got a chance. By the time the Task fired,performActivation'sconversationId != activeConversationIdguard skipped the work. Addressed in 968c9908c5.Matches Apple's event-handler guidance (WWDC25 — Embracing Swift concurrency, Apple HIG — Feedback target of <100 ms click-to-paint).
Fix 3 — Keep the composer on screen during the loading skeleton
Root cause
ChatView.mainContentStackbranched between mutually-exclusive states (skeleton / bootstrap / empty / active). Only the non-skeleton branches rendered a composer, so switching to a cold conversation produced a visible layout pop: composer disappears during the skeleton window, then re-enters when the conversation loads. Behavior predates the skeleton PR (#14906) — the earlier spinner also rendered without a composer — but became more visually disruptive once the skeleton replaced the small spinner.Change
Extract a single
composerSection(width:isInteractionEnabled:)helper and render it in the skeleton branch withisInteractionEnabled: false. The active branch uses the same helper with the caller's currentisInteractionEnabled. Composer keeps a stable screen position across load → active transitions. Typing into the disabled composer during load is blocked, but the input is still anchored — no layout jump.Empty-state views (
ChatEmptyStateView,ChatTemporaryChatEmptyStateView) still own their own inline composers. Unifying those into the extracted helper is tracked as LUM-1114.Why all three are safe
Fix 1 — client memory is still bounded by four existing safeguards this PR does not touch:
mode: \"light\")/historyfetchChatViewModel.trimOldMessagesIfNeededMemoryPressureMonitorUpper bound: 10 VMs × 150 messages ≈ 1,500 messages in-memory, each already daemon-truncated. Stripped or daemon-truncated messages rehydrate on demand via
ChatViewModel.rehydrateMessage(id:)(wired throughMessageCellView.onRehydrate).Fix 2 — the deferral only shifts
conversationManager.selectConversation(id:)by one main-actor tick.windowState.selection— the source of truth for the sidebar highlight — is still set synchronously. Validation runs synchronously so invalid selections never commit. Surface/dock sync lives in the same Task asselectConversationbecause it readsactiveViewModel, which only reflects the new conversation afterselectConversationhas run.Fix 3 — uses the same
ComposerSectionview withisInteractionEnabled: falsewhile the skeleton is visible. No new code paths, no state mutation risk.References
DispatchSource.makeMemoryPressureSource: OS-level API wrapped byMemoryPressureMonitor.ChatPaginationState.Task { @MainActor in ... }as the canonical yield-before-work pattern.Alternatives considered and rejected
isHistoryLoaded = falsefromtrimForBackground, keep the strip. Eliminates the refetch but leaves cached bubbles rendering empty until the user interacts with each one. Worse UX than doing nothing.trimForBackgroundas potential future reuse. Dead code. If we want timer-based eviction later, it's four lines to rewrite.makeViewModel+ observer setup off the main thread entirely. Observers are@MainActor-bound; moving them is a larger refactor. The Task-based yield captures 95% of the UX win for one onChange handler change.ChatEmptyStateView,ChatTemporaryChatEmptyStateView) to externalize their composer. Tracked as LUM-1114; this PR ships the narrow composer-during-skeleton fix first.Root cause analysis
How did the code get into this state?
Fix 1 origin. The aggressive trim landed Feb 25 2026 in 5b73ce12a9 as Milestone 5 of #9272. M1–M4 had already built:
rehydrateMessage(id:),message_content_request)stripHeavyContent()for in-place trim of old messagesM5 was the last piece: "evict memory from inactive threads." In isolation it seemed fine. Stacked on M1–M4 it was redundant and, worse, bypassed M2's lazy rehydration by forcing a blanket reload.
Fix 2 origin. The sidebar click handler followed the natural Swift pattern of calling
conversationManager.selectConversationdirectly after mutatingwindowState.selection. Nothing about the pattern looked wrong — the problem only surfaces when the callee does meaningful synchronous work on a cache miss. The redundant call at the sidebar duplicated work that the onChange handler was already doing.Fix 3 origin. The skeleton was introduced by #14906 as a direct replacement for a
ProgressView(); the earlier spinner also had no composer next to it, so the visible-gap regression was inherited, not introduced.What uncovered all three. c7186d7828 (@observable migration of MainWindowView managers) fixed a 2 s+
MainWindowView.bodyre-evaluation hang that had been masking all three regressions. Once that hang was gone, the strip-then-refetch latency, the event-handler blocking, and the composer pop all became visible bottlenecks.What decisions led to it?
isHistoryLoaded = falseforces a full-conversation refetch, butrehydrateMessage(id:)already existed for exactly this purpose.ConversationManager.selectConversationgrew over time; no one revisited whether it was still acceptable to run synchronously from a tap gesture.Warning signs we missed
ConversationManager→ConversationSelectionStore→ChatViewModel.trimForBackground). Spread-out state is harder to reason about and easier to regress unknowingly.mainContentStackhad four mutually-exclusive branches each owning their own composer (or lacking one). A single always-rendered composer was never considered because there was no forcing function.Prevention
isLoaded = false,isStale = true) to force a reload, check whether an on-demand lazy API already exists and prefer it.AGENTS.md rules added in this PR
clients/AGENTS.md→ Performance and Resource Management → Memory Management:clients/AGENTS.md→ Performance and Resource Management → Concurrency and Task Management:Both rules are durable (encode general principles, not specific APIs that rename), sit next to related guidance, and reference live canonical APIs/WWDC material rather than content that will drift.
Testing
New file
ConversationManagerThreadSwitchCacheTests.swiftcovers three invariants for Fix 1:testSwitchingAwayPreservesIsHistoryLoadedtestSwitchingAwayDoesNotStripMessageContenttestRapidRoundTripPreservesBothConversationsAll three pass.
./build.sh lintis clean.Fixes 2 and 3 are not unit-tested here — SwiftUI paint-commit timing and layout stability during transitions aren't observable from XCTest without UI instrumentation. The AGENTS.md rules plus the inline comments at each call-site are the forward-looking guard. Manual verification is in the test plan.
Test plan
./build.sh test --filter ConversationManagerThreadSwitchCacheTeststrimOldMessagesIfNeeded🤖 Generated with Claude Code