From 34f204620f75aee27aa5a37d174ee076f3aba163 Mon Sep 17 00:00:00 2001 From: "DESKTOP-RTLN3BA\\$punk" Date: Sun, 29 Mar 2026 02:45:59 -0700 Subject: [PATCH 1/2] feat: implement FrameBatchedUpdater for efficient state updates during streaming, enhance message handling in NewChatPage, and improve thread viewport scrolling behavior --- .../new-chat/[[...chat_id]]/page.tsx | 396 ++++++++---------- .../components/assistant-ui/markdown-text.tsx | 1 + .../components/assistant-ui/thread.tsx | 79 +++- surfsense_web/lib/chat/streaming-state.ts | 82 +++- 4 files changed, 326 insertions(+), 232 deletions(-) diff --git a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx index 29bbc0c5c..5bfea3329 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx @@ -56,6 +56,7 @@ import { buildContentForPersistence, buildContentForUI, type ContentPartsState, + FrameBatchedUpdater, readSSEStream, type ThinkingStepData, updateThinkingSteps, @@ -571,6 +572,7 @@ export default function NewChatPage() { // Prepare assistant message const assistantMsgId = `msg-assistant-${Date.now()}`; const currentThinkingSteps = new Map(); + const batcher = new FrameBatchedUpdater(); const contentPartsState: ContentPartsState = { contentParts: [], @@ -642,96 +644,74 @@ export default function NewChatPage() { throw new Error(`Backend error: ${response.status}`); } + const flushMessages = () => { + setMessages((prev) => + prev.map((m) => + m.id === assistantMsgId + ? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) } + : m + ) + ); + }; + const scheduleFlush = () => batcher.schedule(flushMessages); + for await (const parsed of readSSEStream(response)) { - switch (parsed.type) { - case "text-delta": - appendText(contentPartsState, parsed.delta); - setMessages((prev) => - prev.map((m) => - m.id === assistantMsgId - ? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) } - : m - ) - ); - break; + switch (parsed.type) { + case "text-delta": + appendText(contentPartsState, parsed.delta); + scheduleFlush(); + break; - case "tool-input-start": - // Add tool call inline - this breaks the current text segment - addToolCall(contentPartsState, TOOLS_WITH_UI, parsed.toolCallId, parsed.toolName, {}); - setMessages((prev) => - prev.map((m) => - m.id === assistantMsgId - ? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) } - : m - ) - ); - break; + case "tool-input-start": + addToolCall(contentPartsState, TOOLS_WITH_UI, parsed.toolCallId, parsed.toolName, {}); + batcher.flush(); + break; - case "tool-input-available": { - // Update existing tool call's args, or add if not exists - if (toolCallIndices.has(parsed.toolCallId)) { - updateToolCall(contentPartsState, parsed.toolCallId, { args: parsed.input || {} }); - } else { - addToolCall( - contentPartsState, - TOOLS_WITH_UI, - parsed.toolCallId, - parsed.toolName, - parsed.input || {} - ); - } - setMessages((prev) => - prev.map((m) => - m.id === assistantMsgId - ? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) } - : m - ) + case "tool-input-available": { + if (toolCallIndices.has(parsed.toolCallId)) { + updateToolCall(contentPartsState, parsed.toolCallId, { args: parsed.input || {} }); + } else { + addToolCall( + contentPartsState, + TOOLS_WITH_UI, + parsed.toolCallId, + parsed.toolName, + parsed.input || {} ); - break; } + batcher.flush(); + break; + } - case "tool-output-available": { - // Update the tool call with its result - updateToolCall(contentPartsState, parsed.toolCallId, { result: parsed.output }); - markInterruptsCompleted(contentParts); - // Handle podcast-specific logic - if (parsed.output?.status === "pending" && parsed.output?.podcast_id) { - // Check if this is a podcast tool by looking at the content part - const idx = toolCallIndices.get(parsed.toolCallId); - if (idx !== undefined) { - const part = contentParts[idx]; - if (part?.type === "tool-call" && part.toolName === "generate_podcast") { - setActivePodcastTaskId(String(parsed.output.podcast_id)); - } + case "tool-output-available": { + updateToolCall(contentPartsState, parsed.toolCallId, { result: parsed.output }); + markInterruptsCompleted(contentParts); + if (parsed.output?.status === "pending" && parsed.output?.podcast_id) { + const idx = toolCallIndices.get(parsed.toolCallId); + if (idx !== undefined) { + const part = contentParts[idx]; + if (part?.type === "tool-call" && part.toolName === "generate_podcast") { + setActivePodcastTaskId(String(parsed.output.podcast_id)); } } - setMessages((prev) => - prev.map((m) => - m.id === assistantMsgId - ? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) } - : m - ) - ); - break; } + batcher.flush(); + break; + } - case "data-thinking-step": { - const stepData = parsed.data as ThinkingStepData; - if (stepData?.id) { - currentThinkingSteps.set(stepData.id, stepData); - updateThinkingSteps(contentPartsState, currentThinkingSteps); - setMessages((prev) => - prev.map((m) => - m.id === assistantMsgId - ? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) } - : m - ) - ); + case "data-thinking-step": { + const stepData = parsed.data as ThinkingStepData; + if (stepData?.id) { + currentThinkingSteps.set(stepData.id, stepData); + const didUpdate = updateThinkingSteps(contentPartsState, currentThinkingSteps); + if (didUpdate) { + scheduleFlush(); } - break; } + break; + } - case "data-thread-title-update": { + case "data-thread-title-update": { const titleData = parsed.data as { threadId: number; title: string }; if (titleData?.title && titleData?.threadId === currentThreadId) { setCurrentThread((prev) => (prev ? { ...prev, title: titleData.title } : prev)); @@ -803,6 +783,8 @@ export default function NewChatPage() { } } + batcher.flush(); + // Skip persistence for interrupted messages -- handleResume will persist the final version const finalContent = buildContentForPersistence(contentPartsState, TOOLS_WITH_UI); if (contentParts.length > 0 && !wasInterrupted) { @@ -832,6 +814,7 @@ export default function NewChatPage() { trackChatResponseReceived(searchSpaceId, currentThreadId); } } catch (error) { + batcher.dispose(); if (error instanceof Error && error.name === "AbortError") { // Request was cancelled by user - persist partial response if any content was received const hasContent = contentParts.some( @@ -931,6 +914,7 @@ export default function NewChatPage() { abortControllerRef.current = controller; const currentThinkingSteps = new Map(); + const batcher = new FrameBatchedUpdater(); const contentPartsState: ContentPartsState = { contentParts: [], @@ -1018,84 +1002,67 @@ export default function NewChatPage() { throw new Error(`Backend error: ${response.status}`); } - for await (const parsed of readSSEStream(response)) { - switch (parsed.type) { - case "text-delta": - appendText(contentPartsState, parsed.delta); - setMessages((prev) => - prev.map((m) => - m.id === assistantMsgId - ? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) } - : m - ) - ); - break; + const flushMessages = () => { + setMessages((prev) => + prev.map((m) => + m.id === assistantMsgId + ? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) } + : m + ) + ); + }; + const scheduleFlush = () => batcher.schedule(flushMessages); - case "tool-input-start": - addToolCall(contentPartsState, TOOLS_WITH_UI, parsed.toolCallId, parsed.toolName, {}); - setMessages((prev) => - prev.map((m) => - m.id === assistantMsgId - ? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) } - : m - ) - ); - break; + for await (const parsed of readSSEStream(response)) { + switch (parsed.type) { + case "text-delta": + appendText(contentPartsState, parsed.delta); + scheduleFlush(); + break; - case "tool-input-available": - if (toolCallIndices.has(parsed.toolCallId)) { - updateToolCall(contentPartsState, parsed.toolCallId, { - args: parsed.input || {}, - }); - } else { - addToolCall( - contentPartsState, - TOOLS_WITH_UI, - parsed.toolCallId, - parsed.toolName, - parsed.input || {} - ); - } - setMessages((prev) => - prev.map((m) => - m.id === assistantMsgId - ? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) } - : m - ) - ); - break; + case "tool-input-start": + addToolCall(contentPartsState, TOOLS_WITH_UI, parsed.toolCallId, parsed.toolName, {}); + batcher.flush(); + break; - case "tool-output-available": + case "tool-input-available": + if (toolCallIndices.has(parsed.toolCallId)) { updateToolCall(contentPartsState, parsed.toolCallId, { - result: parsed.output, + args: parsed.input || {}, }); - markInterruptsCompleted(contentParts); - setMessages((prev) => - prev.map((m) => - m.id === assistantMsgId - ? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) } - : m - ) + } else { + addToolCall( + contentPartsState, + TOOLS_WITH_UI, + parsed.toolCallId, + parsed.toolName, + parsed.input || {} ); - break; + } + batcher.flush(); + break; - case "data-thinking-step": { - const stepData = parsed.data as ThinkingStepData; - if (stepData?.id) { - currentThinkingSteps.set(stepData.id, stepData); - updateThinkingSteps(contentPartsState, currentThinkingSteps); - setMessages((prev) => - prev.map((m) => - m.id === assistantMsgId - ? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) } - : m - ) - ); + case "tool-output-available": + updateToolCall(contentPartsState, parsed.toolCallId, { + result: parsed.output, + }); + markInterruptsCompleted(contentParts); + batcher.flush(); + break; + + case "data-thinking-step": { + const stepData = parsed.data as ThinkingStepData; + if (stepData?.id) { + currentThinkingSteps.set(stepData.id, stepData); + const didUpdate = updateThinkingSteps(contentPartsState, currentThinkingSteps); + if (didUpdate) { + scheduleFlush(); } - break; } + break; + } - case "data-interrupt-request": { + case "data-interrupt-request": { const interruptData = parsed.data as Record; const actionRequests = (interruptData.action_requests ?? []) as Array<{ name: string; @@ -1144,6 +1111,8 @@ export default function NewChatPage() { } } + batcher.flush(); + const finalContent = buildContentForPersistence(contentPartsState, TOOLS_WITH_UI); if (contentParts.length > 0) { try { @@ -1160,6 +1129,7 @@ export default function NewChatPage() { } } } catch (error) { + batcher.dispose(); if (error instanceof Error && error.name === "AbortError") { return; } @@ -1305,6 +1275,7 @@ export default function NewChatPage() { toolCallIndices: new Map(), }; const { contentParts, toolCallIndices } = contentPartsState; + const batcher = new FrameBatchedUpdater(); // Add placeholder messages to UI // Always add back the user message (with new query for edit, or original content for reload) @@ -1349,92 +1320,77 @@ export default function NewChatPage() { throw new Error(`Backend error: ${response.status}`); } + const flushMessages = () => { + setMessages((prev) => + prev.map((m) => + m.id === assistantMsgId + ? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) } + : m + ) + ); + }; + const scheduleFlush = () => batcher.schedule(flushMessages); + for await (const parsed of readSSEStream(response)) { - switch (parsed.type) { - case "text-delta": - appendText(contentPartsState, parsed.delta); - setMessages((prev) => - prev.map((m) => - m.id === assistantMsgId - ? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) } - : m - ) - ); - break; + switch (parsed.type) { + case "text-delta": + appendText(contentPartsState, parsed.delta); + scheduleFlush(); + break; - case "tool-input-start": - addToolCall(contentPartsState, TOOLS_WITH_UI, parsed.toolCallId, parsed.toolName, {}); - setMessages((prev) => - prev.map((m) => - m.id === assistantMsgId - ? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) } - : m - ) - ); - break; + case "tool-input-start": + addToolCall(contentPartsState, TOOLS_WITH_UI, parsed.toolCallId, parsed.toolName, {}); + batcher.flush(); + break; - case "tool-input-available": - if (toolCallIndices.has(parsed.toolCallId)) { - updateToolCall(contentPartsState, parsed.toolCallId, { args: parsed.input || {} }); - } else { - addToolCall( - contentPartsState, - TOOLS_WITH_UI, - parsed.toolCallId, - parsed.toolName, - parsed.input || {} - ); - } - setMessages((prev) => - prev.map((m) => - m.id === assistantMsgId - ? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) } - : m - ) + case "tool-input-available": + if (toolCallIndices.has(parsed.toolCallId)) { + updateToolCall(contentPartsState, parsed.toolCallId, { args: parsed.input || {} }); + } else { + addToolCall( + contentPartsState, + TOOLS_WITH_UI, + parsed.toolCallId, + parsed.toolName, + parsed.input || {} ); - break; + } + batcher.flush(); + break; - case "tool-output-available": - updateToolCall(contentPartsState, parsed.toolCallId, { result: parsed.output }); - markInterruptsCompleted(contentParts); - if (parsed.output?.status === "pending" && parsed.output?.podcast_id) { - const idx = toolCallIndices.get(parsed.toolCallId); - if (idx !== undefined) { - const part = contentParts[idx]; - if (part?.type === "tool-call" && part.toolName === "generate_podcast") { - setActivePodcastTaskId(String(parsed.output.podcast_id)); - } + case "tool-output-available": + updateToolCall(contentPartsState, parsed.toolCallId, { result: parsed.output }); + markInterruptsCompleted(contentParts); + if (parsed.output?.status === "pending" && parsed.output?.podcast_id) { + const idx = toolCallIndices.get(parsed.toolCallId); + if (idx !== undefined) { + const part = contentParts[idx]; + if (part?.type === "tool-call" && part.toolName === "generate_podcast") { + setActivePodcastTaskId(String(parsed.output.podcast_id)); } } - setMessages((prev) => - prev.map((m) => - m.id === assistantMsgId - ? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) } - : m - ) - ); - break; + } + batcher.flush(); + break; - case "data-thinking-step": { - const stepData = parsed.data as ThinkingStepData; - if (stepData?.id) { - currentThinkingSteps.set(stepData.id, stepData); - updateThinkingSteps(contentPartsState, currentThinkingSteps); - setMessages((prev) => - prev.map((m) => - m.id === assistantMsgId - ? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) } - : m - ) - ); + case "data-thinking-step": { + const stepData = parsed.data as ThinkingStepData; + if (stepData?.id) { + currentThinkingSteps.set(stepData.id, stepData); + const didUpdate = updateThinkingSteps(contentPartsState, currentThinkingSteps); + if (didUpdate) { + scheduleFlush(); } - break; } - - case "error": - throw new Error(parsed.errorText || "Server error"); + break; } + + case "error": + throw new Error(parsed.errorText || "Server error"); } + } + + batcher.flush(); // Persist messages after streaming completes const finalContent = buildContentForPersistence(contentPartsState, TOOLS_WITH_UI); @@ -1477,6 +1433,7 @@ export default function NewChatPage() { if (error instanceof Error && error.name === "AbortError") { return; } + batcher.dispose(); console.error("[NewChatPage] Regeneration error:", error); trackChatError( searchSpaceId, @@ -1484,7 +1441,6 @@ export default function NewChatPage() { error instanceof Error ? error.message : "Unknown error" ); toast.error("Failed to regenerate response. Please try again."); - // Update assistant message with error setMessages((prev) => prev.map((m) => m.id === assistantMsgId diff --git a/surfsense_web/components/assistant-ui/markdown-text.tsx b/surfsense_web/components/assistant-ui/markdown-text.tsx index 815c95b68..192f46670 100644 --- a/surfsense_web/components/assistant-ui/markdown-text.tsx +++ b/surfsense_web/components/assistant-ui/markdown-text.tsx @@ -175,6 +175,7 @@ function parseTextWithCitations(text: string): ReactNode[] { const MarkdownTextImpl = () => { return ( { > thread.isEmpty}> @@ -124,7 +126,7 @@ const ThreadContent: FC = () => { /> @@ -304,7 +306,13 @@ const Composer: FC = () => { const documentPickerRef = useRef(null); const { search_space_id, chat_id } = useParams(); const aui = useAui(); + const threadViewportStore = useThreadViewportStore(); const hasAutoFocusedRef = useRef(false); + const submitCleanupRef = useRef<(() => void) | null>(null); + + useEffect(() => { + return () => { submitCleanupRef.current?.(); }; + }, []); const [quickAskText, setQuickAskText] = useState(); useEffect(() => { @@ -448,15 +456,63 @@ const Composer: FC = () => { // Submit message (blocked during streaming, document picker open, or AI responding to another user) const handleSubmit = useCallback(() => { - if (isThreadRunning || isBlockedByOtherUser) { - return; - } - if (!showDocumentPopover) { - aui.composer().send(); - editorRef.current?.clear(); - setMentionedDocuments([]); - setSidebarDocs([]); - } + if (isThreadRunning || isBlockedByOtherUser) return; + if (showDocumentPopover) return; + + const viewportEl = document.querySelector(".aui-thread-viewport"); + const heightBefore = viewportEl?.scrollHeight ?? 0; + + aui.composer().send(); + editorRef.current?.clear(); + setMentionedDocuments([]); + setSidebarDocs([]); + + // With turnAnchor="top", ViewportSlack adds min-height to the last + // assistant message so that scrolling-to-bottom actually positions the + // user message at the TOP of the viewport. That slack height is + // calculated asynchronously (ResizeObserver → style → layout). + // + // We poll via rAF for ~2 s, re-scrolling whenever scrollHeight changes + // (user msg render → assistant placeholder → ViewportSlack min-height → + // first streamed content). Backup setTimeout calls cover cases where + // the batcher's 50 ms throttle delays the DOM update past the rAF. + const scrollToBottom = () => + threadViewportStore.getState().scrollToBottom({ behavior: "instant" }); + + let lastHeight = heightBefore; + let frames = 0; + let cancelled = false; + const POLL_FRAMES = 120; + + const pollAndScroll = () => { + if (cancelled) return; + const el = document.querySelector(".aui-thread-viewport"); + if (el) { + const h = el.scrollHeight; + if (h !== lastHeight) { + lastHeight = h; + scrollToBottom(); + } + } + if (++frames < POLL_FRAMES) { + requestAnimationFrame(pollAndScroll); + } + }; + requestAnimationFrame(pollAndScroll); + + const t1 = setTimeout(scrollToBottom, 100); + const t2 = setTimeout(scrollToBottom, 300); + const t3 = setTimeout(scrollToBottom, 600); + + // Cleanup if component unmounts during the polling window. The ref is + // checked inside pollAndScroll; timeouts are cleared in the return below. + // Store cleanup fn so it can be called from a useEffect cleanup if needed. + submitCleanupRef.current = () => { + cancelled = true; + clearTimeout(t1); + clearTimeout(t2); + clearTimeout(t3); + }; }, [ showDocumentPopover, isThreadRunning, @@ -464,6 +520,7 @@ const Composer: FC = () => { aui, setMentionedDocuments, setSidebarDocs, + threadViewportStore, ]); const handleDocumentRemove = useCallback( diff --git a/surfsense_web/lib/chat/streaming-state.ts b/surfsense_web/lib/chat/streaming-state.ts index 71965a2cb..895edebf9 100644 --- a/surfsense_web/lib/chat/streaming-state.ts +++ b/surfsense_web/lib/chat/streaming-state.ts @@ -27,18 +27,48 @@ export interface ContentPartsState { toolCallIndices: Map; } +function areThinkingStepsEqual( + current: ThinkingStepData[], + next: ThinkingStepData[] +): boolean { + if (current.length !== next.length) return false; + + for (let i = 0; i < current.length; i += 1) { + const curr = current[i]; + const nxt = next[i]; + if (curr.id !== nxt.id || curr.title !== nxt.title || curr.status !== nxt.status) { + return false; + } + if (curr.items.length !== nxt.items.length) return false; + for (let j = 0; j < curr.items.length; j += 1) { + if (curr.items[j] !== nxt.items[j]) return false; + } + } + + return true; +} + export function updateThinkingSteps( state: ContentPartsState, steps: Map -): void { +): boolean { const stepsArray = Array.from(steps.values()); const existingIdx = state.contentParts.findIndex((p) => p.type === "data-thinking-steps"); if (existingIdx >= 0) { + const existing = state.contentParts[existingIdx]; + if ( + existing?.type === "data-thinking-steps" && + areThinkingStepsEqual(existing.data.steps, stepsArray) + ) { + return false; + } + state.contentParts[existingIdx] = { type: "data-thinking-steps", data: { steps: stepsArray }, }; + return true; } else { state.contentParts.unshift({ type: "data-thinking-steps", @@ -50,6 +80,56 @@ export function updateThinkingSteps( for (const [id, idx] of state.toolCallIndices) { state.toolCallIndices.set(id, idx + 1); } + return true; + } +} + +/** + * Coalesces rapid setMessages calls into at most one React state update per + * throttle interval. During streaming, SSE text-delta events arrive much + * faster than the user can perceive; throttling to ~50 ms lets React + + * ReactMarkdown do far fewer reconciliation passes, eliminating flicker. + */ +export class FrameBatchedUpdater { + private timerId: ReturnType | null = null; + private flusher: (() => void) | null = null; + private dirty = false; + private static readonly INTERVAL_MS = 50; + + /** Mark state as dirty — will flush after the throttle interval. */ + schedule(flush: () => void): void { + this.flusher = flush; + this.dirty = true; + if (this.timerId === null) { + this.timerId = setTimeout(() => { + this.timerId = null; + if (this.dirty) { + this.dirty = false; + this.flusher?.(); + } + }, FrameBatchedUpdater.INTERVAL_MS); + } + } + + /** Immediately flush any pending update (call on tool events or stream end). */ + flush(): void { + if (this.timerId !== null) { + clearTimeout(this.timerId); + this.timerId = null; + } + if (this.dirty) { + this.dirty = false; + this.flusher?.(); + } + } + + dispose(): void { + if (this.timerId !== null) { + clearTimeout(this.timerId); + this.timerId = null; + } + this.dirty = false; + this.flusher = null; } } From 2a06d035f5f039ef2b51f419cfde2ec806b816c2 Mon Sep 17 00:00:00 2001 From: "DESKTOP-RTLN3BA\\$punk" Date: Sun, 29 Mar 2026 02:52:23 -0700 Subject: [PATCH 2/2] refactor: update NewChatPage to re-initialize thread on search space switch and add new SSE event for document updates --- .../[search_space_id]/new-chat/[[...chat_id]]/page.tsx | 7 +++---- surfsense_web/lib/chat/streaming-state.ts | 1 + 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx index 699160aeb..9809c9b2e 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx @@ -273,7 +273,6 @@ export default function NewChatPage() { // Initialize thread and load messages // For new chats (no urlChatId), we use lazy creation - thread is created on first message - // biome-ignore lint/correctness/useExhaustiveDependencies: searchSpaceId triggers re-init when switching spaces with the same urlChatId const initializeThread = useCallback(async () => { setIsInitializing(true); @@ -334,7 +333,6 @@ export default function NewChatPage() { } }, [ urlChatId, - searchSpaceId, setMessageDocumentsMap, setMentionedDocuments, setSidebarDocuments, @@ -342,10 +340,10 @@ export default function NewChatPage() { closeEditorPanel, ]); - // Initialize on mount + // Initialize on mount, and re-init when switching search spaces (even if urlChatId is the same) useEffect(() => { initializeThread(); - }, [initializeThread]); + }, [initializeThread, searchSpaceId]); // Prefetch document titles for @ mention picker // Runs when user lands on page so data is ready when they type @ @@ -882,6 +880,7 @@ export default function NewChatPage() { setMentionedDocuments, setSidebarDocuments, setMessageDocumentsMap, + setAgentCreatedDocuments, queryClient, currentThread, currentUser, diff --git a/surfsense_web/lib/chat/streaming-state.ts b/surfsense_web/lib/chat/streaming-state.ts index 895edebf9..7894c8115 100644 --- a/surfsense_web/lib/chat/streaming-state.ts +++ b/surfsense_web/lib/chat/streaming-state.ts @@ -229,6 +229,7 @@ export type SSEEvent = | { type: "data-thinking-step"; data: ThinkingStepData } | { type: "data-thread-title-update"; data: { threadId: number; title: string } } | { type: "data-interrupt-request"; data: Record } + | { type: "data-documents-updated"; data: Record } | { type: "error"; errorText: string }; /**