Ensures only one browser tab owns an active chat widget at a time. Other tabs detect the active session and offer to transfer it — preserving full conversation history across the handoff.
Embedded chat widgets (like Dialogflow CX Messenger) store state in localStorage, which is shared across tabs. If two tabs load the widget simultaneously, they write to the same keys and corrupt each other's state. There's no built-in mechanism to prevent this.
A single-owner coordination layer that sits between the "Live Chat" button and the chat widget. It uses a two-tier detection strategy:
Direct tab-to-tab messaging with liveness proof.
Tab B clicks "Live Chat"
→ broadcasts "who-owns-chat?"
→ Tab A responds "i-own-chat" within 1000ms
→ Tab B shows "Transfer chat here" prompt
→ user clicks transfer
→ Tab A injects "Transferring chat to new tab" system message
→ Tab A destroys widget (localStorage persists)
→ Tab B creates widget, loads full history from localStorage
If no tab responds within 1000ms (owner tab crashed/closed), Tab B claims ownership freely.
When BroadcastChannel is unavailable (older browsers, cross-origin iframes):
- Detects recent activity via timestamps in
localStorage - Shows a modal: "Start chat again" (fresh) or "I don't want to chat" (dismiss)
- Activity older than 5 minutes is treated as stale and ignored
No beforeunload/unload cleanup — those events are unreliable. Instead, all state evaluation happens at click time. Stale ownership from crashed tabs resolves automatically through timeouts (Tier 1) or staleness checks (Tier 2).
src/
index.js Entry point — wires "Live Chat" button with re-entry guard
chat-coordinator.js Orchestrator — Tier 1 + Tier 2 logic, transfer flow
chat-ownership.js localStorage state — claim, release, staleness checks
chat-ui.js DOM layer — widget lifecycle, modals, message passthrough
Coordinator decides what to do. Ownership tracks who owns chat. UI manages the DOM. The coordinator never touches localStorage directly for messages — it goes through ChatUI.addMessage() → widget API → storage.
Messages are stored in localStorage under the chatMessages key as a JSON array:
[
{ "sender": "user", "text": "Hello", "timestamp": 1740567890000 },
{ "sender": "bot", "text": "Hi there!", "timestamp": 1740567891000 },
{ "sender": "system", "text": "Transferring chat to new tab", "timestamp": 1740567892000 }
]- Transfer preserves history —
release()leaveschatMessagesintact - "Start chat again" clears history —
clearStorage()removes everything - System messages (like transfer notifications) use the same storage path as user/bot messages
npm install
npm run dev # starts dev server at localhost:3000Open http://localhost:3000/test-page/ in two tabs. Click "Live Chat" in Tab A, type some messages, then click "Live Chat" in Tab B to see the transfer flow.
npm test # 36 unit tests (Vitest + jsdom)
npm run test:e2e # 13 E2E tests (Playwright + Chromium, multi-tab)| Suite | Tests | What it covers |
|---|---|---|
chat-ownership.test.js |
11 | claim/release, staleness, localStorage state |
chat-ui.test.js |
16 | widget lifecycle, modals, addMessage passthrough |
chat-coordinator.test.js |
9 | Tier 1/2 flows, transfer message injection order |
tier1-broadcast.spec.js |
5 | Multi-tab BroadcastChannel coordination, chat history transfer |
tier2-fallback.spec.js |
5 | Fallback modal behavior without BroadcastChannel |
stale-state.spec.js |
3 | Recovery from crashed tabs, stale ownership |
| Decision | Rationale |
|---|---|
localStorage over sessionStorage |
sessionStorage is per-tab — can't detect cross-tab state |
No beforeunload cleanup |
Unreliable across browsers; all checks happen at click time |
| 1000ms probe timeout | Accounts for background tab throttling in modern browsers |
| 2s transfer timeout | Handles frozen/suspended tabs during handoff |
| Tier 2 is awareness-only | Without real-time messaging, can't guarantee single-owner — shows advisory modal instead |
| Transfer message via widget API | Coordinator never writes to chatMessages directly; uses ChatUI → widget chain |
- Vanilla JS (ES modules, no framework)
- Vitest + jsdom for unit tests
- Playwright + Chromium for multi-tab E2E tests
- serve for local dev server