diff --git a/frontend/src/api/gateway.ts b/frontend/src/api/gateway.ts
index ce30cf5e..e803ffda 100644
--- a/frontend/src/api/gateway.ts
+++ b/frontend/src/api/gateway.ts
@@ -24,6 +24,28 @@ export interface GatewayInfo {
projects: GatewayProject[];
}
+export interface PipelineItem {
+ story_id: string;
+ name: string;
+ stage: string;
+ agent?: { agent_name: string; model: string; status: string } | null;
+ blocked?: boolean;
+ retry_count?: number;
+ merge_failure?: string;
+}
+
+export interface ProjectPipelineStatus {
+ active: PipelineItem[];
+ backlog: { story_id: string; name: string }[];
+ backlog_count: number;
+ error?: string;
+}
+
+export interface AllProjectsPipeline {
+ active: string;
+ projects: Record;
+}
+
export interface GenerateTokenResponse {
token: string;
}
@@ -111,4 +133,17 @@ export const gatewayApi = {
method: "POST",
});
},
+
+ /// Fetch pipeline status from all registered projects.
+ getAllProjectsPipeline(): Promise {
+ return gatewayRequest("/api/gateway/pipeline");
+ },
+
+ /// Switch the active project.
+ switchProject(project: string): Promise<{ ok: boolean; error?: string }> {
+ return gatewayRequest<{ ok: boolean; error?: string }>(
+ "/api/gateway/switch",
+ { method: "POST", body: JSON.stringify({ project }) },
+ );
+ },
};
diff --git a/frontend/src/components/GatewayPanel.tsx b/frontend/src/components/GatewayPanel.tsx
index d2430160..11bab076 100644
--- a/frontend/src/components/GatewayPanel.tsx
+++ b/frontend/src/components/GatewayPanel.tsx
@@ -1,13 +1,21 @@
/// Gateway management panel shown when huskies runs in `--gateway` mode.
///
/// Provides:
+/// - A cross-project pipeline status view showing active stories per project.
+/// - Clicking a project card switches to it.
/// - An "Add Agent" button that generates a one-time join token.
/// - Instructions for running a build agent with the token.
/// - A list of connected agents with per-agent status, project assignment, and "Remove" buttons.
/// - Auto-refresh every 5 seconds so new agents and disconnections appear without a page reload.
import * as React from "react";
-import { gatewayApi, type JoinedAgent, type GatewayProject } from "../api/gateway";
+import {
+ gatewayApi,
+ type JoinedAgent,
+ type GatewayProject,
+ type AllProjectsPipeline,
+ type PipelineItem,
+} from "../api/gateway";
const { useCallback, useEffect, useRef, useState } = React;
@@ -40,6 +48,127 @@ const STATUS_LABELS: Record = {
disconnected: "Disconnected",
};
+const STAGE_COLORS: Record = {
+ current: "#3fb950",
+ qa: "#d2a679",
+ merge: "#79c0ff",
+ done: "#6e7681",
+};
+
+const STAGE_LABELS: Record = {
+ current: "In Progress",
+ qa: "QA",
+ merge: "Merging",
+ done: "Done",
+};
+
+/// A single story row inside a project pipeline card.
+function StoryRow({ item }: { item: PipelineItem }) {
+ const color = STAGE_COLORS[item.stage] ?? "#8b949e";
+ const label = STAGE_LABELS[item.stage] ?? item.stage;
+
+ return (
+
+
+ {label}
+
+
+ {item.name}
+
+
+ );
+}
+
+/// Pipeline status card for a single project.
+function ProjectPipelineCard({
+ name,
+ pipeline,
+ isActive,
+ onSwitch,
+}: {
+ name: string;
+ pipeline: AllProjectsPipeline["projects"][string];
+ isActive: boolean;
+ onSwitch: (name: string) => void;
+}) {
+ const activeItems = pipeline.active ?? [];
+ const backlogCount = pipeline.backlog_count ?? 0;
+ const hasError = Boolean(pipeline.error);
+
+ return (
+ onSwitch(name)}
+ style={{
+ padding: "12px 16px",
+ background: "#161b22",
+ border: `1px solid ${isActive ? "#238636" : "#30363d"}`,
+ borderRadius: "8px",
+ marginBottom: "8px",
+ cursor: "pointer",
+ }}
+ >
+
0 ? "8px" : 0,
+ }}
+ >
+ {name}
+ {isActive && (
+
+ active
+
+ )}
+
+ {backlogCount > 0 ? `${backlogCount} in backlog` : ""}
+
+
+
+ {hasError ? (
+
{pipeline.error}
+ ) : activeItems.length === 0 ? (
+
No active stories
+ ) : (
+
+ {activeItems.map((item) => (
+
+ ))}
+
+ )}
+
+ );
+}
+
function TokenDisplay({ token }: { token: string }) {
const [copied, setCopied] = useState(false);
@@ -237,16 +366,18 @@ export function GatewayPanel() {
const [token, setToken] = useState(null);
const [generating, setGenerating] = useState(false);
const [error, setError] = useState(null);
+ const [pipeline, setPipeline] = useState(null);
// Add-project form state
const [newProjectName, setNewProjectName] = useState("");
const [newProjectUrl, setNewProjectUrl] = useState("");
const [addingProject, setAddingProject] = useState(false);
- // Keep a stable ref to setAgents so the polling interval doesn't need to
- // be recreated when the agents list changes.
+ // Keep stable refs so polling intervals don't recreate on state changes.
const setAgentsRef = useRef(setAgents);
setAgentsRef.current = setAgents;
+ const setPipelineRef = useRef(setPipeline);
+ setPipelineRef.current = setPipeline;
useEffect(() => {
// Initial load.
@@ -258,16 +389,22 @@ export function GatewayPanel() {
.getGatewayInfo()
.then((info) => setProjects(info.projects))
.catch(() => setProjects([]));
+ gatewayApi
+ .getAllProjectsPipeline()
+ .then(setPipeline)
+ .catch(() => setPipeline(null));
- // Poll the agent list so the dashboard auto-updates when agents connect
- // or disconnect.
+ // Poll so the dashboard auto-updates as agents connect/disconnect and
+ // stories move through pipelines.
const timer = setInterval(() => {
gatewayApi
.listAgents()
.then((updated) => setAgentsRef.current(updated))
- .catch(() => {
- // Swallow poll errors to avoid spamming the error banner.
- });
+ .catch(() => {});
+ gatewayApi
+ .getAllProjectsPipeline()
+ .then((updated) => setPipelineRef.current(updated))
+ .catch(() => {});
}, POLL_INTERVAL_MS);
return () => clearInterval(timer);
@@ -328,6 +465,22 @@ export function GatewayPanel() {
}
}, [newProjectName, newProjectUrl]);
+ const handleSwitchProject = useCallback(async (name: string) => {
+ setError(null);
+ try {
+ const result = await gatewayApi.switchProject(name);
+ if (!result.ok) {
+ setError(result.error ?? "Failed to switch project");
+ return;
+ }
+ // Refresh pipeline to reflect new active project.
+ const updated = await gatewayApi.getAllProjectsPipeline();
+ setPipeline(updated);
+ } catch (e) {
+ setError(e instanceof Error ? e.message : String(e));
+ }
+ }, []);
+
const handleRemoveProject = useCallback(async (name: string) => {
if (!window.confirm(`Remove project "${name}"? This cannot be undone.`)) {
return;
@@ -359,6 +512,34 @@ export function GatewayPanel() {
Manage build agents connected to this gateway.
+ {/* Cross-project pipeline status */}
+
+
+ Pipeline Status
+
+ {pipeline ? (
+ Object.entries(pipeline.projects).map(([name, status]) => (
+
+ ))
+ ) : (
+ Loading pipeline status…
+ )}
+
+
{/* Add Agent */}
String {
format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
}
+/// `GET /api/gateway/pipeline` — fetch pipeline status from all registered projects.
+///
+/// Returns `{ "active": "", "projects": { "": { "active": [...], "backlog": [...], "backlog_count": N } | { "error": "..." } } }`.
+#[handler]
+pub async fn gateway_all_pipeline_handler(state: Data<&Arc>) -> Response {
+ let project_entries: Vec<(String, String)> = state
+ .projects
+ .read()
+ .await
+ .iter()
+ .map(|(n, e)| (n.clone(), e.url.clone()))
+ .collect();
+
+ let mut results: BTreeMap = BTreeMap::new();
+
+ for (name, url) in &project_entries {
+ let mcp_url = format!("{}/mcp", url.trim_end_matches('/'));
+ let rpc_body = json!({
+ "jsonrpc": "2.0",
+ "id": 1,
+ "method": "tools/call",
+ "params": {
+ "name": "get_pipeline_status",
+ "arguments": {}
+ }
+ });
+
+ let status = match state.client.post(&mcp_url).json(&rpc_body).send().await {
+ Ok(resp) => match resp.json::().await {
+ Ok(upstream) => {
+ // The tool result is a JSON string embedded in content[0].text.
+ if let Some(text) = upstream
+ .get("result")
+ .and_then(|r| r.get("content"))
+ .and_then(|c| c.get(0))
+ .and_then(|c| c.get("text"))
+ .and_then(|t| t.as_str())
+ {
+ serde_json::from_str(text)
+ .unwrap_or_else(|_| json!({ "error": "invalid pipeline json" }))
+ } else {
+ json!({ "error": "unexpected response shape" })
+ }
+ }
+ Err(e) => json!({ "error": format!("invalid response: {e}") }),
+ },
+ Err(e) => json!({ "error": format!("unreachable: {e}") }),
+ };
+
+ results.insert(name.clone(), status);
+ }
+
+ let active = state.active_project.read().await.clone();
+ let body = json!({ "active": active, "projects": results });
+ Response::builder()
+ .status(StatusCode::OK)
+ .header("Content-Type", "application/json")
+ .body(Body::from(serde_json::to_vec(&body).unwrap_or_default()))
+}
+
/// `GET /api/gateway/bot-config` — return current bot.toml fields as JSON.
#[handler]
pub async fn gateway_bot_config_get_handler(state: Data<&Arc>) -> Response {
@@ -1622,6 +1682,10 @@ pub async fn run(config_path: &Path, port: u16) -> Result<(), std::io::Error> {
.at("/bot-config", poem::get(gateway_bot_config_page_handler))
.at("/api/gateway", poem::get(gateway_api_handler))
.at("/api/gateway/switch", poem::post(gateway_switch_handler))
+ .at(
+ "/api/gateway/pipeline",
+ poem::get(gateway_all_pipeline_handler),
+ )
.at(
"/api/gateway/projects",
poem::post(gateway_add_project_handler),