research canvas: collapsible sidebar + live topology-run logs
ci / gates (pull_request) Successful in 6s
ci / frontend (pull_request) Failing after 39s
ci / rust (pull_request) Successful in 4m25s
ci / e2e (pull_request) Skipped
ci / publish (pull_request) Skipped

Two operator-visibility improvements on the research canvas:

**A. Collapsible sidebar (RosterColumn)**
- Adds a PanelLeftClose/Open chevron toggle on the roster column
  header. When collapsed the 252px column shrinks to a 32px handle,
  giving the main canvas the extra real estate.
- Persists the choice in localStorage (cm.sidebar.rosterCollapsed)
  so it sticks across nav and reloads. Client-only hydration keeps
  SSR + client render in sync.

**B. Live run logs panel**
- New LiveRunLogs component under ResearchCanvas that renders when
  the topic has at least one active topology_run. Reuses the
  existing SSE endpoint /api/topology-runs/:id/events (Last-Event-ID
  resume already supported), so no backend streaming work needed.
- Multi-run support (rare — usually one, but topology fan-out can
  produce N): tab strip picks which run's stream to view; one
  EventSource at a time.
- Terminal-style panel with timestamped lines, auto-scroll pinned
  to the tail unless the user scrolls up, 'Jump to latest' button
  when unpinned. Header pill mirrors stream state
  (connecting/streaming/done/error).

Small backend addition:
- cm_db::repo::topology_runs::active_run_ids_for_research_topic —
  companion to active_runs_for_research_topic that returns ids.
  Uses dynamic sqlx::query() so cm-db builds air-gapped without
  a fresh cargo sqlx prepare.
- GET /api/research/:id/active-runs — workspace-scoped list feeder
  for the new panel.
This commit is contained in:
Omar Sobh
2026-07-15 10:01:26 -07:00
parent 13ead5cb9f
commit 203c85d671
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",
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>,