From cf350c942b744503ee4baa10b3ebe175a048829d Mon Sep 17 00:00:00 2001 From: Kyle Fu Date: Sat, 14 Mar 2026 23:41:06 +0800 Subject: [PATCH 1/2] feat: add Agents Behavior Comparison page (/compare) - New /compare page for side-by-side comparison of agent behavior files (SOUL.md, AGENTS.md, USER.md, IDENTITY.md) across all configured agents - Multi-select agent filter with pill-style toggle buttons - Collapsible rows with 'collapsed' indicator text - Per-row height adjustment (+/- buttons, independent per row) - Editable textareas with dirty tracking, yellow highlight, and per-cell Save - Synchronized scrolling across agent columns within the same row - Alternating row backgrounds for visual separation - Custom default heights: Persona (400px), Directives (500px) - HeaderBar: added compare button (GitCompareArrows icon) for quick navigation - Added upgrade.sh convenience script for pull+build - Updated .gitignore for runtime log files --- .gitignore | 3 + package-lock.json | 1 - src/app/compare/page.tsx | 575 +++++++++++++++++++ src/features/agents/components/HeaderBar.tsx | 11 +- upgrade.sh | 10 + 5 files changed, 598 insertions(+), 2 deletions(-) create mode 100644 src/app/compare/page.tsx create mode 100755 upgrade.sh diff --git a/.gitignore b/.gitignore index 7bfbb6311..e6372a5cb 100644 --- a/.gitignore +++ b/.gitignore @@ -69,3 +69,6 @@ test-results # bv (beads viewer) local config and caches .bv/ + +studio.err.log +studio.log diff --git a/package-lock.json b/package-lock.json index ed1b1cc57..45db7e81a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5626,7 +5626,6 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, diff --git a/src/app/compare/page.tsx b/src/app/compare/page.tsx new file mode 100644 index 000000000..712d6133c --- /dev/null +++ b/src/app/compare/page.tsx @@ -0,0 +1,575 @@ +"use client"; + +import React, { useCallback, useEffect, useRef, useState, type SyntheticEvent } from "react"; +import Link from "next/link"; +import { ArrowLeft, ChevronDown, ChevronRight, Loader2, RefreshCw } from "lucide-react"; + +import { ThemeToggle } from "@/components/theme-toggle"; +import { AgentAvatar } from "@/features/agents/components/AgentAvatar"; +import { + createAgentFilesState, + type AgentFileName, +} from "@/lib/agents/agentFiles"; +import { parsePersonalityFiles } from "@/lib/agents/personalityBuilder"; +import { readDomainAgentFile, writeDomainAgentFile } from "@/lib/controlplane/domain-runtime-client"; +import { fetchJson } from "@/lib/http"; +import type { GatewayStatus } from "@/lib/gateway/gateway-status"; + +/* ------------------------------------------------------------------ */ +/* Types */ +/* ------------------------------------------------------------------ */ + +type AgentSeed = { + agentId: string; + name: string; + sessionKey: string; + avatarSeed?: string | null; + avatarUrl?: string | null; +}; + +type AgentFilesState = ReturnType; + +type AgentCompareEntry = { + agent: AgentSeed; + files: AgentFilesState; + loading: boolean; + error: string | null; +}; + +/* The four behavior files we compare */ +const COMPARE_FILES: { key: AgentFileName; label: string }[] = [ + { key: "SOUL.md", label: "Persona" }, + { key: "AGENTS.md", label: "Directives" }, + { key: "USER.md", label: "Context" }, + { key: "IDENTITY.md", label: "Identity" }, +]; + +/* ------------------------------------------------------------------ */ +/* Gateway status probe (lightweight) */ +/* ------------------------------------------------------------------ */ + +type RuntimeSummaryEnvelope = { + summary?: { + status?: unknown; + } | null; + error?: unknown; +}; + +const probeGatewayStatus = async (): Promise => { + try { + const envelope = await fetchJson("/api/runtime/summary", { + cache: "no-store", + }); + const raw = envelope?.summary?.status; + const normalized = typeof raw === "string" ? raw.trim().toLowerCase() : ""; + if (normalized === "connected") return "connected"; + if (normalized === "connecting") return "connecting"; + if (normalized === "reconnecting") return "reconnecting"; + if (normalized === "error") return "error"; + return "disconnected"; + } catch { + return "disconnected"; + } +}; + +/* ------------------------------------------------------------------ */ +/* Fleet loading (reuses /api/runtime/fleet) */ +/* ------------------------------------------------------------------ */ + +const loadFleetSeeds = async (): Promise => { + const response = await fetchJson<{ + result?: { + seeds?: AgentSeed[]; + }; + error?: string; + }>("/api/runtime/fleet", { + method: "POST", + cache: "no-store", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + + if (response.error) { + throw new Error(response.error); + } + + const seeds = response.result?.seeds; + if (!Array.isArray(seeds)) { + throw new Error("No agents returned from fleet."); + } + + return seeds; +}; + +/* ------------------------------------------------------------------ */ +/* Load behavior files for one agent */ +/* ------------------------------------------------------------------ */ + +const loadAgentBehaviorFiles = async ( + agentId: string +): Promise => { + const state = createAgentFilesState(); + const results = await Promise.all( + COMPARE_FILES.map(async ({ key }) => { + const file = await readDomainAgentFile({ agentId, name: key }); + return { name: key, content: file.content, exists: file.exists }; + }) + ); + for (const file of results) { + state[file.name] = { content: file.content ?? "", exists: Boolean(file.exists) }; + } + return state; +}; + +/* ------------------------------------------------------------------ */ +/* Compare Page */ +/* ------------------------------------------------------------------ */ + +export default function ComparePage() { + const [gatewayStatus, setGatewayStatus] = useState("connecting"); + const [entries, setEntries] = useState([]); + const [globalLoading, setGlobalLoading] = useState(true); + const [globalError, setGlobalError] = useState(null); + const loadInflightRef = useRef(false); + + /* ---- agent filter ---- */ + const [selectedAgents, setSelectedAgents] = useState>(new Set()); + /* ---- collapsible rows ---- */ + const [collapsedRows, setCollapsedRows] = useState>(new Set()); + /* ---- per-row height (px) ---- */ + const [rowHeights, setRowHeights] = useState>({}); + + /* ---- load everything ---- */ + const loadAll = useCallback(async () => { + if (loadInflightRef.current) return; + loadInflightRef.current = true; + setGlobalLoading(true); + setGlobalError(null); + + try { + /* 1. Check gateway */ + const status = await probeGatewayStatus(); + setGatewayStatus(status); + + if (status !== "connected") { + setGlobalError( + status === "connecting" || status === "reconnecting" + ? "Gateway is connecting… please wait." + : "Gateway is not connected. Return to the main page to configure your connection." + ); + setGlobalLoading(false); + loadInflightRef.current = false; + return; + } + + /* 2. Load fleet */ + const seeds = await loadFleetSeeds(); + if (seeds.length === 0) { + setGlobalError("No agents found in fleet."); + setGlobalLoading(false); + loadInflightRef.current = false; + return; + } + + /* 3. Scaffold entries with loading state */ + const scaffold: AgentCompareEntry[] = seeds.map((agent) => ({ + agent, + files: createAgentFilesState(), + loading: true, + error: null, + })); + setEntries(scaffold); + + /* 4. Load files for each agent in parallel */ + const settled = await Promise.allSettled( + seeds.map((agent) => loadAgentBehaviorFiles(agent.agentId)) + ); + + const final: AgentCompareEntry[] = seeds.map((agent, idx) => { + const result = settled[idx]; + if (result.status === "fulfilled") { + return { agent, files: result.value, loading: false, error: null }; + } + return { + agent, + files: createAgentFilesState(), + loading: false, + error: result.reason instanceof Error ? result.reason.message : "Failed to load files.", + }; + }); + setEntries(final); + setSelectedAgents((prev) => { + if (prev.size === 0) return new Set(seeds.map((s) => s.agentId)); + return prev; + }); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to load agents."; + setGlobalError(message); + } finally { + setGlobalLoading(false); + loadInflightRef.current = false; + } + }, []); + + useEffect(() => { + void loadAll(); + }, [loadAll]); + + /* ---- derive emoji from IDENTITY.md for display ---- */ + const resolveEmoji = (entry: AgentCompareEntry): string => { + try { + const draft = parsePersonalityFiles(entry.files); + return draft.identity.emoji.trim() || ""; + } catch { + return ""; + } + }; + + /* ---- resolve display name ---- */ + const resolveDisplayName = (entry: AgentCompareEntry): string => { + try { + const draft = parsePersonalityFiles(entry.files); + const parsed = draft.identity.name.trim(); + if (parsed) return parsed; + } catch { + /* fall through */ + } + return entry.agent.name || entry.agent.agentId; + }; + + /* ---- toggle agent selection ---- */ + const toggleAgent = (agentId: string) => { + setSelectedAgents((prev) => { + const next = new Set(prev); + if (next.has(agentId)) next.delete(agentId); + else next.add(agentId); + return next; + }); + }; + + /* ---- toggle collapsed row ---- */ + const toggleRow = (key: string) => { + setCollapsedRows((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + }; + + /* ---- row height helpers ---- */ + const DEFAULT_HEIGHTS: Record = { "SOUL.md": 400, "AGENTS.md": 500 }; + const getDefaultHeight = (key: string) => DEFAULT_HEIGHTS[key] ?? 208; + const getRowHeight = (key: string) => rowHeights[key] ?? getDefaultHeight(key); + const adjustRowHeight = (key: string, delta: number) => { + setRowHeights((prev) => { + const cur = prev[key] ?? getDefaultHeight(key); + const next = Math.min(1200, Math.max(100, cur + delta)); + return { ...prev, [key]: next }; + }); + }; + + /* ---- dirty tracking ---- */ + const [dirtyKeys, setDirtyKeys] = useState>(new Set()); + const [savingKeys, setSavingKeys] = useState>(new Set()); + + const updateFileContent = useCallback((agentId: string, fileName: AgentFileName, content: string) => { + setEntries((prev) => + prev.map((e) => + e.agent.agentId === agentId + ? { ...e, files: { ...e.files, [fileName]: { ...e.files[fileName], content } } } + : e + ) + ); + setDirtyKeys((prev) => new Set(prev).add(`${agentId}:${fileName}`)); + }, []); + + const saveFile = useCallback(async (agentId: string, fileName: AgentFileName, content: string) => { + const k = `${agentId}:${fileName}`; + setSavingKeys((prev) => new Set(prev).add(k)); + try { + await writeDomainAgentFile({ agentId, name: fileName, content }); + setDirtyKeys((prev) => { const next = new Set(prev); next.delete(k); return next; }); + } catch (err) { + console.error("Save failed:", err); + } finally { + setSavingKeys((prev) => { const next = new Set(prev); next.delete(k); return next; }); + } + }, []); + + /* ---- synchronized textarea scrolling ---- */ + const scrollSyncGroupRef = useRef>(new Map()); + const isScrollSyncing = useRef(false); + + const registerTextarea = useCallback((groupKey: string, el: HTMLTextAreaElement | null, idx: number) => { + const group = scrollSyncGroupRef.current.get(groupKey) ?? []; + if (el) { + group[idx] = el; + } + scrollSyncGroupRef.current.set(groupKey, group); + }, []); + + const handleSyncScroll = useCallback((groupKey: string, sourceIdx: number) => { + if (isScrollSyncing.current) return; + isScrollSyncing.current = true; + const group = scrollSyncGroupRef.current.get(groupKey) ?? []; + const source = group[sourceIdx]; + if (source) { + for (let i = 0; i < group.length; i++) { + if (i !== sourceIdx && group[i]) { + group[i].scrollTop = source.scrollTop; + } + } + } + isScrollSyncing.current = false; + }, []); + + /* ---- filtered entries ---- */ + const filteredEntries = entries.filter((e) => selectedAgents.has(e.agent.agentId)); + + /* ---------------------------------------------------------------- */ + /* Render */ + /* ---------------------------------------------------------------- */ + + const isConnected = gatewayStatus === "connected"; + + return ( +
+ {/* ---- Header ---- */} +
+
+
+ + + +
+

+ Agents Behavior Comparison +

+
+ + +
+
+
+ + {/* ---- Agent filter chips ---- */} + {entries.length > 0 && ( +
+ {entries.map((entry) => { + const emoji = resolveEmoji(entry); + const displayName = resolveDisplayName(entry); + const isActive = selectedAgents.has(entry.agent.agentId); + return ( + + ); + })} +
+ )} + + {/* ---- Body ---- */} +
+ {/* Global loading / error states */} + {globalLoading && entries.length === 0 ? ( +
+
+ +

Loading agents…

+
+
+ ) : globalError && entries.length === 0 ? ( +
+
+

{globalError}

+ {!isConnected ? ( + + Go to main page + + ) : ( + + )} +
+
+ ) : ( + /* ---- Comparison layout (flex rows) ---- */ +
+
+ {/* Header row */} +
+
+ {filteredEntries.map((entry) => { + const emoji = resolveEmoji(entry); + const displayName = resolveDisplayName(entry); + return ( +
+
+ +
+ + {emoji ? `${emoji} ` : ""} + {displayName} + + + {entry.agent.agentId} + +
+ {entry.loading ? ( + + ) : null} +
+
+ ); + })} +
+ + {/* Data rows — each row is an independent flex container */} + {COMPARE_FILES.map(({ key, label }, rowIdx) => { + const isCollapsed = collapsedRows.has(key); + const height = getRowHeight(key); + return ( +
+ {/* Row label */} +
+ + + {key} + + {!isCollapsed && ( +
+ + +
+ )} +
+ {/* Data cells */} + {filteredEntries.map((entry, colIdx) => { + const dk = `${entry.agent.agentId}:${key}`; + const isDirty = dirtyKeys.has(dk); + const isSaving = savingKeys.has(dk); + return ( +
+ {isCollapsed ? ( +
+ collapsed +
+ ) : entry.error ? ( +
+ {entry.error} +
+ ) : entry.loading ? ( +
+ +
+ ) : ( +
+