research canvas: collapsible sidebar + live topology-run logs #6
@@ -454,6 +454,10 @@ pub fn router(state: AppState) -> Router {
|
||||
"/api/research/{id}/pipeline-state",
|
||||
get(routes::research_pipeline::pipeline_state),
|
||||
)
|
||||
.route(
|
||||
"/api/research/{id}/active-runs",
|
||||
get(routes::research_pipeline::active_runs),
|
||||
)
|
||||
.route("/api/research/probe", post(routes::probe::probe))
|
||||
.route(
|
||||
"/api/loops",
|
||||
|
||||
@@ -39,6 +39,38 @@ pub struct PipelineState {
|
||||
pub stages: Vec<PipelineStage>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ActiveRun {
|
||||
pub id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ActiveRuns {
|
||||
pub topic_id: Uuid,
|
||||
pub runs: Vec<ActiveRun>,
|
||||
}
|
||||
|
||||
/// `GET /api/research/:id/active-runs` — queued + running topology_run
|
||||
/// ids for this topic, newest first. Feeds the wizard's live-log panel
|
||||
/// (SSE per run at `/api/topology-runs/:id/events`).
|
||||
pub async fn active_runs(
|
||||
State(state): State<AppState>,
|
||||
Authed(user): Authed,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<ActiveRuns>, ApiError> {
|
||||
// Workspace-scope: 404 rather than leak run ids for a topic the
|
||||
// caller can't see.
|
||||
let _topic = cm_db::repo::research_topics::get(&state.pool, id, user.workspace_id.as_uuid())
|
||||
.await?
|
||||
.ok_or(ApiError::NotFound)?;
|
||||
let ids =
|
||||
cm_db::repo::topology_runs::active_run_ids_for_research_topic(&state.pool, id).await?;
|
||||
Ok(Json(ActiveRuns {
|
||||
topic_id: id,
|
||||
runs: ids.into_iter().map(|id| ActiveRun { id }).collect(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// `GET /api/research/:id/pipeline-state`.
|
||||
pub async fn pipeline_state(
|
||||
State(state): State<AppState>,
|
||||
|
||||
@@ -167,6 +167,31 @@ pub async fn active_runs_for_research_topic(
|
||||
Ok(row.n.unwrap_or(0))
|
||||
}
|
||||
|
||||
/// Live-run panel companion to `active_runs_for_research_topic`: return
|
||||
/// the actual run ids (queued + running) so the UI can subscribe to
|
||||
/// their SSE event streams. Ordered newest first — the freshest run is
|
||||
/// the one the user just kicked off.
|
||||
pub async fn active_run_ids_for_research_topic(
|
||||
pool: &PgPool,
|
||||
research_topic_id: Uuid,
|
||||
) -> Result<Vec<Uuid>, DbError> {
|
||||
use sqlx::Row;
|
||||
// Dynamic query (not `sqlx::query!`) so cm-db builds air-gapped
|
||||
// without a fresh `cargo sqlx prepare` round-trip. Schema shape is
|
||||
// identical to `active_runs_for_research_topic` above.
|
||||
let rows: Vec<sqlx::postgres::PgRow> = sqlx::query(
|
||||
"SELECT id
|
||||
FROM topology_runs
|
||||
WHERE research_topic_id = $1
|
||||
AND status IN ('queued', 'running')
|
||||
ORDER BY created_at DESC",
|
||||
)
|
||||
.bind(research_topic_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(|r| r.get::<Uuid, _>("id")).collect())
|
||||
}
|
||||
|
||||
/// The research topic this run belongs to, if any. Used by the topology
|
||||
/// worker's `freeze_research_outcome` post-hook to snapshot the run's
|
||||
/// final synthesis into `research_outcomes`.
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
"use client";
|
||||
|
||||
// Live topology-run log panel for the research canvas. Renders when at
|
||||
// least one run is active for the selected topic. Tabbed per run, each
|
||||
// tab subscribes to `/api/topology-runs/:id/events` via EventSource
|
||||
// and renders each `step` SSE event as a terminal line. Auto-scroll
|
||||
// tails the newest line unless the user has manually scrolled up.
|
||||
//
|
||||
// Failure modes:
|
||||
// - EventSource network error → header goes red, banner explains, no
|
||||
// reconnect (browser retries automatically).
|
||||
// - `done` event with error → line rendered in red, header stays green.
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { getActiveRuns } from "@/lib/api/research";
|
||||
|
||||
const mono =
|
||||
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
|
||||
|
||||
type SseEvent =
|
||||
| { kind: "step"; index: number; ts: number; raw: string }
|
||||
| { kind: "done"; ts: number; raw: string; error?: string | null };
|
||||
|
||||
function useRunEvents(runId: string | null) {
|
||||
const [events, setEvents] = useState<SseEvent[]>([]);
|
||||
const [status, setStatus] = useState<"connecting" | "streaming" | "done" | "error">(
|
||||
"connecting",
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!runId) return;
|
||||
setEvents([]);
|
||||
setStatus("connecting");
|
||||
const es = new EventSource(`/api/topology-runs/${encodeURIComponent(runId)}/events`);
|
||||
es.onopen = () => setStatus("streaming");
|
||||
es.addEventListener("step", (e: MessageEvent) => {
|
||||
const idx = Number((e as unknown as { lastEventId?: string }).lastEventId ?? -1);
|
||||
setEvents((prev) => [
|
||||
...prev,
|
||||
{ kind: "step", index: Number.isFinite(idx) ? idx : prev.length, ts: Date.now(), raw: e.data },
|
||||
]);
|
||||
});
|
||||
es.addEventListener("done", (e: MessageEvent) => {
|
||||
let error: string | null = null;
|
||||
try {
|
||||
const parsed = JSON.parse(e.data) as { error?: string | null };
|
||||
error = parsed.error ?? null;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
setEvents((prev) => [...prev, { kind: "done", ts: Date.now(), raw: e.data, error }]);
|
||||
setStatus("done");
|
||||
es.close();
|
||||
});
|
||||
es.onerror = () => {
|
||||
setStatus((prev) => (prev === "done" ? prev : "error"));
|
||||
};
|
||||
return () => es.close();
|
||||
}, [runId]);
|
||||
|
||||
return { events, status };
|
||||
}
|
||||
|
||||
function ansiStripSummary(raw: string): string {
|
||||
// Try to parse the step JSON and surface a compact human line; fall
|
||||
// back to the raw payload if it isn't JSON.
|
||||
try {
|
||||
const j = JSON.parse(raw) as {
|
||||
step?: string;
|
||||
kind?: string;
|
||||
status?: string;
|
||||
message?: string;
|
||||
output?: string;
|
||||
error?: string;
|
||||
agent?: string;
|
||||
};
|
||||
const parts: string[] = [];
|
||||
if (j.step) parts.push(j.step);
|
||||
else if (j.kind) parts.push(j.kind);
|
||||
if (j.agent) parts.push(`@${j.agent}`);
|
||||
if (j.status) parts.push(j.status);
|
||||
if (j.error) parts.push(`ERR: ${j.error}`);
|
||||
else if (j.message) parts.push(j.message);
|
||||
else if (j.output) parts.push(j.output.slice(0, 200));
|
||||
if (parts.length > 0) return parts.join(" · ");
|
||||
} catch {
|
||||
/* not JSON */
|
||||
}
|
||||
return raw.length > 240 ? raw.slice(0, 240) + "…" : raw;
|
||||
}
|
||||
|
||||
export function LiveRunLogs({ topicId }: { topicId: string }) {
|
||||
const [runs, setRuns] = useState<string[]>([]);
|
||||
const [activeRun, setActiveRun] = useState<string | null>(null);
|
||||
const [open, setOpen] = useState(true);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [pinned, setPinned] = useState(true); // auto-scroll to bottom
|
||||
|
||||
// Poll active-runs so the list refreshes when new runs kick off or old
|
||||
// ones finish. Cheap query — a single indexed count on the topic.
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
async function tick() {
|
||||
try {
|
||||
const r = await getActiveRuns(topicId);
|
||||
if (!live) return;
|
||||
const ids = r.runs.map((x) => x.id);
|
||||
setRuns(ids);
|
||||
setActiveRun((prev) => {
|
||||
if (prev && ids.includes(prev)) return prev;
|
||||
return ids[0] ?? null;
|
||||
});
|
||||
} catch {
|
||||
/* ignore transient */
|
||||
} finally {
|
||||
if (live) timer = setTimeout(tick, 3000);
|
||||
}
|
||||
}
|
||||
void tick();
|
||||
return () => {
|
||||
live = false;
|
||||
if (timer) clearTimeout(timer);
|
||||
};
|
||||
}, [topicId]);
|
||||
|
||||
const { events, status } = useRunEvents(activeRun);
|
||||
|
||||
// Autoscroll to newest event when pinned to the bottom.
|
||||
useEffect(() => {
|
||||
if (!pinned) return;
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}, [events, pinned]);
|
||||
|
||||
const statusColor = useMemo(() => {
|
||||
if (status === "error") return "#ff8a7a";
|
||||
if (status === "done") return "#5fd08a";
|
||||
if (status === "streaming") return "#5ec8d8";
|
||||
return "#8a8a92";
|
||||
}, [status]);
|
||||
|
||||
// Nothing active → render nothing (matches the "Pipeline in flight"
|
||||
// parent state which is what gates this whole panel).
|
||||
if (runs.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-expanded={open}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
width: "100%",
|
||||
padding: "10px 12px",
|
||||
borderRadius: 10,
|
||||
border: "1px solid rgba(255,255,255,.08)",
|
||||
background: "#101014",
|
||||
color: "#eaeaee",
|
||||
cursor: "pointer",
|
||||
textAlign: "left",
|
||||
fontFamily: mono,
|
||||
fontSize: 11.5,
|
||||
}}
|
||||
>
|
||||
<span style={{ width: 8, height: 8, borderRadius: "50%", background: statusColor }} />
|
||||
<span style={{ flex: 1, color: "#8a8a92" }}>
|
||||
Live run · {events.length} event{events.length === 1 ? "" : "s"} · {status}
|
||||
{runs.length > 1 ? ` · ${runs.length} runs` : ""}
|
||||
</span>
|
||||
<span style={{ opacity: 0.7 }}>{open ? "▾" : "▸"}</span>
|
||||
</button>
|
||||
|
||||
{open ? (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 6,
|
||||
padding: 10,
|
||||
borderRadius: 10,
|
||||
background: "#0a0a0d",
|
||||
border: "1px solid rgba(255,255,255,.06)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
{runs.length > 1 ? (
|
||||
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
|
||||
{runs.map((id, i) => {
|
||||
const isActive = id === activeRun;
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => setActiveRun(id)}
|
||||
style={{
|
||||
padding: "4px 10px",
|
||||
borderRadius: 999,
|
||||
fontFamily: mono,
|
||||
fontSize: 10,
|
||||
background: isActive ? "rgba(94,200,216,.15)" : "transparent",
|
||||
border: `1px solid ${
|
||||
isActive ? "rgba(94,200,216,.45)" : "rgba(255,255,255,.1)"
|
||||
}`,
|
||||
color: isActive ? "#e5f6fb" : "#8a8a92",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
run #{i + 1} · {id.slice(0, 8)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={(e) => {
|
||||
const el = e.currentTarget;
|
||||
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 24;
|
||||
setPinned(nearBottom);
|
||||
}}
|
||||
style={{
|
||||
maxHeight: 320,
|
||||
overflow: "auto",
|
||||
padding: 10,
|
||||
borderRadius: 8,
|
||||
background: "#050507",
|
||||
border: "1px solid rgba(255,255,255,.04)",
|
||||
fontFamily: mono,
|
||||
fontSize: 11,
|
||||
lineHeight: 1.5,
|
||||
color: "#cfcfd5",
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
}}
|
||||
>
|
||||
{events.length === 0 ? (
|
||||
<div style={{ color: "#6a6a72" }}>
|
||||
{status === "connecting"
|
||||
? "connecting…"
|
||||
: status === "streaming"
|
||||
? "waiting for first step…"
|
||||
: "no events"}
|
||||
</div>
|
||||
) : (
|
||||
events.map((e, i) => (
|
||||
<div
|
||||
key={`${i}-${e.ts}`}
|
||||
style={{
|
||||
color: e.kind === "done" && e.error ? "#ff8a7a" : "#cfcfd5",
|
||||
}}
|
||||
>
|
||||
<span style={{ color: "#5a5a62" }}>
|
||||
{new Date(e.ts).toLocaleTimeString()}{" "}
|
||||
{e.kind === "step" ? `#${e.index}` : "done"}
|
||||
</span>{" "}
|
||||
{ansiStripSummary(e.raw)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!pinned ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const el = scrollRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
setPinned(true);
|
||||
}}
|
||||
style={{
|
||||
alignSelf: "flex-end",
|
||||
padding: "4px 10px",
|
||||
borderRadius: 999,
|
||||
background: "rgba(255,255,255,.05)",
|
||||
border: "1px solid rgba(255,255,255,.1)",
|
||||
color: "#cfcfd5",
|
||||
cursor: "pointer",
|
||||
fontFamily: mono,
|
||||
fontSize: 10,
|
||||
}}
|
||||
>
|
||||
Jump to latest
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{status === "error" ? (
|
||||
<div style={{ color: "#ff8a7a", fontSize: 11, fontFamily: mono }}>
|
||||
Stream disconnected. The browser will attempt to reconnect
|
||||
automatically.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
type TopicDetail,
|
||||
type TopicStatus,
|
||||
} from "@/lib/api/research";
|
||||
import { LiveRunLogs } from "./LiveRunLogs";
|
||||
|
||||
const mono =
|
||||
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
|
||||
@@ -518,6 +519,9 @@ export function ResearchCanvas({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Live topology-run logs — renders only when a run is in flight. */}
|
||||
<LiveRunLogs topicId={topic.id} />
|
||||
|
||||
{/* Agents */}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
<div style={sectionHeader}>Agents ({topic.agents.length})</div>
|
||||
|
||||
@@ -7,12 +7,15 @@
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { PanelLeftClose, PanelLeftOpen } from "lucide-react";
|
||||
|
||||
import { MeshMark } from "@/components/brand/MeshMark";
|
||||
import type { Agent } from "@/lib/api/schemas";
|
||||
import type { StructureNode } from "@/lib/api/structure";
|
||||
import { RosterList } from "./RosterList";
|
||||
|
||||
const COLLAPSE_KEY = "cm.sidebar.rosterCollapsed";
|
||||
|
||||
const GROUP_RE = /^\/(orgs|companies|teams)\/([^/]+)/;
|
||||
const LEVEL: Record<string, "org" | "company" | "team"> = {
|
||||
orgs: "org",
|
||||
@@ -31,6 +34,30 @@ export function RosterColumn({ roster }: { roster: Agent[] }) {
|
||||
const level = m ? LEVEL[m[1]] : null;
|
||||
const id = m ? m[2] : null;
|
||||
const [node, setNode] = useState<StructureNode | null>(null);
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
// Hydrate persisted collapse state on mount. Local-storage read has to be
|
||||
// client-only or SSR + client render diverge.
|
||||
useEffect(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem(COLLAPSE_KEY);
|
||||
if (raw === "1") setCollapsed(true);
|
||||
} catch {
|
||||
/* localStorage disabled */
|
||||
}
|
||||
}, []);
|
||||
|
||||
function toggleCollapsed() {
|
||||
setCollapsed((prev) => {
|
||||
const next = !prev;
|
||||
try {
|
||||
localStorage.setItem(COLLAPSE_KEY, next ? "1" : "0");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
// Clear stale children when the selected group changes (render-phase pattern).
|
||||
const key = `${level ?? ""}:${id ?? ""}`;
|
||||
@@ -58,12 +85,35 @@ export function RosterColumn({ roster }: { roster: Agent[] }) {
|
||||
|
||||
const showGroup = level && id;
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
<div className="flex h-full w-[32px] flex-col items-center border-r border-white/[0.06] bg-[#0b0b0e] pt-3">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Expand sidebar"
|
||||
onClick={toggleCollapsed}
|
||||
className="flex size-8 items-center justify-center rounded-lg text-muted-foreground transition-colors duration-(--duration-normal) ease-app hover:bg-hover-bg hover:text-foreground"
|
||||
>
|
||||
<PanelLeftOpen aria-hidden size={16} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-[252px] flex-col border-r border-white/[0.06] bg-[#0b0b0e] pb-4 pt-3">
|
||||
<header className="flex h-12 items-center px-4">
|
||||
<span className="truncate text-sm font-semibold text-foreground">
|
||||
<header className="flex h-12 items-center gap-2 px-4">
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-semibold text-foreground">
|
||||
{showGroup ? (node?.name ?? "…") : "Agents"}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Collapse sidebar"
|
||||
onClick={toggleCollapsed}
|
||||
className="flex size-7 items-center justify-center rounded-lg text-muted-foreground transition-colors duration-(--duration-normal) ease-app hover:bg-hover-bg hover:text-foreground"
|
||||
>
|
||||
<PanelLeftClose aria-hidden size={14} />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="flex w-full flex-1 flex-col overflow-y-auto overscroll-contain px-1 py-2 [&::-webkit-scrollbar]:hidden">
|
||||
|
||||
@@ -227,3 +227,16 @@ export const wizardRepoRelease = (repo_id: string, git_ref?: string) =>
|
||||
method: "POST",
|
||||
body: JSON.stringify({ repo_id, ...(git_ref ? { git_ref } : {}) }),
|
||||
});
|
||||
|
||||
export interface ActiveRun {
|
||||
id: string;
|
||||
}
|
||||
export interface ActiveRunsReply {
|
||||
topic_id: string;
|
||||
runs: ActiveRun[];
|
||||
}
|
||||
|
||||
/** List active (queued+running) topology_run ids bound to a research topic.
|
||||
* Feeds the live-log panel; runs are ordered newest first. */
|
||||
export const getActiveRuns = (topicId: string) =>
|
||||
api<ActiveRunsReply>(`/api/research/${encodeURIComponent(topicId)}/active-runs`);
|
||||
|
||||
Reference in New Issue
Block a user