From 29097d7d08713e894441993decc5152bc08ed32f Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Fri, 17 Apr 2026 16:18:47 +0100 Subject: [PATCH] feat(chat): multi-tab coordination via BroadcastChannel --- packages/trigger-sdk/src/v3/chat-react.ts | 63 ++++ .../src/v3/chat-tab-coordinator.test.ts | 176 ++++++++++++ .../src/v3/chat-tab-coordinator.ts | 268 ++++++++++++++++++ packages/trigger-sdk/src/v3/chat.ts | 117 ++++++++ .../ai-chat/src/components/chat-view.tsx | 1 + references/ai-chat/src/components/chat.tsx | 17 +- references/ai-chat/src/trigger/chat.ts | 2 +- 7 files changed, 639 insertions(+), 5 deletions(-) create mode 100644 packages/trigger-sdk/src/v3/chat-tab-coordinator.test.ts create mode 100644 packages/trigger-sdk/src/v3/chat-tab-coordinator.ts diff --git a/packages/trigger-sdk/src/v3/chat-react.ts b/packages/trigger-sdk/src/v3/chat-react.ts index 216e6a901..1041c0b78 100644 --- a/packages/trigger-sdk/src/v3/chat-react.ts +++ b/packages/trigger-sdk/src/v3/chat-react.ts @@ -103,9 +103,72 @@ export function useTriggerChatTransport( ref.current?.setTriggerTask(triggerTask); }, [triggerTask]); + // Note: dispose() is NOT called in effect cleanup because React strict mode + // runs cleanup+re-setup, but the transport lives in a ref and isn't recreated. + // Calling dispose() would permanently close the BroadcastChannel. + // The coordinator's beforeunload handler handles tab close cleanup instead. + return ref.current; } +/** + * Sync chat messages across browser tabs. + * + * Requires `multiTab: true` on the transport. Handles: + * - Tracking read-only state (`isReadOnly`) when another tab is active + * - Broadcasting messages from the active tab to other tabs + * - Receiving messages from other tabs and updating local state via `setMessages` + * + * @example + * ```tsx + * const transport = useTriggerChatTransport({ task: "my-chat", multiTab: true, accessToken }); + * const { messages, setMessages } = useChat({ id: chatId, transport }); + * const { isReadOnly } = useMultiTabChat(transport, chatId, messages, setMessages); + * + * + * ``` + */ +export function useMultiTabChat( + transport: TriggerChatTransport, + chatId: string, + messages: T[], + setMessages: (messages: T[]) => void +): { isReadOnly: boolean } { + const [isReadOnly, setIsReadOnly] = useState(() => transport.isReadOnly(chatId)); + + // Track read-only state + useEffect(() => { + const listener = (id: string, readOnly: boolean) => { + if (id === chatId) setIsReadOnly(readOnly); + }; + transport.addReadOnlyListener(listener); + setIsReadOnly(transport.isReadOnly(chatId)); + return () => transport.removeReadOnlyListener(listener); + }, [transport, chatId]); + + // 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. + useEffect(() => { + if (transport.hasClaim(chatId) && messages.length > 0) { + transport.broadcastMessages(chatId, messages as unknown[]); + } + }, [transport, chatId, messages]); + + // Read-only tab: receive messages from the active tab + useEffect(() => { + const listener = (id: string, msgs: unknown[]) => { + if (id === chatId) { + setMessages(msgs as T[]); + } + }; + transport.addMessagesListener(listener); + return () => transport.removeMessagesListener(listener); + }, [transport, chatId, setMessages]); + + return { isReadOnly }; +} + // --------------------------------------------------------------------------- // usePendingMessages — manage steering messages during streaming // --------------------------------------------------------------------------- diff --git a/packages/trigger-sdk/src/v3/chat-tab-coordinator.test.ts b/packages/trigger-sdk/src/v3/chat-tab-coordinator.test.ts new file mode 100644 index 000000000..2731d7698 --- /dev/null +++ b/packages/trigger-sdk/src/v3/chat-tab-coordinator.test.ts @@ -0,0 +1,176 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { ChatTabCoordinator } from "./chat-tab-coordinator.js"; + +// Mock BroadcastChannel for testing +class MockBroadcastChannel { + static instances: MockBroadcastChannel[] = []; + onmessage: ((event: MessageEvent) => void) | null = null; + closed = false; + + constructor(public name: string) { + MockBroadcastChannel.instances.push(this); + } + + postMessage(data: unknown): void { + if (this.closed) return; + // Deliver to all OTHER instances on the same channel + for (const instance of MockBroadcastChannel.instances) { + if (instance !== this && instance.name === this.name && !instance.closed) { + instance.onmessage?.({ data } as MessageEvent); + } + } + } + + close(): void { + this.closed = true; + MockBroadcastChannel.instances = MockBroadcastChannel.instances.filter((i) => i !== this); + } +} + +describe("ChatTabCoordinator", () => { + beforeEach(() => { + MockBroadcastChannel.instances = []; + vi.stubGlobal("BroadcastChannel", MockBroadcastChannel); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + it("tab A claims, tab B sees isReadOnly", () => { + const a = new ChatTabCoordinator(); + const b = new ChatTabCoordinator(); + + expect(b.isReadOnly("chat-1")).toBe(false); + + a.claim("chat-1"); + + expect(b.isReadOnly("chat-1")).toBe(true); + expect(a.isReadOnly("chat-1")).toBe(false); // Owner is not read-only + + a.dispose(); + b.dispose(); + }); + + it("tab A releases, tab B sees isReadOnly = false", () => { + const a = new ChatTabCoordinator(); + const b = new ChatTabCoordinator(); + + a.claim("chat-1"); + expect(b.isReadOnly("chat-1")).toBe(true); + + a.release("chat-1"); + expect(b.isReadOnly("chat-1")).toBe(false); + + a.dispose(); + b.dispose(); + }); + + it("fires listener on claim and release", () => { + const a = new ChatTabCoordinator(); + const b = new ChatTabCoordinator(); + const listener = vi.fn(); + b.addListener(listener); + + a.claim("chat-1"); + expect(listener).toHaveBeenCalledWith("chat-1", true); + + a.release("chat-1"); + expect(listener).toHaveBeenCalledWith("chat-1", false); + + a.dispose(); + b.dispose(); + }); + + it("removeListener stops notifications", () => { + const a = new ChatTabCoordinator(); + const b = new ChatTabCoordinator(); + const listener = vi.fn(); + b.addListener(listener); + b.removeListener(listener); + + a.claim("chat-1"); + expect(listener).not.toHaveBeenCalled(); + + a.dispose(); + b.dispose(); + }); + + it("claim returns false when another tab holds the chatId", () => { + const a = new ChatTabCoordinator(); + const b = new ChatTabCoordinator(); + + expect(a.claim("chat-1")).toBe(true); + expect(b.claim("chat-1")).toBe(false); + + a.dispose(); + b.dispose(); + }); + + it("supports multiple independent chatIds", () => { + const a = new ChatTabCoordinator(); + const b = new ChatTabCoordinator(); + + a.claim("chat-1"); + b.claim("chat-2"); + + expect(a.isReadOnly("chat-1")).toBe(false); + expect(a.isReadOnly("chat-2")).toBe(true); + expect(b.isReadOnly("chat-1")).toBe(true); + expect(b.isReadOnly("chat-2")).toBe(false); + + a.dispose(); + b.dispose(); + }); + + it("heartbeat timeout clears stale claim from crashed tab", () => { + const a = new ChatTabCoordinator(); + const b = new ChatTabCoordinator(); + const listener = vi.fn(); + b.addListener(listener); + + a.claim("chat-1"); + expect(b.isReadOnly("chat-1")).toBe(true); + + // Simulate tab A crashing (close its channel, stop heartbeats) + a.dispose(); + + // Advance past heartbeat timeout (10s) + vi.advanceTimersByTime(11_000); + + expect(b.isReadOnly("chat-1")).toBe(false); + expect(listener).toHaveBeenCalledWith("chat-1", false); + + b.dispose(); + }); + + it("dispose releases all claims", () => { + const a = new ChatTabCoordinator(); + const b = new ChatTabCoordinator(); + + a.claim("chat-1"); + a.claim("chat-2"); + expect(b.isReadOnly("chat-1")).toBe(true); + expect(b.isReadOnly("chat-2")).toBe(true); + + a.dispose(); + expect(b.isReadOnly("chat-1")).toBe(false); + expect(b.isReadOnly("chat-2")).toBe(false); + + b.dispose(); + }); + + it("gracefully degrades when BroadcastChannel is unavailable", () => { + vi.stubGlobal("BroadcastChannel", undefined); + + const coord = new ChatTabCoordinator(); + + // All operations are no-ops + expect(coord.claim("chat-1")).toBe(true); + expect(coord.isReadOnly("chat-1")).toBe(false); + coord.release("chat-1"); // No error + coord.dispose(); // No error + }); +}); diff --git a/packages/trigger-sdk/src/v3/chat-tab-coordinator.ts b/packages/trigger-sdk/src/v3/chat-tab-coordinator.ts new file mode 100644 index 000000000..42d766cd3 --- /dev/null +++ b/packages/trigger-sdk/src/v3/chat-tab-coordinator.ts @@ -0,0 +1,268 @@ +/** + * Coordinates multi-tab access to chat sessions via BroadcastChannel. + * + * When multiple browser tabs open the same chat, only one can be the active + * sender. Others enter read-only mode. The coordinator uses a simple + * claim/release/heartbeat protocol to track ownership per chatId. + * + * Gracefully degrades to a no-op when BroadcastChannel is unavailable + * (SSR, Node.js, old browsers). + * + * @internal + */ + +const CHANNEL_NAME = "trigger-chat-tab-coord"; +const HEARTBEAT_INTERVAL_MS = 5_000; +const HEARTBEAT_TIMEOUT_MS = 10_000; + +type TabMessage = + | { type: "claim"; chatId: string; tabId: string } + | { type: "release"; chatId: string; tabId: string } + | { type: "heartbeat"; chatId: string; tabId: string } + | { type: "messages"; chatId: string; tabId: string; messages: unknown[] } + | { type: "session"; chatId: string; tabId: string; session: { lastEventId?: string } }; + +type ReadOnlyListener = (chatId: string, isReadOnly: boolean) => void; +type MessagesListener = (chatId: string, messages: unknown[]) => void; +type SessionListener = (chatId: string, session: { lastEventId?: string }) => void; + +export class ChatTabCoordinator { + private tabId: string; + private channel: BroadcastChannel | null = null; + /** Claims held by OTHER tabs: chatId -> { tabId, lastSeen } */ + private claims = new Map(); + /** chatIds that THIS tab has claimed */ + private myClaims = new Set(); + private listeners = new Set(); + private messagesListeners = new Set(); + private sessionListeners = new Set(); + private heartbeatTimer: ReturnType | null = null; + private beforeUnloadHandler: (() => void) | null = null; + + constructor() { + this.tabId = + typeof crypto !== "undefined" && crypto.randomUUID + ? crypto.randomUUID() + : `tab-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + if (typeof BroadcastChannel === "undefined") { + return; // No-op mode + } + + this.channel = new BroadcastChannel(CHANNEL_NAME); + this.channel.onmessage = (event: MessageEvent) => { + this.handleMessage(event.data); + }; + + // Heartbeat: send for our claims + check for stale claims from other tabs + this.heartbeatTimer = setInterval(() => { + this.sendHeartbeats(); + this.expireStaleClaimsFromOtherTabs(); + }, HEARTBEAT_INTERVAL_MS); + + // Best-effort release on tab close + this.beforeUnloadHandler = () => this.releaseAll(); + if (typeof window !== "undefined") { + window.addEventListener("beforeunload", this.beforeUnloadHandler); + } + } + + /** + * Attempt to claim a chatId for sending. + * Returns false if another tab already holds it. + */ + claim(chatId: string): boolean { + if (!this.channel) return true; // No-op mode + + const existing = this.claims.get(chatId); + if (existing && existing.tabId !== this.tabId) { + return false; // Another tab holds this chat + } + + this.myClaims.add(chatId); + this.broadcast({ type: "claim", chatId, tabId: this.tabId }); + return true; + } + + /** Release a chatId so other tabs can claim it. */ + release(chatId: string): void { + if (!this.channel) return; + if (!this.myClaims.has(chatId)) return; + + this.myClaims.delete(chatId); + this.broadcast({ type: "release", chatId, tabId: this.tabId }); + } + + /** Check if THIS tab currently holds a claim for the chatId. */ + hasClaim(chatId: string): boolean { + return this.myClaims.has(chatId); + } + + /** Check if another tab holds this chatId. */ + isReadOnly(chatId: string): boolean { + if (!this.channel) return false; + + const claim = this.claims.get(chatId); + return claim != null && claim.tabId !== this.tabId; + } + + addListener(fn: ReadOnlyListener): void { + this.listeners.add(fn); + } + + removeListener(fn: ReadOnlyListener): void { + this.listeners.delete(fn); + } + + /** Broadcast the current messages to other tabs (for real-time sync). */ + broadcastMessages(chatId: string, messages: unknown[]): void { + if (!this.channel) return; + this.broadcast({ type: "messages", chatId, tabId: this.tabId, messages }); + } + + addMessagesListener(fn: MessagesListener): void { + this.messagesListeners.add(fn); + } + + removeMessagesListener(fn: MessagesListener): void { + this.messagesListeners.delete(fn); + } + + /** Broadcast session state (lastEventId) to other tabs. */ + broadcastSession(chatId: string, session: { lastEventId?: string }): void { + if (!this.channel) return; + this.broadcast({ type: "session", chatId, tabId: this.tabId, session }); + } + + addSessionListener(fn: SessionListener): void { + this.sessionListeners.add(fn); + } + + removeSessionListener(fn: SessionListener): void { + this.sessionListeners.delete(fn); + } + + /** Clean up channel, timers, and event listeners. */ + dispose(): void { + this.releaseAll(); + + if (this.heartbeatTimer) { + clearInterval(this.heartbeatTimer); + this.heartbeatTimer = null; + } + + if (this.beforeUnloadHandler && typeof window !== "undefined") { + window.removeEventListener("beforeunload", this.beforeUnloadHandler); + this.beforeUnloadHandler = null; + } + + if (this.channel) { + this.channel.close(); + this.channel = null; + } + + this.listeners.clear(); + this.messagesListeners.clear(); + this.sessionListeners.clear(); + } + + // --- Private --- + + private handleMessage(msg: TabMessage): void { + if (msg.tabId === this.tabId) return; // Ignore own messages + + switch (msg.type) { + case "claim": { + const wasReadOnly = this.isReadOnly(msg.chatId); + this.claims.set(msg.chatId, { tabId: msg.tabId, lastSeen: Date.now() }); + if (!wasReadOnly) { + this.notify(msg.chatId, true); + } + break; + } + case "release": { + const claim = this.claims.get(msg.chatId); + if (claim && claim.tabId === msg.tabId) { + this.claims.delete(msg.chatId); + this.notify(msg.chatId, false); + } + break; + } + case "heartbeat": { + const claim = this.claims.get(msg.chatId); + if (claim && claim.tabId === msg.tabId) { + claim.lastSeen = Date.now(); + } + break; + } + case "messages": { + this.notifyMessages(msg.chatId, msg.messages); + break; + } + case "session": { + this.notifySession(msg.chatId, msg.session); + break; + } + } + } + + private sendHeartbeats(): void { + for (const chatId of this.myClaims) { + this.broadcast({ type: "heartbeat", chatId, tabId: this.tabId }); + } + } + + private expireStaleClaimsFromOtherTabs(): void { + const now = Date.now(); + for (const [chatId, claim] of this.claims) { + if (claim.tabId !== this.tabId && now - claim.lastSeen > HEARTBEAT_TIMEOUT_MS) { + this.claims.delete(chatId); + this.notify(chatId, false); + } + } + } + + private releaseAll(): void { + for (const chatId of [...this.myClaims]) { + this.release(chatId); + } + } + + private broadcast(msg: TabMessage): void { + try { + this.channel?.postMessage(msg); + } catch { + // Channel may be closed + } + } + + private notify(chatId: string, isReadOnly: boolean): void { + for (const fn of this.listeners) { + try { + fn(chatId, isReadOnly); + } catch { + // Non-fatal + } + } + } + + private notifyMessages(chatId: string, messages: unknown[]): void { + for (const fn of this.messagesListeners) { + try { + fn(chatId, messages); + } catch { + // Non-fatal + } + } + } + + private notifySession(chatId: string, session: { lastEventId?: string }): void { + for (const fn of this.sessionListeners) { + try { + fn(chatId, session); + } catch { + // Non-fatal + } + } + } +} diff --git a/packages/trigger-sdk/src/v3/chat.ts b/packages/trigger-sdk/src/v3/chat.ts index db6ef2e40..8cddc02cb 100644 --- a/packages/trigger-sdk/src/v3/chat.ts +++ b/packages/trigger-sdk/src/v3/chat.ts @@ -37,6 +37,7 @@ function isRunPatAuthError(error: unknown): boolean { return e.name === "TriggerApiError" && (e.status === 401 || e.status === 403); } import { CHAT_MESSAGES_STREAM_ID, CHAT_STOP_STREAM_ID } from "./chat-constants.js"; +import { ChatTabCoordinator } from "./chat-tab-coordinator.js"; const DEFAULT_STREAM_KEY = "chat"; const DEFAULT_BASE_URL = "https://api.trigger.dev"; @@ -258,6 +259,20 @@ type TriggerChatTransportOptionsBase = { params: RenewRunAccessTokenParams ) => string | undefined | null | Promise; + /** + * Enable multi-tab coordination. When `true`, only one browser tab + * can send messages to a given chatId at a time. Other tabs enter + * read-only mode. Uses `BroadcastChannel` for cross-tab communication. + * + * Use `transport.isReadOnly(chatId)` or the `useChatTabCoordination` hook + * to check read-only state and disable the input UI. + * + * No-op when `BroadcastChannel` is unavailable (SSR, Node.js). + * + * @default false + */ + multiTab?: boolean; + /** * Read-only "watch" mode for observing an existing chat run from the * outside (e.g. a dashboard viewer that wants to show an agent run's @@ -384,6 +399,7 @@ export class TriggerChatTransport implements ChatTransport { private readonly defaultMetadata: Record | undefined; private readonly triggerOptions: TriggerChatTransportOptions["triggerOptions"]; private readonly watchMode: boolean; + private coordinator: ChatTabCoordinator | null = null; private _onSessionChange: | (( chatId: string, @@ -423,6 +439,19 @@ export class TriggerChatTransport implements ChatTransport { this.renewRunAccessToken = options.renewRunAccessToken; this.watchMode = options.watch ?? false; + if (options.multiTab && !this.watchMode) { + this.coordinator = new ChatTabCoordinator(); + + // Sync session state (lastEventId) from other tabs so this tab + // doesn't replay old SSE events when it takes over sending. + this.coordinator.addSessionListener((chatId, sessionUpdate) => { + const session = this.sessions.get(chatId); + if (session && sessionUpdate.lastEventId) { + session.lastEventId = sessionUpdate.lastEventId; + } + }); + } + // Restore sessions from external storage if (options.sessions) { for (const [chatId, session] of Object.entries(options.sessions)) { @@ -447,6 +476,14 @@ export class TriggerChatTransport implements ChatTransport { ): Promise> => { const { trigger, chatId, messageId, messages, abortSignal, body, metadata } = options; + // Multi-tab coordination: prevent sending from a read-only tab + if (this.coordinator) { + if (this.coordinator.isReadOnly(chatId)) { + throw new Error("This chat is active in another tab"); + } + this.coordinator.claim(chatId); + } + const mergedMetadata = this.defaultMetadata || metadata ? { ...(this.defaultMetadata ?? {}), ...((metadata as Record) ?? {}) } @@ -501,6 +538,7 @@ export class TriggerChatTransport implements ChatTransport { } else { previousRunId = session.runId; this.sessions.delete(chatId); + this.coordinator?.release(chatId); this.notifySessionChange(chatId, null); isContinuation = true; } @@ -739,6 +777,14 @@ export class TriggerChatTransport implements ChatTransport { chatId: string, action: unknown ): Promise> => { + // Multi-tab coordination: prevent sending from a read-only tab + if (this.coordinator) { + if (this.coordinator.isReadOnly(chatId)) { + throw new Error("This chat is active in another tab"); + } + this.coordinator.claim(chatId); + } + const session = this.sessions.get(chatId); if (session?.runId) { @@ -844,6 +890,59 @@ export class TriggerChatTransport implements ChatTransport { this.triggerTaskFn = fn; } + /** + * Check if a chatId is read-only (another tab is actively sending). + * Always returns `false` when `multiTab` is not enabled. + */ + isReadOnly(chatId: string): boolean { + return this.coordinator?.isReadOnly(chatId) ?? false; + } + + /** + * Check if THIS tab currently holds a claim for the chatId + * (i.e. this tab called sendMessages and the turn is in-flight). + */ + hasClaim(chatId: string): boolean { + return this.coordinator?.hasClaim(chatId) ?? false; + } + + /** + * Listen for read-only state changes across tabs. + * The listener receives `(chatId, isReadOnly)`. + */ + addReadOnlyListener(fn: (chatId: string, isReadOnly: boolean) => void): void { + this.coordinator?.addListener(fn); + } + + /** Remove a previously added read-only listener. */ + removeReadOnlyListener(fn: (chatId: string, isReadOnly: boolean) => void): void { + this.coordinator?.removeListener(fn); + } + + /** Broadcast messages to other tabs for real-time sync. */ + broadcastMessages(chatId: string, messages: unknown[]): void { + this.coordinator?.broadcastMessages(chatId, messages); + } + + /** Listen for message updates from other tabs. */ + addMessagesListener(fn: (chatId: string, messages: unknown[]) => void): void { + this.coordinator?.addMessagesListener(fn); + } + + /** Remove a previously added messages listener. */ + removeMessagesListener(fn: (chatId: string, messages: unknown[]) => void): void { + this.coordinator?.removeMessagesListener(fn); + } + + /** + * Clean up resources (BroadcastChannel, heartbeat timers). + * Call on component unmount when using `multiTab`. + */ + dispose(): void { + this.coordinator?.dispose(); + this.coordinator = null; + } + /** * Inject or update a session for a chat. Useful for resuming conversations * from persisted state without recreating the transport. @@ -1175,6 +1274,7 @@ export class TriggerChatTransport implements ChatTransport { // Clear session so triggerNewRun creates a fresh one this.sessions.delete(chatId); + this.coordinator?.release(chatId); this.notifySessionChange(chatId, null); try { @@ -1233,6 +1333,17 @@ export class TriggerChatTransport implements ChatTransport { this.notifySessionChange(chatId, session); } + // Release multi-tab claim so other tabs can send + this.coordinator?.release(chatId); + + // Broadcast session to other tabs so they have the latest + // lastEventId (prevents replaying old SSE events on next send) + if (session) { + this.coordinator?.broadcastSession(chatId, { + lastEventId: session.lastEventId, + }); + } + // Watch mode: keep the subscription open across turn // boundaries so the consumer sees turn 2, 3, etc. through // a single long-lived ReadableStream. Filter the control @@ -1269,6 +1380,12 @@ export class TriggerChatTransport implements ChatTransport { } controller.error(error); + } finally { + // Release multi-tab claim when the stream closes for any reason + // (turn-complete, error, abort, stream end) + if (chatId) { + this.coordinator?.release(chatId); + } } }, }); diff --git a/references/ai-chat/src/components/chat-view.tsx b/references/ai-chat/src/components/chat-view.tsx index 0cf82a9bf..5f72a8b0f 100644 --- a/references/ai-chat/src/components/chat-view.tsx +++ b/references/ai-chat/src/components/chat-view.tsx @@ -63,6 +63,7 @@ export function ChatView({ sessions, onSessionChange: handleSessionChange, clientData: { userId: "user_123" }, + multiTab: true, triggerOptions: { tags: ["user:user_123"], }, diff --git a/references/ai-chat/src/components/chat.tsx b/references/ai-chat/src/components/chat.tsx index 93990c0b3..9798000e9 100644 --- a/references/ai-chat/src/components/chat.tsx +++ b/references/ai-chat/src/components/chat.tsx @@ -8,7 +8,7 @@ import { import type { ChatUiMessage } from "@/lib/chat-tools"; import type { TriggerChatTransport } from "@trigger.dev/sdk/chat"; import type { CompactionChunkData } from "@trigger.dev/sdk/ai"; -import { usePendingMessages } from "@trigger.dev/sdk/chat/react"; +import { usePendingMessages, useMultiTabChat } from "@trigger.dev/sdk/chat/react"; import { useCallback, useEffect, useRef, useState } from "react"; import { Streamdown } from "streamdown"; import { MODEL_OPTIONS } from "@/lib/models"; @@ -353,6 +353,9 @@ export function Chat({ lastAssistantMessageIsCompleteWithToolCalls(opts), }); + // Multi-tab coordination: sync messages between tabs + const { isReadOnly } = useMultiTabChat(transport, chatId, messages, setMessages); + // Use transport.stopGeneration for reliable stop after reconnect. // Once the AI SDK passes abortSignal through reconnectToStream, // aiStop() alone will suffice. Until then, this covers both cases. @@ -768,17 +771,23 @@ export function Chat({ }} className="shrink-0 border-t border-gray-200 bg-white p-4" > + {isReadOnly && ( +
+ This chat is active in another tab. Messages are read-only. +
+ )}
setInput(e.target.value)} - placeholder="Type a message..." - className="flex-1 rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500" + placeholder={isReadOnly ? "Chat is active in another tab" : "Type a message..."} + disabled={isReadOnly} + className="flex-1 rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 disabled:bg-gray-100 disabled:text-gray-400" />