fix(ai-chat): defer multi-tab broadcasts, disable streamdown word animation

This commit is contained in:
Eric Allam
2026-04-17 22:12:25 +01:00
parent 788661adba
commit cf7aec8944
2 changed files with 41 additions and 20 deletions
+35 -4
View File
@@ -148,13 +148,44 @@ export function useMultiTabChat<T = unknown>(
// 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<number | ReturnType<typeof setTimeout> | 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[]) => {
+6 -16
View File
@@ -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({
</p>
)}
{messages.map((message, messageIndex) => (
{messages.map((message) => (
<div
key={message.id}
className={`flex ${message.role === "user" ? "justify-end" : "justify-start"}`}
@@ -580,17 +580,7 @@ export function Chat({
{message.parts.map((part, i) => {
if (part.type === "text") {
if (message.role === "assistant") {
return (
<Streamdown
key={i}
animated
isAnimating={
status === "streaming" && messageIndex === messages.length - 1
}
>
{part.text}
</Streamdown>
);
return <Streamdown key={i}>{part.text}</Streamdown>;
}
return <span key={i}>{part.text}</span>;
}
@@ -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 })
}
/>
);