From cf7aec89447ef7eeaa326b1ecb3dc1192302fe76 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Fri, 17 Apr 2026 22:12:25 +0100 Subject: [PATCH] fix(ai-chat): defer multi-tab broadcasts, disable streamdown word animation --- packages/trigger-sdk/src/v3/chat-react.ts | 39 +++++++++++++++++++--- references/ai-chat/src/components/chat.tsx | 22 ++++-------- 2 files changed, 41 insertions(+), 20 deletions(-) diff --git a/packages/trigger-sdk/src/v3/chat-react.ts b/packages/trigger-sdk/src/v3/chat-react.ts index 1041c0b78..3ca31e9d4 100644 --- a/packages/trigger-sdk/src/v3/chat-react.ts +++ b/packages/trigger-sdk/src/v3/chat-react.ts @@ -148,13 +148,44 @@ export function useMultiTabChat( // Active tab: broadcast messages to other tabs on change. // Only broadcast when THIS tab holds the claim (is the current sender). - // Using !isReadOnly alone causes a feedback loop when both tabs are idle. + // Deferred via requestIdleCallback so the structured clone in + // BroadcastChannel.postMessage never blocks rendering during streaming. + const idleRef = useRef | null>(null); + const latestMessagesRef = useRef(messages); + latestMessagesRef.current = messages; + useEffect(() => { - if (transport.hasClaim(chatId) && messages.length > 0) { - transport.broadcastMessages(chatId, messages as unknown[]); - } + if (!transport.hasClaim(chatId) || messages.length === 0) return; + if (idleRef.current !== null) return; // Already scheduled + + const schedule = + typeof requestIdleCallback === "function" + ? requestIdleCallback + : (fn: () => void) => setTimeout(fn, 50); + + idleRef.current = schedule(() => { + idleRef.current = null; + if (transport.hasClaim(chatId)) { + transport.broadcastMessages(chatId, latestMessagesRef.current as unknown[]); + } + }); }, [transport, chatId, messages]); + // Flush final state when claim is released (turn complete) + useEffect(() => { + if (!transport.hasClaim(chatId) && latestMessagesRef.current.length > 0) { + if (idleRef.current !== null) { + const cancel = + typeof cancelIdleCallback === "function" + ? cancelIdleCallback + : clearTimeout; + cancel(idleRef.current as any); + idleRef.current = null; + } + transport.broadcastMessages(chatId, latestMessagesRef.current as unknown[]); + } + }, [transport, chatId, isReadOnly]); + // Read-only tab: receive messages from the active tab useEffect(() => { const listener = (id: string, msgs: unknown[]) => { diff --git a/references/ai-chat/src/components/chat.tsx b/references/ai-chat/src/components/chat.tsx index 9798000e9..7fc91803a 100644 --- a/references/ai-chat/src/components/chat.tsx +++ b/references/ai-chat/src/components/chat.tsx @@ -22,7 +22,7 @@ function ToolInvocation({ part: any; onApprove?: (approvalId: string) => void; onDeny?: (approvalId: string) => void; - onToolOutput?: (toolCallId: string, output: unknown) => void; + onToolOutput?: (tool: string, toolCallId: string, output: unknown) => void; }) { const [expanded, setExpanded] = useState(false); const toolName = part.type.startsWith("tool-") ? part.type.slice(5) : "tool"; @@ -87,7 +87,7 @@ function ToolInvocation({ key={opt.id} type="button" onClick={() => - onToolOutput?.(part.toolCallId, { + onToolOutput?.(toolName, part.toolCallId, { skipped: false, answers: [{ questionId: args.question, optionId: opt.id, text: opt.label }], }) @@ -566,7 +566,7 @@ export function Chat({

)} - {messages.map((message, messageIndex) => ( + {messages.map((message) => (
{ if (part.type === "text") { if (message.role === "assistant") { - return ( - - {part.text} - - ); + return {part.text}; } return {part.text}; } @@ -652,8 +642,8 @@ export function Chat({ part={part} onApprove={handleApprove} onDeny={handleDeny} - onToolOutput={(toolCallId, output) => - addToolOutput({ toolCallId, output }) + onToolOutput={(tool, toolCallId, output) => + addToolOutput({ tool, toolCallId, output }) } /> );