diff --git a/frontend/src/App.css b/frontend/src/App.css index 3b7e89f4..dad5103e 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -209,6 +209,16 @@ body, } } +/* Spinner for in-progress deterministic merges */ +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + /* Agent lozenge appearance animation (simulates arriving from agents panel) */ @keyframes agentAppear { from { diff --git a/frontend/src/api/client.test.ts b/frontend/src/api/client.test.ts index 9c03cab7..dbd2ff5f 100644 --- a/frontend/src/api/client.test.ts +++ b/frontend/src/api/client.test.ts @@ -267,6 +267,7 @@ describe("ChatWebSocket", () => { qa: [], merge: [], done: [], + deterministic_merges_in_flight: [], }; instances[1].simulateMessage({ type: "pipeline_state", ...freshState }); diff --git a/frontend/src/api/client/types.ts b/frontend/src/api/client/types.ts index 1cff0982..71448b20 100644 --- a/frontend/src/api/client/types.ts +++ b/frontend/src/api/client/types.ts @@ -69,6 +69,8 @@ export interface PipelineState { qa: PipelineStageItem[]; merge: PipelineStageItem[]; done: PipelineStageItem[]; + /** Story IDs that currently have a deterministic merge in progress. */ + deterministic_merges_in_flight: string[]; } /** A message received from the Huskies server over WebSocket. */ @@ -84,6 +86,7 @@ export type WsResponse = qa: PipelineStageItem[]; merge: PipelineStageItem[]; done: PipelineStageItem[]; + deterministic_merges_in_flight: string[]; } | { type: "permission_request"; diff --git a/frontend/src/api/client/websocket.ts b/frontend/src/api/client/websocket.ts index 7ce62541..8367a092 100644 --- a/frontend/src/api/client/websocket.ts +++ b/frontend/src/api/client/websocket.ts @@ -123,6 +123,8 @@ export class ChatWebSocket { qa: data.qa, merge: data.merge, done: data.done, + deterministic_merges_in_flight: + data.deterministic_merges_in_flight ?? [], }); if (data.type === "permission_request") this.onPermissionRequest?.( diff --git a/frontend/src/components/ChatPipelinePanel.tsx b/frontend/src/components/ChatPipelinePanel.tsx index d095722a..28fbc37b 100644 --- a/frontend/src/components/ChatPipelinePanel.tsx +++ b/frontend/src/components/ChatPipelinePanel.tsx @@ -110,52 +110,66 @@ export function ChatPipelinePanel({ configVersion={agentConfigVersion} stateVersion={agentStateVersion} /> - onSelectWorkItem(item.story_id)} - onStopAgent={onStopAgent} - onDeleteItem={onDeleteItem} - /> - onSelectWorkItem(item.story_id)} - onStopAgent={onStopAgent} - onDeleteItem={onDeleteItem} - /> - onSelectWorkItem(item.story_id)} - onStopAgent={onStopAgent} - onDeleteItem={onDeleteItem} - /> - onSelectWorkItem(item.story_id)} - agentRoster={agentRoster} - busyAgentNames={busyAgentNames} - onStartAgent={onStartAgent} - onStopAgent={onStopAgent} - onDeleteItem={onDeleteItem} - /> - onSelectWorkItem(item.story_id)} - agentRoster={agentRoster} - busyAgentNames={busyAgentNames} - onStartAgent={onStartAgent} - onStopAgent={onStopAgent} - onDeleteItem={onDeleteItem} - /> + {(() => { + const mergesInFlight = new Set( + pipeline.deterministic_merges_in_flight ?? [], + ); + return ( + <> + onSelectWorkItem(item.story_id)} + onStopAgent={onStopAgent} + onDeleteItem={onDeleteItem} + mergesInFlight={mergesInFlight} + /> + onSelectWorkItem(item.story_id)} + onStopAgent={onStopAgent} + onDeleteItem={onDeleteItem} + mergesInFlight={mergesInFlight} + /> + onSelectWorkItem(item.story_id)} + onStopAgent={onStopAgent} + onDeleteItem={onDeleteItem} + mergesInFlight={mergesInFlight} + /> + onSelectWorkItem(item.story_id)} + agentRoster={agentRoster} + busyAgentNames={busyAgentNames} + onStartAgent={onStartAgent} + onStopAgent={onStopAgent} + onDeleteItem={onDeleteItem} + mergesInFlight={mergesInFlight} + /> + onSelectWorkItem(item.story_id)} + agentRoster={agentRoster} + busyAgentNames={busyAgentNames} + onStartAgent={onStartAgent} + onStopAgent={onStopAgent} + onDeleteItem={onDeleteItem} + mergesInFlight={mergesInFlight} + /> + + ); + })()} )} diff --git a/frontend/src/components/LozengeFlyContext.agentlozenge.test.tsx b/frontend/src/components/LozengeFlyContext.agentlozenge.test.tsx index 50316cc8..c8cf9706 100644 --- a/frontend/src/components/LozengeFlyContext.agentlozenge.test.tsx +++ b/frontend/src/components/LozengeFlyContext.agentlozenge.test.tsx @@ -14,6 +14,7 @@ function makePipeline(overrides: Partial = {}): PipelineState { qa: [], merge: [], done: [], + deterministic_merges_in_flight: [], ...overrides, }; } diff --git a/frontend/src/components/LozengeFlyContext.flyin.test.tsx b/frontend/src/components/LozengeFlyContext.flyin.test.tsx index c730ef4b..26a5629d 100644 --- a/frontend/src/components/LozengeFlyContext.flyin.test.tsx +++ b/frontend/src/components/LozengeFlyContext.flyin.test.tsx @@ -14,6 +14,7 @@ function makePipeline(overrides: Partial = {}): PipelineState { qa: [], merge: [], done: [], + deterministic_merges_in_flight: [], ...overrides, }; } diff --git a/frontend/src/components/LozengeFlyContext.flyout.test.tsx b/frontend/src/components/LozengeFlyContext.flyout.test.tsx index 59326fc4..0f0c2b9e 100644 --- a/frontend/src/components/LozengeFlyContext.flyout.test.tsx +++ b/frontend/src/components/LozengeFlyContext.flyout.test.tsx @@ -14,6 +14,7 @@ function makePipeline(overrides: Partial = {}): PipelineState { qa: [], merge: [], done: [], + deterministic_merges_in_flight: [], ...overrides, }; } diff --git a/frontend/src/components/LozengeFlyContext.hidden.test.tsx b/frontend/src/components/LozengeFlyContext.hidden.test.tsx index 34466dd5..e1e4abbb 100644 --- a/frontend/src/components/LozengeFlyContext.hidden.test.tsx +++ b/frontend/src/components/LozengeFlyContext.hidden.test.tsx @@ -14,6 +14,7 @@ function makePipeline(overrides: Partial = {}): PipelineState { qa: [], merge: [], done: [], + deterministic_merges_in_flight: [], ...overrides, }; } diff --git a/frontend/src/components/StagePanel.test.tsx b/frontend/src/components/StagePanel.test.tsx index db3a83d7..82f5ec5c 100644 --- a/frontend/src/components/StagePanel.test.tsx +++ b/frontend/src/components/StagePanel.test.tsx @@ -324,4 +324,71 @@ describe("StagePanel", () => { screen.queryByTestId("merge-failure-reason-31_story_no_failure"), ).not.toBeInTheDocument(); }); + + it("shows merge-in-flight icon when story is in mergesInFlight set", () => { + const items: PipelineStageItem[] = [ + { + story_id: "40_story_merging", + name: "Merging Story", + error: null, + merge_failure: null, + agent: null, + review_hold: null, + qa: null, + depends_on: null, + }, + ]; + const mergesInFlight = new Set(["40_story_merging"]); + render( + , + ); + expect( + screen.getByTestId("merge-in-flight-icon-40_story_merging"), + ).toBeInTheDocument(); + }); + + it("does not show merge-in-flight icon when story is not in mergesInFlight set", () => { + const items: PipelineStageItem[] = [ + { + story_id: "41_story_not_merging", + name: "Idle Story", + error: null, + merge_failure: null, + agent: null, + review_hold: null, + qa: null, + depends_on: null, + }, + ]; + const mergesInFlight = new Set(["99_story_other"]); + render( + , + ); + expect( + screen.queryByTestId("merge-in-flight-icon-41_story_not_merging"), + ).not.toBeInTheDocument(); + }); + + it("does not show merge-in-flight icon when mergesInFlight prop is absent", () => { + const items: PipelineStageItem[] = [ + { + story_id: "42_story_no_prop", + name: "No Prop Story", + error: null, + merge_failure: null, + agent: null, + review_hold: null, + qa: null, + depends_on: null, + }, + ]; + render(); + expect( + screen.queryByTestId("merge-in-flight-icon-42_story_no_prop"), + ).not.toBeInTheDocument(); + }); }); diff --git a/frontend/src/components/StagePanel.tsx b/frontend/src/components/StagePanel.tsx index 14e06111..699fcb81 100644 --- a/frontend/src/components/StagePanel.tsx +++ b/frontend/src/components/StagePanel.tsx @@ -53,6 +53,8 @@ interface StagePanelProps { busyAgentNames?: Set; /** Called when the user requests to start an agent on a story. */ onStartAgent?: (storyId: string, agentName?: string) => void; + /** Set of story IDs that currently have a deterministic merge in progress. */ + mergesInFlight?: Set; } function AgentLozenge({ @@ -259,6 +261,7 @@ export function StagePanel({ agentRoster, busyAgentNames, onStartAgent, + mergesInFlight, }: StagePanelProps) { const showStartButton = Boolean(onStartAgent) && @@ -355,6 +358,19 @@ export function StagePanel({ ✕ )} + {mergesInFlight?.has(item.story_id) && ( + + ⟳ + + )} {itemNumber && ( , pub merge: Vec, pub done: Vec, + /// Story IDs that currently have a deterministic merge in progress. + pub deterministic_merges_in_flight: Vec, } /// Load the full pipeline state (all 5 active stages). @@ -71,12 +73,19 @@ pub fn load_pipeline_state(ctx: &AppContext) -> Result { let typed_items = crate::pipeline_state::read_all_typed(); + let deterministic_merges_in_flight = ctx + .services + .agents + .list_running_merges() + .unwrap_or_default(); + let mut state = PipelineState { backlog: Vec::new(), current: Vec::new(), qa: Vec::new(), merge: Vec::new(), done: Vec::new(), + deterministic_merges_in_flight, }; for item in typed_items { diff --git a/server/src/service/ws/message/convert.rs b/server/src/service/ws/message/convert.rs index f9bd60fd..06b61d8f 100644 --- a/server/src/service/ws/message/convert.rs +++ b/server/src/service/ws/message/convert.rs @@ -53,6 +53,7 @@ pub fn pipeline_state_to_response(s: PipelineState) -> WsResponse { qa: s.qa, merge: s.merge, done: s.done, + deterministic_merges_in_flight: s.deterministic_merges_in_flight, } } @@ -238,6 +239,7 @@ mod tests { blocked: None, depends_on: None, }], + deterministic_merges_in_flight: vec![], }; let resp = pipeline_state_to_response(state); let json = serde_json::to_value(&resp).unwrap(); @@ -260,6 +262,7 @@ mod tests { qa: vec![], merge: vec![], done: vec![], + deterministic_merges_in_flight: vec![], }; let resp = pipeline_state_to_response(state); let json = serde_json::to_value(&resp).unwrap(); @@ -294,6 +297,7 @@ mod tests { qa: vec![], merge: vec![], done: vec![], + deterministic_merges_in_flight: vec![], }; let resp: WsResponse = state.into(); let json = serde_json::to_value(&resp).unwrap(); diff --git a/server/src/service/ws/message/response.rs b/server/src/service/ws/message/response.rs index 259b3b5b..4292b930 100644 --- a/server/src/service/ws/message/response.rs +++ b/server/src/service/ws/message/response.rs @@ -54,6 +54,8 @@ pub enum WsResponse { qa: Vec, merge: Vec, done: Vec, + /// Story IDs that currently have a deterministic merge in progress. + deterministic_merges_in_flight: Vec, }, /// `.huskies/project.toml` was modified; the frontend should re-fetch the /// agent roster. Does NOT trigger a pipeline state refresh. @@ -215,6 +217,7 @@ mod tests { qa: vec![], merge: vec![], done: vec![], + deterministic_merges_in_flight: vec![], }; let json = serde_json::to_value(&resp).unwrap(); assert_eq!(json["type"], "pipeline_state");