fix: side panel

This commit is contained in:
Timothy
2026-04-13 21:08:11 -07:00
parent aa281aad34
commit fd3ef36a15
5 changed files with 206 additions and 637 deletions
@@ -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]">&middot;</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>
);
}
+23 -19
View File
@@ -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
View File
@@ -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
+6 -1
View File
@@ -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**