research canvas: collapsible sidebar + live topology-run logs #6
@@ -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>,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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`);
|
||||||
|
|||||||
Reference in New Issue
Block a user