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
14 changes: 14 additions & 0 deletions apps/web/CONVENTIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,20 @@ References:
- [Zustand — Reading/writing state outside components](https://zustand.docs.pmnd.rs/guides/reading-and-writing-state-outside-components)
- [React Router — Middleware](https://reactrouter.com/how-to/middleware)

### Turn state lives in `domains/messaging/turn-store.ts`

Turn lifecycle (sending, thinking, streaming, idle, errored), queue
depth, active tool-call count, and current turn identity are managed
by the turn store. Use `useTurnStore(selector)` in React components
and `useTurnStore.getState()` in non-React code (stream handlers,
reconciliation). Do not prop-drill turn state or dispatch functions.

Action naming follows the
[Flux-inspired practice](https://zustand.docs.pmnd.rs/learn/guides/flux-inspired-practice):
`on*` for SSE-event reactions (`onTextDelta`, `onStreamError`,
`onPollReconciled`), imperative for user/system-initiated actions
(`requestSend`, `cancelGeneration`, `resetTurn`).

### Selector patterns and `useShallow`

Selectors control re-render granularity. Choose the right pattern based
Expand Down
11 changes: 6 additions & 5 deletions apps/web/src/domains/chat/chat-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
type MutableRefObject,
type RefObject,
useCallback,
useEffect,
useReducer,
useRef,
useState,
Expand All @@ -15,8 +16,8 @@ import {
INITIAL_INTERACTION_STATE,
} from "@/domains/interactions/interaction-store.js";
import {
turnReducer,
INITIAL_TURN_STATE,
useTurnStore,
} from "@/domains/messaging/turn-store.js";
import type { DisplayMessage } from "@/domains/chat/lib/reconcile.js";
import { useSyncChatStore } from "@/domains/chat/chat-store.js";
Expand Down Expand Up @@ -54,7 +55,10 @@ export function ChatPage() {
const { assistantState, assistantId } = lifecycle;

const [messages, setMessages] = useState<DisplayMessage[]>([]);
const [turnState, dispatchTurn] = useReducer(turnReducer, INITIAL_TURN_STATE);
useEffect(() => {
useTurnStore.setState(INITIAL_TURN_STATE);
}, []);

const [interactionState, dispatchInteraction] = useReducer(
interactionReducer,
INITIAL_INTERACTION_STATE,
Expand Down Expand Up @@ -94,7 +98,6 @@ export function ChatPage() {
activeConversationKey: null,
assistantId,
sendMessage,
dispatchTurn,
dispatchInteraction,
});

Expand Down Expand Up @@ -128,8 +131,6 @@ export function ChatPage() {
isKeyboardOpen: false,
messages,
setMessages,
turnState,
dispatchTurn,
input,
setInput,
error,
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 () => {},
dispatchTurn: () => {},
dispatchInteraction: () => {},
}, true);
});
Expand All @@ -24,7 +23,6 @@ describe("useChatStore", () => {
it("initializes with noop action refs", () => {
const state = useChatStore.getState();
expect(typeof state.sendMessage).toBe("function");
expect(typeof state.dispatchTurn).toBe("function");
expect(typeof state.dispatchInteraction).toBe("function");
});

Expand Down Expand Up @@ -59,7 +57,6 @@ describe("useChatStore", () => {
activeConversationKey: "conv-abc",
assistantId: "ast-new",
sendMessage: async () => {},
dispatchTurn: () => {},
dispatchInteraction: () => {},
};
useChatStore.setState(fullState, true);
Expand Down
12 changes: 2 additions & 10 deletions apps/web/src/domains/chat/chat-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ import { create } from "zustand";
import { useShallow } from "zustand/shallow";

import type { DisplayAttachment, DisplayMessage } from "@/domains/chat/lib/reconcile.js";
import type { DomainEvent } from "@/domains/messaging/turn-store.js";
import type { InteractionEvent } from "@/domains/interactions/interaction-store.js";

// ---------------------------------------------------------------------------
Expand All @@ -41,8 +40,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 a turn state-machine event. */
dispatchTurn: Dispatch<DomainEvent>;
/** Dispatch an interaction state-machine event. */
dispatchInteraction: Dispatch<InteractionEvent>;
}
Expand All @@ -61,7 +58,6 @@ export const useChatStore = create<ChatStore>()(() => ({
activeConversationKey: null,
assistantId: null,
sendMessage: NOOP_SEND,
dispatchTurn: NOOP_DISPATCH as Dispatch<DomainEvent>,
dispatchInteraction: NOOP_DISPATCH as Dispatch<InteractionEvent>,
}));

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

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

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

// ---------------------------------------------------------------------------
Expand All @@ -130,14 +123,13 @@ export function useChatState(): ChatState {
}

/**
* Stable action dispatchers (sendMessage, dispatchTurn, dispatchInteraction).
* Stable action dispatchers (sendMessage, dispatchInteraction).
* Does **not** re-render when messages or active conversation change.
*/
export function useChatActions(): ChatActions {
return useChatStore(
useShallow((s) => ({
sendMessage: s.sendMessage,
dispatchTurn: s.dispatchTurn,
dispatchInteraction: s.dispatchInteraction,
})),
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { renderToStaticMarkup } from "react-dom/server";
import {
type ChatAttachment,
} from "@/domains/chat/components/chat-attachments/index.js";
import { INITIAL_TURN_STATE, type TurnState } from "@/domains/messaging/turn-store.js";
import { INITIAL_TURN_STATE, type TurnState, useTurnStore } from "@/domains/messaging/turn-store.js";

import { ChatComposer, computeGhostSuffix, shouldSubmitOnEnter } from "@/domains/chat/components/chat-composer/chat-composer.js";

Expand Down Expand Up @@ -294,7 +294,6 @@ function renderComposer(props: Partial<Parameters<typeof ChatComposer>[0]> = {})
chatAttachments={[]}
onAddAttachmentFiles={() => {}}
onRemoveAttachment={() => {}}
turnState={INITIAL_TURN_STATE}
onStopGenerating={() => {}}
assistantId="asst_test"
{...props}
Expand All @@ -316,41 +315,45 @@ describe("ChatComposer — placeholder", () => {

describe("ChatComposer — send/stop button visibility", () => {
test("idle state renders a Send button (aria-label='Send message')", () => {
const html = renderComposer({ turnState: INITIAL_TURN_STATE });
useTurnStore.setState(INITIAL_TURN_STATE);
const html = renderComposer();
expect(html).toContain('aria-label="Send message"');
expect(html).not.toContain('aria-label="Stop generating"');
});

test("isSending(turnState)=true on desktop renders only the Stop button (send/attach/voice hidden)", () => {
test("isSending=true on desktop renders only the Stop button (send/attach/voice hidden)", () => {
mockIsMobile = false;
const sending: TurnState = {
...INITIAL_TURN_STATE,
phase: "streaming",
};
const html = renderComposer({ turnState: sending });
useTurnStore.setState(sending);
const html = renderComposer();
expect(html).toContain('aria-label="Stop generating"');
expect(html).not.toContain('aria-label="Send message"');
});

test("isSending(turnState)=true on mobile with no input renders only Stop button", () => {
test("isSending=true on mobile with no input renders only Stop button", () => {
mockIsMobile = true;
const sending: TurnState = {
...INITIAL_TURN_STATE,
phase: "streaming",
};
const html = renderComposer({ turnState: sending, input: "" });
useTurnStore.setState(sending);
const html = renderComposer({ input: "" });
expect(html).toContain('aria-label="Stop generating"');
expect(html).not.toContain('aria-label="Send message"');
mockIsMobile = false;
});

test("isSending(turnState)=true on mobile with user input renders only Send button", () => {
test("isSending=true on mobile with user input renders only Send button", () => {
mockIsMobile = true;
const sending: TurnState = {
...INITIAL_TURN_STATE,
phase: "streaming",
};
const html = renderComposer({ turnState: sending, input: "hello" });
useTurnStore.setState(sending);
const html = renderComposer({ input: "hello" });
expect(html).not.toContain('aria-label="Stop generating"');
expect(html).toContain('aria-label="Send message"');
mockIsMobile = false;
Expand All @@ -363,7 +366,8 @@ describe("ChatComposer — send/stop button visibility", () => {
...INITIAL_TURN_STATE,
phase: "awaiting_user_input",
};
const html = renderComposer({ turnState: awaiting });
useTurnStore.setState(awaiting);
const html = renderComposer();
expect(html).toContain('aria-label="Send message"');
expect(html).not.toContain('aria-label="Stop generating"');
});
Expand Down Expand Up @@ -431,7 +435,6 @@ describe("ChatComposer — Stop button click invokes onStopGenerating", () => {
// captured callback directly.
const onStopGenerating = mock(() => {});
renderComposer({
turnState: { ...INITIAL_TURN_STATE, phase: "streaming" },
onStopGenerating,
});
onStopGenerating();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import {
VoiceInputButton,
type VoiceInputButtonHandle,
} from "@/domains/chat/components/voice-input-button.js";
import { isSending, type TurnState } from "@/domains/messaging/turn-store.js";
import { isSending, useTurnStore } from "@/domains/messaging/turn-store.js";
import { useIsMobile } from "@/hooks/use-is-mobile.js";
import { isPointerCoarse } from "@/utils/pointer.js";
import { useAudioAmplitude } from "@/domains/voice/use-audio-amplitude.js";
Expand Down Expand Up @@ -188,8 +188,6 @@ export interface ChatComposerProps {
onVoiceError?: (code: string | null) => void;
onVoiceBeforeStart?: () => boolean | Promise<boolean>;

// turn state
turnState: TurnState;
onStopGenerating: () => void;

// assistant id used by AttachFileButton's disabled guard
Expand Down Expand Up @@ -249,7 +247,6 @@ export function ChatComposer({
voiceInterim,
onVoiceError,
onVoiceBeforeStart,
turnState,
onStopGenerating,
assistantId,
thresholdPickerSlot,
Expand Down Expand Up @@ -360,6 +357,7 @@ export function ChatComposer({
[emojiState, input, inputRef, setInput, onTextChange],
);

const turnState = useTurnStore();
const isGenerating =
isSending(turnState) && turnState.phase !== "awaiting_user_input";

Expand Down
19 changes: 8 additions & 11 deletions apps/web/src/domains/chat/components/chat-route-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ import { buildTranscriptItems } from "@/domains/chat/lib/transcript/build-items.
import type { TranscriptPaginationState } from "@/domains/chat/lib/transcript/types.js";
import { getThinkingStatusText, isSendDisabled, shouldShowThinkingIndicator, type UIContext } from "@/domains/chat/lib/turn-selectors.js";
import { isSurfaceInteractive } from "@/domains/chat/lib/types.js";
import type { TurnState } from "@/domains/messaging/turn-store.js";

import type { MainView, OpenedAppState, OpenedDocumentState, ViewerState } from "@/stores/viewer-store.js";
import { submitQuestionResponse } from "@/domains/chat/lib/api.js";
import { useActiveProfileModel } from "@/domains/chat/lib/use-active-profile-model.js";
Expand All @@ -85,7 +85,7 @@ import { isChannelConversation as _isChannelConversation } from "@/domains/chat/
import { getDiskPressureChatBlockReason } from "@/domains/assistant/disk-pressure.js";
import type { DiskPressureStatusEventPayload } from "@/domains/assistant/use-disk-pressure-monitor.js";
import type { InteractionEvent } from "@/domains/interactions/interaction-store.js";
import type { DomainEvent } from "@/domains/messaging/turn-store.js";
import { 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";
import { DiskPressureBanner, type DiskPressureBannerMode } from "@/domains/chat/components/disk-pressure-banner.js";
Expand Down Expand Up @@ -223,7 +223,7 @@ export interface ChatRouteRefs {
requestIdToStableIdRef: MutableRefObject<Map<string, string>>;
pendingLocalDeletionsRef: MutableRefObject<Set<string>>;
confirmationToolCallMapRef: MutableRefObject<Map<string, string>>;
turnStateRef: MutableRefObject<TurnState>;

interactionStateRef: MutableRefObject<InteractionState>;
reconcileAfterNextStreamOpenRef: MutableRefObject<boolean>;
}
Expand Down Expand Up @@ -251,10 +251,6 @@ export interface ChatRouteContentProps {
messages: DisplayMessage[];
setMessages: Dispatch<SetStateAction<DisplayMessage[]>>;

// Turn state
turnState: TurnState;
dispatchTurn: Dispatch<DomainEvent>;

// Input
input: string;
setInput: Dispatch<SetStateAction<string>>;
Expand Down Expand Up @@ -383,8 +379,6 @@ export function ChatRouteContent({
isKeyboardOpen,
messages,
setMessages,
turnState,
dispatchTurn: _dispatchTurn,
input,
setInput,
error,
Expand Down Expand Up @@ -509,11 +503,15 @@ export function ChatRouteContent({
requestIdToStableIdRef: _requestIdToStableIdRef,
pendingLocalDeletionsRef: _pendingLocalDeletionsRef,
confirmationToolCallMapRef: _confirmationToolCallMapRef,
turnStateRef: _turnStateRef,
interactionStateRef,
reconcileAfterNextStreamOpenRef: _reconcileAfterNextStreamOpenRef,
} = refs;

// -------------------------------------------------------------------------
// Turn state (read from Zustand store)
// -------------------------------------------------------------------------
const turnState = useTurnStore();

// -------------------------------------------------------------------------
// Derived interaction state
// -------------------------------------------------------------------------
Expand Down Expand Up @@ -1212,7 +1210,6 @@ export function ChatRouteContent({
onVoiceRecordingChange: handleVoiceRecordingChange,
onVoiceError: _setVoiceError,
onVoiceBeforeStart: handleVoiceBeforeStart,
turnState,
onStopGenerating: handleStopGenerating,
assistantId,
modelSupportsVision: activeModelSupportsVision,
Expand Down
8 changes: 3 additions & 5 deletions apps/web/src/domains/chat/hooks/use-conversation-history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import {
} from "@/domains/chat/lib/diagnostics.js";
import type { TranscriptPaginationState } from "@/domains/chat/lib/transcript/types.js";
import type { ContextWindowUsage } from "@/domains/chat/components/context-window-indicator.js";
import type { DomainEvent } from "@/domains/messaging/turn-store.js";
import { useTurnStore } from "@/domains/messaging/turn-store.js";
import type { InteractionEvent, InteractionState } from "@/domains/interactions/interaction-store.js";
import type { ConversationListAction } from "@/domains/conversations/conversation-list-store.js";
import type { SubagentAction } from "@/domains/subagents/subagent-store.js";
Expand Down Expand Up @@ -130,7 +130,7 @@ interface UseConversationHistoryParams {
setSuggestion: Dispatch<SetStateAction<string | null>>;
setCompactionCircuitOpenUntil: Dispatch<SetStateAction<Date | null>>;
setInput: Dispatch<SetStateAction<string>>;
dispatchTurn: Dispatch<DomainEvent>;

dispatchSubagent: Dispatch<SubagentAction>;

// Callbacks
Expand Down Expand Up @@ -207,7 +207,6 @@ export function useConversationHistory({
setSuggestion,
setCompactionCircuitOpenUntil,
setInput,
dispatchTurn,
dispatchSubagent,
resetChatAttachments,
syncNeedsNewBubbleFromMessages,
Expand Down Expand Up @@ -326,7 +325,7 @@ export function useConversationHistory({
previousPagination: transcriptPaginationRef.current,
cacheSize: conversationCacheRef.current.size,
});
dispatchTurn({ type: "TURN_RESET" });
useTurnStore.getState().resetTurn();
setIsLoadingHistory(true);
needsNewBubbleRef.current = true;
setMessages([]);
Expand Down Expand Up @@ -672,7 +671,6 @@ export function useConversationHistory({
setSuggestion,
setCompactionCircuitOpenUntil,
setInput,
dispatchTurn,
dispatchSubagent,
onDraftRestored,
shouldSuppressGenericChatErrorNotice,
Expand Down
Loading