fix: side panel
This commit is contained in:
@@ -1,551 +0,0 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { X, Cpu, Zap, Clock, RotateCcw, CheckCircle2, AlertCircle, Loader2, ChevronDown, ChevronRight, Copy, Check, Terminal, Wrench, BookOpen, GitBranch, Bot } from "lucide-react";
|
||||
import type { GraphNode, NodeStatus } from "./graph-types";
|
||||
import type { NodeSpec, ToolInfo, NodeCriteria } from "../api/types";
|
||||
import { workersApi } from "../api/workers";
|
||||
import { logsApi } from "../api/logs";
|
||||
import MarkdownContent from "./MarkdownContent";
|
||||
|
||||
interface Tool {
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
credentials?: ToolCredential[];
|
||||
}
|
||||
|
||||
interface ToolCredential {
|
||||
key: string;
|
||||
label: string;
|
||||
connected: boolean;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
export interface SubagentReport {
|
||||
subagent_id: string;
|
||||
message: string;
|
||||
data?: Record<string, unknown>;
|
||||
timestamp: string;
|
||||
status?: "running" | "complete" | "error";
|
||||
}
|
||||
|
||||
interface ContextUsage {
|
||||
usagePct: number;
|
||||
messageCount: number;
|
||||
estimatedTokens: number;
|
||||
maxTokens: number;
|
||||
}
|
||||
|
||||
interface NodeDetailPanelProps {
|
||||
node: GraphNode | null;
|
||||
nodeSpec?: NodeSpec | null;
|
||||
allNodeSpecs?: NodeSpec[];
|
||||
subagentReports?: SubagentReport[];
|
||||
sessionId?: string;
|
||||
colonyId?: string;
|
||||
workerSessionId?: string | null;
|
||||
nodeLogs?: string[];
|
||||
actionPlan?: string;
|
||||
contextUsage?: ContextUsage;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const statusConfig: Record<NodeStatus, { label: string; color: string; Icon: React.FC<{ className?: string }> }> = {
|
||||
running: { label: "Running", color: "hsl(45,95%,58%)", Icon: ({ className }) => <Loader2 className={`${className} animate-spin`} /> },
|
||||
looping: { label: "Looping", color: "hsl(38,90%,55%)", Icon: ({ className }) => <RotateCcw className={`${className} animate-spin`} style={{ animationDuration: "2s" }} /> },
|
||||
complete: { label: "Complete", color: "hsl(43,70%,45%)", Icon: ({ className }) => <CheckCircle2 className={className} /> },
|
||||
pending: { label: "Pending", color: "hsl(220,15%,45%)", Icon: ({ className }) => <Clock className={className} /> },
|
||||
error: { label: "Error", color: "hsl(0,65%,55%)", Icon: ({ className }) => <AlertCircle className={className} /> },
|
||||
};
|
||||
|
||||
function formatNodeId(id: string): string {
|
||||
return id.split("-").map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
|
||||
}
|
||||
|
||||
function CredentialRow({ cred }: { cred: ToolCredential }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between px-3 py-2 rounded-lg bg-background/60 border border-border/30 mt-1.5">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${cred.connected ? "bg-primary" : "bg-muted-foreground/40"}`} />
|
||||
<span className="text-[11px] text-muted-foreground font-medium truncate">{cred.label}</span>
|
||||
</div>
|
||||
{cred.connected ? (
|
||||
<span className="text-[10px] text-primary/80 font-medium flex-shrink-0 ml-2">Connected</span>
|
||||
) : (
|
||||
<button className="text-[10px] px-2 py-0.5 rounded-md bg-primary/15 text-primary border border-primary/25 font-semibold hover:bg-primary/25 transition-colors flex-shrink-0 ml-2">
|
||||
Connect
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolRow({ tool }: { tool: Tool }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const hasCreds = tool.credentials && tool.credentials.length > 0;
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border/20 overflow-hidden">
|
||||
<button
|
||||
onClick={() => hasCreds && setExpanded(v => !v)}
|
||||
className={`w-full flex items-start gap-3 p-3 bg-muted/30 hover:bg-muted/50 transition-colors text-left ${!hasCreds ? "cursor-default" : ""}`}
|
||||
>
|
||||
<span className="text-base leading-none mt-0.5 flex-shrink-0">{tool.icon}</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-medium text-foreground">{tool.name}</p>
|
||||
<p className="text-[11px] text-muted-foreground mt-0.5 leading-relaxed">{tool.description}</p>
|
||||
</div>
|
||||
{hasCreds && (
|
||||
<span className="flex-shrink-0 mt-0.5">
|
||||
{expanded
|
||||
? <ChevronDown className="w-3 h-3 text-muted-foreground" />
|
||||
: <ChevronRight className="w-3 h-3 text-muted-foreground" />
|
||||
}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{expanded && hasCreds && (
|
||||
<div className="px-3 pb-3 bg-muted/20 border-t border-border/15">
|
||||
<p className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider mt-2 mb-1">Credentials</p>
|
||||
{tool.credentials!.map(cred => (
|
||||
<CredentialRow key={cred.key} cred={cred} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LogsTab({ nodeId, isActive: _isActive, sessionId, colonyId, workerSessionId, nodeLogs }: { nodeId: string; isActive: boolean; sessionId?: string; colonyId?: string; workerSessionId?: string | null; nodeLogs?: string[] }) {
|
||||
const [historicalLines, setHistoricalLines] = useState<string[]>([]);
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Fetch historical logs when session is available (post-execution viewing)
|
||||
useEffect(() => {
|
||||
if (sessionId && colonyId && workerSessionId) {
|
||||
logsApi.nodeLogs(sessionId, colonyId, nodeId, workerSessionId)
|
||||
.then(r => {
|
||||
const realLines: string[] = [];
|
||||
if (r.details) {
|
||||
for (const d of r.details) {
|
||||
realLines.push(`[LOG] ${d.node_name} — ${d.success ? "SUCCESS" : "FAILED"}${d.error ? ` (${d.error})` : ""} — ${d.total_steps} steps`);
|
||||
}
|
||||
}
|
||||
if (r.tool_logs) {
|
||||
for (const s of r.tool_logs) {
|
||||
realLines.push(`[STEP ${s.step_index}] ${s.llm_text.slice(0, 120)}${s.llm_text.length > 120 ? "..." : ""}`);
|
||||
}
|
||||
}
|
||||
if (realLines.length > 0) {
|
||||
setHistoricalLines(realLines);
|
||||
}
|
||||
})
|
||||
.catch(() => { /* keep fallback on error */ });
|
||||
}
|
||||
}, [sessionId, colonyId, nodeId, workerSessionId]);
|
||||
|
||||
// Resolve which lines to display: live SSE logs > historical > default
|
||||
const lines = (nodeLogs && nodeLogs.length > 0)
|
||||
? nodeLogs
|
||||
: historicalLines.length > 0
|
||||
? historicalLines
|
||||
: ["[--:--:--] INFO Awaiting execution..."];
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [lines]);
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-auto bg-background/80 rounded-xl border border-border/20 font-mono text-[10.5px] leading-relaxed p-3">
|
||||
{lines.map((line, i) => {
|
||||
const isWarn = line.includes(" WARN ");
|
||||
const isErr = line.includes(" ERROR ");
|
||||
const isDebug = line.includes(" DEBUG ");
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={isErr ? "text-red-400" : isWarn ? "text-yellow-400/80" : isDebug ? "text-muted-foreground/50" : "text-green-400/70"}
|
||||
>
|
||||
{line}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SystemPromptTab({ systemPrompt }: { systemPrompt?: string }) {
|
||||
const prompt = systemPrompt || "";
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(prompt);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
};
|
||||
|
||||
if (!prompt) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<p className="text-xs text-muted-foreground/60 italic text-center">No system prompt configured</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-auto flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">System Prompt</p>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="flex items-center gap-1 text-[10px] text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{copied ? <Check className="w-3 h-3 text-primary" /> : <Copy className="w-3 h-3" />}
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
readOnly
|
||||
value={prompt}
|
||||
className="flex-1 min-h-[240px] w-full rounded-xl bg-muted/30 border border-border/20 text-[11px] text-muted-foreground leading-relaxed p-3 font-mono resize-none focus:outline-none focus:border-border/40"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SubagentStatusBadge({ status }: { status?: "running" | "complete" | "error" }) {
|
||||
if (!status) return null;
|
||||
if (status === "running") {
|
||||
return (
|
||||
<span className="ml-auto flex items-center gap-1 text-[10px] font-medium flex-shrink-0" style={{ color: "hsl(45,95%,58%)" }}>
|
||||
<span className="relative flex h-1.5 w-1.5">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full opacity-75" style={{ backgroundColor: "hsl(45,95%,58%)" }} />
|
||||
<span className="relative inline-flex rounded-full h-1.5 w-1.5" style={{ backgroundColor: "hsl(45,95%,58%)" }} />
|
||||
</span>
|
||||
Running
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status === "complete") {
|
||||
return (
|
||||
<span className="ml-auto flex items-center gap-1 text-[10px] font-medium flex-shrink-0" style={{ color: "hsl(43,70%,45%)" }}>
|
||||
<CheckCircle2 className="w-3 h-3" />
|
||||
Complete
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="ml-auto flex items-center gap-1 text-[10px] font-medium flex-shrink-0" style={{ color: "hsl(0,65%,55%)" }}>
|
||||
<AlertCircle className="w-3 h-3" />
|
||||
Failed
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function SubagentsTab({ subAgentIds, allNodeSpecs, subagentReports }: { subAgentIds: string[]; allNodeSpecs: NodeSpec[]; subagentReports: SubagentReport[] }) {
|
||||
if (subAgentIds.length === 0) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<p className="text-xs text-muted-foreground/60 italic text-center">No subagents assigned to this node.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider mb-1">Sub-agents ({subAgentIds.length})</p>
|
||||
{subAgentIds.map(saId => {
|
||||
const spec = allNodeSpecs.find(n => n.id === saId);
|
||||
const reports = subagentReports.filter(r => r.subagent_id === saId);
|
||||
// Derive status from latest report that has a status field
|
||||
const latestStatus = [...reports].reverse().find(r => r.status)?.status;
|
||||
// Progress messages are reports without a status field (from report_to_parent)
|
||||
const progressReports = reports.filter(r => !r.status);
|
||||
|
||||
return (
|
||||
<div key={saId} className="rounded-xl border border-border/20 overflow-hidden">
|
||||
<div className="p-3 bg-muted/30">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Bot className="w-3.5 h-3.5 text-primary/70 flex-shrink-0" />
|
||||
<span className="text-xs font-medium text-foreground truncate">{spec?.name || saId}</span>
|
||||
<SubagentStatusBadge status={latestStatus} />
|
||||
</div>
|
||||
{spec?.description && (
|
||||
<p className="text-[11px] text-muted-foreground leading-relaxed mt-1">{spec.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Static info: tools + output keys */}
|
||||
<div className="px-3 py-2 border-t border-border/15 bg-muted/15">
|
||||
{spec?.tools && spec.tools.length > 0 && (
|
||||
<div className="mb-1.5">
|
||||
<span className="text-[10px] text-muted-foreground font-medium">Tools: </span>
|
||||
<span className="text-[10px] text-foreground/70">{spec.tools.join(", ")}</span>
|
||||
</div>
|
||||
)}
|
||||
{spec?.output_keys && spec.output_keys.length > 0 && (
|
||||
<div>
|
||||
<span className="text-[10px] text-muted-foreground font-medium">Outputs: </span>
|
||||
<span className="text-[10px] text-foreground/70 font-mono">{spec.output_keys.join(", ")}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Live progress reports (from report_to_parent) */}
|
||||
{progressReports.length > 0 && (
|
||||
<div className="px-3 py-2 border-t border-border/15 bg-background/60">
|
||||
<p className="text-[10px] text-muted-foreground font-medium mb-1">Reports ({progressReports.length})</p>
|
||||
{progressReports.map((r, i) => (
|
||||
<div key={i} className="text-[10.5px] text-foreground/70 leading-relaxed py-0.5">{r.message}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type Tab = "overview" | "breakdown" | "tools" | "logs" | "subagents";
|
||||
|
||||
const tabs: { id: Tab; label: string; Icon: React.FC<{ className?: string }> }[] = [
|
||||
{ id: "overview", label: "Overview", Icon: ({ className }) => <GitBranch className={className} /> },
|
||||
{ id: "breakdown", label: "Breakdown", Icon: ({ className }) => <BookOpen className={className} /> },
|
||||
{ id: "tools", label: "Tools", Icon: ({ className }) => <Wrench className={className} /> },
|
||||
{ id: "logs", label: "Logs", Icon: ({ className }) => <Terminal className={className} /> },
|
||||
{ id: "subagents", label: "Subagents", Icon: ({ className }) => <Bot className={className} /> },
|
||||
];
|
||||
|
||||
export default function NodeDetailPanel({ node, nodeSpec, allNodeSpecs, subagentReports, sessionId, colonyId, workerSessionId, nodeLogs, actionPlan, contextUsage, onClose }: NodeDetailPanelProps) {
|
||||
const [activeTab, setActiveTab] = useState<Tab>("overview");
|
||||
const [realTools, setRealTools] = useState<ToolInfo[] | null>(null);
|
||||
const [realCriteria, setRealCriteria] = useState<NodeCriteria | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveTab("overview");
|
||||
setRealTools(null);
|
||||
setRealCriteria(null);
|
||||
}, [node?.id]);
|
||||
|
||||
// Fetch real tool descriptions when Tools tab is active and session is loaded
|
||||
useEffect(() => {
|
||||
if (activeTab === "tools" && sessionId && colonyId && node) {
|
||||
workersApi.nodeTools(sessionId, colonyId, node.id)
|
||||
.then(r => setRealTools(r.tools))
|
||||
.catch(() => setRealTools(null));
|
||||
}
|
||||
}, [activeTab, sessionId, colonyId, node?.id]);
|
||||
|
||||
// Fetch real criteria when Overview tab is active and session is loaded
|
||||
useEffect(() => {
|
||||
if (activeTab === "breakdown" && sessionId && colonyId && node) {
|
||||
workersApi.nodeCriteria(sessionId, colonyId, node.id, workerSessionId || undefined)
|
||||
.then(r => setRealCriteria(r))
|
||||
.catch(() => setRealCriteria(null));
|
||||
}
|
||||
}, [activeTab, sessionId, colonyId, node?.id, workerSessionId]);
|
||||
|
||||
if (!node) return null;
|
||||
|
||||
const status = statusConfig[node.status];
|
||||
const StatusIcon = status.Icon;
|
||||
const isActive = node.status === "running" || node.status === "looping";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full border-l border-border/40 bg-card/20 animate-in slide-in-from-right">
|
||||
{/* Header */}
|
||||
<div className="px-4 pt-4 pb-3 border-b border-border/30 flex items-start justify-between gap-2 flex-shrink-0">
|
||||
<div className="flex items-start gap-3 min-w-0">
|
||||
<div
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 mt-0.5"
|
||||
style={{ backgroundColor: `${status.color}18`, border: `1.5px solid ${status.color}35` }}
|
||||
>
|
||||
<Cpu className="w-3.5 h-3.5" style={{ color: status.color }} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-semibold text-foreground leading-tight">{formatNodeId(node.id)}</h3>
|
||||
<div className="flex items-center gap-1.5 mt-1">
|
||||
<span style={{ color: status.color }}><StatusIcon className="w-3 h-3 flex-shrink-0" /></span>
|
||||
<span className="text-[11px] font-medium" style={{ color: status.color }}>{status.label}</span>
|
||||
{node.iterations !== undefined && node.iterations > 0 && (
|
||||
<>
|
||||
<span className="text-muted-foreground/40 text-[10px]">·</span>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{node.iterations}{node.maxIterations ? `/${node.maxIterations}` : ""} iterations
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors flex-shrink-0"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Status label */}
|
||||
{node.statusLabel && (
|
||||
<div className="px-4 py-2 border-b border-border/20 flex-shrink-0">
|
||||
<div className="flex items-center gap-2 text-[11px] text-muted-foreground bg-muted/40 rounded-lg px-3 py-2">
|
||||
<Zap className="w-3 h-3 text-primary flex-shrink-0" />
|
||||
<span className="italic">{node.statusLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Context window usage */}
|
||||
{contextUsage && (
|
||||
<div className="px-4 py-2 border-b border-border/20 flex-shrink-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-[10px] text-muted-foreground font-medium">Context</span>
|
||||
<span className="text-[10px] text-muted-foreground/70 ml-auto">
|
||||
{(contextUsage.estimatedTokens / 1000).toFixed(1)}k / {(contextUsage.maxTokens / 1000).toFixed(0)}k tokens
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full h-1.5 rounded-full bg-muted/50 overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-500 ease-out"
|
||||
style={{
|
||||
width: `${Math.min(contextUsage.usagePct, 100)}%`,
|
||||
backgroundColor: contextUsage.usagePct >= 90
|
||||
? "hsl(0,65%,55%)"
|
||||
: contextUsage.usagePct >= 70
|
||||
? "hsl(35,90%,55%)"
|
||||
: "hsl(45,95%,58%)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-[10px] text-muted-foreground/60">{contextUsage.messageCount} messages</span>
|
||||
<span className="text-[10px] font-medium ml-auto" style={{
|
||||
color: contextUsage.usagePct >= 90
|
||||
? "hsl(0,65%,55%)"
|
||||
: contextUsage.usagePct >= 70
|
||||
? "hsl(35,90%,55%)"
|
||||
: "hsl(45,95%,58%)",
|
||||
}}>
|
||||
{contextUsage.usagePct}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab bar */}
|
||||
<div className="flex border-b border-border/30 flex-shrink-0 px-2 pt-1 overflow-x-auto scrollbar-hide">
|
||||
{tabs.filter(t => t.id !== "subagents" || (nodeSpec?.sub_agents && nodeSpec.sub_agents.length > 0)).map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`flex items-center gap-1.5 px-3 py-2 text-[11px] font-medium border-b-2 transition-colors -mb-px ${
|
||||
activeTab === tab.id
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<tab.Icon className="w-3 h-3" />
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
<div className="flex-1 overflow-auto px-4 py-4 flex flex-col gap-3">
|
||||
{activeTab === "overview" && (
|
||||
<SystemPromptTab systemPrompt={nodeSpec?.system_prompt} />
|
||||
)}
|
||||
|
||||
{activeTab === "breakdown" && (
|
||||
<>
|
||||
<p className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">Action Plan</p>
|
||||
{actionPlan ? (
|
||||
<div className="rounded-lg border border-border/30 bg-background/60 px-3 py-2.5 text-[11px] leading-relaxed text-foreground/80">
|
||||
<MarkdownContent content={actionPlan} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center py-6">
|
||||
<p className="text-[11px] text-muted-foreground/50 italic">Action plan will appear when node starts running</p>
|
||||
</div>
|
||||
)}
|
||||
{(() => {
|
||||
if (realCriteria && realCriteria.success_criteria) {
|
||||
const criteriaLines = realCriteria.success_criteria.split("\n").filter(l => l.trim());
|
||||
const passed = realCriteria.last_execution?.success ?? null;
|
||||
return (
|
||||
<div className="mt-1">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">Judge Criteria</p>
|
||||
{passed !== null && (
|
||||
<span className={`text-[10px] font-medium px-2 py-0.5 rounded-full ${passed ? "bg-[hsl(43,70%,45%)]/15 text-[hsl(43,70%,45%)]" : "bg-red-500/15 text-red-400"}`}>
|
||||
{passed ? "Passed" : "Failed"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{criteriaLines.map((line, i) => (
|
||||
<div key={i} className="flex items-start gap-2">
|
||||
<div className={`mt-0.5 w-3.5 h-3.5 rounded-full flex-shrink-0 flex items-center justify-center border ${passed ? "border-transparent bg-[hsl(43,70%,45%)]" : "border-border/40 bg-muted/30"}`}>
|
||||
{passed && (
|
||||
<svg viewBox="0 0 8 8" className="w-2 h-2" fill="none">
|
||||
<path d="M1.5 4l2 2 3-3" stroke="white" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<span className={`text-[11px] leading-relaxed ${passed ? "text-foreground/70" : "text-foreground/80"}`}>{line}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
{node.next && node.next.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<p className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider mb-2">Sends to</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{node.next.map((n) => (
|
||||
<span key={n} className="text-[11px] px-2.5 py-1 rounded-full bg-primary/10 text-primary border border-primary/20 font-medium">
|
||||
{formatNodeId(n)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === "tools" && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider mb-1">Tools & Integrations</p>
|
||||
{realTools && realTools.length > 0
|
||||
? realTools.map((t, i) => (
|
||||
<ToolRow key={i} tool={{ name: t.name, description: t.description || "No description available", icon: "\ud83d\udd27" }} />
|
||||
))
|
||||
: (
|
||||
<div className="flex items-center justify-center py-6">
|
||||
<p className="text-[11px] text-muted-foreground/50 italic">No tools available</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "logs" && (
|
||||
<LogsTab nodeId={node.id} isActive={isActive} sessionId={sessionId} colonyId={colonyId} workerSessionId={workerSessionId} nodeLogs={nodeLogs} />
|
||||
)}
|
||||
|
||||
{activeTab === "subagents" && nodeSpec?.sub_agents && (
|
||||
<SubagentsTab
|
||||
subAgentIds={nodeSpec.sub_agents}
|
||||
allNodeSpecs={allNodeSpecs || []}
|
||||
subagentReports={subagentReports || []}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { X, Webhook, Clock, Activity, ArrowRight, Zap } from "lucide-react";
|
||||
import type { GraphNode } from "./graph-types";
|
||||
import { cronToLabel } from "@/lib/graphUtils";
|
||||
|
||||
interface TriggerDetailPanelProps {
|
||||
trigger: GraphNode;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function TriggerIcon({ type }: { type?: string }) {
|
||||
const cls = "w-4 h-4";
|
||||
switch (type) {
|
||||
case "webhook":
|
||||
return <Webhook className={cls} />;
|
||||
case "timer":
|
||||
return <Clock className={cls} />;
|
||||
case "api":
|
||||
return <ArrowRight className={cls} />;
|
||||
case "event":
|
||||
return <Activity className={cls} />;
|
||||
default:
|
||||
return <Zap className={cls} />;
|
||||
}
|
||||
}
|
||||
|
||||
function formatCountdown(seconds: number): string {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
if (h > 0) return `${h}h ${String(m).padStart(2, "0")}m ${String(s).padStart(2, "0")}s`;
|
||||
if (m > 0) return `${m}m ${String(s).padStart(2, "0")}s`;
|
||||
return `${s}s`;
|
||||
}
|
||||
|
||||
export default function TriggerDetailPanel({ trigger, onClose }: TriggerDetailPanelProps) {
|
||||
const isActive = trigger.status === "running" || trigger.status === "complete";
|
||||
const config = (trigger.triggerConfig || {}) as Record<string, unknown>;
|
||||
const cron = config.cron as string | undefined;
|
||||
const interval = config.interval_minutes as number | undefined;
|
||||
const nextFireIn = config.next_fire_in as number | undefined;
|
||||
|
||||
const schedule = cron
|
||||
? cronToLabel(cron)
|
||||
: interval != null
|
||||
? interval >= 60
|
||||
? `Every ${interval / 60}h`
|
||||
: `Every ${interval}m`
|
||||
: null;
|
||||
|
||||
// Hide noisy frontend-only fields so only the raw operator config shows
|
||||
const displayEntries = Object.entries(config).filter(
|
||||
([k]) => k !== "next_fire_in" && k !== "entry_node",
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full border-l border-border/40 bg-card/20 animate-in slide-in-from-right">
|
||||
{/* Header */}
|
||||
<div className="px-4 pt-4 pb-3 border-b border-border/30 flex items-start justify-between gap-2 flex-shrink-0">
|
||||
<div className="flex items-start gap-3 min-w-0">
|
||||
<div
|
||||
className={[
|
||||
"w-9 h-9 rounded-lg flex items-center justify-center flex-shrink-0",
|
||||
isActive ? "bg-primary/15 text-primary" : "bg-muted/50 text-muted-foreground",
|
||||
].join(" ")}
|
||||
>
|
||||
<TriggerIcon type={trigger.triggerType} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-semibold text-foreground leading-tight truncate">
|
||||
{trigger.label}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span
|
||||
className={[
|
||||
"text-[10px] font-medium px-1.5 py-0.5 rounded-full",
|
||||
isActive
|
||||
? "bg-emerald-500/15 text-emerald-400"
|
||||
: "bg-muted/60 text-muted-foreground",
|
||||
].join(" ")}
|
||||
>
|
||||
{isActive ? "active" : "inactive"}
|
||||
</span>
|
||||
{trigger.triggerType && (
|
||||
<span className="text-[10px] text-muted-foreground uppercase tracking-wider">
|
||||
{trigger.triggerType}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors flex-shrink-0"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-auto px-4 py-4 space-y-4">
|
||||
{schedule && (
|
||||
<div>
|
||||
<p className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider mb-1.5">
|
||||
Schedule
|
||||
</p>
|
||||
<div className="rounded-lg border border-border/30 bg-background/60 px-3 py-2.5">
|
||||
<p className="text-xs text-foreground">{schedule}</p>
|
||||
{cron && (
|
||||
<p className="text-[10px] text-muted-foreground mt-1 font-mono">{cron}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isActive && nextFireIn != null && nextFireIn > 0 && (
|
||||
<div>
|
||||
<p className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider mb-1.5">
|
||||
Next fire
|
||||
</p>
|
||||
<div className="rounded-lg border border-border/30 bg-background/60 px-3 py-2.5">
|
||||
<p className="text-xs text-foreground italic">in {formatCountdown(nextFireIn)}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{displayEntries.length > 0 && (
|
||||
<div>
|
||||
<p className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider mb-1.5">
|
||||
Config
|
||||
</p>
|
||||
<div className="rounded-lg border border-border/30 bg-background/60 px-3 py-2.5 space-y-1">
|
||||
{displayEntries.map(([k, v]) => (
|
||||
<div key={k} className="flex items-start justify-between gap-3 text-[11px]">
|
||||
<span className="text-muted-foreground font-mono">{k}</span>
|
||||
<span className="text-foreground font-mono text-right truncate">
|
||||
{typeof v === "object" ? JSON.stringify(v) : String(v)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<p className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider mb-1.5">
|
||||
Trigger ID
|
||||
</p>
|
||||
<div className="rounded-lg border border-border/30 bg-background/60 px-3 py-2.5">
|
||||
<p className="text-[11px] text-muted-foreground font-mono break-all">
|
||||
{trigger.id.replace(/^__trigger_/, "")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,8 +3,8 @@ import { useParams, useLocation } from "react-router-dom";
|
||||
import { Loader2, WifiOff, KeyRound, FolderOpen, X } from "lucide-react";
|
||||
import type { GraphNode, NodeStatus } from "@/components/graph-types";
|
||||
import TriggersPanel from "@/components/TriggersPanel";
|
||||
import TriggerDetailPanel from "@/components/TriggerDetailPanel";
|
||||
import ChatPanel, { type ChatMessage, type ImageContent } from "@/components/ChatPanel";
|
||||
import NodeDetailPanel from "@/components/NodeDetailPanel";
|
||||
import CredentialsModal, {
|
||||
type Credential,
|
||||
clearCredentialCache,
|
||||
@@ -1105,6 +1105,11 @@ export default function ColonyChat() {
|
||||
const liveSelectedNode = selectedNode && graphNodes.find((n) => n.id === selectedNode.id);
|
||||
const resolvedSelectedNode = liveSelectedNode || selectedNode;
|
||||
|
||||
const triggers = useMemo(
|
||||
() => graphNodes.filter((n) => n.nodeType === "trigger"),
|
||||
[graphNodes],
|
||||
);
|
||||
|
||||
// ── Render ─────────────────────────────────────────────────────────────
|
||||
|
||||
if (!colony && !isNewChat && !agentState.loading) {
|
||||
@@ -1197,25 +1202,24 @@ export default function ColonyChat() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Triggers sidebar */}
|
||||
<div className="w-[260px] flex-shrink-0">
|
||||
<TriggersPanel
|
||||
triggers={graphNodes.filter((n) => n.nodeType === "trigger")}
|
||||
selectedId={resolvedSelectedNode?.id ?? null}
|
||||
onSelect={(trigger) =>
|
||||
setSelectedNode((prev) => (prev?.id === trigger.id ? null : trigger))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{/* Triggers sidebar — only rendered when the colony actually has triggers */}
|
||||
{triggers.length > 0 && (
|
||||
<div className="w-[260px] flex-shrink-0">
|
||||
<TriggersPanel
|
||||
triggers={triggers}
|
||||
selectedId={resolvedSelectedNode?.id ?? null}
|
||||
onSelect={(trigger) =>
|
||||
setSelectedNode((prev) => (prev?.id === trigger.id ? null : trigger))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Node detail panel */}
|
||||
{resolvedSelectedNode && (
|
||||
<div className="w-[480px] min-w-[400px] flex-shrink-0">
|
||||
<NodeDetailPanel
|
||||
node={resolvedSelectedNode}
|
||||
sessionId={agentState.sessionId || ""}
|
||||
nodeLogs={agentState.nodeLogs[resolvedSelectedNode.id] || []}
|
||||
actionPlan={agentState.nodeActionPlans[resolvedSelectedNode.id]}
|
||||
{/* Trigger detail panel */}
|
||||
{resolvedSelectedNode && resolvedSelectedNode.nodeType === "trigger" && (
|
||||
<div className="w-[380px] min-w-[320px] flex-shrink-0">
|
||||
<TriggerDetailPanel
|
||||
trigger={resolvedSelectedNode}
|
||||
onClose={() => setSelectedNode(null)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
+20
-66
@@ -1851,76 +1851,34 @@ fi
|
||||
echo ""
|
||||
|
||||
# ============================================================
|
||||
# Step 4b: Load browser extension into Chrome (one-time setup)
|
||||
# Step 4b: Install browser extension from Chrome Web Store
|
||||
# ============================================================
|
||||
|
||||
echo -e "${YELLOW}⬢${NC} ${BLUE}${BOLD}Setting up browser extension...${NC}"
|
||||
echo -e "${YELLOW}⬢${NC} ${BLUE}${BOLD}Installing browser extension...${NC}"
|
||||
echo ""
|
||||
|
||||
EXTENSION_PATH="$SCRIPT_DIR/tools/browser-extension"
|
||||
CHROME_BIN=""
|
||||
CHROME_LAUNCHED=false
|
||||
EXTENSION_URL="https://chromewebstore.google.com/detail/hive-browser-bridge/jkpcegnbfimimjodblcemoheedidnppm"
|
||||
EXTENSION_INSTALLED=false
|
||||
|
||||
# Find Chrome binary
|
||||
for _bin in "google-chrome" "google-chrome-stable" "chromium" "chromium-browser" "microsoft-edge" "microsoft-edge-stable"; do
|
||||
if command -v "$_bin" &> /dev/null; then
|
||||
CHROME_BIN="$_bin"
|
||||
break
|
||||
fi
|
||||
done
|
||||
# macOS
|
||||
if [ -z "$CHROME_BIN" ]; then
|
||||
for _path in \
|
||||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
|
||||
"$HOME/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
|
||||
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"; do
|
||||
if [ -e "$_path" ]; then
|
||||
CHROME_BIN="$_path"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
echo -e " Install ${BOLD}Hive Browser Bridge${NC} from the Chrome Web Store, then click ${BOLD}Add to Chrome${NC}."
|
||||
echo -e " ${DIM}${EXTENSION_URL}${NC}"
|
||||
echo ""
|
||||
read -r -p " Press Enter to open the Web Store... " _dummy || true
|
||||
|
||||
if [ ! -d "$EXTENSION_PATH" ]; then
|
||||
echo -e "${YELLOW} Extension not found at $EXTENSION_PATH — skipping${NC}"
|
||||
elif [ -z "$CHROME_BIN" ]; then
|
||||
echo -e "${YELLOW} Chrome not found — skipping${NC}"
|
||||
echo -e "${DIM} Install Chrome, then load: $EXTENSION_PATH via chrome://extensions${NC}"
|
||||
if [[ "$OSTYPE" == darwin* ]]; then
|
||||
open "$EXTENSION_URL" 2>/dev/null
|
||||
elif command -v xdg-open &> /dev/null; then
|
||||
xdg-open "$EXTENSION_URL" > /dev/null 2>&1 &
|
||||
elif command -v wslview &> /dev/null; then
|
||||
wslview "$EXTENSION_URL" > /dev/null 2>&1 &
|
||||
else
|
||||
# Copy path to clipboard (best-effort)
|
||||
if command -v xclip &> /dev/null; then
|
||||
printf '%s' "$EXTENSION_PATH" | xclip -selection clipboard 2>/dev/null && _copied=true
|
||||
elif command -v xsel &> /dev/null; then
|
||||
printf '%s' "$EXTENSION_PATH" | xsel --clipboard --input 2>/dev/null && _copied=true
|
||||
elif command -v pbcopy &> /dev/null; then
|
||||
printf '%s' "$EXTENSION_PATH" | pbcopy 2>/dev/null && _copied=true
|
||||
fi
|
||||
|
||||
read -r -p " Press Enter when you are ready to set up the Chrome extension... " _dummy || true
|
||||
echo ""
|
||||
|
||||
# Open setup guide in default browser
|
||||
SETUP_URL="file://$SCRIPT_DIR/docs/browser-extension-setup.html?path=$(printf '%s' "$EXTENSION_PATH" | sed 's/ /%20/g')"
|
||||
echo -e " Opening browser extension setup guide..."
|
||||
if [ "${_copied:-false}" = "true" ]; then
|
||||
echo -e " ${DIM}(extension path copied to clipboard — paste it in the folder picker)${NC}"
|
||||
fi
|
||||
if [[ "$OSTYPE" == darwin* ]]; then
|
||||
open "$SETUP_URL" 2>/dev/null
|
||||
elif command -v xdg-open &> /dev/null; then
|
||||
xdg-open "$SETUP_URL" > /dev/null 2>&1 &
|
||||
elif command -v wslview &> /dev/null; then
|
||||
wslview "$SETUP_URL" > /dev/null 2>&1 &
|
||||
else
|
||||
echo -e " ${DIM}Could not open browser automatically. Visit:${NC}"
|
||||
echo -e " ${BOLD}$SETUP_URL${NC}"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
read -r -p " Press Enter once you've finished the extension setup... " _dummy || true
|
||||
CHROME_LAUNCHED=true
|
||||
echo -e " ${DIM}Could not open browser automatically — open the URL above in Chrome.${NC}"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
read -r -p " Press Enter once the extension is installed... " _dummy || true
|
||||
EXTENSION_INSTALLED=true
|
||||
|
||||
echo ""
|
||||
|
||||
# ============================================================
|
||||
@@ -1987,12 +1945,8 @@ else
|
||||
fi
|
||||
|
||||
echo -n " ⬡ browser extension... "
|
||||
if [ "$CHROME_LAUNCHED" = true ]; then
|
||||
if [ "$EXTENSION_INSTALLED" = true ]; then
|
||||
echo -e "${GREEN}ok${NC}"
|
||||
elif [ -d "$EXTENSION_PATH" ] && [ -n "$CHROME_BIN" ]; then
|
||||
echo -e "${GREEN}ok${NC}"
|
||||
elif [ -d "$EXTENSION_PATH" ]; then
|
||||
echo -e "${YELLOW}-- (Chrome not found)${NC}"
|
||||
else
|
||||
echo -e "${YELLOW}--${NC}"
|
||||
fi
|
||||
|
||||
@@ -20,7 +20,12 @@ Your existing Chrome browser
|
||||
- Each subagent → one `chrome.tabGroups` entry, colour-coded in your tab bar
|
||||
- `context.destroy` closes the group's tabs; Chrome stays alive
|
||||
|
||||
## Install (unpacked extension)
|
||||
## Install
|
||||
|
||||
Install from the Chrome Web Store:
|
||||
https://chromewebstore.google.com/detail/hive-browser-bridge/jkpcegnbfimimjodblcemoheedidnppm
|
||||
|
||||
### Developer install (unpacked)
|
||||
|
||||
1. Open `chrome://extensions`
|
||||
2. Enable **Developer mode**
|
||||
|
||||
Reference in New Issue
Block a user