feat(FN-1154): sync AI session state across browser tabs

- 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
This commit is contained in:
gsxdsm
2026-04-08 16:48:35 -07:00
parent b17d4c9b73
commit e5dc8373b9
8 changed files with 1741 additions and 71 deletions

View File

@@ -0,0 +1,211 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
AiSessionSyncStore,
__destroyAiSessionSyncStoreForTests,
__resetAiSessionSyncStoreForTests,
} from "../useAiSessionSync";
class MockBroadcastChannel {
static channels = new Map<string, Set<MockBroadcastChannel>>();
readonly name: string;
onmessage: ((event: MessageEvent<unknown>) => void) | null = null;
constructor(name: string) {
this.name = name;
const group = MockBroadcastChannel.channels.get(name) ?? new Set<MockBroadcastChannel>();
group.add(this);
MockBroadcastChannel.channels.set(name, group);
}
postMessage(data: unknown): void {
const group = MockBroadcastChannel.channels.get(this.name);
if (!group) return;
for (const channel of group) {
if (channel === this) continue;
channel.onmessage?.({ data } as MessageEvent<unknown>);
}
}
close(): void {
const group = MockBroadcastChannel.channels.get(this.name);
if (!group) return;
group.delete(this);
if (group.size === 0) {
MockBroadcastChannel.channels.delete(this.name);
}
}
}
describe("AiSessionSyncStore", () => {
const originalBroadcastChannel = globalThis.BroadcastChannel;
const stores: AiSessionSyncStore[] = [];
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(0);
MockBroadcastChannel.channels.clear();
__resetAiSessionSyncStoreForTests();
__destroyAiSessionSyncStoreForTests();
(globalThis as unknown as { BroadcastChannel: typeof BroadcastChannel }).BroadcastChannel =
MockBroadcastChannel as unknown as typeof BroadcastChannel;
});
afterEach(() => {
while (stores.length > 0) {
stores.pop()?.destroy();
}
__resetAiSessionSyncStoreForTests();
__destroyAiSessionSyncStoreForTests();
vi.useRealTimers();
(globalThis as unknown as { BroadcastChannel: typeof BroadcastChannel }).BroadcastChannel =
originalBroadcastChannel;
});
function createStore(): AiSessionSyncStore {
const store = new AiSessionSyncStore();
stores.push(store);
return store;
}
it("handles session updates/completion and tab ownership messages", () => {
const storeA = createStore();
const storeB = createStore();
storeA.broadcastUpdate({
sessionId: "sess-1",
status: "awaiting_input",
needsInput: true,
type: "planning",
title: "Cross-tab planning",
projectId: "proj-1",
timestamp: 10,
});
const syncedUpdate = storeB.getSnapshot().sessions.get("sess-1");
expect(syncedUpdate?.status).toBe("awaiting_input");
expect(syncedUpdate?.needsInput).toBe(true);
storeA.broadcastLock("sess-1", "tab-a");
expect(storeB.getSnapshot().activeTabMap.get("sess-1")?.tabId).toBe("tab-a");
storeA.broadcastUnlock("sess-1", "tab-a");
expect(storeB.getSnapshot().activeTabMap.has("sess-1")).toBe(false);
storeA.broadcastCompleted({ sessionId: "sess-1", status: "complete", timestamp: 20 });
const completed = storeB.getSnapshot().sessions.get("sess-1");
expect(completed?.status).toBe("complete");
expect(completed?.needsInput).toBe(false);
});
it("ignores stale updates using timestamp deduplication", () => {
const storeA = createStore();
const storeB = createStore();
storeA.broadcastUpdate({
sessionId: "sess-2",
status: "awaiting_input",
needsInput: true,
type: "planning",
title: "Latest state",
projectId: "proj-1",
timestamp: 200,
});
storeA.broadcastUpdate({
sessionId: "sess-2",
status: "error",
needsInput: false,
type: "planning",
title: "Stale state",
projectId: "proj-1",
timestamp: 100,
});
const state = storeB.getSnapshot().sessions.get("sess-2");
expect(state?.status).toBe("awaiting_input");
expect(state?.lastEventTimestamp).toBe(200);
});
it("responds to sync requests with known session state", () => {
const storeA = createStore();
const storeB = createStore();
storeA.broadcastUpdate({
sessionId: "sess-3",
status: "generating",
needsInput: false,
type: "mission_interview",
title: "Mission planning",
projectId: "proj-1",
timestamp: 50,
owningTabId: "tab-source",
});
storeB.requestSync();
const synced = storeB.getSnapshot().sessions.get("sess-3");
expect(synced).toBeDefined();
expect(synced?.status).toBe("generating");
expect(synced?.type).toBe("mission_interview");
});
it("falls back to localStorage storage events when BroadcastChannel is unavailable", () => {
(globalThis as unknown as { BroadcastChannel?: typeof BroadcastChannel }).BroadcastChannel =
undefined;
const storeA = createStore();
const storeB = createStore();
// Local updates still work without BroadcastChannel.
storeA.broadcastUpdate({
sessionId: "sess-4",
status: "generating",
needsInput: false,
type: "subtask",
title: "Fallback session",
projectId: "proj-1",
timestamp: 50,
});
const envelope = {
id: "evt-1",
message: {
type: "session:updated",
sessionId: "sess-4",
status: "awaiting_input",
needsInput: true,
sessionType: "subtask",
title: "Fallback session",
projectId: "proj-1",
timestamp: 75,
},
};
window.dispatchEvent(
new StorageEvent("storage", {
key: "fusion:ai-session-sync",
newValue: JSON.stringify(envelope),
}),
);
const state = storeB.getSnapshot().sessions.get("sess-4");
expect(state?.status).toBe("awaiting_input");
expect(state?.needsInput).toBe(true);
});
it("broadcasts tab:inactive for owned sessions during page unload cleanup", () => {
const storeA = createStore();
const storeB = createStore();
storeA.broadcastLock("sess-5", "tab-owner");
expect(storeB.getSnapshot().activeTabMap.get("sess-5")?.tabId).toBe("tab-owner");
window.dispatchEvent(new Event("beforeunload"));
expect(storeB.getSnapshot().activeTabMap.has("sess-5")).toBe(false);
});
});

View File

@@ -0,0 +1,138 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, renderHook, waitFor } from "@testing-library/react";
import { useBackgroundSessions } from "../useBackgroundSessions";
import {
__destroyAiSessionSyncStoreForTests,
__resetAiSessionSyncStoreForTests,
useAiSessionSync,
} from "../useAiSessionSync";
import * as apiModule from "../../api";
vi.mock("../../api", () => ({
fetchAiSessions: vi.fn(),
deleteAiSession: vi.fn(),
}));
const mockFetchAiSessions = vi.mocked(apiModule.fetchAiSessions);
const mockDeleteAiSession = vi.mocked(apiModule.deleteAiSession);
class MockEventSource {
static instances: MockEventSource[] = [];
readonly url: string;
private listeners = new Map<string, Set<(event: MessageEvent) => void>>();
constructor(url: string) {
this.url = url;
MockEventSource.instances.push(this);
}
addEventListener(type: string, listener: (event: MessageEvent) => void): void {
const set = this.listeners.get(type) ?? new Set<(event: MessageEvent) => void>();
set.add(listener);
this.listeners.set(type, set);
}
removeEventListener(type: string, listener: (event: MessageEvent) => void): void {
this.listeners.get(type)?.delete(listener);
}
close(): void {
this.listeners.clear();
}
emit(type: string, payload: unknown): void {
const event = { data: JSON.stringify(payload) } as MessageEvent;
for (const listener of this.listeners.get(type) ?? []) {
listener(event);
}
}
}
describe("useBackgroundSessions", () => {
const originalEventSource = globalThis.EventSource;
beforeEach(() => {
vi.clearAllMocks();
__resetAiSessionSyncStoreForTests();
__destroyAiSessionSyncStoreForTests();
MockEventSource.instances = [];
(globalThis as unknown as { EventSource: typeof EventSource }).EventSource =
MockEventSource as unknown as typeof EventSource;
mockFetchAiSessions.mockResolvedValue([]);
mockDeleteAiSession.mockResolvedValue(undefined);
});
afterEach(() => {
__resetAiSessionSyncStoreForTests();
__destroyAiSessionSyncStoreForTests();
(globalThis as unknown as { EventSource: typeof EventSource }).EventSource = originalEventSource;
});
it("merges cross-tab session updates into the local list", async () => {
const background = renderHook(() => useBackgroundSessions("proj-1"));
const sync = renderHook(() => useAiSessionSync());
await waitFor(() => {
expect(mockFetchAiSessions).toHaveBeenCalledWith("proj-1");
});
act(() => {
sync.result.current.broadcastUpdate({
sessionId: "sess-cross-tab",
status: "awaiting_input",
needsInput: true,
type: "planning",
title: "Cross-tab planning",
projectId: "proj-1",
owningTabId: "tab-other",
timestamp: 500,
});
});
await waitFor(() => {
expect(background.result.current.sessions).toHaveLength(1);
expect(background.result.current.sessions[0]).toMatchObject({
id: "sess-cross-tab",
status: "awaiting_input",
type: "planning",
});
});
});
it("broadcasts SSE updates through the sync store", async () => {
const background = renderHook(() => useBackgroundSessions("proj-1"));
const sync = renderHook(() => useAiSessionSync());
await waitFor(() => {
expect(MockEventSource.instances.length).toBeGreaterThan(0);
});
const eventSource = MockEventSource.instances[0];
act(() => {
eventSource.emit("ai_session:updated", {
id: "sess-sse",
type: "subtask",
status: "generating",
title: "SSE session",
projectId: "proj-1",
lockedByTab: "tab-remote",
updatedAt: "2026-04-08T00:00:00.000Z",
});
});
await waitFor(() => {
expect(background.result.current.sessions[0]?.id).toBe("sess-sse");
});
await waitFor(() => {
const synced = sync.result.current.sessions.get("sess-sse");
expect(synced?.status).toBe("generating");
expect(synced?.type).toBe("subtask");
expect(synced?.owningTabId).toBe("tab-remote");
});
});
});

View File

@@ -0,0 +1,770 @@
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();
}

View File

@@ -1,5 +1,6 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { fetchAiSessions, deleteAiSession, type AiSessionSummary } from "../api";
import { useAiSessionSync } from "./useAiSessionSync";
interface UseBackgroundSessionsResult {
sessions: AiSessionSummary[];
@@ -11,20 +12,120 @@ interface UseBackgroundSessionsResult {
refresh: () => void;
}
function parseTimestamp(updatedAt: string | undefined): number {
if (!updatedAt) return 0;
const parsed = Date.parse(updatedAt);
return Number.isFinite(parsed) ? parsed : 0;
}
function shouldIncludeSession(session: AiSessionSummary): boolean {
return (
session.status === "generating" ||
session.status === "awaiting_input" ||
session.status === "complete" ||
session.status === "error"
);
}
export function useBackgroundSessions(projectId?: string): UseBackgroundSessionsResult {
const [sessions, setSessions] = useState<AiSessionSummary[]>([]);
const eventSourceRef = useRef<EventSource | null>(null);
const sessionTimestampsRef = useRef<Map<string, number>>(new Map());
const {
sessions: syncedSessions,
broadcastUpdate,
broadcastCompleted,
requestSync,
} = useAiSessionSync();
const refresh = useCallback(() => {
fetchAiSessions(projectId).then(setSessions).catch(() => {});
fetchAiSessions(projectId)
.then((fetched) => {
const nextTimestampMap = new Map<string, number>();
for (const session of fetched) {
nextTimestampMap.set(session.id, parseTimestamp(session.updatedAt));
}
sessionTimestampsRef.current = nextTimestampMap;
setSessions(fetched);
})
.catch(() => {});
}, [projectId]);
// Initial fetch
// Initial load: request state from sibling tabs first, then fetch authoritative API state.
useEffect(() => {
requestSync();
refresh();
}, [refresh]);
}, [refresh, requestSync]);
// Listen for SSE events
// Merge cross-tab state updates as a low-latency supplement to SSE/API.
useEffect(() => {
setSessions((prev) => {
if (syncedSessions.size === 0) {
return prev;
}
let changed = false;
const nextById = new Map(prev.map((session) => [session.id, session]));
for (const syncState of syncedSessions.values()) {
if (projectId && syncState.projectId && syncState.projectId !== projectId) {
continue;
}
const incomingTimestamp = syncState.lastEventTimestamp;
const knownTimestamp = sessionTimestampsRef.current.get(syncState.sessionId) ?? 0;
if (incomingTimestamp < knownTimestamp) {
continue;
}
const existing = nextById.get(syncState.sessionId);
const type = syncState.type ?? existing?.type;
const title = syncState.title ?? existing?.title;
// Without type/title metadata we cannot safely materialize a new list item yet.
if (!existing && (!type || !title)) {
continue;
}
const nextSession: AiSessionSummary = {
id: syncState.sessionId,
type: type ?? "planning",
status: syncState.status,
title: title ?? "AI Session",
projectId: syncState.projectId ?? existing?.projectId ?? projectId ?? null,
lockedByTab: syncState.owningTabId ?? existing?.lockedByTab ?? null,
updatedAt: syncState.updatedAt ?? existing?.updatedAt ?? new Date(incomingTimestamp).toISOString(),
};
const previous = nextById.get(syncState.sessionId);
const hasChanged =
!previous ||
previous.status !== nextSession.status ||
previous.title !== nextSession.title ||
previous.type !== nextSession.type ||
previous.projectId !== nextSession.projectId ||
previous.lockedByTab !== nextSession.lockedByTab ||
previous.updatedAt !== nextSession.updatedAt;
if (hasChanged) {
nextById.set(syncState.sessionId, nextSession);
sessionTimestampsRef.current.set(syncState.sessionId, incomingTimestamp);
changed = true;
}
}
if (!changed) {
return prev;
}
return [...nextById.values()].sort(
(a, b) => parseTimestamp(b.updatedAt) - parseTimestamp(a.updatedAt),
);
});
}, [projectId, syncedSessions]);
// Listen for server-side SSE events (authoritative source of truth).
useEffect(() => {
const params = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
const es = new EventSource(`/api/events${params}`);
@@ -33,32 +134,62 @@ export function useBackgroundSessions(projectId?: string): UseBackgroundSessions
const handleUpdated = (e: MessageEvent) => {
try {
const updated = JSON.parse(e.data) as AiSessionSummary;
const eventTimestamp = parseTimestamp(updated.updatedAt) || Date.now();
setSessions((prev) => {
const knownTimestamp = sessionTimestampsRef.current.get(updated.id) ?? 0;
if (eventTimestamp < knownTimestamp) {
return prev;
}
sessionTimestampsRef.current.set(updated.id, eventTimestamp);
const idx = prev.findIndex((s) => s.id === updated.id);
if (idx >= 0) {
const next = [...prev];
next[idx] = updated;
return next;
}
// New session — include in-progress, complete, and retryable error sessions
if (
updated.status === "generating" ||
updated.status === "awaiting_input" ||
updated.status === "complete" ||
updated.status === "error"
) {
if (shouldIncludeSession(updated)) {
return [updated, ...prev];
}
return prev;
});
} catch { /* ignore */ }
broadcastUpdate({
sessionId: updated.id,
status: updated.status,
needsInput: updated.status === "awaiting_input",
type: updated.type,
title: updated.title,
projectId: updated.projectId,
owningTabId: updated.lockedByTab,
updatedAt: updated.updatedAt,
timestamp: eventTimestamp,
});
if (updated.status === "complete" || updated.status === "error") {
broadcastCompleted({
sessionId: updated.id,
status: updated.status,
timestamp: eventTimestamp,
});
}
} catch {
// ignore malformed payload
}
};
const handleDeleted = (e: MessageEvent) => {
try {
const id = JSON.parse(e.data);
const id = JSON.parse(e.data) as string;
setSessions((prev) => prev.filter((s) => s.id !== id));
} catch { /* ignore */ }
sessionTimestampsRef.current.delete(id);
} catch {
// ignore malformed payload
}
};
es.addEventListener("ai_session:updated", handleUpdated);
@@ -69,28 +200,28 @@ export function useBackgroundSessions(projectId?: string): UseBackgroundSessions
es.removeEventListener("ai_session:deleted", handleDeleted);
es.close();
};
}, [projectId]);
}, [broadcastCompleted, broadcastUpdate, projectId]);
const dismissSession = useCallback((id: string) => {
deleteAiSession(id).catch(() => {});
setSessions((prev) => prev.filter((s) => s.id !== id));
sessionTimestampsRef.current.delete(id);
}, []);
// Filter to only active sessions
const active = sessions.filter(
(s) =>
s.status === "generating" ||
s.status === "awaiting_input" ||
s.status === "complete" ||
s.status === "error",
const active = useMemo(
() => sessions.filter((session) => shouldIncludeSession(session)),
[sessions],
);
const planningSessions = active.filter((s) => s.type === "planning");
const planningSessions = useMemo(
() => active.filter((session) => session.type === "planning"),
[active],
);
return {
sessions: active,
generating: active.filter((s) => s.status === "generating").length,
needsInput: active.filter((s) => s.status === "awaiting_input").length,
generating: active.filter((session) => session.status === "generating").length,
needsInput: active.filter((session) => session.status === "awaiting_input").length,
planningSessions,
dismissSession,
refresh,