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
44 changes: 1 addition & 43 deletions apps/web/.cross-domain-allowlist.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,30 +2,21 @@
"src/domains/chat/api/messages.ts": [
"onboarding"
],
"src/domains/chat/api/stream.test.ts": [
"messaging"
],
"src/domains/chat/chat-layout.tsx": [
"conversations",
"subagents"
],
"src/domains/chat/chat-page.tsx": [
"conversations",
"interactions",
"messaging",
"onboarding",
"subagents"
],
"src/domains/chat/components/chat-composer/chat-composer.test.tsx": [
"messaging"
],
"src/domains/chat/components/chat-composer/chat-composer.tsx": [
"messaging",
"voice"
],
"src/domains/chat/components/chat-route-content.tsx": [
"interactions",
"messaging",
"subagents"
],
"src/domains/chat/components/mobile-subagent-detail-overlay.tsx": [
Expand Down Expand Up @@ -57,45 +48,24 @@
"subagents"
],
"src/domains/chat/hooks/use-conversation-switch.ts": [
"interactions",
"messaging"
],
"src/domains/chat/hooks/use-event-stream.ts": [
"messaging"
"interactions"
],
"src/domains/chat/hooks/use-interaction-actions.ts": [
"interactions",
"messaging",
"trust-rules"
],
"src/domains/chat/hooks/use-message-queue.ts": [
"messaging"
],
"src/domains/chat/hooks/use-message-reconciliation.test.tsx": [
"messaging"
],
"src/domains/chat/hooks/use-message-reconciliation.ts": [
"messaging"
],
"src/domains/chat/hooks/use-send-message.ts": [
"conversations",
"interactions",
"messaging",
"onboarding",
"subagents"
],
"src/domains/chat/hooks/use-stream-event-handler.ts": [
"messaging"
],
"src/domains/chat/hooks/use-subagent-card-data.test.ts": [
"subagents"
],
"src/domains/chat/hooks/use-subagent-card-data.ts": [
"subagents"
],
"src/domains/chat/hooks/use-tool-call-card-data.ts": [
"messaging"
],
"src/domains/chat/hooks/use-voice-input.ts": [
"voice"
],
Expand All @@ -105,12 +75,6 @@
"src/domains/chat/transcript/transcript-subagent-inline.test.tsx": [
"subagents"
],
"src/domains/chat/utils/debug-api.test.ts": [
"messaging"
],
"src/domains/chat/utils/debug-api.ts": [
"messaging"
],
"src/domains/chat/utils/stream-handlers/interaction-handlers.test.ts": [
"interactions"
],
Expand All @@ -135,12 +99,6 @@
"src/domains/chat/utils/stream-handlers/subagent-handlers.ts": [
"subagents"
],
"src/domains/chat/utils/stream-handlers/test-helpers.ts": [
"messaging"
],
"src/domains/chat/utils/stream-handlers/types.ts": [
"messaging"
],
"src/domains/conversations/use-attention-tracking.ts": [
"chat"
],
Expand Down
2 changes: 2 additions & 0 deletions apps/web/docs/CONVENTIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,8 @@ src/
viewer-store.ts
sse-connected-store.ts
conversation-store.ts
turn-store.ts
turn-coordinator.ts # atomic turn-store + conversation-store transitions
domains/ # feature modules
messages/ # message lifecycle
message-store.ts
Expand Down
32 changes: 29 additions & 3 deletions apps/web/docs/STATE_MANAGEMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,12 +178,17 @@ References:
- [web.dev — Sign-out best practices](https://web.dev/articles/sign-out-best-practices)
- [React — Preserving and Resetting State](https://react.dev/learn/preserving-and-resetting-state)

## Turn state lives in `domains/messaging/turn-store.ts`
## Turn state lives in `stores/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,
by the turn store at `src/stores/turn-store.ts`. It sits at the top
level because the same store is read by chat handlers, send-message
flow, error handlers, and reconciliation — per the
"two-or-more domains → top-level" rule above.

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
Expand All @@ -192,6 +197,27 @@ Action naming follows the
`onPollReconciled`), imperative for user/system-initiated actions
(`requestSend`, `cancelGeneration`, `resetTurn`).

### Terminal-turn cleanup goes through `endTurn`

A turn's "complete" state is split between two stores: `turn-store.phase`
(the active turn's lifecycle, one per tab) and
`conversation-store.processingConversationIds` (the sidebar's view of
which conversations are processing — includes background conversations
from Slack, Telegram, etc.). Both must transition on every terminal
event.

Production callers do **not** call `turnStore.completeTurn()` /
`cancelGeneration()` / `onStreamError()` / `onSessionError()` /
`onPollReconciled()` directly. They call `endTurn` from
[`stores/turn-coordinator.ts`](../src/stores/turn-coordinator.ts) — a
single atomic two-store transition that takes the `conversationId` and
a terminal `reason`. This prevents the "forget to clear the processing
key" class of bug from re-appearing in every new terminal-event path.

The turn-store still exports the underlying actions because they're
the implementation `endTurn` delegates to (and tests sometimes spy on
them directly), but new production callers should use `endTurn`.

## Selector patterns

**New code uses atomic selectors via `createSelectors`** — see the next
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/domains/chat/api/stream.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ import {
INITIAL_TURN_STATE,
turnReducer,
isSending,
} from "@/domains/messaging/turn-store";
} from "@/stores/turn-store";
import { parseAssistantEvent } from "@/domains/chat/api/event-parser";
import { subscribeChatEvents, type ChatStreamReconnectCause } from "@/domains/chat/api/stream";
import { useAssistantIdentityStore } from "@/stores/assistant-identity-store";
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/domains/chat/chat-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ import type { ContextWindowUsage } from "@/domains/chat/components/context-windo
import type { TranscriptHandle } from "@/domains/chat/transcript/transcript";
import type { TranscriptItem } from "@/domains/chat/transcript/types";
import type { TranscriptPaginationState } from "@/domains/chat/transcript/types";
import { type UIContext } from "@/domains/messaging/turn-selectors";
import { type UIContext } from "@/stores/turn-selectors";
import { peekPendingPreChatContext, type PreChatOnboardingContext } from "@/domains/onboarding/prechat";
import { createDraftConversationId } from "@/domains/chat/utils/conversation-selection";
import type { WebSyncRouter } from "@/lib/sync/web-sync-router";
Expand Down Expand Up @@ -99,7 +99,7 @@ import { useQueryClient } from "@tanstack/react-query";
import { shouldSuppressGenericChatErrorNotice } from "@/domains/chat/utils/error-classification";
import { hasPendingAssistantResponse } from "@/domains/chat/utils/chat-utils";
import { isSurfaceInteractive } from "@/domains/chat/types/types";
import { useTurnStore } from "@/domains/messaging/turn-store";
import { useTurnStore } from "@/stores/turn-store";
import { isChannelConversation } from "@/domains/chat/utils/conversation-channel";
import { buildMoveToGroupTargets } from "@/domains/chat/utils/group-conversations";
import { ConversationActionsMenu } from "@/domains/chat/components/conversation-actions-menu";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { createRef } from "react";
import { cleanup, render } from "@testing-library/react";

import type { ChatAttachment } from "@/domains/chat/components/chat-attachments/use-chat-attachments";
import { INITIAL_TURN_STATE, type TurnState, useTurnStore } from "@/domains/messaging/turn-store";
import { INITIAL_TURN_STATE, type TurnState, useTurnStore } from "@/stores/turn-store";

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

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";
import { type TurnPhase, useTurnStore } from "@/domains/messaging/turn-store";
import { type TurnPhase, useTurnStore } from "@/stores/turn-store";
import { useIsMobile } from "@/hooks/use-is-mobile";
import { isPointerCoarse } from "@/utils/pointer";
import { useAudioAmplitude } from "@/domains/voice/use-audio-amplitude";
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/domains/chat/components/chat-route-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ import {
isSendDisabled,
shouldShowThinkingIndicator,
type UIContext,
} from "@/domains/messaging/turn-selectors";
} from "@/stores/turn-selectors";
import { isSurfaceInteractive } from "@/domains/chat/types/types";

import { useViewerStore, type MainView, type OpenedAppState, type OpenedDocumentState } from "@/stores/viewer-store";
Expand All @@ -110,7 +110,7 @@ import { haptic } from "@/utils/haptics";
import { isChannelConversation as _isChannelConversation } from "@/domains/chat/utils/conversation-channel";
import { getDiskPressureChatBlockReason } from "@/assistant/disk-pressure";
import type { DiskPressureStatusEventPayload } from "@/assistant/use-disk-pressure-monitor";
import { type TurnState, useTurnStore } from "@/domains/messaging/turn-store";
import { type TurnState, useTurnStore } from "@/stores/turn-store";
import type { QuestionResponseEntry, AllowlistOption, ScopeOption, DirectoryScopeOption, ConfirmationDecision } from "@/domains/chat/api/event-types";
import type { CharacterComponents, CharacterTraits } from "@/types/avatar";
import { DiskPressureBanner, type DiskPressureBannerMode } from "@/domains/chat/components/disk-pressure-banner";
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/domains/chat/hooks/use-conversation-switch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {
useRef,
} from "react";

import { useTurnStore } from "@/domains/messaging/turn-store";
import { useTurnStore } from "@/stores/turn-store";
import { useInteractionStore } from "@/domains/interactions/interaction-store";
import { useConversationStore } from "@/stores/conversation-store";
import { recordChatDiagnostic } from "@/domains/chat/utils/diagnostics";
Expand Down
15 changes: 6 additions & 9 deletions apps/web/src/domains/chat/hooks/use-event-stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,12 @@ import type {
WebSyncRouter,
} from "@/lib/sync/web-sync-router";

import { useConversationStore } from "@/stores/conversation-store";
import { endTurn } from "@/stores/turn-coordinator";
import type { DisplayMessage } from "@/domains/chat/utils/reconcile";
import {
isSending,
useTurnStore,
} from "@/domains/messaging/turn-store";
} from "@/stores/turn-store";
import type { ChatEventStream } from "@/domains/chat/api/stream";
import { useEventBusStore } from "@/stores/event-bus-store";
import type { UseAssistantReachabilityResult } from "@/assistant/use-assistant-reachability";
Expand Down Expand Up @@ -448,13 +448,10 @@ export function useEventStream({
epoch: streamEpochRef.current,
messageLength: reason.length,
});
useTurnStore.getState().onSessionError();
{
const convId = streamContextRef.current?.conversationId;
if (convId) {
useConversationStore.getState().removeProcessingConversationId(convId);
}
}
endTurn({
conversationId: streamContextRef.current?.conversationId,
reason: "session_error",
});
// Idle SSE drops should reopen the stream without interrupting the
// user; active turns still surface the reconnect state immediately.
if (hadActiveTurn) {
Expand Down
10 changes: 7 additions & 3 deletions apps/web/src/domains/chat/hooks/use-interaction-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ import { addTrustRule } from "@/domains/trust-rules/api";
import type { DisplayMessage } from "@/domains/chat/utils/reconcile";
import { useInteractionStore } from "@/domains/interactions/interaction-store";
import { useConversationStore } from "@/stores/conversation-store";
import { useTurnStore } from "@/domains/messaging/turn-store";
import { useTurnStore } from "@/stores/turn-store";
import { endTurn } from "@/stores/turn-coordinator";

import { clearConfirmationByRequestId } from "@/domains/chat/hooks/send-message-utils";
import { deriveCommandText } from "@/domains/chat/utils/chat-utils";
Expand Down Expand Up @@ -183,7 +184,7 @@ export function useInteractionActions({
if (convKey) {
useConversationStore.getState().removeAttentionConversationId(convKey);
}
useTurnStore.getState().onStreamError();
endTurn({ conversationId: convKey, reason: "error" });
}, []);

// -------------------------------------------------------------------------
Expand Down Expand Up @@ -236,7 +237,10 @@ export function useInteractionActions({

const handleContactPromptCancel = useCallback(() => {
useInteractionStore.getState().dismissContactRequest();
useTurnStore.getState().onStreamError();
endTurn({
conversationId: useConversationStore.getState().activeConversationId,
reason: "error",
});
}, []);

// -------------------------------------------------------------------------
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/domains/chat/hooks/use-message-queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import {

import type { DisplayMessage } from "@/domains/chat/utils/reconcile";
import { clearQueueStatus } from "@/domains/chat/hooks/stream-message-updaters";
import { useTurnStore } from "@/domains/messaging/turn-store";
import { useTurnStore } from "@/stores/turn-store";
import { deleteQueuedMessage, steerToMessage } from "@/domains/chat/api/messages";

// ---------------------------------------------------------------------------
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { renderToStaticMarkup } from "react-dom/server";
import { createElement, type Dispatch, type RefObject, type SetStateAction } from "react";

import type { DisplayMessage } from "@/domains/chat/utils/reconcile";
import { INITIAL_TURN_STATE, type TurnState, useTurnStore } from "@/domains/messaging/turn-store";
import { INITIAL_TURN_STATE, type TurnState, useTurnStore } from "@/stores/turn-store";
import { useConversationStore } from "@/stores/conversation-store";

// ---------------------------------------------------------------------------
Expand Down
31 changes: 15 additions & 16 deletions apps/web/src/domains/chat/hooks/use-message-reconciliation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@ import {
summarizeRuntimeMessages,
} from "@/domains/chat/utils/diagnostics";
import { type DisplayMessage, reconcileMessages } from "@/domains/chat/utils/reconcile";
import { isSending, useTurnStore } from "@/domains/messaging/turn-store";
import { isSending, useTurnStore } from "@/stores/turn-store";
import { fetchConversationMessages, type RuntimeMessage } from "@/domains/chat/api/messages";
import { useConversationStore } from "@/stores/conversation-store";
import { endTurn } from "@/stores/turn-coordinator";

const RECONCILE_DELAY_MS = 5000;
const RECONCILE_MAX_MS = 60_000;
Expand Down Expand Up @@ -224,21 +225,19 @@ export function useMessageReconciliation({
isSending(useTurnStore.getState()) &&
useTurnStore.getState().activeTurnId === snapshotTurnId;
if (wasStuck) {
useTurnStore.getState().onPollReconciled(snapshotTurnId);
// The rescue must also clear the conversation-level processing
// key — `processingConversationIds` is set at send time and is
// normally cleared by the SSE terminal-event handlers
// (`handleAssistantActivityState(idle)`, `handleMessageComplete`,
// `handleGenerationCancelled`, error handlers). When SSE drops
// the terminal event, those handlers never run, and the
// graduation effect in `useAttentionTracking` explicitly skips
// the active conversation. Without this call the rescue would
// leave `activeConversationIsProcessing` stuck at `true` — which
// keeps `canStopGeneration` true and the sidebar processing dot
// visible even though the turn has clearly completed.
useConversationStore
.getState()
.removeProcessingConversationId(snapshotConversationId);
// The rescue must clear BOTH the turn-store (so the local
// lifecycle becomes idle) AND the conversation-level processing
// key (so `canStopGeneration` and the sidebar processing dot
// can settle). `endTurn` does both atomically — without that
// pairing the rescue would leave `activeConversationIsProcessing`
// stuck because the graduation effect in `useAttentionTracking`
// explicitly skips the active conversation, making this the
// only path that clears it when SSE drops the terminal event.
endTurn({
conversationId: snapshotConversationId,
reason: "rescued",
rescuedTurnId: snapshotTurnId,
});
// `POLL_RECONCILED` is the silent-stall rescue: the server
// reports assistant progress that the client never observed
// via SSE, meaning a terminal event (`message_complete`
Expand Down
Loading