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:
211
packages/dashboard/app/hooks/__tests__/useAiSessionSync.test.ts
Normal file
211
packages/dashboard/app/hooks/__tests__/useAiSessionSync.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
770
packages/dashboard/app/hooks/useAiSessionSync.ts
Normal file
770
packages/dashboard/app/hooks/useAiSessionSync.ts
Normal 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();
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user