Skip to content

Commit 955e727

Browse files
committed
feat(chat): multi-tab coordination via BroadcastChannel
1 parent 79546ee commit 955e727

7 files changed

Lines changed: 639 additions & 5 deletions

File tree

packages/trigger-sdk/src/v3/chat-react.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,9 +103,72 @@ export function useTriggerChatTransport<TTask extends AnyTask = AnyTask>(
103103
ref.current?.setTriggerTask(triggerTask);
104104
}, [triggerTask]);
105105

106+
// Note: dispose() is NOT called in effect cleanup because React strict mode
107+
// runs cleanup+re-setup, but the transport lives in a ref and isn't recreated.
108+
// Calling dispose() would permanently close the BroadcastChannel.
109+
// The coordinator's beforeunload handler handles tab close cleanup instead.
110+
106111
return ref.current;
107112
}
108113

114+
/**
115+
* Sync chat messages across browser tabs.
116+
*
117+
* Requires `multiTab: true` on the transport. Handles:
118+
* - Tracking read-only state (`isReadOnly`) when another tab is active
119+
* - Broadcasting messages from the active tab to other tabs
120+
* - Receiving messages from other tabs and updating local state via `setMessages`
121+
*
122+
* @example
123+
* ```tsx
124+
* const transport = useTriggerChatTransport({ task: "my-chat", multiTab: true, accessToken });
125+
* const { messages, setMessages } = useChat({ id: chatId, transport });
126+
* const { isReadOnly } = useMultiTabChat(transport, chatId, messages, setMessages);
127+
*
128+
* <input disabled={isReadOnly} placeholder={isReadOnly ? "Active in another tab" : "Type a message..."} />
129+
* ```
130+
*/
131+
export function useMultiTabChat<T = unknown>(
132+
transport: TriggerChatTransport,
133+
chatId: string,
134+
messages: T[],
135+
setMessages: (messages: T[]) => void
136+
): { isReadOnly: boolean } {
137+
const [isReadOnly, setIsReadOnly] = useState(() => transport.isReadOnly(chatId));
138+
139+
// Track read-only state
140+
useEffect(() => {
141+
const listener = (id: string, readOnly: boolean) => {
142+
if (id === chatId) setIsReadOnly(readOnly);
143+
};
144+
transport.addReadOnlyListener(listener);
145+
setIsReadOnly(transport.isReadOnly(chatId));
146+
return () => transport.removeReadOnlyListener(listener);
147+
}, [transport, chatId]);
148+
149+
// Active tab: broadcast messages to other tabs on change.
150+
// Only broadcast when THIS tab holds the claim (is the current sender).
151+
// Using !isReadOnly alone causes a feedback loop when both tabs are idle.
152+
useEffect(() => {
153+
if (transport.hasClaim(chatId) && messages.length > 0) {
154+
transport.broadcastMessages(chatId, messages as unknown[]);
155+
}
156+
}, [transport, chatId, messages]);
157+
158+
// Read-only tab: receive messages from the active tab
159+
useEffect(() => {
160+
const listener = (id: string, msgs: unknown[]) => {
161+
if (id === chatId) {
162+
setMessages(msgs as T[]);
163+
}
164+
};
165+
transport.addMessagesListener(listener);
166+
return () => transport.removeMessagesListener(listener);
167+
}, [transport, chatId, setMessages]);
168+
169+
return { isReadOnly };
170+
}
171+
109172
// ---------------------------------------------------------------------------
110173
// usePendingMessages — manage steering messages during streaming
111174
// ---------------------------------------------------------------------------
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2+
import { ChatTabCoordinator } from "./chat-tab-coordinator.js";
3+
4+
// Mock BroadcastChannel for testing
5+
class MockBroadcastChannel {
6+
static instances: MockBroadcastChannel[] = [];
7+
onmessage: ((event: MessageEvent) => void) | null = null;
8+
closed = false;
9+
10+
constructor(public name: string) {
11+
MockBroadcastChannel.instances.push(this);
12+
}
13+
14+
postMessage(data: unknown): void {
15+
if (this.closed) return;
16+
// Deliver to all OTHER instances on the same channel
17+
for (const instance of MockBroadcastChannel.instances) {
18+
if (instance !== this && instance.name === this.name && !instance.closed) {
19+
instance.onmessage?.({ data } as MessageEvent);
20+
}
21+
}
22+
}
23+
24+
close(): void {
25+
this.closed = true;
26+
MockBroadcastChannel.instances = MockBroadcastChannel.instances.filter((i) => i !== this);
27+
}
28+
}
29+
30+
describe("ChatTabCoordinator", () => {
31+
beforeEach(() => {
32+
MockBroadcastChannel.instances = [];
33+
vi.stubGlobal("BroadcastChannel", MockBroadcastChannel);
34+
vi.useFakeTimers();
35+
});
36+
37+
afterEach(() => {
38+
vi.restoreAllMocks();
39+
vi.useRealTimers();
40+
});
41+
42+
it("tab A claims, tab B sees isReadOnly", () => {
43+
const a = new ChatTabCoordinator();
44+
const b = new ChatTabCoordinator();
45+
46+
expect(b.isReadOnly("chat-1")).toBe(false);
47+
48+
a.claim("chat-1");
49+
50+
expect(b.isReadOnly("chat-1")).toBe(true);
51+
expect(a.isReadOnly("chat-1")).toBe(false); // Owner is not read-only
52+
53+
a.dispose();
54+
b.dispose();
55+
});
56+
57+
it("tab A releases, tab B sees isReadOnly = false", () => {
58+
const a = new ChatTabCoordinator();
59+
const b = new ChatTabCoordinator();
60+
61+
a.claim("chat-1");
62+
expect(b.isReadOnly("chat-1")).toBe(true);
63+
64+
a.release("chat-1");
65+
expect(b.isReadOnly("chat-1")).toBe(false);
66+
67+
a.dispose();
68+
b.dispose();
69+
});
70+
71+
it("fires listener on claim and release", () => {
72+
const a = new ChatTabCoordinator();
73+
const b = new ChatTabCoordinator();
74+
const listener = vi.fn();
75+
b.addListener(listener);
76+
77+
a.claim("chat-1");
78+
expect(listener).toHaveBeenCalledWith("chat-1", true);
79+
80+
a.release("chat-1");
81+
expect(listener).toHaveBeenCalledWith("chat-1", false);
82+
83+
a.dispose();
84+
b.dispose();
85+
});
86+
87+
it("removeListener stops notifications", () => {
88+
const a = new ChatTabCoordinator();
89+
const b = new ChatTabCoordinator();
90+
const listener = vi.fn();
91+
b.addListener(listener);
92+
b.removeListener(listener);
93+
94+
a.claim("chat-1");
95+
expect(listener).not.toHaveBeenCalled();
96+
97+
a.dispose();
98+
b.dispose();
99+
});
100+
101+
it("claim returns false when another tab holds the chatId", () => {
102+
const a = new ChatTabCoordinator();
103+
const b = new ChatTabCoordinator();
104+
105+
expect(a.claim("chat-1")).toBe(true);
106+
expect(b.claim("chat-1")).toBe(false);
107+
108+
a.dispose();
109+
b.dispose();
110+
});
111+
112+
it("supports multiple independent chatIds", () => {
113+
const a = new ChatTabCoordinator();
114+
const b = new ChatTabCoordinator();
115+
116+
a.claim("chat-1");
117+
b.claim("chat-2");
118+
119+
expect(a.isReadOnly("chat-1")).toBe(false);
120+
expect(a.isReadOnly("chat-2")).toBe(true);
121+
expect(b.isReadOnly("chat-1")).toBe(true);
122+
expect(b.isReadOnly("chat-2")).toBe(false);
123+
124+
a.dispose();
125+
b.dispose();
126+
});
127+
128+
it("heartbeat timeout clears stale claim from crashed tab", () => {
129+
const a = new ChatTabCoordinator();
130+
const b = new ChatTabCoordinator();
131+
const listener = vi.fn();
132+
b.addListener(listener);
133+
134+
a.claim("chat-1");
135+
expect(b.isReadOnly("chat-1")).toBe(true);
136+
137+
// Simulate tab A crashing (close its channel, stop heartbeats)
138+
a.dispose();
139+
140+
// Advance past heartbeat timeout (10s)
141+
vi.advanceTimersByTime(11_000);
142+
143+
expect(b.isReadOnly("chat-1")).toBe(false);
144+
expect(listener).toHaveBeenCalledWith("chat-1", false);
145+
146+
b.dispose();
147+
});
148+
149+
it("dispose releases all claims", () => {
150+
const a = new ChatTabCoordinator();
151+
const b = new ChatTabCoordinator();
152+
153+
a.claim("chat-1");
154+
a.claim("chat-2");
155+
expect(b.isReadOnly("chat-1")).toBe(true);
156+
expect(b.isReadOnly("chat-2")).toBe(true);
157+
158+
a.dispose();
159+
expect(b.isReadOnly("chat-1")).toBe(false);
160+
expect(b.isReadOnly("chat-2")).toBe(false);
161+
162+
b.dispose();
163+
});
164+
165+
it("gracefully degrades when BroadcastChannel is unavailable", () => {
166+
vi.stubGlobal("BroadcastChannel", undefined);
167+
168+
const coord = new ChatTabCoordinator();
169+
170+
// All operations are no-ops
171+
expect(coord.claim("chat-1")).toBe(true);
172+
expect(coord.isReadOnly("chat-1")).toBe(false);
173+
coord.release("chat-1"); // No error
174+
coord.dispose(); // No error
175+
});
176+
});

0 commit comments

Comments
 (0)