Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fd4dc1a69a | |||
| 377cd39c2a | |||
| e92caeef24 | |||
| a995818db2 | |||
| 0772b4d300 | |||
| 684e0d8dc6 |
@@ -1032,6 +1032,10 @@ class SessionManager:
|
||||
_consolidation_session_dir = queen_dir
|
||||
|
||||
async def _on_compaction(_event) -> None:
|
||||
# Only consolidate on queen compactions — worker and subagent
|
||||
# compactions are frequent and don't warrant a memory update.
|
||||
if getattr(_event, "stream_id", None) != "queen":
|
||||
return
|
||||
from framework.agents.queen.queen_memory import consolidate_queen_memory
|
||||
|
||||
asyncio.create_task(
|
||||
|
||||
@@ -274,10 +274,10 @@ export default function ChatPanel({ messages, onSend, isWaiting, isWorkerWaiting
|
||||
return true;
|
||||
});
|
||||
|
||||
// Group consecutive subagent messages into parallel bubbles.
|
||||
// A message is a "subagent message" if its nodeId contains ":subagent:".
|
||||
// Consecutive runs of subagent messages with 2+ distinct nodeIds become
|
||||
// a ParallelSubagentBubble; runs with only 1 nodeId render normally.
|
||||
// Group subagent messages into parallel bubbles.
|
||||
// A subagent message has nodeId containing ":subagent:".
|
||||
// The run only ends on hard boundaries (user messages, run_dividers)
|
||||
// so interleaved queen/tool/system messages don't fragment the bubble.
|
||||
type RenderItem =
|
||||
| { kind: "message"; msg: ChatMessage }
|
||||
| { kind: "parallel"; groupId: string; groups: SubagentGroup[] };
|
||||
@@ -293,35 +293,61 @@ export default function ChatPanel({ messages, onSend, isWaiting, isWorkerWaiting
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
// Start collecting a consecutive subagent run
|
||||
const runStart = i;
|
||||
while (
|
||||
i < threadMessages.length &&
|
||||
threadMessages[i].nodeId?.includes(":subagent:")
|
||||
) {
|
||||
|
||||
// Start a subagent run. Collect all subagent messages, allowing
|
||||
// non-subagent messages in between (they render as normal items
|
||||
// before the bubble). Only break on hard boundaries.
|
||||
const subagentMsgs: ChatMessage[] = [];
|
||||
const interleaved: { idx: number; msg: ChatMessage }[] = [];
|
||||
const firstId = msg.id;
|
||||
|
||||
while (i < threadMessages.length) {
|
||||
const m = threadMessages[i];
|
||||
const isSa = m.nodeId?.includes(":subagent:");
|
||||
|
||||
if (isSa) {
|
||||
subagentMsgs.push(m);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Hard boundary — stop the run
|
||||
if (m.type === "user" || m.type === "run_divider") break;
|
||||
|
||||
// Worker message from a non-subagent node means the graph has
|
||||
// moved on to the next stage. Close the bubble even if some
|
||||
// subagents are still streaming in the background.
|
||||
if (m.role === "worker" && m.nodeId && !m.nodeId.includes(":subagent:")) break;
|
||||
|
||||
// Soft interruption (queen output, system, tool_status without
|
||||
// nodeId) — render it normally but keep the subagent run going
|
||||
interleaved.push({ idx: items.length + interleaved.length, msg: m });
|
||||
i++;
|
||||
}
|
||||
const runMsgs = threadMessages.slice(runStart, i);
|
||||
// Group by nodeId. With the backend fix, each subagent instance
|
||||
// gets a unique nodeId (e.g. "node:subagent:agent:2" for instance 2).
|
||||
const byNode = new Map<string, ChatMessage[]>();
|
||||
for (const m of runMsgs) {
|
||||
const nid = m.nodeId!;
|
||||
if (!byNode.has(nid)) byNode.set(nid, []);
|
||||
byNode.get(nid)!.push(m);
|
||||
|
||||
// Emit interleaved messages first (before the bubble)
|
||||
for (const { msg: im } of interleaved) {
|
||||
items.push({ kind: "message", msg: im });
|
||||
}
|
||||
// Always fold subagent messages into a consolidated bubble —
|
||||
// even a single subagent gets the folded view with one square.
|
||||
const groups: SubagentGroup[] = [];
|
||||
for (const [nodeId, msgs] of byNode) {
|
||||
groups.push({
|
||||
nodeId,
|
||||
messages: msgs,
|
||||
contextUsage: contextUsage?.[nodeId],
|
||||
});
|
||||
|
||||
// Build the single parallel bubble from all collected subagent msgs
|
||||
if (subagentMsgs.length > 0) {
|
||||
const byNode = new Map<string, ChatMessage[]>();
|
||||
for (const m of subagentMsgs) {
|
||||
const nid = m.nodeId!;
|
||||
if (!byNode.has(nid)) byNode.set(nid, []);
|
||||
byNode.get(nid)!.push(m);
|
||||
}
|
||||
const groups: SubagentGroup[] = [];
|
||||
for (const [nodeId, msgs] of byNode) {
|
||||
groups.push({
|
||||
nodeId,
|
||||
messages: msgs,
|
||||
contextUsage: contextUsage?.[nodeId],
|
||||
});
|
||||
}
|
||||
items.push({ kind: "parallel", groupId: `par-${firstId}`, groups });
|
||||
}
|
||||
const groupId = `par-${runMsgs[0].id}`;
|
||||
items.push({ kind: "parallel", groupId, groups });
|
||||
}
|
||||
return items;
|
||||
}, [threadMessages, contextUsage]);
|
||||
|
||||
@@ -3,322 +3,411 @@ import { ChevronDown, ChevronUp, Cpu } from "lucide-react";
|
||||
import type { ChatMessage, ContextUsageEntry } from "@/components/ChatPanel";
|
||||
import MarkdownContent from "@/components/MarkdownContent";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const workerColor = "hsl(220,60%,55%)";
|
||||
|
||||
/** Palette for distinguishing individual subagent squares */
|
||||
const SUBAGENT_COLORS = [
|
||||
"hsl(220,60%,55%)", // blue
|
||||
"hsl(260,50%,55%)", // purple
|
||||
"hsl(180,50%,45%)", // teal
|
||||
"hsl(30,70%,50%)", // orange
|
||||
"hsl(340,55%,50%)", // rose
|
||||
"hsl(150,45%,45%)", // green
|
||||
"hsl(45,80%,50%)", // amber
|
||||
"hsl(290,45%,55%)", // violet
|
||||
"hsl(220,60%,55%)",
|
||||
"hsl(260,50%,55%)",
|
||||
"hsl(180,50%,45%)",
|
||||
"hsl(30,70%,50%)",
|
||||
"hsl(340,55%,50%)",
|
||||
"hsl(150,45%,45%)",
|
||||
"hsl(45,80%,50%)",
|
||||
"hsl(290,45%,55%)",
|
||||
];
|
||||
|
||||
function colorForIndex(i: number): string {
|
||||
return SUBAGENT_COLORS[i % SUBAGENT_COLORS.length];
|
||||
}
|
||||
|
||||
/** Extract a short display label from a subagent node_id like "parentNode:subagent:myAgent" */
|
||||
function subagentLabel(nodeId: string): string {
|
||||
const parts = nodeId.split(":subagent:");
|
||||
if (parts.length >= 2) {
|
||||
// Title-case the agent ID portion
|
||||
return parts[1]
|
||||
.replace(/[_-]/g, " ")
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
.trim();
|
||||
}
|
||||
return nodeId
|
||||
const raw = parts.length >= 2 ? parts[1] : nodeId;
|
||||
return raw
|
||||
.replace(/:\d+$/, "") // strip instance suffix like ":3"
|
||||
.replace(/[_-]/g, " ")
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
.trim();
|
||||
}
|
||||
|
||||
export interface SubagentGroup {
|
||||
/** Unique node_id for this subagent (includes instance suffix for duplicates) */
|
||||
nodeId: string;
|
||||
/** All chat messages from this subagent (stream snapshots, tool pills, etc.) */
|
||||
messages: ChatMessage[];
|
||||
/** Context window usage for this subagent's event loop */
|
||||
contextUsage?: ContextUsageEntry;
|
||||
}
|
||||
|
||||
interface ParallelSubagentBubbleProps {
|
||||
/** Grouped subagent data — one entry per parallel subagent */
|
||||
groups: SubagentGroup[];
|
||||
/** ID for the overall group — used for expand/collapse persistence */
|
||||
groupId: string;
|
||||
}
|
||||
|
||||
/** A single subagent square in the folded view */
|
||||
function SubagentSquare({
|
||||
group,
|
||||
index,
|
||||
isLatest,
|
||||
label,
|
||||
}: {
|
||||
group: SubagentGroup;
|
||||
index: number;
|
||||
isLatest: boolean;
|
||||
/** Display label — may include instance number for duplicates */
|
||||
label: string;
|
||||
}) {
|
||||
const color = colorForIndex(index);
|
||||
const fillPct = group.contextUsage?.usagePct ?? 0;
|
||||
const msgCount = group.messages.filter(
|
||||
(m) => m.type !== "tool_status" && m.role === "worker"
|
||||
).length;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative flex flex-col items-center gap-1"
|
||||
title={`${label}\n${msgCount} message${msgCount !== 1 ? "s" : ""}\nContext: ${fillPct}%`}
|
||||
>
|
||||
{/* Message count badge */}
|
||||
<span
|
||||
className="text-[10px] font-semibold tabular-nums leading-none"
|
||||
style={{ color }}
|
||||
>
|
||||
{msgCount}
|
||||
</span>
|
||||
|
||||
{/* Color-filled square — fill level = context window usage */}
|
||||
<div
|
||||
className={`relative w-10 h-10 rounded-md overflow-hidden transition-all ${
|
||||
isLatest ? "ring-2 ring-offset-1 ring-offset-background" : ""
|
||||
}`}
|
||||
style={{
|
||||
backgroundColor: `${color}12`,
|
||||
...(isLatest ? { ringColor: color } : {}),
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute bottom-0 left-0 right-0 transition-all duration-500 ease-out"
|
||||
style={{
|
||||
height: `${Math.min(fillPct, 100)}%`,
|
||||
backgroundColor: color,
|
||||
opacity: 0.55,
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className="absolute inset-0 flex items-center justify-center text-[10px] font-semibold tabular-nums"
|
||||
style={{ color }}
|
||||
>
|
||||
{fillPct}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Subagent label */}
|
||||
<span
|
||||
className="text-[9px] text-muted-foreground/70 max-w-[56px] truncate text-center leading-tight"
|
||||
title={label}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Return the last element of an array (compat with ES2021 targets). */
|
||||
function last<T>(arr: T[]): T | undefined {
|
||||
return arr[arr.length - 1];
|
||||
}
|
||||
|
||||
/** Stable unique key for a group. */
|
||||
function groupKey(g: SubagentGroup): string {
|
||||
return g.nodeId;
|
||||
export interface SubagentGroup {
|
||||
nodeId: string;
|
||||
messages: ChatMessage[];
|
||||
contextUsage?: ContextUsageEntry;
|
||||
}
|
||||
|
||||
const ParallelSubagentBubble = memo(function ParallelSubagentBubble({
|
||||
groups,
|
||||
}: ParallelSubagentBubbleProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const previewRef = useRef<HTMLDivElement>(null);
|
||||
interface ParallelSubagentBubbleProps {
|
||||
groups: SubagentGroup[];
|
||||
groupId: string;
|
||||
}
|
||||
|
||||
// Compute display labels — append instance number when multiple groups
|
||||
// share the same nodeId (e.g. 3× browser-researcher → #1, #2, #3).
|
||||
const labels: string[] = (() => {
|
||||
const countByNode = new Map<string, number>();
|
||||
for (const g of groups) {
|
||||
countByNode.set(g.nodeId, (countByNode.get(g.nodeId) ?? 0) + 1);
|
||||
// ---------------------------------------------------------------------------
|
||||
// Thermometer — vertical context gauge on right edge of each pane
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool overlay — shown when a tool_status message is active (not all done)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ToolOverlay({
|
||||
toolName,
|
||||
color,
|
||||
visible,
|
||||
}: {
|
||||
toolName: string;
|
||||
color: string;
|
||||
visible: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="absolute inset-0 top-[22px] flex items-center justify-center transition-opacity duration-200 z-10"
|
||||
style={{
|
||||
background: "rgba(8,8,14,0.82)",
|
||||
opacity: visible ? 1 : 0,
|
||||
pointerEvents: visible ? "auto" : "none",
|
||||
}}
|
||||
>
|
||||
<div className="text-center px-3 py-2 rounded-md border" style={{ borderColor: `${color}40` }}>
|
||||
<div className="text-[10px] font-medium" style={{ color }}>
|
||||
{toolName}
|
||||
</div>
|
||||
<div className="text-[11px] mt-0.5" style={{ color }}>
|
||||
{visible ? "..." : "\u2713"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Single tmux pane
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function MuxPane({
|
||||
group,
|
||||
index,
|
||||
label,
|
||||
isFocused,
|
||||
isZoomed,
|
||||
onClickTitle,
|
||||
}: {
|
||||
group: SubagentGroup;
|
||||
index: number;
|
||||
label: string;
|
||||
isFocused: boolean;
|
||||
isZoomed: boolean;
|
||||
onClickTitle: () => void;
|
||||
}) {
|
||||
const bodyRef = useRef<HTMLDivElement>(null);
|
||||
const stickRef = useRef(true);
|
||||
const color = colorForIndex(index);
|
||||
const pct = group.contextUsage?.usagePct ?? 0;
|
||||
|
||||
const streamMsgs = group.messages.filter((m) => m.type !== "tool_status");
|
||||
const latestContent = last(streamMsgs)?.content ?? "";
|
||||
const msgCount = streamMsgs.length;
|
||||
|
||||
// Detect active tool and finished state from latest tool_status
|
||||
const latestTool = last(
|
||||
group.messages.filter((m) => m.type === "tool_status")
|
||||
);
|
||||
let activeToolName = "";
|
||||
let toolRunning = false;
|
||||
let isFinished = false;
|
||||
if (latestTool) {
|
||||
try {
|
||||
const parsed = JSON.parse(latestTool.content);
|
||||
const tools: { name: string; done: boolean }[] = parsed.tools || [];
|
||||
const allDone = parsed.allDone as boolean | undefined;
|
||||
const running = tools.find((t) => !t.done);
|
||||
if (running) {
|
||||
activeToolName = running.name;
|
||||
toolRunning = true;
|
||||
}
|
||||
// Finished when all tools are done and one of them is set_output
|
||||
// or report_to_parent (terminal tool calls)
|
||||
if (allDone && tools.length > 0) {
|
||||
const hasTerminal = tools.some(
|
||||
(t) =>
|
||||
t.done &&
|
||||
(t.name === "set_output" || t.name === "report_to_parent")
|
||||
);
|
||||
if (hasTerminal) isFinished = true;
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
const indexByNode = new Map<string, number>();
|
||||
return groups.map((g) => {
|
||||
const base = subagentLabel(g.nodeId);
|
||||
if ((countByNode.get(g.nodeId) ?? 1) <= 1) return base;
|
||||
const idx = (indexByNode.get(g.nodeId) ?? 0) + 1;
|
||||
indexByNode.set(g.nodeId, idx);
|
||||
return `${base} #${idx}`;
|
||||
});
|
||||
})();
|
||||
}
|
||||
|
||||
// Find the subagent that most recently received a stream update
|
||||
const latestIdx = groups.reduce<number>((bestIdx, g, i) => {
|
||||
const filtered = g.messages.filter((m) => m.type !== "tool_status");
|
||||
const lastMsg = last(filtered);
|
||||
if (!lastMsg) return bestIdx;
|
||||
if (bestIdx < 0) return i;
|
||||
const bestFiltered = groups[bestIdx].messages.filter((m) => m.type !== "tool_status");
|
||||
const bestLast = last(bestFiltered);
|
||||
if (!bestLast) return i;
|
||||
return (lastMsg.createdAt ?? 0) >= (bestLast.createdAt ?? 0) ? i : bestIdx;
|
||||
}, -1);
|
||||
|
||||
const latestGroup = latestIdx >= 0 ? groups[latestIdx] : null;
|
||||
|
||||
const latestContent = latestGroup
|
||||
? last(latestGroup.messages.filter((m) => m.type !== "tool_status"))?.content ?? ""
|
||||
: "";
|
||||
|
||||
const latestLabel = latestIdx >= 0 ? labels[latestIdx] : "";
|
||||
|
||||
// Auto-scroll the preview window to bottom when content changes
|
||||
// Auto-scroll
|
||||
useEffect(() => {
|
||||
if (previewRef.current) {
|
||||
previewRef.current.scrollTop = previewRef.current.scrollHeight;
|
||||
if (stickRef.current && bodyRef.current) {
|
||||
bodyRef.current.scrollTop = bodyRef.current.scrollHeight;
|
||||
}
|
||||
}, [latestContent]);
|
||||
|
||||
if (groups.length === 0) return null;
|
||||
const handleScroll = () => {
|
||||
const el = bodyRef.current;
|
||||
if (!el) return;
|
||||
stickRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 30;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex gap-3">
|
||||
{/* Left icon */}
|
||||
<div
|
||||
className="flex flex-col min-h-0 overflow-hidden relative transition-all duration-200"
|
||||
style={{
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: isFocused && !isFinished ? `${color}60` : "transparent",
|
||||
opacity: isFinished ? 0.4 : isFocused || isZoomed ? 1 : 0.55,
|
||||
...(isZoomed
|
||||
? { gridColumn: "1 / -1", gridRow: "1 / -1", zIndex: 10 }
|
||||
: {}),
|
||||
}}
|
||||
>
|
||||
{/* Title bar */}
|
||||
<div
|
||||
className="flex-shrink-0 w-7 h-7 rounded-xl flex items-center justify-center mt-1"
|
||||
style={{
|
||||
backgroundColor: `${workerColor}18`,
|
||||
border: `1.5px solid ${workerColor}35`,
|
||||
}}
|
||||
className="flex items-center gap-1.5 px-2 py-[3px] flex-shrink-0 cursor-pointer select-none"
|
||||
style={{ background: "#0e0e16", borderBottom: "1px solid #1a1a2a" }}
|
||||
onClick={onClickTitle}
|
||||
>
|
||||
<Cpu className="w-3.5 h-3.5" style={{ color: workerColor }} />
|
||||
{isFinished ? (
|
||||
<span className="text-[8px] flex-shrink-0 leading-none" style={{ color: "#4a4" }}>✓</span>
|
||||
) : (
|
||||
<div
|
||||
className="w-[6px] h-[6px] rounded-full flex-shrink-0"
|
||||
style={{ background: color }}
|
||||
/>
|
||||
)}
|
||||
<span className="text-[9px] flex-shrink-0" style={{ color: isFinished ? "#555" : color }}>
|
||||
{label}
|
||||
</span>
|
||||
<span className="flex-1" />
|
||||
<span className="text-[8px] tabular-nums flex-shrink-0" style={{ color: "#555" }}>
|
||||
{msgCount}
|
||||
</span>
|
||||
<div
|
||||
className="w-[36px] h-[3px] rounded-full overflow-hidden flex-shrink-0"
|
||||
style={{ background: "#1a1a2a" }}
|
||||
>
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-500"
|
||||
style={{
|
||||
width: `${Math.min(pct, 100)}%`,
|
||||
backgroundColor:
|
||||
pct >= 80 ? "hsl(0,65%,55%)" : pct >= 50 ? "hsl(35,90%,55%)" : color,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[8px] tabular-nums flex-shrink-0" style={{ color: "#555" }}>
|
||||
{pct}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0 max-w-[85%]">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-medium text-xs" style={{ color: workerColor }}>
|
||||
{groups.length === 1 ? "Sub-agent" : "Parallel Agents"}
|
||||
</span>
|
||||
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-md bg-muted text-muted-foreground">
|
||||
{groups.length} running
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="ml-auto text-muted-foreground/60 hover:text-muted-foreground transition-colors p-0.5 rounded"
|
||||
title={expanded ? "Collapse" : "Expand"}
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronUp className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<ChevronDown className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{expanded ? (
|
||||
/* ── Expanded view: show individual subagent messages ── */
|
||||
<div className="space-y-3 rounded-2xl rounded-tl-md border border-border/30 bg-muted/20 p-3">
|
||||
{groups.map((group, gi) => {
|
||||
const color = colorForIndex(gi);
|
||||
const streamMsgs = group.messages.filter(
|
||||
(m) => m.type !== "tool_status"
|
||||
);
|
||||
const lastContent = last(streamMsgs)?.content ?? "";
|
||||
return (
|
||||
<div key={groupKey(group)} className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Cpu
|
||||
className="w-3 h-3 flex-shrink-0"
|
||||
style={{ color }}
|
||||
/>
|
||||
<span
|
||||
className="text-xs font-medium"
|
||||
style={{ color }}
|
||||
>
|
||||
{labels[gi]}
|
||||
</span>
|
||||
{group.contextUsage && (
|
||||
<span className="text-[10px] text-muted-foreground/60 tabular-nums">
|
||||
{group.contextUsage.usagePct}% ctx
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{lastContent && (
|
||||
<div className="text-sm leading-relaxed rounded-xl bg-muted/40 px-3 py-2">
|
||||
<MarkdownContent content={lastContent} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{/* Body */}
|
||||
<div
|
||||
ref={bodyRef}
|
||||
onScroll={handleScroll}
|
||||
className="flex-1 min-h-0 overflow-y-auto px-2 py-1 text-[10px] leading-[1.7]"
|
||||
style={{ background: "#08080e", color: "#555", fontFamily: "monospace" }}
|
||||
>
|
||||
{latestContent ? (
|
||||
<div style={{ color: "#ccc" }}>
|
||||
<MarkdownContent content={latestContent} />
|
||||
</div>
|
||||
) : (
|
||||
/* ── Folded view: preview window + squares ── */
|
||||
<div className="rounded-2xl rounded-tl-md border border-border/30 bg-muted/20 overflow-hidden">
|
||||
{/* Preview window: latest stream content */}
|
||||
<div className="border-b border-border/20 px-3 py-2">
|
||||
{latestContent ? (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Cpu
|
||||
className="w-2.5 h-2.5 flex-shrink-0"
|
||||
style={{
|
||||
color: latestIdx >= 0
|
||||
? colorForIndex(latestIdx)
|
||||
: workerColor,
|
||||
}}
|
||||
/>
|
||||
<span className="text-[10px] text-muted-foreground/70 font-medium">
|
||||
{latestLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
ref={previewRef}
|
||||
className="text-sm leading-relaxed max-h-[120px] overflow-y-auto"
|
||||
>
|
||||
<MarkdownContent content={latestContent} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5 py-1">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground animate-bounce" style={{ animationDelay: "0ms" }} />
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground animate-bounce" style={{ animationDelay: "150ms" }} />
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground animate-bounce" style={{ animationDelay: "300ms" }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span style={{ color: "#333" }}>waiting...</span>
|
||||
)}
|
||||
{/* Blinking cursor — hidden when finished */}
|
||||
{!isFinished && (
|
||||
<span
|
||||
className="inline-block w-[6px] h-[11px] align-middle ml-0.5"
|
||||
style={{
|
||||
background: color,
|
||||
animation: "cursorBlink 1s step-end infinite",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Subagent squares row */}
|
||||
<div className="flex items-start gap-3 px-3 py-2.5 flex-wrap">
|
||||
{/* Tool overlay */}
|
||||
<ToolOverlay
|
||||
toolName={activeToolName}
|
||||
color={color}
|
||||
visible={toolRunning}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ParallelSubagentBubble = memo(
|
||||
function ParallelSubagentBubble({ groups }: ParallelSubagentBubbleProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [zoomedIdx, setZoomedIdx] = useState<number | null>(null);
|
||||
|
||||
// Labels with instance numbers for duplicates
|
||||
const labels: string[] = (() => {
|
||||
const countByBase = new Map<string, number>();
|
||||
const bases = groups.map((g) => subagentLabel(g.nodeId));
|
||||
for (const b of bases)
|
||||
countByBase.set(b, (countByBase.get(b) ?? 0) + 1);
|
||||
const idxByBase = new Map<string, number>();
|
||||
return bases.map((b) => {
|
||||
if ((countByBase.get(b) ?? 1) <= 1) return b;
|
||||
const idx = (idxByBase.get(b) ?? 0) + 1;
|
||||
idxByBase.set(b, idx);
|
||||
return `${b} #${idx}`;
|
||||
});
|
||||
})();
|
||||
|
||||
// Latest-active pane
|
||||
const latestIdx = groups.reduce<number>((best, g, i) => {
|
||||
const filtered = g.messages.filter((m) => m.type !== "tool_status");
|
||||
const lm = last(filtered);
|
||||
if (!lm) return best;
|
||||
if (best < 0) return i;
|
||||
const bm = last(
|
||||
groups[best].messages.filter((m) => m.type !== "tool_status")
|
||||
);
|
||||
if (!bm) return i;
|
||||
return (lm.createdAt ?? 0) >= (bm.createdAt ?? 0) ? i : best;
|
||||
}, -1);
|
||||
|
||||
// Per-group finished detection (same logic as MuxPane)
|
||||
const finishedFlags = groups.map((g) => {
|
||||
const lt = last(g.messages.filter((m) => m.type === "tool_status"));
|
||||
if (!lt) return false;
|
||||
try {
|
||||
const p = JSON.parse(lt.content);
|
||||
const tools: { name: string; done: boolean }[] = p.tools || [];
|
||||
if (!p.allDone || tools.length === 0) return false;
|
||||
return tools.some(
|
||||
(t) => t.done && (t.name === "set_output" || t.name === "report_to_parent")
|
||||
);
|
||||
} catch { return false; }
|
||||
});
|
||||
const activeCount = finishedFlags.filter((f) => !f).length;
|
||||
|
||||
if (groups.length === 0) return null;
|
||||
|
||||
// Grid sizing: 2 columns, auto rows capped at a fixed height
|
||||
const rows = Math.ceil(groups.length / 2);
|
||||
const gridHeight = expanded
|
||||
? Math.min(rows * 200, 480)
|
||||
: Math.min(rows * 100, 240);
|
||||
|
||||
return (
|
||||
<div className="flex gap-3">
|
||||
{/* Left icon */}
|
||||
<div
|
||||
className="flex-shrink-0 w-7 h-7 rounded-xl flex items-center justify-center mt-1"
|
||||
style={{
|
||||
backgroundColor: `${workerColor}18`,
|
||||
border: `1.5px solid ${workerColor}35`,
|
||||
}}
|
||||
>
|
||||
<Cpu className="w-3.5 h-3.5" style={{ color: workerColor }} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0 max-w-[90%]">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-medium text-xs" style={{ color: workerColor }}>
|
||||
{groups.length === 1 ? "Sub-agent" : "Parallel Agents"}
|
||||
</span>
|
||||
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-md bg-muted text-muted-foreground">
|
||||
{activeCount > 0 ? `${activeCount} running` : `${groups.length} done`}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
setExpanded((v) => !v);
|
||||
setZoomedIdx(null);
|
||||
}}
|
||||
className="ml-auto text-muted-foreground/60 hover:text-muted-foreground transition-colors p-0.5 rounded"
|
||||
title={expanded ? "Collapse" : "Expand"}
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronUp className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<ChevronDown className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mux frame */}
|
||||
<div
|
||||
className="rounded-lg overflow-hidden"
|
||||
style={{
|
||||
border: "2px solid #1a1a2a",
|
||||
background: "#08080e",
|
||||
}}
|
||||
>
|
||||
{/* Grid */}
|
||||
<div
|
||||
className="grid gap-px"
|
||||
style={{
|
||||
gridTemplateColumns:
|
||||
groups.length === 1 ? "1fr" : "1fr 1fr",
|
||||
gridTemplateRows: `repeat(${rows}, 1fr)`,
|
||||
height: gridHeight,
|
||||
background: "#111",
|
||||
}}
|
||||
>
|
||||
{groups.map((group, i) => (
|
||||
<SubagentSquare
|
||||
key={groupKey(group)}
|
||||
<MuxPane
|
||||
key={group.nodeId}
|
||||
group={group}
|
||||
index={i}
|
||||
label={labels[i]}
|
||||
isLatest={latestIdx === i}
|
||||
isFocused={latestIdx === i}
|
||||
isZoomed={zoomedIdx === i}
|
||||
onClickTitle={() =>
|
||||
setZoomedIdx(zoomedIdx === i ? null : i)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
(prev, next) =>
|
||||
prev.groupId === next.groupId &&
|
||||
prev.groups.length === next.groups.length &&
|
||||
prev.groups.every(
|
||||
(g, i) =>
|
||||
g.nodeId === next.groups[i].nodeId &&
|
||||
g.messages.length === next.groups[i].messages.length &&
|
||||
last(g.messages)?.content === last(next.groups[i].messages)?.content &&
|
||||
g.contextUsage?.usagePct === next.groups[i].contextUsage?.usagePct
|
||||
));
|
||||
);
|
||||
},
|
||||
(prev, next) =>
|
||||
prev.groupId === next.groupId &&
|
||||
prev.groups.length === next.groups.length &&
|
||||
prev.groups.every(
|
||||
(g, i) =>
|
||||
g.nodeId === next.groups[i].nodeId &&
|
||||
g.messages.length === next.groups[i].messages.length &&
|
||||
last(g.messages)?.content === last(next.groups[i].messages)?.content &&
|
||||
g.contextUsage?.usagePct === next.groups[i].contextUsage?.usagePct
|
||||
)
|
||||
);
|
||||
|
||||
export default ParallelSubagentBubble;
|
||||
|
||||
// Injected as a global style (keyframes can't be inline)
|
||||
if (typeof document !== "undefined") {
|
||||
const id = "parallel-subagent-keyframes";
|
||||
if (!document.getElementById(id)) {
|
||||
const style = document.createElement("style");
|
||||
style.id = id;
|
||||
style.textContent = `
|
||||
@keyframes cursorBlink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } }
|
||||
@keyframes thermoPulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -412,15 +412,22 @@ def register_tools(
|
||||
Returns:
|
||||
Dict with update result or error
|
||||
"""
|
||||
# Accept stringified JSON and deserialize
|
||||
import json
|
||||
if isinstance(values, str):
|
||||
values = json.loads(values)
|
||||
if not isinstance(values, list):
|
||||
return {"error": f"values must be a 2D list or JSON string, got {type(values).__name__}"}
|
||||
# Credentials check first so missing-creds errors aren't masked
|
||||
client = _get_client()
|
||||
if isinstance(client, dict):
|
||||
return client
|
||||
# Accept stringified JSON and deserialize
|
||||
import json
|
||||
|
||||
if isinstance(values, str):
|
||||
try:
|
||||
values = json.loads(values)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return {"error": "values is not valid JSON"}
|
||||
if not isinstance(values, list):
|
||||
return {
|
||||
"error": f"values must be a 2D list or JSON string, got {type(values).__name__}"
|
||||
}
|
||||
try:
|
||||
return client.update_values(spreadsheet_id, range_name, values, value_input_option)
|
||||
except httpx.TimeoutException:
|
||||
@@ -453,15 +460,22 @@ def register_tools(
|
||||
Returns:
|
||||
Dict with append result or error
|
||||
"""
|
||||
# Accept stringified JSON and deserialize
|
||||
import json
|
||||
if isinstance(values, str):
|
||||
values = json.loads(values)
|
||||
if not isinstance(values, list):
|
||||
return {"error": f"values must be a 2D list or JSON string, got {type(values).__name__}"}
|
||||
# Credentials check first so missing-creds errors aren't masked
|
||||
client = _get_client()
|
||||
if isinstance(client, dict):
|
||||
return client
|
||||
# Accept stringified JSON and deserialize
|
||||
import json
|
||||
|
||||
if isinstance(values, str):
|
||||
try:
|
||||
values = json.loads(values)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return {"error": "values is not valid JSON"}
|
||||
if not isinstance(values, list):
|
||||
return {
|
||||
"error": f"values must be a 2D list or JSON string, got {type(values).__name__}"
|
||||
}
|
||||
try:
|
||||
return client.append_values(spreadsheet_id, range_name, values, value_input_option)
|
||||
except httpx.TimeoutException:
|
||||
|
||||
Reference in New Issue
Block a user