refactor(web): convert conversation-list-store to Zustand with direct named actions#31214
Conversation
… named actions Closes LUM-1622 - Convert conversationListReducer (20 action types + discriminated union) to Zustand store with create() + createSelectors + 20 direct named actions - Remove dispatchConversationList threading from 12 consumer files and StreamHandlerContext — consumers now call useConversationListStore.getState().actionName() directly - Rename exported patchConversation() helper to applyConversationPatch() to avoid collision with the new store action of the same name - Rewrite 31 tests from reducer pattern to Zustand store API - Update stale docstrings referencing useReducer/dispatch patterns - Clean up unused Dispatch/ConversationListAction imports across all files Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
Note: I can only respond to comments from users who have write access to this repository. ⚙️ Control Options:
|
Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
There was a problem hiding this comment.
✦ APPROVE
Value: Completes the Zustand migration for the last major reducer-pattern store. Eliminates dispatchConversationList: Dispatch<ConversationListAction> threading across 8 hooks and StreamHandlerContext — 20 discriminated-union action interfaces down to 0, ~120 lines of parameter passing deleted from consumers. LUM-1622 done.
Store design (conversation-list-store.ts) — all correct:
createSelectors(create<ConversationListStore>(...))— canonical pattern ✅- Named actions co-located in store (Flux-inspired Zustand pattern) ✅
applyConversationPatch/applyGroupPatchrename avoids shadowing the store action methods — clean ✅reset()creates freshSet()instances — not reusing mutable state ✅deleteGroupAndResetConversations+graduateProcessingKeyupdate multiple fields atomically in oneset()— correct ✅- Set helpers return same reference on no-ops — preserves Zustand shallow equality bailout ✅
Consumer hooks — all correct:
getState().action()in event handlers / effects (not hooks) — correct pattern ✅dispatchConversationListremoved from alluseCallbackdep arrays ✅StreamHandlerContext.dispatchConversationListeliminated at the type level intypes.ts✅test-helpers.tsmock removed,metadata-handlers.test.tspre-seeds + reads store state — correct testing approach ✅
Two non-blocking observations:
graduateProcessingKey in the store calls get() twice in one set():
set({
processingKeys: removeFromSet(get().processingKeys, key),
attentionKeys: hasPendingInteraction ? addToSet(get().attentionKeys, key) : get().attentionKeys,
});Both get() calls read the same pre-mutation snapshot, so this is correct. Functional set(state => ...) form would be marginally more explicit about intent, but this works fine in practice.
use-attention-tracking.ts — decideGraduationDispatches still returns typed action objects (.type field), and the new loop branches on ADD_ATTENTION_KEY vs else. Since graduateProcessingKey now exists as a compound action, this could eventually be simplified — but the current mapping is correct: ADD_ATTENTION_KEY → addAttentionKey, everything else → removeProcessingKey. No behavioral regression.
One housekeeping note: #31140 (previous LUM-1622 PR — feat(chat): Convert conversation-list useReducer to Zustand store) is now closed without merge, superseded by this PR. May want to confirm the Linear ticket reflects this PR as the closing one.
Anti-patterns clean: No useShallow on owned store. No Dispatch threading. No barrel files. No useReducer in consumers. ✅
The 20-interface discriminated union was load-bearing complexity for no architectural gain over named actions. This is the right call.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6fc9a030ca
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| * so Zustand can bail out of re-renders. | ||
| */ | ||
| export function patchConversation( | ||
| export function applyConversationPatch( |
There was a problem hiding this comment.
Preserve patchConversation export alias
Renaming the exported helper to applyConversationPatch removes the patchConversation symbol, but apps/web/src/domains/chat/hooks/use-conversation-actions.test.ts still imports patchConversation from this module. That causes the test file to fail at module load when run, so this refactor leaves part of the test surface broken unless all importers are updated in the same commit (or a compatibility alias is kept).
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Already fixed in 7de0174 — the test file was updated to import applyConversationPatch in the follow-up commit. CI is green.
| return changed ? result : conversations; | ||
| } |
There was a problem hiding this comment.
🚩 Test file imports renamed function patchConversation (now applyConversationPatch)
The PR renamed the exported helper from patchConversation to applyConversationPatch in the store (apps/web/src/domains/conversations/conversation-list-store.ts:61), but apps/web/src/domains/chat/hooks/use-conversation-actions.test.ts:5 still imports patchConversation. This will be caught by TypeScript (bunx tsc --noEmit), so it's not reported as a bug per review guidelines. Additionally, applyConversationPatch is exported but only consumed internally by the store's own patchConversation action — the export is no longer needed externally.
(Refers to lines 61-73)
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
The rename was already applied to the test file in 7de0174 — CI is green.
The export is still needed: use-conversation-actions.test.ts imports and tests applyConversationPatch directly (verifying immutability, no-match behavior, multi-field patches). The function is a pure utility used by the store's patchConversation action, and keeping it exported + tested is intentional.
…state access Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
fbc093d
Prompt / plan
Closes LUM-1622. Converts the last remaining reducer-pattern shared store (
conversationListReducer) to Zustand withcreate()+createSelectors+ direct named actions — completing the migration started by #31201 (subagent-store) and #31209 (viewer-store).Why needed
conversationListReduceruseduseReducerwith a 20-type discriminated union (ConversationListAction) and threadeddispatchConversationList: Dispatch<ConversationListAction>through 12 consumer files andStreamHandlerContext. This is the exact prop-drilling / parameter-threading anti-pattern that Zustand is designed to eliminate.What changed
Store conversion (
conversation-list-store.ts):conversationListReducer+ConversationListAction(20 type interfaces + discriminated union) with a Zustand store usingcreate()+createSelectorssetConversations(),patchConversation(),markConversationSeen(),prependConversation(),removeConversation(),resolveDraftKey(),setGroups(),appendGroup(),patchGroup(),replaceOptimisticGroup(),removeGroup(),deleteGroupAndResetConversations(),setActiveKey(),setEditingKey(),addProcessingKey(),removeProcessingKey(),removeMultipleProcessingKeys(),transferProcessingKey(),addAttentionKey(),removeAttentionKey(),graduateProcessingKey(),reset()patchConversation()helper toapplyConversationPatch()to avoid collision with the store actionINITIAL_CONVERSATION_LIST_STATE,conversationListReducer,ConversationListAction, and all 20 action type interfacesConsumer updates (12 files):
dispatchConversationListparameter from all hook interfaces and function signaturesdispatchConversationList({ type: "ACTION_NAME", ... })calls withuseConversationListStore.getState().actionName(...)— correct pattern for event handlers/callbacks per CONVENTIONS.mddispatchConversationListfromStreamHandlerContext(the last remainingDispatch<Action>in the context)Dispatch,ConversationListActionimports throughoutTests:
conversationListReducer(state, action)pattern togetState().actionName()withafterEachresetBenefits
dispatchConversationListas a parameter{ type: "ACTION_NAME" }dispatch objectsSafety
caseblocktsc --noEmitpasses, lint passesWhat was NOT done and why
activeConversationKeyandeditingConversationKeycould arguably live in a separate store, but this would add churn without benefit — LUM-1651 (mainView → URL routing) will likely reshape this state anywayuseReducerinstances (terminal, voice, navigation-history): Per Zustand guidance, component-local state that isn't shared doesn't need a Zustand storeReferences
Test plan
bun test conversation-list-store.test.ts)bunx tsc --noEmit— cleanbun run lint— cleanLink to Devin session: https://app.devin.ai/sessions/565d827296144ac9bf12bd108169e5ef
Requested by: @ashleeradka