feat: implement auto-reporting for async tasks in AgentsPanel and ChatInterface

This commit is contained in:
Xi Zhang
2026-06-10 02:27:32 +01:00
parent 8c5fd382ad
commit 569b6393a9
8 changed files with 637 additions and 21 deletions
+146 -13
View File
@@ -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<string | null>(null);
const [details, setDetails] = useState<Record<string, TaskDetail>>({});
const terminalDetailSignaturesRef = useRef(new Map<string, string>());
// 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<Record<string, boolean>>({});
// 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"}
</p>
<button
type="button"
onClick={refresh}
aria-label="Refresh agents"
title="Refresh"
className="rounded p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
<RefreshCw
className="size-3.5"
aria-hidden="true"
/>
</button>
<div className="flex items-center gap-1">
{/* Auto-report toggle: when on, finished agents loop back to the main
chat automatically (no need to click "Notify main chat" each time). */}
<button
type="button"
onClick={toggleAutoNotify}
aria-pressed={autoNotify}
aria-label={`Auto-report ${autoNotify ? "on" : "off"}`}
title={
autoNotify
? "Auto-report on: finished agents are sent to the main chat automatically"
: "Auto-report off: use each agent's “Notify main chat” button"
}
className={cn(
"inline-flex items-center gap-1 rounded-md px-1.5 py-1 text-[11px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
autoNotify
? "bg-accent text-[var(--brand)]"
: "text-muted-foreground hover:bg-accent hover:text-foreground"
)}
>
<BellRing
className="size-3.5"
aria-hidden="true"
/>
<span className="hidden min-[340px]:inline">
Auto-report {autoNotify ? "On" : "Off"}
</span>
</button>
<button
type="button"
onClick={refresh}
aria-label="Refresh agents"
title="Refresh"
className="rounded p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
<RefreshCw
className="size-3.5"
aria-hidden="true"
/>
</button>
</div>
</div>
{error && (
@@ -349,6 +440,48 @@ export function AgentsPanel() {
)}
</div>
{/* 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) && (
<div className="mt-1 border-t border-border pt-2">
{autoNotify ? (
<p className="inline-flex items-center gap-1.5 text-[11px] text-muted-foreground">
<BellRing
className="size-3.5 text-[var(--brand)]"
aria-hidden="true"
/>
Auto-report on · future completions notify the
main chat
</p>
) : (
<button
type="button"
onClick={() => reportToMain(task)}
disabled={
!onReportToMainChat ||
!!reported[asyncTaskReportKey(task)]
}
title={
onReportToMainChat
? "Send this result back to the main agent"
: "Open the conversation to notify the main agent"
}
className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-[11px] font-medium text-foreground transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
>
<CornerUpLeft
className="size-3.5 text-[var(--brand)]"
aria-hidden="true"
/>
{reported[asyncTaskReportKey(task)]
? "Reported to main chat"
: "Notify main chat"}
</button>
)}
</div>
)}
{(() => {
const running =
normalizeAsyncStatus(task.liveStatus) === "running";
+152 -3
View File
@@ -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<ChatInterfaceProps>(
({ assistant, onShowAgents }) => {
({ assistant, onShowAgents, onNotifyReady }) => {
const [metaOpen, setMetaOpen] = useState<
"tasks" | "files" | "workspace" | null
>(null);
@@ -218,6 +238,85 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
[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<ChatInterfaceProps>(
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<ChatInterfaceProps>(
// 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<string>();
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") {
+20 -1
View File
@@ -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<ChatMessageProps>(
}));
}, []);
// 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 (
<div className="flex w-full justify-center py-1.5">
<span className="inline-flex items-center gap-1.5 rounded-full border border-border bg-muted/40 px-3 py-1 text-xs text-muted-foreground">
<Bell
className="size-3 text-[var(--brand)]"
aria-hidden="true"
/>
Background agent reported back
</span>
</div>
);
}
return (
<div
className={cn(
+9 -2
View File
@@ -6,9 +6,13 @@ import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { WorkspacePanel } from "@/app/components/WorkspacePanel";
import { AgentsPanel } from "@/app/components/AgentsPanel";
import type { MainChatReporter } from "@/lib/asyncAgents";
interface InspectorPanelProps {
onClose: () => 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) {
</div>
{tab === "agents" ? (
<div className="min-h-0 flex-1 overflow-hidden p-3">
<AgentsPanel />
<AgentsPanel onReportToMainChat={onReportToMainChat} />
</div>
) : (
<div className="min-h-0 flex-1 overflow-y-auto p-3">
+32
View File
@@ -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];
}
+16 -2
View File
@@ -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<Assistant | null>(null);
const [isDesktopLayout, setIsDesktopLayout] = useState<boolean | null>(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<MainChatReporter | null>(
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"
>
<InspectorPanel onClose={closeInspector} />
<InspectorPanel
onClose={closeInspector}
onReportToMainChat={notifyMainChat}
/>
</aside>
</div>
)}
@@ -392,6 +402,7 @@ function HomePageInner({
<ChatInterface
assistant={assistant}
onShowAgents={showAgentsInspector}
onNotifyReady={(fn) => setNotifyMainChat(() => fn)}
/>
</ChatProvider>
)}
@@ -407,7 +418,10 @@ function HomePageInner({
minSize={20}
className="relative min-w-[300px]"
>
<InspectorPanel onClose={closeInspector} />
<InspectorPanel
onClose={closeInspector}
onReportToMainChat={notifyMainChat}
/>
</ResizablePanel>
</>
)}
+110
View File
@@ -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).
+152
View File
@@ -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<string, boolean> {
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<string, boolean>;
}
} catch {
// Corrupt/unavailable storage → treat as empty (everything off).
}
return {};
}
function save(map: Record<string, boolean>): 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<string, ReportedState> {
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<string, ReportedState>;
}
} catch {
// Corrupt/unavailable storage → rebuild the baseline when needed.
}
return {};
}
function saveReported(map: Record<string, ReportedState>): 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<string> {
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<string>
): 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);
}