From 23016d797ec664cf9e1304b81f556d4135b610f6 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 19 May 2026 19:41:43 +0000 Subject: [PATCH 1/2] refactor(web): convert interaction consumers to direct Zustand store 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 --- apps/web/src/domains/chat/chat-page.tsx | 12 +- apps/web/src/domains/chat/chat-store.ts | 16 +- .../chat/components/chat-route-content.tsx | 43 +- .../chat/hooks/use-conversation-history.ts | 18 +- .../chat/hooks/use-conversation-loader.ts | 8 +- .../chat/hooks/use-interaction-actions.ts | 121 ++-- .../domains/chat/hooks/use-send-message.ts | 16 +- .../chat/hooks/use-stream-event-handler.ts | 5 - .../interaction-handlers.test.ts | 34 +- .../stream-handlers/interaction-handlers.ts | 62 +- .../utils/stream-handlers/test-helpers.ts | 1 - .../chat/utils/stream-handlers/types.ts | 2 - .../interactions/interaction-store.test.ts | 551 ++++++------------ .../domains/interactions/interaction-store.ts | 478 ++++++--------- 14 files changed, 494 insertions(+), 873 deletions(-) diff --git a/apps/web/src/domains/chat/chat-page.tsx b/apps/web/src/domains/chat/chat-page.tsx index db6c77c8633..31e74b8b346 100644 --- a/apps/web/src/domains/chat/chat-page.tsx +++ b/apps/web/src/domains/chat/chat-page.tsx @@ -10,10 +10,7 @@ import { import { useIsMobile } from "@/hooks/use-is-mobile.js"; import { useAuth } from "@/lib/auth/auth-provider.js"; import { useAssistantLifecycle } from "@/domains/chat/hooks/use-assistant-lifecycle.js"; -import { - interactionReducer, - INITIAL_INTERACTION_STATE, -} from "@/domains/interactions/interaction-store.js"; + import { turnReducer, INITIAL_TURN_STATE, @@ -55,10 +52,6 @@ export function ChatPage() { const [messages, setMessages] = useState([]); const [turnState, dispatchTurn] = useReducer(turnReducer, INITIAL_TURN_STATE); - const [interactionState, dispatchInteraction] = useReducer( - interactionReducer, - INITIAL_INTERACTION_STATE, - ); const [input, setInput] = useState(""); const [error, setError] = useState<{ message: string } | null>(null); const [compactionCircuitOpenUntil, setCompactionCircuitOpenUntil] = @@ -95,7 +88,6 @@ export function ChatPage() { assistantId, sendMessage, dispatchTurn, - dispatchInteraction, }); if (authLoading || assistantState.kind === "loading") { @@ -135,8 +127,6 @@ export function ChatPage() { error, setError, isLoadingHistory: false, - interactionState, - dispatchInteraction, conversations: [], activeConversationKey: null, activeConversation: undefined, diff --git a/apps/web/src/domains/chat/chat-store.ts b/apps/web/src/domains/chat/chat-store.ts index 4fe5c1bc4ba..094c7d91bd3 100644 --- a/apps/web/src/domains/chat/chat-store.ts +++ b/apps/web/src/domains/chat/chat-store.ts @@ -12,7 +12,9 @@ * * **Convenience hooks** — grouped slices for common patterns: * - `useChatState()` — state slice (messages, conversation key, assistant ID) - * - `useChatActions()` — stable action refs (sendMessage, dispatchers) + * - `useChatActions()` — stable action refs (sendMessage, dispatchTurn) + * + * Interaction state lives in its own store (`useInteractionStore`). * * Reference: {@link https://zustand.docs.pmnd.rs/} */ @@ -23,7 +25,6 @@ import { useShallow } from "zustand/shallow"; import type { DisplayAttachment, DisplayMessage } from "@/domains/chat/lib/reconcile.js"; import type { DomainEvent } from "@/domains/messaging/turn-store.js"; -import type { InteractionEvent } from "@/domains/interactions/interaction-store.js"; // --------------------------------------------------------------------------- // Store shape @@ -43,8 +44,6 @@ export interface ChatActions { sendMessage: (content: string, attachments?: DisplayAttachment[]) => Promise; /** Dispatch a turn state-machine event. */ dispatchTurn: Dispatch; - /** Dispatch an interaction state-machine event. */ - dispatchInteraction: Dispatch; } export type ChatStore = ChatState & ChatActions; @@ -62,7 +61,6 @@ export const useChatStore = create()(() => ({ assistantId: null, sendMessage: NOOP_SEND, dispatchTurn: NOOP_DISPATCH as Dispatch, - dispatchInteraction: NOOP_DISPATCH as Dispatch, })); // --------------------------------------------------------------------------- @@ -75,7 +73,6 @@ export interface ChatStoreSyncProps { assistantId: string | null; sendMessage: (content: string, attachments?: DisplayAttachment[]) => Promise; dispatchTurn: Dispatch; - dispatchInteraction: Dispatch; } /** @@ -90,7 +87,6 @@ export function useSyncChatStore(props: ChatStoreSyncProps): void { assistantId, sendMessage, dispatchTurn, - dispatchInteraction, } = props; useEffect(() => { @@ -105,9 +101,8 @@ export function useSyncChatStore(props: ChatStoreSyncProps): void { useChatStore.setState({ sendMessage, dispatchTurn, - dispatchInteraction, }); - }, [sendMessage, dispatchTurn, dispatchInteraction]); + }, [sendMessage, dispatchTurn]); } // --------------------------------------------------------------------------- @@ -130,7 +125,7 @@ export function useChatState(): ChatState { } /** - * Stable action dispatchers (sendMessage, dispatchTurn, dispatchInteraction). + * Stable action dispatchers (sendMessage, dispatchTurn). * Does **not** re-render when messages or active conversation change. */ export function useChatActions(): ChatActions { @@ -138,7 +133,6 @@ export function useChatActions(): ChatActions { useShallow((s) => ({ sendMessage: s.sendMessage, dispatchTurn: s.dispatchTurn, - dispatchInteraction: s.dispatchInteraction, })), ); } diff --git a/apps/web/src/domains/chat/components/chat-route-content.tsx b/apps/web/src/domains/chat/components/chat-route-content.tsx index 02e9eb4614c..d266f2ba5e5 100644 --- a/apps/web/src/domains/chat/components/chat-route-content.tsx +++ b/apps/web/src/domains/chat/components/chat-route-content.tsx @@ -66,7 +66,7 @@ import { pickRandomPlaceholder } from "@/domains/chat/lib/empty-state-constants. import { useEmptyStateGreeting } from "@/domains/chat/lib/use-empty-state-greeting.js"; import { getChatBillingBannerDecision, shouldShowGenericChatErrorNotice } from "@/domains/chat/lib/error-classification.js"; import { fetchOlderHistoryPage } from "@/domains/chat/lib/history.js"; -import { type InteractionState } from "@/domains/interactions/interaction-store.js"; +import { useInteractionStore } from "@/domains/interactions/interaction-store.js"; import type { SubagentEntry, SubagentMapState } from "@/domains/subagents/subagent-store.js"; import type { DisplayAttachment, DisplayMessage } from "@/domains/chat/lib/reconcile.js"; import { buildTranscriptItems } from "@/domains/chat/lib/transcript/build-items.js"; @@ -84,7 +84,7 @@ import { haptic } from "@/utils/haptics.js"; import { isChannelConversation as _isChannelConversation } from "@/domains/chat/lib/conversation-channel.js"; import { getDiskPressureChatBlockReason } from "@/domains/assistant/disk-pressure.js"; import type { DiskPressureStatusEventPayload } from "@/domains/assistant/use-disk-pressure-monitor.js"; -import type { InteractionEvent } from "@/domains/interactions/interaction-store.js"; + import type { DomainEvent } from "@/domains/messaging/turn-store.js"; import type { QuestionResponseEntry, AllowlistOption, ScopeOption, DirectoryScopeOption, ConfirmationDecision } from "@/domains/chat/lib/event-types.js"; import type { CharacterComponents, CharacterTraits } from "@/domains/avatar/types.js"; @@ -224,7 +224,7 @@ export interface ChatRouteRefs { pendingLocalDeletionsRef: MutableRefObject>; confirmationToolCallMapRef: MutableRefObject>; turnStateRef: MutableRefObject; - interactionStateRef: MutableRefObject; + reconcileAfterNextStreamOpenRef: MutableRefObject; } @@ -266,9 +266,7 @@ export interface ChatRouteContentProps { // Loading isLoadingHistory: boolean; - // Interaction - interactionState: InteractionState; - dispatchInteraction: Dispatch; + // Conversation conversations: Conversation[]; @@ -390,8 +388,6 @@ export function ChatRouteContent({ error, setError, isLoadingHistory, - interactionState, - dispatchInteraction, conversations: _conversations, activeConversationKey, activeConversation, @@ -510,25 +506,24 @@ export function ChatRouteContent({ pendingLocalDeletionsRef: _pendingLocalDeletionsRef, confirmationToolCallMapRef: _confirmationToolCallMapRef, turnStateRef: _turnStateRef, - interactionStateRef, reconcileAfterNextStreamOpenRef: _reconcileAfterNextStreamOpenRef, } = refs; // ------------------------------------------------------------------------- - // Derived interaction state + // Interaction state (from Zustand store) // ------------------------------------------------------------------------- - const pendingSecret = interactionState.pendingSecret; - const pendingConfirmation = interactionState.pendingConfirmation; - const pendingContactRequest = interactionState.pendingContactRequest; - const pendingQuestion = interactionState.pendingQuestion; - const isSubmittingSecret = interactionState.isSubmittingSecret; - const isSubmittingConfirmation = interactionState.isSubmittingConfirmation; - const isSubmittingContactRequest = interactionState.isSubmittingContactRequest; - const isSubmittingQuestion = interactionState.isSubmittingQuestion; - const contactRequestAccepted = interactionState.contactRequestAccepted; - const secretSaved = interactionState.secretSaved; - const inlineConfirmationToolCallId = interactionState.inlineConfirmationToolCallId; + const pendingSecret = useInteractionStore((s) => s.pendingSecret); + const pendingConfirmation = useInteractionStore((s) => s.pendingConfirmation); + const pendingContactRequest = useInteractionStore((s) => s.pendingContactRequest); + const pendingQuestion = useInteractionStore((s) => s.pendingQuestion); + const isSubmittingSecret = useInteractionStore((s) => s.isSubmittingSecret); + const isSubmittingConfirmation = useInteractionStore((s) => s.isSubmittingConfirmation); + const isSubmittingContactRequest = useInteractionStore((s) => s.isSubmittingContactRequest); + const isSubmittingQuestion = useInteractionStore((s) => s.isSubmittingQuestion); + const contactRequestAccepted = useInteractionStore((s) => s.contactRequestAccepted); + const secretSaved = useInteractionStore((s) => s.secretSaved); + const inlineConfirmationToolCallId = useInteractionStore((s) => s.inlineConfirmationToolCallId); const inlineConfirmationAttached = inlineConfirmationToolCallId !== null; // ------------------------------------------------------------------------- @@ -929,8 +924,8 @@ export function ChatRouteContent({ // ------------------------------------------------------------------------- const handleDismissPendingQuestion = useCallback(() => { - const snapshot = interactionStateRef.current.pendingQuestion; - dispatchInteraction({ type: "DISMISS_QUESTION" }); + const snapshot = useInteractionStore.getState().pendingQuestion; + useInteractionStore.getState().dismissQuestion(); if (!snapshot) return; const ctx = streamContextRef.current; if (!ctx) return; @@ -953,7 +948,7 @@ export function ChatRouteContent({ tags: { context: "submit_question_response_close" }, }); }); - }, [dispatchInteraction, interactionStateRef, streamContextRef]); + }, [streamContextRef]); // ------------------------------------------------------------------------- // Empty state placeholder (stable per mount) diff --git a/apps/web/src/domains/chat/hooks/use-conversation-history.ts b/apps/web/src/domains/chat/hooks/use-conversation-history.ts index 3140574278d..ee4be56c07f 100644 --- a/apps/web/src/domains/chat/hooks/use-conversation-history.ts +++ b/apps/web/src/domains/chat/hooks/use-conversation-history.ts @@ -29,7 +29,7 @@ import { import type { TranscriptPaginationState } from "@/domains/chat/lib/transcript/types.js"; import type { ContextWindowUsage } from "@/domains/chat/components/context-window-indicator.js"; import type { DomainEvent } from "@/domains/messaging/turn-store.js"; -import type { InteractionEvent, InteractionState } from "@/domains/interactions/interaction-store.js"; +import { useInteractionStore } from "@/domains/interactions/interaction-store.js"; import type { ConversationListAction } from "@/domains/conversations/conversation-list-store.js"; import type { SubagentAction } from "@/domains/subagents/subagent-store.js"; import type { SubagentStatus } from "@/domains/chat/lib/event-types.js"; @@ -101,7 +101,7 @@ interface UseConversationHistoryParams { inputRef: MutableRefObject; draftsRef: MutableRefObject>; messagesRef: MutableRefObject; - interactionStateRef: MutableRefObject; + contextWindowUsageByConversationRef: MutableRefObject>; dismissedSurfaceIdsRef: MutableRefObject>; needsNewBubbleRef: MutableRefObject; @@ -124,7 +124,6 @@ interface UseConversationHistoryParams { setTranscriptPagination: Dispatch>>; setIsLoadingHistory: Dispatch>; setError: Dispatch>; - dispatchInteraction: Dispatch; setAutoGreetPending: Dispatch>; setContextWindowUsage: Dispatch>; setSuggestion: Dispatch>; @@ -180,7 +179,6 @@ export function useConversationHistory({ inputRef, draftsRef, messagesRef, - interactionStateRef, contextWindowUsageByConversationRef, dismissedSurfaceIdsRef, needsNewBubbleRef, @@ -201,7 +199,6 @@ export function useConversationHistory({ setTranscriptPagination, setIsLoadingHistory, setError, - dispatchInteraction, setAutoGreetPending, setContextWindowUsage, setSuggestion, @@ -254,7 +251,8 @@ export function useConversationHistory({ } // If the outgoing conversation has a pending interaction, mark it as // needing attention so the sidebar shows an alert icon. - if (interactionStateRef.current.pendingSecret || interactionStateRef.current.pendingConfirmation) { + const interactionSnapshot = useInteractionStore.getState(); + if (interactionSnapshot.pendingSecret || interactionSnapshot.pendingConfirmation) { dispatchConversationList({ type: "ADD_ATTENTION_KEY", key: outgoingKey }); } // Cache outgoing conversation's messages (LRU eviction) @@ -340,7 +338,7 @@ export function useConversationHistory({ isLoadingOlder: false, isPinnedToLatest: true, }); - dispatchInteraction({ type: "RESET_ALL" }); + useInteractionStore.getState().resetAll(); confirmationToolCallMapRef.current.clear(); setAutoGreetPending(false); resetChatAttachments(); @@ -394,7 +392,7 @@ export function useConversationHistory({ interactions.pendingSecret as Record, ); if (loadEpochRef.current === epoch) { - dispatchInteraction({ type: "SHOW_SECRET", payload: parsed }); + useInteractionStore.getState().showSecret(parsed); } } if (interactions.pendingConfirmation) { @@ -402,7 +400,7 @@ export function useConversationHistory({ interactions.pendingConfirmation as Record, ); if (loadEpochRef.current === epoch) { - dispatchInteraction({ type: "SHOW_CONFIRMATION", payload: state }); + useInteractionStore.getState().showConfirmation(state); } } if (!interactions.pendingSecret && !interactions.pendingConfirmation) { @@ -644,7 +642,6 @@ export function useConversationHistory({ inputRef, draftsRef, messagesRef, - interactionStateRef, contextWindowUsageByConversationRef, dismissedSurfaceIdsRef, needsNewBubbleRef, @@ -666,7 +663,6 @@ export function useConversationHistory({ setIsLoadingHistory, dispatchConversationList, setError, - dispatchInteraction, setAutoGreetPending, setContextWindowUsage, setSuggestion, diff --git a/apps/web/src/domains/chat/hooks/use-conversation-loader.ts b/apps/web/src/domains/chat/hooks/use-conversation-loader.ts index f689c706f4a..fef3caf59d7 100644 --- a/apps/web/src/domains/chat/hooks/use-conversation-loader.ts +++ b/apps/web/src/domains/chat/hooks/use-conversation-loader.ts @@ -33,7 +33,7 @@ import { import type { TranscriptPaginationState } from "@/domains/chat/lib/transcript/types.js"; import type { ContextWindowUsage } from "@/domains/chat/components/context-window-indicator.js"; import type { DomainEvent } from "@/domains/messaging/turn-store.js"; -import type { InteractionEvent, InteractionState } from "@/domains/interactions/interaction-store.js"; + import type { ConversationListAction } from "@/domains/conversations/conversation-list-store.js"; import type { SubagentAction } from "@/domains/subagents/subagent-store.js"; import { haptic } from "@/utils/haptics.js"; @@ -104,7 +104,6 @@ interface UseConversationLoaderParams { draftsRef: MutableRefObject>; messagesRef: MutableRefObject; conversationsRef: MutableRefObject; - interactionStateRef: MutableRefObject; contextWindowUsageByConversationRef: MutableRefObject>; dismissedSurfaceIdsRef: MutableRefObject>; needsNewBubbleRef: MutableRefObject; @@ -131,7 +130,6 @@ interface UseConversationLoaderParams { setTranscriptPagination: Dispatch>>; setIsLoadingHistory: Dispatch>; setError: Dispatch>; - dispatchInteraction: Dispatch; setAutoGreetPending: Dispatch>; setContextWindowUsage: Dispatch>; setSuggestion: Dispatch>; @@ -200,7 +198,6 @@ export function useConversationLoader({ draftsRef, messagesRef, conversationsRef, - interactionStateRef, contextWindowUsageByConversationRef, dismissedSurfaceIdsRef, needsNewBubbleRef, @@ -225,7 +222,6 @@ export function useConversationLoader({ setTranscriptPagination, setIsLoadingHistory, setError, - dispatchInteraction, setAutoGreetPending, setContextWindowUsage, setSuggestion, @@ -447,7 +443,6 @@ export function useConversationLoader({ inputRef, draftsRef, messagesRef, - interactionStateRef, contextWindowUsageByConversationRef, dismissedSurfaceIdsRef, needsNewBubbleRef, @@ -468,7 +463,6 @@ export function useConversationLoader({ setTranscriptPagination, setIsLoadingHistory, setError, - dispatchInteraction, setAutoGreetPending, setContextWindowUsage, setSuggestion, diff --git a/apps/web/src/domains/chat/hooks/use-interaction-actions.ts b/apps/web/src/domains/chat/hooks/use-interaction-actions.ts index 3fcc76df20b..8021d5f5104 100644 --- a/apps/web/src/domains/chat/hooks/use-interaction-actions.ts +++ b/apps/web/src/domains/chat/hooks/use-interaction-actions.ts @@ -2,12 +2,13 @@ * Encapsulates all interaction-prompt action handlers: secret, confirmation, * contact-request, question-response, surface-action, and rule-editor flows. * - * Extracts ~530 lines of callback logic from `AssistantPageClient` into a - * single hook with a clean return surface. The hook is framework-agnostic - * aside from React's `useCallback` / `useState` — no Next.js imports. + * Each handler calls the interaction store's named actions directly + * (e.g. `submitSecretStart()`, `dismissConfirmation()`) instead of + * dispatching event objects. Non-reactive reads use + * `useInteractionStore.getState()` to avoid stale closures. * - * @see domains/interactions/interaction-store.ts — reducer that drives prompt state - * @see send-message-utils.ts — pure helpers reused here + * @see domains/interactions/interaction-store.ts — Zustand store for prompt state + * @see send-message-utils.ts — pure helpers reused here */ import * as Sentry from "@sentry/react"; @@ -27,7 +28,7 @@ import { } from "@/domains/chat/lib/api.js"; import { addTrustRule } from "@/domains/trust-rules/api.js"; import type { DisplayMessage } from "@/domains/chat/lib/reconcile.js"; -import type { InteractionState, InteractionEvent } from "@/domains/interactions/interaction-store.js"; +import { useInteractionStore } from "@/domains/interactions/interaction-store.js"; import type { ConversationListAction } from "@/domains/conversations/conversation-list-store.js"; import type { DomainEvent as TurnEvent } from "@/domains/messaging/turn-store.js"; @@ -73,9 +74,6 @@ export interface ToolCallRuleContext { // --------------------------------------------------------------------------- export interface UseInteractionActionsParams { - interactionState: InteractionState; - interactionStateRef: MutableRefObject; - dispatchInteraction: Dispatch; dispatchConversationList: Dispatch; dispatchTurn: Dispatch; setMessages: Dispatch DisplayMessage[])>; @@ -115,9 +113,6 @@ export interface UseInteractionActionsReturn { // --------------------------------------------------------------------------- export function useInteractionActions({ - interactionState, - interactionStateRef, - dispatchInteraction, dispatchConversationList, dispatchTurn, setMessages, @@ -127,16 +122,14 @@ export function useInteractionActions({ activeConversationKeyRef, confirmationToolCallMapRef, }: UseInteractionActionsParams): UseInteractionActionsReturn { - const { - pendingSecret, - isSubmittingSecret, - pendingConfirmation, - isSubmittingConfirmation, - pendingContactRequest, - isSubmittingContactRequest, - pendingQuestion, - isSubmittingQuestion, - } = interactionState; + const pendingSecret = useInteractionStore((s) => s.pendingSecret); + const isSubmittingSecret = useInteractionStore((s) => s.isSubmittingSecret); + const pendingConfirmation = useInteractionStore((s) => s.pendingConfirmation); + const isSubmittingConfirmation = useInteractionStore((s) => s.isSubmittingConfirmation); + const pendingContactRequest = useInteractionStore((s) => s.pendingContactRequest); + const isSubmittingContactRequest = useInteractionStore((s) => s.isSubmittingContactRequest); + const pendingQuestion = useInteractionStore((s) => s.pendingQuestion); + const isSubmittingQuestion = useInteractionStore((s) => s.isSubmittingQuestion); const [showRuleEditor, setShowRuleEditor] = useState(false); const [ruleEditorContext, setRuleEditorContext] = useState(null); @@ -150,13 +143,13 @@ export function useInteractionActions({ const handleSecretSubmit = useCallback( async (value: string, delivery: string = "store") => { if (!pendingSecret || isSubmittingSecret) return; - dispatchInteraction({ type: "SUBMIT_SECRET_START" }); + useInteractionStore.getState().submitSecretStart(); setError(null); const ctx = streamContextRef.current; if (!ctx) { setError({ message: "No active session. Please try again." }); - dispatchInteraction({ type: "SUBMIT_SECRET_END" }); + useInteractionStore.getState().submitSecretEnd(); return; } @@ -169,26 +162,26 @@ export function useInteractionActions({ ); if (!result.ok) { setError({ message: result.error }); - dispatchInteraction({ type: "SUBMIT_SECRET_END" }); + useInteractionStore.getState().submitSecretEnd(); return; } - dispatchInteraction({ type: "SUBMIT_SECRET_END", saved: true }); + useInteractionStore.getState().submitSecretEnd(true); const convKey = activeConversationKeyRef.current; if (convKey) { dispatchConversationList({ type: "REMOVE_ATTENTION_KEY", key: convKey }); } const savedRequestId = pendingSecret.requestId; setTimeout(() => { - const current = interactionStateRef.current.pendingSecret; + const current = useInteractionStore.getState().pendingSecret; if (current?.requestId === savedRequestId) { - dispatchInteraction({ type: "DISMISS_SECRET" }); + useInteractionStore.getState().dismissSecret(); } }, 1500); } catch (err) { Sentry.captureException(err, { tags: { context: "submit_secret" } }); setError({ message: "Failed to submit secret. Please try again." }); - dispatchInteraction({ type: "SUBMIT_SECRET_END" }); + useInteractionStore.getState().submitSecretEnd(); } }, [pendingSecret, isSubmittingSecret], @@ -196,11 +189,11 @@ export function useInteractionActions({ const handleSecretCancel = useCallback(() => { const ctx = streamContextRef.current; - const requestId = interactionStateRef.current.pendingSecret?.requestId; + const requestId = useInteractionStore.getState().pendingSecret?.requestId; if (ctx && requestId) { submitSecretResponse(ctx.assistantId, requestId, "", "none").catch(() => {}); } - dispatchInteraction({ type: "DISMISS_SECRET" }); + useInteractionStore.getState().dismissSecret(); const convKey = activeConversationKeyRef.current; if (convKey) { dispatchConversationList({ type: "REMOVE_ATTENTION_KEY", key: convKey }); @@ -215,13 +208,13 @@ export function useInteractionActions({ const handleContactPromptSubmit = useCallback( async (address: string, channelType: string) => { if (!pendingContactRequest || isSubmittingContactRequest) return; - dispatchInteraction({ type: "SUBMIT_CONTACT_REQUEST_START" }); + useInteractionStore.getState().submitContactRequestStart(); setError(null); const ctx = streamContextRef.current; if (!ctx) { setError({ message: "No active session. Please try again." }); - dispatchInteraction({ type: "SUBMIT_CONTACT_REQUEST_END" }); + useInteractionStore.getState().submitContactRequestEnd(); return; } @@ -235,29 +228,29 @@ export function useInteractionActions({ ); if (!result.ok) { setError({ message: result.error }); - dispatchInteraction({ type: "SUBMIT_CONTACT_REQUEST_END" }); + useInteractionStore.getState().submitContactRequestEnd(); return; } - dispatchInteraction({ type: "ACCEPT_CONTACT_REQUEST" }); + useInteractionStore.getState().acceptContactRequest(); const savedRequestId = pendingContactRequest.requestId; setTimeout(() => { - const current = interactionStateRef.current.pendingContactRequest; + const current = useInteractionStore.getState().pendingContactRequest; if (current?.requestId === savedRequestId) { - dispatchInteraction({ type: "DISMISS_CONTACT_REQUEST" }); + useInteractionStore.getState().dismissContactRequest(); } }, 1500); } catch (err) { Sentry.captureException(err, { tags: { context: "submit_contact_prompt" } }); setError({ message: "Failed to save contact. Please try again." }); - dispatchInteraction({ type: "SUBMIT_CONTACT_REQUEST_END" }); + useInteractionStore.getState().submitContactRequestEnd(); } }, [pendingContactRequest, isSubmittingContactRequest, streamContextRef], ); const handleContactPromptCancel = useCallback(() => { - dispatchInteraction({ type: "DISMISS_CONTACT_REQUEST" }); + useInteractionStore.getState().dismissContactRequest(); dispatchTurn({ type: "STREAM_ERROR" }); }, []); @@ -274,8 +267,8 @@ export function useInteractionActions({ const cleanupAfterConfirmationDecision = useCallback( (snapshot: NonNullable, mappedToolCallId: string | undefined, decision: ConfirmationDecision) => { const confirmationDecisionValue = decision === "allow" ? "approved" : "denied"; - dispatchInteraction({ type: "DISMISS_CONFIRMATION" }); - dispatchInteraction({ type: "SET_INLINE_CONFIRMATION_TOOL_CALL_ID", toolCallId: null }); + useInteractionStore.getState().dismissConfirmation(); + useInteractionStore.getState().setInlineConfirmationToolCallId(null); const convKey = activeConversationKeyRef.current; if (convKey) { dispatchConversationList({ type: "REMOVE_ATTENTION_KEY", key: convKey }); @@ -380,7 +373,7 @@ export function useInteractionActions({ } confirmationToolCallMapRef.current.delete(snapshot.requestId); - dispatchInteraction({ type: "SUBMIT_CONFIRMATION_END" }); + useInteractionStore.getState().submitConfirmationEnd(); }, [], ); @@ -389,13 +382,13 @@ export function useInteractionActions({ async (decision: ConfirmationDecision) => { const snapshot = pendingConfirmation; if (!pendingConfirmation || isSubmittingConfirmation) return; - dispatchInteraction({ type: "SUBMIT_CONFIRMATION_START" }); + useInteractionStore.getState().submitConfirmationStart(); setError(null); const ctx = streamContextRef.current; if (!ctx) { setError({ message: "No active session. Please try again." }); - dispatchInteraction({ type: "SUBMIT_CONFIRMATION_END" }); + useInteractionStore.getState().submitConfirmationEnd(); return; } @@ -422,7 +415,7 @@ export function useInteractionActions({ if (!result.ok) { setError({ message: result.error }); - dispatchInteraction({ type: "SUBMIT_CONFIRMATION_END" }); + useInteractionStore.getState().submitConfirmationEnd(); return; } cleanupAfterConfirmationDecision(snapshot!, mappedToolCallId, decision); @@ -437,14 +430,14 @@ export function useInteractionActions({ if (!result.ok) { setError({ message: result.error }); - dispatchInteraction({ type: "SUBMIT_CONFIRMATION_END" }); + useInteractionStore.getState().submitConfirmationEnd(); return; } cleanupAfterConfirmationDecision(snapshot!, mappedToolCallId, decision); } catch (err) { Sentry.captureException(err, { tags: { context: "submit_confirmation" } }); setError({ message: "Failed to submit confirmation. Please try again." }); - dispatchInteraction({ type: "SUBMIT_CONFIRMATION_END" }); + useInteractionStore.getState().submitConfirmationEnd(); } }, [pendingConfirmation, isSubmittingConfirmation, cleanupAfterConfirmationDecision], @@ -458,13 +451,13 @@ export function useInteractionActions({ async (responses: QuestionResponseEntry[]) => { const snapshot = pendingQuestion; if (!snapshot || isSubmittingQuestion) return; - dispatchInteraction({ type: "SUBMIT_QUESTION_START" }); + useInteractionStore.getState().submitQuestionStart(); setError(null); const ctx = streamContextRef.current; if (!ctx) { setError({ message: "No active session. Please try again." }); - dispatchInteraction({ type: "SUBMIT_QUESTION_END" }); + useInteractionStore.getState().submitQuestionEnd(); return; } @@ -476,24 +469,24 @@ export function useInteractionActions({ ); if (!result.ok) { setError({ message: result.error }); - dispatchInteraction({ type: "SUBMIT_QUESTION_END" }); + useInteractionStore.getState().submitQuestionEnd(); return; } // Guard against an SSE-driven `question_request` that lands while // our POST is in flight: only clear the prompt if the snapshot we // submitted is still the current one. - if (interactionStateRef.current.pendingQuestion?.requestId === snapshot.requestId) { - dispatchInteraction({ type: "DISMISS_QUESTION" }); + if (useInteractionStore.getState().pendingQuestion?.requestId === snapshot.requestId) { + useInteractionStore.getState().dismissQuestion(); } else { - dispatchInteraction({ type: "SUBMIT_QUESTION_END" }); + useInteractionStore.getState().submitQuestionEnd(); } } catch (err) { Sentry.captureException(err, { tags: { context: "submit_question_response" } }); setError({ message: "Failed to submit response. Please try again." }); - dispatchInteraction({ type: "SUBMIT_QUESTION_END" }); + useInteractionStore.getState().submitQuestionEnd(); } }, - [pendingQuestion, isSubmittingQuestion, dispatchInteraction], + [pendingQuestion, isSubmittingQuestion], ); // ------------------------------------------------------------------------- @@ -509,7 +502,7 @@ export function useInteractionActions({ } const snapshot = pendingConfirmation; - dispatchInteraction({ type: "SUBMIT_CONFIRMATION_START" }); + useInteractionStore.getState().submitConfirmationStart(); const mappedToolCallId = confirmationToolCallMapRef.current.get(snapshot.requestId); @@ -533,8 +526,8 @@ export function useInteractionActions({ if (!result.ok) { setError({ message: result.error }); - dispatchInteraction({ type: "SUBMIT_CONFIRMATION_END" }); - dispatchInteraction({ type: "SET_INLINE_CONFIRMATION_TOOL_CALL_ID", toolCallId: null }); + useInteractionStore.getState().submitConfirmationEnd(); + useInteractionStore.getState().setInlineConfirmationToolCallId(null); setMessages((prev: DisplayMessage[]) => clearConfirmationByRequestId(prev, snapshot.requestId)); setRuleEditorContext(editorContext); setShowRuleEditor(true); @@ -547,12 +540,12 @@ export function useInteractionActions({ setShowRuleEditor(true); } catch (err) { Sentry.captureException(err, { tags: { context: "allow_and_create_rule" } }); - dispatchInteraction({ type: "SET_INLINE_CONFIRMATION_TOOL_CALL_ID", toolCallId: null }); + useInteractionStore.getState().setInlineConfirmationToolCallId(null); setMessages((prev: DisplayMessage[]) => clearConfirmationByRequestId(prev, snapshot.requestId)); setRuleEditorContext(editorContext); setShowRuleEditor(true); setError({ message: "Failed to submit confirmation, but you can still create a rule." }); - dispatchInteraction({ type: "SUBMIT_CONFIRMATION_END" }); + useInteractionStore.getState().submitConfirmationEnd(); } }, [pendingConfirmation, isSubmittingConfirmation, cleanupAfterConfirmationDecision]); @@ -602,7 +595,7 @@ export function useInteractionActions({ } setIsSavingRule(true); - dispatchInteraction({ type: "SUBMIT_CONFIRMATION_START" }); + useInteractionStore.getState().submitConfirmationStart(); try { const result = await submitConfirmation( ctx.assistantId, @@ -625,11 +618,11 @@ export function useInteractionActions({ return; } finally { setIsSavingRule(false); - dispatchInteraction({ type: "SUBMIT_CONFIRMATION_END" }); + useInteractionStore.getState().submitConfirmationEnd(); } - dispatchInteraction({ type: "DISMISS_CONFIRMATION_IF_MATCHES", requestId: context.requestId }); - dispatchInteraction({ type: "SET_INLINE_CONFIRMATION_TOOL_CALL_ID", toolCallId: null }); + useInteractionStore.getState().dismissConfirmationIfMatches(context.requestId); + useInteractionStore.getState().setInlineConfirmationToolCallId(null); confirmationToolCallMapRef.current.delete(context.requestId); setMessages((prev: DisplayMessage[]) => clearConfirmationByRequestId(prev, context.requestId)); setShowRuleEditor(false); diff --git a/apps/web/src/domains/chat/hooks/use-send-message.ts b/apps/web/src/domains/chat/hooks/use-send-message.ts index 2feeef3f9e1..d788877793e 100644 --- a/apps/web/src/domains/chat/hooks/use-send-message.ts +++ b/apps/web/src/domains/chat/hooks/use-send-message.ts @@ -40,7 +40,7 @@ import { recordChatDiagnostic } from "@/domains/chat/lib/diagnostics.js"; import { newStableId } from "@/domains/chat/lib/stable-id.js"; import { saveDismissedSurfaceIds } from "@/domains/chat/lib/dismissedSurfacesStorage.js"; import { isSending, type TurnState, type DomainEvent } from "@/domains/messaging/turn-store.js"; -import type { InteractionEvent } from "@/domains/interactions/interaction-store.js"; +import { useInteractionStore } from "@/domains/interactions/interaction-store.js"; import type { ConversationListAction } from "@/domains/conversations/conversation-list-store.js"; import type { SubagentAction } from "@/domains/subagents/subagent-store.js"; import type { PreChatOnboardingContext } from "@/lib/onboarding/prechat.js"; @@ -109,7 +109,6 @@ interface UseSendMessageParams { setMessages: Dispatch>; setError: Dispatch>; dispatchConversationList: Dispatch; - dispatchInteraction: Dispatch; setStreamRetryNonce: Dispatch>; setInput: Dispatch>; dispatchTurn: Dispatch; @@ -156,7 +155,6 @@ export function useSendMessage({ setMessages, setError, dispatchConversationList, - dispatchInteraction, setStreamRetryNonce, setInput, dispatchTurn, @@ -319,13 +317,13 @@ export function useSendMessage({ ); if (!isCurrentSendScope(effectiveConversationKey)) return; if (interactions.pendingSecret) { - dispatchInteraction({ type: "SHOW_SECRET", payload: parsePendingSecretState(interactions.pendingSecret) }); + useInteractionStore.getState().showSecret(parsePendingSecretState(interactions.pendingSecret)); if (!reply) return; } if (interactions.pendingConfirmation) { const { confData, state } = parsePendingConfirmationData(interactions.pendingConfirmation); restoredConfData = confData; - dispatchInteraction({ type: "SHOW_CONFIRMATION", payload: state }); + useInteractionStore.getState().showConfirmation(state); if (!reply) return; } } catch { @@ -384,10 +382,10 @@ export function useSendMessage({ if (!isCurrentSendScope(effectiveConversationKey)) return prev; const result = attachConfirmationToToolCall(prev, capturedConfData); if (result.attachedToolCallId) { - dispatchInteraction({ type: "SET_INLINE_CONFIRMATION_TOOL_CALL_ID", toolCallId: result.attachedToolCallId }); + useInteractionStore.getState().setInlineConfirmationToolCallId(result.attachedToolCallId); confirmationToolCallMapRef.current.set(capturedConfData.requestId, result.attachedToolCallId); } else { - dispatchInteraction({ type: "SET_INLINE_CONFIRMATION_TOOL_CALL_ID", toolCallId: null }); + useInteractionStore.getState().setInlineConfirmationToolCallId(null); } return result.updatedMessages; }); @@ -426,7 +424,7 @@ export function useSendMessage({ return; } setError(null); - dispatchInteraction({ type: "RESET_SECRET_AND_CONFIRMATION" }); + useInteractionStore.getState().resetSecretAndConfirmation(); confirmationToolCallMapRef.current.clear(); // Clear pending confirmations and dismiss interactive surfaces in a // single functional updater so the two transforms compose correctly @@ -610,7 +608,7 @@ export function useSendMessage({ dispatchTurn({ type: "GENERATION_CANCELLED" }); setMessages(stopStreamingAndClearConfirmations); needsNewBubbleRef.current = true; - dispatchInteraction({ type: "RESET_ALL" }); + useInteractionStore.getState().resetAll(); dispatchSubagent({ type: "SUBAGENT_RESET" }); confirmationToolCallMapRef.current.clear(); dispatchConversationList({ type: "REMOVE_PROCESSING_KEY", key: activeConversationKey }); diff --git a/apps/web/src/domains/chat/hooks/use-stream-event-handler.ts b/apps/web/src/domains/chat/hooks/use-stream-event-handler.ts index c1641418499..0523cf9da75 100644 --- a/apps/web/src/domains/chat/hooks/use-stream-event-handler.ts +++ b/apps/web/src/domains/chat/hooks/use-stream-event-handler.ts @@ -8,7 +8,6 @@ import { } from "react"; import { useQueryClient } from "@tanstack/react-query"; -import type { InteractionEvent } from "@/domains/interactions/interaction-store.js"; import type { SubagentAction } from "@/domains/subagents/subagent-store.js"; import type { ConversationListAction } from "@/domains/conversations/conversation-list-store.js"; import type { @@ -118,7 +117,6 @@ export interface UseStreamEventHandlerParams { startReconciliationLoop: (epoch: number) => void; // --- Interaction state (secret, confirmation, contact request) --- - dispatchInteraction: Dispatch; confirmationToolCallMapRef: MutableRefObject>; // --- Subagent state --- @@ -199,7 +197,6 @@ export function useStreamEventHandler( streamRef, cancelReconciliation, startReconciliationLoop, - dispatchInteraction, confirmationToolCallMapRef, dispatchSubagent, @@ -308,7 +305,6 @@ export function useStreamEventHandler( streamRef, cancelReconciliation, startReconciliationLoop, - dispatchInteraction, confirmationToolCallMapRef, dispatchSubagent, @@ -482,7 +478,6 @@ export function useStreamEventHandler( turnStateRef, setError, streamRef, - dispatchInteraction, confirmationToolCallMapRef, dispatchSubagent, setAssetsRefreshKey, diff --git a/apps/web/src/domains/chat/utils/stream-handlers/interaction-handlers.test.ts b/apps/web/src/domains/chat/utils/stream-handlers/interaction-handlers.test.ts index 8519082c02c..2f16ce5ef0f 100644 --- a/apps/web/src/domains/chat/utils/stream-handlers/interaction-handlers.test.ts +++ b/apps/web/src/domains/chat/utils/stream-handlers/interaction-handlers.test.ts @@ -1,5 +1,6 @@ -import { describe, expect, it } from "bun:test"; +import { describe, expect, it, beforeEach } from "bun:test"; +import { useInteractionStore } from "@/domains/interactions/interaction-store.js"; import { makeCtx } from "@/domains/chat/utils/stream-handlers/test-helpers.js"; import { handleSecretRequest, @@ -7,8 +8,12 @@ import { handleContactRequest, } from "@/domains/chat/utils/stream-handlers/interaction-handlers.js"; +beforeEach(() => { + useInteractionStore.getState().resetAll(); +}); + describe("handleSecretRequest", () => { - it("dispatches SECRET_REQUEST and SHOW_SECRET", () => { + it("dispatches SECRET_REQUEST turn event and updates interaction store", () => { const ctx = makeCtx(); handleSecretRequest( { type: "secret_request", requestId: "sr-1", label: "API Key" }, @@ -17,17 +22,13 @@ describe("handleSecretRequest", () => { expect(ctx.dispatchTurn).toHaveBeenCalledWith({ type: "SECRET_REQUEST", }); - expect(ctx.dispatchInteraction).toHaveBeenCalledWith( - expect.objectContaining({ - type: "SHOW_SECRET", - payload: expect.objectContaining({ requestId: "sr-1" }), - }), - ); + const state = useInteractionStore.getState(); + expect(state.pendingSecret).toMatchObject({ requestId: "sr-1", label: "API Key" }); }); }); describe("handleConfirmationRequest", () => { - it("dispatches CONFIRMATION_REQUEST and SHOW_CONFIRMATION", () => { + it("dispatches CONFIRMATION_REQUEST turn event and updates interaction store", () => { const ctx = makeCtx(); handleConfirmationRequest( { type: "confirmation_request", requestId: "cr-1", title: "Allow?" }, @@ -36,15 +37,14 @@ describe("handleConfirmationRequest", () => { expect(ctx.dispatchTurn).toHaveBeenCalledWith({ type: "CONFIRMATION_REQUEST", }); - expect(ctx.dispatchInteraction).toHaveBeenCalledWith( - expect.objectContaining({ type: "SHOW_CONFIRMATION" }), - ); + const state = useInteractionStore.getState(); + expect(state.pendingConfirmation).toMatchObject({ requestId: "cr-1" }); expect(ctx.setMessages).toHaveBeenCalled(); }); }); describe("handleContactRequest", () => { - it("dispatches CONTACT_REQUEST and SHOW_CONTACT_REQUEST", () => { + it("dispatches CONTACT_REQUEST turn event and updates interaction store", () => { const ctx = makeCtx(); handleContactRequest( { type: "contact_request", requestId: "ctc-1", channel: "email" }, @@ -53,11 +53,7 @@ describe("handleContactRequest", () => { expect(ctx.dispatchTurn).toHaveBeenCalledWith({ type: "CONTACT_REQUEST", }); - expect(ctx.dispatchInteraction).toHaveBeenCalledWith( - expect.objectContaining({ - type: "SHOW_CONTACT_REQUEST", - payload: expect.objectContaining({ requestId: "ctc-1" }), - }), - ); + const state = useInteractionStore.getState(); + expect(state.pendingContactRequest).toMatchObject({ requestId: "ctc-1" }); }); }); diff --git a/apps/web/src/domains/chat/utils/stream-handlers/interaction-handlers.ts b/apps/web/src/domains/chat/utils/stream-handlers/interaction-handlers.ts index 4e94c3d9507..87e12c7062c 100644 --- a/apps/web/src/domains/chat/utils/stream-handlers/interaction-handlers.ts +++ b/apps/web/src/domains/chat/utils/stream-handlers/interaction-handlers.ts @@ -7,6 +7,7 @@ import type { import { normalizeQuestionRequest } from "@/domains/chat/lib/api.js"; import { attachConfirmationToToolCall } from "@/domains/chat/utils/chat-utils.js"; import type { PendingConfirmationState } from "@/domains/chat/types.js"; +import { useInteractionStore } from "@/domains/interactions/interaction-store.js"; import type { StreamHandlerContext } from "@/domains/chat/utils/stream-handlers/types.js"; export function handleSecretRequest( @@ -14,18 +15,15 @@ export function handleSecretRequest( ctx: StreamHandlerContext, ): void { ctx.dispatchTurn({ type: "SECRET_REQUEST" }); - ctx.dispatchInteraction({ - type: "SHOW_SECRET", - payload: { - requestId: event.requestId, - label: event.label, - description: event.description, - placeholder: event.placeholder, - allowOneTimeSend: event.allowOneTimeSend, - allowedTools: event.allowedTools, - allowedDomains: event.allowedDomains, - purpose: event.purpose, - }, + useInteractionStore.getState().showSecret({ + requestId: event.requestId, + label: event.label, + description: event.description, + placeholder: event.placeholder, + allowOneTimeSend: event.allowOneTimeSend, + allowedTools: event.allowedTools, + allowedDomains: event.allowedDomains, + purpose: event.purpose, }); } @@ -50,25 +48,19 @@ export function handleConfirmationRequest( input: event.input, toolUseId: event.toolUseId, }; - ctx.dispatchInteraction({ type: "SHOW_CONFIRMATION", payload: confData }); + useInteractionStore.getState().showConfirmation(confData); const result = attachConfirmationToToolCall(ctx.messagesRef.current, confData); ctx.setMessages(() => result.updatedMessages); if (result.attachedToolCallId) { - ctx.dispatchInteraction({ - type: "SET_INLINE_CONFIRMATION_TOOL_CALL_ID", - toolCallId: result.attachedToolCallId, - }); + useInteractionStore.getState().setInlineConfirmationToolCallId(result.attachedToolCallId); ctx.confirmationToolCallMapRef.current.set( confData.requestId, result.attachedToolCallId, ); } else { - ctx.dispatchInteraction({ - type: "SET_INLINE_CONFIRMATION_TOOL_CALL_ID", - toolCallId: null, - }); + useInteractionStore.getState().setInlineConfirmationToolCallId(null); } } @@ -77,16 +69,13 @@ export function handleContactRequest( ctx: StreamHandlerContext, ): void { ctx.dispatchTurn({ type: "CONTACT_REQUEST" }); - ctx.dispatchInteraction({ - type: "SHOW_CONTACT_REQUEST", - payload: { - requestId: event.requestId, - channel: event.channel, - placeholder: event.placeholder, - label: event.label, - description: event.description, - role: event.role, - }, + useInteractionStore.getState().showContactRequest({ + requestId: event.requestId, + channel: event.channel, + placeholder: event.placeholder, + label: event.label, + description: event.description, + role: event.role, }); } @@ -97,12 +86,9 @@ export function handleQuestionRequest( const entries = normalizeQuestionRequest(event); if (entries.length === 0) return; ctx.dispatchTurn({ type: "QUESTION_REQUEST" }); - ctx.dispatchInteraction({ - type: "SHOW_QUESTION", - payload: { - requestId: event.requestId, - entries, - toolUseId: event.toolUseId, - }, + useInteractionStore.getState().showQuestion({ + requestId: event.requestId, + entries, + toolUseId: event.toolUseId, }); } diff --git a/apps/web/src/domains/chat/utils/stream-handlers/test-helpers.ts b/apps/web/src/domains/chat/utils/stream-handlers/test-helpers.ts index 3179ac64aed..aaa1f04ce64 100644 --- a/apps/web/src/domains/chat/utils/stream-handlers/test-helpers.ts +++ b/apps/web/src/domains/chat/utils/stream-handlers/test-helpers.ts @@ -25,7 +25,6 @@ export function makeCtx( streamRef: { current: { cancel: mock(() => {}) } as never }, cancelReconciliation: mock(() => {}), startReconciliationLoop: mock(() => {}), - dispatchInteraction: mock(() => {}), confirmationToolCallMapRef: { current: new Map() }, setAssetsRefreshKey: mock(() => {}), dismissedSurfaceIdsRef: { current: new Set() }, diff --git a/apps/web/src/domains/chat/utils/stream-handlers/types.ts b/apps/web/src/domains/chat/utils/stream-handlers/types.ts index d4d2cc21dc0..dd32a1bb9f1 100644 --- a/apps/web/src/domains/chat/utils/stream-handlers/types.ts +++ b/apps/web/src/domains/chat/utils/stream-handlers/types.ts @@ -4,7 +4,6 @@ import type { SetStateAction, } from "react"; -import type { InteractionEvent } from "@/domains/interactions/interaction-store.js"; import type { SubagentAction } from "@/domains/subagents/subagent-store.js"; import type { ChatEventStream } from "@/domains/chat/lib/api.js"; import type { ConversationListAction } from "@/domains/conversations/conversation-list-store.js"; @@ -67,7 +66,6 @@ export interface StreamHandlerContext { startReconciliationLoop: (epoch: number) => void; // --- Interaction state --- - dispatchInteraction: Dispatch; confirmationToolCallMapRef: MutableRefObject>; // --- Subagent state --- diff --git a/apps/web/src/domains/interactions/interaction-store.test.ts b/apps/web/src/domains/interactions/interaction-store.test.ts index a5508aa82e9..2917efc6b5e 100644 --- a/apps/web/src/domains/interactions/interaction-store.test.ts +++ b/apps/web/src/domains/interactions/interaction-store.test.ts @@ -1,450 +1,239 @@ -import { describe, expect, it } from "bun:test"; +import { beforeEach, describe, expect, it } from "bun:test"; import { - INITIAL_INTERACTION_STATE, + useInteractionStore, hasActiveInteraction, - interactionReducer, - type InteractionState, } from "@/domains/interactions/interaction-store.js"; -describe("interactionReducer", () => { +// Reset store between tests to avoid cross-contamination +beforeEach(() => { + useInteractionStore.getState().resetAll(); +}); + +describe("useInteractionStore", () => { // ----- Secret flow ----- describe("secret flow", () => { - it("SHOW_SECRET sets pendingSecret and resets submit/saved flags", () => { + it("showSecret sets pendingSecret and resets submit/saved flags", () => { const payload = { requestId: "r1", label: "API Key" }; - const next = interactionReducer(INITIAL_INTERACTION_STATE, { - type: "SHOW_SECRET", - payload, - }); - expect(next.pendingSecret).toEqual(payload); - expect(next.isSubmittingSecret).toBe(false); - expect(next.secretSaved).toBe(false); + useInteractionStore.getState().showSecret(payload); + const s = useInteractionStore.getState(); + expect(s.pendingSecret).toEqual(payload); + expect(s.isSubmittingSecret).toBe(false); + expect(s.secretSaved).toBe(false); }); - it("SUBMIT_SECRET_START sets isSubmittingSecret", () => { - const state: InteractionState = { - ...INITIAL_INTERACTION_STATE, - pendingSecret: { requestId: "r1" }, - }; - const next = interactionReducer(state, { type: "SUBMIT_SECRET_START" }); - expect(next.isSubmittingSecret).toBe(true); + it("submitSecretStart sets isSubmittingSecret", () => { + useInteractionStore.getState().showSecret({ requestId: "r1" }); + useInteractionStore.getState().submitSecretStart(); + expect(useInteractionStore.getState().isSubmittingSecret).toBe(true); }); - it("SUBMIT_SECRET_END clears isSubmittingSecret and sets saved flag", () => { - const state: InteractionState = { - ...INITIAL_INTERACTION_STATE, - pendingSecret: { requestId: "r1" }, - isSubmittingSecret: true, - }; - const next = interactionReducer(state, { - type: "SUBMIT_SECRET_END", - saved: true, - }); - expect(next.isSubmittingSecret).toBe(false); - expect(next.secretSaved).toBe(true); + it("submitSecretEnd clears isSubmittingSecret and sets saved flag", () => { + useInteractionStore.getState().showSecret({ requestId: "r1" }); + useInteractionStore.getState().submitSecretStart(); + useInteractionStore.getState().submitSecretEnd(true); + const s = useInteractionStore.getState(); + expect(s.isSubmittingSecret).toBe(false); + expect(s.secretSaved).toBe(true); }); - it("DISMISS_SECRET clears pendingSecret and isSubmittingSecret", () => { - const state: InteractionState = { - ...INITIAL_INTERACTION_STATE, - pendingSecret: { requestId: "r1" }, - isSubmittingSecret: true, - }; - const next = interactionReducer(state, { type: "DISMISS_SECRET" }); - expect(next.pendingSecret).toBeNull(); - expect(next.isSubmittingSecret).toBe(false); + it("dismissSecret clears pendingSecret and isSubmittingSecret", () => { + useInteractionStore.getState().showSecret({ requestId: "r1" }); + useInteractionStore.getState().submitSecretStart(); + useInteractionStore.getState().dismissSecret(); + const s = useInteractionStore.getState(); + expect(s.pendingSecret).toBeNull(); + expect(s.isSubmittingSecret).toBe(false); }); - it("UPDATE_SECRET applies patch when requestId matches", () => { - const state: InteractionState = { - ...INITIAL_INTERACTION_STATE, - pendingSecret: { requestId: "r1", label: "old" }, - }; - const next = interactionReducer(state, { - type: "UPDATE_SECRET", - requestId: "r1", - patch: { label: "new" }, - }); - expect(next.pendingSecret?.label).toBe("new"); + it("updateSecret applies patch when requestId matches", () => { + useInteractionStore.getState().showSecret({ requestId: "r1", label: "old" }); + useInteractionStore.getState().updateSecret("r1", { label: "new" }); + expect(useInteractionStore.getState().pendingSecret?.label).toBe("new"); }); - it("UPDATE_SECRET is no-op when requestId does not match", () => { - const state: InteractionState = { - ...INITIAL_INTERACTION_STATE, - pendingSecret: { requestId: "r1", label: "old" }, - }; - const next = interactionReducer(state, { - type: "UPDATE_SECRET", - requestId: "r2", - patch: { label: "new" }, - }); - expect(next).toBe(state); + it("updateSecret is a no-op when requestId does not match", () => { + useInteractionStore.getState().showSecret({ requestId: "r1", label: "old" }); + useInteractionStore.getState().updateSecret("r2", { label: "new" }); + expect(useInteractionStore.getState().pendingSecret?.label).toBe("old"); }); - it("UPDATE_SECRET is no-op when no pending secret", () => { - const next = interactionReducer(INITIAL_INTERACTION_STATE, { - type: "UPDATE_SECRET", - requestId: "r1", - patch: { label: "new" }, - }); - expect(next).toBe(INITIAL_INTERACTION_STATE); + it("updateSecret is a no-op when pendingSecret is null", () => { + useInteractionStore.getState().updateSecret("r1", { label: "new" }); + expect(useInteractionStore.getState().pendingSecret).toBeNull(); }); }); // ----- Confirmation flow ----- describe("confirmation flow", () => { - it("SHOW_CONFIRMATION sets pendingConfirmation", () => { - const payload = { requestId: "c1", title: "Allow?" }; - const next = interactionReducer(INITIAL_INTERACTION_STATE, { - type: "SHOW_CONFIRMATION", - payload, - }); - expect(next.pendingConfirmation).toEqual(payload); - expect(next.isSubmittingConfirmation).toBe(false); - }); - - it("SUBMIT_CONFIRMATION_START / END toggle flag", () => { - const state: InteractionState = { - ...INITIAL_INTERACTION_STATE, - pendingConfirmation: { requestId: "c1" }, - }; - const started = interactionReducer(state, { - type: "SUBMIT_CONFIRMATION_START", - }); - expect(started.isSubmittingConfirmation).toBe(true); - - const ended = interactionReducer(started, { - type: "SUBMIT_CONFIRMATION_END", - }); - expect(ended.isSubmittingConfirmation).toBe(false); - }); - - it("DISMISS_CONFIRMATION clears pendingConfirmation", () => { - const state: InteractionState = { - ...INITIAL_INTERACTION_STATE, - pendingConfirmation: { requestId: "c1" }, - isSubmittingConfirmation: true, - }; - const next = interactionReducer(state, { - type: "DISMISS_CONFIRMATION", - }); - expect(next.pendingConfirmation).toBeNull(); - expect(next.isSubmittingConfirmation).toBe(false); + it("showConfirmation sets pendingConfirmation", () => { + const payload = { requestId: "c1", title: "Deploy?" }; + useInteractionStore.getState().showConfirmation(payload); + const s = useInteractionStore.getState(); + expect(s.pendingConfirmation).toEqual(payload); + expect(s.isSubmittingConfirmation).toBe(false); }); - it("DISMISS_CONFIRMATION_IF_MATCHES clears when requestId matches", () => { - const state: InteractionState = { - ...INITIAL_INTERACTION_STATE, - pendingConfirmation: { requestId: "c1" }, - isSubmittingConfirmation: true, - }; - const next = interactionReducer(state, { - type: "DISMISS_CONFIRMATION_IF_MATCHES", - requestId: "c1", - }); - expect(next.pendingConfirmation).toBeNull(); - expect(next.isSubmittingConfirmation).toBe(false); + it("submitConfirmationStart/End cycle", () => { + useInteractionStore.getState().showConfirmation({ requestId: "c1" }); + useInteractionStore.getState().submitConfirmationStart(); + expect(useInteractionStore.getState().isSubmittingConfirmation).toBe(true); + useInteractionStore.getState().submitConfirmationEnd(); + expect(useInteractionStore.getState().isSubmittingConfirmation).toBe(false); }); - it("DISMISS_CONFIRMATION_IF_MATCHES is no-op when requestId does not match", () => { - const state: InteractionState = { - ...INITIAL_INTERACTION_STATE, - pendingConfirmation: { requestId: "c1" }, - isSubmittingConfirmation: true, - }; - const next = interactionReducer(state, { - type: "DISMISS_CONFIRMATION_IF_MATCHES", - requestId: "c2", - }); - expect(next).toBe(state); + it("dismissConfirmation clears state", () => { + useInteractionStore.getState().showConfirmation({ requestId: "c1" }); + useInteractionStore.getState().submitConfirmationStart(); + useInteractionStore.getState().dismissConfirmation(); + const s = useInteractionStore.getState(); + expect(s.pendingConfirmation).toBeNull(); + expect(s.isSubmittingConfirmation).toBe(false); }); - it("DISMISS_CONFIRMATION_IF_MATCHES is no-op when no pending confirmation", () => { - const next = interactionReducer(INITIAL_INTERACTION_STATE, { - type: "DISMISS_CONFIRMATION_IF_MATCHES", - requestId: "c1", - }); - expect(next).toBe(INITIAL_INTERACTION_STATE); + it("dismissConfirmationIfMatches clears when requestId matches", () => { + useInteractionStore.getState().showConfirmation({ requestId: "c1" }); + useInteractionStore.getState().dismissConfirmationIfMatches("c1"); + expect(useInteractionStore.getState().pendingConfirmation).toBeNull(); }); - it("UPDATE_CONFIRMATION applies patch when requestId matches", () => { - const state: InteractionState = { - ...INITIAL_INTERACTION_STATE, - pendingConfirmation: { requestId: "c1", title: "old" }, - }; - const next = interactionReducer(state, { - type: "UPDATE_CONFIRMATION", - requestId: "c1", - patch: { title: "new" }, - }); - expect(next.pendingConfirmation?.title).toBe("new"); + it("dismissConfirmationIfMatches is a no-op when requestId does not match", () => { + useInteractionStore.getState().showConfirmation({ requestId: "c1" }); + useInteractionStore.getState().dismissConfirmationIfMatches("c2"); + expect(useInteractionStore.getState().pendingConfirmation).not.toBeNull(); }); - it("UPDATE_CONFIRMATION is no-op when requestId does not match", () => { - const state: InteractionState = { - ...INITIAL_INTERACTION_STATE, - pendingConfirmation: { requestId: "c1", title: "old" }, - }; - const next = interactionReducer(state, { - type: "UPDATE_CONFIRMATION", - requestId: "c2", - patch: { title: "new" }, - }); - expect(next).toBe(state); + it("updateConfirmation applies patch when requestId matches", () => { + useInteractionStore.getState().showConfirmation({ requestId: "c1", title: "old" }); + useInteractionStore.getState().updateConfirmation("c1", { title: "new" }); + expect(useInteractionStore.getState().pendingConfirmation?.title).toBe("new"); }); - it("SET_INLINE_CONFIRMATION_TOOL_CALL_ID sets the id", () => { - const next = interactionReducer(INITIAL_INTERACTION_STATE, { - type: "SET_INLINE_CONFIRMATION_TOOL_CALL_ID", - toolCallId: "tc-1", - }); - expect(next.inlineConfirmationToolCallId).toBe("tc-1"); + it("updateConfirmation is a no-op when requestId does not match", () => { + useInteractionStore.getState().showConfirmation({ requestId: "c1", title: "old" }); + useInteractionStore.getState().updateConfirmation("c2", { title: "new" }); + expect(useInteractionStore.getState().pendingConfirmation?.title).toBe("old"); }); - it("SET_INLINE_CONFIRMATION_TOOL_CALL_ID clears the id with null", () => { - const state: InteractionState = { - ...INITIAL_INTERACTION_STATE, - inlineConfirmationToolCallId: "tc-1", - }; - const next = interactionReducer(state, { - type: "SET_INLINE_CONFIRMATION_TOOL_CALL_ID", - toolCallId: null, - }); - expect(next.inlineConfirmationToolCallId).toBeNull(); + it("setInlineConfirmationToolCallId sets the value", () => { + useInteractionStore.getState().setInlineConfirmationToolCallId("tc-1"); + expect(useInteractionStore.getState().inlineConfirmationToolCallId).toBe("tc-1"); + useInteractionStore.getState().setInlineConfirmationToolCallId(null); + expect(useInteractionStore.getState().inlineConfirmationToolCallId).toBeNull(); }); }); // ----- Contact request flow ----- describe("contact request flow", () => { - it("SHOW_CONTACT_REQUEST sets pendingContactRequest", () => { + it("showContactRequest sets state and resets flags", () => { const payload = { requestId: "cr1", channel: "email" }; - const next = interactionReducer(INITIAL_INTERACTION_STATE, { - type: "SHOW_CONTACT_REQUEST", - payload, - }); - expect(next.pendingContactRequest).toEqual(payload); - expect(next.isSubmittingContactRequest).toBe(false); - expect(next.contactRequestAccepted).toBe(false); + useInteractionStore.getState().showContactRequest(payload); + const s = useInteractionStore.getState(); + expect(s.pendingContactRequest).toEqual(payload); + expect(s.isSubmittingContactRequest).toBe(false); + expect(s.contactRequestAccepted).toBe(false); }); - it("SUBMIT_CONTACT_REQUEST_START / END toggle flag", () => { - const state: InteractionState = { - ...INITIAL_INTERACTION_STATE, - pendingContactRequest: { requestId: "cr1" }, - }; - const started = interactionReducer(state, { - type: "SUBMIT_CONTACT_REQUEST_START", - }); - expect(started.isSubmittingContactRequest).toBe(true); - - const ended = interactionReducer(started, { - type: "SUBMIT_CONTACT_REQUEST_END", - }); - expect(ended.isSubmittingContactRequest).toBe(false); + it("submitContactRequestStart/End cycle", () => { + useInteractionStore.getState().showContactRequest({ requestId: "cr1" }); + useInteractionStore.getState().submitContactRequestStart(); + expect(useInteractionStore.getState().isSubmittingContactRequest).toBe(true); + useInteractionStore.getState().submitContactRequestEnd(); + expect(useInteractionStore.getState().isSubmittingContactRequest).toBe(false); }); - it("DISMISS_CONTACT_REQUEST clears pendingContactRequest", () => { - const state: InteractionState = { - ...INITIAL_INTERACTION_STATE, - pendingContactRequest: { requestId: "cr1" }, - isSubmittingContactRequest: true, - }; - const next = interactionReducer(state, { - type: "DISMISS_CONTACT_REQUEST", - }); - expect(next.pendingContactRequest).toBeNull(); - expect(next.isSubmittingContactRequest).toBe(false); + it("dismissContactRequest clears state", () => { + useInteractionStore.getState().showContactRequest({ requestId: "cr1" }); + useInteractionStore.getState().dismissContactRequest(); + const s = useInteractionStore.getState(); + expect(s.pendingContactRequest).toBeNull(); + expect(s.isSubmittingContactRequest).toBe(false); }); - it("ACCEPT_CONTACT_REQUEST sets contactRequestAccepted", () => { - const state: InteractionState = { - ...INITIAL_INTERACTION_STATE, - pendingContactRequest: { requestId: "cr1" }, - }; - const next = interactionReducer(state, { - type: "ACCEPT_CONTACT_REQUEST", - }); - expect(next.contactRequestAccepted).toBe(true); + it("acceptContactRequest sets flag", () => { + useInteractionStore.getState().showContactRequest({ requestId: "cr1" }); + useInteractionStore.getState().acceptContactRequest(); + expect(useInteractionStore.getState().contactRequestAccepted).toBe(true); }); }); // ----- Question flow ----- describe("question flow", () => { - const questionPayload = { - requestId: "q1", - entries: [ - { - id: "q1", - question: "Which option?", - description: "Pick one", - options: [{ id: "a", label: "A" }], - freeTextPlaceholder: "Or type your own...", - }, - ], - toolUseId: "tu-1", - }; - - it("SHOW_QUESTION sets pendingQuestion and resets submit/dismissed flags", () => { - const state: InteractionState = { - ...INITIAL_INTERACTION_STATE, - isSubmittingQuestion: true, - isQuestionCardDismissed: true, - }; - const next = interactionReducer(state, { - type: "SHOW_QUESTION", - payload: questionPayload, - }); - expect(next.pendingQuestion).toEqual(questionPayload); - expect(next.isSubmittingQuestion).toBe(false); - expect(next.isQuestionCardDismissed).toBe(false); - }); - - it("SUBMIT_QUESTION_START sets isSubmittingQuestion", () => { - const state: InteractionState = { - ...INITIAL_INTERACTION_STATE, - pendingQuestion: questionPayload, - }; - const next = interactionReducer(state, { type: "SUBMIT_QUESTION_START" }); - expect(next.isSubmittingQuestion).toBe(true); - }); - - it("SUBMIT_QUESTION_END clears isSubmittingQuestion", () => { - const state: InteractionState = { - ...INITIAL_INTERACTION_STATE, - pendingQuestion: questionPayload, - isSubmittingQuestion: true, - }; - const next = interactionReducer(state, { type: "SUBMIT_QUESTION_END" }); - expect(next.isSubmittingQuestion).toBe(false); - }); - - it("DISMISS_QUESTION clears all question state", () => { - const state: InteractionState = { - ...INITIAL_INTERACTION_STATE, - pendingQuestion: questionPayload, - isSubmittingQuestion: true, - isQuestionCardDismissed: true, - }; - const next = interactionReducer(state, { type: "DISMISS_QUESTION" }); - expect(next.pendingQuestion).toBeNull(); - expect(next.isSubmittingQuestion).toBe(false); - expect(next.isQuestionCardDismissed).toBe(false); - }); - - it("DISMISS_QUESTION_CARD hides card but preserves pendingQuestion", () => { - const state: InteractionState = { - ...INITIAL_INTERACTION_STATE, - pendingQuestion: questionPayload, - }; - const next = interactionReducer(state, { type: "DISMISS_QUESTION_CARD" }); - expect(next.isQuestionCardDismissed).toBe(true); - expect(next.pendingQuestion).toEqual(questionPayload); + it("showQuestion sets state and resets flags", () => { + const payload = { requestId: "q1", entries: [] }; + useInteractionStore.getState().showQuestion(payload); + const s = useInteractionStore.getState(); + expect(s.pendingQuestion).toEqual(payload); + expect(s.isSubmittingQuestion).toBe(false); + expect(s.isQuestionCardDismissed).toBe(false); + }); + + it("submitQuestionStart/End cycle", () => { + useInteractionStore.getState().showQuestion({ requestId: "q1", entries: [] }); + useInteractionStore.getState().submitQuestionStart(); + expect(useInteractionStore.getState().isSubmittingQuestion).toBe(true); + useInteractionStore.getState().submitQuestionEnd(); + expect(useInteractionStore.getState().isSubmittingQuestion).toBe(false); + }); + + it("dismissQuestion clears all question state", () => { + useInteractionStore.getState().showQuestion({ requestId: "q1", entries: [] }); + useInteractionStore.getState().dismissQuestionCard(); + useInteractionStore.getState().dismissQuestion(); + const s = useInteractionStore.getState(); + expect(s.pendingQuestion).toBeNull(); + expect(s.isSubmittingQuestion).toBe(false); + expect(s.isQuestionCardDismissed).toBe(false); + }); + + it("dismissQuestionCard hides card but keeps pendingQuestion", () => { + useInteractionStore.getState().showQuestion({ requestId: "q1", entries: [] }); + useInteractionStore.getState().dismissQuestionCard(); + const s = useInteractionStore.getState(); + expect(s.pendingQuestion).not.toBeNull(); + expect(s.isQuestionCardDismissed).toBe(true); }); }); - // ----- Reset ----- - describe("RESET_ALL", () => { - it("resets all state to initial values", () => { - const dirty: InteractionState = { - pendingSecret: { requestId: "r1" }, - isSubmittingSecret: true, - secretSaved: true, - pendingConfirmation: { requestId: "c1" }, - isSubmittingConfirmation: true, - pendingContactRequest: { requestId: "cr1" }, - isSubmittingContactRequest: true, - contactRequestAccepted: true, - pendingQuestion: { requestId: "q1", entries: [{ id: "q1", question: "Pick one", options: [] }] }, - isSubmittingQuestion: true, - isQuestionCardDismissed: true, - inlineConfirmationToolCallId: "tc-1", - }; - const next = interactionReducer(dirty, { type: "RESET_ALL" }); - expect(next).toEqual(INITIAL_INTERACTION_STATE); - }); - }); - - describe("RESET_SECRET_AND_CONFIRMATION", () => { - it("clears secret and confirmation but preserves contact request and question", () => { - const dirty: InteractionState = { - pendingSecret: { requestId: "r1" }, - isSubmittingSecret: true, - secretSaved: true, - pendingConfirmation: { requestId: "c1" }, - isSubmittingConfirmation: true, - pendingContactRequest: { requestId: "cr1" }, - isSubmittingContactRequest: true, - contactRequestAccepted: true, - pendingQuestion: { requestId: "q1", entries: [{ id: "q1", question: "Pick one", options: [] }] }, - isSubmittingQuestion: true, - isQuestionCardDismissed: true, - inlineConfirmationToolCallId: "tc-1", - }; - const next = interactionReducer(dirty, { - type: "RESET_SECRET_AND_CONFIRMATION", - }); - expect(next.pendingSecret).toBeNull(); - expect(next.isSubmittingSecret).toBe(false); - expect(next.secretSaved).toBe(false); - expect(next.pendingConfirmation).toBeNull(); - expect(next.isSubmittingConfirmation).toBe(false); - expect(next.inlineConfirmationToolCallId).toBeNull(); - expect(next.pendingContactRequest).toEqual({ requestId: "cr1" }); - expect(next.isSubmittingContactRequest).toBe(true); - expect(next.contactRequestAccepted).toBe(true); - // Question state is preserved — the daemon is still blocking on - // /question-response/ and clearing the card would leave the user with - // no way to answer. Only explicit DISMISS_QUESTION clears it. - expect(next.pendingQuestion).toEqual({ requestId: "q1", entries: [{ id: "q1", question: "Pick one", options: [] }] }); - expect(next.isSubmittingQuestion).toBe(true); - expect(next.isQuestionCardDismissed).toBe(true); + // ----- Reset flows ----- + describe("reset flows", () => { + it("resetSecretAndConfirmation clears secret+confirmation but preserves question", () => { + useInteractionStore.getState().showSecret({ requestId: "r1" }); + useInteractionStore.getState().showConfirmation({ requestId: "c1" }); + useInteractionStore.getState().showQuestion({ requestId: "q1", entries: [] }); + useInteractionStore.getState().setInlineConfirmationToolCallId("tc-1"); + + useInteractionStore.getState().resetSecretAndConfirmation(); + const s = useInteractionStore.getState(); + expect(s.pendingSecret).toBeNull(); + expect(s.pendingConfirmation).toBeNull(); + expect(s.inlineConfirmationToolCallId).toBeNull(); + expect(s.pendingQuestion).not.toBeNull(); + }); + + it("resetAll returns to initial state", () => { + useInteractionStore.getState().showSecret({ requestId: "r1" }); + useInteractionStore.getState().showConfirmation({ requestId: "c1" }); + useInteractionStore.getState().showContactRequest({ requestId: "cr1" }); + useInteractionStore.getState().showQuestion({ requestId: "q1", entries: [] }); + + useInteractionStore.getState().resetAll(); + const s = useInteractionStore.getState(); + expect(s.pendingSecret).toBeNull(); + expect(s.pendingConfirmation).toBeNull(); + expect(s.pendingContactRequest).toBeNull(); + expect(s.pendingQuestion).toBeNull(); }); }); // ----- hasActiveInteraction ----- describe("hasActiveInteraction", () => { it("returns false for initial state", () => { - expect(hasActiveInteraction(INITIAL_INTERACTION_STATE)).toBe(false); - }); - - it("returns true when secret is pending", () => { - const state: InteractionState = { - ...INITIAL_INTERACTION_STATE, - pendingSecret: { requestId: "r1" }, - }; - expect(hasActiveInteraction(state)).toBe(true); - }); - - it("returns true when confirmation is pending", () => { - const state: InteractionState = { - ...INITIAL_INTERACTION_STATE, - pendingConfirmation: { requestId: "c1" }, - }; - expect(hasActiveInteraction(state)).toBe(true); + expect(hasActiveInteraction(useInteractionStore.getState())).toBe(false); }); - it("returns true when contact request is pending", () => { - const state: InteractionState = { - ...INITIAL_INTERACTION_STATE, - pendingContactRequest: { requestId: "cr1" }, - }; - expect(hasActiveInteraction(state)).toBe(true); + it("returns true when any prompt is pending", () => { + useInteractionStore.getState().showSecret({ requestId: "r1" }); + expect(hasActiveInteraction(useInteractionStore.getState())).toBe(true); }); - - it("returns true when question is pending", () => { - const state: InteractionState = { - ...INITIAL_INTERACTION_STATE, - pendingQuestion: { requestId: "q1", entries: [{ id: "q1", question: "Pick one", options: [] }] }, - }; - expect(hasActiveInteraction(state)).toBe(true); - }); - }); - - // ----- Unknown event ----- - it("returns state unchanged for unknown event types", () => { - const result = interactionReducer( - INITIAL_INTERACTION_STATE, - { type: "UNKNOWN" } as never, - ); - expect(result).toBe(INITIAL_INTERACTION_STATE); }); }); diff --git a/apps/web/src/domains/interactions/interaction-store.ts b/apps/web/src/domains/interactions/interaction-store.ts index c02a291e5e1..6234492ed01 100644 --- a/apps/web/src/domains/interactions/interaction-store.ts +++ b/apps/web/src/domains/interactions/interaction-store.ts @@ -1,13 +1,18 @@ /** - * Interaction-level state machine for user-facing prompts. + * Zustand store for interaction-prompt state (secret, confirmation, + * contact-request, question). * - * Consolidates pending secret, confirmation, and contact-request state into a - * single reducer with typed domain events and pure transitions. + * Manages four independent prompt lifecycles — each can be pending, + * submitting, or idle simultaneously. Uses direct named actions per + * Zustand's recommended pattern. * - * @see https://react.dev/learn/extracting-state-logic-into-a-reducer - * @see https://react.dev/learn/scaling-up-with-reducer-and-context + * @see https://zustand.docs.pmnd.rs/guides/flux-inspired-practice + * @see https://zustand.docs.pmnd.rs/guides/updating-state */ +import { create } from "zustand"; +import { useShallow } from "zustand/shallow"; + import type { PendingSecretState, PendingConfirmationState, @@ -40,7 +45,53 @@ export interface InteractionState { inlineConfirmationToolCallId: string | null; } -export const INITIAL_INTERACTION_STATE: InteractionState = { +// --------------------------------------------------------------------------- +// Actions +// --------------------------------------------------------------------------- + +export interface InteractionActions { + // Secret + showSecret: (payload: PendingSecretState) => void; + submitSecretStart: () => void; + submitSecretEnd: (saved?: boolean) => void; + dismissSecret: () => void; + updateSecret: (requestId: string, patch: Partial) => void; + + // Confirmation + showConfirmation: (payload: PendingConfirmationState) => void; + submitConfirmationStart: () => void; + submitConfirmationEnd: () => void; + dismissConfirmation: () => void; + dismissConfirmationIfMatches: (requestId: string) => void; + updateConfirmation: (requestId: string, patch: Partial) => void; + setInlineConfirmationToolCallId: (toolCallId: string | null) => void; + + // Contact request + showContactRequest: (payload: PendingContactRequestState) => void; + submitContactRequestStart: () => void; + submitContactRequestEnd: () => void; + dismissContactRequest: () => void; + acceptContactRequest: () => void; + + // Question + showQuestion: (payload: PendingQuestionState) => void; + submitQuestionStart: () => void; + submitQuestionEnd: () => void; + dismissQuestion: () => void; + dismissQuestionCard: () => void; + + // Resets + resetSecretAndConfirmation: () => void; + resetAll: () => void; +} + +export type InteractionStore = InteractionState & InteractionActions; + +// --------------------------------------------------------------------------- +// Initial state +// --------------------------------------------------------------------------- + +const INITIAL_STATE: InteractionState = { pendingSecret: null, isSubmittingSecret: false, secretSaved: false, @@ -74,316 +125,163 @@ export function hasActiveInteraction(state: InteractionState): boolean { } // --------------------------------------------------------------------------- -// Domain events +// Store // --------------------------------------------------------------------------- -export interface ShowSecret { - type: "SHOW_SECRET"; - payload: PendingSecretState; -} - -export interface SubmitSecretStart { - type: "SUBMIT_SECRET_START"; -} - -export interface SubmitSecretEnd { - type: "SUBMIT_SECRET_END"; - saved?: boolean; -} +export const useInteractionStore = create()((set, get) => ({ + ...INITIAL_STATE, + + // ----- Secret ----- + showSecret: (payload) => + set({ pendingSecret: payload, isSubmittingSecret: false, secretSaved: false }), -export interface DismissSecret { - type: "DISMISS_SECRET"; -} + submitSecretStart: () => + set({ isSubmittingSecret: true }), -/** Conditionally update the pending secret — only applies if the current - * requestId matches, preventing stale updates from overwriting newer state. */ -export interface UpdateSecret { - type: "UPDATE_SECRET"; - requestId: string; - patch: Partial; -} + submitSecretEnd: (saved) => + set({ isSubmittingSecret: false, secretSaved: saved ?? false }), -export interface ShowConfirmation { - type: "SHOW_CONFIRMATION"; - payload: PendingConfirmationState; -} + dismissSecret: () => + set({ pendingSecret: null, isSubmittingSecret: false }), -export interface SubmitConfirmationStart { - type: "SUBMIT_CONFIRMATION_START"; -} + updateSecret: (requestId, patch) => { + const { pendingSecret } = get(); + if (!pendingSecret || pendingSecret.requestId !== requestId) return; + set({ pendingSecret: { ...pendingSecret, ...patch } }); + }, -export interface SubmitConfirmationEnd { - type: "SUBMIT_CONFIRMATION_END"; -} + // ----- Confirmation ----- + showConfirmation: (payload) => + set({ pendingConfirmation: payload, isSubmittingConfirmation: false }), -export interface DismissConfirmation { - type: "DISMISS_CONFIRMATION"; -} + submitConfirmationStart: () => + set({ isSubmittingConfirmation: true }), -/** Conditionally dismiss the pending confirmation — only clears if the current - * requestId matches, preventing a concurrent confirmation from being lost. */ -export interface DismissConfirmationIfMatches { - type: "DISMISS_CONFIRMATION_IF_MATCHES"; - requestId: string; -} + submitConfirmationEnd: () => + set({ isSubmittingConfirmation: false }), -/** Conditionally update the pending confirmation — only applies if the current - * requestId matches. */ -export interface UpdateConfirmation { - type: "UPDATE_CONFIRMATION"; - requestId: string; - patch: Partial; -} + dismissConfirmation: () => + set({ pendingConfirmation: null, isSubmittingConfirmation: false }), -export interface SetInlineConfirmationToolCallId { - type: "SET_INLINE_CONFIRMATION_TOOL_CALL_ID"; - toolCallId: string | null; -} + dismissConfirmationIfMatches: (requestId) => { + const { pendingConfirmation } = get(); + if (!pendingConfirmation || pendingConfirmation.requestId !== requestId) return; + set({ pendingConfirmation: null, isSubmittingConfirmation: false }); + }, -export interface ShowContactRequest { - type: "SHOW_CONTACT_REQUEST"; - payload: PendingContactRequestState; -} + updateConfirmation: (requestId, patch) => { + const { pendingConfirmation } = get(); + if (!pendingConfirmation || pendingConfirmation.requestId !== requestId) return; + set({ pendingConfirmation: { ...pendingConfirmation, ...patch } }); + }, -export interface SubmitContactRequestStart { - type: "SUBMIT_CONTACT_REQUEST_START"; -} + setInlineConfirmationToolCallId: (toolCallId) => + set({ inlineConfirmationToolCallId: toolCallId }), -export interface SubmitContactRequestEnd { - type: "SUBMIT_CONTACT_REQUEST_END"; -} + // ----- Contact request ----- + showContactRequest: (payload) => + set({ + pendingContactRequest: payload, + isSubmittingContactRequest: false, + contactRequestAccepted: false, + }), -export interface DismissContactRequest { - type: "DISMISS_CONTACT_REQUEST"; -} + submitContactRequestStart: () => + set({ isSubmittingContactRequest: true }), -export interface AcceptContactRequest { - type: "ACCEPT_CONTACT_REQUEST"; -} + submitContactRequestEnd: () => + set({ isSubmittingContactRequest: false }), -export interface ShowQuestion { - type: "SHOW_QUESTION"; - payload: PendingQuestionState; -} + dismissContactRequest: () => + set({ pendingContactRequest: null, isSubmittingContactRequest: false }), -export interface SubmitQuestionStart { - type: "SUBMIT_QUESTION_START"; -} + acceptContactRequest: () => + set({ contactRequestAccepted: true }), -export interface SubmitQuestionEnd { - type: "SUBMIT_QUESTION_END"; -} + // ----- Question ----- + showQuestion: (payload) => + set({ pendingQuestion: payload, isSubmittingQuestion: false, isQuestionCardDismissed: false }), -/** Clear question state entirely (e.g. after successful submission). */ -export interface DismissQuestion { - type: "DISMISS_QUESTION"; -} + submitQuestionStart: () => + set({ isSubmittingQuestion: true }), -/** Hide the question card UI but keep `pendingQuestion` set so the - * composer free-text intercept still routes to `submitQuestionResponse`. */ -export interface DismissQuestionCard { - type: "DISMISS_QUESTION_CARD"; -} + submitQuestionEnd: () => + set({ isSubmittingQuestion: false }), -/** Clear secret and confirmation state only — used when sending a message. - * Preserves contact request state (the composer is still enabled during - * contact requests, so sending a message should not dismiss them). */ -export interface ResetSecretAndConfirmation { - type: "RESET_SECRET_AND_CONFIRMATION"; -} + dismissQuestion: () => + set({ pendingQuestion: null, isSubmittingQuestion: false, isQuestionCardDismissed: false }), -/** Clear all interaction state — used on conversation switch. */ -export interface ResetAll { - type: "RESET_ALL"; -} + dismissQuestionCard: () => + set({ isQuestionCardDismissed: true }), -export type InteractionEvent = - | ShowSecret - | SubmitSecretStart - | SubmitSecretEnd - | DismissSecret - | UpdateSecret - | ShowConfirmation - | SubmitConfirmationStart - | SubmitConfirmationEnd - | DismissConfirmation - | DismissConfirmationIfMatches - | UpdateConfirmation - | SetInlineConfirmationToolCallId - | ShowContactRequest - | SubmitContactRequestStart - | SubmitContactRequestEnd - | DismissContactRequest - | AcceptContactRequest - | ShowQuestion - | SubmitQuestionStart - | SubmitQuestionEnd - | DismissQuestion - | DismissQuestionCard - | ResetSecretAndConfirmation - | ResetAll; + // ----- Resets ----- + resetSecretAndConfirmation: () => + set({ + pendingSecret: null, + isSubmittingSecret: false, + secretSaved: false, + pendingConfirmation: null, + isSubmittingConfirmation: false, + inlineConfirmationToolCallId: null, + // Question state intentionally NOT cleared — the composer intercept + // (`pendingQuestion && trimmed`) only fires for text sends; clearing + // the question would hide the card while the daemon blocks on + // /question-response/. + }), + + resetAll: () => set(INITIAL_STATE), +})); // --------------------------------------------------------------------------- -// Reducer +// Convenience hooks // --------------------------------------------------------------------------- -export function interactionReducer( - state: InteractionState, - event: InteractionEvent, -): InteractionState { - switch (event.type) { - // ----- Secret ----- - case "SHOW_SECRET": - return { - ...state, - pendingSecret: event.payload, - isSubmittingSecret: false, - secretSaved: false, - }; - - case "SUBMIT_SECRET_START": - return { ...state, isSubmittingSecret: true }; - - case "SUBMIT_SECRET_END": - return { - ...state, - isSubmittingSecret: false, - secretSaved: event.saved ?? false, - }; - - case "DISMISS_SECRET": - return { - ...state, - pendingSecret: null, - isSubmittingSecret: false, - }; - - case "UPDATE_SECRET": - if (!state.pendingSecret || state.pendingSecret.requestId !== event.requestId) { - return state; - } - return { - ...state, - pendingSecret: { ...state.pendingSecret, ...event.patch }, - }; - - // ----- Confirmation ----- - case "SHOW_CONFIRMATION": - return { - ...state, - pendingConfirmation: event.payload, - isSubmittingConfirmation: false, - }; - - case "SUBMIT_CONFIRMATION_START": - return { ...state, isSubmittingConfirmation: true }; - - case "SUBMIT_CONFIRMATION_END": - return { ...state, isSubmittingConfirmation: false }; - - case "DISMISS_CONFIRMATION": - return { - ...state, - pendingConfirmation: null, - isSubmittingConfirmation: false, - }; - - case "DISMISS_CONFIRMATION_IF_MATCHES": - if (!state.pendingConfirmation || state.pendingConfirmation.requestId !== event.requestId) { - return state; - } - return { - ...state, - pendingConfirmation: null, - isSubmittingConfirmation: false, - }; - - case "UPDATE_CONFIRMATION": - if (!state.pendingConfirmation || state.pendingConfirmation.requestId !== event.requestId) { - return state; - } - return { - ...state, - pendingConfirmation: { ...state.pendingConfirmation, ...event.patch }, - }; - - case "SET_INLINE_CONFIRMATION_TOOL_CALL_ID": - return { ...state, inlineConfirmationToolCallId: event.toolCallId }; - - // ----- Contact request ----- - case "SHOW_CONTACT_REQUEST": - return { - ...state, - pendingContactRequest: event.payload, - isSubmittingContactRequest: false, - contactRequestAccepted: false, - }; - - case "SUBMIT_CONTACT_REQUEST_START": - return { ...state, isSubmittingContactRequest: true }; - - case "SUBMIT_CONTACT_REQUEST_END": - return { ...state, isSubmittingContactRequest: false }; - - case "DISMISS_CONTACT_REQUEST": - return { - ...state, - pendingContactRequest: null, - isSubmittingContactRequest: false, - }; - - case "ACCEPT_CONTACT_REQUEST": - return { ...state, contactRequestAccepted: true }; - - // ----- Question ----- - case "SHOW_QUESTION": - return { - ...state, - pendingQuestion: event.payload, - isSubmittingQuestion: false, - isQuestionCardDismissed: false, - }; - - case "SUBMIT_QUESTION_START": - return { ...state, isSubmittingQuestion: true }; - - case "SUBMIT_QUESTION_END": - return { ...state, isSubmittingQuestion: false }; - - case "DISMISS_QUESTION": - return { - ...state, - pendingQuestion: null, - isSubmittingQuestion: false, - isQuestionCardDismissed: false, - }; - - case "DISMISS_QUESTION_CARD": - return { ...state, isQuestionCardDismissed: true }; - - // ----- Reset ----- - case "RESET_SECRET_AND_CONFIRMATION": - return { - ...state, - pendingSecret: null, - isSubmittingSecret: false, - secretSaved: false, - pendingConfirmation: null, - isSubmittingConfirmation: false, - inlineConfirmationToolCallId: null, - // Question state is intentionally NOT cleared here. The composer - // intercept (`pendingQuestion && trimmed`) only fires for text - // sends; attachment-only sends bypass it and land here. Clearing - // the question would hide the card while the daemon is still - // blocking on /question-response/, leaving the user with no way - // to answer. Question state is managed by its own events - // (DISMISS_QUESTION, DISMISS_QUESTION_CARD). - }; - - case "RESET_ALL": - return { ...INITIAL_INTERACTION_STATE }; - - default: - return state; - } +export function useInteractionState(): InteractionState { + return useInteractionStore( + useShallow((s) => ({ + pendingSecret: s.pendingSecret, + isSubmittingSecret: s.isSubmittingSecret, + secretSaved: s.secretSaved, + pendingConfirmation: s.pendingConfirmation, + isSubmittingConfirmation: s.isSubmittingConfirmation, + pendingContactRequest: s.pendingContactRequest, + isSubmittingContactRequest: s.isSubmittingContactRequest, + contactRequestAccepted: s.contactRequestAccepted, + pendingQuestion: s.pendingQuestion, + isSubmittingQuestion: s.isSubmittingQuestion, + isQuestionCardDismissed: s.isQuestionCardDismissed, + inlineConfirmationToolCallId: s.inlineConfirmationToolCallId, + })), + ); +} + +export function useInteractionActions(): InteractionActions { + return useInteractionStore( + useShallow((s) => ({ + showSecret: s.showSecret, + submitSecretStart: s.submitSecretStart, + submitSecretEnd: s.submitSecretEnd, + dismissSecret: s.dismissSecret, + updateSecret: s.updateSecret, + showConfirmation: s.showConfirmation, + submitConfirmationStart: s.submitConfirmationStart, + submitConfirmationEnd: s.submitConfirmationEnd, + dismissConfirmation: s.dismissConfirmation, + dismissConfirmationIfMatches: s.dismissConfirmationIfMatches, + updateConfirmation: s.updateConfirmation, + setInlineConfirmationToolCallId: s.setInlineConfirmationToolCallId, + showContactRequest: s.showContactRequest, + submitContactRequestStart: s.submitContactRequestStart, + submitContactRequestEnd: s.submitContactRequestEnd, + dismissContactRequest: s.dismissContactRequest, + acceptContactRequest: s.acceptContactRequest, + showQuestion: s.showQuestion, + submitQuestionStart: s.submitQuestionStart, + submitQuestionEnd: s.submitQuestionEnd, + dismissQuestion: s.dismissQuestion, + dismissQuestionCard: s.dismissQuestionCard, + resetSecretAndConfirmation: s.resetSecretAndConfirmation, + resetAll: s.resetAll, + })), + ); } From 0cf8fad7ca61cab2fae7187bca4e3622fadf1701 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 19 May 2026 19:46:03 +0000 Subject: [PATCH 2/2] refactor(web): apply createSelectors pattern to interaction store 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 --- apps/web/src/domains/chat/chat-store.test.ts | 3 - .../chat/components/chat-route-content.tsx | 22 +++---- .../chat/hooks/use-interaction-actions.ts | 16 ++--- .../domains/interactions/interaction-store.ts | 59 ++----------------- 4 files changed, 23 insertions(+), 77 deletions(-) diff --git a/apps/web/src/domains/chat/chat-store.test.ts b/apps/web/src/domains/chat/chat-store.test.ts index 786e69268f0..ea16bd44eab 100644 --- a/apps/web/src/domains/chat/chat-store.test.ts +++ b/apps/web/src/domains/chat/chat-store.test.ts @@ -9,7 +9,6 @@ beforeEach(() => { assistantId: null, sendMessage: async () => {}, dispatchTurn: () => {}, - dispatchInteraction: () => {}, }, true); }); @@ -25,7 +24,6 @@ describe("useChatStore", () => { const state = useChatStore.getState(); expect(typeof state.sendMessage).toBe("function"); expect(typeof state.dispatchTurn).toBe("function"); - expect(typeof state.dispatchInteraction).toBe("function"); }); it("setState updates only the targeted state fields", () => { @@ -60,7 +58,6 @@ describe("useChatStore", () => { assistantId: "ast-new", sendMessage: async () => {}, dispatchTurn: () => {}, - dispatchInteraction: () => {}, }; useChatStore.setState(fullState, true); diff --git a/apps/web/src/domains/chat/components/chat-route-content.tsx b/apps/web/src/domains/chat/components/chat-route-content.tsx index d266f2ba5e5..dd5ab655e1e 100644 --- a/apps/web/src/domains/chat/components/chat-route-content.tsx +++ b/apps/web/src/domains/chat/components/chat-route-content.tsx @@ -513,17 +513,17 @@ export function ChatRouteContent({ // Interaction state (from Zustand store) // ------------------------------------------------------------------------- - const pendingSecret = useInteractionStore((s) => s.pendingSecret); - const pendingConfirmation = useInteractionStore((s) => s.pendingConfirmation); - const pendingContactRequest = useInteractionStore((s) => s.pendingContactRequest); - const pendingQuestion = useInteractionStore((s) => s.pendingQuestion); - const isSubmittingSecret = useInteractionStore((s) => s.isSubmittingSecret); - const isSubmittingConfirmation = useInteractionStore((s) => s.isSubmittingConfirmation); - const isSubmittingContactRequest = useInteractionStore((s) => s.isSubmittingContactRequest); - const isSubmittingQuestion = useInteractionStore((s) => s.isSubmittingQuestion); - const contactRequestAccepted = useInteractionStore((s) => s.contactRequestAccepted); - const secretSaved = useInteractionStore((s) => s.secretSaved); - const inlineConfirmationToolCallId = useInteractionStore((s) => s.inlineConfirmationToolCallId); + const pendingSecret = useInteractionStore.use.pendingSecret(); + const pendingConfirmation = useInteractionStore.use.pendingConfirmation(); + const pendingContactRequest = useInteractionStore.use.pendingContactRequest(); + const pendingQuestion = useInteractionStore.use.pendingQuestion(); + const isSubmittingSecret = useInteractionStore.use.isSubmittingSecret(); + const isSubmittingConfirmation = useInteractionStore.use.isSubmittingConfirmation(); + const isSubmittingContactRequest = useInteractionStore.use.isSubmittingContactRequest(); + const isSubmittingQuestion = useInteractionStore.use.isSubmittingQuestion(); + const contactRequestAccepted = useInteractionStore.use.contactRequestAccepted(); + const secretSaved = useInteractionStore.use.secretSaved(); + const inlineConfirmationToolCallId = useInteractionStore.use.inlineConfirmationToolCallId(); const inlineConfirmationAttached = inlineConfirmationToolCallId !== null; // ------------------------------------------------------------------------- diff --git a/apps/web/src/domains/chat/hooks/use-interaction-actions.ts b/apps/web/src/domains/chat/hooks/use-interaction-actions.ts index 8021d5f5104..cdcfddbdeea 100644 --- a/apps/web/src/domains/chat/hooks/use-interaction-actions.ts +++ b/apps/web/src/domains/chat/hooks/use-interaction-actions.ts @@ -122,14 +122,14 @@ export function useInteractionActions({ activeConversationKeyRef, confirmationToolCallMapRef, }: UseInteractionActionsParams): UseInteractionActionsReturn { - const pendingSecret = useInteractionStore((s) => s.pendingSecret); - const isSubmittingSecret = useInteractionStore((s) => s.isSubmittingSecret); - const pendingConfirmation = useInteractionStore((s) => s.pendingConfirmation); - const isSubmittingConfirmation = useInteractionStore((s) => s.isSubmittingConfirmation); - const pendingContactRequest = useInteractionStore((s) => s.pendingContactRequest); - const isSubmittingContactRequest = useInteractionStore((s) => s.isSubmittingContactRequest); - const pendingQuestion = useInteractionStore((s) => s.pendingQuestion); - const isSubmittingQuestion = useInteractionStore((s) => s.isSubmittingQuestion); + const pendingSecret = useInteractionStore.use.pendingSecret(); + const isSubmittingSecret = useInteractionStore.use.isSubmittingSecret(); + const pendingConfirmation = useInteractionStore.use.pendingConfirmation(); + const isSubmittingConfirmation = useInteractionStore.use.isSubmittingConfirmation(); + const pendingContactRequest = useInteractionStore.use.pendingContactRequest(); + const isSubmittingContactRequest = useInteractionStore.use.isSubmittingContactRequest(); + const pendingQuestion = useInteractionStore.use.pendingQuestion(); + const isSubmittingQuestion = useInteractionStore.use.isSubmittingQuestion(); const [showRuleEditor, setShowRuleEditor] = useState(false); const [ruleEditorContext, setRuleEditorContext] = useState(null); diff --git a/apps/web/src/domains/interactions/interaction-store.ts b/apps/web/src/domains/interactions/interaction-store.ts index 6234492ed01..690cb6d24a6 100644 --- a/apps/web/src/domains/interactions/interaction-store.ts +++ b/apps/web/src/domains/interactions/interaction-store.ts @@ -11,7 +11,8 @@ */ import { create } from "zustand"; -import { useShallow } from "zustand/shallow"; + +import { createSelectors } from "@/utils/create-selectors.js"; import type { PendingSecretState, @@ -128,7 +129,7 @@ export function hasActiveInteraction(state: InteractionState): boolean { // Store // --------------------------------------------------------------------------- -export const useInteractionStore = create()((set, get) => ({ +const useInteractionStoreBase = create()((set, get) => ({ ...INITIAL_STATE, // ----- Secret ----- @@ -232,56 +233,4 @@ export const useInteractionStore = create()((set, get) => ({ resetAll: () => set(INITIAL_STATE), })); -// --------------------------------------------------------------------------- -// Convenience hooks -// --------------------------------------------------------------------------- - -export function useInteractionState(): InteractionState { - return useInteractionStore( - useShallow((s) => ({ - pendingSecret: s.pendingSecret, - isSubmittingSecret: s.isSubmittingSecret, - secretSaved: s.secretSaved, - pendingConfirmation: s.pendingConfirmation, - isSubmittingConfirmation: s.isSubmittingConfirmation, - pendingContactRequest: s.pendingContactRequest, - isSubmittingContactRequest: s.isSubmittingContactRequest, - contactRequestAccepted: s.contactRequestAccepted, - pendingQuestion: s.pendingQuestion, - isSubmittingQuestion: s.isSubmittingQuestion, - isQuestionCardDismissed: s.isQuestionCardDismissed, - inlineConfirmationToolCallId: s.inlineConfirmationToolCallId, - })), - ); -} - -export function useInteractionActions(): InteractionActions { - return useInteractionStore( - useShallow((s) => ({ - showSecret: s.showSecret, - submitSecretStart: s.submitSecretStart, - submitSecretEnd: s.submitSecretEnd, - dismissSecret: s.dismissSecret, - updateSecret: s.updateSecret, - showConfirmation: s.showConfirmation, - submitConfirmationStart: s.submitConfirmationStart, - submitConfirmationEnd: s.submitConfirmationEnd, - dismissConfirmation: s.dismissConfirmation, - dismissConfirmationIfMatches: s.dismissConfirmationIfMatches, - updateConfirmation: s.updateConfirmation, - setInlineConfirmationToolCallId: s.setInlineConfirmationToolCallId, - showContactRequest: s.showContactRequest, - submitContactRequestStart: s.submitContactRequestStart, - submitContactRequestEnd: s.submitContactRequestEnd, - dismissContactRequest: s.dismissContactRequest, - acceptContactRequest: s.acceptContactRequest, - showQuestion: s.showQuestion, - submitQuestionStart: s.submitQuestionStart, - submitQuestionEnd: s.submitQuestionEnd, - dismissQuestion: s.dismissQuestion, - dismissQuestionCard: s.dismissQuestionCard, - resetSecretAndConfirmation: s.resetSecretAndConfirmation, - resetAll: s.resetAll, - })), - ); -} +export const useInteractionStore = createSelectors(useInteractionStoreBase);