Compare commits

...

6 Commits

Author SHA1 Message Date
Timothy fd4dc1a69a fix: google_sheets JSON parse error before credentials check
Move _get_client() before JSON deserialization so missing-credentials
errors aren't masked by input validation. Wrap json.loads in try/except
for non-JSON string inputs.
2026-03-19 20:13:18 -07:00
Timothy 377cd39c2a chore: lint 2026-03-19 20:07:42 -07:00
Timothy e92caeef24 fix: line too long in google_sheets_tool
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 20:06:31 -07:00
Timothy a995818db2 fix: subagent bubble boundary 2026-03-19 17:57:33 -07:00
Timothy 0772b4d300 feat: better subagent interleave logic 2026-03-19 16:58:34 -07:00
Timothy 684e0d8dc6 fix: no memory consolidation for worker 2026-03-19 16:58:00 -07:00
4 changed files with 444 additions and 311 deletions
+4
View File
@@ -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(
+55 -29
View File
@@ -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" }}>&#10003;</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: