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();