feature: merge sidebars with functionalities

This commit is contained in:
Timothy
2026-04-17 18:12:18 -07:00
parent 023fb9b8d0
commit e972112074
10 changed files with 833 additions and 705 deletions
+2 -1
View File
@@ -62,7 +62,8 @@
"additionalDirectories": [
"/home/timothy/.hive/skills/writing-hive-skills",
"/tmp",
"/home/timothy/.hive/skills"
"/home/timothy/.hive/skills",
"/home/timothy/aden/hive/core/frontend/src/components"
]
},
"hooks": {
+117 -5
View File
@@ -53,19 +53,131 @@ def _worker_info_to_dict(info) -> dict:
async def handle_list_workers(request: web.Request) -> web.Response:
"""GET /api/sessions/{session_id}/workers -- list workers in a session's colony."""
"""GET /api/sessions/{session_id}/workers -- list workers in a session's colony.
Returns two populations merged:
1. In-memory workers from the session's unified ColonyRuntime
(``session.colony._workers``). Includes live + just-finished
entries since ``_workers`` isn't pruned on termination.
2. Historical worker directories on disk under
``<session_dir>/workers/`` that are not in memory. Populated
from dir name / first user message / dir mtime. These appear
as ``status="historical"`` so the frontend can style them
distinctly from actives.
Falls back to the legacy ``session.colony_runtime`` for the
in-memory half when ``session.colony`` isn't set.
"""
session, err = resolve_session(request)
if err:
return err
runtime = session.colony_runtime
if runtime is None:
return web.json_response({"workers": []})
runtime = getattr(session, "colony", None) or getattr(session, "colony_runtime", None)
workers: list[dict] = []
known_ids: set[str] = set()
storage_path: Path | None = None
if runtime is not None:
for info in runtime.list_workers():
workers.append(_worker_info_to_dict(info))
known_ids.add(info.id)
raw_storage = getattr(runtime, "_storage_path", None)
if raw_storage is not None:
storage_path = Path(raw_storage)
# Fall back to the session's directory if the runtime didn't expose one.
if storage_path is None:
session_dir = getattr(session, "queen_dir", None) or getattr(session, "session_dir", None)
if session_dir is not None:
storage_path = Path(session_dir)
if storage_path is not None:
workers.extend(
await asyncio.to_thread(_walk_historical_workers, storage_path, known_ids)
)
workers = [_worker_info_to_dict(info) for info in runtime.list_workers()]
return web.json_response({"workers": workers})
def _walk_historical_workers(storage_path: Path, known_ids: set[str]) -> list[dict]:
"""Scan ``<storage_path>/workers/`` for worker session dirs not already
in memory and return minimal ``WorkerSummary``-shaped entries.
We don't persist a standalone status file per worker, so the on-disk
entries get ``status="historical"`` and ``result=None``. The task is
reconstructed from the first non-boilerplate user message in the
worker's conversation parts.
"""
workers_dir = storage_path / "workers"
if not workers_dir.exists() or not workers_dir.is_dir():
return []
out: list[dict] = []
try:
entries = list(workers_dir.iterdir())
except OSError:
return []
# Newest dir first so recent runs surface first in the tab.
entries.sort(key=lambda p: _safe_mtime(p), reverse=True)
for entry in entries:
if not entry.is_dir():
continue
wid = entry.name
if wid in known_ids:
continue
out.append(
{
"worker_id": wid,
"task": _extract_historical_task(entry),
"status": "historical",
"started_at": _safe_mtime(entry),
"result": None,
}
)
return out
def _safe_mtime(path: Path) -> float:
try:
return path.stat().st_mtime
except OSError:
return 0.0
def _extract_historical_task(worker_dir: Path) -> str:
"""Pull the worker's initial task from its conversation parts.
seq 0 is a boilerplate "Hello" greeting in most flows; the real
task lands in an early user message (typically seq 1 or 2). Scan
the first few parts and return the first ``role="user"`` content
that isn't the greeting. Bounded at 5 parts to stay cheap on
directory listings containing hundreds of workers.
"""
parts_dir = worker_dir / "conversations" / "parts"
if not parts_dir.exists():
return ""
try:
for i in range(5):
p = parts_dir / f"{i:010d}.json"
if not p.exists():
break
data = json.loads(p.read_text(encoding="utf-8"))
if data.get("role") != "user":
continue
content = data.get("content", "")
if not isinstance(content, str):
continue
text = content.strip()
if not text or text.lower() == "hello":
continue
return text[:400]
except Exception:
return ""
return ""
# ── Skills & tools ─────────────────────────────────────────────────
def _parsed_skill_to_dict(skill) -> dict:
+6 -20
View File
@@ -311,24 +311,6 @@ def _payload_change_signature(payload: list[dict]) -> tuple:
)
async def handle_list_live_workers(request: web.Request) -> web.Response:
"""GET /api/sessions/{session_id}/workers — list live workers.
Returns an array of ``{worker_id, task, status, started_at, duration_seconds,
is_active}`` objects. Active workers come first. The queen overseer
(persistent worker) is included because the frontend should know it
exists, but the stop action on it is a session-level kill the UI
should treat it differently (not offered here).
"""
session, err = resolve_session(request)
if err:
return err
colony = _active_colony(session)
payload = _build_live_workers_payload(colony)
return web.json_response({"workers": payload})
async def handle_live_workers_stream(request: web.Request) -> web.StreamResponse:
"""GET /api/sessions/{session_id}/workers/stream — SSE feed.
@@ -477,8 +459,12 @@ def register_routes(app: web.Application) -> None:
"/api/sessions/{session_id}/colonies/{colony_id}/nodes/{node_id}/tools",
handle_node_tools,
)
# Live worker control
app.router.add_get("/api/sessions/{session_id}/workers", handle_list_live_workers)
# Live worker control. The GET /workers list endpoint lives in
# routes_colony_workers.py — it reads from session.colony (the
# unified ColonyRuntime where run_parallel_workers-spawned workers
# actually live) and returns the WorkerSummary shape the frontend
# types against. Registering a duplicate here shadowed it in
# aiohttp's router and broke the Sessions tab.
app.router.add_get(
"/api/sessions/{session_id}/workers/stream", handle_live_workers_stream
)
@@ -8,6 +8,13 @@ import {
ChevronRight,
ChevronDown,
ArrowLeft,
Square,
Play,
Clock,
Webhook,
Zap,
Activity,
Loader2,
} from "lucide-react";
import {
colonyWorkersApi,
@@ -17,13 +24,18 @@ import {
type ProgressStep,
type WorkerSummary,
} from "@/api/colonyWorkers";
import { workersApi } from "@/api/workers";
import { sessionsApi } from "@/api/sessions";
import { cronToLabel } from "@/lib/graphUtils";
import type { GraphNode } from "@/components/graph-types";
import { useColonyWorkers } from "@/context/ColonyWorkersContext";
interface ColonyWorkersPanelProps {
sessionId: string;
onClose: () => void;
}
type TabKey = "skills" | "tools" | "sessions";
type TabKey = "skills" | "tools" | "sessions" | "triggers";
function statusClasses(status: string): string {
const s = status.toLowerCase();
@@ -64,7 +76,7 @@ export default function ColonyWorkersPanel({
sessionId,
onClose,
}: ColonyWorkersPanelProps) {
const [tab, setTab] = useState<TabKey>("skills");
const [tab, setTab] = useState<TabKey>("sessions");
// ── Resizable width (mirrors QueenProfilePanel) ─────────────────────
const MIN_WIDTH = 280;
@@ -126,15 +138,17 @@ export default function ColonyWorkersPanel({
{/* Tab bar */}
<div className="flex border-b border-border/60 flex-shrink-0">
<TabButton active={tab === "sessions"} onClick={() => setTab("sessions")} label="Sessions" />
<TabButton active={tab === "triggers"} onClick={() => setTab("triggers")} label="Triggers" />
<TabButton active={tab === "skills"} onClick={() => setTab("skills")} label="Skills" />
<TabButton active={tab === "tools"} onClick={() => setTab("tools")} label="Tools" />
<TabButton active={tab === "sessions"} onClick={() => setTab("sessions")} label="Sessions" />
</div>
<div className="flex-1 overflow-y-auto">
{tab === "sessions" && <SessionsTab sessionId={sessionId} />}
{tab === "triggers" && <TriggersTab sessionId={sessionId} />}
{tab === "skills" && <SkillsTab sessionId={sessionId} />}
{tab === "tools" && <ToolsTab sessionId={sessionId} />}
{tab === "sessions" && <SessionsTab sessionId={sessionId} />}
</div>
</aside>
);
@@ -450,6 +464,8 @@ function SessionsTab({ sessionId }: { sessionId: string }) {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selected, setSelected] = useState<string | null>(null);
const [stoppingId, setStoppingId] = useState<string | null>(null);
const [stoppingAll, setStoppingAll] = useState(false);
const refresh = useCallback(() => {
setLoading(true);
@@ -465,11 +481,77 @@ function SessionsTab({ sessionId }: { sessionId: string }) {
refresh();
}, [refresh]);
// Light poll so live workers tick their duration/status without the
// user hitting refresh. 2s matches the cadence of the standalone
// WorkersPanel this tab replaces.
useEffect(() => {
const id = setInterval(() => {
colonyWorkersApi
.list(sessionId)
.then((r) => setWorkers(r.workers))
.catch(() => {
/* swallow poll-time errors; the next tick retries. */
});
}, 2000);
return () => clearInterval(id);
}, [sessionId]);
const selectedWorker = useMemo(
() => (selected ? workers.find((w) => w.worker_id === selected) : null),
[selected, workers],
);
const stopOne = useCallback(
async (workerId: string) => {
setStoppingId(workerId);
try {
await workersApi.stopLive(sessionId, workerId);
} catch {
/* next poll reflects truth */
} finally {
setStoppingId(null);
refresh();
}
},
[sessionId, refresh],
);
const stopAll = useCallback(async () => {
setStoppingAll(true);
try {
await workersApi.stopAllLive(sessionId);
} catch {
/* ignore */
} finally {
setStoppingAll(false);
refresh();
}
}, [sessionId, refresh]);
// Split into active / history buckets — active workers are hoisted
// to the top and rendered with a primary-tinted card so the user's
// attention lands there first. History stays visible but muted so
// prior runs stay auditable without competing for focus.
//
// NB: this useMemo MUST run on every render (no conditional
// early-return before it) — React's Rules of Hooks require a
// stable hook order. Previously we returned early on `selected`
// BEFORE calling useMemo, which produced React error #300 in
// the minified prod build the moment the user drilled into a
// worker detail view.
const { activeWorkers, historyWorkers } = useMemo(() => {
const act: WorkerSummary[] = [];
const hist: WorkerSummary[] = [];
for (const w of workers) {
(isWorkerActive(w) ? act : hist).push(w);
}
const byRecent = (a: WorkerSummary, b: WorkerSummary) =>
(b.started_at || 0) - (a.started_at || 0);
act.sort(byRecent);
hist.sort(byRecent);
return { activeWorkers: act, historyWorkers: hist };
}, [workers]);
if (selected) {
return (
<WorkerDetail
@@ -481,41 +563,165 @@ function SessionsTab({ sessionId }: { sessionId: string }) {
);
}
return (
<TabShell loading={loading} error={error} onRefresh={refresh} empty={workers.length === 0 ? "No workers spawned yet." : null}>
<ul className="flex flex-col gap-1.5">
{workers.map((w) => (
<li key={w.worker_id}>
<button
onClick={() => setSelected(w.worker_id)}
className="w-full text-left rounded-lg border border-border/60 bg-background/40 px-3 py-2.5 hover:bg-muted/30 transition-colors"
const activeCount = activeWorkers.length;
const renderCard = (w: WorkerSummary, active: boolean) => (
<li key={w.worker_id}>
<div
className={`rounded-lg border transition-colors ${
active
? "border-primary/40 bg-primary/[0.06] ring-1 ring-primary/20 hover:bg-primary/10"
: "border-border/40 bg-background/20 opacity-80 hover:bg-muted/20 hover:opacity-100"
}`}
>
<button
onClick={() => setSelected(w.worker_id)}
className="w-full text-left px-3 py-2.5"
>
<div className="flex items-center justify-between mb-1 gap-2">
<code
className={`text-xs font-mono ${active ? "text-foreground" : "text-foreground/70"}`}
>
<div className="flex items-center justify-between mb-1 gap-2">
<code className="text-xs font-mono text-foreground">{shortId(w.worker_id)}</code>
<div className="flex items-center gap-1">
<span
className={`text-[10px] px-1.5 py-0.5 rounded-full font-medium ${statusClasses(w.status)}`}
>
{w.status}
</span>
<ChevronRight className="w-3 h-3 text-muted-foreground" />
</div>
</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>
{shortId(w.worker_id)}
</code>
<div className="flex items-center gap-1">
<span
className={`text-[10px] px-1.5 py-0.5 rounded-full font-medium ${statusClasses(w.status)}`}
>
{w.status}
</span>
<ChevronRight className="w-3 h-3 text-muted-foreground" />
</div>
</div>
{w.task && (
<p
className={`text-xs line-clamp-2 mb-1 ${
active ? "text-foreground/85" : "text-foreground/60"
}`}
>
{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>
</button>
{active && (
<div className="border-t border-primary/20 px-3 py-1.5 flex justify-end">
<button
onClick={(e) => {
e.stopPropagation();
stopOne(w.worker_id);
}}
disabled={stoppingId === w.worker_id}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded border border-destructive/40 text-destructive text-[10px] hover:bg-destructive/10 disabled:opacity-50 transition-colors"
title="Stop this worker"
>
<Square className="w-2.5 h-2.5" />
{stoppingId === w.worker_id ? "Stopping…" : "Stop"}
</button>
</div>
)}
</div>
</li>
);
return (
<TabShell
loading={loading}
error={error}
onRefresh={refresh}
empty={workers.length === 0 ? "No workers spawned yet." : null}
headerRight={
activeCount > 0 ? (
<button
onClick={stopAll}
disabled={stoppingAll}
className="text-[10px] px-2 py-0.5 rounded border border-destructive/40 text-destructive hover:bg-destructive/10 disabled:opacity-50 transition-colors"
title={`Stop ${activeCount} active worker${activeCount === 1 ? "" : "s"}`}
>
{stoppingAll ? "Stopping…" : `Stop all (${activeCount})`}
</button>
) : null
}
>
<div className="flex flex-col gap-3">
{activeWorkers.length > 0 && (
<section>
<h4 className="text-[10px] uppercase tracking-wide font-semibold text-primary mb-1.5 flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-primary animate-pulse" />
Active ({activeWorkers.length})
</h4>
<ul className="flex flex-col gap-1.5">
{activeWorkers.map((w) => renderCard(w, true))}
</ul>
</section>
)}
{historyWorkers.length > 0 && (
<section>
<h4 className="text-[10px] uppercase tracking-wide font-semibold text-muted-foreground mb-1.5">
History ({historyWorkers.length})
</h4>
<ul className="flex flex-col gap-1.5">
{historyWorkers.map((w) => renderCard(w, false))}
</ul>
</section>
)}
</div>
</TabShell>
);
}
function isWorkerActive(w: WorkerSummary): boolean {
const s = (w.status || "").toLowerCase();
return s === "pending" || s === "running";
}
// ── Triggers tab ───────────────────────────────────────────────────────
function TriggersTab({ sessionId }: { sessionId: string }) {
const { triggers } = useColonyWorkers();
const [selectedId, setSelectedId] = useState<string | null>(null);
const selected = useMemo(
() => (selectedId ? triggers.find((t) => t.id === selectedId) ?? null : null),
[selectedId, triggers],
);
if (selected) {
return (
<TriggerDetail
sessionId={sessionId}
trigger={selected}
onBack={() => setSelectedId(null)}
/>
);
}
return (
<TabShell
loading={false}
error={null}
onRefresh={() => {
/* triggers come from SSE in the colony page — no pull-refresh needed */
}}
empty={
triggers.length === 0
? "No triggers configured. Ask the queen to set a schedule or webhook."
: null
}
>
<ul className="flex flex-col gap-1.5">
{triggers.map((t) => (
<li key={t.id}>
<TriggerCard trigger={t} onClick={() => setSelectedId(t.id)} />
</li>
))}
</ul>
@@ -523,6 +729,261 @@ function SessionsTab({ sessionId }: { sessionId: string }) {
);
}
function triggerIsActive(t: GraphNode): boolean {
return t.status === "running" || t.status === "complete";
}
function TriggerIcon({ type }: { type?: string }) {
const cls = "w-3.5 h-3.5";
switch (type) {
case "webhook":
return <Webhook className={cls} />;
case "timer":
return <Clock className={cls} />;
case "api":
return <ChevronRight className={cls} />;
case "event":
return <Activity className={cls} />;
default:
return <Zap className={cls} />;
}
}
function scheduleLabel(config: Record<string, unknown> | undefined): string | null {
if (!config) return null;
const cron = config.cron as string | undefined;
if (cron) return cronToLabel(cron);
const interval = config.interval_minutes as number | undefined;
if (interval != null) {
if (interval >= 60) return `Every ${interval / 60}h`;
return `Every ${interval}m`;
}
return null;
}
function countdownLabel(nextFireIn: number | undefined): string | null {
if (nextFireIn == null || nextFireIn <= 0) return null;
const h = Math.floor(nextFireIn / 3600);
const m = Math.floor((nextFireIn % 3600) / 60);
const s = Math.floor(nextFireIn % 60);
return h > 0
? `next in ${h}h ${String(m).padStart(2, "0")}m`
: `next in ${m}m ${String(s).padStart(2, "0")}s`;
}
function TriggerCard({ trigger, onClick }: { trigger: GraphNode; onClick: () => void }) {
const isActive = triggerIsActive(trigger);
const schedule = scheduleLabel(trigger.triggerConfig);
const nextFireIn = trigger.triggerConfig?.next_fire_in as number | undefined;
const countdown = isActive ? countdownLabel(nextFireIn) : null;
return (
<button
onClick={onClick}
className="w-full text-left rounded-lg border border-border/60 bg-background/40 px-3 py-2.5 hover:bg-muted/30 transition-colors"
>
<div className="flex items-center gap-2">
<span
className={`flex-shrink-0 w-6 h-6 rounded-full flex items-center justify-center ${
isActive ? "bg-primary/15 text-primary" : "bg-muted/60 text-muted-foreground"
}`}
>
<TriggerIcon type={trigger.triggerType} />
</span>
<div className="min-w-0 flex-1">
<p className="text-xs font-medium text-foreground truncate">{trigger.label}</p>
{schedule && schedule !== trigger.label && (
<p className="text-[10.5px] text-muted-foreground truncate mt-0.5">{schedule}</p>
)}
</div>
<span
className={`flex-shrink-0 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"
}`}
>
{isActive ? "active" : "inactive"}
</span>
</div>
{countdown && (
<p className="text-[10px] text-muted-foreground mt-1.5 italic pl-8">{countdown}</p>
)}
</button>
);
}
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`;
}
function TriggerDetail({
sessionId,
trigger,
onBack,
}: {
sessionId: string;
trigger: GraphNode;
onBack: () => void;
}) {
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const isActive = triggerIsActive(trigger);
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 triggerId = trigger.id.replace(/^__trigger_/, "");
const handleToggle = async () => {
if (!sessionId || busy) return;
setBusy(true);
setError(null);
try {
if (isActive) {
await sessionsApi.deactivateTrigger(sessionId, triggerId);
} else {
await sessionsApi.activateTrigger(sessionId, triggerId);
}
// SSE TRIGGER_ACTIVATED / TRIGGER_DEACTIVATED flips the card
// state in the context; we don't set local state.
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
};
const schedule = cron
? cronToLabel(cron)
: interval != null
? interval >= 60
? `Every ${interval / 60}h`
: `Every ${interval}m`
: null;
// Hide UI-synthesised fields so the user sees only real operator config.
const displayEntries = Object.entries(config).filter(
([k]) => k !== "next_fire_in" && k !== "entry_node",
);
return (
<div className="px-4 py-3">
<button
onClick={onBack}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground mb-3"
>
<ArrowLeft className="w-3 h-3" />
All triggers
</button>
<div className="rounded-lg border border-border/60 bg-background/40 px-3 py-2.5 mb-3">
<div className="flex items-start gap-2.5 mb-2">
<div
className={`w-7 h-7 rounded-lg flex items-center justify-center flex-shrink-0 ${
isActive ? "bg-primary/15 text-primary" : "bg-muted/50 text-muted-foreground"
}`}
>
<TriggerIcon type={trigger.triggerType} />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-foreground leading-tight truncate">
{trigger.label}
</p>
<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"
}`}
>
{isActive ? "active" : "inactive"}
</span>
{trigger.triggerType && (
<span className="text-[10px] text-muted-foreground uppercase tracking-wider">
{trigger.triggerType}
</span>
)}
</div>
</div>
</div>
</div>
{schedule && (
<Section label="Schedule">
<p className="text-xs text-foreground">{schedule}</p>
{cron && <p className="text-[10px] text-muted-foreground mt-1 font-mono">{cron}</p>}
</Section>
)}
{isActive && nextFireIn != null && nextFireIn > 0 && (
<Section label="Next fire">
<p className="text-xs text-foreground italic">in {formatCountdown(nextFireIn)}</p>
</Section>
)}
{displayEntries.length > 0 && (
<Section label="Config">
<div className="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>
</Section>
)}
<Section label="Trigger ID">
<p className="text-[11px] text-muted-foreground font-mono break-all">{triggerId}</p>
</Section>
{error && (
<p className="text-[10.5px] text-destructive leading-snug mb-2">{error}</p>
)}
<button
type="button"
onClick={handleToggle}
disabled={busy || !sessionId}
className={`w-full flex items-center justify-center gap-1.5 px-3 py-2 rounded-lg text-xs font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
isActive
? "bg-muted/50 text-foreground hover:bg-muted/70 border border-border/30"
: "bg-primary/15 text-primary hover:bg-primary/25 border border-primary/30"
}`}
>
{busy ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : isActive ? (
<Square className="w-3.5 h-3.5" />
) : (
<Play className="w-3.5 h-3.5" />
)}
{busy ? "Working…" : isActive ? "Stop trigger" : "Start trigger"}
</button>
</div>
);
}
function Section({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="mb-3">
<p className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider mb-1.5">
{label}
</p>
<div className="rounded-lg border border-border/30 bg-background/60 px-3 py-2.5">
{children}
</div>
</div>
);
}
// ── Worker detail view (inside Sessions tab) ───────────────────────────
function WorkerDetail({
@@ -536,7 +997,14 @@ function WorkerDetail({
workerId: string;
onBack: () => void;
}) {
const { snapshot, streamState, error } = useProgressStream(sessionId, workerId);
// Historical workers (loaded from disk rather than live memory) have
// no live progress.db stream to attach to — opening the SSE just
// renders "No progress rows yet." forever, which is what the user
// was calling "middle of nowhere". Skip the stream and show the
// result summary + an archived-conversation hint instead.
const isHistorical =
worker?.status === "historical" ||
(worker != null && !isWorkerActive(worker) && worker.result == null);
return (
<div className="px-4 py-3">
@@ -565,9 +1033,41 @@ function WorkerDetail({
{worker?.result?.duration_seconds
? ` · ${worker.result.duration_seconds.toFixed(1)}s`
: ""}
{worker?.result?.tokens_used
? ` · ${worker.result.tokens_used.toLocaleString()} tok`
: ""}
</div>
{worker?.result?.summary && (
<p className="mt-2 text-xs text-foreground/90 border-t border-border/40 pt-2">
{worker.result.summary}
</p>
)}
{worker?.result?.error && (
<p className="mt-2 text-xs text-destructive border-t border-destructive/30 pt-2">
{worker.result.error}
</p>
)}
</div>
{isHistorical ? (
<HistoricalWorkerPlaceholder workerId={workerId} />
) : (
<LiveWorkerProgress sessionId={sessionId} workerId={workerId} />
)}
</div>
);
}
function LiveWorkerProgress({
sessionId,
workerId,
}: {
sessionId: string;
workerId: string;
}) {
const { snapshot, streamState, error } = useProgressStream(sessionId, workerId);
return (
<>
<div className="flex items-center justify-between mb-1.5">
<div className="flex items-center gap-1.5 text-xs font-semibold text-foreground/90">
<Database className="w-3.5 h-3.5 text-primary" />
@@ -583,6 +1083,24 @@ function WorkerDetail({
)}
<ProgressView snapshot={snapshot} />
</>
);
}
function HistoricalWorkerPlaceholder({ workerId }: { workerId: string }) {
return (
<div className="rounded-lg border border-border/40 bg-background/30 px-3 py-4 text-xs text-muted-foreground space-y-1.5">
<p className="text-foreground/80">This worker has finished.</p>
<p>
Live progress is no longer streaming. The worker's full conversation is
archived under{" "}
<code className="text-[11px] font-mono text-foreground/80">
workers/{shortId(workerId)}/conversations/
</code>{" "}
in the session data folder use the{" "}
<span className="text-foreground/80 font-medium">Data</span> button in
the header to open it.
</p>
</div>
);
}
@@ -767,17 +1285,20 @@ function TabShell({
error,
onRefresh,
empty,
headerRight,
children,
}: {
loading: boolean;
error: string | null;
onRefresh: () => void;
empty: string | null;
headerRight?: React.ReactNode;
children: React.ReactNode;
}) {
return (
<div className="px-4 py-3">
<div className="flex justify-end mb-2">
<div className="flex items-center justify-between gap-2 mb-2">
<div>{headerRight}</div>
<button
onClick={onRefresh}
className="p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors"
@@ -1,211 +0,0 @@
import { useState } from "react";
import { X, Webhook, Clock, Activity, ArrowRight, Zap, Play, Square, Loader2 } from "lucide-react";
import type { GraphNode } from "./graph-types";
import { cronToLabel } from "@/lib/graphUtils";
import { sessionsApi } from "@/api/sessions";
interface TriggerDetailPanelProps {
trigger: GraphNode;
sessionId: string;
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, sessionId, onClose }: TriggerDetailPanelProps) {
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
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 triggerId = trigger.id.replace(/^__trigger_/, "");
const handleToggle = async () => {
if (!sessionId || busy) return;
setBusy(true);
setError(null);
try {
if (isActive) {
await sessionsApi.deactivateTrigger(sessionId, triggerId);
} else {
await sessionsApi.activateTrigger(sessionId, triggerId);
}
// The SSE TRIGGER_ACTIVATED / TRIGGER_DEACTIVATED event will flip
// the card status; we don't need to set local state here.
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
setError(msg);
} finally {
setBusy(false);
}
};
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">
{triggerId}
</p>
</div>
</div>
</div>
{/* Footer with start/stop control */}
<div className="px-4 py-3 border-t border-border/30 flex-shrink-0 space-y-2">
{error && (
<p className="text-[10.5px] text-red-400 leading-snug">{error}</p>
)}
<button
type="button"
onClick={handleToggle}
disabled={busy || !sessionId}
className={[
"w-full flex items-center justify-center gap-1.5 px-3 py-2 rounded-lg text-xs font-medium transition-colors",
"disabled:opacity-50 disabled:cursor-not-allowed",
isActive
? "bg-muted/50 text-foreground hover:bg-muted/70 border border-border/30"
: "bg-primary/15 text-primary hover:bg-primary/25 border border-primary/30",
].join(" ")}
>
{busy ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : isActive ? (
<Square className="w-3.5 h-3.5" />
) : (
<Play className="w-3.5 h-3.5" />
)}
{busy ? "Working…" : isActive ? "Stop trigger" : "Start trigger"}
</button>
</div>
</div>
);
}
@@ -1,143 +0,0 @@
import { Clock, Webhook, Zap, ArrowRight, Activity } from "lucide-react";
import type { GraphNode } from "./graph-types";
import { cronToLabel } from "@/lib/graphUtils";
interface TriggersPanelProps {
triggers: GraphNode[];
selectedId?: string | null;
onSelect?: (trigger: GraphNode) => void;
}
function TriggerIcon({ type }: { type?: string }) {
const cls = "w-3.5 h-3.5";
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 scheduleLabel(config: Record<string, unknown> | undefined): string | null {
if (!config) return null;
const cron = config.cron as string | undefined;
if (cron) return cronToLabel(cron);
const interval = config.interval_minutes as number | undefined;
if (interval != null) {
if (interval >= 60) return `Every ${interval / 60}h`;
return `Every ${interval}m`;
}
return null;
}
function countdownLabel(nextFireIn: number | undefined): string | null {
if (nextFireIn == null || nextFireIn <= 0) return null;
const h = Math.floor(nextFireIn / 3600);
const m = Math.floor((nextFireIn % 3600) / 60);
const s = Math.floor(nextFireIn % 60);
return h > 0
? `next in ${h}h ${String(m).padStart(2, "0")}m`
: `next in ${m}m ${String(s).padStart(2, "0")}s`;
}
function TriggerCard({
trigger,
selected,
onClick,
}: {
trigger: GraphNode;
selected: boolean;
onClick?: () => void;
}) {
const isActive = trigger.status === "running" || trigger.status === "complete";
const schedule = scheduleLabel(trigger.triggerConfig);
const nextFireIn = trigger.triggerConfig?.next_fire_in as number | undefined;
const countdown = isActive ? countdownLabel(nextFireIn) : null;
return (
<button
type="button"
onClick={onClick}
className={[
"w-full text-left rounded-lg border px-3 py-2.5 transition-colors",
selected
? "bg-primary/10 border-primary/30"
: "bg-background/60 border-border/30 hover:bg-muted/40 hover:border-border/50",
].join(" ")}
>
<div className="flex items-center gap-2">
<span
className={[
"flex-shrink-0 w-6 h-6 rounded-full flex items-center justify-center",
isActive ? "bg-primary/15 text-primary" : "bg-muted/60 text-muted-foreground",
].join(" ")}
>
<TriggerIcon type={trigger.triggerType} />
</span>
<div className="min-w-0 flex-1">
<p className="text-xs font-medium text-foreground truncate">{trigger.label}</p>
{schedule && schedule !== trigger.label && (
<p className="text-[10.5px] text-muted-foreground truncate mt-0.5">{schedule}</p>
)}
</div>
<span
className={[
"flex-shrink-0 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>
</div>
{countdown && (
<p className="text-[10px] text-muted-foreground mt-1.5 italic pl-8">{countdown}</p>
)}
</button>
);
}
export default function TriggersPanel({ triggers, selectedId, onSelect }: TriggersPanelProps) {
return (
<div className="flex flex-col h-full bg-card/30 border-l border-border/30">
<div className="px-4 py-3 border-b border-border/30 flex items-center gap-2">
<Clock className="w-3.5 h-3.5 text-muted-foreground" />
<h3 className="text-xs font-semibold text-foreground uppercase tracking-wide">
Triggers
</h3>
{triggers.length > 0 && (
<span className="ml-auto text-[10px] text-muted-foreground">
{triggers.length}
</span>
)}
</div>
<div className="flex-1 overflow-y-auto px-3 py-3 space-y-2">
{triggers.length === 0 ? (
<div className="text-center py-8">
<Clock className="w-6 h-6 mx-auto text-muted-foreground/40 mb-2" />
<p className="text-[11px] text-muted-foreground">No triggers configured</p>
<p className="text-[10px] text-muted-foreground/70 mt-1 px-2">
Ask the queen to set a schedule or webhook
</p>
</div>
) : (
triggers.map((t) => (
<TriggerCard
key={t.id}
trigger={t}
selected={selectedId === t.id}
onClick={onSelect ? () => onSelect(t) : undefined}
/>
))
)}
</div>
</div>
);
}
@@ -1,183 +0,0 @@
import { useEffect, useState, useCallback } from "react";
import { Loader2, Square, XCircle, CheckCircle2, OctagonX } from "lucide-react";
import { workersApi, type LiveWorker } from "@/api/workers";
interface WorkersPanelProps {
sessionId: string | null;
}
function statusClassName(w: LiveWorker): string {
if (w.is_active) return "text-blue-600";
const s = (w.result_status || w.status || "").toLowerCase();
if (s.includes("success")) return "text-emerald-600";
if (s.includes("fail") || s.includes("error")) return "text-destructive";
if (s.includes("stop") || s.includes("timeout")) return "text-amber-600";
return "text-muted-foreground";
}
function StatusIcon({ worker }: { worker: LiveWorker }) {
const cls = `w-3.5 h-3.5 ${statusClassName(worker)}`;
if (worker.is_active) return <Loader2 className={`${cls} animate-spin`} />;
const s = (worker.result_status || worker.status || "").toLowerCase();
if (s.includes("success")) return <CheckCircle2 className={cls} />;
if (s.includes("fail") || s.includes("error")) return <XCircle className={cls} />;
if (s.includes("stop") || s.includes("timeout")) return <OctagonX className={cls} />;
return <CheckCircle2 className={cls} />;
}
function formatDuration(seconds: number): string {
if (seconds < 60) return `${Math.round(seconds)}s`;
const m = Math.floor(seconds / 60);
const s = Math.round(seconds % 60);
return `${m}m ${String(s).padStart(2, "0")}s`;
}
export default function WorkersPanel({ sessionId }: WorkersPanelProps) {
const [workers, setWorkers] = useState<LiveWorker[]>([]);
const [loading, setLoading] = useState(false);
const [stoppingId, setStoppingId] = useState<string | null>(null);
const [stoppingAll, setStoppingAll] = useState(false);
// SSE stream — the backend polls the runtime internally and only
// emits on change, so this is both live and cheap. ``loading`` is
// true until the first snapshot arrives.
useEffect(() => {
if (!sessionId) {
setWorkers([]);
setLoading(false);
return;
}
setLoading(true);
const es = new EventSource(`/api/sessions/${sessionId}/workers/stream`);
es.addEventListener("snapshot", (e) => {
try {
const data = JSON.parse((e as MessageEvent).data) as { workers: LiveWorker[] };
setWorkers(data.workers || []);
setLoading(false);
} catch {
/* ignore malformed frame */
}
});
es.onerror = () => {
// EventSource auto-reconnects. Flip loading off so the UI
// shows "no workers" rather than a perpetual loader during a
// backend blip.
setLoading(false);
};
return () => es.close();
}, [sessionId]);
const stopOne = useCallback(
async (workerId: string) => {
if (!sessionId) return;
setStoppingId(workerId);
try {
await workersApi.stopLive(sessionId, workerId);
} catch {
// Non-fatal — the SSE stream will reflect the true state.
} finally {
setStoppingId(null);
}
},
[sessionId],
);
const stopAll = useCallback(async () => {
if (!sessionId) return;
setStoppingAll(true);
try {
await workersApi.stopAllLive(sessionId);
} catch {
// ignore
} finally {
setStoppingAll(false);
}
}, [sessionId]);
const activeCount = workers.filter((w) => w.is_active).length;
return (
<div className="h-full flex flex-col border-l border-border bg-card/30">
<div className="px-3 py-2 border-b border-border flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Workers
</span>
{activeCount > 0 && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-blue-500/15 text-blue-600 font-medium">
{activeCount} active
</span>
)}
</div>
{activeCount > 0 && (
<button
onClick={stopAll}
disabled={stoppingAll}
className="text-[10px] px-2 py-0.5 rounded border border-destructive/40 text-destructive hover:bg-destructive/10 disabled:opacity-50 transition-colors"
title="Stop all active workers"
>
{stoppingAll ? "Stopping…" : "Stop all"}
</button>
)}
</div>
<div className="flex-1 overflow-y-auto p-2 space-y-1.5">
{loading && workers.length === 0 && (
<div className="text-xs text-muted-foreground p-2">Loading</div>
)}
{!loading && workers.length === 0 && (
<div className="text-xs text-muted-foreground p-2">
No workers have been spawned in this session yet.
</div>
)}
{workers.map((w) => (
<div
key={w.worker_id}
className="rounded border border-border/60 bg-background/70 p-2 text-xs"
>
<div className="flex items-start gap-2">
<StatusIcon worker={w} />
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<span
className="font-mono text-[10px] text-muted-foreground truncate"
title={w.worker_id}
>
{w.worker_id.slice(0, 24)}
</span>
<span className={`text-[10px] ${statusClassName(w)}`}>
{w.is_active ? w.status : (w.result_status || w.status)}
</span>
</div>
<div className="mt-1 text-[11px] text-foreground/90 line-clamp-2">
{w.task || "(no task)"}
</div>
<div className="mt-1 flex items-center justify-between gap-2 text-[10px] text-muted-foreground">
<span>{formatDuration(w.duration_seconds)}</span>
{w.is_active && (
<button
onClick={() => stopOne(w.worker_id)}
disabled={stoppingId === w.worker_id}
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded border border-destructive/40 text-destructive hover:bg-destructive/10 disabled:opacity-50 transition-colors"
title="Stop this worker"
>
<Square className="w-2.5 h-2.5" />
{stoppingId === w.worker_id ? "…" : "Stop"}
</button>
)}
</div>
{w.result_summary && !w.is_active && (
<div className="mt-1 text-[10px] text-muted-foreground line-clamp-2 italic">
{w.result_summary}
</div>
)}
</div>
</div>
</div>
))}
</div>
</div>
);
}
@@ -1,24 +1,69 @@
import { createContext, useContext, useCallback, type ReactNode } from "react";
import {
createContext,
useCallback,
useContext,
useState,
type ReactNode,
} from "react";
import type { GraphNode } from "@/components/graph-types";
interface ColonyWorkersContextValue {
openColonyWorkers: (sessionId: string) => void;
/** The colony session the tabbed panel should attach to. Set by
* whichever page owns a colony session (colony-chat today). The
* panel auto-renders whenever this is non-null AND the user hasn't
* dismissed it for the current session. */
sessionId: string | null;
setSessionId: (sessionId: string | null) => void;
/** User dismissal: flipped by the panel's close button. Reset when
* sessionId changes (so the panel re-opens on the next colony visit
* / tab-switch) or when the header toggle re-requests it. */
dismissed: boolean;
/** Toggles the panel. When the panel is currently visible we dismiss
* it; when hidden we un-dismiss. Both actions are no-ops if there's
* no active sessionId — the header button only matters inside a
* colony room. */
toggleColonyWorkers: () => void;
/** Current session's triggers, pushed from whichever page is active
* (colony-chat today). ``ColonyWorkersPanel`` reads these to render
* its Triggers tab without having to re-subscribe to SSE itself. */
triggers: GraphNode[];
setTriggers: (triggers: GraphNode[]) => 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],
);
export function ColonyWorkersProvider({ children }: { children: ReactNode }) {
const [sessionId, setSessionIdState] = useState<string | null>(null);
const [dismissed, setDismissed] = useState(false);
const [triggers, setTriggers] = useState<GraphNode[]>([]);
const setSessionId = useCallback((next: string | null) => {
setSessionIdState((prev) => {
// Reset dismissal whenever the active session changes so entering
// a new colony opens the panel again even if the user closed it
// in the previous room.
if (prev !== next) setDismissed(false);
return next;
});
}, []);
const toggleColonyWorkers = useCallback(() => {
setDismissed((d) => !d);
}, []);
return (
<ColonyWorkersContext.Provider value={{ openColonyWorkers }}>
<ColonyWorkersContext.Provider
value={{
sessionId,
setSessionId,
dismissed,
toggleColonyWorkers,
triggers,
setTriggers,
}}
>
{children}
</ColonyWorkersContext.Provider>
);
+61 -43
View File
@@ -1,4 +1,4 @@
import { useEffect, useState, useCallback } from "react";
import { useEffect, useState, useCallback, type ReactNode } from "react";
import { Outlet, useLocation } from "react-router-dom";
import Sidebar from "@/components/Sidebar";
import AppHeader from "@/components/AppHeader";
@@ -7,13 +7,18 @@ 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";
import {
ColonyWorkersProvider,
useColonyWorkers,
} from "@/context/ColonyWorkersContext";
export default function AppLayout() {
return (
<ColonyProvider>
<HeaderActionsProvider>
<AppLayoutInner />
<ColonyWorkersProvider>
<AppLayoutInner />
</ColonyWorkersProvider>
</HeaderActionsProvider>
</ColonyProvider>
);
@@ -23,15 +28,10 @@ function AppLayoutInner() {
const { colonies } = useColony();
const location = useLocation();
const [openQueenId, setOpenQueenId] = useState<string | null>(null);
const [openWorkersSessionId, setOpenWorkersSessionId] = useState<string | null>(
null,
);
// Close side panels whenever the route changes so they don't bleed
// across pages (panel state lives at the layout level).
// Queen profile closes on route change (it's a per-queen view).
useEffect(() => {
setOpenQueenId(null);
setOpenWorkersSessionId(null);
}, [location.pathname]);
const handleOpenQueenProfile = useCallback(
@@ -39,42 +39,60 @@ function AppLayoutInner() {
[],
);
const handleOpenColonyWorkers = useCallback(
(sessionId: string) =>
setOpenWorkersSessionId((prev) => (prev === sessionId ? null : sessionId)),
[],
);
return (
<QueenProfileProvider onOpen={handleOpenQueenProfile}>
<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>
</ColonyWorkersProvider>
<LayoutShell
openQueenId={openQueenId}
onCloseQueenProfile={() => setOpenQueenId(null)}
onOpenQueenProfile={handleOpenQueenProfile}
colonies={colonies}
/>
</QueenProfileProvider>
);
}
function LayoutShell({
openQueenId,
onCloseQueenProfile,
onOpenQueenProfile,
colonies,
}: {
openQueenId: string | null;
onCloseQueenProfile: () => void;
onOpenQueenProfile: (queenId: string) => void;
colonies: ReturnType<typeof useColony>["colonies"];
}) {
const { sessionId, dismissed, toggleColonyWorkers } = useColonyWorkers();
const showWorkersPanel = Boolean(sessionId && !dismissed);
return (
<div className="flex h-screen bg-background overflow-hidden">
<Sidebar />
<div className="flex-1 min-w-0 flex flex-col">
<AppHeader onOpenQueenProfile={onOpenQueenProfile} />
<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={onCloseQueenProfile}
/>
)}
{showWorkersPanel && sessionId && (
<ColonyWorkersPanel
sessionId={sessionId}
onClose={toggleColonyWorkers}
/>
)}
</div>
</div>
</div>
);
}
// Re-exported so tsc sees React used (removes import-only warning when
// the file compiles down to JSX-less output).
export type { ReactNode };
+28 -46
View File
@@ -2,9 +2,6 @@ import { useState, useCallback, useRef, useEffect, useMemo } from "react";
import { useParams, useLocation } from "react-router-dom";
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";
import WorkersPanel from "@/components/WorkersPanel";
import ChatPanel, { type ChatMessage, type ImageContent } from "@/components/ChatPanel";
import CredentialsModal, {
type Credential,
@@ -197,7 +194,7 @@ export default function ColonyChat() {
const location = useLocation();
const { colonies, markVisited, refresh: refreshColonies } = useColony();
const { setActions } = useHeaderActions();
const { openColonyWorkers } = useColonyWorkers();
const { toggleColonyWorkers } = useColonyWorkers();
// Route state from home page (new chat flow)
const routeState = (location.state || {}) as {
@@ -244,7 +241,6 @@ export default function ColonyChat() {
const [credentialsOpen, setCredentialsOpen] = useState(false);
const [credentialAgentPath, setCredentialAgentPath] = useState<string | null>(null);
const [dismissedBanner, setDismissedBanner] = useState<string | null>(null);
const [selectedNode, setSelectedNode] = useState<GraphNode | null>(null);
// ── Header actions (Credentials, Data, Browser) ─────────────────────────
useEffect(() => {
@@ -269,9 +265,9 @@ export default function ColonyChat() {
)}
{agentState.sessionId && (
<button
onClick={() => openColonyWorkers(agentState.sessionId!)}
onClick={() => toggleColonyWorkers()}
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"
title="Show / hide the colony workers panel"
>
<Users className="w-3.5 h-3.5" />
Workers
@@ -281,7 +277,7 @@ export default function ColonyChat() {
</>,
);
return () => setActions(null);
}, [agentState.sessionId, setActions, openColonyWorkers]);
}, [agentState.sessionId, setActions, toggleColonyWorkers]);
// Refs for SSE callback stability
const messagesRef = useRef(messages);
@@ -1239,16 +1235,31 @@ export default function ColonyChat() {
.catch(() => {});
}, [agentState.sessionId, agentState.pendingQuestion, updateState]);
// ── Resolved selected node (sync with live graph updates) ──────────────
const liveSelectedNode = selectedNode && graphNodes.find((n) => n.id === selectedNode.id);
const resolvedSelectedNode = liveSelectedNode || selectedNode;
const triggers = useMemo(
() => graphNodes.filter((n) => n.nodeType === "trigger"),
[graphNodes],
);
// Mirror live triggers into the shared context so the tabbed
// ColonyWorkersPanel (rendered at the layout level) can render the
// Triggers tab without having to re-subscribe to the session SSE.
const { setTriggers: setCtxTriggers, setSessionId: setCtxSessionId } =
useColonyWorkers();
useEffect(() => {
setCtxTriggers(triggers);
return () => setCtxTriggers([]);
}, [triggers, setCtxTriggers]);
// Publish the live colony session id to the context. The AppLayout
// renders ``ColonyWorkersPanel`` whenever this is non-null AND the
// user hasn't dismissed it (via the X button). Cleanup clears it so
// the panel closes when we leave the colony room.
useEffect(() => {
if (!agentState.sessionId) return;
setCtxSessionId(agentState.sessionId);
return () => setCtxSessionId(null);
}, [agentState.sessionId, setCtxSessionId]);
// ── Render ─────────────────────────────────────────────────────────────
if (!colony && !isNewChat && !agentState.loading) {
@@ -1341,39 +1352,10 @@ export default function ColonyChat() {
/>
</div>
{/* Workers sidebar — live list of active + recently-finished workers
with per-worker stop controls. Shown whenever the queen is in
working or reviewing phase (i.e., there's a meaningful worker
population to manage). */}
{(agentState.queenPhase === "working" || agentState.queenPhase === "reviewing") && (
<div className="w-[260px] flex-shrink-0">
<WorkersPanel sessionId={agentState.sessionId} />
</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>
)}
{/* Trigger detail panel */}
{resolvedSelectedNode && resolvedSelectedNode.nodeType === "trigger" && (
<div className="w-[380px] min-w-[320px] flex-shrink-0">
<TriggerDetailPanel
trigger={resolvedSelectedNode}
sessionId={agentState.sessionId || ""}
onClose={() => setSelectedNode(null)}
/>
</div>
)}
{/* Workers / Triggers / Skills / Tools now live in the tabbed
ColonyWorkersPanel rendered by AppLayout. Trigger data is
pushed up via ColonyWorkersContext (see the useEffect that
mirrors `triggers` into context.setTriggers). */}
</div>
<CredentialsModal