Skip to content
Merged
19 changes: 19 additions & 0 deletions apps/web/src/domains/chat/chat-layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ 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 { 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";

Expand Down Expand Up @@ -117,6 +120,22 @@ 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,
});

// 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<ReactNode>(null);
const [topBarRightSlot, setTopBarRightSlot] = useState<ReactNode>(null);
Expand Down
14 changes: 0 additions & 14 deletions apps/web/src/domains/chat/chat-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -176,15 +175,12 @@ export function ChatPage() {
const assistantIdRef = useRef<string | null>(assistantId);
useEffect(() => { assistantIdRef.current = assistantId; }, [assistantId]);

const conversationsRef = useRef<typeof conversations>(conversations);
conversationsRef.current = conversations;

const streamRef = useRef<ChatEventStream | null>(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<Map<string, string | undefined>>(new Map());
const dismissedSurfaceIdsRef = useRef<Set<string>>(new Set());
const pendingOnboardingContextRef = useRef<PreChatOnboardingContext | null>(null);
const onboardingDraftConversationKeyRef = useRef<string | null>(null);
Expand Down Expand Up @@ -320,9 +316,6 @@ export function ChatPage() {
searchParams,
navigate,
conversations,
activeConversation,
processingKeys,
attentionKeys,
transcriptPagination,
conversationGroupsUI,
refreshEpoch,
Expand All @@ -336,7 +329,6 @@ export function ChatPage() {
inputRef,
draftsRef,
messagesRef,
conversationsRef,
contextWindowUsageByConversationRef,
dismissedSurfaceIdsRef,
needsNewBubbleRef,
Expand All @@ -345,7 +337,6 @@ export function ChatPage() {
requestIdToStableIdRef,
pendingLocalDeletionsRef,
confirmationToolCallMapRef,
processingSnapshotsRef,
refreshSettleRef,
lastSuggestionMsgIdRef,
autoGreetRef,
Expand Down Expand Up @@ -470,7 +461,6 @@ export function ChatPage() {
setMessages,
messagesRef,
needsNewBubbleRef,
processingSnapshotsRef,
setError,
streamRef,
cancelReconciliation,
Expand Down Expand Up @@ -509,12 +499,10 @@ export function ChatPage() {
assistantIdRef,
activeConversationKeyRef,
messagesRef,
conversationsRef,
streamRef,
streamContextRef,
streamEpochRef,
needsNewBubbleRef,
processingSnapshotsRef,
dismissedSurfaceIdsRef,
pendingOnboardingContextRef,
onboardingDraftConversationKeyRef,
Expand Down Expand Up @@ -581,7 +569,6 @@ export function ChatPage() {
reachabilityProbe: reachability.probe,
reachabilityPhase: reachability.state.phase,
reachabilityReset: reachability.reset,
processingSnapshotsRef,
setMessages,
setError,
streamRetryNonce,
Expand Down Expand Up @@ -1089,7 +1076,6 @@ export function ChatPage() {
refreshSettleRef,
streamRef,
streamEpochRef,
processingSnapshotsRef,
historyLoadedRef,
pendingQueuedStableIdsRef,
requestIdToStableIdRef,
Expand Down
2 changes: 0 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 @@ -218,7 +218,6 @@ export interface ChatRouteRefs {
refreshSettleRef: MutableRefObject<RefreshSettleHandle | null>;
streamRef: MutableRefObject<ChatEventStream | null>;
streamEpochRef: MutableRefObject<number>;
processingSnapshotsRef: MutableRefObject<Map<string, string | undefined>>;
historyLoadedRef: MutableRefObject<boolean>;
pendingQueuedStableIdsRef: MutableRefObject<string[]>;
requestIdToStableIdRef: MutableRefObject<Map<string, string>>;
Expand Down Expand Up @@ -491,7 +490,6 @@ export function ChatRouteContent({
refreshSettleRef,
streamRef: _streamRef,
streamEpochRef: _streamEpochRef,
processingSnapshotsRef: _processingSnapshotsRef,
historyLoadedRef: _historyLoadedRef,
pendingQueuedStableIdsRef: _pendingQueuedStableIdsRef,
requestIdToStableIdRef: _requestIdToStableIdRef,
Expand Down
95 changes: 41 additions & 54 deletions apps/web/src/domains/chat/hooks/use-attention-tracking.ts
Original file line number Diff line number Diff line change
@@ -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<string>;
attentionKeys: Set<string>;

// Refs
conversationsRef: MutableRefObject<Conversation[]>;
processingSnapshotsRef: MutableRefObject<Map<string, string | undefined>>;

}

// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------
Expand All @@ -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
Expand Down Expand Up @@ -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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P0 Badge Avoid consuming outlet context in layout-mounted hook

useAttentionTracking now runs from ChatLayout, but it immediately calls useAssistantContext() and destructures assistantId/assistantState. useAssistantContext() is a useOutletContext wrapper intended for ChatLayout children; when called in the layout itself there is no parent outlet context providing this shape, so this resolves to null/undefined and the destructure throws at runtime. That makes /assistant routes crash as soon as ChatLayout mounts.

Useful? React with 👍 / 👎.


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<string | null>(null);
const initialAttentionSweepDoneRef = useRef(false);

Expand Down Expand Up @@ -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);
}
Expand All @@ -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.
Expand Down Expand Up @@ -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);
}
Expand All @@ -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
Expand Down
Loading