Skip to content

Optimize conversation restoration startup and reduce redundant sidebar recomputation#23317

Merged
ashleeradka merged 8 commits into
mainfrom
devin/1775180145-lum-665-fix-restoration-hang
Apr 3, 2026
Merged

Optimize conversation restoration startup and reduce redundant sidebar recomputation#23317
ashleeradka merged 8 commits into
mainfrom
devin/1775180145-lum-665-fix-restoration-hang

Conversation

@devin-ai-integration
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot commented Apr 3, 2026

Summary

Eliminates a 2s+ main-thread hang during startup caused by the conversation restoration flow executing redundant activations, heavyweight property-observer side effects, and unnecessary SwiftUI animation computations synchronously. Also reduces ongoing sidebar recomputation cost by caching derived properties and using copy-modify-writeback for struct mutations.

Why

During startup, handleConversationListResponse restores ~50 conversations from the daemon. The previous implementation had several compounding costs:

  • Double activation: activateConversation(firstVisible.id) triggered a full side-effect chain (VM creation, daemon HTTP call, Combine subscriptions), then restoreLastActiveConversation() could activate a different conversation — running the entire chain again.
  • Heavyweight didSet: activeConversationId.didSet ran ~50 lines of synchronous side effects. Per Apple's Observation framework guidance, property observers should perform lightweight bookkeeping; side effects belong in explicit methods.
  • Animation overhead: Bulk conversations array assignment triggered SwiftUI diffing and animation interpolation for every row. Apple's Transaction API allows suppressing this via disablesAnimations.
  • Redundant computed properties: groupedConversations, visibleConversations, etc. were computed on every view body access. Multiple views reading the same property within a single layout pass each re-ran O(N log N) sort + O(N) filter independently. Apple recommends pre-computing expensive derived values outside the view body.

Changes

  1. Eliminate double activation (ConversationRestorer): Activation target (saved last-active → first visible → new) computed once and activated once.

  2. Decouple side effects from activeConversationId didSet (ConversationSelectionStore): didSet now does only UserDefaults persistence and stale-anchor clearing. Heavy work (VM creation, daemon notification, observation wiring) moves to explicit performActivation(for:) / performDeactivation(). Setter is private(set) — all callers go through these methods or the activateConversation() facade. performActivation(for:) short-circuits when the target is already active.

  3. Suppress animations during bulk restoration (ConversationRestorer): Both groups and conversations assignments wrapped in withTransaction { disablesAnimations = true } per Apple's Transaction API.

  4. Cache derived sidebar properties (ConversationListStore): groupedConversations, visibleConversations, sortedGroups, unseenVisibleConversationCount, archivedConversations converted to stored private(set) properties, recomputed once per conversations/groups mutation via didSet. Early-returns when conversations is empty.

  5. Copy-modify-writeback for multi-field struct mutations (ConversationListStore, ConversationRestorer, ConversationManager): With change ci: add web and platform CI/PR workflows #4, each conversations[index].field = value triggers didSetrecomputeDerivedProperties() (O(N log N)). All multi-mutation paths now copy the struct locally and write back once:

    • Bulk loops (markAllConversationsSeen, restoreUnseen): snapshot pattern — N mutations → 1 writeback
    • Single-element multi-field (mergeAssistantAttention, pinConversation, unpinConversation, moveConversationToGroup, rollbackUnreadMutationIfNeeded, handleAssistantMessageArrival, handleNotificationIntentForExistingConversation): copy-modify-writeback → 1 didSet each
    • ConversationRestorer existing-conversation merge: non-attention fields → 1 writeback, then mergeAssistantAttention for pendingAttentionOverrides reconciliation → 1 more. Total: 2, down from 4.
  6. Fix archiveConversation fallback selection (ConversationManager): visibleAfter/visibleBefore now exclude .private conversations, matching visibleConversations semantics.

  7. Fix closeConversation stale selection (ConversationManager): Added performDeactivation() fallback when no next conversation exists.

Benefits

  • Speed: Eliminates redundant activation cycle and O(N log N) recomputation on every struct-field mutation during hot paths. Animation suppression avoids SwiftUI diffing/interpolation for ~50 rows at startup.
  • Maintainability: activeConversationId setter is private(set) with lightweight didSet — side effects are explicit and discoverable via performActivation/performDeactivation. Cached derived properties make the write-triggers explicit.
  • Correctness: Archive fallback no longer selects private conversations. Close fallback no longer leaves stale selection.

Safety

  • All existing activeConversationId = ... call sites migrated to performActivation(for:) / performDeactivation() (verified via grep). The private(set) access control will surface any missed callers as compile errors.
  • Copy-modify-writeback preserves identical mutation semantics — same fields set to the same values, just batched into a single array writeback.
  • withTransaction { disablesAnimations } only suppresses animation interpolation; it does not skip view updates. Views still reflect the new state.
  • Remaining single-field mutations (title rename, resolveConversationId, backfillConversationId, unarchiveConversation) trigger exactly one recompute each and are unchanged.

References

Review & Testing Checklist for Human

  • Build locally in Xcode — CI macOS checks are skipping. The private(set) change on activeConversationId will surface any missed callers as compile errors. This is the most important verification step.
  • performActivation(for:) short-circuit guard — Verify no flow depends on re-activation when the conversation is already active (e.g., after VM eviction while a conversation remains selected). The guard conversationId != activeConversationId skips VM creation, daemon notification, and observation wiring.
  • Startup with ~50 conversations — Launch the app, verify correct conversation activates (last-active if saved, otherwise first visible), sidebar populates without animation artifacts.
  • Conversation switching / close / archive — Exercise switching, closing the last conversation, archiving adjacent to private conversations, draft mode entry/exit.
  • Mark-all-seen → undo → mark-unread — Snapshot-writeback batches mutations atomically. Verify the full cycle works correctly.
  • Pin / unpin / move-to-group — These use copy-modify-writeback. Verify pin, unpin (including heuristic group restoration for legacy pins), and drag-to-group.

Notes

  • import SwiftUI added to ConversationRestorer.swift (model-layer file) for Transaction/withTransaction. This is the Apple-recommended API but is a layering consideration.
  • lastActiveConversationIdString added to MockConversationRestorerDelegate to satisfy the updated ConversationRestorerDelegate protocol.
  • ConversationManager.activeConversationId facade is now read-only. All activation routes through activateConversation() / enterDraftMode().
  • Last-active restoration uses local UUIDs which are regenerated on cold restart, so activationTarget only helps on reconnect within the same process. This matches prior behavior.

Link to Devin session: https://app.devin.ai/sessions/3ea6d041e22243cda500f9884cc251e1
Requested by: @ashleeradka


Open with Devin

…ess animations, cache derived properties

- Eliminate double activation in ConversationRestorer.handleConversationListResponse():
  Compute the single activation target (saved last-active or first visible) up front
  and activate exactly once, instead of activating firstVisible then potentially
  re-activating via restoreLastActiveConversation().

- Refactor activeConversationId didSet into explicit performActivation/performDeactivation:
  The didSet now performs only lightweight bookkeeping (UserDefaults, anchor clearing).
  Heavy side effects (VM creation, daemon notification, observation setup) moved to
  performActivation(for:) and performDeactivation() methods that callers invoke explicitly.

- Suppress animations during bulk conversation list restoration:
  Wrap the bulk conversations array assignment in withTransaction with
  disablesAnimations to skip animation interpolation for ~50 rows on startup.

- Cache computed sidebar properties as stored properties in ConversationListStore:
  Convert groupedConversations, visibleConversations, sortedGroups,
  unseenVisibleConversationCount, and archivedConversations from computed to stored
  properties recomputed once per conversations/groups mutation via didSet.

Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
@devin-ai-integration
Copy link
Copy Markdown
Contributor Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment and CI monitoring

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration Bot and others added 3 commits April 3, 2026 01:53
Protocol requirement added in ConversationRestorer.swift for the
single-activation-target optimization. The mock needs it to compile.

Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
The visibleAfter/visibleBefore filters only checked !isArchived, while
visibleConversations also excludes .private conversations. This could
cause archiveConversation to activate a private conversation as the
next selection target. Now consistent with visibleConversations.

Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
ashleeradka
ashleeradka previously approved these changes Apr 3, 2026
devin-ai-integration[bot]

This comment was marked as resolved.

…tion, guard empty recompute

- Make activeConversationId private(set) on ConversationSelectionStore. Remove
  the public setter from ConversationManager facade — only performActivation(for:)
  and performDeactivation() can change selection. Prevents future code from
  silently bypassing VM creation, daemon notification, and observer wiring.

- Add performDeactivation() fallback in closeConversation when both branches
  fail to find a next conversation, preventing stale activeConversationId.

- Guard recomputeDerivedProperties() to skip expensive sort/filter/bucket work
  when conversations is empty (e.g. when groups is assigned before conversations
  during restoration).

Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
…es calls

Per-field struct mutations on conversations[index] each trigger
conversations.didSet → recomputeDerivedProperties() (O(N log N) sort +
O(N) filter + O(N) bucketing). With stored derived properties, this is
a regression vs the old computed-property approach which deferred work
to view body evaluation.

Fixed hot paths:
- mergeAssistantAttention: 3–6 field writes → 1 writeback
- markAllConversationsSeen: N element mutations → 1 snapshot writeback
- restoreUnseen: N element mutations → 1 snapshot writeback
- rollbackUnreadMutationIfNeeded: 2 field writes → 1 writeback
- ConversationRestorer existing-conversation merge: 3 field writes +
  mergeAssistantAttention (4 total didSet triggers) → 1 writeback with
  inlined attention fields

Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration Bot and others added 2 commits April 3, 2026 21:26
…conciliation

The previous commit inlined attention field assignments to avoid
extra didSet triggers, but this bypassed the pendingAttentionOverrides
logic in mergeAssistantAttention. This matters when a notification
conversation is created and opened by the user before the session list
response arrives — the user's local seen/unread state would be
overwritten by stale server data.

Now: non-attention fields (groupId, displayOrder, forkParent) use
copy-modify-writeback (1 didSet), then mergeAssistantAttention handles
attention fields with override reconciliation (1 more didSet). Total:
2 didSet triggers, down from 4 in the original code.

Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
- pinConversation: 2 per-field writes → 1 writeback
- unpinConversation: 2 per-field writes → 1 writeback
- moveConversationToGroup: 2-3 per-field writes → 1 writeback
- handleAssistantMessageArrival: up to 3 per-field writes → 1 writeback
- handleNotificationIntentForExistingConversation: up to 3 per-field
  writes → 1 writeback
- performActivation(for:): add short-circuit guard when already-active
  conversation is re-activated, avoiding redundant VM creation, daemon
  notification, and observation wiring
- ConversationRestorer groups assignment: wrap in withTransaction to
  suppress animations during the groups-only didSet

Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
@devin-ai-integration devin-ai-integration Bot changed the title Fix LUM-665: Eliminate startup hang during conversation restoration Optimize conversation restoration startup and reduce redundant sidebar recomputation Apr 3, 2026
@ashleeradka ashleeradka merged commit 2282648 into main Apr 3, 2026
6 checks passed
@ashleeradka ashleeradka deleted the devin/1775180145-lum-665-fix-restoration-hang branch April 3, 2026 22:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant