Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 0 additions & 13 deletions apps/web/src/domains/chat/chat-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,13 @@ import {
type RefObject,
useCallback,
useEffect,
useReducer,
useRef,
useState,
} from "react";

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,
Expand Down Expand Up @@ -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] =
Expand Down Expand Up @@ -99,7 +89,6 @@ export function ChatPage() {
activeConversationKey: null,
assistantId,
sendMessage,
dispatchInteraction,
});

if (authLoading || assistantState.kind === "loading") {
Expand Down Expand Up @@ -137,8 +126,6 @@ export function ChatPage() {
error,
setError,
isLoadingHistory: false,
interactionState,
dispatchInteraction,
conversations: [],
activeConversationKey: null,
activeConversation: undefined,
Expand Down
3 changes: 0 additions & 3 deletions apps/web/src/domains/chat/chat-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ beforeEach(() => {
activeConversationKey: null,
assistantId: null,
sendMessage: async () => {},
dispatchInteraction: () => {},
}, true);
});

Expand All @@ -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", () => {
Expand Down Expand Up @@ -57,7 +55,6 @@ describe("useChatStore", () => {
activeConversationKey: "conv-abc",
assistantId: "ast-new",
sendMessage: async () => {},
dispatchInteraction: () => {},
};
useChatStore.setState(fullState, true);

Expand Down
20 changes: 7 additions & 13 deletions apps/web/src/domains/chat/chat-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<void>;
/** Dispatch an interaction state-machine event. */
dispatchInteraction: Dispatch<InteractionEvent>;
}

export type ChatStore = ChatState & ChatActions;
Expand All @@ -51,14 +51,12 @@ export type ChatStore = ChatState & ChatActions;
// ---------------------------------------------------------------------------

const NOOP_SEND: ChatActions["sendMessage"] = async () => {};
const NOOP_DISPATCH: Dispatch<never> = () => {};

export const useChatStore = create<ChatStore>()(() => ({
messages: [],
activeConversationKey: null,
assistantId: null,
sendMessage: NOOP_SEND,
dispatchInteraction: NOOP_DISPATCH as Dispatch<InteractionEvent>,
}));

// ---------------------------------------------------------------------------
Expand All @@ -70,7 +68,6 @@ export interface ChatStoreSyncProps {
activeConversationKey: string | null;
assistantId: string | null;
sendMessage: (content: string, attachments?: DisplayAttachment[]) => Promise<void>;
dispatchInteraction: Dispatch<InteractionEvent>;
}

/**
Expand All @@ -84,7 +81,6 @@ export function useSyncChatStore(props: ChatStoreSyncProps): void {
activeConversationKey,
assistantId,
sendMessage,
dispatchInteraction,
} = props;

useEffect(() => {
Expand All @@ -98,9 +94,8 @@ export function useSyncChatStore(props: ChatStoreSyncProps): void {
useEffect(() => {
useChatStore.setState({
sendMessage,
dispatchInteraction,
});
}, [sendMessage, dispatchInteraction]);
}, [sendMessage]);
}

// ---------------------------------------------------------------------------
Expand All @@ -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,
})),
);
}
Expand Down
42 changes: 18 additions & 24 deletions apps/web/src/domains/chat/components/chat-route-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -224,7 +223,6 @@ export interface ChatRouteRefs {
pendingLocalDeletionsRef: MutableRefObject<Set<string>>;
confirmationToolCallMapRef: MutableRefObject<Map<string, string>>;

interactionStateRef: MutableRefObject<InteractionState>;
reconcileAfterNextStreamOpenRef: MutableRefObject<boolean>;
}

Expand Down Expand Up @@ -262,9 +260,7 @@ export interface ChatRouteContentProps {
// Loading
isLoadingHistory: boolean;

// Interaction
interactionState: InteractionState;
dispatchInteraction: Dispatch<InteractionEvent>;


// Conversation
conversations: Conversation[];
Expand Down Expand Up @@ -384,8 +380,6 @@ export function ChatRouteContent({
error,
setError,
isLoadingHistory,
interactionState,
dispatchInteraction,
conversations: _conversations,
activeConversationKey,
activeConversation,
Expand Down Expand Up @@ -503,7 +497,7 @@ export function ChatRouteContent({
requestIdToStableIdRef: _requestIdToStableIdRef,
pendingLocalDeletionsRef: _pendingLocalDeletionsRef,
confirmationToolCallMapRef: _confirmationToolCallMapRef,
interactionStateRef,

reconcileAfterNextStreamOpenRef: _reconcileAfterNextStreamOpenRef,
} = refs;

Expand All @@ -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;

// -------------------------------------------------------------------------
Expand Down Expand Up @@ -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;
Expand All @@ -951,7 +945,7 @@ export function ChatRouteContent({
tags: { context: "submit_question_response_close" },
});
});
}, [dispatchInteraction, interactionStateRef, streamContextRef]);
}, [streamContextRef]);

// -------------------------------------------------------------------------
// Empty state placeholder (stable per mount)
Expand Down
18 changes: 7 additions & 11 deletions apps/web/src/domains/chat/hooks/use-conversation-history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -101,7 +101,7 @@ interface UseConversationHistoryParams {
inputRef: MutableRefObject<HTMLTextAreaElement | null>;
draftsRef: MutableRefObject<Map<string, string>>;
messagesRef: MutableRefObject<DisplayMessage[]>;
interactionStateRef: MutableRefObject<InteractionState>;

contextWindowUsageByConversationRef: MutableRefObject<Map<string, ContextWindowUsage>>;
dismissedSurfaceIdsRef: MutableRefObject<Set<string>>;
needsNewBubbleRef: MutableRefObject<boolean>;
Expand All @@ -124,7 +124,6 @@ interface UseConversationHistoryParams {
setTranscriptPagination: Dispatch<SetStateAction<Omit<TranscriptPaginationState, "items">>>;
setIsLoadingHistory: Dispatch<SetStateAction<boolean>>;
setError: Dispatch<SetStateAction<ChatError | null>>;
dispatchInteraction: Dispatch<InteractionEvent>;
setAutoGreetPending: Dispatch<SetStateAction<boolean>>;
setContextWindowUsage: Dispatch<SetStateAction<ContextWindowUsage | null>>;
setSuggestion: Dispatch<SetStateAction<string | null>>;
Expand Down Expand Up @@ -180,7 +179,6 @@ export function useConversationHistory({
inputRef,
draftsRef,
messagesRef,
interactionStateRef,
contextWindowUsageByConversationRef,
dismissedSurfaceIdsRef,
needsNewBubbleRef,
Expand All @@ -201,7 +199,6 @@ export function useConversationHistory({
setTranscriptPagination,
setIsLoadingHistory,
setError,
dispatchInteraction,
setAutoGreetPending,
setContextWindowUsage,
setSuggestion,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -339,7 +337,7 @@ export function useConversationHistory({
isLoadingOlder: false,
isPinnedToLatest: true,
});
dispatchInteraction({ type: "RESET_ALL" });
useInteractionStore.getState().resetAll();
confirmationToolCallMapRef.current.clear();
setAutoGreetPending(false);
resetChatAttachments();
Expand Down Expand Up @@ -393,15 +391,15 @@ export function useConversationHistory({
interactions.pendingSecret as Record<string, unknown>,
);
if (loadEpochRef.current === epoch) {
dispatchInteraction({ type: "SHOW_SECRET", payload: parsed });
useInteractionStore.getState().showSecret(parsed);
}
}
if (interactions.pendingConfirmation) {
const { state } = parsePendingConfirmationData(
interactions.pendingConfirmation as Record<string, unknown>,
);
if (loadEpochRef.current === epoch) {
dispatchInteraction({ type: "SHOW_CONFIRMATION", payload: state });
useInteractionStore.getState().showConfirmation(state);
}
}
if (!interactions.pendingSecret && !interactions.pendingConfirmation) {
Expand Down Expand Up @@ -643,7 +641,6 @@ export function useConversationHistory({
inputRef,
draftsRef,
messagesRef,
interactionStateRef,
contextWindowUsageByConversationRef,
dismissedSurfaceIdsRef,
needsNewBubbleRef,
Expand All @@ -665,7 +662,6 @@ export function useConversationHistory({
setIsLoadingHistory,
dispatchConversationList,
setError,
dispatchInteraction,
setAutoGreetPending,
setContextWindowUsage,
setSuggestion,
Expand Down
Loading