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,
buildContentForUI,
type ContentPartsState,
FrameBatchedUpdater,
readSSEStream,
type ThinkingStepData,
updateThinkingSteps,
@ -272,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);
@ -333,7 +333,6 @@ export default function NewChatPage() {
}
}, [
urlChatId,
searchSpaceId,
setMessageDocumentsMap,
setMentionedDocuments,
setSidebarDocuments,
@ -341,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 @
@ -571,6 +570,7 @@ export default function NewChatPage() {
// Prepare assistant message
const assistantMsgId = `msg-assistant-${Date.now()}`;
const currentThinkingSteps = new Map<string, ThinkingStepData>();
const batcher = new FrameBatchedUpdater();
const contentPartsState: ContentPartsState = {
contentParts: [],
@ -642,96 +642,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 +781,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 +812,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(
@ -899,6 +880,7 @@ export default function NewChatPage() {
setMentionedDocuments,
setSidebarDocuments,
setMessageDocumentsMap,
setAgentCreatedDocuments,
queryClient,
currentThread,
currentUser,
@ -931,6 +913,7 @@ export default function NewChatPage() {
abortControllerRef.current = controller;
const currentThinkingSteps = new Map<string, ThinkingStepData>();
const batcher = new FrameBatchedUpdater();
const contentPartsState: ContentPartsState = {
contentParts: [],
@ -1018,84 +1001,67 @@ 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
)
);
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;
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 actionRequests = (interruptData.action_requests ?? []) as Array<{
name: string;
@ -1144,6 +1110,8 @@ export default function NewChatPage() {
}
}
batcher.flush();
const finalContent = buildContentForPersistence(contentPartsState, TOOLS_WITH_UI);
if (contentParts.length > 0) {
try {
@ -1160,6 +1128,7 @@ export default function NewChatPage() {
}
}
} catch (error) {
batcher.dispose();
if (error instanceof Error && error.name === "AbortError") {
return;
}
@ -1305,6 +1274,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 +1319,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;
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":
throw new Error(parsed.errorText || "Server error");
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");
}
}
batcher.flush();
// Persist messages after streaming completes
const finalContent = buildContentForPersistence(contentPartsState, TOOLS_WITH_UI);
@ -1477,6 +1432,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 +1440,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

View file

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

View file

@ -5,6 +5,7 @@ import {
ThreadPrimitive,
useAui,
useAuiState,
useThreadViewportStore,
} from "@assistant-ui/react";
import { useAtom, useAtomValue, useSetAtom } from "jotai";
import {
@ -113,7 +114,8 @@ const ThreadContent: FC = () => {
>
<ThreadPrimitive.Viewport
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}>
<ThreadWelcome />
@ -128,7 +130,7 @@ const ThreadContent: FC = () => {
/>
<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))" }}
>
<ThreadScrollToBottom />
@ -349,7 +351,13 @@ const Composer: FC = () => {
const promptPickerRef = useRef<PromptPickerRef>(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 [clipboardInitialText, setClipboardInitialText] = useState<string | undefined>();
const clipboardLoadedRef = useRef(false);
@ -593,6 +601,63 @@ const Composer: FC = () => {
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,
showPromptPicker,
@ -602,6 +667,7 @@ const Composer: FC = () => {
aui,
setMentionedDocuments,
setSidebarDocs,
threadViewportStore,
]);
const handleDocumentRemove = useCallback(

View file

@ -27,18 +27,48 @@ export interface ContentPartsState {
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(
state: ContentPartsState,
steps: Map<string, ThinkingStepData>
): 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<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-thread-title-update"; data: { threadId: number; title: string } }
| { type: "data-interrupt-request"; data: Record<string, unknown> }
| { type: "data-documents-updated"; data: Record<string, unknown> }
| { type: "error"; errorText: string };
/**