From 5be360cad64e4312147ece35a55d8254a31739a9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 20 May 2026 19:42:17 +0000 Subject: [PATCH 1/4] fix(web): hydrate conversation list at ChatLayout (LUM-1732) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sidebar conversation list is rendered by ChatLayout and visible on every /assistant/* route — chat, home, library, contacts, identity. But the Zustand store backing it was hydrated only by useConversationLoader, which is mounted only inside ChatPage. Direct navigation to any non-chat route mounted ChatLayout without ChatPage, so the init effect never fired and the sidebar stayed empty until the user navigated into /assistant. Extract the list fetch into a new useConversationListInit hook mounted in ChatLayout. The new hook uses TanStack Query (getChatContext) and syncs results into the existing Zustand store, so consumers keep their subscription model. useConversationLoader's init effect now reads from the same query's cache for active-key resolution — no duplicate fetch. The deeper architectural issue — server-derived conversation list living in Zustand instead of TanStack Query — is tracked separately in LUM-1731. https://claude.ai/code/session_013dBXRbLF218UhdLq7FEAvv --- apps/web/src/domains/chat/chat-layout.tsx | 12 +++ .../chat/hooks/use-conversation-loader.ts | 32 +++---- .../use-conversation-list-init.ts | 85 +++++++++++++++++++ 3 files changed, 114 insertions(+), 15 deletions(-) create mode 100644 apps/web/src/domains/conversations/use-conversation-list-init.ts diff --git a/apps/web/src/domains/chat/chat-layout.tsx b/apps/web/src/domains/chat/chat-layout.tsx index 6a84dec5cbc..135562e425e 100644 --- a/apps/web/src/domains/chat/chat-layout.tsx +++ b/apps/web/src/domains/chat/chat-layout.tsx @@ -16,6 +16,8 @@ import { useAssistantLifecycle } from "@/domains/chat/hooks/use-assistant-lifecy import type { AssistantContextValue } from "@/domains/chat/assistant-context.js"; import { useConversationListStore } from "@/domains/conversations/conversation-list-store.js"; +import { useConversationListInit } from "@/domains/conversations/use-conversation-list-init.js"; +import { useFeatureFlagStore } from "@/lib/feature-flags/feature-flag-store.js"; import { OfflineBanner } from "@/components/offline-banner.js"; import { AssistantSideMenu } from "@/domains/chat/components/assistant-side-menu.js"; @@ -113,6 +115,16 @@ export function ChatLayout() { onRedirect: navigate, }); + // Hydrate the sidebar conversation list at the layout level so every + // chat-layout child route (home, library, contacts, identity, chat) + // inherits a populated sidebar on direct navigation — not just /assistant. + const conversationGroupsUI = useFeatureFlagStore.use.conversationGroupsUI(); + useConversationListInit({ + assistantId: lifecycle.assistantId, + assistantStateKind: lifecycle.assistantState.kind, + conversationGroupsUI, + }); + const assistantContext = useMemo( () => ({ assistantId: lifecycle.assistantId, 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 9771050b7a4..b9d8558aadd 100644 --- a/apps/web/src/domains/chat/hooks/use-conversation-loader.ts +++ b/apps/web/src/domains/chat/hooks/use-conversation-loader.ts @@ -37,9 +37,12 @@ import { type HistoryPaginationSnapshot, } from "@/domains/chat/hooks/use-conversation-history.js"; import { useAttentionTracking } from "@/domains/chat/hooks/use-attention-tracking.js"; +import { useQueryClient } from "@tanstack/react-query"; + import { getChatContext } from "@/domains/chat/api/assistant.js"; import { ApiError } from "@/domains/chat/api/client.js"; import { type Conversation, fetchGroups, listConversations } from "@/domains/chat/api/conversations.js"; +import { chatContextQueryKey } from "@/domains/conversations/use-conversation-list-init.js"; // Re-export for consumers that import from this module export { @@ -228,6 +231,7 @@ export function useConversationLoader({ // ------------------------------------------------------------------------- const refreshConversationsRef = useRef<() => Promise>(async () => {}); const hydratedAssistantIdRef = useRef(null); + const queryClient = useQueryClient(); // ------------------------------------------------------------------------- // refreshConversations -- fetch conversation list + groups @@ -306,7 +310,19 @@ export function useConversationLoader({ const init = async () => { try { - const ctx = await getChatContext(); + // Conversations + groups are hydrated by `useConversationListInit` + // in `ChatLayout` so the sidebar populates on every chat-layout + // route, not just `/assistant`. Reuse that cached query result + // here for the chat-specific active-key resolution; only fall + // back to a direct fetch if the cache is somehow cold (e.g. + // ChatPage mounted without a parent layout in a test). + const cached = queryClient.getQueryData>>( + chatContextQueryKey(assistantId), + ); + const ctx = cached ?? (await queryClient.fetchQuery({ + queryKey: chatContextQueryKey(assistantId), + queryFn: getChatContext, + })); if (!ctx || cancelled) return; const qpKey = searchParams.get("conversationKey"); @@ -332,20 +348,6 @@ export function useConversationLoader({ setError((prev) => prev?.code === CHAT_CONTEXT_LOAD_FAILED_CODE ? null : prev, ); - useConversationListStore.getState().setConversations(ctx.conversations); - - if (conversationGroupsUI) { - fetchGroups(ctx.assistantId) - .then((groups) => { - if (!cancelled) useConversationListStore.getState().setGroups(groups); - }) - .catch((err) => { - Sentry.captureException(err, { - level: "warning", - tags: { context: "fetchGroups.init" }, - }); - }); - } useConversationListStore.getState().setActiveKey(key); } catch (err) { diff --git a/apps/web/src/domains/conversations/use-conversation-list-init.ts b/apps/web/src/domains/conversations/use-conversation-list-init.ts new file mode 100644 index 00000000000..b90d16e78ff --- /dev/null +++ b/apps/web/src/domains/conversations/use-conversation-list-init.ts @@ -0,0 +1,85 @@ +/** + * Initial conversation-list hydration for the chat-layout sidebar. + * + * The sidebar (rendered by `ChatLayout`) is shared across every route + * mounted under `/assistant/*` — chat, home, library, contacts, + * identity. Its data lives in the Zustand `useConversationListStore`. + * + * Without this hook, the store stays empty on direct navigation to any + * non-chat route because the previous loader (`useConversationLoader`) + * was mounted only inside `ChatPage`. This hook fixes that by fetching + * the conversation list at the layout level so every sibling route + * inherits a populated sidebar. + * + * Server state lives in TanStack Query per `apps/web/CONVENTIONS.md`; + * the Zustand store is kept in sync via a small effect so existing + * consumers (sidebar, send pipeline, attention tracking) keep their + * subscription model. As more of the conversation-list slice migrates + * to Query, this sync layer can shrink. + * + * References: + * - https://tanstack.com/query/latest/docs/framework/react/guides/queries + * - https://zustand.docs.pmnd.rs/guides/updating-state + */ +import { useEffect } from "react"; +import { useQuery } from "@tanstack/react-query"; +import * as Sentry from "@sentry/react"; + +import { fetchGroups } from "@/domains/chat/api/conversations.js"; +import { getChatContext } from "@/domains/chat/api/assistant.js"; +import { useConversationListStore } from "@/domains/conversations/conversation-list-store.js"; +import type { AssistantState } from "@/domains/chat/hooks/use-assistant-lifecycle.js"; + +export const CHAT_CONTEXT_QUERY_KEY = "chat-context" as const; + +export function chatContextQueryKey(assistantId: string | null) { + return [CHAT_CONTEXT_QUERY_KEY, assistantId ?? ""] as const; +} + +interface UseConversationListInitParams { + assistantId: string | null; + assistantStateKind: AssistantState["kind"]; + conversationGroupsUI: boolean; +} + +export function useConversationListInit({ + assistantId, + assistantStateKind, + conversationGroupsUI, +}: UseConversationListInitParams) { + const isActive = assistantStateKind === "active" && Boolean(assistantId); + + const chatContextQuery = useQuery({ + queryKey: chatContextQueryKey(assistantId), + queryFn: getChatContext, + enabled: isActive, + staleTime: 30_000, + }); + + useEffect(() => { + const data = chatContextQuery.data; + if (!data) return; + useConversationListStore.getState().setConversations(data.conversations); + }, [chatContextQuery.data]); + + useEffect(() => { + if (!isActive || !assistantId) return; + if (!conversationGroupsUI) return; + let cancelled = false; + fetchGroups(assistantId) + .then((groups) => { + if (!cancelled) { + useConversationListStore.getState().setGroups(groups); + } + }) + .catch((err) => { + Sentry.captureException(err, { + level: "warning", + tags: { context: "fetchGroups.init" }, + }); + }); + return () => { + cancelled = true; + }; + }, [isActive, assistantId, conversationGroupsUI]); +} From 05a3041df3b3115aad6ab02a6d5a989abc565e13 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 20 May 2026 20:06:30 +0000 Subject: [PATCH 2/4] fix(web): address review feedback on conversation-list hydration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two findings flagged independently by Codex (P2) and Devin (BUG_0001 + ANALYSIS_0001), both fixed properly: 1. Init effect was short-circuiting on cached data via `getQueryData`, so refreshEpoch / reachabilityReadyEpoch re-runs (pull-to-refresh, pod recovery) returned stale results instead of fetching fresh data as documented at lines 381–384. Replaced with `fetchQuery({ staleTime: 0 })` — always forces a fresh request, and TanStack Query's same-key dedup means concurrent fetches on initial mount (between this hook and `useConversationListInit` in ChatLayout) still collapse to one network call. Updates flow back through the shared cache so the sidebar refreshes alongside. 2. `setActiveKey` was firing one render cycle before `setConversations` (which had moved to `useConversationListInit`'s effect). Consumers like ChatPage's `activeConversation` lookup briefly saw an active key with empty conversations. Restored atomic writes by setting conversations alongside activeKey in the init effect — idempotent with `useConversationListInit`'s write but guarantees ordering for the chat path. https://claude.ai/code/session_013dBXRbLF218UhdLq7FEAvv --- .../chat/hooks/use-conversation-loader.ts | 31 ++++++++++++------- 1 file changed, 20 insertions(+), 11 deletions(-) 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 b9d8558aadd..67da51edff0 100644 --- a/apps/web/src/domains/chat/hooks/use-conversation-loader.ts +++ b/apps/web/src/domains/chat/hooks/use-conversation-loader.ts @@ -310,19 +310,22 @@ export function useConversationLoader({ const init = async () => { try { - // Conversations + groups are hydrated by `useConversationListInit` - // in `ChatLayout` so the sidebar populates on every chat-layout - // route, not just `/assistant`. Reuse that cached query result - // here for the chat-specific active-key resolution; only fall - // back to a direct fetch if the cache is somehow cold (e.g. - // ChatPage mounted without a parent layout in a test). - const cached = queryClient.getQueryData>>( - chatContextQueryKey(assistantId), - ); - const ctx = cached ?? (await queryClient.fetchQuery({ + // Always fetch (not just read cache): this effect re-runs when + // `refreshEpoch` or `reachabilityReadyEpoch` changes, and those + // changes specifically signal "treat any cached data as stale + // and pick up server-side changes" (pull-to-refresh, pod + // recovery, conversation removal, etc.). `fetchQuery` with + // `staleTime: 0` forces a fresh request and writes through to + // the same cache that `useConversationListInit` (mounted in + // `ChatLayout`) subscribes to — so the sidebar refreshes too, + // and concurrent fetches on initial mount dedup via the + // shared query key. + // https://tanstack.com/query/latest/docs/reference/QueryClient#queryclientfetchquery + const ctx = await queryClient.fetchQuery({ queryKey: chatContextQueryKey(assistantId), queryFn: getChatContext, - })); + staleTime: 0, + }); if (!ctx || cancelled) return; const qpKey = searchParams.get("conversationKey"); @@ -349,6 +352,12 @@ export function useConversationLoader({ prev?.code === CHAT_CONTEXT_LOAD_FAILED_CODE ? null : prev, ); + // Set conversations and activeKey atomically so consumers of + // `useConversationListStore` never observe an active key with + // an empty conversations list (which would render `undefined` + // for the active conversation on this commit). Idempotent with + // the matching write in `useConversationListInit`'s effect. + useConversationListStore.getState().setConversations(ctx.conversations); useConversationListStore.getState().setActiveKey(key); } catch (err) { if (cancelled) return; From b65b2c8e31e9680ca2bfc66fbb3e2b00b996c13f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 20 May 2026 21:11:07 +0000 Subject: [PATCH 3/4] fix(web): move useAttentionTracking to ChatLayout (LUM-1736) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of LUM-1734's chat-page data lifecycle redesign. Same root cause as LUM-1732 (sidebar conversation list not hydrating on non-chat routes): per-conversation processing/attention indicators were live only while the user was on /assistant, because useAttentionTracking was mounted in ChatPage. Now mounted in ChatLayout so the 10s polling loop + graduation logic cover home / library / contacts / identity too. The move required first untangling two shared refs between useAttentionTracking (the reader) and useSendMessage (the writer): - processingSnapshotsRef → fold into useConversationListStore as state. addProcessingKey now takes an optional snapshot; remove* and transfer* actions clear/transfer the matching snapshot atomically. No more parallel ref to keep in sync. - conversationsRef → drop in favor of useConversationListStore.getState().conversations inside async callbacks. The ref was a workaround for stale closures; the store's getState() is the idiomatic always-latest read. useAttentionTracking is now zero-prop — reads everything from useConversationListStore selectors + useAssistantContext. Also removes the now-orphaned processingSnapshotsRef plumbing from useEventStream, useStreamEventHandler, useConversationLoader, and ChatPage. removeProcessingKey now atomically clears the snapshot, so the explicit .delete() calls in those hooks became dead code. https://claude.ai/code/session_013dBXRbLF218UhdLq7FEAvv --- apps/web/src/domains/chat/chat-layout.tsx | 7 ++ apps/web/src/domains/chat/chat-page.tsx | 14 --- .../chat/components/chat-route-content.tsx | 2 - .../chat/hooks/use-attention-tracking.ts | 95 ++++++++----------- .../chat/hooks/use-conversation-loader.ts | 31 +----- .../domains/chat/hooks/use-event-stream.ts | 6 +- .../domains/chat/hooks/use-send-message.ts | 51 +++++----- .../chat/hooks/use-stream-event-handler.ts | 17 +--- .../conversations/conversation-list-store.ts | 69 +++++++++++--- 9 files changed, 140 insertions(+), 152 deletions(-) diff --git a/apps/web/src/domains/chat/chat-layout.tsx b/apps/web/src/domains/chat/chat-layout.tsx index d8ec9f0728c..91ce9403921 100644 --- a/apps/web/src/domains/chat/chat-layout.tsx +++ b/apps/web/src/domains/chat/chat-layout.tsx @@ -17,6 +17,7 @@ import type { AssistantContextValue } from "@/domains/chat/assistant-context.js" import { useConversationListStore } from "@/domains/conversations/conversation-list-store.js"; import { useConversationListInit } from "@/domains/conversations/use-conversation-list-init.js"; +import { useAttentionTracking } from "@/domains/chat/hooks/use-attention-tracking.js"; import { useFeatureFlagStore } from "@/lib/feature-flags/feature-flag-store.js"; import { useViewerStore } from "@/stores/viewer-store.js"; import { useSubagentStore } from "@/domains/subagents/subagent-store.js"; @@ -129,6 +130,12 @@ export function ChatLayout() { conversationGroupsUI, }); + // Track processing/attention indicators for every conversation in the + // sidebar, on every chat-layout child route. Mounted here (not ChatPage) + // so the 10s polling loop and graduation logic stay live when the user is + // on home/library/contacts/identity. + useAttentionTracking(); + // --- Layout slot state for child route content --- const [topBarCenter, setTopBarCenter] = useState(null); const [topBarRightSlot, setTopBarRightSlot] = useState(null); diff --git a/apps/web/src/domains/chat/chat-page.tsx b/apps/web/src/domains/chat/chat-page.tsx index ef22af850c5..084b140b88b 100644 --- a/apps/web/src/domains/chat/chat-page.tsx +++ b/apps/web/src/domains/chat/chat-page.tsx @@ -144,7 +144,6 @@ export function ChatPage() { const activeConversationKey = useConversationListStore.use.activeConversationKey(); const editingConversationKey = useConversationListStore.use.editingConversationKey(); const processingKeys = useConversationListStore.use.processingKeys(); - const attentionKeys = useConversationListStore.use.attentionKeys(); const viewerState = useViewerStore(useShallow((s) => ({ mainView: s.mainView, activeAppId: s.activeAppId, @@ -176,15 +175,12 @@ export function ChatPage() { const assistantIdRef = useRef(assistantId); useEffect(() => { assistantIdRef.current = assistantId; }, [assistantId]); - const conversationsRef = useRef(conversations); - conversationsRef.current = conversations; const streamRef = useRef(null); const streamEpochRef = useRef(0); const streamContextRef = useRef<{ assistantId: string; conversationKey: string } | null>(null); const reconcileAfterNextStreamOpenRef = useRef(false); const needsNewBubbleRef = useRef(true); - const processingSnapshotsRef = useRef>(new Map()); const dismissedSurfaceIdsRef = useRef>(new Set()); const pendingOnboardingContextRef = useRef(null); const onboardingDraftConversationKeyRef = useRef(null); @@ -320,9 +316,6 @@ export function ChatPage() { searchParams, navigate, conversations, - activeConversation, - processingKeys, - attentionKeys, transcriptPagination, conversationGroupsUI, refreshEpoch, @@ -336,7 +329,6 @@ export function ChatPage() { inputRef, draftsRef, messagesRef, - conversationsRef, contextWindowUsageByConversationRef, dismissedSurfaceIdsRef, needsNewBubbleRef, @@ -345,7 +337,6 @@ export function ChatPage() { requestIdToStableIdRef, pendingLocalDeletionsRef, confirmationToolCallMapRef, - processingSnapshotsRef, refreshSettleRef, lastSuggestionMsgIdRef, autoGreetRef, @@ -470,7 +461,6 @@ export function ChatPage() { setMessages, messagesRef, needsNewBubbleRef, - processingSnapshotsRef, setError, streamRef, cancelReconciliation, @@ -509,12 +499,10 @@ export function ChatPage() { assistantIdRef, activeConversationKeyRef, messagesRef, - conversationsRef, streamRef, streamContextRef, streamEpochRef, needsNewBubbleRef, - processingSnapshotsRef, dismissedSurfaceIdsRef, pendingOnboardingContextRef, onboardingDraftConversationKeyRef, @@ -581,7 +569,6 @@ export function ChatPage() { reachabilityProbe: reachability.probe, reachabilityPhase: reachability.state.phase, reachabilityReset: reachability.reset, - processingSnapshotsRef, setMessages, setError, streamRetryNonce, @@ -1089,7 +1076,6 @@ export function ChatPage() { refreshSettleRef, streamRef, streamEpochRef, - processingSnapshotsRef, historyLoadedRef, pendingQueuedStableIdsRef, requestIdToStableIdRef, 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 54cb235297a..8d2bd02fdec 100644 --- a/apps/web/src/domains/chat/components/chat-route-content.tsx +++ b/apps/web/src/domains/chat/components/chat-route-content.tsx @@ -218,7 +218,6 @@ export interface ChatRouteRefs { refreshSettleRef: MutableRefObject; streamRef: MutableRefObject; streamEpochRef: MutableRefObject; - processingSnapshotsRef: MutableRefObject>; historyLoadedRef: MutableRefObject; pendingQueuedStableIdsRef: MutableRefObject; requestIdToStableIdRef: MutableRefObject>; @@ -491,7 +490,6 @@ export function ChatRouteContent({ refreshSettleRef, streamRef: _streamRef, streamEpochRef: _streamEpochRef, - processingSnapshotsRef: _processingSnapshotsRef, historyLoadedRef: _historyLoadedRef, pendingQueuedStableIdsRef: _pendingQueuedStableIdsRef, requestIdToStableIdRef: _requestIdToStableIdRef, diff --git a/apps/web/src/domains/chat/hooks/use-attention-tracking.ts b/apps/web/src/domains/chat/hooks/use-attention-tracking.ts index 2af0b4a41eb..5c923f93e98 100644 --- a/apps/web/src/domains/chat/hooks/use-attention-tracking.ts +++ b/apps/web/src/domains/chat/hooks/use-attention-tracking.ts @@ -1,33 +1,12 @@ import * as Sentry from "@sentry/react"; -import { - type MutableRefObject, - useEffect, - useRef, -} from "react"; +import { useEffect, useRef } from "react"; +import { useAssistantContext } from "@/domains/chat/assistant-context.js"; import { useConversationListStore } from "@/domains/conversations/conversation-list-store.js"; -import type { AssistantStateKind } from "@/domains/chat/types.js"; -import { type Conversation, markConversationSeen } from "@/domains/chat/api/conversations.js"; +import { markConversationSeen } from "@/domains/chat/api/conversations.js"; import { listConversationKeysWithPendingInteractions } from "@/domains/chat/api/interactions.js"; -interface UseAttentionTrackingParams { - assistantId: string | null; - assistantStateKind: AssistantStateKind; - activeConversationKey: string | null; - - // Collections - conversations: Conversation[]; - activeConversation: Conversation | undefined; - processingKeys: Set; - attentionKeys: Set; - - // Refs - conversationsRef: MutableRefObject; - processingSnapshotsRef: MutableRefObject>; - -} - // --------------------------------------------------------------------------- // Hook // --------------------------------------------------------------------------- @@ -36,6 +15,11 @@ interface UseAttentionTrackingParams { * Tracks which conversations need user attention (pending interactions) * and manages processing-key lifecycle for background conversations. * + * Reads conversations, processingKeys, attentionKeys, and processingSnapshots + * directly from `useConversationListStore`. Mounted in `ChatLayout` so the + * sidebar's processing/attention indicators stay live on every chat-layout + * route (home, library, contacts, identity, chat) — not only `/assistant`. + * * Handles: * - Marking conversations as seen when opened * - Graduating processing keys when the assistant finishes responding @@ -79,17 +63,19 @@ export function decideGraduationDispatches( return actions; } -export function useAttentionTracking({ - assistantId, - assistantStateKind, - activeConversationKey, - conversations, - activeConversation, - processingKeys, - attentionKeys, - conversationsRef, - processingSnapshotsRef, -}: UseAttentionTrackingParams) { +export function useAttentionTracking() { + const { assistantId, assistantState } = useAssistantContext(); + const assistantStateKind = assistantState.kind; + + const conversations = useConversationListStore.use.conversations(); + const activeConversationKey = useConversationListStore.use.activeConversationKey(); + const processingKeys = useConversationListStore.use.processingKeys(); + const attentionKeys = useConversationListStore.use.attentionKeys(); + + const activeConversation = conversations.find( + (c) => c.conversationKey === activeConversationKey, + ); + const lastSeenOnOpenConversationKeyRef = useRef(null); const initialAttentionSweepDoneRef = useRef(false); @@ -135,12 +121,13 @@ export function useAttentionTracking({ // ------------------------------------------------------------------------- useEffect(() => { if (processingKeys.size === 0) return; + const snapshots = useConversationListStore.getState().processingSnapshots; const graduatingKeys: string[] = []; for (const key of processingKeys) { if (key === activeConversationKey) continue; const conv = conversations.find((c) => c.conversationKey === key); if (!conv) continue; - const snapshot = processingSnapshotsRef.current.get(key); + const snapshot = snapshots.get(key); if (conv.latestAssistantMessageAt && conv.latestAssistantMessageAt !== snapshot) { graduatingKeys.push(key); } @@ -164,13 +151,12 @@ export function useAttentionTracking({ useConversationListStore.getState().addAttentionKey(action.key); } else { useConversationListStore.getState().removeProcessingKey(action.key); - processingSnapshotsRef.current.delete(action.key); } } })(); return () => { cancelled = true; }; - }, [conversations, processingKeys, activeConversationKey, assistantId, processingSnapshotsRef]); + }, [conversations, processingKeys, activeConversationKey, assistantId]); // ------------------------------------------------------------------------- // Poll processing + attention conversations every 10s. @@ -198,27 +184,35 @@ export function useAttentionTracking({ } if (cancelled) return; + // Read latest store values inside the tick — the effect captured the + // sets at scheduling time, which would be stale ten seconds later. + const state = useConversationListStore.getState(); + const currentProcessingKeys = state.processingKeys; + const currentAttentionKeys = state.attentionKeys; + const currentConversations = state.conversations; + const currentSnapshots = state.processingSnapshots; + const currentActiveKey = state.activeConversationKey; + // Graduate processing keys that are now pending; drop ones the // assistant has finished responding to without raising anything. - for (const key of processingKeys) { - if (key === activeConversationKey) continue; - if (attentionKeys.has(key)) continue; + for (const key of currentProcessingKeys) { + if (key === currentActiveKey) continue; + if (currentAttentionKeys.has(key)) continue; if (pendingKeys.has(key)) { useConversationListStore.getState().addAttentionKey(key); useConversationListStore.getState().removeProcessingKey(key); continue; } - const conv = conversationsRef.current.find((c) => c.conversationKey === key); - const snapshot = processingSnapshotsRef.current.get(key); + const conv = currentConversations.find((c) => c.conversationKey === key); + const snapshot = currentSnapshots.get(key); if (conv?.latestAssistantMessageAt && conv.latestAssistantMessageAt !== snapshot) { useConversationListStore.getState().removeProcessingKey(key); - processingSnapshotsRef.current.delete(key); } } // Clear attention keys whose interaction has been resolved. - for (const key of attentionKeys) { - if (key === activeConversationKey) continue; + for (const key of currentAttentionKeys) { + if (key === currentActiveKey) continue; if (!pendingKeys.has(key)) { useConversationListStore.getState().removeAttentionKey(key); } @@ -229,14 +223,7 @@ export function useAttentionTracking({ cancelled = true; clearInterval(pollInterval); }; - }, [ - assistantId, - processingKeys, - attentionKeys, - activeConversationKey, - conversationsRef, - processingSnapshotsRef, - ]); + }, [assistantId, processingKeys, attentionKeys]); // ------------------------------------------------------------------------- // One-time sweep on mount: seed attention keys for every non-active 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 613eda2b22a..2831c4fc7ee 100644 --- a/apps/web/src/domains/chat/hooks/use-conversation-loader.ts +++ b/apps/web/src/domains/chat/hooks/use-conversation-loader.ts @@ -38,7 +38,6 @@ import { useConversationHistory, type HistoryPaginationSnapshot, } from "@/domains/chat/hooks/use-conversation-history.js"; -import { useAttentionTracking } from "@/domains/chat/hooks/use-attention-tracking.js"; import { useQueryClient } from "@tanstack/react-query"; import { getChatContext } from "@/domains/chat/api/assistant.js"; @@ -79,9 +78,6 @@ interface UseConversationLoaderParams { // Collections conversations: Conversation[]; - activeConversation: Conversation | undefined; - processingKeys: Set; - attentionKeys: Set; transcriptPagination: Omit; // Feature flags / epochs @@ -101,7 +97,6 @@ interface UseConversationLoaderParams { inputRef: MutableRefObject; draftsRef: MutableRefObject>; messagesRef: MutableRefObject; - conversationsRef: MutableRefObject; contextWindowUsageByConversationRef: MutableRefObject>; dismissedSurfaceIdsRef: MutableRefObject>; needsNewBubbleRef: MutableRefObject; @@ -110,7 +105,6 @@ interface UseConversationLoaderParams { requestIdToStableIdRef: MutableRefObject>; pendingLocalDeletionsRef: MutableRefObject>; confirmationToolCallMapRef: MutableRefObject>; - processingSnapshotsRef: MutableRefObject>; refreshSettleRef: MutableRefObject; lastSuggestionMsgIdRef: MutableRefObject; autoGreetRef: MutableRefObject; @@ -167,7 +161,10 @@ interface UseConversationLoaderParams { * * Delegates to: * - `useConversationHistory` -- conversation switch, cache, and history loading - * - `useAttentionTracking` -- processing/attention key lifecycle and polling + * + * Attention/processing-key tracking is now owned by `useAttentionTracking`, + * mounted in `ChatLayout` so its 10s polling loop covers every chat-layout + * route (home/library/contacts/identity), not only `/assistant`. */ export function useConversationLoader({ assistantId, @@ -177,9 +174,6 @@ export function useConversationLoader({ searchParams, navigate, conversations, - activeConversation, - processingKeys, - attentionKeys, transcriptPagination, conversationGroupsUI, refreshEpoch, @@ -193,7 +187,6 @@ export function useConversationLoader({ inputRef, draftsRef, messagesRef, - conversationsRef, contextWindowUsageByConversationRef, dismissedSurfaceIdsRef, needsNewBubbleRef, @@ -202,7 +195,6 @@ export function useConversationLoader({ requestIdToStableIdRef, pendingLocalDeletionsRef, confirmationToolCallMapRef, - processingSnapshotsRef, refreshSettleRef, lastSuggestionMsgIdRef, autoGreetRef, @@ -484,21 +476,6 @@ export function useConversationLoader({ shouldSuppressGenericChatErrorNotice, }); - // ------------------------------------------------------------------------- - // Delegate: attention tracking and processing key lifecycle - // ------------------------------------------------------------------------- - useAttentionTracking({ - assistantId, - assistantStateKind, - activeConversationKey, - conversations, - activeConversation, - processingKeys, - attentionKeys, - conversationsRef, - processingSnapshotsRef, - }); - // ------------------------------------------------------------------------- // switchConversation // ------------------------------------------------------------------------- diff --git a/apps/web/src/domains/chat/hooks/use-event-stream.ts b/apps/web/src/domains/chat/hooks/use-event-stream.ts index b93fd81d980..a6f39c4eecd 100644 --- a/apps/web/src/domains/chat/hooks/use-event-stream.ts +++ b/apps/web/src/domains/chat/hooks/use-event-stream.ts @@ -88,9 +88,6 @@ export interface UseEventStreamParams { reachabilityPhase: string; reachabilityReset: () => void; - // Conversation list - processingSnapshotsRef: MutableRefObject>; - // Messages setMessages: Dispatch>; @@ -140,7 +137,6 @@ export function useEventStream({ reachabilityProbe, reachabilityPhase, reachabilityReset, - processingSnapshotsRef, setMessages, setError, streamRetryNonce, @@ -249,8 +245,8 @@ export function useEventStream({ { const convKey = streamContextRef.current?.conversationKey; if (convKey) { + // `removeProcessingKey` clears the matching snapshot atomically. useConversationListStore.getState().removeProcessingKey(convKey); - processingSnapshotsRef.current.delete(convKey); } } reachabilityProbeRef.current(); 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 f450ec08d30..abf14ef83f6 100644 --- a/apps/web/src/domains/chat/hooks/use-send-message.ts +++ b/apps/web/src/domains/chat/hooks/use-send-message.ts @@ -82,7 +82,6 @@ interface UseSendMessageParams { activeConversationKeyRef: MutableRefObject; messagesRef: MutableRefObject; - conversationsRef: MutableRefObject; streamRef: MutableRefObject; streamContextRef: MutableRefObject<{ assistantId: string; @@ -90,7 +89,6 @@ interface UseSendMessageParams { } | null>; streamEpochRef: MutableRefObject; needsNewBubbleRef: MutableRefObject; - processingSnapshotsRef: MutableRefObject>; dismissedSurfaceIdsRef: MutableRefObject>; pendingOnboardingContextRef: MutableRefObject; onboardingDraftConversationKeyRef: MutableRefObject; @@ -128,12 +126,10 @@ export function useSendMessage({ assistantIdRef, activeConversationKeyRef, messagesRef, - conversationsRef, streamRef, streamContextRef, streamEpochRef, needsNewBubbleRef, - processingSnapshotsRef, dismissedSurfaceIdsRef, pendingOnboardingContextRef, onboardingDraftConversationKeyRef, @@ -483,14 +479,19 @@ export function useSendMessage({ const fallbackTurnId = newTurnId(); useTurnStore.getState().requestSend(fallbackTurnId); useTurnStore.getState().acceptSend(fallbackTurnId); - useConversationListStore.getState().addProcessingKey(activeConversationKey); - const currentConv = conversationsRef.current.find( - (c) => c.conversationKey === activeConversationKey, - ); - processingSnapshotsRef.current.set( - activeConversationKey, - currentConv?.latestAssistantMessageAt as string | undefined, - ); + { + const currentConv = useConversationListStore + .getState() + .conversations.find( + (c) => c.conversationKey === activeConversationKey, + ); + useConversationListStore + .getState() + .addProcessingKey( + activeConversationKey, + currentConv?.latestAssistantMessageAt as string | undefined, + ); + } return; } } catch { @@ -503,12 +504,15 @@ export function useSendMessage({ const turnId = newTurnId(); useTurnStore.getState().requestSend(turnId); - useConversationListStore.getState().addProcessingKey(activeConversationKey); - const currentConv = conversationsRef.current.find(c => c.conversationKey === activeConversationKey); - processingSnapshotsRef.current.set( - activeConversationKey, - currentConv?.latestAssistantMessageAt as string | undefined, - ); + const currentConv = useConversationListStore + .getState() + .conversations.find((c) => c.conversationKey === activeConversationKey); + useConversationListStore + .getState() + .addProcessingKey( + activeConversationKey, + currentConv?.latestAssistantMessageAt as string | undefined, + ); // Optimistically add a stub conversation to the sidebar for draft // conversations that don't exist on the server yet. @@ -533,12 +537,9 @@ export function useSendMessage({ // Resolve draft key -> server-assigned conversation ID. if (resolvedId && resolvedId !== activeConversationKey) { const newKey = resolvedId; - useConversationListStore.getState().transferProcessingKey(activeConversationKey, newKey); - const snapshot = processingSnapshotsRef.current.get(activeConversationKey); - processingSnapshotsRef.current.delete(activeConversationKey); - if (snapshot !== undefined) { - processingSnapshotsRef.current.set(newKey, snapshot); - } + useConversationListStore + .getState() + .transferProcessingKey(activeConversationKey, newKey); useConversationListStore.getState().resolveDraftKey(activeConversationKey, newKey); resolveEditChatDraftKey(activeConversationKey, newKey); @@ -562,7 +563,6 @@ export function useSendMessage({ setError({ message: "Something went wrong. Please try again." }); useTurnStore.getState().onStreamError(); const keysToClean = [activeConversationKey, resolvedId].filter(Boolean) as string[]; - for (const k of keysToClean) processingSnapshotsRef.current.delete(k); if (keysToClean.length > 0) { useConversationListStore.getState().removeMultipleProcessingKeys(keysToClean); } @@ -595,7 +595,6 @@ export function useSendMessage({ useSubagentStore.getState().reset(); confirmationToolCallMapRef.current.clear(); useConversationListStore.getState().removeProcessingKey(activeConversationKey); - processingSnapshotsRef.current.delete(activeConversationKey); try { await cancelGeneration(assistantId, activeConversationKey); } catch { 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 9affc49693f..9467ff883b4 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 @@ -94,11 +94,6 @@ export interface UseStreamEventHandlerParams { messagesRef: MutableRefObject; needsNewBubbleRef: MutableRefObject; - // --- Processing --- - processingSnapshotsRef: MutableRefObject< - Map - >; - // --- Error & stream lifecycle --- setError: Dispatch>; streamRef: MutableRefObject; @@ -174,7 +169,6 @@ export function useStreamEventHandler( setMessages, messagesRef, needsNewBubbleRef, - processingSnapshotsRef, setError, streamRef, cancelReconciliation, @@ -212,13 +206,10 @@ export function useStreamEventHandler( invalidateAvatarRef.current = invalidateAvatar; /** Remove a conversation key from the processing set and snapshots map. */ - const clearProcessingKey = useCallback( - (convKey: string) => { - useConversationListStore.getState().removeProcessingKey(convKey); - processingSnapshotsRef.current.delete(convKey); - }, - [processingSnapshotsRef], - ); + const clearProcessingKey = useCallback((convKey: string) => { + // `removeProcessingKey` clears the matching snapshot in the same set call. + useConversationListStore.getState().removeProcessingKey(convKey); + }, []); // --- Main event handler --- diff --git a/apps/web/src/domains/conversations/conversation-list-store.ts b/apps/web/src/domains/conversations/conversation-list-store.ts index d28e50e507a..657c4b398a3 100644 --- a/apps/web/src/domains/conversations/conversation-list-store.ts +++ b/apps/web/src/domains/conversations/conversation-list-store.ts @@ -96,6 +96,15 @@ export interface ConversationListState { activeConversationKey: string | null; editingConversationKey: string | null; processingKeys: Set; + /** + * Per-conversation snapshot of `latestAssistantMessageAt` at the moment the + * key was added to `processingKeys`. The attention-tracking graduation logic + * compares the current `latestAssistantMessageAt` against this snapshot to + * detect when the assistant has finished responding. Entries are added by + * `addProcessingKey` and cleared by every action that removes from + * `processingKeys`, so the two collections stay in sync. + */ + processingSnapshots: Map; attentionKeys: Set; } @@ -121,7 +130,7 @@ export interface ConversationListActions { setEditingKey: (key: string | null) => void; // --- Processing keys --- - addProcessingKey: (key: string) => void; + addProcessingKey: (key: string, snapshot?: string) => void; removeProcessingKey: (key: string) => void; removeMultipleProcessingKeys: (keys: string[]) => void; transferProcessingKey: (oldKey: string, newKey: string) => void; @@ -145,9 +154,22 @@ const INITIAL_STATE: ConversationListState = { activeConversationKey: null, editingConversationKey: null, processingKeys: new Set(), + processingSnapshots: new Map(), attentionKeys: new Set(), }; +/** + * Return a new Map with the given key removed, or the same reference if the + * key wasn't present — lets Zustand's shallow equality bail out of + * unnecessary re-renders. + */ +function deleteFromMap(prev: Map, key: K): Map { + if (!prev.has(key)) return prev; + const next = new Map(prev); + next.delete(key); + return next; +} + // --------------------------------------------------------------------------- // Store // --------------------------------------------------------------------------- @@ -272,27 +294,46 @@ export const useConversationListStore = createSelectors( // --- Processing keys --- - addProcessingKey: (key) => { - set({ processingKeys: addToSet(get().processingKeys, key) }); + addProcessingKey: (key, snapshot) => { + const { processingKeys, processingSnapshots } = get(); + const nextSnapshots = new Map(processingSnapshots); + nextSnapshots.set(key, snapshot); + set({ + processingKeys: addToSet(processingKeys, key), + processingSnapshots: nextSnapshots, + }); }, removeProcessingKey: (key) => { - set({ processingKeys: removeFromSet(get().processingKeys, key) }); + set({ + processingKeys: removeFromSet(get().processingKeys, key), + processingSnapshots: deleteFromMap(get().processingSnapshots, key), + }); }, removeMultipleProcessingKeys: (keys) => { + const { processingKeys, processingSnapshots } = get(); + let nextSnapshots = processingSnapshots; + for (const key of keys) { + nextSnapshots = deleteFromMap(nextSnapshots, key); + } set({ - processingKeys: removeMultipleFromSet(get().processingKeys, keys), + processingKeys: removeMultipleFromSet(processingKeys, keys), + processingSnapshots: nextSnapshots, }); }, transferProcessingKey: (oldKey, newKey) => { - const { processingKeys } = get(); + const { processingKeys, processingSnapshots } = get(); if (!processingKeys.has(oldKey)) return; - const next = new Set(processingKeys); - next.delete(oldKey); - next.add(newKey); - set({ processingKeys: next }); + const nextKeys = new Set(processingKeys); + nextKeys.delete(oldKey); + nextKeys.add(newKey); + const nextSnapshots = new Map(processingSnapshots); + const snapshot = nextSnapshots.get(oldKey); + nextSnapshots.delete(oldKey); + nextSnapshots.set(newKey, snapshot); + set({ processingKeys: nextKeys, processingSnapshots: nextSnapshots }); }, // --- Attention keys --- @@ -310,6 +351,7 @@ export const useConversationListStore = createSelectors( graduateProcessingKey: (key, hasPendingInteraction) => { set((state) => ({ processingKeys: removeFromSet(state.processingKeys, key), + processingSnapshots: deleteFromMap(state.processingSnapshots, key), attentionKeys: hasPendingInteraction ? addToSet(state.attentionKeys, key) : state.attentionKeys, @@ -319,7 +361,12 @@ export const useConversationListStore = createSelectors( // --- Reset --- reset: () => { - set({ ...INITIAL_STATE, processingKeys: new Set(), attentionKeys: new Set() }); + set({ + ...INITIAL_STATE, + processingKeys: new Set(), + processingSnapshots: new Map(), + attentionKeys: new Set(), + }); }, })), ); From a039fe55944c499f76ec8ed610ac277456b3bcac Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 20 May 2026 21:33:02 +0000 Subject: [PATCH 4/4] fix(web): pass lifecycle params to useAttentionTracking (P0 crash fix) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex P0 flag: useAttentionTracking was calling useAssistantContext() (a useOutletContext wrapper) from inside ChatLayout. ChatLayout is the ROUTE THAT PROVIDES that outlet context — there's no parent providing it. The destructure would throw at runtime, crashing every chat-layout route mount. Fix: hoist the lifecycle values up. ChatLayout already has `lifecycle.assistantId` and `lifecycle.assistantState.kind` from useAssistantLifecycle. Pass them to useAttentionTracking as explicit params (same pattern as useConversationListInit). https://claude.ai/code/session_013dBXRbLF218UhdLq7FEAvv --- apps/web/src/domains/chat/chat-layout.tsx | 9 +++++++-- .../chat/hooks/use-attention-tracking.ts | 17 ++++++++++++----- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/apps/web/src/domains/chat/chat-layout.tsx b/apps/web/src/domains/chat/chat-layout.tsx index 91ce9403921..861c3018be2 100644 --- a/apps/web/src/domains/chat/chat-layout.tsx +++ b/apps/web/src/domains/chat/chat-layout.tsx @@ -133,8 +133,13 @@ export function ChatLayout() { // Track processing/attention indicators for every conversation in the // sidebar, on every chat-layout child route. Mounted here (not ChatPage) // so the 10s polling loop and graduation logic stay live when the user is - // on home/library/contacts/identity. - useAttentionTracking(); + // on home/library/contacts/identity. Pass lifecycle values directly — + // `useAssistantContext()` would crash here since this hook runs inside + // the layout that PROVIDES that context (no parent outlet to read from). + useAttentionTracking({ + assistantId: lifecycle.assistantId, + assistantStateKind: lifecycle.assistantState.kind, + }); // --- Layout slot state for child route content --- const [topBarCenter, setTopBarCenter] = useState(null); diff --git a/apps/web/src/domains/chat/hooks/use-attention-tracking.ts b/apps/web/src/domains/chat/hooks/use-attention-tracking.ts index 5c923f93e98..8b4968c7eb3 100644 --- a/apps/web/src/domains/chat/hooks/use-attention-tracking.ts +++ b/apps/web/src/domains/chat/hooks/use-attention-tracking.ts @@ -2,10 +2,17 @@ import * as Sentry from "@sentry/react"; import { useEffect, useRef } from "react"; -import { useAssistantContext } from "@/domains/chat/assistant-context.js"; import { useConversationListStore } from "@/domains/conversations/conversation-list-store.js"; import { markConversationSeen } from "@/domains/chat/api/conversations.js"; import { listConversationKeysWithPendingInteractions } from "@/domains/chat/api/interactions.js"; +import type { AssistantState } from "@/domains/chat/hooks/use-assistant-lifecycle.js"; + +interface UseAttentionTrackingParams { + /** From `useAssistantLifecycle` in `ChatLayout`. */ + assistantId: string | null; + /** From `useAssistantLifecycle` in `ChatLayout`. */ + assistantStateKind: AssistantState["kind"]; +} // --------------------------------------------------------------------------- // Hook @@ -63,10 +70,10 @@ export function decideGraduationDispatches( return actions; } -export function useAttentionTracking() { - const { assistantId, assistantState } = useAssistantContext(); - const assistantStateKind = assistantState.kind; - +export function useAttentionTracking({ + assistantId, + assistantStateKind, +}: UseAttentionTrackingParams) { const conversations = useConversationListStore.use.conversations(); const activeConversationKey = useConversationListStore.use.activeConversationKey(); const processingKeys = useConversationListStore.use.processingKeys();