Skip to content

refactor(web): convert interaction store to direct named actions + createSelectors#31142

Merged
ashleeradka merged 4 commits into
mainfrom
devin/lum-1624-convert-interaction-state-machine-usereducer-to-zustand
May 19, 2026
Merged

refactor(web): convert interaction store to direct named actions + createSelectors#31142
ashleeradka merged 4 commits into
mainfrom
devin/lum-1624-convert-interaction-state-machine-usereducer-to-zustand

Conversation

@ashleeradka
Copy link
Copy Markdown
Contributor

@ashleeradka ashleeradka commented May 19, 2026

Prompt / plan

Closes LUM-1624. Converts the interaction store from a reducer/dispatch pattern to idiomatic Zustand direct named actions with createSelectors, and removes all dispatchInteraction prop-threading from consumers.

Why

  1. Reducer/dispatch is Zustand's escape hatch, not its recommendation. The Zustand flux guide positions dispatch({ type, payload }) as a fallback for Redux migrations. The idiomatic pattern is colocated named actions that call set() directly — no action-type strings, no switch statement.

  2. dispatchInteraction prop-threading was unnecessary. Every consumer imported the dispatch function through 3+ layers of props/context. With Zustand, components call useInteractionStore.getState().showSecret(payload) directly — no props, no context, no ref sync.

  3. interactionStateRef was manual Zustand. The old MutableRefObject<InteractionState> synced via useEffect solved the same problem as store.getState() — non-reactive reads without stale closures. Zustand eliminates this entirely.

  4. createSelectors is the established pattern. Per CONVENTIONS.md and the auth store, every store wraps with createSelectors() for auto-generated .use.field() hooks with per-field re-render optimization. Zustand docs.

What changed

Area Change
interaction-store.ts Rewritten: reducer/dispatch → 25 direct named actions. Wrapped with createSelectors(). Removed manual convenience hooks
chat-route-content.tsx Removed interactionState/dispatchInteraction props. Uses .use.field() selectors
use-interaction-actions.ts Removed dispatchInteraction/interactionStateRef params. 40+ dispatch calls → getState().action()
use-stream-event-handler.ts Removed dispatchInteraction from params and StreamHandlerContext
use-conversation-history.ts Removed dispatchInteraction/interactionStateRef. Uses getState()
use-send-message.ts Removed dispatchInteraction. 6 dispatch calls → store actions
use-conversation-loader.ts Removed prop-threading of dispatchInteraction/interactionStateRef
interaction-handlers.ts Calls useInteractionStore.getState() directly instead of receiving dispatch in context
chat-store.ts Removed dispatchInteraction and dispatchTurn bridge properties — both stores are accessed directly
chat-page.tsx Removed useReducer for interactions, removed interactionStateRef from refs

Net: 15 files, +448 −885 lines (net −437)

Safety

  • No behavioral changes. Same state transitions, same prompt lifecycles. Only the API surface changed.
  • No wire/protocol/API changes. Purely internal state management refactoring.
  • Store scoping unchanged. Interaction store remains a module-level singleton.

Alternatives not taken

  1. Keep reducer/dispatch wrapper — simpler PR but leaves non-idiomatic Zustand that CONVENTIONS.md explicitly discourages.
  2. Split use-interaction-actions.ts (734→600 lines) — still large, but splitting per prompt type is a separate concern. Deferred to follow-up.
  3. Move store to src/stores/ — interaction state is domain-specific (only consumed within domains/chat/), not cross-domain like auth. Stays in domains/interactions/.

Test plan

  • 33 tests pass (interaction store + chat-store + stream handler tests)
  • ESLint clean
  • TypeScript clean (only pre-existing errors: gitignored @/generated/ and design-library ref types)
  • CI green

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


Open in Devin Review

@linear
Copy link
Copy Markdown

linear Bot commented May 19, 2026

LUM-1624

@devin-ai-integration
Copy link
Copy Markdown
Contributor

🤖 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

Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no potential bugs to report.

View in Devin Review to see 5 additional findings.

Open in Devin Review

@devin-ai-integration
Copy link
Copy Markdown
Contributor

Test Results — Interaction State Machine Zustand Refactor

Ran shell-only unit tests to verify the Zustand store integration. Full E2E UI testing was not possible (app can't build locally due to gitignored generated API client, no preview deployment, interaction prompts require a running assistant backend).

Devin session

Zustand Store Integration Tests (NEW — 10/10 passed, 46 assertions)
Test Result
createInteractionStore() initializes with INITIAL_INTERACTION_STATE
dispatch updates state readable via getState()
subscribe fires exactly once on dispatch
RESET_ALL returns state to initial after multiple dispatches
Store isolation: mutations to storeA don't affect storeB
Store isolation: subscribe on storeB doesn't fire for storeA's dispatch
Full secret lifecycle: show → submit → end → dismiss
Full confirmation lifecycle: show → submit → end → dismiss
Full question lifecycle: show → dismiss card → dismiss fully
dispatch is on store API, not inside getState() (Flux pattern)
Existing Reducer Tests (34/34 passed, 62 assertions)

All 34 pre-existing interactionReducer tests pass unchanged — secret flow (7), confirmation flow (9), contact request flow (4), question flow (5), RESET_ALL, RESET_SECRET_AND_CONFIRMATION, hasActiveInteraction (5), unknown events.

Static Analysis & CI
  • TypeScript: Zero type errors in all 16 changed files
  • ESLint: Clean (exit code 0)
  • CI: All 3 checks green (Lint/Type Check/Build, Socket Security ×2)

Limitations

  • E2E UI flows untested: Secret prompts, confirmation cards, contact requests, and question cards are rendered by chat-route-content.tsx from store state. Without a running assistant backend to send stream events, the full UI rendering pipeline could not be exercised. The unit tests verify the state management layer is correctly wired.
  • Stream handler integration tests: interaction-handlers.test.ts has a pre-existing import error (@/generated/api/client.gen.js) unrelated to this PR.

@devin-ai-integration devin-ai-integration Bot changed the title refactor(chat): convert interaction state machine from useReducer to Zustand store refactor(chat): convert interaction state machine to Zustand and co-locate into interactions/ May 19, 2026
@devin-ai-integration devin-ai-integration Bot changed the title refactor(chat): convert interaction state machine to Zustand and co-locate into interactions/ refactor(chat): convert interaction useReducer to Zustand store + co-locate into interactions/ May 19, 2026
devin-ai-integration Bot and others added 3 commits May 19, 2026 19:41
…actions

Replace all dispatchInteraction/interactionStateRef prop-threading with
direct useInteractionStore calls. Stream handlers, hooks, and components
now call named actions (showSecret, resetAll, etc.) via getState() for
non-reactive reads and selector hooks for reactive subscriptions.

Removes InteractionEvent type, dispatch pattern, and interactionStateRef
from all consumer interfaces.

Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
…t-interaction-state-machine-usereducer-to-zustand
Wrap useInteractionStore with createSelectors() per CONVENTIONS.md,
enabling .use.field() auto-generated selector hooks. Removes manual
useInteractionState/useInteractionActions convenience hooks (dead code).

Updates consumers from useInteractionStore((s) => s.field) to
useInteractionStore.use.field() for idiomatic per-field subscriptions.

Also removes dispatchInteraction from chat-store.test.ts which was
testing the removed bridge property.

Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
@devin-ai-integration devin-ai-integration Bot force-pushed the devin/lum-1624-convert-interaction-state-machine-usereducer-to-zustand branch from 68ab4bd to 0cf8fad Compare May 19, 2026 19:46
@devin-ai-integration devin-ai-integration Bot changed the title refactor(chat): convert interaction useReducer to Zustand store + co-locate into interactions/ refactor(web): convert interaction store to direct named actions + createSelectors May 19, 2026
Resolved conflicts in 10 files where both branches removed their
respective dispatch prop-threading:
- Our branch removed dispatchInteraction (interaction Zustand store)
- Main's #31144 removed dispatchTurn (turn Zustand store)
Resolution: remove both dispatchers, use both Zustand stores directly.

Also incorporates main's #31194 (organization store migration) and

Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
#31110 (OpenAPI spec sync).
Copy link
Copy Markdown
Contributor

@vex-assistant-bot vex-assistant-bot Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

APPROVE (HEAD 07c7354e22)

Value: Removes ~300 lines of dispatch boilerplate and prop-threading while giving each interaction UI atomic subscriptions — components re-render only when their specific prompt field changes, not when any interaction event fires anywhere.

What this does: Replaces interactionReducer + InteractionEvent union + dispatchInteraction prop-drilling with a useInteractionStore Zustand store using direct named actions (showSecret(), dismissConfirmation(), etc.) and createSelectors. The Dispatch<InteractionEvent> wire is pulled from ChatStore, ChatPage, ChatRouteContent, and ~6 hooks. Stream handlers and async callbacks switch to .getState().action() for non-reactive reads.

Pattern compliance: Exactly follows the auth-store.ts / organization-store.ts canonical — createSelectors(create<Store>()(…)) → consumers call useInteractionStore.use.field() for atomic per-field subscriptions. The InteractionEvent union type is gone entirely; no stale .dispatch refs in closure. ✅

Test migration: beforeEach(() => useInteractionStore.getState().resetAll()) is the right reset strategy for module-level Zustand singletons — no more constructing { ...INITIAL_INTERACTION_STATE, field: override } per-test. The 211 deleted test lines are boilerplate removal, not coverage loss; every flow (secret, confirmation, contact-request, question) retains full transition coverage. ✅

Stale closure elimination: The old interactionStateRef.current pattern is fully removed. Stream handlers call useInteractionStore.getState().showSecret(…) at invocation time — same semantics as the ref, but without threading the ref through StreamHandlerContext. ✅

Non-blocking notes:

  • ChatRouteContentProps has a blank line artifact where the interaction props were removed (around line 260 in the diff) — cosmetic.
  • Diff truncated before dismissQuestion's implementation. The old DISMISS_QUESTION reducer explicitly zeroed isQuestionCardDismissed. Worth a quick confirm that the new action does the same — if it's covered by a test or the CI, no action needed.

Vellum Constitution — Inviting: direct named actions (showSecret, dismissConfirmation) lower the cognitive cost of touching interaction flows — any contributor can call them without studying the InteractionEvent union first.

@ashleeradka ashleeradka merged commit 4d835c2 into main May 19, 2026
3 checks passed
@ashleeradka ashleeradka deleted the devin/lum-1624-convert-interaction-state-machine-usereducer-to-zustand branch May 19, 2026 20:36
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