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) => {
|
||||
|
||||
Reference in New Issue
Block a user