feat: colony side bar

This commit is contained in:
Richard Tang
2026-04-17 11:52:49 -07:00
parent 0abd1125b7
commit 97432ea08c
9 changed files with 350 additions and 34 deletions
-10
View File
@@ -1,10 +0,0 @@
{
"mcpServers": {
"gcu-tools": {
"type": "stdio",
"command": "uv",
"args": ["run", "python", "-m", "gcu.server", "--stdio"],
"cwd": "/home/timothy/aden/hive/tools"
}
}
}
@@ -823,8 +823,8 @@ async def run_shutdown_reflection(
# ---------------------------------------------------------------------------
_LONG_REFLECT_INTERVAL = 5
_SHORT_REFLECT_TURN_INTERVAL = 2
_SHORT_REFLECT_COOLDOWN_SEC = 120.0
_SHORT_REFLECT_TURN_INTERVAL = 3
_SHORT_REFLECT_COOLDOWN_SEC = 300.0
async def subscribe_reflection_triggers(
+2
View File
@@ -292,6 +292,7 @@ def create_app(model: str | None = None) -> web.Application:
from framework.server.routes_execution import register_routes as register_execution_routes
from framework.server.routes_logs import register_routes as register_log_routes
from framework.server.routes_messages import register_routes as register_message_routes
from framework.server.routes_colony_workers import register_routes as register_colony_worker_routes
from framework.server.routes_queens import register_routes as register_queen_routes
from framework.server.routes_sessions import register_routes as register_session_routes
from framework.server.routes_workers import register_routes as register_worker_routes
@@ -305,6 +306,7 @@ def create_app(model: str | None = None) -> web.Application:
register_worker_routes(app)
register_log_routes(app)
register_queen_routes(app)
register_colony_worker_routes(app)
# Static file serving — Option C production mode
# If frontend/dist/ exists, serve built frontend files on /
@@ -0,0 +1,58 @@
"""Colony worker inspection routes.
These expose per-spawned-worker data (identified by worker_id) so the
frontend can render a colony-workers sidebar analogous to the queen
profile panel. Distinct from ``routes_workers.py``, which deals with
*graph nodes* inside a worker definition rather than live worker
instances.
- GET /api/sessions/{session_id}/workers -- list live + completed workers
"""
import logging
from aiohttp import web
from framework.server.app import resolve_session
logger = logging.getLogger(__name__)
def _worker_info_to_dict(info) -> dict:
"""Serialize a WorkerInfo dataclass to a JSON-friendly dict."""
result_dict = None
if info.result is not None:
r = info.result
result_dict = {
"status": r.status,
"summary": r.summary,
"error": r.error,
"tokens_used": r.tokens_used,
"duration_seconds": r.duration_seconds,
}
return {
"worker_id": info.id,
"task": info.task,
"status": str(info.status),
"started_at": info.started_at,
"result": result_dict,
}
async def handle_list_workers(request: web.Request) -> web.Response:
"""GET /api/sessions/{session_id}/workers -- list workers in a session's colony."""
session, err = resolve_session(request)
if err:
return err
runtime = session.colony_runtime
if runtime is None:
return web.json_response({"workers": []})
workers = [_worker_info_to_dict(info) for info in runtime.list_workers()]
return web.json_response({"workers": workers})
def register_routes(app: web.Application) -> None:
"""Register colony worker routes."""
app.router.add_get("/api/sessions/{session_id}/workers", handle_list_workers)
+23
View File
@@ -0,0 +1,23 @@
import { api } from "./client";
export interface WorkerResult {
status: string;
summary: string;
error: string | null;
tokens_used: number;
duration_seconds: number;
}
export interface WorkerSummary {
worker_id: string;
task: string;
status: string;
started_at: number;
result: WorkerResult | null;
}
export const colonyWorkersApi = {
/** List spawned workers (live + completed) for a colony session. */
list: (sessionId: string) =>
api.get<{ workers: WorkerSummary[] }>(`/sessions/${sessionId}/workers`),
};
@@ -0,0 +1,179 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { X, Users, RefreshCw } from "lucide-react";
import { colonyWorkersApi, type WorkerSummary } from "@/api/colonyWorkers";
interface ColonyWorkersPanelProps {
sessionId: string;
onClose: () => void;
}
function statusClasses(status: string): string {
const s = status.toLowerCase();
if (s === "running" || s === "pending") return "bg-primary/15 text-primary";
if (s === "completed") return "bg-emerald-500/15 text-emerald-500";
if (s === "failed") return "bg-destructive/15 text-destructive";
if (s === "stopped") return "bg-muted text-muted-foreground";
return "bg-muted text-muted-foreground";
}
function shortId(worker_id: string): string {
return worker_id.length > 8 ? worker_id.slice(0, 8) : worker_id;
}
function fmtStarted(ts: number): string {
if (!ts) return "";
try {
const d = new Date(ts * 1000);
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
} catch {
return "";
}
}
export default function ColonyWorkersPanel({
sessionId,
onClose,
}: ColonyWorkersPanelProps) {
const [workers, setWorkers] = useState<WorkerSummary[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(() => {
setLoading(true);
setError(null);
colonyWorkersApi
.list(sessionId)
.then((r) => setWorkers(r.workers))
.catch((e) => setError(e?.message ?? "Failed to load workers"))
.finally(() => setLoading(false));
}, [sessionId]);
useEffect(() => {
refresh();
}, [refresh]);
// ── Resizable width (mirrors QueenProfilePanel) ─────────────────────
const MIN_WIDTH = 280;
const MAX_WIDTH = 600;
const [width, setWidth] = useState(360);
const dragging = useRef(false);
const startX = useRef(0);
const startWidth = useRef(0);
const onDragStart = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
dragging.current = true;
startX.current = e.clientX;
startWidth.current = width;
const onMove = (ev: MouseEvent) => {
if (!dragging.current) return;
const delta = startX.current - ev.clientX;
setWidth(Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, startWidth.current + delta)));
};
const onUp = () => {
dragging.current = false;
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
document.body.style.cursor = "";
document.body.style.userSelect = "";
};
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
},
[width],
);
return (
<aside
className="flex-shrink-0 border-l border-border/60 bg-card overflow-y-auto relative"
style={{ width }}
>
<div
onMouseDown={onDragStart}
className="absolute top-0 left-0 w-1 h-full cursor-col-resize hover:bg-primary/30 active:bg-primary/50 transition-colors z-10"
/>
{/* Header */}
<div className="flex items-center justify-between px-5 py-3.5 border-b border-border/60">
<div className="flex items-center gap-2 text-sm font-semibold text-foreground">
<Users className="w-4 h-4 text-primary" />
COLONY WORKERS
</div>
<div className="flex items-center gap-1">
<button
onClick={refresh}
className="p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors"
title="Refresh"
>
<RefreshCw className={`w-4 h-4 ${loading ? "animate-spin" : ""}`} />
</button>
<button
onClick={onClose}
className="p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
<div className="px-4 py-4">
{error && (
<div className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive mb-3">
{error}
</div>
)}
{loading && workers.length === 0 ? (
<div className="flex justify-center py-10">
<div className="w-6 h-6 border-2 border-primary/30 border-t-primary rounded-full animate-spin" />
</div>
) : workers.length === 0 ? (
<p className="text-xs text-muted-foreground text-center py-8">
No workers spawned yet.
</p>
) : (
<ul className="flex flex-col gap-1.5">
{workers.map((w) => (
<li
key={w.worker_id}
className="rounded-lg border border-border/60 bg-background/40 px-3 py-2.5 hover:bg-muted/30 transition-colors cursor-default"
>
<div className="flex items-center justify-between mb-1">
<code className="text-xs font-mono text-foreground">
{shortId(w.worker_id)}
</code>
<span
className={`text-[10px] px-1.5 py-0.5 rounded-full font-medium ${statusClasses(w.status)}`}
>
{w.status}
</span>
</div>
{w.task && (
<p className="text-xs text-foreground/80 line-clamp-2 mb-1">
{w.task}
</p>
)}
<div className="flex items-center justify-between text-[10px] text-muted-foreground">
<span>{fmtStarted(w.started_at)}</span>
{w.result && (
<span>
{w.result.duration_seconds
? `${w.result.duration_seconds.toFixed(1)}s`
: ""}
{w.result.tokens_used
? ` · ${w.result.tokens_used.toLocaleString()} tok`
: ""}
</span>
)}
</div>
</li>
))}
</ul>
)}
</div>
</aside>
);
}
@@ -0,0 +1,32 @@
import { createContext, useContext, useCallback, type ReactNode } from "react";
interface ColonyWorkersContextValue {
openColonyWorkers: (sessionId: string) => void;
}
const ColonyWorkersContext = createContext<ColonyWorkersContextValue | null>(null);
export function ColonyWorkersProvider({
onOpen,
children,
}: {
onOpen: (sessionId: string) => void;
children: ReactNode;
}) {
const openColonyWorkers = useCallback(
(sessionId: string) => onOpen(sessionId),
[onOpen],
);
return (
<ColonyWorkersContext.Provider value={{ openColonyWorkers }}>
{children}
</ColonyWorkersContext.Provider>
);
}
export function useColonyWorkers() {
const ctx = useContext(ColonyWorkersContext);
if (!ctx)
throw new Error("useColonyWorkers must be used within ColonyWorkersProvider");
return ctx;
}
+40 -20
View File
@@ -3,9 +3,11 @@ import { Outlet, useLocation } from "react-router-dom";
import Sidebar from "@/components/Sidebar";
import AppHeader from "@/components/AppHeader";
import QueenProfilePanel from "@/components/QueenProfilePanel";
import ColonyWorkersPanel from "@/components/ColonyWorkersPanel";
import { ColonyProvider, useColony } from "@/context/ColonyContext";
import { HeaderActionsProvider } from "@/context/HeaderActionsContext";
import { QueenProfileProvider } from "@/context/QueenProfileContext";
import { ColonyWorkersProvider } from "@/context/ColonyWorkersContext";
export default function AppLayout() {
return (
@@ -21,11 +23,15 @@ function AppLayoutInner() {
const { colonies } = useColony();
const location = useLocation();
const [openQueenId, setOpenQueenId] = useState<string | null>(null);
const [openWorkersSessionId, setOpenWorkersSessionId] = useState<string | null>(
null,
);
// Close the profile panel whenever the route changes so it doesn't
// bleed across pages (the panel state lives at the layout level).
// Close side panels whenever the route changes so they don't bleed
// across pages (panel state lives at the layout level).
useEffect(() => {
setOpenQueenId(null);
setOpenWorkersSessionId(null);
}, [location.pathname]);
const handleOpenQueenProfile = useCallback(
@@ -33,28 +39,42 @@ function AppLayoutInner() {
[],
);
const handleOpenColonyWorkers = useCallback(
(sessionId: string) =>
setOpenWorkersSessionId((prev) => (prev === sessionId ? null : sessionId)),
[],
);
return (
<QueenProfileProvider onOpen={handleOpenQueenProfile}>
<div className="flex h-screen bg-background overflow-hidden">
<Sidebar />
<div className="flex-1 min-w-0 flex flex-col">
<AppHeader onOpenQueenProfile={handleOpenQueenProfile} />
<div className="flex-1 min-h-0 flex">
<main className="flex-1 min-w-0 flex flex-col">
<Outlet />
</main>
{openQueenId && (
<QueenProfilePanel
queenId={openQueenId}
colonies={colonies.filter(
(c) => c.queenProfileId === openQueenId,
)}
onClose={() => setOpenQueenId(null)}
/>
)}
<ColonyWorkersProvider onOpen={handleOpenColonyWorkers}>
<div className="flex h-screen bg-background overflow-hidden">
<Sidebar />
<div className="flex-1 min-w-0 flex flex-col">
<AppHeader onOpenQueenProfile={handleOpenQueenProfile} />
<div className="flex-1 min-h-0 flex">
<main className="flex-1 min-w-0 flex flex-col">
<Outlet />
</main>
{openQueenId && (
<QueenProfilePanel
queenId={openQueenId}
colonies={colonies.filter(
(c) => c.queenProfileId === openQueenId,
)}
onClose={() => setOpenQueenId(null)}
/>
)}
{openWorkersSessionId && (
<ColonyWorkersPanel
sessionId={openWorkersSessionId}
onClose={() => setOpenWorkersSessionId(null)}
/>
)}
</div>
</div>
</div>
</div>
</ColonyWorkersProvider>
</QueenProfileProvider>
);
}
+14 -2
View File
@@ -1,6 +1,6 @@
import { useState, useCallback, useRef, useEffect, useMemo } from "react";
import { useParams, useLocation } from "react-router-dom";
import { Loader2, WifiOff, KeyRound, FolderOpen, X } from "lucide-react";
import { Loader2, WifiOff, KeyRound, FolderOpen, X, Users } from "lucide-react";
import type { GraphNode, NodeStatus } from "@/components/graph-types";
import TriggersPanel from "@/components/TriggersPanel";
import TriggerDetailPanel from "@/components/TriggerDetailPanel";
@@ -22,6 +22,7 @@ import { cronToLabel } from "@/lib/graphUtils";
import { ApiError } from "@/api/client";
import { useColony } from "@/context/ColonyContext";
import { useHeaderActions } from "@/context/HeaderActionsContext";
import { useColonyWorkers } from "@/context/ColonyWorkersContext";
import { agentSlug, getQueenForAgent } from "@/lib/colony-registry";
import BrowserStatusBadge from "@/components/BrowserStatusBadge";
@@ -195,6 +196,7 @@ export default function ColonyChat() {
const location = useLocation();
const { colonies, markVisited, refresh: refreshColonies } = useColony();
const { setActions } = useHeaderActions();
const { openColonyWorkers } = useColonyWorkers();
// Route state from home page (new chat flow)
const routeState = (location.state || {}) as {
@@ -264,11 +266,21 @@ export default function ColonyChat() {
Data
</button>
)}
{agentState.sessionId && (
<button
onClick={() => openColonyWorkers(agentState.sessionId!)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors flex-shrink-0"
title="Show workers in this colony"
>
<Users className="w-3.5 h-3.5" />
Workers
</button>
)}
<BrowserStatusBadge />
</>,
);
return () => setActions(null);
}, [agentState.sessionId, setActions]);
}, [agentState.sessionId, setActions, openColonyWorkers]);
// Refs for SSE callback stability
const messagesRef = useRef(messages);