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

- Add a shared AiSessionSync store with BroadcastChannel + storage fallback, ownership locks, heartbeats, and stale-tab detection
- Merge cross-tab session snapshots into useBackgroundSessions with timestamp guards and rebroadcast SSE updates to peers
- Update background session UI and planning/mission/subtask modals to show active-tab lock status and only allow takeover when ownership is stale
- Add hook tests covering sync store messaging/fallback behavior and background session cross-tab merge flows
This commit is contained in:
gsxdsm
2026-04-08 16:48:35 -07:00
parent d70cef1939
commit 2a34ebe6cc
8 changed files with 1741 additions and 71 deletions

View File

@@ -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) => {