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 })
}
/>
);