diff --git a/src/app/components/AgentsPanel.tsx b/src/app/components/AgentsPanel.tsx index 42c0add..66820e0 100644 --- a/src/app/components/AgentsPanel.tsx +++ b/src/app/components/AgentsPanel.tsx @@ -3,13 +3,16 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { ArrowUp, + BellRing, Bot, ChevronDown, ChevronRight, + CornerUpLeft, Loader2, RefreshCw, } from "lucide-react"; import { useQueryState } from "nuqs"; +import { toast } from "sonner"; import { useClient } from "@/providers/ClientProvider"; import { cn } from "@/lib/utils"; import { extractStringFromMessageContent } from "@/app/utils/utils"; @@ -17,11 +20,16 @@ import { type EnrichedAsyncTask, ASYNC_STATUS_META, agentLabel, + asyncTaskReportKey, countRunning, formatElapsed, + isTerminalStatus, + type MainChatReporter, normalizeAsyncStatus, } from "@/lib/asyncAgents"; import { useAsyncAgents } from "@/app/hooks/useAsyncAgents"; +import { useAutoNotify } from "@/app/hooks/useAutoNotify"; +import { initializeThreadAutoNotifyReports } from "@/lib/autoNotify"; import { messagesToSubAgentSteps, type SubAgentStep, @@ -85,7 +93,14 @@ function elapsedOf(task: EnrichedAsyncTask, now: number): string { * thread). Polling is in {@link useAsyncAgents} and stops when this is unmounted * (i.e. the Agents tab is closed). */ -export function AgentsPanel() { +interface AgentsPanelProps { + // Submit a message on the main thread (loops an async result back to the main + // agent). Returns false if the main chat is mid-run. Null when the chat view + // isn't mounted (Skills/Memory) — the "Notify main chat" button then disables. + onReportToMainChat?: MainChatReporter | null; +} + +export function AgentsPanel({ onReportToMainChat }: AgentsPanelProps) { const client = useClient(); const [threadId] = useQueryState("threadId"); const { tasks, loaded, error, refresh } = useAsyncAgents(threadId); @@ -93,6 +108,13 @@ export function AgentsPanel() { const [expandedId, setExpandedId] = useState(null); const [details, setDetails] = useState>({}); const terminalDetailSignaturesRef = useRef(new Map()); + // Which tasks have been reported to the main chat this session (so the button + // flips to "Reported" and we don't double-poke the main agent). + const [reported, setReported] = useState>({}); + // Per-thread "auto-report finished agents to the main chat" toggle. The actual + // auto-injection runs in ChatInterface (always mounted on the chat view); here + // we just own the switch. Shared reactively via useAutoNotify. + const [autoNotify, setAutoNotify] = useAutoNotify(threadId); // Hidden power-user feature: a tiny composer per expanded task that sends a // message straight to that sub-agent's own thread. Note this is a SIDE channel // — the reply lands in the sub-agent's thread (shown here), the main agent @@ -146,6 +168,46 @@ export function AgentsPanel() { } }; + // Loop a finished task's result back to the MAIN agent: inject the exact + // "[Async tasks update]" signal block as a user turn on the main thread (via + // the registered notify hook). The main agent recognizes it and calls + // check_async_task(task_id) to fetch the real result — zero backend change. + const reportToMain = (task: EnrichedAsyncTask) => { + if (!onReportToMainChat || !threadId) { + toast.error("Open the conversation to notify the main agent."); + return; + } + const result = onReportToMainChat(task, threadId); + if (result === "sent") { + setReported((r) => ({ ...r, [asyncTaskReportKey(task)]: true })); + toast.success("Reported to the main chat."); + return; + } + if (result === "duplicate") { + setReported((r) => ({ ...r, [asyncTaskReportKey(task)]: true })); + toast.info("This result is already in the main chat."); + return; + } + toast.error( + result === "wrong-thread" + ? "The active conversation changed — reopen this task and try again." + : "Main chat is busy — try again when it's idle." + ); + }; + + const toggleAutoNotify = () => { + if (!threadId) return; + if (!autoNotify) { + initializeThreadAutoNotifyReports( + threadId, + tasks + .filter((task) => isTerminalStatus(task.liveStatus)) + .map(asyncTaskReportKey) + ); + } + setAutoNotify(!autoNotify); + }; + // Fetch the expanded task's own thread for its steps; re-fetch whenever the // task list refreshes (so a running task's steps stay live). useEffect(() => { @@ -222,18 +284,47 @@ export function AgentsPanel() { ? `${tasks.length} total` : "Background agents"}

- +
+ {/* Auto-report toggle: when on, finished agents loop back to the main + chat automatically (no need to click "Notify main chat" each time). */} + + +
{error && ( @@ -349,6 +440,48 @@ export function AgentsPanel() { )} + {/* Loop this finished task's result back to the MAIN agent. + When auto-report is on it's sent automatically, so the + manual button is replaced by a note (avoids a double + inject — auto and manual track dedup separately). */} + {isTerminalStatus(task.liveStatus) && ( +
+ {autoNotify ? ( +

+

+ ) : ( + + )} +
+ )} + {(() => { const running = normalizeAsyncStatus(task.liveStatus) === "running"; diff --git a/src/app/components/ChatInterface.tsx b/src/app/components/ChatInterface.tsx index feb4f35..1b433e8 100644 --- a/src/app/components/ChatInterface.tsx +++ b/src/app/components/ChatInterface.tsx @@ -45,8 +45,24 @@ import { setThreadAutoApprove, migrateNewThreadAutoApprove, } from "@/lib/autoApprove"; -import { countRunning } from "@/lib/asyncAgents"; +import { + agentLabel, + asyncTaskReportKey, + asyncUpdateMatchesTask, + asyncUpdateMessageKey, + countRunning, + formatAsyncUpdateMessage, + isTerminalStatus, + type MainChatReporter, +} from "@/lib/asyncAgents"; import { useAsyncAgents } from "@/app/hooks/useAsyncAgents"; +import { useAutoNotify } from "@/app/hooks/useAutoNotify"; +import { + getThreadAutoNotifyReportedKeys, + initializeThreadAutoNotifyReports, + isThreadAutoNotifyInitialized, + markThreadAutoNotifyReported, +} from "@/lib/autoNotify"; import { lastTextOf, type SubAgentStep } from "@/lib/subAgentActivity"; import { useStickToBottom } from "use-stick-to-bottom"; import { FilesPopover } from "@/app/components/TasksFilesSidebar"; @@ -66,6 +82,10 @@ interface ChatInterfaceProps { assistant: Assistant | null; // Open the right inspector on its Agents tab (composer "agents running" pulse). onShowAgents?: () => void; + // Register a "submit a message on THIS (main) thread" function up to page so + // the Agents board can loop an async result back to the main agent. Returns + // false if the main chat is mid-run (can't take a turn). Cleared on unmount. + onNotifyReady?: (notify: MainChatReporter | null) => void; } const SUGGESTED_PROMPTS = [ @@ -160,7 +180,7 @@ const getStatusIcon = (status: TodoItem["status"], className?: string) => { }; export const ChatInterface = React.memo( - ({ assistant, onShowAgents }) => { + ({ assistant, onShowAgents, onNotifyReady }) => { const [metaOpen, setMetaOpen] = useState< "tasks" | "files" | "workspace" | null >(null); @@ -218,6 +238,85 @@ export const ChatInterface = React.memo( [liveAgentTasks] ); + // Auto-report: when on for this thread, a sub-agent that FINISHES while we're + // watching is looped back to the main agent automatically (same signal as the + // manual "Notify main chat" button — rendered as a system pill). We baseline + // tasks already terminal at mount / when the toggle is switched on so we never + // replay old completions, and only inject while the main chat is idle (one at + // a time; isLoading gates the rest until the agent finishes the turn). + const [autoNotify] = useAutoNotify(threadId); + // Latch covering the gap between submitting an auto-report and `isLoading` + // flipping true — without it a poll in that window could fire a SECOND report + // and collide on the main thread. Cleared once the run is confirmed running. + const autoFireInFlightRef = useRef(false); + + useEffect(() => { + autoFireInFlightRef.current = false; + }, [threadId]); + + // Once a run is actually in flight (isLoading true — from a user message, the + // agent's own turn, or our auto-report), release the latch: the isLoading gate + // now governs, and the next queued report fires when the thread next goes idle. + useEffect(() => { + if (isLoading) autoFireInFlightRef.current = false; + }, [isLoading]); + + useEffect(() => { + if (!liveAgentTasks || liveAgentTasks.length === 0) return; + if (!threadId || !autoNotify) return; + // One-time migration/baseline: existing terminal tasks predate the setting + // and must not replay when this feature first appears or is restored. + if (!isThreadAutoNotifyInitialized(threadId)) { + initializeThreadAutoNotifyReports( + threadId, + liveAgentTasks + .filter((task) => isTerminalStatus(task.liveStatus)) + .map(asyncTaskReportKey) + ); + return; + } + // Don't fire when: off; the thread is busy (the agent's own turn takes the + // slot); a report we just sent hasn't started yet; or the USER is composing + // a query (draft text) — their message has priority, so we hold the queue + // until the composer is clear. Pending completions stay unreported (= the + // queue) and drain one per idle window. + if (isLoading || autoFireInFlightRef.current || input.trim()) return; + const reportedKeys = getThreadAutoNotifyReportedKeys(threadId); + for (const t of liveAgentTasks) { + if (!isTerminalStatus(t.liveStatus)) continue; + const key = asyncTaskReportKey(t); + if (reportedKeys.has(key)) continue; + if ( + messages.some( + (message) => + message.type === "human" && + asyncUpdateMatchesTask( + extractStringFromMessageContent(message), + t + ) + ) + ) { + markThreadAutoNotifyReported(threadId, key); + continue; + } + autoFireInFlightRef.current = true; + markThreadAutoNotifyReported(threadId, key); + sendMessage(formatAsyncUpdateMessage(t)); + toast.success( + `Auto-reported ${agentLabel(t.agent_name)} to the main chat.` + ); + break; // one per idle window; the rest fire once this turn settles + } + }, [ + liveAgentTasks, + autoNotify, + isLoading, + input, + messages, + sendMessage, + threadId, + ]); + // Re-engage stick-to-bottom whenever a new run starts (sending a message or // resuming an interrupt → isLoading flips true). Without this, if the user had // drifted even slightly off the bottom after the previous answer, a short new @@ -226,6 +325,47 @@ export const ChatInterface = React.memo( if (isLoading) void scrollToBottom(); }, [isLoading, scrollToBottom]); + // Register a "notify the main agent" hook up to page (Agents board → "Notify + // main chat" loops an async result back here). A ref keeps the latest + // sendMessage/isLoading so the once-registered closure always reads current + // values. Returns false if a run is in flight (the agent can't take a turn). + const notifyStateRef = useRef({ + sendMessage, + isLoading, + messages, + threadId, + }); + notifyStateRef.current = { sendMessage, isLoading, messages, threadId }; + const onNotifyReadyRef = useRef(onNotifyReady); + onNotifyReadyRef.current = onNotifyReady; + useEffect(() => { + const notify: MainChatReporter = (task, expectedThreadId) => { + const current = notifyStateRef.current; + if (current.threadId !== expectedThreadId) return "wrong-thread"; + if (current.isLoading) return "busy"; + if ( + current.messages.some( + (message) => + message.type === "human" && + asyncUpdateMatchesTask( + extractStringFromMessageContent(message), + task + ) + ) + ) { + return "duplicate"; + } + markThreadAutoNotifyReported( + expectedThreadId, + asyncTaskReportKey(task) + ); + current.sendMessage(formatAsyncUpdateMessage(task)); + return "sent"; + }; + onNotifyReadyRef.current?.(notify); + return () => onNotifyReadyRef.current?.(null); + }, []); + // The model behind the latest assistant reply (read from message metadata). // Token/context usage is intentionally NOT shown — the backend doesn't persist // usage_metadata, so it isn't reliably available here. @@ -515,10 +655,19 @@ export const ChatInterface = React.memo( // Keep them OUT of the main flow — they render under each sub-agent block's // "Steps" instead. (streamMetadata is live-only; once complete these messages // aren't in thread state anyway.) + const seenAsyncUpdates = new Set(); const visibleMessages = messages.filter((message: Message) => { const meta = stream.getMessagesMetadata(message)?.streamMetadata; const ns = meta?.["langgraph_checkpoint_ns"]; - return !(typeof ns === "string" && ns.includes("|")); + if (typeof ns === "string" && ns.includes("|")) return false; + if (message.type !== "human") return true; + const key = asyncUpdateMessageKey( + extractStringFromMessageContent(message) + ); + if (!key) return true; + if (seenAsyncUpdates.has(key)) return false; + seenAsyncUpdates.add(key); + return true; }); visibleMessages.forEach((message: Message) => { if (message.type === "ai") { diff --git a/src/app/components/ChatMessage.tsx b/src/app/components/ChatMessage.tsx index 365de56..b562073 100644 --- a/src/app/components/ChatMessage.tsx +++ b/src/app/components/ChatMessage.tsx @@ -19,7 +19,8 @@ import type { } from "@/app/types/types"; import { Message } from "@langchain/langgraph-sdk"; import type { SubAgentStep } from "@/lib/subAgentActivity"; -import { Brain, Check, ChevronRight, Copy, Pencil } from "lucide-react"; +import { isAsyncUpdateMessage } from "@/lib/asyncAgents"; +import { Bell, Brain, Check, ChevronRight, Copy, Pencil } from "lucide-react"; import { extractSubAgentContent, extractStringFromMessageContent, @@ -214,6 +215,24 @@ export const ChatMessage = React.memo( })); }, []); + // A "[Async tasks update]" signal we injected (from the Agents board's + // "Notify main chat") is a background-completion notice, not something the + // user typed — render it as a low-key centered system pill, not a user + // bubble. The main agent's response (check_async_task etc.) renders normally. + if (isUser && isAsyncUpdateMessage(messageContent)) { + return ( +
+ + +
+ ); + } + return (
void; + // Loop a finished async agent's result back to the main chat (Agents tab). + // Null when the chat view isn't mounted (e.g. viewing Skills/Memory). + onReportToMainChat?: MainChatReporter | null; } type InspectorTab = "workspace" | "agents"; @@ -21,7 +25,10 @@ type InspectorTab = "workspace" | "agents"; * The active tab is mirrored to the `inspectorTab` URL param so the composer's * "agents running" indicator can deep-link straight to the Agents tab. */ -export function InspectorPanel({ onClose }: InspectorPanelProps) { +export function InspectorPanel({ + onClose, + onReportToMainChat, +}: InspectorPanelProps) { const [tabParam, setTab] = useQueryState("inspectorTab"); const tab: InspectorTab = tabParam === "agents" ? "agents" : "workspace"; @@ -86,7 +93,7 @@ export function InspectorPanel({ onClose }: InspectorPanelProps) {
{tab === "agents" ? (
- +
) : (
diff --git a/src/app/hooks/useAutoNotify.ts b/src/app/hooks/useAutoNotify.ts new file mode 100644 index 0000000..c02f020 --- /dev/null +++ b/src/app/hooks/useAutoNotify.ts @@ -0,0 +1,32 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { + getThreadAutoNotify, + setThreadAutoNotify, + subscribeAutoNotify, +} from "@/lib/autoNotify"; + +/** + * Reactive per-thread "auto-report finished async agents to the main chat" + * preference. Stays in sync across components (the toggle in the Agents board + * and the auto-injection effect on the chat view both use this) and across tabs. + */ +export function useAutoNotify( + threadId: string | null +): [boolean, (on: boolean) => void] { + const [on, setOn] = useState(() => getThreadAutoNotify(threadId)); + + useEffect(() => { + const sync = () => setOn(getThreadAutoNotify(threadId)); + sync(); + return subscribeAutoNotify(sync); + }, [threadId]); + + const set = useCallback( + (next: boolean) => setThreadAutoNotify(threadId, next), + [threadId] + ); + + return [on, set]; +} diff --git a/src/app/page.tsx b/src/app/page.tsx index ddb5384..2a18613 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -31,6 +31,7 @@ import { BetaBadge } from "@/app/components/BetaBadge"; import { HealthIndicator } from "@/app/components/HealthIndicator"; import { InspectorPanel } from "@/app/components/InspectorPanel"; import { setThreadAutoApprove } from "@/lib/autoApprove"; +import type { MainChatReporter } from "@/lib/asyncAgents"; interface HomePageInnerProps { config: DeploymentConfig; @@ -57,6 +58,12 @@ function HomePageInner({ const [assistant, setAssistant] = useState(null); const [isDesktopLayout, setIsDesktopLayout] = useState(null); const [chatSessionRevision, setChatSessionRevision] = useState(0); + // "Submit a message on the main thread" — registered by ChatInterface (only + // while it's mounted, i.e. on the chat view), used by the Agents board to loop + // an async result back to the main agent. Null when not on the chat view. + const [notifyMainChat, setNotifyMainChat] = useState( + null + ); const fetchAssistant = useCallback(async () => { const isUUID = @@ -346,7 +353,10 @@ function HomePageInner({ aria-label="Inspector" className="relative z-10 h-full w-[min(22rem,calc(100vw-2.25rem))] bg-background shadow-xl" > - +
)} @@ -392,6 +402,7 @@ function HomePageInner({ setNotifyMainChat(() => fn)} /> )} @@ -407,7 +418,10 @@ function HomePageInner({ minSize={20} className="relative min-w-[300px]" > - + )} diff --git a/src/lib/asyncAgents.ts b/src/lib/asyncAgents.ts index 470cc0e..7c00a49 100644 --- a/src/lib/asyncAgents.ts +++ b/src/lib/asyncAgents.ts @@ -55,6 +55,116 @@ export function agentLabel(name: string): string { return AGENT_LABELS[name] ?? name; } +// --------------------------------------------------------------------------- +// "Report to main chat" — loop a finished async task back to the main agent. +// --------------------------------------------------------------------------- +// The EvoScientist main agent recognizes a synthetic "[Async tasks update]" +// USER message as a background-completion SIGNAL (not a new request) and responds +// by calling check_async_task(task_id) to fetch the real result from the +// sub-agent's own thread. This mirrors the backend's format_batch_message +// (cli/async_notifier.py): the message carries NO result, only the signal — +// the agent fetches the result itself. So the WebUI can loop a result back with +// zero backend change: submit this exact block as a user turn on the MAIN thread. + +/** Prefix the main agent's ASYNC_NOTIFICATIONS prompt section keys on. */ +export const ASYNC_UPDATE_MARKER = "[Async tasks update]"; + +export interface AsyncTaskReportTarget { + agent_name: string; + task_id: string; + run_id?: string; + liveStatus?: string; + status: string; +} + +export type MainChatReportResult = + | "sent" + | "busy" + | "duplicate" + | "wrong-thread"; + +export type MainChatReporter = ( + task: AsyncTaskReportTarget, + expectedThreadId: string +) => MainChatReportResult; + +interface ParsedAsyncUpdate { + taskId: string; + runId?: string; +} + +export function asyncTaskReportKey(task: { + task_id: string; + run_id?: string; +}): string { + return `${task.task_id}:${task.run_id || "legacy"}`; +} + +export function parseAsyncUpdateMessage( + text: string +): ParsedAsyncUpdate | null { + const lines = text.trim().split(/\r?\n/); + if (lines[0] !== ASYNC_UPDATE_MARKER || !lines[1]) return null; + try { + const payload = JSON.parse(lines[1]) as { + task_id?: unknown; + run_id?: unknown; + }; + if (typeof payload.task_id !== "string" || !payload.task_id) return null; + return { + taskId: payload.task_id, + runId: + typeof payload.run_id === "string" && payload.run_id + ? payload.run_id + : undefined, + }; + } catch { + return null; + } +} + +/** True if a (human) message is one of our injected async-completion signals — + * used to render it as a system pill instead of a user bubble. */ +export function isAsyncUpdateMessage(text: string): boolean { + return parseAsyncUpdateMessage(text) !== null; +} + +export function asyncUpdateMessageKey(text: string): string | null { + const parsed = parseAsyncUpdateMessage(text); + if (!parsed) return null; + return `${parsed.taskId}:${parsed.runId || "legacy"}`; +} + +export function asyncUpdateMatchesTask( + text: string, + task: { task_id: string; run_id?: string } +): boolean { + const parsed = parseAsyncUpdateMessage(text); + if (!parsed || parsed.taskId !== task.task_id) return false; + return !parsed.runId || parsed.runId === task.run_id; +} + +/** Build the "[Async tasks update]" signal block for one finished task, + * mirroring the backend's single-task format_batch_message output and adding + * `run_id` for client-side deduplication. `task_id` must match the key the main + * agent tracks in its `async_tasks` state (that's how check_async_task resolves + * it). */ +export function formatAsyncUpdateMessage(task: AsyncTaskReportTarget): string { + const status = task.liveStatus || task.status || "success"; + const line = JSON.stringify({ + agent: task.agent_name, + kind: "agent", + ...(task.run_id ? { run_id: task.run_id } : {}), + status, + task_id: task.task_id, + }); + return [ + ASYNC_UPDATE_MARKER, + line, + "(Signal only — fetch full result via check_async_task (sub-agents) if relevant to the current step, else acknowledge & continue.)", + ].join("\n"); +} + // Theme dot + label + pulse per status. CSS vars referenced via arbitrary-value // classes (the base's semantic bg tokens are dead in this fork — see CLAUDE.md). // NOTE: never put a bracketed class literal in a comment (Tailwind scans those). diff --git a/src/lib/autoNotify.ts b/src/lib/autoNotify.ts new file mode 100644 index 0000000..1c85ded --- /dev/null +++ b/src/lib/autoNotify.ts @@ -0,0 +1,152 @@ +// Per-thread "auto-report" preference: when on, a finished async sub-agent's +// result is AUTOMATICALLY looped back to the main agent (the same "[Async tasks +// update]" signal the manual "Notify main chat" button injects), so the main +// agent fetches it via check_async_task and integrates — like the TUI's auto +// report. ON by default (a conversation's background tasks loop back unless you +// turn it off); persisted in localStorage so the choice FOLLOWS each thread +// across reloads/views. +// +// Unlike auto-approve, this setting is read in ONE place (the toggle, in the +// Agents board) and CONSUMED in another (the auto-injection effect, on the chat +// view). So it ships a tiny pub/sub on top of localStorage: setting it notifies +// in-page subscribers immediately (custom event) and other tabs via `storage`. + +const STORAGE_KEY = "evoscientist-auto-notify"; +const REPORTED_STORAGE_KEY = "evoscientist-auto-notify-reported"; +const CHANGE_EVENT = "evo-auto-notify-change"; + +function load(): Record { + if (typeof window === "undefined") return {}; + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return {}; + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + return parsed as Record; + } + } catch { + // Corrupt/unavailable storage → treat as empty (everything off). + } + return {}; +} + +function save(map: Record): void { + if (typeof window === "undefined") return; + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(map)); + } catch { + // Quota/private-mode failures are non-fatal — it just won't persist. + } +} + +interface ReportedState { + initialized: boolean; + keys: string[]; +} + +function loadReported(): Record { + if (typeof window === "undefined") return {}; + try { + const raw = localStorage.getItem(REPORTED_STORAGE_KEY); + if (!raw) return {}; + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + return parsed as Record; + } + } catch { + // Corrupt/unavailable storage → rebuild the baseline when needed. + } + return {}; +} + +function saveReported(map: Record): void { + if (typeof window === "undefined") return; + try { + localStorage.setItem(REPORTED_STORAGE_KEY, JSON.stringify(map)); + } catch { + // Persistence failure is non-fatal; the current session still dedups via UI. + } +} + +/** Whether auto-report is on for this thread. ON by default — a thread is off + * only if it was explicitly turned off. A null thread (pending new chat) reads + * off since it has no async tasks yet; once it gets a real id it defaults on. */ +export function getThreadAutoNotify(threadId: string | null): boolean { + if (!threadId) return false; + return load()[threadId] !== false; +} + +/** Turn auto-report on/off for a thread and notify subscribers (this tab + others). */ +export function setThreadAutoNotify( + threadId: string | null, + on: boolean +): void { + if (!threadId) return; + const map = load(); + if (on) { + // On is the default → drop the entry so the map stays small. + delete map[threadId]; + } else { + // Store only explicit "off" entries (absence == on == default). + map[threadId] = false; + } + save(map); + if (typeof window !== "undefined") { + window.dispatchEvent(new Event(CHANGE_EVENT)); + } +} + +/** Subscribe to auto-report changes (in-page via custom event, cross-tab via + * storage). Returns an unsubscribe function. */ +export function subscribeAutoNotify(listener: () => void): () => void { + if (typeof window === "undefined") return () => {}; + window.addEventListener(CHANGE_EVENT, listener); + window.addEventListener("storage", listener); + return () => { + window.removeEventListener(CHANGE_EVENT, listener); + window.removeEventListener("storage", listener); + }; +} + +export function getThreadAutoNotifyReportedKeys( + threadId: string | null +): Set { + if (!threadId) return new Set(); + const state = loadReported()[threadId]; + return new Set(Array.isArray(state?.keys) ? state.keys : []); +} + +export function isThreadAutoNotifyInitialized( + threadId: string | null +): boolean { + if (!threadId) return false; + return loadReported()[threadId]?.initialized === true; +} + +export function initializeThreadAutoNotifyReports( + threadId: string | null, + keys: Iterable +): void { + if (!threadId) return; + const map = loadReported(); + const existing = map[threadId]; + map[threadId] = { + initialized: true, + keys: Array.from(new Set([...(existing?.keys ?? []), ...keys])), + }; + saveReported(map); +} + +export function markThreadAutoNotifyReported( + threadId: string | null, + key: string +): void { + if (!threadId) return; + const map = loadReported(); + const existing = map[threadId]; + map[threadId] = { + initialized: existing?.initialized ?? true, + keys: Array.from(new Set([...(existing?.keys ?? []), key])), + }; + saveReported(map); +}