feature: merge sidebars with functionalities
This commit is contained in:
@@ -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": {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user