research canvas: collapsible sidebar + live topology-run logs #6

Merged
osobh merged 1 commits from research-sidebar-collapse-and-live-logs into main 2026-07-15 17:01:49 +00:00
7 changed files with 433 additions and 2 deletions
+4
View File
@@ -454,6 +454,10 @@ pub fn router(state: AppState) -> Router {
"/api/research/{id}/pipeline-state", "/api/research/{id}/pipeline-state",
get(routes::research_pipeline::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/research/probe", post(routes::probe::probe))
.route( .route(
"/api/loops", "/api/loops",
@@ -39,6 +39,38 @@ pub struct PipelineState {
pub stages: Vec<PipelineStage>, 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`. /// `GET /api/research/:id/pipeline-state`.
pub async fn pipeline_state( pub async fn pipeline_state(
State(state): State<AppState>, State(state): State<AppState>,
+25
View File
@@ -167,6 +167,31 @@ pub async fn active_runs_for_research_topic(
Ok(row.n.unwrap_or(0)) 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 /// 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 /// worker's `freeze_research_outcome` post-hook to snapshot the run's
/// final synthesis into `research_outcomes`. /// 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 TopicDetail,
type TopicStatus, type TopicStatus,
} from "@/lib/api/research"; } from "@/lib/api/research";
import { LiveRunLogs } from "./LiveRunLogs";
const mono = const mono =
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace"; "ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
@@ -518,6 +519,9 @@ export function ResearchCanvas({
</div> </div>
) : null} ) : null}
{/* Live topology-run logs — renders only when a run is in flight. */}
<LiveRunLogs topicId={topic.id} />
{/* Agents */} {/* Agents */}
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}> <div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<div style={sectionHeader}>Agents ({topic.agents.length})</div> <div style={sectionHeader}>Agents ({topic.agents.length})</div>
+52 -2
View File
@@ -7,12 +7,15 @@
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { PanelLeftClose, PanelLeftOpen } from "lucide-react";
import { MeshMark } from "@/components/brand/MeshMark"; import { MeshMark } from "@/components/brand/MeshMark";
import type { Agent } from "@/lib/api/schemas"; import type { Agent } from "@/lib/api/schemas";
import type { StructureNode } from "@/lib/api/structure"; import type { StructureNode } from "@/lib/api/structure";
import { RosterList } from "./RosterList"; import { RosterList } from "./RosterList";
const COLLAPSE_KEY = "cm.sidebar.rosterCollapsed";
const GROUP_RE = /^\/(orgs|companies|teams)\/([^/]+)/; const GROUP_RE = /^\/(orgs|companies|teams)\/([^/]+)/;
const LEVEL: Record<string, "org" | "company" | "team"> = { const LEVEL: Record<string, "org" | "company" | "team"> = {
orgs: "org", orgs: "org",
@@ -31,6 +34,30 @@ export function RosterColumn({ roster }: { roster: Agent[] }) {
const level = m ? LEVEL[m[1]] : null; const level = m ? LEVEL[m[1]] : null;
const id = m ? m[2] : null; const id = m ? m[2] : null;
const [node, setNode] = useState<StructureNode | null>(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). // Clear stale children when the selected group changes (render-phase pattern).
const key = `${level ?? ""}:${id ?? ""}`; const key = `${level ?? ""}:${id ?? ""}`;
@@ -58,12 +85,35 @@ export function RosterColumn({ roster }: { roster: Agent[] }) {
const showGroup = level && id; 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 ( return (
<div className="flex h-full w-[252px] flex-col border-r border-white/[0.06] bg-[#0b0b0e] pb-4 pt-3"> <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"> <header className="flex h-12 items-center gap-2 px-4">
<span className="truncate text-sm font-semibold text-foreground"> <span className="min-w-0 flex-1 truncate text-sm font-semibold text-foreground">
{showGroup ? (node?.name ?? "…") : "Agents"} {showGroup ? (node?.name ?? "…") : "Agents"}
</span> </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> </header>
<div className="flex w-full flex-1 flex-col overflow-y-auto overscroll-contain px-1 py-2 [&::-webkit-scrollbar]:hidden"> <div className="flex w-full flex-1 flex-col overflow-y-auto overscroll-contain px-1 py-2 [&::-webkit-scrollbar]:hidden">
+13
View File
@@ -227,3 +227,16 @@ export const wizardRepoRelease = (repo_id: string, git_ref?: string) =>
method: "POST", method: "POST",
body: JSON.stringify({ repo_id, ...(git_ref ? { git_ref } : {}) }), 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`);