feat(chat): multi-tab coordination via BroadcastChannel
This commit is contained in:
@@ -103,9 +103,72 @@ export function useTriggerChatTransport<TTask extends AnyTask = AnyTask>(
|
||||
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);
|
||||
*
|
||||
* <input disabled={isReadOnly} placeholder={isReadOnly ? "Active in another tab" : "Type a message..."} />
|
||||
* ```
|
||||
*/
|
||||
export function useMultiTabChat<T = unknown>(
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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<string, { tabId: string; lastSeen: number }>();
|
||||
/** chatIds that THIS tab has claimed */
|
||||
private myClaims = new Set<string>();
|
||||
private listeners = new Set<ReadOnlyListener>();
|
||||
private messagesListeners = new Set<MessagesListener>();
|
||||
private sessionListeners = new Set<SessionListener>();
|
||||
private heartbeatTimer: ReturnType<typeof setInterval> | 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<TabMessage>) => {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<TClientData = unknown> = {
|
||||
params: RenewRunAccessTokenParams
|
||||
) => string | undefined | null | Promise<string | undefined | null>;
|
||||
|
||||
/**
|
||||
* 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<UIMessage> {
|
||||
private readonly defaultMetadata: Record<string, unknown> | 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<UIMessage> {
|
||||
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<UIMessage> {
|
||||
): Promise<ReadableStream<UIMessageChunk>> => {
|
||||
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<string, unknown>) ?? {}) }
|
||||
@@ -501,6 +538,7 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
|
||||
} 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<UIMessage> {
|
||||
chatId: string,
|
||||
action: unknown
|
||||
): Promise<ReadableStream<UIMessageChunk>> => {
|
||||
// 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<UIMessage> {
|
||||
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<UIMessage> {
|
||||
|
||||
// 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<UIMessage> {
|
||||
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<UIMessage> {
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -63,6 +63,7 @@ export function ChatView({
|
||||
sessions,
|
||||
onSessionChange: handleSessionChange,
|
||||
clientData: { userId: "user_123" },
|
||||
multiTab: true,
|
||||
triggerOptions: {
|
||||
tags: ["user:user_123"],
|
||||
},
|
||||
|
||||
@@ -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 && (
|
||||
<div className="mb-2 rounded border border-amber-200 bg-amber-50 px-3 py-1.5 text-xs text-amber-700">
|
||||
This chat is active in another tab. Messages are read-only.
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!input.trim()}
|
||||
disabled={!input.trim() || isReadOnly}
|
||||
className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
Send
|
||||
|
||||
@@ -176,7 +176,7 @@ export const aiChat = chat
|
||||
.agent({
|
||||
id: "ai-chat",
|
||||
idleTimeoutInSeconds: 60,
|
||||
chatAccessTokenTTL: "1m",
|
||||
chatAccessTokenTTL: "1h",
|
||||
|
||||
// #region Compaction — automatic context window management
|
||||
compaction: {
|
||||
|
||||
Reference in New Issue
Block a user