Merge pull request #1033 from MODSetter/dev_mod

fix: chat UI issues
This commit is contained in:
Rohan Verma 2026-03-29 02:54:38 -07:00 committed by GitHub
commit 05030f6664
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 333 additions and 230 deletions

View file

@ -56,6 +56,7 @@ import {
buildContentForPersistence, buildContentForPersistence,
buildContentForUI, buildContentForUI,
type ContentPartsState, type ContentPartsState,
FrameBatchedUpdater,
readSSEStream, readSSEStream,
type ThinkingStepData, type ThinkingStepData,
updateThinkingSteps, updateThinkingSteps,
@ -272,7 +273,6 @@ export default function NewChatPage() {
// Initialize thread and load messages // Initialize thread and load messages
// For new chats (no urlChatId), we use lazy creation - thread is created on first message // 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 () => { const initializeThread = useCallback(async () => {
setIsInitializing(true); setIsInitializing(true);
@ -333,7 +333,6 @@ export default function NewChatPage() {
} }
}, [ }, [
urlChatId, urlChatId,
searchSpaceId,
setMessageDocumentsMap, setMessageDocumentsMap,
setMentionedDocuments, setMentionedDocuments,
setSidebarDocuments, setSidebarDocuments,
@ -341,10 +340,10 @@ export default function NewChatPage() {
closeEditorPanel, closeEditorPanel,
]); ]);
// Initialize on mount // Initialize on mount, and re-init when switching search spaces (even if urlChatId is the same)
useEffect(() => { useEffect(() => {
initializeThread(); initializeThread();
}, [initializeThread]); }, [initializeThread, searchSpaceId]);
// Prefetch document titles for @ mention picker // Prefetch document titles for @ mention picker
// Runs when user lands on page so data is ready when they type @ // Runs when user lands on page so data is ready when they type @
@ -571,6 +570,7 @@ export default function NewChatPage() {
// Prepare assistant message // Prepare assistant message
const assistantMsgId = `msg-assistant-${Date.now()}`; const assistantMsgId = `msg-assistant-${Date.now()}`;
const currentThinkingSteps = new Map<string, ThinkingStepData>(); const currentThinkingSteps = new Map<string, ThinkingStepData>();
const batcher = new FrameBatchedUpdater();
const contentPartsState: ContentPartsState = { const contentPartsState: ContentPartsState = {
contentParts: [], contentParts: [],
@ -642,96 +642,74 @@ export default function NewChatPage() {
throw new Error(`Backend error: ${response.status}`); 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)) { for await (const parsed of readSSEStream(response)) {
switch (parsed.type) { switch (parsed.type) {
case "text-delta": case "text-delta":
appendText(contentPartsState, parsed.delta); appendText(contentPartsState, parsed.delta);
setMessages((prev) => scheduleFlush();
prev.map((m) => break;
m.id === assistantMsgId
? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) }
: m
)
);
break;
case "tool-input-start": case "tool-input-start":
// Add tool call inline - this breaks the current text segment addToolCall(contentPartsState, TOOLS_WITH_UI, parsed.toolCallId, parsed.toolName, {});
addToolCall(contentPartsState, TOOLS_WITH_UI, parsed.toolCallId, parsed.toolName, {}); batcher.flush();
setMessages((prev) => break;
prev.map((m) =>
m.id === assistantMsgId
? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) }
: m
)
);
break;
case "tool-input-available": { case "tool-input-available": {
// Update existing tool call's args, or add if not exists if (toolCallIndices.has(parsed.toolCallId)) {
if (toolCallIndices.has(parsed.toolCallId)) { updateToolCall(contentPartsState, parsed.toolCallId, { args: parsed.input || {} });
updateToolCall(contentPartsState, parsed.toolCallId, { args: parsed.input || {} }); } else {
} else { addToolCall(
addToolCall( contentPartsState,
contentPartsState, TOOLS_WITH_UI,
TOOLS_WITH_UI, parsed.toolCallId,
parsed.toolCallId, parsed.toolName,
parsed.toolName, parsed.input || {}
parsed.input || {}
);
}
setMessages((prev) =>
prev.map((m) =>
m.id === assistantMsgId
? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) }
: m
)
); );
break;
} }
batcher.flush();
break;
}
case "tool-output-available": { case "tool-output-available": {
// Update the tool call with its result updateToolCall(contentPartsState, parsed.toolCallId, { result: parsed.output });
updateToolCall(contentPartsState, parsed.toolCallId, { result: parsed.output }); markInterruptsCompleted(contentParts);
markInterruptsCompleted(contentParts); if (parsed.output?.status === "pending" && parsed.output?.podcast_id) {
// Handle podcast-specific logic const idx = toolCallIndices.get(parsed.toolCallId);
if (parsed.output?.status === "pending" && parsed.output?.podcast_id) { if (idx !== undefined) {
// Check if this is a podcast tool by looking at the content part const part = contentParts[idx];
const idx = toolCallIndices.get(parsed.toolCallId); if (part?.type === "tool-call" && part.toolName === "generate_podcast") {
if (idx !== undefined) { setActivePodcastTaskId(String(parsed.output.podcast_id));
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": { case "data-thinking-step": {
const stepData = parsed.data as ThinkingStepData; const stepData = parsed.data as ThinkingStepData;
if (stepData?.id) { if (stepData?.id) {
currentThinkingSteps.set(stepData.id, stepData); currentThinkingSteps.set(stepData.id, stepData);
updateThinkingSteps(contentPartsState, currentThinkingSteps); const didUpdate = updateThinkingSteps(contentPartsState, currentThinkingSteps);
setMessages((prev) => if (didUpdate) {
prev.map((m) => scheduleFlush();
m.id === assistantMsgId
? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) }
: m
)
);
} }
break;
} }
break;
}
case "data-thread-title-update": { case "data-thread-title-update": {
const titleData = parsed.data as { threadId: number; title: string }; const titleData = parsed.data as { threadId: number; title: string };
if (titleData?.title && titleData?.threadId === currentThreadId) { if (titleData?.title && titleData?.threadId === currentThreadId) {
setCurrentThread((prev) => (prev ? { ...prev, title: titleData.title } : prev)); setCurrentThread((prev) => (prev ? { ...prev, title: titleData.title } : prev));
@ -803,6 +781,8 @@ export default function NewChatPage() {
} }
} }
batcher.flush();
// Skip persistence for interrupted messages -- handleResume will persist the final version // Skip persistence for interrupted messages -- handleResume will persist the final version
const finalContent = buildContentForPersistence(contentPartsState, TOOLS_WITH_UI); const finalContent = buildContentForPersistence(contentPartsState, TOOLS_WITH_UI);
if (contentParts.length > 0 && !wasInterrupted) { if (contentParts.length > 0 && !wasInterrupted) {
@ -832,6 +812,7 @@ export default function NewChatPage() {
trackChatResponseReceived(searchSpaceId, currentThreadId); trackChatResponseReceived(searchSpaceId, currentThreadId);
} }
} catch (error) { } catch (error) {
batcher.dispose();
if (error instanceof Error && error.name === "AbortError") { if (error instanceof Error && error.name === "AbortError") {
// Request was cancelled by user - persist partial response if any content was received // Request was cancelled by user - persist partial response if any content was received
const hasContent = contentParts.some( const hasContent = contentParts.some(
@ -899,6 +880,7 @@ export default function NewChatPage() {
setMentionedDocuments, setMentionedDocuments,
setSidebarDocuments, setSidebarDocuments,
setMessageDocumentsMap, setMessageDocumentsMap,
setAgentCreatedDocuments,
queryClient, queryClient,
currentThread, currentThread,
currentUser, currentUser,
@ -931,6 +913,7 @@ export default function NewChatPage() {
abortControllerRef.current = controller; abortControllerRef.current = controller;
const currentThinkingSteps = new Map<string, ThinkingStepData>(); const currentThinkingSteps = new Map<string, ThinkingStepData>();
const batcher = new FrameBatchedUpdater();
const contentPartsState: ContentPartsState = { const contentPartsState: ContentPartsState = {
contentParts: [], contentParts: [],
@ -1018,84 +1001,67 @@ export default function NewChatPage() {
throw new Error(`Backend error: ${response.status}`); 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)) { for await (const parsed of readSSEStream(response)) {
switch (parsed.type) { switch (parsed.type) {
case "text-delta": case "text-delta":
appendText(contentPartsState, parsed.delta); appendText(contentPartsState, parsed.delta);
setMessages((prev) => scheduleFlush();
prev.map((m) => break;
m.id === assistantMsgId
? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) }
: m
)
);
break;
case "tool-input-start": case "tool-input-start":
addToolCall(contentPartsState, TOOLS_WITH_UI, parsed.toolCallId, parsed.toolName, {}); addToolCall(contentPartsState, TOOLS_WITH_UI, parsed.toolCallId, parsed.toolName, {});
setMessages((prev) => batcher.flush();
prev.map((m) => break;
m.id === assistantMsgId
? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) }
: m
)
);
break;
case "tool-input-available": case "tool-input-available":
if (toolCallIndices.has(parsed.toolCallId)) { 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-output-available":
updateToolCall(contentPartsState, parsed.toolCallId, { updateToolCall(contentPartsState, parsed.toolCallId, {
result: parsed.output, args: parsed.input || {},
}); });
markInterruptsCompleted(contentParts); } else {
setMessages((prev) => addToolCall(
prev.map((m) => contentPartsState,
m.id === assistantMsgId TOOLS_WITH_UI,
? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) } parsed.toolCallId,
: m parsed.toolName,
) parsed.input || {}
); );
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
)
);
}
break;
} }
batcher.flush();
break;
case "data-interrupt-request": { 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;
}
case "data-interrupt-request": {
const interruptData = parsed.data as Record<string, unknown>; const interruptData = parsed.data as Record<string, unknown>;
const actionRequests = (interruptData.action_requests ?? []) as Array<{ const actionRequests = (interruptData.action_requests ?? []) as Array<{
name: string; name: string;
@ -1144,6 +1110,8 @@ export default function NewChatPage() {
} }
} }
batcher.flush();
const finalContent = buildContentForPersistence(contentPartsState, TOOLS_WITH_UI); const finalContent = buildContentForPersistence(contentPartsState, TOOLS_WITH_UI);
if (contentParts.length > 0) { if (contentParts.length > 0) {
try { try {
@ -1160,6 +1128,7 @@ export default function NewChatPage() {
} }
} }
} catch (error) { } catch (error) {
batcher.dispose();
if (error instanceof Error && error.name === "AbortError") { if (error instanceof Error && error.name === "AbortError") {
return; return;
} }
@ -1305,6 +1274,7 @@ export default function NewChatPage() {
toolCallIndices: new Map(), toolCallIndices: new Map(),
}; };
const { contentParts, toolCallIndices } = contentPartsState; const { contentParts, toolCallIndices } = contentPartsState;
const batcher = new FrameBatchedUpdater();
// Add placeholder messages to UI // Add placeholder messages to UI
// Always add back the user message (with new query for edit, or original content for reload) // Always add back the user message (with new query for edit, or original content for reload)
@ -1349,92 +1319,77 @@ export default function NewChatPage() {
throw new Error(`Backend error: ${response.status}`); 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)) { for await (const parsed of readSSEStream(response)) {
switch (parsed.type) { switch (parsed.type) {
case "text-delta": case "text-delta":
appendText(contentPartsState, parsed.delta); appendText(contentPartsState, parsed.delta);
setMessages((prev) => scheduleFlush();
prev.map((m) => break;
m.id === assistantMsgId
? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) }
: m
)
);
break;
case "tool-input-start": case "tool-input-start":
addToolCall(contentPartsState, TOOLS_WITH_UI, parsed.toolCallId, parsed.toolName, {}); addToolCall(contentPartsState, TOOLS_WITH_UI, parsed.toolCallId, parsed.toolName, {});
setMessages((prev) => batcher.flush();
prev.map((m) => break;
m.id === assistantMsgId
? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) }
: m
)
);
break;
case "tool-input-available": case "tool-input-available":
if (toolCallIndices.has(parsed.toolCallId)) { if (toolCallIndices.has(parsed.toolCallId)) {
updateToolCall(contentPartsState, parsed.toolCallId, { args: parsed.input || {} }); updateToolCall(contentPartsState, parsed.toolCallId, { args: parsed.input || {} });
} else { } else {
addToolCall( addToolCall(
contentPartsState, contentPartsState,
TOOLS_WITH_UI, TOOLS_WITH_UI,
parsed.toolCallId, parsed.toolCallId,
parsed.toolName, parsed.toolName,
parsed.input || {} parsed.input || {}
);
}
setMessages((prev) =>
prev.map((m) =>
m.id === assistantMsgId
? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) }
: m
)
); );
break; }
batcher.flush();
break;
case "tool-output-available": case "tool-output-available":
updateToolCall(contentPartsState, parsed.toolCallId, { result: parsed.output }); updateToolCall(contentPartsState, parsed.toolCallId, { result: parsed.output });
markInterruptsCompleted(contentParts); markInterruptsCompleted(contentParts);
if (parsed.output?.status === "pending" && parsed.output?.podcast_id) { if (parsed.output?.status === "pending" && parsed.output?.podcast_id) {
const idx = toolCallIndices.get(parsed.toolCallId); const idx = toolCallIndices.get(parsed.toolCallId);
if (idx !== undefined) { if (idx !== undefined) {
const part = contentParts[idx]; const part = contentParts[idx];
if (part?.type === "tool-call" && part.toolName === "generate_podcast") { if (part?.type === "tool-call" && part.toolName === "generate_podcast") {
setActivePodcastTaskId(String(parsed.output.podcast_id)); setActivePodcastTaskId(String(parsed.output.podcast_id));
}
} }
} }
setMessages((prev) =>
prev.map((m) =>
m.id === assistantMsgId
? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) }
: m
)
);
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
)
);
}
break;
} }
batcher.flush();
break;
case "error": case "data-thinking-step": {
throw new Error(parsed.errorText || "Server error"); 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");
} }
}
batcher.flush();
// Persist messages after streaming completes // Persist messages after streaming completes
const finalContent = buildContentForPersistence(contentPartsState, TOOLS_WITH_UI); const finalContent = buildContentForPersistence(contentPartsState, TOOLS_WITH_UI);
@ -1477,6 +1432,7 @@ export default function NewChatPage() {
if (error instanceof Error && error.name === "AbortError") { if (error instanceof Error && error.name === "AbortError") {
return; return;
} }
batcher.dispose();
console.error("[NewChatPage] Regeneration error:", error); console.error("[NewChatPage] Regeneration error:", error);
trackChatError( trackChatError(
searchSpaceId, searchSpaceId,
@ -1484,7 +1440,6 @@ export default function NewChatPage() {
error instanceof Error ? error.message : "Unknown error" error instanceof Error ? error.message : "Unknown error"
); );
toast.error("Failed to regenerate response. Please try again."); toast.error("Failed to regenerate response. Please try again.");
// Update assistant message with error
setMessages((prev) => setMessages((prev) =>
prev.map((m) => prev.map((m) =>
m.id === assistantMsgId m.id === assistantMsgId

View file

@ -175,6 +175,7 @@ function parseTextWithCitations(text: string): ReactNode[] {
const MarkdownTextImpl = () => { const MarkdownTextImpl = () => {
return ( return (
<MarkdownTextPrimitive <MarkdownTextPrimitive
smooth={false}
remarkPlugins={[remarkGfm, remarkMath]} remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={[rehypeKatex]} rehypePlugins={[rehypeKatex]}
className="aui-md" className="aui-md"

View file

@ -5,6 +5,7 @@ import {
ThreadPrimitive, ThreadPrimitive,
useAui, useAui,
useAuiState, useAuiState,
useThreadViewportStore,
} from "@assistant-ui/react"; } from "@assistant-ui/react";
import { useAtom, useAtomValue, useSetAtom } from "jotai"; import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { import {
@ -113,7 +114,8 @@ const ThreadContent: FC = () => {
> >
<ThreadPrimitive.Viewport <ThreadPrimitive.Viewport
turnAnchor="top" turnAnchor="top"
className="aui-thread-viewport relative flex flex-1 min-h-0 flex-col overflow-y-scroll px-4 pt-4" className="aui-thread-viewport relative flex flex-1 min-h-0 flex-col overflow-y-auto px-4 pt-4"
style={{ scrollbarGutter: "stable" }}
> >
<AuiIf condition={({ thread }) => thread.isEmpty}> <AuiIf condition={({ thread }) => thread.isEmpty}>
<ThreadWelcome /> <ThreadWelcome />
@ -128,7 +130,7 @@ const ThreadContent: FC = () => {
/> />
<ThreadPrimitive.ViewportFooter <ThreadPrimitive.ViewportFooter
className="aui-thread-viewport-footer sticky bottom-0 z-10 mx-auto mt-auto flex w-full max-w-(--thread-max-width) flex-col gap-4 overflow-visible rounded-t-3xl bg-main-panel pb-4 md:pb-6" className="aui-thread-viewport-footer sticky bottom-0 z-10 mx-auto flex w-full max-w-(--thread-max-width) flex-col gap-4 overflow-visible rounded-t-3xl bg-main-panel pb-4 md:pb-6"
style={{ paddingBottom: "max(1rem, env(safe-area-inset-bottom))" }} style={{ paddingBottom: "max(1rem, env(safe-area-inset-bottom))" }}
> >
<ThreadScrollToBottom /> <ThreadScrollToBottom />
@ -349,7 +351,13 @@ const Composer: FC = () => {
const promptPickerRef = useRef<PromptPickerRef>(null); const promptPickerRef = useRef<PromptPickerRef>(null);
const { search_space_id, chat_id } = useParams(); const { search_space_id, chat_id } = useParams();
const aui = useAui(); const aui = useAui();
const threadViewportStore = useThreadViewportStore();
const hasAutoFocusedRef = useRef(false); const hasAutoFocusedRef = useRef(false);
const submitCleanupRef = useRef<(() => void) | null>(null);
useEffect(() => {
return () => { submitCleanupRef.current?.(); };
}, []);
const [clipboardInitialText, setClipboardInitialText] = useState<string | undefined>(); const [clipboardInitialText, setClipboardInitialText] = useState<string | undefined>();
const clipboardLoadedRef = useRef(false); const clipboardLoadedRef = useRef(false);
@ -593,6 +601,63 @@ const Composer: FC = () => {
setMentionedDocuments([]); setMentionedDocuments([]);
setSidebarDocs([]); 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, showDocumentPopover,
showPromptPicker, showPromptPicker,
@ -602,6 +667,7 @@ const Composer: FC = () => {
aui, aui,
setMentionedDocuments, setMentionedDocuments,
setSidebarDocs, setSidebarDocs,
threadViewportStore,
]); ]);
const handleDocumentRemove = useCallback( const handleDocumentRemove = useCallback(

View file

@ -27,18 +27,48 @@ export interface ContentPartsState {
toolCallIndices: Map<string, number>; toolCallIndices: Map<string, number>;
} }
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( export function updateThinkingSteps(
state: ContentPartsState, state: ContentPartsState,
steps: Map<string, ThinkingStepData> steps: Map<string, ThinkingStepData>
): void { ): boolean {
const stepsArray = Array.from(steps.values()); const stepsArray = Array.from(steps.values());
const existingIdx = state.contentParts.findIndex((p) => p.type === "data-thinking-steps"); const existingIdx = state.contentParts.findIndex((p) => p.type === "data-thinking-steps");
if (existingIdx >= 0) { if (existingIdx >= 0) {
const existing = state.contentParts[existingIdx];
if (
existing?.type === "data-thinking-steps" &&
areThinkingStepsEqual(existing.data.steps, stepsArray)
) {
return false;
}
state.contentParts[existingIdx] = { state.contentParts[existingIdx] = {
type: "data-thinking-steps", type: "data-thinking-steps",
data: { steps: stepsArray }, data: { steps: stepsArray },
}; };
return true;
} else { } else {
state.contentParts.unshift({ state.contentParts.unshift({
type: "data-thinking-steps", type: "data-thinking-steps",
@ -50,6 +80,56 @@ export function updateThinkingSteps(
for (const [id, idx] of state.toolCallIndices) { for (const [id, idx] of state.toolCallIndices) {
state.toolCallIndices.set(id, idx + 1); 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<typeof setTimeout> | 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;
} }
} }
@ -149,6 +229,7 @@ export type SSEEvent =
| { type: "data-thinking-step"; data: ThinkingStepData } | { type: "data-thinking-step"; data: ThinkingStepData }
| { type: "data-thread-title-update"; data: { threadId: number; title: string } } | { type: "data-thread-title-update"; data: { threadId: number; title: string } }
| { type: "data-interrupt-request"; data: Record<string, unknown> } | { type: "data-interrupt-request"; data: Record<string, unknown> }
| { type: "data-documents-updated"; data: Record<string, unknown> }
| { type: "error"; errorText: string }; | { type: "error"; errorText: string };
/** /**