diff --git a/apps/web/src/domains/chat/chat-page.tsx b/apps/web/src/domains/chat/chat-page.tsx index b24da58f010..8ef1c20aa3b 100644 --- a/apps/web/src/domains/chat/chat-page.tsx +++ b/apps/web/src/domains/chat/chat-page.tsx @@ -3,7 +3,6 @@ import { type RefObject, useCallback, useEffect, - useReducer, useRef, useState, } from "react"; @@ -11,10 +10,6 @@ import { import { useIsMobile } from "@/hooks/use-is-mobile.js"; import { useAuthStore } from "@/stores/auth-store.js"; import { useAssistantLifecycle } from "@/domains/chat/hooks/use-assistant-lifecycle.js"; -import { - interactionReducer, - INITIAL_INTERACTION_STATE, -} from "@/domains/interactions/interaction-store.js"; import { INITIAL_TURN_STATE, useTurnStore, @@ -59,11 +54,6 @@ export function ChatPage() { useEffect(() => { useTurnStore.setState(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] = @@ -99,7 +89,6 @@ export function ChatPage() { activeConversationKey: null, assistantId, sendMessage, - dispatchInteraction, }); if (authLoading || assistantState.kind === "loading") { @@ -137,8 +126,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.test.ts b/apps/web/src/domains/chat/chat-store.test.ts index f9aaf79d178..700a0b5a021 100644 --- a/apps/web/src/domains/chat/chat-store.test.ts +++ b/apps/web/src/domains/chat/chat-store.test.ts @@ -8,7 +8,6 @@ beforeEach(() => { activeConversationKey: null, assistantId: null, sendMessage: async () => {}, - dispatchInteraction: () => {}, }, true); }); @@ -23,7 +22,6 @@ describe("useChatStore", () => { it("initializes with noop action refs", () => { const state = useChatStore.getState(); expect(typeof state.sendMessage).toBe("function"); - expect(typeof state.dispatchInteraction).toBe("function"); }); it("setState updates only the targeted state fields", () => { @@ -57,7 +55,6 @@ describe("useChatStore", () => { activeConversationKey: "conv-abc", assistantId: "ast-new", sendMessage: async () => {}, - dispatchInteraction: () => {}, }; useChatStore.setState(fullState, true); diff --git a/apps/web/src/domains/chat/chat-store.ts b/apps/web/src/domains/chat/chat-store.ts index 4b0c7a2b079..c010781ff60 100644 --- a/apps/web/src/domains/chat/chat-store.ts +++ b/apps/web/src/domains/chat/chat-store.ts @@ -12,17 +12,19 @@ * * **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) + * + * Interaction state lives in its own store (`useInteractionStore`). + * Turn state lives in its own store (`useTurnStore`). * * Reference: {@link https://zustand.docs.pmnd.rs/} */ -import { type Dispatch, useEffect } from "react"; +import { useEffect } from "react"; import { create } from "zustand"; import { useShallow } from "zustand/shallow"; import type { DisplayAttachment, DisplayMessage } from "@/domains/chat/lib/reconcile.js"; -import type { InteractionEvent } from "@/domains/interactions/interaction-store.js"; // --------------------------------------------------------------------------- // Store shape @@ -40,8 +42,6 @@ export interface ChatState { export interface ChatActions { /** Send a user message (with optional attachments) to the active conversation. */ sendMessage: (content: string, attachments?: DisplayAttachment[]) => Promise; - /** Dispatch an interaction state-machine event. */ - dispatchInteraction: Dispatch; } export type ChatStore = ChatState & ChatActions; @@ -51,14 +51,12 @@ export type ChatStore = ChatState & ChatActions; // --------------------------------------------------------------------------- const NOOP_SEND: ChatActions["sendMessage"] = async () => {}; -const NOOP_DISPATCH: Dispatch = () => {}; export const useChatStore = create()(() => ({ messages: [], activeConversationKey: null, assistantId: null, sendMessage: NOOP_SEND, - dispatchInteraction: NOOP_DISPATCH as Dispatch, })); // --------------------------------------------------------------------------- @@ -70,7 +68,6 @@ export interface ChatStoreSyncProps { activeConversationKey: string | null; assistantId: string | null; sendMessage: (content: string, attachments?: DisplayAttachment[]) => Promise; - dispatchInteraction: Dispatch; } /** @@ -84,7 +81,6 @@ export function useSyncChatStore(props: ChatStoreSyncProps): void { activeConversationKey, assistantId, sendMessage, - dispatchInteraction, } = props; useEffect(() => { @@ -98,9 +94,8 @@ export function useSyncChatStore(props: ChatStoreSyncProps): void { useEffect(() => { useChatStore.setState({ sendMessage, - dispatchInteraction, }); - }, [sendMessage, dispatchInteraction]); + }, [sendMessage]); } // --------------------------------------------------------------------------- @@ -123,14 +118,13 @@ export function useChatState(): ChatState { } /** - * Stable action dispatchers (sendMessage, dispatchInteraction). + * Stable action refs (sendMessage). * Does **not** re-render when messages or active conversation change. */ export function useChatActions(): ChatActions { return useChatStore( useShallow((s) => ({ sendMessage: s.sendMessage, - 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 7ddb7a5a83b..e5909e3bd30 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,6 @@ 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 { useTurnStore } 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 +223,6 @@ export interface ChatRouteRefs { pendingLocalDeletionsRef: MutableRefObject>; confirmationToolCallMapRef: MutableRefObject>; - interactionStateRef: MutableRefObject; reconcileAfterNextStreamOpenRef: MutableRefObject; } @@ -262,9 +260,7 @@ export interface ChatRouteContentProps { // Loading isLoadingHistory: boolean; - // Interaction - interactionState: InteractionState; - dispatchInteraction: Dispatch; + // Conversation conversations: Conversation[]; @@ -384,8 +380,6 @@ export function ChatRouteContent({ error, setError, isLoadingHistory, - interactionState, - dispatchInteraction, conversations: _conversations, activeConversationKey, activeConversation, @@ -503,7 +497,7 @@ export function ChatRouteContent({ requestIdToStableIdRef: _requestIdToStableIdRef, pendingLocalDeletionsRef: _pendingLocalDeletionsRef, confirmationToolCallMapRef: _confirmationToolCallMapRef, - interactionStateRef, + reconcileAfterNextStreamOpenRef: _reconcileAfterNextStreamOpenRef, } = refs; @@ -513,20 +507,20 @@ export function ChatRouteContent({ const turnState = useTurnStore(); // ------------------------------------------------------------------------- - // 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.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; // ------------------------------------------------------------------------- @@ -927,8 +921,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; @@ -951,7 +945,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 0e1a78b6605..019a098ad68 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 { useTurnStore } 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, @@ -253,7 +250,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) @@ -339,7 +337,7 @@ export function useConversationHistory({ isLoadingOlder: false, isPinnedToLatest: true, }); - dispatchInteraction({ type: "RESET_ALL" }); + useInteractionStore.getState().resetAll(); confirmationToolCallMapRef.current.clear(); setAutoGreetPending(false); resetChatAttachments(); @@ -393,7 +391,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) { @@ -401,7 +399,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) { @@ -643,7 +641,6 @@ export function useConversationHistory({ inputRef, draftsRef, messagesRef, - interactionStateRef, contextWindowUsageByConversationRef, dismissedSurfaceIdsRef, needsNewBubbleRef, @@ -665,7 +662,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 66b6066c093..d4294cb6da1 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 { 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, @@ -446,7 +442,6 @@ export function useConversationLoader({ inputRef, draftsRef, messagesRef, - interactionStateRef, contextWindowUsageByConversationRef, dismissedSurfaceIdsRef, needsNewBubbleRef, @@ -467,7 +462,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 d06d64bcb06..c35503f06e9 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 { useTurnStore } from "@/domains/messaging/turn-store.js"; @@ -73,9 +74,6 @@ export interface ToolCallRuleContext { // --------------------------------------------------------------------------- export interface UseInteractionActionsParams { - interactionState: InteractionState; - interactionStateRef: MutableRefObject; - dispatchInteraction: Dispatch; dispatchConversationList: Dispatch; setMessages: Dispatch DisplayMessage[])>; @@ -115,9 +113,6 @@ export interface UseInteractionActionsReturn { // --------------------------------------------------------------------------- export function useInteractionActions({ - interactionState, - interactionStateRef, - dispatchInteraction, dispatchConversationList, setMessages, setError, @@ -126,16 +121,14 @@ export function useInteractionActions({ activeConversationKeyRef, confirmationToolCallMapRef, }: UseInteractionActionsParams): UseInteractionActionsReturn { - const { - pendingSecret, - isSubmittingSecret, - pendingConfirmation, - isSubmittingConfirmation, - pendingContactRequest, - isSubmittingContactRequest, - pendingQuestion, - isSubmittingQuestion, - } = interactionState; + 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); @@ -149,13 +142,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; } @@ -168,26 +161,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], @@ -195,11 +188,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 }); @@ -214,13 +207,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; } @@ -234,29 +227,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(); useTurnStore.getState().onStreamError(); }, []); @@ -273,8 +266,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 }); @@ -379,7 +372,7 @@ export function useInteractionActions({ } confirmationToolCallMapRef.current.delete(snapshot.requestId); - dispatchInteraction({ type: "SUBMIT_CONFIRMATION_END" }); + useInteractionStore.getState().submitConfirmationEnd(); }, [], ); @@ -388,13 +381,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; } @@ -421,7 +414,7 @@ export function useInteractionActions({ if (!result.ok) { setError({ message: result.error }); - dispatchInteraction({ type: "SUBMIT_CONFIRMATION_END" }); + useInteractionStore.getState().submitConfirmationEnd(); return; } cleanupAfterConfirmationDecision(snapshot!, mappedToolCallId, decision); @@ -436,14 +429,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], @@ -457,13 +450,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; } @@ -475,24 +468,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], ); // ------------------------------------------------------------------------- @@ -508,7 +501,7 @@ export function useInteractionActions({ } const snapshot = pendingConfirmation; - dispatchInteraction({ type: "SUBMIT_CONFIRMATION_START" }); + useInteractionStore.getState().submitConfirmationStart(); const mappedToolCallId = confirmationToolCallMapRef.current.get(snapshot.requestId); @@ -532,8 +525,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); @@ -546,12 +539,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]); @@ -601,7 +594,7 @@ export function useInteractionActions({ } setIsSavingRule(true); - dispatchInteraction({ type: "SUBMIT_CONFIRMATION_START" }); + useInteractionStore.getState().submitConfirmationStart(); try { const result = await submitConfirmation( ctx.assistantId, @@ -624,11 +617,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 241e64cd6ed..96ae42c4c0f 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, useTurnStore } 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>; @@ -155,7 +154,6 @@ export function useSendMessage({ setMessages, setError, dispatchConversationList, - dispatchInteraction, setStreamRetryNonce, setInput, dispatchSubagent, @@ -316,13 +314,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 { @@ -381,10 +379,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; }); @@ -423,7 +421,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 @@ -607,7 +605,7 @@ export function useSendMessage({ useTurnStore.getState().cancelGeneration(); 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 64dc7b4227e..65e0a4a0ae9 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 { @@ -114,7 +113,6 @@ export interface UseStreamEventHandlerParams { startReconciliationLoop: (epoch: number) => void; // --- Interaction state (secret, confirmation, contact request) --- - dispatchInteraction: Dispatch; confirmationToolCallMapRef: MutableRefObject>; // --- Subagent state --- @@ -193,7 +191,6 @@ export function useStreamEventHandler( streamRef, cancelReconciliation, startReconciliationLoop, - dispatchInteraction, confirmationToolCallMapRef, dispatchSubagent, @@ -302,7 +299,6 @@ export function useStreamEventHandler( streamRef, cancelReconciliation, startReconciliationLoop, - dispatchInteraction, confirmationToolCallMapRef, dispatchSubagent, @@ -474,7 +470,6 @@ export function useStreamEventHandler( needsNewBubbleRef, 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 511309904fe..9d0280688af 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,51 +8,46 @@ 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" }, ctx, ); expect(ctx.turnActions.onSecretRequest).toHaveBeenCalled(); - 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?" }, ctx, ); expect(ctx.turnActions.onConfirmationRequest).toHaveBeenCalled(); - 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" }, ctx, ); expect(ctx.turnActions.onContactRequest).toHaveBeenCalled(); - 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 dd54af094e7..6dee3dc6074 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.turnActions.onSecretRequest(); - 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.turnActions.onContactRequest(); - 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.turnActions.onQuestionRequest(); - 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 9015f3e82f9..f689d76995f 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 @@ -52,7 +52,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 758b990a66c..befa31254d8 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..690cb6d24a6 100644 --- a/apps/web/src/domains/interactions/interaction-store.ts +++ b/apps/web/src/domains/interactions/interaction-store.ts @@ -1,13 +1,19 @@ /** - * 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 { createSelectors } from "@/utils/create-selectors.js"; + import type { PendingSecretState, PendingConfirmationState, @@ -40,7 +46,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 +126,111 @@ 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 interface DismissSecret { - type: "DISMISS_SECRET"; -} +const useInteractionStoreBase = create()((set, get) => ({ + ...INITIAL_STATE, + + // ----- Secret ----- + showSecret: (payload) => + set({ pendingSecret: payload, isSubmittingSecret: false, secretSaved: false }), -/** 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; -} + submitSecretStart: () => + set({ isSubmittingSecret: true }), -export interface ShowConfirmation { - type: "SHOW_CONFIRMATION"; - payload: PendingConfirmationState; -} - -export interface SubmitConfirmationStart { - type: "SUBMIT_CONFIRMATION_START"; -} + submitSecretEnd: (saved) => + set({ isSubmittingSecret: false, secretSaved: saved ?? false }), -export interface SubmitConfirmationEnd { - type: "SUBMIT_CONFIRMATION_END"; -} + dismissSecret: () => + set({ pendingSecret: null, isSubmittingSecret: false }), -export interface DismissConfirmation { - type: "DISMISS_CONFIRMATION"; -} + updateSecret: (requestId, patch) => { + const { pendingSecret } = get(); + if (!pendingSecret || pendingSecret.requestId !== requestId) return; + set({ pendingSecret: { ...pendingSecret, ...patch } }); + }, -/** 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; -} + // ----- Confirmation ----- + showConfirmation: (payload) => + set({ pendingConfirmation: payload, isSubmittingConfirmation: false }), -/** Conditionally update the pending confirmation — only applies if the current - * requestId matches. */ -export interface UpdateConfirmation { - type: "UPDATE_CONFIRMATION"; - requestId: string; - patch: Partial; -} + submitConfirmationStart: () => + set({ isSubmittingConfirmation: true }), -export interface SetInlineConfirmationToolCallId { - type: "SET_INLINE_CONFIRMATION_TOOL_CALL_ID"; - toolCallId: string | null; -} + submitConfirmationEnd: () => + set({ isSubmittingConfirmation: false }), -export interface ShowContactRequest { - type: "SHOW_CONTACT_REQUEST"; - payload: PendingContactRequestState; -} + dismissConfirmation: () => + set({ pendingConfirmation: null, isSubmittingConfirmation: false }), -export interface SubmitContactRequestStart { - type: "SUBMIT_CONTACT_REQUEST_START"; -} + dismissConfirmationIfMatches: (requestId) => { + const { pendingConfirmation } = get(); + if (!pendingConfirmation || pendingConfirmation.requestId !== requestId) return; + set({ pendingConfirmation: null, isSubmittingConfirmation: false }); + }, -export interface SubmitContactRequestEnd { - type: "SUBMIT_CONTACT_REQUEST_END"; -} + updateConfirmation: (requestId, patch) => { + const { pendingConfirmation } = get(); + if (!pendingConfirmation || pendingConfirmation.requestId !== requestId) return; + set({ pendingConfirmation: { ...pendingConfirmation, ...patch } }); + }, -export interface DismissContactRequest { - type: "DISMISS_CONTACT_REQUEST"; -} + setInlineConfirmationToolCallId: (toolCallId) => + set({ inlineConfirmationToolCallId: toolCallId }), -export interface AcceptContactRequest { - type: "ACCEPT_CONTACT_REQUEST"; -} + // ----- Contact request ----- + showContactRequest: (payload) => + set({ + pendingContactRequest: payload, + isSubmittingContactRequest: false, + contactRequestAccepted: false, + }), -export interface ShowQuestion { - type: "SHOW_QUESTION"; - payload: PendingQuestionState; -} + submitContactRequestStart: () => + set({ isSubmittingContactRequest: true }), -export interface SubmitQuestionStart { - type: "SUBMIT_QUESTION_START"; -} + submitContactRequestEnd: () => + set({ isSubmittingContactRequest: false }), -export interface SubmitQuestionEnd { - type: "SUBMIT_QUESTION_END"; -} + dismissContactRequest: () => + set({ pendingContactRequest: null, isSubmittingContactRequest: false }), -/** Clear question state entirely (e.g. after successful submission). */ -export interface DismissQuestion { - type: "DISMISS_QUESTION"; -} + acceptContactRequest: () => + set({ contactRequestAccepted: 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"; -} + // ----- Question ----- + showQuestion: (payload) => + set({ pendingQuestion: payload, isSubmittingQuestion: false, isQuestionCardDismissed: 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"; -} + submitQuestionStart: () => + set({ isSubmittingQuestion: true }), -/** Clear all interaction state — used on conversation switch. */ -export interface ResetAll { - type: "RESET_ALL"; -} + submitQuestionEnd: () => + set({ isSubmittingQuestion: false }), -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; + dismissQuestion: () => + set({ pendingQuestion: null, isSubmittingQuestion: false, isQuestionCardDismissed: false }), -// --------------------------------------------------------------------------- -// Reducer -// --------------------------------------------------------------------------- + dismissQuestionCard: () => + set({ isQuestionCardDismissed: true }), -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; - } -} + // ----- 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), +})); + +export const useInteractionStore = createSelectors(useInteractionStoreBase);