feat: colony tab by group
This commit is contained in:
@@ -6,10 +6,18 @@ profile panel. Distinct from ``routes_workers.py``, which deals with
|
||||
*graph nodes* inside a worker definition rather than live worker
|
||||
instances.
|
||||
|
||||
- GET /api/sessions/{session_id}/workers -- list live + completed workers
|
||||
- GET /api/sessions/{session_id}/workers — live + completed workers
|
||||
- GET /api/sessions/{session_id}/colony/skills — colony's shared skills catalog
|
||||
- GET /api/sessions/{session_id}/colony/tools — colony's default tools
|
||||
- GET /api/sessions/{session_id}/colony/progress/snapshot — progress.db tasks/steps snapshot
|
||||
- GET /api/sessions/{session_id}/colony/progress/stream — SSE feed of upserts (polled)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
@@ -17,6 +25,11 @@ from framework.server.app import resolve_session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Poll interval for the progress SSE stream. Progress rows flip on the
|
||||
# order of seconds as workers finish LLM turns, so 1s feels live without
|
||||
# hammering the DB.
|
||||
_PROGRESS_POLL_INTERVAL = 1.0
|
||||
|
||||
|
||||
def _worker_info_to_dict(info) -> dict:
|
||||
"""Serialize a WorkerInfo dataclass to a JSON-friendly dict."""
|
||||
@@ -53,6 +66,307 @@ async def handle_list_workers(request: web.Request) -> web.Response:
|
||||
return web.json_response({"workers": workers})
|
||||
|
||||
|
||||
# ── Skills & tools ─────────────────────────────────────────────────
|
||||
|
||||
def _parsed_skill_to_dict(skill) -> dict:
|
||||
"""Serialize a ParsedSkill for the frontend."""
|
||||
return {
|
||||
"name": skill.name,
|
||||
"description": skill.description,
|
||||
"location": skill.location,
|
||||
"base_dir": skill.base_dir,
|
||||
"source_scope": skill.source_scope,
|
||||
}
|
||||
|
||||
|
||||
async def handle_list_colony_skills(request: web.Request) -> web.Response:
|
||||
"""GET /api/sessions/{session_id}/colony/skills -- list skills the colony sees."""
|
||||
session, err = resolve_session(request)
|
||||
if err:
|
||||
return err
|
||||
|
||||
runtime = session.colony_runtime
|
||||
if runtime is None:
|
||||
return web.json_response({"skills": []})
|
||||
|
||||
# Reach into the skills manager's catalog. There is no public
|
||||
# iterator yet; we touch the private dict directly and defensively
|
||||
# tolerate either shape (bare SkillsManager, or the
|
||||
# from_precomputed variant which has no catalog).
|
||||
catalog = getattr(runtime._skills_manager, "_catalog", None)
|
||||
skills_dict = getattr(catalog, "_skills", None) if catalog is not None else None
|
||||
if not isinstance(skills_dict, dict):
|
||||
return web.json_response({"skills": []})
|
||||
|
||||
skills = [_parsed_skill_to_dict(s) for s in skills_dict.values()]
|
||||
skills.sort(key=lambda s: s["name"])
|
||||
return web.json_response({"skills": skills})
|
||||
|
||||
|
||||
# Tools that ship with the framework and have no credential provider,
|
||||
# but still deserve their own logical group. Surfaced to the frontend
|
||||
# as ``provider="system"`` so the UI treats them exactly like a
|
||||
# credential-backed group.
|
||||
_SYSTEM_TOOLS: frozenset[str] = frozenset(
|
||||
{
|
||||
"get_account_info",
|
||||
"get_current_time",
|
||||
"bash_kill",
|
||||
"bash_output",
|
||||
"execute_command_tool",
|
||||
"example_tool",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _tool_to_dict(tool, provider_map: dict[str, str] | None) -> dict:
|
||||
"""Serialize a Tool dataclass for the frontend.
|
||||
|
||||
``provider_map`` is the colony runtime's tool_name → credential
|
||||
provider map (built by the CredentialResolver pipeline stage from
|
||||
``CredentialStoreAdapter.get_tool_provider_map()``). Credential-
|
||||
backed tools get a canonical provider key (e.g. ``"hubspot"``,
|
||||
``"gmail"``); framework / core tools return ``None``, except for
|
||||
the hand-picked entries in ``_SYSTEM_TOOLS`` which are tagged
|
||||
``"system"``.
|
||||
"""
|
||||
name = getattr(tool, "name", "")
|
||||
provider = (provider_map or {}).get(name)
|
||||
if provider is None and name in _SYSTEM_TOOLS:
|
||||
provider = "system"
|
||||
return {
|
||||
"name": name,
|
||||
"description": getattr(tool, "description", ""),
|
||||
"provider": provider,
|
||||
}
|
||||
|
||||
|
||||
async def handle_list_colony_tools(request: web.Request) -> web.Response:
|
||||
"""GET /api/sessions/{session_id}/colony/tools -- list the colony's default tools."""
|
||||
session, err = resolve_session(request)
|
||||
if err:
|
||||
return err
|
||||
|
||||
runtime = session.colony_runtime
|
||||
if runtime is None:
|
||||
return web.json_response({"tools": []})
|
||||
|
||||
provider_map = getattr(runtime, "_tool_provider_map", None)
|
||||
tools = [_tool_to_dict(t, provider_map) for t in (runtime._tools or [])]
|
||||
tools.sort(key=lambda t: t["name"])
|
||||
return web.json_response({"tools": tools})
|
||||
|
||||
|
||||
# ── Progress DB (tasks/steps) ──────────────────────────────────────
|
||||
|
||||
def _resolve_progress_db(session) -> Path | None:
|
||||
"""Resolve the colony's progress.db path for ``session``.
|
||||
|
||||
Returns ``None`` if the session is not bound to a colony yet or if
|
||||
the DB file doesn't exist.
|
||||
"""
|
||||
colony_name = getattr(session, "colony_name", None)
|
||||
if not colony_name:
|
||||
return None
|
||||
db_path = Path.home() / ".hive" / "colonies" / colony_name / "data" / "progress.db"
|
||||
return db_path if db_path.exists() else None
|
||||
|
||||
|
||||
def _read_progress_snapshot(db_path: Path, worker_id: str | None) -> dict:
|
||||
"""Read tasks + steps from progress.db, optionally filtered by worker_id.
|
||||
|
||||
The worker_id filter applies to tasks (claimed by that worker) and
|
||||
to steps (executed by that worker). If omitted, returns all rows.
|
||||
"""
|
||||
con = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True, timeout=5.0)
|
||||
try:
|
||||
con.row_factory = sqlite3.Row
|
||||
if worker_id:
|
||||
task_rows = con.execute(
|
||||
"SELECT * FROM tasks WHERE worker_id = ? ORDER BY updated_at DESC",
|
||||
(worker_id,),
|
||||
).fetchall()
|
||||
step_rows = con.execute(
|
||||
"SELECT * FROM steps WHERE worker_id = ? ORDER BY task_id, seq",
|
||||
(worker_id,),
|
||||
).fetchall()
|
||||
else:
|
||||
task_rows = con.execute(
|
||||
"SELECT * FROM tasks ORDER BY updated_at DESC LIMIT 500"
|
||||
).fetchall()
|
||||
step_rows = con.execute(
|
||||
"SELECT * FROM steps ORDER BY task_id, seq LIMIT 2000"
|
||||
).fetchall()
|
||||
return {
|
||||
"tasks": [dict(r) for r in task_rows],
|
||||
"steps": [dict(r) for r in step_rows],
|
||||
}
|
||||
finally:
|
||||
con.close()
|
||||
|
||||
|
||||
async def handle_progress_snapshot(request: web.Request) -> web.Response:
|
||||
"""GET /api/sessions/{session_id}/colony/progress/snapshot
|
||||
|
||||
Optional ?worker_id=... to filter to rows touched by a specific worker.
|
||||
"""
|
||||
session, err = resolve_session(request)
|
||||
if err:
|
||||
return err
|
||||
|
||||
db_path = _resolve_progress_db(session)
|
||||
if db_path is None:
|
||||
return web.json_response({"tasks": [], "steps": []})
|
||||
|
||||
worker_id = request.query.get("worker_id") or None
|
||||
snapshot = await asyncio.to_thread(_read_progress_snapshot, db_path, worker_id)
|
||||
return web.json_response(snapshot)
|
||||
|
||||
|
||||
def _read_progress_upserts(
|
||||
db_path: Path,
|
||||
worker_id: str | None,
|
||||
since: str | None,
|
||||
) -> tuple[list[dict], list[dict], str | None]:
|
||||
"""Return task/step rows with ``updated_at`` (tasks) or a derived
|
||||
timestamp (steps) newer than ``since``, plus the new high-water mark.
|
||||
|
||||
Steps don't carry an ``updated_at`` column — we use
|
||||
``COALESCE(completed_at, started_at)`` as the change witness. A step
|
||||
without either timestamp hasn't changed since the last poll and is
|
||||
skipped.
|
||||
|
||||
``since`` is an ISO8601 string (as produced by progress_db._now_iso).
|
||||
``None`` means "give me everything" — used for the SSE priming frame.
|
||||
"""
|
||||
con = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True, timeout=5.0)
|
||||
try:
|
||||
con.row_factory = sqlite3.Row
|
||||
task_sql = "SELECT * FROM tasks"
|
||||
step_sql = (
|
||||
"SELECT *, COALESCE(completed_at, started_at) AS _ts "
|
||||
"FROM steps WHERE COALESCE(completed_at, started_at) IS NOT NULL"
|
||||
)
|
||||
task_args: list = []
|
||||
step_args: list = []
|
||||
if since is not None:
|
||||
task_sql += " WHERE updated_at > ?"
|
||||
step_sql += " AND COALESCE(completed_at, started_at) > ?"
|
||||
task_args.append(since)
|
||||
step_args.append(since)
|
||||
if worker_id:
|
||||
joiner_t = " AND " if since is not None else " WHERE "
|
||||
task_sql += joiner_t + "worker_id = ?"
|
||||
step_sql += " AND worker_id = ?"
|
||||
task_args.append(worker_id)
|
||||
step_args.append(worker_id)
|
||||
task_sql += " ORDER BY updated_at"
|
||||
step_sql += " ORDER BY _ts"
|
||||
|
||||
task_rows = con.execute(task_sql, task_args).fetchall()
|
||||
step_rows = con.execute(step_sql, step_args).fetchall()
|
||||
|
||||
tasks = [dict(r) for r in task_rows]
|
||||
steps = [dict(r) for r in step_rows]
|
||||
# High-water mark = max timestamp across both sets. Fall back to
|
||||
# the previous ``since`` when nothing changed.
|
||||
ts_values = [t["updated_at"] for t in tasks]
|
||||
ts_values.extend(s["_ts"] for s in steps if s.get("_ts"))
|
||||
new_since = max(ts_values) if ts_values else since
|
||||
return tasks, steps, new_since
|
||||
finally:
|
||||
con.close()
|
||||
|
||||
|
||||
async def handle_progress_stream(request: web.Request) -> web.StreamResponse:
|
||||
"""GET /api/sessions/{session_id}/colony/progress/stream
|
||||
|
||||
SSE feed that emits ``snapshot`` once (current state) followed by
|
||||
``upsert`` events whenever a task/step row changes. Polls the DB
|
||||
every ``_PROGRESS_POLL_INTERVAL`` seconds — the sqlite3 CLI path
|
||||
workers use for writes doesn't fire SQLite's update hook on our
|
||||
connection, so polling is the robust option.
|
||||
"""
|
||||
session, err = resolve_session(request)
|
||||
if err:
|
||||
return err
|
||||
|
||||
worker_id = request.query.get("worker_id") or None
|
||||
|
||||
resp = web.StreamResponse(
|
||||
status=200,
|
||||
headers={
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
await resp.prepare(request)
|
||||
|
||||
async def _send(event: str, data: dict) -> None:
|
||||
payload = f"event: {event}\ndata: {json.dumps(data)}\n\n"
|
||||
await resp.write(payload.encode("utf-8"))
|
||||
|
||||
db_path = _resolve_progress_db(session)
|
||||
if db_path is None:
|
||||
await _send("snapshot", {"tasks": [], "steps": []})
|
||||
await _send("end", {"reason": "no_progress_db"})
|
||||
return resp
|
||||
|
||||
try:
|
||||
snapshot = await asyncio.to_thread(_read_progress_snapshot, db_path, worker_id)
|
||||
await _send("snapshot", snapshot)
|
||||
|
||||
since: str | None = None
|
||||
# Initialize the high-water mark from the snapshot so we don't
|
||||
# re-emit every row as "new" on the first poll.
|
||||
ts_values: list[str] = [t.get("updated_at") for t in snapshot["tasks"] if t.get("updated_at")]
|
||||
ts_values.extend(
|
||||
s.get("completed_at") or s.get("started_at")
|
||||
for s in snapshot["steps"]
|
||||
if s.get("completed_at") or s.get("started_at")
|
||||
)
|
||||
if ts_values:
|
||||
since = max(v for v in ts_values if v)
|
||||
|
||||
# The loop relies on client disconnect surfacing as
|
||||
# ConnectionResetError from ``_send`` — no explicit alive check
|
||||
# required.
|
||||
while True:
|
||||
await asyncio.sleep(_PROGRESS_POLL_INTERVAL)
|
||||
tasks, steps, new_since = await asyncio.to_thread(
|
||||
_read_progress_upserts, db_path, worker_id, since
|
||||
)
|
||||
if tasks or steps:
|
||||
await _send("upsert", {"tasks": tasks, "steps": steps})
|
||||
since = new_since
|
||||
except (asyncio.CancelledError, ConnectionResetError):
|
||||
# Client disconnected; clean exit.
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.warning("progress stream error: %s", exc, exc_info=True)
|
||||
try:
|
||||
await _send("error", {"message": str(exc)})
|
||||
except Exception:
|
||||
pass
|
||||
return resp
|
||||
|
||||
|
||||
def register_routes(app: web.Application) -> None:
|
||||
"""Register colony worker routes."""
|
||||
app.router.add_get("/api/sessions/{session_id}/workers", handle_list_workers)
|
||||
app.router.add_get(
|
||||
"/api/sessions/{session_id}/colony/skills", handle_list_colony_skills
|
||||
)
|
||||
app.router.add_get(
|
||||
"/api/sessions/{session_id}/colony/tools", handle_list_colony_tools
|
||||
)
|
||||
app.router.add_get(
|
||||
"/api/sessions/{session_id}/colony/progress/snapshot",
|
||||
handle_progress_snapshot,
|
||||
)
|
||||
app.router.add_get(
|
||||
"/api/sessions/{session_id}/colony/progress/stream",
|
||||
handle_progress_stream,
|
||||
)
|
||||
|
||||
@@ -16,8 +16,88 @@ export interface WorkerSummary {
|
||||
result: WorkerResult | null;
|
||||
}
|
||||
|
||||
export interface ColonySkill {
|
||||
name: string;
|
||||
description: string;
|
||||
location: string;
|
||||
base_dir: string;
|
||||
source_scope: string;
|
||||
}
|
||||
|
||||
export interface ColonyTool {
|
||||
name: string;
|
||||
description: string;
|
||||
/** Canonical credential/provider key (e.g. "hubspot", "gmail") for
|
||||
* tools bound to an Aden credential. ``null`` for framework/core
|
||||
* tools that don't require a provider credential. */
|
||||
provider: string | null;
|
||||
}
|
||||
|
||||
export interface ProgressTask {
|
||||
id: string;
|
||||
seq: number | null;
|
||||
priority: number;
|
||||
goal: string;
|
||||
payload: string | null;
|
||||
status: string;
|
||||
worker_id: string | null;
|
||||
claim_token: string | null;
|
||||
claimed_at: string | null;
|
||||
started_at: string | null;
|
||||
completed_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
retry_count: number;
|
||||
max_retries: number;
|
||||
last_error: string | null;
|
||||
parent_task_id: string | null;
|
||||
source: string | null;
|
||||
}
|
||||
|
||||
export interface ProgressStep {
|
||||
id: string;
|
||||
task_id: string;
|
||||
seq: number;
|
||||
title: string;
|
||||
detail: string | null;
|
||||
status: string;
|
||||
evidence: string | null;
|
||||
worker_id: string | null;
|
||||
started_at: string | null;
|
||||
completed_at: string | null;
|
||||
/** Present only on upsert events; not on snapshot rows. */
|
||||
_ts?: string | null;
|
||||
}
|
||||
|
||||
export interface ProgressSnapshot {
|
||||
tasks: ProgressTask[];
|
||||
steps: ProgressStep[];
|
||||
}
|
||||
|
||||
export const colonyWorkersApi = {
|
||||
/** List spawned workers (live + completed) for a colony session. */
|
||||
list: (sessionId: string) =>
|
||||
api.get<{ workers: WorkerSummary[] }>(`/sessions/${sessionId}/workers`),
|
||||
|
||||
/** List the colony's shared skills catalog. */
|
||||
listSkills: (sessionId: string) =>
|
||||
api.get<{ skills: ColonySkill[] }>(`/sessions/${sessionId}/colony/skills`),
|
||||
|
||||
/** List the colony's default tools. */
|
||||
listTools: (sessionId: string) =>
|
||||
api.get<{ tools: ColonyTool[] }>(`/sessions/${sessionId}/colony/tools`),
|
||||
|
||||
/** Snapshot of progress.db tasks + steps, optionally filtered by worker_id. */
|
||||
progressSnapshot: (sessionId: string, workerId?: string) => {
|
||||
const qs = workerId ? `?worker_id=${encodeURIComponent(workerId)}` : "";
|
||||
return api.get<ProgressSnapshot>(
|
||||
`/sessions/${sessionId}/colony/progress/snapshot${qs}`,
|
||||
);
|
||||
},
|
||||
|
||||
/** Build the URL for the live progress SSE stream. */
|
||||
progressStreamUrl: (sessionId: string, workerId?: string): string => {
|
||||
const qs = workerId ? `?worker_id=${encodeURIComponent(workerId)}` : "";
|
||||
return `/api/sessions/${sessionId}/colony/progress/stream${qs}`;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,16 +1,35 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { X, Users, RefreshCw } from "lucide-react";
|
||||
import { colonyWorkersApi, type WorkerSummary } from "@/api/colonyWorkers";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
X,
|
||||
Users,
|
||||
RefreshCw,
|
||||
Wrench,
|
||||
Database,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
ArrowLeft,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
colonyWorkersApi,
|
||||
type ColonySkill,
|
||||
type ColonyTool,
|
||||
type ProgressSnapshot,
|
||||
type ProgressStep,
|
||||
type WorkerSummary,
|
||||
} from "@/api/colonyWorkers";
|
||||
|
||||
interface ColonyWorkersPanelProps {
|
||||
sessionId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type TabKey = "skills" | "tools" | "sessions";
|
||||
|
||||
function statusClasses(status: string): string {
|
||||
const s = status.toLowerCase();
|
||||
if (s === "running" || s === "pending") return "bg-primary/15 text-primary";
|
||||
if (s === "completed") return "bg-emerald-500/15 text-emerald-500";
|
||||
if (s === "running" || s === "pending" || s === "claimed" || s === "in_progress")
|
||||
return "bg-primary/15 text-primary";
|
||||
if (s === "completed" || s === "done") return "bg-emerald-500/15 text-emerald-500";
|
||||
if (s === "failed") return "bg-destructive/15 text-destructive";
|
||||
if (s === "stopped") return "bg-muted text-muted-foreground";
|
||||
return "bg-muted text-muted-foreground";
|
||||
@@ -30,32 +49,27 @@ function fmtStarted(ts: number): string {
|
||||
}
|
||||
}
|
||||
|
||||
function fmtIso(ts: string | null | undefined): string {
|
||||
if (!ts) return "";
|
||||
try {
|
||||
const d = new Date(ts);
|
||||
if (isNaN(d.getTime())) return ts;
|
||||
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||
} catch {
|
||||
return ts;
|
||||
}
|
||||
}
|
||||
|
||||
export default function ColonyWorkersPanel({
|
||||
sessionId,
|
||||
onClose,
|
||||
}: ColonyWorkersPanelProps) {
|
||||
const [workers, setWorkers] = useState<WorkerSummary[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
colonyWorkersApi
|
||||
.list(sessionId)
|
||||
.then((r) => setWorkers(r.workers))
|
||||
.catch((e) => setError(e?.message ?? "Failed to load workers"))
|
||||
.finally(() => setLoading(false));
|
||||
}, [sessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
const [tab, setTab] = useState<TabKey>("skills");
|
||||
|
||||
// ── Resizable width (mirrors QueenProfilePanel) ─────────────────────
|
||||
const MIN_WIDTH = 280;
|
||||
const MAX_WIDTH = 600;
|
||||
const [width, setWidth] = useState(360);
|
||||
const [width, setWidth] = useState(380);
|
||||
const dragging = useRef(false);
|
||||
const startX = useRef(0);
|
||||
const startWidth = useRef(0);
|
||||
@@ -89,7 +103,7 @@ export default function ColonyWorkersPanel({
|
||||
|
||||
return (
|
||||
<aside
|
||||
className="flex-shrink-0 border-l border-border/60 bg-card overflow-y-auto relative"
|
||||
className="flex-shrink-0 border-l border-border/60 bg-card overflow-hidden relative flex flex-col"
|
||||
style={{ width }}
|
||||
>
|
||||
<div
|
||||
@@ -97,83 +111,697 @@ export default function ColonyWorkersPanel({
|
||||
className="absolute top-0 left-0 w-1 h-full cursor-col-resize hover:bg-primary/30 active:bg-primary/50 transition-colors z-10"
|
||||
/>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-3.5 border-b border-border/60">
|
||||
<div className="flex items-center justify-between px-5 py-3.5 border-b border-border/60 flex-shrink-0">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-foreground">
|
||||
<Users className="w-4 h-4 text-primary" />
|
||||
COLONY WORKERS
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={refresh}
|
||||
className="p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors"
|
||||
title="Refresh"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? "animate-spin" : ""}`} />
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-4">
|
||||
{error && (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive mb-3">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{/* Tab bar */}
|
||||
<div className="flex border-b border-border/60 flex-shrink-0">
|
||||
<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>
|
||||
|
||||
{loading && workers.length === 0 ? (
|
||||
<div className="flex justify-center py-10">
|
||||
<div className="w-6 h-6 border-2 border-primary/30 border-t-primary rounded-full animate-spin" />
|
||||
</div>
|
||||
) : workers.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground text-center py-8">
|
||||
No workers spawned yet.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1.5">
|
||||
{workers.map((w) => (
|
||||
<li
|
||||
key={w.worker_id}
|
||||
className="rounded-lg border border-border/60 bg-background/40 px-3 py-2.5 hover:bg-muted/30 transition-colors cursor-default"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<code className="text-xs font-mono text-foreground">
|
||||
{shortId(w.worker_id)}
|
||||
</code>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{tab === "skills" && <SkillsTab sessionId={sessionId} />}
|
||||
{tab === "tools" && <ToolsTab sessionId={sessionId} />}
|
||||
{tab === "sessions" && <SessionsTab sessionId={sessionId} />}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function TabButton({
|
||||
active,
|
||||
onClick,
|
||||
label,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`flex-1 px-3 py-2 text-xs font-medium transition-colors border-b-2 ${
|
||||
active
|
||||
? "border-primary text-foreground"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground hover:bg-muted/30"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Skills tab ─────────────────────────────────────────────────────────
|
||||
|
||||
function SkillsTab({ sessionId }: { sessionId: string }) {
|
||||
const [skills, setSkills] = useState<ColonySkill[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
colonyWorkersApi
|
||||
.listSkills(sessionId)
|
||||
.then((r) => setSkills(r.skills))
|
||||
.catch((e) => setError(e?.message ?? "Failed to load skills"))
|
||||
.finally(() => setLoading(false));
|
||||
}, [sessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
// Group by source_scope: user + project are shown expanded; framework
|
||||
// is folded by default to keep the tab scannable (framework skills are
|
||||
// the long list of built-ins that rarely change).
|
||||
const groups = useMemo(() => {
|
||||
const byScope: Record<string, ColonySkill[]> = { user: [], project: [], framework: [] };
|
||||
for (const s of skills) {
|
||||
const bucket = byScope[s.source_scope] ?? (byScope[s.source_scope] = []);
|
||||
bucket.push(s);
|
||||
}
|
||||
return [
|
||||
{ key: "user", label: "User skills", items: byScope.user, defaultOpen: true },
|
||||
{ key: "project", label: "Project skills", items: byScope.project, defaultOpen: true },
|
||||
{ key: "framework", label: "Framework skills", items: byScope.framework, defaultOpen: false },
|
||||
].filter((g) => g.items.length > 0);
|
||||
}, [skills]);
|
||||
|
||||
return (
|
||||
<TabShell loading={loading} error={error} onRefresh={refresh} empty={skills.length === 0 ? "No skills loaded." : null}>
|
||||
<div className="flex flex-col gap-3">
|
||||
{groups.map((g) => (
|
||||
<SkillGroup key={g.key} label={g.label} items={g.items} defaultOpen={g.defaultOpen} />
|
||||
))}
|
||||
</div>
|
||||
</TabShell>
|
||||
);
|
||||
}
|
||||
|
||||
function SkillGroup({
|
||||
label,
|
||||
items,
|
||||
defaultOpen,
|
||||
}: {
|
||||
label: string;
|
||||
items: ColonySkill[];
|
||||
defaultOpen: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
return (
|
||||
<section>
|
||||
<button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="w-full flex items-center gap-1.5 mb-1.5 text-[11px] uppercase tracking-wide font-semibold text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{open ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
|
||||
<span>{label}</span>
|
||||
<span className="text-muted-foreground/60">({items.length})</span>
|
||||
</button>
|
||||
{open && (
|
||||
<ul className="flex flex-col gap-1.5">
|
||||
{items.map((s) => (
|
||||
<li
|
||||
key={s.name}
|
||||
className="rounded-lg border border-border/60 bg-background/40 px-3 py-2.5"
|
||||
>
|
||||
<code className="text-xs font-mono text-foreground block mb-1 truncate">
|
||||
{s.name}
|
||||
</code>
|
||||
{s.description && (
|
||||
<p className="text-xs text-foreground/75 line-clamp-3">{s.description}</p>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Tools tab ──────────────────────────────────────────────────────────
|
||||
|
||||
function ToolsTab({ sessionId }: { sessionId: string }) {
|
||||
const [tools, setTools] = useState<ColonyTool[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
colonyWorkersApi
|
||||
.listTools(sessionId)
|
||||
.then((r) => setTools(r.tools))
|
||||
.catch((e) => setError(e?.message ?? "Failed to load tools"))
|
||||
.finally(() => setLoading(false));
|
||||
}, [sessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const groups = useMemo(() => groupTools(tools), [tools]);
|
||||
|
||||
return (
|
||||
<TabShell loading={loading} error={error} onRefresh={refresh} empty={tools.length === 0 ? "No tools configured." : null}>
|
||||
<div className="flex flex-col gap-3">
|
||||
{groups.map((g) => (
|
||||
<ToolGroup key={g.key} label={g.label} items={g.items} />
|
||||
))}
|
||||
</div>
|
||||
</TabShell>
|
||||
);
|
||||
}
|
||||
|
||||
/** Display-label overrides for provider keys and framework-prefix
|
||||
* groups that don't titlecase nicely. Anything not listed here gets
|
||||
* a snake_case → Title Case conversion. */
|
||||
const _LABEL_OVERRIDES: Record<string, string> = {
|
||||
hubspot: "HubSpot",
|
||||
github: "GitHub",
|
||||
gitlab: "GitLab",
|
||||
openai: "OpenAI",
|
||||
aws_s3: "AWS S3",
|
||||
azure_sql: "Azure SQL",
|
||||
bigquery: "BigQuery",
|
||||
microsoft_graph: "Microsoft Graph",
|
||||
browser: "Browser",
|
||||
bash: "Bash",
|
||||
system: "System",
|
||||
};
|
||||
|
||||
/** Framework/core tools don't have a credential provider, so they fall
|
||||
* through to this map. Authoritative names for multi-file core tool
|
||||
* groups; unmatched names fall through to a first-underscore prefix
|
||||
* grouping. Keeping this small is deliberate — the credential system
|
||||
* owns the rest. */
|
||||
const _FRAMEWORK_GROUPS: Record<string, string> = {
|
||||
read_file: "Filesystem",
|
||||
write_file: "Filesystem",
|
||||
edit_file: "Filesystem",
|
||||
list_files: "Filesystem",
|
||||
list_dir: "Filesystem",
|
||||
list_directory: "Filesystem",
|
||||
search_files: "Filesystem",
|
||||
grep_search: "Filesystem",
|
||||
hashline_edit: "Filesystem",
|
||||
replace_file_content: "Filesystem",
|
||||
apply_diff: "File edits",
|
||||
apply_patch: "File edits",
|
||||
web_scrape: "Web & research",
|
||||
search_wikipedia: "Web & research",
|
||||
search_papers: "Web & research",
|
||||
download_paper: "Web & research",
|
||||
pdf_read: "Web & research",
|
||||
send_email: "Email",
|
||||
dns_security_scan: "Security scans",
|
||||
http_headers_scan: "Security scans",
|
||||
port_scan: "Security scans",
|
||||
ssl_tls_scan: "Security scans",
|
||||
subdomain_enumerate: "Security scans",
|
||||
tech_stack_detect: "Security scans",
|
||||
risk_score: "Security scans",
|
||||
query_runtime_log_raw: "Runtime logs",
|
||||
query_runtime_log_details: "Runtime logs",
|
||||
query_runtime_logs: "Runtime logs",
|
||||
};
|
||||
|
||||
interface ToolGroupData {
|
||||
key: string;
|
||||
label: string;
|
||||
items: ColonyTool[];
|
||||
}
|
||||
|
||||
function labelFor(raw: string): string {
|
||||
const override = _LABEL_OVERRIDES[raw];
|
||||
if (override) return override;
|
||||
return raw
|
||||
.split("_")
|
||||
.map((w) => (w.length > 0 ? w[0].toUpperCase() + w.slice(1) : w))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function groupTools(tools: ColonyTool[]): ToolGroupData[] {
|
||||
const buckets = new Map<string, ColonyTool[]>();
|
||||
|
||||
const put = (label: string, t: ColonyTool) => {
|
||||
const arr = buckets.get(label) ?? [];
|
||||
arr.push(t);
|
||||
buckets.set(label, arr);
|
||||
};
|
||||
|
||||
for (const t of tools) {
|
||||
// Preferred: backend-provided credential provider key. This is the
|
||||
// authoritative grouping — it comes from the same CredentialSpec
|
||||
// table that declares which tools need which credentials.
|
||||
if (t.provider) {
|
||||
put(labelFor(t.provider), t);
|
||||
continue;
|
||||
}
|
||||
const explicit = _FRAMEWORK_GROUPS[t.name];
|
||||
if (explicit) {
|
||||
put(explicit, t);
|
||||
continue;
|
||||
}
|
||||
// Last-resort: first-underscore prefix. Keeps e.g. all browser_*
|
||||
// and bash_* tools together even though they have no credential.
|
||||
const underscore = t.name.indexOf("_");
|
||||
if (underscore > 0) {
|
||||
put(labelFor(t.name.slice(0, underscore)), t);
|
||||
continue;
|
||||
}
|
||||
put("Other", t);
|
||||
}
|
||||
|
||||
// Collapse any single-item group into "Other" so the panel isn't
|
||||
// full of one-entry sections.
|
||||
const result: ToolGroupData[] = [];
|
||||
const other: ColonyTool[] = buckets.get("Other") ?? [];
|
||||
for (const [label, items] of buckets) {
|
||||
if (label === "Other") continue;
|
||||
if (items.length < 2) {
|
||||
other.push(...items);
|
||||
continue;
|
||||
}
|
||||
items.sort((a, b) => a.name.localeCompare(b.name));
|
||||
result.push({ key: label, label, items });
|
||||
}
|
||||
result.sort((a, b) => a.label.localeCompare(b.label));
|
||||
if (other.length) {
|
||||
other.sort((a, b) => a.name.localeCompare(b.name));
|
||||
result.push({ key: "Other", label: "Other", items: other });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function ToolGroup({ label, items }: { label: string; items: ColonyTool[] }) {
|
||||
// Default folded — 100+ tools across ~15 groups is only readable when
|
||||
// the user picks the one they want to inspect.
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<section>
|
||||
<button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="w-full flex items-center gap-1.5 mb-1.5 text-[11px] uppercase tracking-wide font-semibold text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{open ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
|
||||
<span>{label}</span>
|
||||
<span className="text-muted-foreground/60">({items.length})</span>
|
||||
</button>
|
||||
{open && (
|
||||
<ul className="flex flex-col gap-1.5">
|
||||
{items.map((t) => (
|
||||
<li
|
||||
key={t.name}
|
||||
className="rounded-lg border border-border/60 bg-background/40 px-3 py-2.5"
|
||||
>
|
||||
<div className="flex items-center gap-1.5 min-w-0 mb-1">
|
||||
<Wrench className="w-3 h-3 text-primary flex-shrink-0" />
|
||||
<code className="text-xs font-mono text-foreground truncate">{t.name}</code>
|
||||
</div>
|
||||
{t.description && (
|
||||
<p className="text-xs text-foreground/75 line-clamp-3">{t.description}</p>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Sessions tab ───────────────────────────────────────────────────────
|
||||
|
||||
function SessionsTab({ sessionId }: { sessionId: string }) {
|
||||
const [workers, setWorkers] = useState<WorkerSummary[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
colonyWorkersApi
|
||||
.list(sessionId)
|
||||
.then((r) => setWorkers(r.workers))
|
||||
.catch((e) => setError(e?.message ?? "Failed to load workers"))
|
||||
.finally(() => setLoading(false));
|
||||
}, [sessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const selectedWorker = useMemo(
|
||||
() => (selected ? workers.find((w) => w.worker_id === selected) : null),
|
||||
[selected, workers],
|
||||
);
|
||||
|
||||
if (selected) {
|
||||
return (
|
||||
<WorkerDetail
|
||||
sessionId={sessionId}
|
||||
worker={selectedWorker}
|
||||
workerId={selected}
|
||||
onBack={() => setSelected(null)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
{w.task && (
|
||||
<p className="text-xs text-foreground/80 line-clamp-2 mb-1">
|
||||
{w.task}
|
||||
</p>
|
||||
</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 className="flex items-center justify-between text-[10px] text-muted-foreground">
|
||||
<span>{fmtStarted(w.started_at)}</span>
|
||||
{w.result && (
|
||||
<span>
|
||||
{w.result.duration_seconds
|
||||
? `${w.result.duration_seconds.toFixed(1)}s`
|
||||
: ""}
|
||||
{w.result.tokens_used
|
||||
? ` · ${w.result.tokens_used.toLocaleString()} tok`
|
||||
: ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</TabShell>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Worker detail view (inside Sessions tab) ───────────────────────────
|
||||
|
||||
function WorkerDetail({
|
||||
sessionId,
|
||||
worker,
|
||||
workerId,
|
||||
onBack,
|
||||
}: {
|
||||
sessionId: string;
|
||||
worker: WorkerSummary | null | undefined;
|
||||
workerId: string;
|
||||
onBack: () => void;
|
||||
}) {
|
||||
const { snapshot, streamState, error } = useProgressStream(sessionId, workerId);
|
||||
|
||||
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 sessions
|
||||
</button>
|
||||
|
||||
<div className="rounded-lg border border-border/60 bg-background/40 px-3 py-2.5 mb-3">
|
||||
<div className="flex items-center justify-between mb-1 gap-2">
|
||||
<code className="text-xs font-mono text-foreground">{shortId(workerId)}</code>
|
||||
{worker && (
|
||||
<span
|
||||
className={`text-[10px] px-1.5 py-0.5 rounded-full font-medium ${statusClasses(worker.status)}`}
|
||||
>
|
||||
{worker.status}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{worker?.task && <p className="text-xs text-foreground/80 mb-1">{worker.task}</p>}
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
{worker ? fmtStarted(worker.started_at) : ""}
|
||||
{worker?.result?.duration_seconds
|
||||
? ` · ${worker.result.duration_seconds.toFixed(1)}s`
|
||||
: ""}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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" />
|
||||
Progress (progress.db)
|
||||
</div>
|
||||
<StreamBadge state={streamState} />
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive mb-2">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ProgressView snapshot={snapshot} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StreamBadge({ state }: { state: "connecting" | "open" | "closed" | "error" }) {
|
||||
const cls =
|
||||
state === "open"
|
||||
? "bg-emerald-500/15 text-emerald-500"
|
||||
: state === "connecting"
|
||||
? "bg-primary/15 text-primary"
|
||||
: state === "error"
|
||||
? "bg-destructive/15 text-destructive"
|
||||
: "bg-muted text-muted-foreground";
|
||||
return (
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded-full font-medium ${cls}`}>{state}</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ProgressView({ snapshot }: { snapshot: ProgressSnapshot }) {
|
||||
const stepsByTask = useMemo(() => {
|
||||
const m = new Map<string, ProgressStep[]>();
|
||||
for (const step of snapshot.steps) {
|
||||
const arr = m.get(step.task_id) ?? [];
|
||||
arr.push(step);
|
||||
m.set(step.task_id, arr);
|
||||
}
|
||||
for (const arr of m.values()) arr.sort((a, b) => a.seq - b.seq);
|
||||
return m;
|
||||
}, [snapshot.steps]);
|
||||
|
||||
if (snapshot.tasks.length === 0 && snapshot.steps.length === 0) {
|
||||
return (
|
||||
<p className="text-xs text-muted-foreground text-center py-6">
|
||||
No progress rows yet.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{snapshot.tasks.map((t) => (
|
||||
<li
|
||||
key={t.id}
|
||||
className="rounded-lg border border-border/60 bg-background/40 px-3 py-2"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2 mb-1">
|
||||
<span className="text-xs text-foreground/90 break-words flex-1">{t.goal}</span>
|
||||
<span
|
||||
className={`text-[10px] px-1.5 py-0.5 rounded-full font-medium flex-shrink-0 ${statusClasses(t.status)}`}
|
||||
>
|
||||
{t.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-[10px] text-muted-foreground">
|
||||
<code className="font-mono">{t.id.slice(0, 8)}</code>
|
||||
{t.updated_at && <span>· upd {fmtIso(t.updated_at)}</span>}
|
||||
{t.retry_count > 0 && (
|
||||
<span>
|
||||
· retry {t.retry_count}/{t.max_retries}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(() => {
|
||||
const steps = stepsByTask.get(t.id) ?? [];
|
||||
if (steps.length === 0) return null;
|
||||
return (
|
||||
<ul className="mt-2 pl-2 border-l border-border/40 flex flex-col gap-1">
|
||||
{steps.map((s) => (
|
||||
<li key={s.id} className="flex items-start gap-1.5 text-[11px]">
|
||||
<span
|
||||
className={`mt-0.5 w-1.5 h-1.5 rounded-full flex-shrink-0 ${
|
||||
s.status === "completed" || s.status === "done"
|
||||
? "bg-emerald-500"
|
||||
: s.status === "failed"
|
||||
? "bg-destructive"
|
||||
: s.status === "in_progress" || s.status === "running"
|
||||
? "bg-primary animate-pulse"
|
||||
: "bg-muted-foreground/40"
|
||||
}`}
|
||||
/>
|
||||
<span className="text-foreground/80 flex-1 break-words">{s.title}</span>
|
||||
{s.completed_at && (
|
||||
<span className="text-[10px] text-muted-foreground flex-shrink-0">
|
||||
{fmtIso(s.completed_at)}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
})()}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Hook: live progress via SSE ────────────────────────────────────────
|
||||
|
||||
function useProgressStream(sessionId: string, workerId: string) {
|
||||
const [snapshot, setSnapshot] = useState<ProgressSnapshot>({ tasks: [], steps: [] });
|
||||
const [streamState, setStreamState] = useState<"connecting" | "open" | "closed" | "error">(
|
||||
"connecting",
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setSnapshot({ tasks: [], steps: [] });
|
||||
setError(null);
|
||||
setStreamState("connecting");
|
||||
|
||||
const url = colonyWorkersApi.progressStreamUrl(sessionId, workerId);
|
||||
const es = new EventSource(url);
|
||||
|
||||
es.addEventListener("open", () => setStreamState("open"));
|
||||
|
||||
es.addEventListener("snapshot", (e) => {
|
||||
try {
|
||||
const data = JSON.parse((e as MessageEvent).data) as ProgressSnapshot;
|
||||
setSnapshot(data);
|
||||
setStreamState("open");
|
||||
} catch (err) {
|
||||
setError(`snapshot parse failed: ${String(err)}`);
|
||||
}
|
||||
});
|
||||
|
||||
es.addEventListener("upsert", (e) => {
|
||||
try {
|
||||
const data = JSON.parse((e as MessageEvent).data) as ProgressSnapshot;
|
||||
setSnapshot((prev) => mergeSnapshot(prev, data));
|
||||
} catch (err) {
|
||||
setError(`upsert parse failed: ${String(err)}`);
|
||||
}
|
||||
});
|
||||
|
||||
es.addEventListener("error", (e) => {
|
||||
try {
|
||||
const data = JSON.parse((e as MessageEvent).data) as { message?: string };
|
||||
if (data.message) setError(data.message);
|
||||
} catch {
|
||||
/* EventSource raw error — state below handles it. */
|
||||
}
|
||||
});
|
||||
|
||||
es.onerror = () => {
|
||||
// EventSource auto-retries; surface the transient state so the
|
||||
// badge reflects reality.
|
||||
setStreamState((s) => (s === "open" ? "error" : s));
|
||||
};
|
||||
|
||||
return () => {
|
||||
es.close();
|
||||
setStreamState("closed");
|
||||
};
|
||||
}, [sessionId, workerId]);
|
||||
|
||||
return { snapshot, streamState, error };
|
||||
}
|
||||
|
||||
function mergeSnapshot(prev: ProgressSnapshot, upsert: ProgressSnapshot): ProgressSnapshot {
|
||||
const taskMap = new Map(prev.tasks.map((t) => [t.id, t]));
|
||||
for (const t of upsert.tasks) taskMap.set(t.id, t);
|
||||
const tasks = Array.from(taskMap.values()).sort((a, b) =>
|
||||
b.updated_at.localeCompare(a.updated_at),
|
||||
);
|
||||
|
||||
const stepMap = new Map(prev.steps.map((s) => [s.id, s]));
|
||||
for (const s of upsert.steps) stepMap.set(s.id, s);
|
||||
const steps = Array.from(stepMap.values()).sort((a, b) => {
|
||||
if (a.task_id !== b.task_id) return a.task_id.localeCompare(b.task_id);
|
||||
return a.seq - b.seq;
|
||||
});
|
||||
|
||||
return { tasks, steps };
|
||||
}
|
||||
|
||||
// ── Shared tab shell: loading / error / empty / refresh button ─────────
|
||||
|
||||
function TabShell({
|
||||
loading,
|
||||
error,
|
||||
onRefresh,
|
||||
empty,
|
||||
children,
|
||||
}: {
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
onRefresh: () => void;
|
||||
empty: string | null;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="px-4 py-3">
|
||||
<div className="flex justify-end mb-2">
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
className="p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors"
|
||||
title="Refresh"
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${loading ? "animate-spin" : ""}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive mb-3">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && !error ? (
|
||||
<div className="flex justify-center py-10">
|
||||
<div className="w-6 h-6 border-2 border-primary/30 border-t-primary rounded-full animate-spin" />
|
||||
</div>
|
||||
) : empty ? (
|
||||
<p className="text-xs text-muted-foreground text-center py-8">{empty}</p>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user