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:
@@ -1,6 +1,8 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { Lightbulb, Layers, Target, Loader2, HelpCircle, X } from "lucide-react";
|
||||
import { useState, useRef, useEffect, useMemo } from "react";
|
||||
import { Lightbulb, Layers, Target, Loader2, HelpCircle, X, Lock } from "lucide-react";
|
||||
import type { AiSessionSummary } from "../api";
|
||||
import { useAiSessionSync } from "../hooks/useAiSessionSync";
|
||||
import { getSessionTabId } from "../utils/getSessionTabId";
|
||||
|
||||
interface BackgroundTasksIndicatorProps {
|
||||
sessions: AiSessionSummary[];
|
||||
@@ -30,7 +32,13 @@ export function BackgroundTasksIndicator({
|
||||
onDismissSession,
|
||||
}: BackgroundTasksIndicatorProps) {
|
||||
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||
const [recentlyUpdated, setRecentlyUpdated] = useState<Set<string>>(new Set());
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const previousSessionSignatureRef = useRef<Map<string, string>>(new Map());
|
||||
const clearUpdatedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const { activeTabMap } = useAiSessionSync();
|
||||
const localSessionTabId = useMemo(() => getSessionTabId(), []);
|
||||
|
||||
// Close popover on outside click
|
||||
useEffect(() => {
|
||||
@@ -44,6 +52,53 @@ export function BackgroundTasksIndicator({
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, [popoverOpen]);
|
||||
|
||||
// Animate per-item changes when session status/lock/timestamp changes.
|
||||
useEffect(() => {
|
||||
const changed = new Set<string>();
|
||||
const nextSignature = new Map<string, string>();
|
||||
|
||||
for (const session of sessions) {
|
||||
const ownership = activeTabMap.get(session.id);
|
||||
const signature = [
|
||||
session.status,
|
||||
session.updatedAt,
|
||||
ownership?.tabId ?? session.lockedByTab ?? "",
|
||||
ownership?.stale ? "stale" : "fresh",
|
||||
].join("|");
|
||||
|
||||
const previous = previousSessionSignatureRef.current.get(session.id);
|
||||
if (previous && previous !== signature) {
|
||||
changed.add(session.id);
|
||||
}
|
||||
|
||||
nextSignature.set(session.id, signature);
|
||||
}
|
||||
|
||||
previousSessionSignatureRef.current = nextSignature;
|
||||
|
||||
if (changed.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setRecentlyUpdated(changed);
|
||||
|
||||
if (clearUpdatedTimerRef.current) {
|
||||
clearTimeout(clearUpdatedTimerRef.current);
|
||||
}
|
||||
|
||||
clearUpdatedTimerRef.current = setTimeout(() => {
|
||||
setRecentlyUpdated(new Set());
|
||||
clearUpdatedTimerRef.current = null;
|
||||
}, 500);
|
||||
|
||||
return () => {
|
||||
if (clearUpdatedTimerRef.current) {
|
||||
clearTimeout(clearUpdatedTimerRef.current);
|
||||
clearUpdatedTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [activeTabMap, sessions]);
|
||||
|
||||
if (sessions.length === 0) return null;
|
||||
|
||||
const total = sessions.length;
|
||||
@@ -73,12 +128,32 @@ export function BackgroundTasksIndicator({
|
||||
const Icon = TYPE_ICONS[session.type];
|
||||
const isGenerating = session.status === "generating";
|
||||
const isAwaiting = session.status === "awaiting_input";
|
||||
const activeTab = activeTabMap.get(session.id);
|
||||
const owningTabId = activeTab?.tabId ?? session.lockedByTab ?? null;
|
||||
const activeElsewhere = Boolean(
|
||||
owningTabId && owningTabId !== localSessionTabId && !activeTab?.stale,
|
||||
);
|
||||
const isUpdated = recentlyUpdated.has(session.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={session.id}
|
||||
className="background-tasks-indicator__item"
|
||||
className={`background-tasks-indicator__item${isUpdated ? " background-tasks-indicator__item--updated" : ""}`}
|
||||
style={{
|
||||
transition: "background-color 220ms ease, transform 220ms ease",
|
||||
backgroundColor: isUpdated
|
||||
? "var(--color-accent-soft, rgba(59, 130, 246, 0.14))"
|
||||
: undefined,
|
||||
transform: isUpdated ? "translateY(-1px)" : undefined,
|
||||
}}
|
||||
onClick={() => {
|
||||
if (
|
||||
activeElsewhere &&
|
||||
!window.confirm("This session is active in another tab. Open anyway?")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
onOpenSession(session);
|
||||
setPopoverOpen(false);
|
||||
}}
|
||||
@@ -91,7 +166,8 @@ export function BackgroundTasksIndicator({
|
||||
<div className="background-tasks-indicator__session-meta">
|
||||
{TYPE_LABELS[session.type]}
|
||||
{isGenerating && " — generating..."}
|
||||
{isAwaiting && " — needs input"}
|
||||
{isAwaiting && !activeElsewhere && " — needs input"}
|
||||
{isAwaiting && activeElsewhere && " — active in another tab"}
|
||||
</div>
|
||||
</div>
|
||||
{isGenerating && (
|
||||
@@ -104,13 +180,20 @@ export function BackgroundTasksIndicator({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{isAwaiting && (
|
||||
{isAwaiting && !activeElsewhere && (
|
||||
<HelpCircle
|
||||
size={14}
|
||||
className="background-tasks-indicator__session-icon"
|
||||
style={{ color: "var(--triage)" }}
|
||||
/>
|
||||
)}
|
||||
{isAwaiting && activeElsewhere && (
|
||||
<Lock
|
||||
size={14}
|
||||
className="background-tasks-indicator__session-icon"
|
||||
style={{ color: "var(--text-muted)" }}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
className="background-tasks-indicator__item-dismiss"
|
||||
onClick={(e) => {
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { ConversationHistory } from "./ConversationHistory";
|
||||
import { useSessionLock } from "../hooks/useSessionLock";
|
||||
import { useAiSessionSync } from "../hooks/useAiSessionSync";
|
||||
import { getSessionTabId } from "../utils/getSessionTabId";
|
||||
|
||||
interface MissionInterviewModalProps {
|
||||
@@ -95,6 +96,7 @@ export function MissionInterviewModal({
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const streamConnectionRef = useRef<{ close: () => void; isConnected: () => boolean } | null>(null);
|
||||
const currentSessionIdRef = useRef<string | null>(null);
|
||||
const trackedLockSessionRef = useRef<string | null>(null);
|
||||
const [lockSessionId, setLockSessionId] = useState<string | null>(resumeSessionId ?? null);
|
||||
const sessionTabId = useMemo(() => getSessionTabId(), []);
|
||||
const {
|
||||
@@ -102,6 +104,14 @@ export function MissionInterviewModal({
|
||||
takeControl,
|
||||
isLoading: isLockLoading,
|
||||
} = useSessionLock(isOpen ? lockSessionId : null);
|
||||
const {
|
||||
activeTabMap,
|
||||
broadcastUpdate,
|
||||
broadcastCompleted,
|
||||
broadcastLock,
|
||||
broadcastUnlock,
|
||||
broadcastHeartbeat,
|
||||
} = useAiSessionSync();
|
||||
|
||||
const connectToMissionInterviewStream = useCallback(
|
||||
(sessionId: string) => {
|
||||
@@ -109,6 +119,15 @@ export function MissionInterviewModal({
|
||||
const connection = connectMissionInterviewStream(sessionId, projectId, {
|
||||
onThinking: (data) => {
|
||||
setStreamingOutput((prev) => prev + data);
|
||||
broadcastUpdate({
|
||||
sessionId,
|
||||
status: "generating",
|
||||
needsInput: false,
|
||||
owningTabId: sessionTabId,
|
||||
type: "mission_interview",
|
||||
title: missionGoal.trim() || "Mission interview",
|
||||
projectId: projectId ?? null,
|
||||
});
|
||||
},
|
||||
onQuestion: (question) => {
|
||||
setIsReconnecting(false);
|
||||
@@ -117,6 +136,16 @@ export function MissionInterviewModal({
|
||||
setView({ type: "question", sessionId, question });
|
||||
setStreamingOutput("");
|
||||
setHasProgress(true);
|
||||
|
||||
broadcastUpdate({
|
||||
sessionId,
|
||||
status: "awaiting_input",
|
||||
needsInput: true,
|
||||
owningTabId: sessionTabId,
|
||||
type: "mission_interview",
|
||||
title: missionGoal.trim() || "Mission interview",
|
||||
projectId: projectId ?? null,
|
||||
});
|
||||
},
|
||||
onSummary: (summary) => {
|
||||
setIsReconnecting(false);
|
||||
@@ -126,6 +155,16 @@ export function MissionInterviewModal({
|
||||
setEditedSummary(summary);
|
||||
setStreamingOutput("");
|
||||
setHasProgress(true);
|
||||
|
||||
broadcastUpdate({
|
||||
sessionId,
|
||||
status: "complete",
|
||||
needsInput: false,
|
||||
owningTabId: sessionTabId,
|
||||
type: "mission_interview",
|
||||
title: missionGoal.trim() || "Mission interview",
|
||||
projectId: projectId ?? null,
|
||||
});
|
||||
},
|
||||
onError: (message) => {
|
||||
const errorMessage = message || "Session failed while contacting the AI.";
|
||||
@@ -136,11 +175,23 @@ export function MissionInterviewModal({
|
||||
setStreamingOutput("");
|
||||
setHasProgress(true);
|
||||
currentSessionIdRef.current = sessionId;
|
||||
|
||||
broadcastUpdate({
|
||||
sessionId,
|
||||
status: "error",
|
||||
needsInput: false,
|
||||
owningTabId: sessionTabId,
|
||||
type: "mission_interview",
|
||||
title: missionGoal.trim() || "Mission interview",
|
||||
projectId: projectId ?? null,
|
||||
});
|
||||
broadcastCompleted({ sessionId, status: "error" });
|
||||
},
|
||||
onComplete: () => {
|
||||
setIsReconnecting(false);
|
||||
setIsRetrying(false);
|
||||
currentSessionIdRef.current = null;
|
||||
broadcastCompleted({ sessionId, status: "complete" });
|
||||
},
|
||||
onConnectionStateChange: (state) => {
|
||||
setIsReconnecting(state === "reconnecting");
|
||||
@@ -149,7 +200,7 @@ export function MissionInterviewModal({
|
||||
|
||||
streamConnectionRef.current = connection;
|
||||
},
|
||||
[projectId],
|
||||
[broadcastCompleted, broadcastUpdate, missionGoal, projectId, sessionTabId],
|
||||
);
|
||||
|
||||
const handleStartInterview = useCallback(
|
||||
@@ -285,13 +336,59 @@ export function MissionInterviewModal({
|
||||
};
|
||||
}, [connectToMissionInterviewStream, isOpen, resumeSessionId, view.type, projectId]);
|
||||
|
||||
// Broadcast ownership transitions between tabs.
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
if (trackedLockSessionRef.current) {
|
||||
broadcastUnlock(trackedLockSessionRef.current, sessionTabId);
|
||||
trackedLockSessionRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (lockSessionId && trackedLockSessionRef.current !== lockSessionId) {
|
||||
if (trackedLockSessionRef.current) {
|
||||
broadcastUnlock(trackedLockSessionRef.current, sessionTabId);
|
||||
}
|
||||
broadcastLock(lockSessionId, sessionTabId);
|
||||
trackedLockSessionRef.current = lockSessionId;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!lockSessionId && trackedLockSessionRef.current) {
|
||||
broadcastUnlock(trackedLockSessionRef.current, sessionTabId);
|
||||
trackedLockSessionRef.current = null;
|
||||
}
|
||||
}, [broadcastLock, broadcastUnlock, isOpen, lockSessionId, sessionTabId]);
|
||||
|
||||
// Keep heartbeat alive while this tab owns an active mission interview session.
|
||||
useEffect(() => {
|
||||
if (!isOpen || !lockSessionId || trackedLockSessionRef.current !== lockSessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
broadcastHeartbeat(sessionTabId);
|
||||
const timer = setInterval(() => {
|
||||
broadcastHeartbeat(sessionTabId);
|
||||
}, 30_000);
|
||||
|
||||
return () => {
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [broadcastHeartbeat, isOpen, lockSessionId, sessionTabId]);
|
||||
|
||||
// Cleanup stream on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
streamConnectionRef.current?.close();
|
||||
streamConnectionRef.current = null;
|
||||
|
||||
if (trackedLockSessionRef.current) {
|
||||
broadcastUnlock(trackedLockSessionRef.current, sessionTabId);
|
||||
trackedLockSessionRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
}, [broadcastUnlock, sessionTabId]);
|
||||
|
||||
// Unload protection
|
||||
useEffect(() => {
|
||||
@@ -475,6 +572,11 @@ export function MissionInterviewModal({
|
||||
const showSendToBackgroundButton =
|
||||
view.type === "loading" || view.type === "question" || view.type === "summary" || view.type === "error";
|
||||
|
||||
const activeLockInfo = lockSessionId ? activeTabMap.get(lockSessionId) : null;
|
||||
const activeRemoteTab = activeLockInfo && activeLockInfo.tabId !== sessionTabId;
|
||||
const activeInAnotherTab = Boolean(activeRemoteTab && !activeLockInfo.stale);
|
||||
const allowTakeover = isLockedByOther && (!activeRemoteTab || activeLockInfo.stale);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
@@ -505,6 +607,11 @@ export function MissionInterviewModal({
|
||||
<div className="planning-modal-body">
|
||||
{error && <div className="form-error planning-error">{error}</div>}
|
||||
{isReconnecting && <div className="form-hint text-muted">Reconnecting…</div>}
|
||||
{activeInAnotherTab && (
|
||||
<div className="form-hint text-muted" data-testid="session-active-another-tab-banner">
|
||||
Session is active in another tab.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view.type === "initial" && (
|
||||
<div className="planning-initial">
|
||||
@@ -657,17 +764,23 @@ export function MissionInterviewModal({
|
||||
<div className="session-lock-overlay" data-testid="session-lock-overlay">
|
||||
<div className="session-lock-banner">
|
||||
<Lock size={16} />
|
||||
<span>This session is active in another tab</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void takeControl();
|
||||
}}
|
||||
disabled={isLockLoading}
|
||||
className="btn btn-primary session-lock-take-control"
|
||||
>
|
||||
{isLockLoading ? "Taking control..." : "Take Control"}
|
||||
</button>
|
||||
<span>
|
||||
{allowTakeover
|
||||
? "This session is active in another tab"
|
||||
: "This session is active in another tab (live heartbeat)"}
|
||||
</span>
|
||||
{allowTakeover && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void takeControl();
|
||||
}}
|
||||
disabled={isLockLoading}
|
||||
className="btn btn-primary session-lock-take-control"
|
||||
>
|
||||
{isLockLoading ? "Taking control..." : "Take Control"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -25,6 +25,7 @@ import { Lightbulb, X, Loader2, CheckCircle, ArrowLeft, ArrowRight, Sparkles, Li
|
||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
import { ConversationHistory } from "./ConversationHistory";
|
||||
import { useSessionLock } from "../hooks/useSessionLock";
|
||||
import { useAiSessionSync } from "../hooks/useAiSessionSync";
|
||||
import { getSessionTabId } from "../utils/getSessionTabId";
|
||||
|
||||
interface PlanningModeModalProps {
|
||||
@@ -105,6 +106,14 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
takeControl,
|
||||
isLoading: isLockLoading,
|
||||
} = useSessionLock(isOpen ? lockSessionId : null);
|
||||
const {
|
||||
activeTabMap,
|
||||
broadcastUpdate,
|
||||
broadcastCompleted,
|
||||
broadcastLock,
|
||||
broadcastUnlock,
|
||||
broadcastHeartbeat,
|
||||
} = useAiSessionSync();
|
||||
const [planningModelProvider, setPlanningModelProvider] = useState<string | undefined>(undefined);
|
||||
const [planningModelId, setPlanningModelId] = useState<string | undefined>(undefined);
|
||||
const [loadedModels, setLoadedModels] = useState<ModelInfo[]>([]);
|
||||
@@ -112,6 +121,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
const [modelsError, setModelsError] = useState<string | null>(null);
|
||||
const [favoriteProviders, setFavoriteProviders] = useState<string[]>([]);
|
||||
const [favoriteModels, setFavoriteModels] = useState<string[]>([]);
|
||||
const trackedLockSessionRef = useRef<string | null>(null);
|
||||
|
||||
const planningSelectionValue = getModelSelectionValue(planningModelProvider, planningModelId);
|
||||
|
||||
@@ -158,6 +168,15 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
const connection = connectPlanningStream(sessionId, projectId, {
|
||||
onThinking: (data) => {
|
||||
setStreamingOutput((prev) => prev + data);
|
||||
broadcastUpdate({
|
||||
sessionId,
|
||||
status: "generating",
|
||||
needsInput: false,
|
||||
owningTabId: sessionTabId,
|
||||
type: "planning",
|
||||
title: initialPlan.trim() || "Planning session",
|
||||
projectId: projectId ?? null,
|
||||
});
|
||||
},
|
||||
onQuestion: (question) => {
|
||||
setIsReconnecting(false);
|
||||
@@ -168,6 +187,16 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
session: { sessionId, currentQuestion: question, summary: null },
|
||||
});
|
||||
setStreamingOutput("");
|
||||
|
||||
broadcastUpdate({
|
||||
sessionId,
|
||||
status: "awaiting_input",
|
||||
needsInput: true,
|
||||
owningTabId: sessionTabId,
|
||||
type: "planning",
|
||||
title: initialPlan.trim() || "Planning session",
|
||||
projectId: projectId ?? null,
|
||||
});
|
||||
},
|
||||
onSummary: (summary) => {
|
||||
setIsReconnecting(false);
|
||||
@@ -180,6 +209,16 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
});
|
||||
setEditedSummary(summary);
|
||||
setStreamingOutput("");
|
||||
|
||||
broadcastUpdate({
|
||||
sessionId,
|
||||
status: "complete",
|
||||
needsInput: false,
|
||||
owningTabId: sessionTabId,
|
||||
type: "planning",
|
||||
title: initialPlan.trim() || "Planning session",
|
||||
projectId: projectId ?? null,
|
||||
});
|
||||
},
|
||||
onError: (message) => {
|
||||
const errorMessage = message || "Session failed while contacting the AI.";
|
||||
@@ -198,11 +237,23 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
});
|
||||
setStreamingOutput("");
|
||||
currentSessionIdRef.current = sessionId;
|
||||
|
||||
broadcastUpdate({
|
||||
sessionId,
|
||||
status: "error",
|
||||
needsInput: false,
|
||||
owningTabId: sessionTabId,
|
||||
type: "planning",
|
||||
title: initialPlan.trim() || "Planning session",
|
||||
projectId: projectId ?? null,
|
||||
});
|
||||
broadcastCompleted({ sessionId, status: "error" });
|
||||
},
|
||||
onComplete: () => {
|
||||
setIsReconnecting(false);
|
||||
setIsRetrying(false);
|
||||
currentSessionIdRef.current = null;
|
||||
broadcastCompleted({ sessionId, status: "complete" });
|
||||
},
|
||||
onConnectionStateChange: (state) => {
|
||||
setIsReconnecting(state === "reconnecting");
|
||||
@@ -211,7 +262,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
|
||||
streamConnectionRef.current = connection;
|
||||
},
|
||||
[projectId],
|
||||
[broadcastCompleted, broadcastUpdate, initialPlan, projectId, sessionTabId],
|
||||
);
|
||||
|
||||
const handleStartPlanning = useCallback(async (planOverride?: string) => {
|
||||
@@ -341,13 +392,59 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Broadcast lock ownership transitions for cross-tab awareness.
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
if (trackedLockSessionRef.current) {
|
||||
broadcastUnlock(trackedLockSessionRef.current, sessionTabId);
|
||||
trackedLockSessionRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (lockSessionId && trackedLockSessionRef.current !== lockSessionId) {
|
||||
if (trackedLockSessionRef.current) {
|
||||
broadcastUnlock(trackedLockSessionRef.current, sessionTabId);
|
||||
}
|
||||
broadcastLock(lockSessionId, sessionTabId);
|
||||
trackedLockSessionRef.current = lockSessionId;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!lockSessionId && trackedLockSessionRef.current) {
|
||||
broadcastUnlock(trackedLockSessionRef.current, sessionTabId);
|
||||
trackedLockSessionRef.current = null;
|
||||
}
|
||||
}, [broadcastLock, broadcastUnlock, isOpen, lockSessionId, sessionTabId]);
|
||||
|
||||
// Emit heartbeat while this tab actively owns the current session lock.
|
||||
useEffect(() => {
|
||||
if (!isOpen || !lockSessionId || trackedLockSessionRef.current !== lockSessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
broadcastHeartbeat(sessionTabId);
|
||||
const timer = setInterval(() => {
|
||||
broadcastHeartbeat(sessionTabId);
|
||||
}, 30_000);
|
||||
|
||||
return () => {
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [broadcastHeartbeat, isOpen, lockSessionId, sessionTabId]);
|
||||
|
||||
// Cleanup stream connection on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
streamConnectionRef.current?.close();
|
||||
streamConnectionRef.current = null;
|
||||
|
||||
if (trackedLockSessionRef.current) {
|
||||
broadcastUnlock(trackedLockSessionRef.current, sessionTabId);
|
||||
trackedLockSessionRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
}, [broadcastUnlock, sessionTabId]);
|
||||
|
||||
// Handle browser unload while modal is open
|
||||
useEffect(() => {
|
||||
@@ -569,6 +666,11 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
const showSendToBackgroundButton =
|
||||
view.type === "loading" || view.type === "question" || view.type === "summary" || view.type === "error";
|
||||
|
||||
const activeLockInfo = lockSessionId ? activeTabMap.get(lockSessionId) : null;
|
||||
const activeRemoteTab = activeLockInfo && activeLockInfo.tabId !== sessionTabId;
|
||||
const activeInAnotherTab = Boolean(activeRemoteTab && !activeLockInfo.stale);
|
||||
const allowTakeover = isLockedByOther && (!activeRemoteTab || activeLockInfo.stale);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
@@ -599,6 +701,11 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
<div className="planning-modal-body">
|
||||
{error && <div className="form-error planning-error">{error}</div>}
|
||||
{isReconnecting && <div className="form-hint text-muted">Reconnecting…</div>}
|
||||
{activeInAnotherTab && (
|
||||
<div className="form-hint text-muted" data-testid="session-active-another-tab-banner">
|
||||
Session is active in another tab.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view.type === "initial" && (
|
||||
<div className="planning-initial">
|
||||
@@ -848,17 +955,23 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
<div className="session-lock-overlay" data-testid="session-lock-overlay">
|
||||
<div className="session-lock-banner">
|
||||
<Lock size={16} />
|
||||
<span>This session is active in another tab</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void takeControl();
|
||||
}}
|
||||
disabled={isLockLoading}
|
||||
className="btn btn-primary session-lock-take-control"
|
||||
>
|
||||
{isLockLoading ? "Taking control..." : "Take Control"}
|
||||
</button>
|
||||
<span>
|
||||
{allowTakeover
|
||||
? "This session is active in another tab"
|
||||
: "This session is active in another tab (live heartbeat)"}
|
||||
</span>
|
||||
{allowTakeover && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void takeControl();
|
||||
}}
|
||||
disabled={isLockLoading}
|
||||
className="btn btn-primary session-lock-take-control"
|
||||
>
|
||||
{isLockLoading ? "Taking control..." : "Take Control"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import { CheckCircle, Loader2, ListTree, Plus, Trash2, X, GripVertical, ArrowUp, ArrowDown, Minimize2, RefreshCw, Lock } from "lucide-react";
|
||||
import { ConversationHistory } from "./ConversationHistory";
|
||||
import { useSessionLock } from "../hooks/useSessionLock";
|
||||
import { useAiSessionSync } from "../hooks/useAiSessionSync";
|
||||
import { getSessionTabId } from "../utils/getSessionTabId";
|
||||
|
||||
interface SubtaskBreakdownModalProps {
|
||||
@@ -89,6 +90,7 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
|
||||
const streamRef = useRef<{ close: () => void; isConnected: () => boolean } | null>(null);
|
||||
const titleRefs = useRef<Array<HTMLInputElement | null>>([]);
|
||||
const autoStartedRef = useRef(false);
|
||||
const trackedLockSessionRef = useRef<string | null>(null);
|
||||
|
||||
const sessionId = view.type === "generating" || view.type === "editing" || view.type === "creating" || view.type === "error"
|
||||
? view.sessionId
|
||||
@@ -99,6 +101,14 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
|
||||
takeControl,
|
||||
isLoading: isLockLoading,
|
||||
} = useSessionLock(isOpen ? sessionId : null);
|
||||
const {
|
||||
activeTabMap,
|
||||
broadcastUpdate,
|
||||
broadcastCompleted,
|
||||
broadcastLock,
|
||||
broadcastUnlock,
|
||||
broadcastHeartbeat,
|
||||
} = useAiSessionSync();
|
||||
|
||||
const isInvalid = useMemo(() => {
|
||||
if (subtasks.length === 0) return true;
|
||||
@@ -107,6 +117,10 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
|
||||
}, [subtasks]);
|
||||
|
||||
const showSendToBackgroundButton = view.type === "generating" || view.type === "editing" || view.type === "error";
|
||||
const activeLockInfo = sessionId ? activeTabMap.get(sessionId) : null;
|
||||
const activeRemoteTab = activeLockInfo && activeLockInfo.tabId !== sessionTabId;
|
||||
const activeInAnotherTab = Boolean(activeRemoteTab && !activeLockInfo.stale);
|
||||
const allowTakeover = isLockedByOther && (!activeRemoteTab || activeLockInfo.stale);
|
||||
|
||||
const resetState = useCallback(() => {
|
||||
// Save to localStorage before cleanup (preserve for re-entry)
|
||||
@@ -152,7 +166,18 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
|
||||
(activeSessionId: string) => {
|
||||
streamRef.current?.close();
|
||||
streamRef.current = connectSubtaskStream(activeSessionId, projectId, {
|
||||
onThinking: (data) => setThinkingOutput((prev) => prev + data),
|
||||
onThinking: (data) => {
|
||||
setThinkingOutput((prev) => prev + data);
|
||||
broadcastUpdate({
|
||||
sessionId: activeSessionId,
|
||||
status: "generating",
|
||||
needsInput: false,
|
||||
owningTabId: sessionTabId,
|
||||
type: "subtask",
|
||||
title: localDescription.trim() || "Subtask breakdown",
|
||||
projectId: projectId ?? null,
|
||||
});
|
||||
},
|
||||
onSubtasks: (items) => {
|
||||
setIsReconnecting(false);
|
||||
setIsRetrying(false);
|
||||
@@ -160,6 +185,16 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
|
||||
setSubtasks(items);
|
||||
setView({ type: "editing", sessionId: activeSessionId });
|
||||
setDirty(false);
|
||||
|
||||
broadcastUpdate({
|
||||
sessionId: activeSessionId,
|
||||
status: "awaiting_input",
|
||||
needsInput: true,
|
||||
owningTabId: sessionTabId,
|
||||
type: "subtask",
|
||||
title: localDescription.trim() || "Subtask breakdown",
|
||||
projectId: projectId ?? null,
|
||||
});
|
||||
},
|
||||
onError: (message) => {
|
||||
const errorMessage = message || "Session failed while contacting the AI.";
|
||||
@@ -167,13 +202,33 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
|
||||
setIsRetrying(false);
|
||||
setError(null);
|
||||
setView({ type: "error", sessionId: activeSessionId, errorMessage });
|
||||
|
||||
broadcastUpdate({
|
||||
sessionId: activeSessionId,
|
||||
status: "error",
|
||||
needsInput: false,
|
||||
owningTabId: sessionTabId,
|
||||
type: "subtask",
|
||||
title: localDescription.trim() || "Subtask breakdown",
|
||||
projectId: projectId ?? null,
|
||||
});
|
||||
broadcastCompleted({ sessionId: activeSessionId, status: "error" });
|
||||
},
|
||||
onComplete: () => {
|
||||
broadcastCompleted({ sessionId: activeSessionId, status: "complete" });
|
||||
},
|
||||
onConnectionStateChange: (state) => {
|
||||
setIsReconnecting(state === "reconnecting");
|
||||
},
|
||||
});
|
||||
},
|
||||
[projectId],
|
||||
[
|
||||
broadcastCompleted,
|
||||
broadcastUpdate,
|
||||
localDescription,
|
||||
projectId,
|
||||
sessionTabId,
|
||||
],
|
||||
);
|
||||
|
||||
const beginBreakdown = useCallback(async () => {
|
||||
@@ -246,11 +301,56 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
|
||||
})();
|
||||
}, [connectToSubtaskStream, isOpen, resumeSessionId, view.type, projectId]);
|
||||
|
||||
// Broadcast lock ownership transitions across tabs.
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
if (trackedLockSessionRef.current) {
|
||||
broadcastUnlock(trackedLockSessionRef.current, sessionTabId);
|
||||
trackedLockSessionRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (sessionId && trackedLockSessionRef.current !== sessionId) {
|
||||
if (trackedLockSessionRef.current) {
|
||||
broadcastUnlock(trackedLockSessionRef.current, sessionTabId);
|
||||
}
|
||||
broadcastLock(sessionId, sessionTabId);
|
||||
trackedLockSessionRef.current = sessionId;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!sessionId && trackedLockSessionRef.current) {
|
||||
broadcastUnlock(trackedLockSessionRef.current, sessionTabId);
|
||||
trackedLockSessionRef.current = null;
|
||||
}
|
||||
}, [broadcastLock, broadcastUnlock, isOpen, sessionId, sessionTabId]);
|
||||
|
||||
// Keep ownership heartbeat alive while this tab is interacting with the session.
|
||||
useEffect(() => {
|
||||
if (!isOpen || !sessionId || trackedLockSessionRef.current !== sessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
broadcastHeartbeat(sessionTabId);
|
||||
const timer = setInterval(() => {
|
||||
broadcastHeartbeat(sessionTabId);
|
||||
}, 30_000);
|
||||
|
||||
return () => {
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [broadcastHeartbeat, isOpen, sessionId, sessionTabId]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
streamRef.current?.close();
|
||||
if (trackedLockSessionRef.current) {
|
||||
broadcastUnlock(trackedLockSessionRef.current, sessionTabId);
|
||||
trackedLockSessionRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
}, [broadcastUnlock, sessionTabId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
@@ -442,6 +542,11 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
|
||||
<div className="planning-modal-body">
|
||||
{error && <div className="form-error planning-error">{error}</div>}
|
||||
{isReconnecting && <div className="form-hint text-muted">Reconnecting…</div>}
|
||||
{activeInAnotherTab && (
|
||||
<div className="form-hint text-muted" data-testid="session-active-another-tab-banner">
|
||||
Session is active in another tab.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view.type === "initial" && (
|
||||
<div className="planning-initial">
|
||||
@@ -691,17 +796,23 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
|
||||
<div className="session-lock-overlay" data-testid="session-lock-overlay">
|
||||
<div className="session-lock-banner">
|
||||
<Lock size={16} />
|
||||
<span>This session is active in another tab</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void takeControl();
|
||||
}}
|
||||
disabled={isLockLoading}
|
||||
className="btn btn-primary session-lock-take-control"
|
||||
>
|
||||
{isLockLoading ? "Taking control..." : "Take Control"}
|
||||
</button>
|
||||
<span>
|
||||
{allowTakeover
|
||||
? "This session is active in another tab"
|
||||
: "This session is active in another tab (live heartbeat)"}
|
||||
</span>
|
||||
{allowTakeover && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void takeControl();
|
||||
}}
|
||||
disabled={isLockLoading}
|
||||
className="btn btn-primary session-lock-take-control"
|
||||
>
|
||||
{isLockLoading ? "Taking control..." : "Take Control"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
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