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 ? (
+
+
+ Auto-report on · future completions notify the
+ main chat
+
+ ) : (
+
+ )}
+
+ )}
+
{(() => {
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 (
+
+
+
+ Background agent reported back
+
+
+ );
+ }
+
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);
+}