From 9e06fff8a8a494f2439bc3c74e0346899820b8fb Mon Sep 17 00:00:00 2001
From: dave
Date: Thu, 14 May 2026 16:13:01 +0000
Subject: [PATCH] huskies: merge 1046
---
frontend/src/api/gateway.ts | 1 +
frontend/src/components/GatewayPanel.tsx | 455 +++++++++++++-----
.../src/http/mcp/story_tools/story/query.rs | 7 +
server/src/http/workflow/pipeline.rs | 6 +-
server/src/service/gateway/io.rs | 4 +-
server/src/service/ws/message/convert.rs | 3 +
6 files changed, 342 insertions(+), 134 deletions(-)
diff --git a/frontend/src/api/gateway.ts b/frontend/src/api/gateway.ts
index cd91305f..e0e01111 100644
--- a/frontend/src/api/gateway.ts
+++ b/frontend/src/api/gateway.ts
@@ -38,6 +38,7 @@ export interface ProjectPipelineStatus {
active: PipelineItem[];
backlog: { story_id: string; name: string }[];
backlog_count: number;
+ archived?: PipelineItem[];
error?: string;
}
diff --git a/frontend/src/components/GatewayPanel.tsx b/frontend/src/components/GatewayPanel.tsx
index 1c9a6af6..ebacdeaf 100644
--- a/frontend/src/components/GatewayPanel.tsx
+++ b/frontend/src/components/GatewayPanel.tsx
@@ -49,17 +49,21 @@ const STATUS_LABELS: Record = {
};
const STAGE_COLORS: Record = {
+ backlog: "#8b949e",
current: "#3fb950",
qa: "#d2a679",
merge: "#79c0ff",
done: "#6e7681",
+ archived: "#6e7681",
};
const STAGE_LABELS: Record = {
+ backlog: "Backlog",
current: "In Progress",
qa: "QA",
merge: "Merging",
done: "Done",
+ archived: "Archived",
};
/// A single story row inside a project pipeline card.
@@ -120,106 +124,6 @@ export function StoryRow({ item }: { item: PipelineItem }) {
);
}
-/// 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 backlogItems = pipeline.backlog ?? [];
- const backlogCount = pipeline.backlog_count ?? 0;
- const remainingBacklog = backlogCount - backlogItems.length;
- 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 || backlogItems.length > 0 ? "8px" : 0,
- }}
- >
- {name}
- {isActive && (
-
- active
-
- )}
-
-
- {hasError ? (
-
{pipeline.error}
- ) : activeItems.length === 0 && backlogItems.length === 0 ? (
-
- {backlogCount > 0 ? `${backlogCount} in backlog` : "No active stories"}
-
- ) : (
-
- {activeItems.map((item) => (
-
- ))}
- {backlogItems.length > 0 && (
-
0 ? "6px" : 0, borderTop: activeItems.length > 0 ? "1px solid #21262d" : "none", paddingTop: activeItems.length > 0 ? "6px" : 0 }}>
- {backlogItems.map((item) => {
- const idNum = item.story_id.match(/^(\d+)/)?.[1];
- return (
-
- {idNum && #{idNum}}
- {item.name}
-
- );
- })}
- {remainingBacklog > 0 && (
-
- …and {remainingBacklog} more
-
- )}
-
- )}
-
- )}
-
- );
-}
function TokenDisplay({ token }: { token: string }) {
const [copied, setCopied] = useState(false);
@@ -411,6 +315,270 @@ function AgentRow({
);
}
+type TabKey = "backlog" | "in-progress" | "done" | "archived";
+
+const TAB_STORAGE_KEY = "gateway_selected_tab";
+
+/// Read the persisted tab from localStorage, defaulting to "in-progress".
+function readStoredTab(): TabKey {
+ const stored = localStorage.getItem(TAB_STORAGE_KEY);
+ if (
+ stored === "backlog" ||
+ stored === "in-progress" ||
+ stored === "done" ||
+ stored === "archived"
+ ) {
+ return stored;
+ }
+ return "in-progress";
+}
+
+/// Aggregate pipeline items from all projects for a given tab.
+function aggregateItems(
+ pipeline: AllProjectsPipeline,
+ tab: TabKey,
+): { project: string; items: PipelineItem[] }[] {
+ return Object.entries(pipeline.projects)
+ .map(([project, status]) => {
+ if (status.error) return { project, items: [] };
+ if (tab === "backlog") {
+ return {
+ project,
+ items: (status.backlog ?? []).map((b) => ({
+ story_id: b.story_id,
+ name: b.name,
+ stage: "backlog",
+ })),
+ };
+ }
+ if (tab === "in-progress") {
+ return {
+ project,
+ items: (status.active ?? []).filter(
+ (i) => i.stage !== "done",
+ ),
+ };
+ }
+ if (tab === "done") {
+ return {
+ project,
+ items: (status.active ?? []).filter((i) => i.stage === "done"),
+ };
+ }
+ // archived
+ return { project, items: status.archived ?? [] };
+ })
+ .filter((g) => g.items.length > 0);
+}
+
+/// Count total items across all projects for a given tab.
+function tabCount(pipeline: AllProjectsPipeline, tab: TabKey): number {
+ return Object.values(pipeline.projects).reduce((sum, status) => {
+ if (status.error) return sum;
+ if (tab === "backlog") return sum + (status.backlog_count ?? 0);
+ if (tab === "in-progress") {
+ return (
+ sum +
+ (status.active ?? []).filter((i) => i.stage !== "done").length
+ );
+ }
+ if (tab === "done") {
+ return (
+ sum + (status.active ?? []).filter((i) => i.stage === "done").length
+ );
+ }
+ return sum + (status.archived ?? []).length;
+ }, 0);
+}
+
+/// Tab bar button.
+function TabButton({
+ label,
+ count,
+ active,
+ onClick,
+}: {
+ label: string;
+ count: number;
+ active: boolean;
+ onClick: () => void;
+}) {
+ return (
+
+ );
+}
+
+/// A project-labelled story row used in the aggregate tab view.
+function ProjectStoryRow({
+ project,
+ item,
+ showProject,
+}: {
+ project: string;
+ item: PipelineItem;
+ showProject: boolean;
+}) {
+ return (
+
+ {showProject && (
+
+ {project}
+
+ )}
+
+
+
+
+ );
+}
+
+const IN_PROGRESS_STAGE_LABELS: Record = {
+ current: "Coding",
+ qa: "QA",
+ merge: "Merging",
+};
+
+/// In Progress tab content — items grouped by stage (coding / qa / merging).
+function InProgressTabContent({
+ groups,
+}: {
+ groups: { project: string; items: PipelineItem[] }[];
+}) {
+ const allItems = groups.flatMap((g) =>
+ g.items.map((item) => ({ project: g.project, item })),
+ );
+ const multiProject = new Set(allItems.map((x) => x.project)).size > 1;
+
+ const byStage = {
+ current: allItems.filter((x) => x.item.stage === "current"),
+ qa: allItems.filter((x) => x.item.stage === "qa"),
+ merge: allItems.filter((x) => x.item.stage === "merge"),
+ };
+
+ const stages = (["current", "qa", "merge"] as const).filter(
+ (s) => byStage[s].length > 0,
+ );
+
+ if (allItems.length === 0) {
+ return (
+
+ No items in progress.
+
+ );
+ }
+
+ return (
+
+ {stages.map((stage) => (
+
+
+ {IN_PROGRESS_STAGE_LABELS[stage]}{" "}
+
+ ({byStage[stage].length})
+
+
+ {byStage[stage].map(({ project, item }) => (
+
+ ))}
+
+ ))}
+
+ );
+}
+
+/// Flat list tab content for Backlog, Done, and Archived.
+function FlatTabContent({
+ groups,
+ emptyMessage,
+}: {
+ groups: { project: string; items: PipelineItem[] }[];
+ emptyMessage: string;
+}) {
+ const allItems = groups.flatMap((g) =>
+ g.items.map((item) => ({ project: g.project, item })),
+ );
+ const multiProject = new Set(allItems.map((x) => x.project)).size > 1;
+
+ if (allItems.length === 0) {
+ return (
+ {emptyMessage}
+ );
+ }
+
+ return (
+
+ {allItems.map(({ project, item }) => (
+
+ ))}
+
+ );
+}
+
/// Gateway management panel — rendered when running in `--gateway` mode.
export function GatewayPanel() {
const [agents, setAgents] = useState([]);
@@ -419,6 +587,7 @@ export function GatewayPanel() {
const [generating, setGenerating] = useState(false);
const [error, setError] = useState(null);
const [pipeline, setPipeline] = useState(null);
+ const [selectedTab, setSelectedTab] = useState(readStoredTab);
// Keep stable refs so polling intervals don't recreate on state changes.
const setAgentsRef = useRef(setAgents);
@@ -494,20 +663,9 @@ export function GatewayPanel() {
[],
);
- 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 handleSelectTab = useCallback((tab: TabKey) => {
+ setSelectedTab(tab);
+ localStorage.setItem(TAB_STORAGE_KEY, tab);
}, []);
@@ -529,29 +687,62 @@ export function GatewayPanel() {
Manage build agents connected to this gateway.
- {/* Cross-project pipeline status */}
+ {/* Cross-project pipeline tabs */}
-
- Pipeline Status
-
- {pipeline ? (
- Object.entries(pipeline.projects).map(([name, status]) => (
- (
+ handleSelectTab(key)}
/>
- ))
+ ))}
+
+
+ {/* Tab content */}
+ {pipeline ? (
+ <>
+ {selectedTab === "backlog" && (
+
+ )}
+ {selectedTab === "in-progress" && (
+
+ )}
+ {selectedTab === "done" && (
+
+ )}
+ {selectedTab === "archived" && (
+
+ )}
+ >
) : (
Loading pipeline status…
)}
diff --git a/server/src/http/mcp/story_tools/story/query.rs b/server/src/http/mcp/story_tools/story/query.rs
index 4bf35c58..78856bc2 100644
--- a/server/src/http/mcp/story_tools/story/query.rs
+++ b/server/src/http/mcp/story_tools/story/query.rs
@@ -90,10 +90,17 @@ pub(crate) fn tool_get_pipeline_status(ctx: &AppContext) -> Result = state
+ .archived
+ .iter()
+ .map(|s| json!({ "story_id": s.story_id, "name": s.name, "stage": "archived" }))
+ .collect();
+
serde_json::to_string_pretty(&json!({
"active": active,
"backlog": backlog,
"backlog_count": backlog.len(),
+ "archived": archived,
"deterministic_merges_in_flight": running_merges,
}))
.map_err(|e| format!("Serialization error: {e}"))
diff --git a/server/src/http/workflow/pipeline.rs b/server/src/http/workflow/pipeline.rs
index c7764229..74889622 100644
--- a/server/src/http/workflow/pipeline.rs
+++ b/server/src/http/workflow/pipeline.rs
@@ -64,6 +64,8 @@ pub struct PipelineState {
pub done: Vec,
/// Abandoned, superseded, and rejected items (story 984).
pub closed: Vec,
+ /// Items swept from Done into the archived terminal state.
+ pub archived: Vec,
/// Story IDs that currently have a deterministic merge in progress.
pub deterministic_merges_in_flight: Vec,
}
@@ -104,6 +106,7 @@ pub fn load_pipeline_state(ctx: &AppContext) -> Result {
merge: Vec::new(),
done: Vec::new(),
closed: Vec::new(),
+ archived: Vec::new(),
deterministic_merges_in_flight,
};
@@ -194,7 +197,7 @@ pub fn load_pipeline_state(ctx: &AppContext) -> Result {
Stage::Abandoned { .. } | Stage::Superseded { .. } | Stage::Rejected { .. } => {
state.closed.push(story)
}
- Stage::Archived { .. } => {} // Completed/MergeFailed/ReviewHeld stay hidden
+ Stage::Archived { .. } => state.archived.push(story),
}
}
@@ -205,6 +208,7 @@ pub fn load_pipeline_state(ctx: &AppContext) -> Result {
state.merge.sort_by(|a, b| a.story_id.cmp(&b.story_id));
state.done.sort_by(|a, b| a.story_id.cmp(&b.story_id));
state.closed.sort_by(|a, b| a.story_id.cmp(&b.story_id));
+ state.archived.sort_by(|a, b| a.story_id.cmp(&b.story_id));
Ok(state)
}
diff --git a/server/src/service/gateway/io.rs b/server/src/service/gateway/io.rs
index f05ae384..e41b285b 100644
--- a/server/src/service/gateway/io.rs
+++ b/server/src/service/gateway/io.rs
@@ -234,11 +234,13 @@ pub async fn fetch_one_project_pipeline_items(url: &str, client: &Client) -> Val
match serde_json::from_str::(text) {
Ok(pipeline) => {
let active = pipeline.get("active").cloned().unwrap_or(json!([]));
+ let backlog = pipeline.get("backlog").cloned().unwrap_or(json!([]));
let backlog_count = pipeline
.get("backlog_count")
.and_then(|n| n.as_u64())
.unwrap_or(0);
- json!({ "active": active, "backlog_count": backlog_count })
+ let archived = pipeline.get("archived").cloned().unwrap_or(json!([]));
+ json!({ "active": active, "backlog": backlog, "backlog_count": backlog_count, "archived": archived })
}
Err(_) => json!({ "error": "invalid pipeline JSON" }),
}
diff --git a/server/src/service/ws/message/convert.rs b/server/src/service/ws/message/convert.rs
index 6e0e5b8e..ec30e300 100644
--- a/server/src/service/ws/message/convert.rs
+++ b/server/src/service/ws/message/convert.rs
@@ -249,6 +249,7 @@ mod tests {
epic_id: None,
}],
closed: vec![],
+ archived: vec![],
deterministic_merges_in_flight: vec![],
};
let resp = pipeline_state_to_response(state);
@@ -273,6 +274,7 @@ mod tests {
merge: vec![],
done: vec![],
closed: vec![],
+ archived: vec![],
deterministic_merges_in_flight: vec![],
};
let resp = pipeline_state_to_response(state);
@@ -311,6 +313,7 @@ mod tests {
merge: vec![],
done: vec![],
closed: vec![],
+ archived: vec![],
deterministic_merges_in_flight: vec![],
};
let resp: WsResponse = state.into();