feat: implement auto-reporting for async tasks in AgentsPanel and ChatInterface
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user