- Add a shared AiSessionSync store with BroadcastChannel + storage fallback, ownership locks, heartbeats, and stale-tab detection - Merge cross-tab session snapshots into useBackgroundSessions with timestamp guards and rebroadcast SSE updates to peers - Update background session UI and planning/mission/subtask modals to show active-tab lock status and only allow takeover when ownership is stale - Add hook tests covering sync store messaging/fallback behavior and background session cross-tab merge flows
771 lines
20 KiB
TypeScript
771 lines
20 KiB
TypeScript
import { useCallback, useEffect, useSyncExternalStore } from "react";
|
|
import type { AiSessionSummary } from "../api";
|
|
|
|
const CHANNEL_NAME = "fusion:ai-session-sync";
|
|
const STORAGE_FALLBACK_KEY = "fusion:ai-session-sync";
|
|
const HEARTBEAT_INTERVAL_MS = 30_000;
|
|
const HEARTBEAT_STALE_THRESHOLD_MS = 60_000;
|
|
|
|
type SessionStatus = AiSessionSummary["status"];
|
|
type SessionType = AiSessionSummary["type"];
|
|
|
|
export interface SessionSyncState {
|
|
sessionId: string;
|
|
status: SessionStatus;
|
|
needsInput: boolean;
|
|
lastEventTimestamp: number;
|
|
owningTabId: string | null;
|
|
type?: SessionType;
|
|
title?: string;
|
|
projectId?: string | null;
|
|
updatedAt?: string;
|
|
}
|
|
|
|
export interface ActiveTabState {
|
|
sessionId: string;
|
|
tabId: string;
|
|
lastHeartbeatTimestamp: number;
|
|
lastLockTimestamp: number;
|
|
stale: boolean;
|
|
}
|
|
|
|
interface StorageFallbackEnvelope {
|
|
id: string;
|
|
message: AiSessionSyncMessage;
|
|
}
|
|
|
|
interface StoreSnapshot {
|
|
tabId: string;
|
|
sessions: Map<string, SessionSyncState>;
|
|
activeTabMap: Map<string, ActiveTabState>;
|
|
}
|
|
|
|
interface SessionUpdatePayload {
|
|
sessionId: string;
|
|
status: SessionStatus;
|
|
needsInput?: boolean;
|
|
timestamp?: number;
|
|
owningTabId?: string | null;
|
|
type?: SessionType;
|
|
title?: string;
|
|
projectId?: string | null;
|
|
updatedAt?: string;
|
|
}
|
|
|
|
interface SessionCompletedPayload {
|
|
sessionId: string;
|
|
status?: Extract<SessionStatus, "complete" | "error">;
|
|
timestamp?: number;
|
|
}
|
|
|
|
interface TabMessageBase {
|
|
tabId: string;
|
|
timestamp: number;
|
|
senderTabId?: string;
|
|
}
|
|
|
|
type AiSessionSyncMessage =
|
|
| ({
|
|
type: "session:updated";
|
|
sessionId: string;
|
|
status: SessionStatus;
|
|
needsInput?: boolean;
|
|
owningTabId?: string | null;
|
|
sessionType?: SessionType;
|
|
title?: string;
|
|
projectId?: string | null;
|
|
updatedAt?: string;
|
|
timestamp: number;
|
|
} & Partial<TabMessageBase>)
|
|
| ({
|
|
type: "session:completed";
|
|
sessionId: string;
|
|
status?: Extract<SessionStatus, "complete" | "error">;
|
|
timestamp: number;
|
|
} & Partial<TabMessageBase>)
|
|
| ({
|
|
type: "tab:active";
|
|
sessionId: string;
|
|
} & TabMessageBase)
|
|
| ({
|
|
type: "tab:inactive";
|
|
sessionId: string;
|
|
} & TabMessageBase)
|
|
| ({
|
|
type: "tab:heartbeat";
|
|
} & TabMessageBase)
|
|
| ({
|
|
type: "sync:request";
|
|
} & TabMessageBase)
|
|
| ({
|
|
type: "sync:response";
|
|
tabId: string;
|
|
sessions: SessionSyncState[];
|
|
locks?: Array<{ sessionId: string; tabId: string; timestamp: number }>;
|
|
heartbeats?: Array<{ tabId: string; timestamp: number }>;
|
|
timestamp: number;
|
|
senderTabId?: string;
|
|
} & Partial<TabMessageBase>);
|
|
|
|
function now(): number {
|
|
return Date.now();
|
|
}
|
|
|
|
function createTabId(): string {
|
|
const cryptoApi = globalThis.crypto;
|
|
if (cryptoApi && typeof cryptoApi.randomUUID === "function") {
|
|
return cryptoApi.randomUUID();
|
|
}
|
|
|
|
return `tab-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
|
}
|
|
|
|
function parseMessage(raw: unknown): AiSessionSyncMessage | null {
|
|
if (!raw || typeof raw !== "object") {
|
|
return null;
|
|
}
|
|
|
|
const candidate = raw as { type?: unknown; timestamp?: unknown };
|
|
if (typeof candidate.type !== "string") {
|
|
return null;
|
|
}
|
|
|
|
if (typeof candidate.timestamp !== "number" || !Number.isFinite(candidate.timestamp)) {
|
|
return null;
|
|
}
|
|
|
|
return raw as AiSessionSyncMessage;
|
|
}
|
|
|
|
export class AiSessionSyncStore {
|
|
private readonly tabId: string;
|
|
private readonly listeners = new Set<() => void>();
|
|
private readonly sessionStates = new Map<string, SessionSyncState>();
|
|
private readonly ownershipBySession = new Map<string, { tabId: string; timestamp: number }>();
|
|
private readonly heartbeatByTab = new Map<string, number>();
|
|
private readonly ownedSessions = new Map<string, string>();
|
|
|
|
private snapshot: StoreSnapshot;
|
|
private channel: BroadcastChannel | null = null;
|
|
private usingStorageFallback = false;
|
|
private cleanupStorageListener: (() => void) | null = null;
|
|
private cleanupBeforeUnload: (() => void) | null = null;
|
|
private heartbeatInterval: ReturnType<typeof setInterval> | null = null;
|
|
private staleSweepInterval: ReturnType<typeof setInterval> | null = null;
|
|
|
|
constructor() {
|
|
this.tabId = createTabId();
|
|
this.snapshot = {
|
|
tabId: this.tabId,
|
|
sessions: new Map(),
|
|
activeTabMap: new Map(),
|
|
};
|
|
|
|
if (!this.isBrowser()) {
|
|
return;
|
|
}
|
|
|
|
this.initializeTransport();
|
|
this.startHeartbeat();
|
|
this.startStaleSweep();
|
|
this.setupBeforeUnloadCleanup();
|
|
}
|
|
|
|
subscribe(listener: () => void): () => void {
|
|
this.listeners.add(listener);
|
|
return () => {
|
|
this.listeners.delete(listener);
|
|
};
|
|
}
|
|
|
|
getSnapshot(): StoreSnapshot {
|
|
return this.snapshot;
|
|
}
|
|
|
|
requestSync(): void {
|
|
this.publish({
|
|
type: "sync:request",
|
|
tabId: this.tabId,
|
|
timestamp: now(),
|
|
});
|
|
}
|
|
|
|
broadcastUpdate(payload: SessionUpdatePayload): void {
|
|
const timestamp = payload.timestamp ?? now();
|
|
|
|
this.applySessionUpdate(
|
|
{
|
|
sessionId: payload.sessionId,
|
|
status: payload.status,
|
|
needsInput: payload.needsInput ?? payload.status === "awaiting_input",
|
|
owningTabId: payload.owningTabId,
|
|
type: payload.type,
|
|
title: payload.title,
|
|
projectId: payload.projectId,
|
|
updatedAt: payload.updatedAt,
|
|
},
|
|
timestamp,
|
|
);
|
|
|
|
this.publish({
|
|
type: "session:updated",
|
|
sessionId: payload.sessionId,
|
|
status: payload.status,
|
|
needsInput: payload.needsInput,
|
|
owningTabId: payload.owningTabId,
|
|
sessionType: payload.type,
|
|
title: payload.title,
|
|
projectId: payload.projectId,
|
|
updatedAt: payload.updatedAt,
|
|
timestamp,
|
|
});
|
|
}
|
|
|
|
broadcastCompleted(payload: SessionCompletedPayload): void {
|
|
const status = payload.status ?? "complete";
|
|
const timestamp = payload.timestamp ?? now();
|
|
|
|
this.applySessionUpdate(
|
|
{
|
|
sessionId: payload.sessionId,
|
|
status,
|
|
needsInput: false,
|
|
owningTabId: null,
|
|
},
|
|
timestamp,
|
|
);
|
|
|
|
this.ownershipBySession.delete(payload.sessionId);
|
|
this.ownedSessions.delete(payload.sessionId);
|
|
this.emit();
|
|
|
|
this.publish({
|
|
type: "session:completed",
|
|
sessionId: payload.sessionId,
|
|
status,
|
|
timestamp,
|
|
});
|
|
}
|
|
|
|
broadcastLock(sessionId: string, tabId: string): void {
|
|
const timestamp = now();
|
|
|
|
this.applyTabOwnership(sessionId, tabId, timestamp);
|
|
this.ownedSessions.set(sessionId, tabId);
|
|
|
|
this.publish({
|
|
type: "tab:active",
|
|
tabId,
|
|
sessionId,
|
|
timestamp,
|
|
});
|
|
}
|
|
|
|
broadcastUnlock(sessionId: string, tabId: string): void {
|
|
const timestamp = now();
|
|
this.releaseTabOwnership(sessionId, tabId, timestamp);
|
|
this.ownedSessions.delete(sessionId);
|
|
|
|
this.publish({
|
|
type: "tab:inactive",
|
|
tabId,
|
|
sessionId,
|
|
timestamp,
|
|
});
|
|
}
|
|
|
|
broadcastHeartbeat(tabId: string): void {
|
|
const timestamp = now();
|
|
this.updateHeartbeat(tabId, timestamp);
|
|
|
|
this.publish({
|
|
type: "tab:heartbeat",
|
|
tabId,
|
|
timestamp,
|
|
});
|
|
}
|
|
|
|
destroy(): void {
|
|
this.cleanupStorageListener?.();
|
|
this.cleanupStorageListener = null;
|
|
|
|
this.cleanupBeforeUnload?.();
|
|
this.cleanupBeforeUnload = null;
|
|
|
|
if (this.channel) {
|
|
this.channel.close();
|
|
this.channel = null;
|
|
}
|
|
|
|
if (this.heartbeatInterval) {
|
|
clearInterval(this.heartbeatInterval);
|
|
this.heartbeatInterval = null;
|
|
}
|
|
|
|
if (this.staleSweepInterval) {
|
|
clearInterval(this.staleSweepInterval);
|
|
this.staleSweepInterval = null;
|
|
}
|
|
}
|
|
|
|
reset(): void {
|
|
this.sessionStates.clear();
|
|
this.ownershipBySession.clear();
|
|
this.heartbeatByTab.clear();
|
|
this.ownedSessions.clear();
|
|
|
|
this.snapshot = {
|
|
tabId: this.tabId,
|
|
sessions: new Map(),
|
|
activeTabMap: new Map(),
|
|
};
|
|
|
|
this.emit();
|
|
}
|
|
|
|
private isBrowser(): boolean {
|
|
return typeof window !== "undefined";
|
|
}
|
|
|
|
private initializeTransport(): void {
|
|
if (typeof BroadcastChannel !== "undefined") {
|
|
try {
|
|
this.channel = new BroadcastChannel(CHANNEL_NAME);
|
|
this.channel.onmessage = (event: MessageEvent<unknown>) => {
|
|
const parsed = parseMessage(event.data);
|
|
if (parsed) {
|
|
this.handleIncomingMessage(parsed);
|
|
}
|
|
};
|
|
this.usingStorageFallback = false;
|
|
return;
|
|
} catch {
|
|
// Fall back to localStorage below.
|
|
}
|
|
}
|
|
|
|
this.usingStorageFallback = true;
|
|
const storageHandler = (event: StorageEvent) => {
|
|
if (event.key !== STORAGE_FALLBACK_KEY || !event.newValue) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const parsedEnvelope = JSON.parse(event.newValue) as StorageFallbackEnvelope;
|
|
const parsedMessage = parseMessage(parsedEnvelope.message);
|
|
if (parsedMessage) {
|
|
this.handleIncomingMessage(parsedMessage);
|
|
}
|
|
} catch {
|
|
// Ignore malformed fallback payloads.
|
|
}
|
|
};
|
|
|
|
window.addEventListener("storage", storageHandler);
|
|
this.cleanupStorageListener = () => {
|
|
window.removeEventListener("storage", storageHandler);
|
|
};
|
|
}
|
|
|
|
private startHeartbeat(): void {
|
|
this.heartbeatInterval = setInterval(() => {
|
|
const timestamp = now();
|
|
this.updateHeartbeat(this.tabId, timestamp);
|
|
|
|
this.publish({
|
|
type: "tab:heartbeat",
|
|
tabId: this.tabId,
|
|
timestamp,
|
|
});
|
|
}, HEARTBEAT_INTERVAL_MS);
|
|
}
|
|
|
|
private startStaleSweep(): void {
|
|
this.staleSweepInterval = setInterval(() => {
|
|
this.emit();
|
|
}, 10_000);
|
|
}
|
|
|
|
private setupBeforeUnloadCleanup(): void {
|
|
const handleBeforeUnload = () => {
|
|
for (const [sessionId, owningTabId] of this.ownedSessions.entries()) {
|
|
const timestamp = now();
|
|
this.publish({
|
|
type: "tab:inactive",
|
|
tabId: owningTabId,
|
|
sessionId,
|
|
timestamp,
|
|
});
|
|
}
|
|
};
|
|
|
|
window.addEventListener("beforeunload", handleBeforeUnload);
|
|
this.cleanupBeforeUnload = () => {
|
|
window.removeEventListener("beforeunload", handleBeforeUnload);
|
|
};
|
|
}
|
|
|
|
private publish(message: AiSessionSyncMessage): void {
|
|
const withSender: AiSessionSyncMessage = {
|
|
...message,
|
|
senderTabId: this.tabId,
|
|
};
|
|
|
|
if (this.channel) {
|
|
this.channel.postMessage(withSender);
|
|
return;
|
|
}
|
|
|
|
if (!this.usingStorageFallback) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const envelope: StorageFallbackEnvelope = {
|
|
id: `${withSender.type}-${withSender.timestamp}-${Math.random().toString(36).slice(2, 8)}`,
|
|
message: withSender,
|
|
};
|
|
window.localStorage.setItem(STORAGE_FALLBACK_KEY, JSON.stringify(envelope));
|
|
} catch {
|
|
// Ignore fallback write failures.
|
|
}
|
|
}
|
|
|
|
private handleIncomingMessage(message: AiSessionSyncMessage): void {
|
|
switch (message.type) {
|
|
case "session:updated": {
|
|
this.applySessionUpdate(
|
|
{
|
|
sessionId: message.sessionId,
|
|
status: message.status,
|
|
needsInput: message.needsInput,
|
|
owningTabId: message.owningTabId,
|
|
type: message.sessionType,
|
|
title: message.title,
|
|
projectId: message.projectId,
|
|
updatedAt: message.updatedAt,
|
|
},
|
|
message.timestamp,
|
|
);
|
|
return;
|
|
}
|
|
|
|
case "session:completed": {
|
|
this.applySessionUpdate(
|
|
{
|
|
sessionId: message.sessionId,
|
|
status: message.status ?? "complete",
|
|
needsInput: false,
|
|
owningTabId: null,
|
|
},
|
|
message.timestamp,
|
|
);
|
|
this.ownershipBySession.delete(message.sessionId);
|
|
this.emit();
|
|
return;
|
|
}
|
|
|
|
case "tab:active": {
|
|
this.applyTabOwnership(message.sessionId, message.tabId, message.timestamp);
|
|
return;
|
|
}
|
|
|
|
case "tab:inactive": {
|
|
this.releaseTabOwnership(message.sessionId, message.tabId, message.timestamp);
|
|
return;
|
|
}
|
|
|
|
case "tab:heartbeat": {
|
|
this.updateHeartbeat(message.tabId, message.timestamp);
|
|
return;
|
|
}
|
|
|
|
case "sync:request": {
|
|
if (message.tabId === this.tabId) {
|
|
return;
|
|
}
|
|
|
|
const sessions = [...this.sessionStates.values()].map((session) => {
|
|
const ownership = this.ownershipBySession.get(session.sessionId);
|
|
return {
|
|
...session,
|
|
owningTabId: ownership?.tabId ?? session.owningTabId ?? null,
|
|
};
|
|
});
|
|
|
|
const locks = [...this.ownershipBySession.entries()].map(([sessionId, lock]) => ({
|
|
sessionId,
|
|
tabId: lock.tabId,
|
|
timestamp: lock.timestamp,
|
|
}));
|
|
|
|
const heartbeats = [...this.heartbeatByTab.entries()].map(([tabId, timestamp]) => ({
|
|
tabId,
|
|
timestamp,
|
|
}));
|
|
|
|
this.publish({
|
|
type: "sync:response",
|
|
tabId: message.tabId,
|
|
sessions,
|
|
locks,
|
|
heartbeats,
|
|
timestamp: now(),
|
|
});
|
|
return;
|
|
}
|
|
|
|
case "sync:response": {
|
|
if (message.tabId !== this.tabId) {
|
|
return;
|
|
}
|
|
|
|
for (const session of message.sessions) {
|
|
this.applySessionUpdate(
|
|
{
|
|
sessionId: session.sessionId,
|
|
status: session.status,
|
|
needsInput: session.needsInput,
|
|
owningTabId: session.owningTabId,
|
|
type: session.type,
|
|
title: session.title,
|
|
projectId: session.projectId,
|
|
updatedAt: session.updatedAt,
|
|
},
|
|
session.lastEventTimestamp,
|
|
false,
|
|
);
|
|
}
|
|
|
|
for (const lock of message.locks ?? []) {
|
|
this.applyTabOwnership(lock.sessionId, lock.tabId, lock.timestamp, false);
|
|
}
|
|
|
|
for (const heartbeat of message.heartbeats ?? []) {
|
|
this.updateHeartbeat(heartbeat.tabId, heartbeat.timestamp, false);
|
|
}
|
|
|
|
this.emit();
|
|
return;
|
|
}
|
|
|
|
default:
|
|
return;
|
|
}
|
|
}
|
|
|
|
private applySessionUpdate(
|
|
update: {
|
|
sessionId: string;
|
|
status: SessionStatus;
|
|
needsInput?: boolean;
|
|
owningTabId?: string | null;
|
|
type?: SessionType;
|
|
title?: string;
|
|
projectId?: string | null;
|
|
updatedAt?: string;
|
|
},
|
|
timestamp: number,
|
|
shouldEmit = true,
|
|
): void {
|
|
const existing = this.sessionStates.get(update.sessionId);
|
|
if (existing && timestamp < existing.lastEventTimestamp) {
|
|
return;
|
|
}
|
|
|
|
const ownership = this.ownershipBySession.get(update.sessionId);
|
|
|
|
const nextState: SessionSyncState = {
|
|
sessionId: update.sessionId,
|
|
status: update.status,
|
|
needsInput: update.needsInput ?? update.status === "awaiting_input",
|
|
lastEventTimestamp: timestamp,
|
|
owningTabId: update.owningTabId ?? ownership?.tabId ?? existing?.owningTabId ?? null,
|
|
type: update.type ?? existing?.type,
|
|
title: update.title ?? existing?.title,
|
|
projectId: update.projectId ?? existing?.projectId,
|
|
updatedAt: update.updatedAt ?? new Date(timestamp).toISOString(),
|
|
};
|
|
|
|
this.sessionStates.set(update.sessionId, nextState);
|
|
|
|
if (update.owningTabId !== undefined) {
|
|
if (update.owningTabId) {
|
|
this.applyTabOwnership(update.sessionId, update.owningTabId, timestamp, false);
|
|
} else {
|
|
this.ownershipBySession.delete(update.sessionId);
|
|
}
|
|
}
|
|
|
|
if (shouldEmit) {
|
|
this.emit();
|
|
}
|
|
}
|
|
|
|
private applyTabOwnership(sessionId: string, tabId: string, timestamp: number, shouldEmit = true): void {
|
|
const existing = this.ownershipBySession.get(sessionId);
|
|
if (existing && timestamp < existing.timestamp) {
|
|
return;
|
|
}
|
|
|
|
this.ownershipBySession.set(sessionId, { tabId, timestamp });
|
|
this.updateHeartbeat(tabId, timestamp, false);
|
|
|
|
const existingSession = this.sessionStates.get(sessionId);
|
|
if (existingSession && timestamp >= existingSession.lastEventTimestamp) {
|
|
this.sessionStates.set(sessionId, {
|
|
...existingSession,
|
|
owningTabId: tabId,
|
|
lastEventTimestamp: timestamp,
|
|
});
|
|
}
|
|
|
|
if (shouldEmit) {
|
|
this.emit();
|
|
}
|
|
}
|
|
|
|
private releaseTabOwnership(sessionId: string, tabId: string, timestamp: number, shouldEmit = true): void {
|
|
const existing = this.ownershipBySession.get(sessionId);
|
|
if (!existing) {
|
|
return;
|
|
}
|
|
|
|
if (existing.tabId !== tabId || timestamp < existing.timestamp) {
|
|
return;
|
|
}
|
|
|
|
this.ownershipBySession.delete(sessionId);
|
|
|
|
const existingSession = this.sessionStates.get(sessionId);
|
|
if (existingSession && timestamp >= existingSession.lastEventTimestamp) {
|
|
this.sessionStates.set(sessionId, {
|
|
...existingSession,
|
|
owningTabId: null,
|
|
lastEventTimestamp: timestamp,
|
|
});
|
|
}
|
|
|
|
if (shouldEmit) {
|
|
this.emit();
|
|
}
|
|
}
|
|
|
|
private updateHeartbeat(tabId: string, timestamp: number, shouldEmit = true): void {
|
|
const previous = this.heartbeatByTab.get(tabId);
|
|
if (previous !== undefined && timestamp < previous) {
|
|
return;
|
|
}
|
|
|
|
this.heartbeatByTab.set(tabId, timestamp);
|
|
|
|
if (shouldEmit) {
|
|
this.emit();
|
|
}
|
|
}
|
|
|
|
private emit(): void {
|
|
const currentTime = now();
|
|
const sessionsSnapshot = new Map<string, SessionSyncState>();
|
|
|
|
for (const [sessionId, session] of this.sessionStates.entries()) {
|
|
const ownership = this.ownershipBySession.get(sessionId);
|
|
sessionsSnapshot.set(sessionId, {
|
|
...session,
|
|
owningTabId: ownership?.tabId ?? session.owningTabId ?? null,
|
|
});
|
|
}
|
|
|
|
const activeTabMap = new Map<string, ActiveTabState>();
|
|
for (const [sessionId, ownership] of this.ownershipBySession.entries()) {
|
|
const heartbeat = this.heartbeatByTab.get(ownership.tabId) ?? ownership.timestamp;
|
|
const stale = currentTime - heartbeat > HEARTBEAT_STALE_THRESHOLD_MS;
|
|
activeTabMap.set(sessionId, {
|
|
sessionId,
|
|
tabId: ownership.tabId,
|
|
lastHeartbeatTimestamp: heartbeat,
|
|
lastLockTimestamp: ownership.timestamp,
|
|
stale,
|
|
});
|
|
}
|
|
|
|
this.snapshot = {
|
|
tabId: this.tabId,
|
|
sessions: sessionsSnapshot,
|
|
activeTabMap,
|
|
};
|
|
|
|
for (const listener of this.listeners) {
|
|
listener();
|
|
}
|
|
}
|
|
}
|
|
|
|
const aiSessionSyncStore = new AiSessionSyncStore();
|
|
|
|
export function useAiSessionSync(): {
|
|
tabId: string;
|
|
sessions: Map<string, SessionSyncState>;
|
|
activeTabMap: Map<string, ActiveTabState>;
|
|
broadcastUpdate: (payload: SessionUpdatePayload) => void;
|
|
broadcastCompleted: (payload: SessionCompletedPayload) => void;
|
|
broadcastLock: (sessionId: string, tabId: string) => void;
|
|
broadcastUnlock: (sessionId: string, tabId: string) => void;
|
|
broadcastHeartbeat: (tabId: string) => void;
|
|
requestSync: () => void;
|
|
} {
|
|
const snapshot = useSyncExternalStore(
|
|
(listener) => aiSessionSyncStore.subscribe(listener),
|
|
() => aiSessionSyncStore.getSnapshot(),
|
|
() => aiSessionSyncStore.getSnapshot(),
|
|
);
|
|
|
|
useEffect(() => {
|
|
aiSessionSyncStore.requestSync();
|
|
}, []);
|
|
|
|
const broadcastUpdate = useCallback((payload: SessionUpdatePayload) => {
|
|
aiSessionSyncStore.broadcastUpdate(payload);
|
|
}, []);
|
|
|
|
const broadcastCompleted = useCallback((payload: SessionCompletedPayload) => {
|
|
aiSessionSyncStore.broadcastCompleted(payload);
|
|
}, []);
|
|
|
|
const broadcastLock = useCallback((sessionId: string, tabId: string) => {
|
|
aiSessionSyncStore.broadcastLock(sessionId, tabId);
|
|
}, []);
|
|
|
|
const broadcastUnlock = useCallback((sessionId: string, tabId: string) => {
|
|
aiSessionSyncStore.broadcastUnlock(sessionId, tabId);
|
|
}, []);
|
|
|
|
const broadcastHeartbeat = useCallback((tabId: string) => {
|
|
aiSessionSyncStore.broadcastHeartbeat(tabId);
|
|
}, []);
|
|
|
|
const requestSync = useCallback(() => {
|
|
aiSessionSyncStore.requestSync();
|
|
}, []);
|
|
|
|
return {
|
|
tabId: snapshot.tabId,
|
|
sessions: snapshot.sessions,
|
|
activeTabMap: snapshot.activeTabMap,
|
|
broadcastUpdate,
|
|
broadcastCompleted,
|
|
broadcastLock,
|
|
broadcastUnlock,
|
|
broadcastHeartbeat,
|
|
requestSync,
|
|
};
|
|
}
|
|
|
|
export function __resetAiSessionSyncStoreForTests(): void {
|
|
aiSessionSyncStore.reset();
|
|
}
|
|
|
|
export function __destroyAiSessionSyncStoreForTests(): void {
|
|
aiSessionSyncStore.destroy();
|
|
}
|