huskies: merge 820

This commit is contained in:
dave
2026-04-29 17:15:01 +00:00
parent c84786364a
commit 8a42839b37
15 changed files with 180 additions and 46 deletions
+10
View File
@@ -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 {
+1
View File
@@ -267,6 +267,7 @@ describe("ChatWebSocket", () => {
qa: [],
merge: [],
done: [],
deterministic_merges_in_flight: [],
};
instances[1].simulateMessage({ type: "pipeline_state", ...freshState });
+3
View File
@@ -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";
+2
View File
@@ -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?.(
+60 -46
View File
@@ -110,52 +110,66 @@ export function ChatPipelinePanel({
configVersion={agentConfigVersion}
stateVersion={agentStateVersion}
/>
<StagePanel
title="Done"
items={pipeline.done ?? []}
costs={storyTokenCosts}
onItemClick={(item) => onSelectWorkItem(item.story_id)}
onStopAgent={onStopAgent}
onDeleteItem={onDeleteItem}
/>
<StagePanel
title="To Merge"
items={pipeline.merge}
costs={storyTokenCosts}
onItemClick={(item) => onSelectWorkItem(item.story_id)}
onStopAgent={onStopAgent}
onDeleteItem={onDeleteItem}
/>
<StagePanel
title="QA"
items={pipeline.qa}
costs={storyTokenCosts}
onItemClick={(item) => onSelectWorkItem(item.story_id)}
onStopAgent={onStopAgent}
onDeleteItem={onDeleteItem}
/>
<StagePanel
title="Current"
items={pipeline.current}
costs={storyTokenCosts}
onItemClick={(item) => onSelectWorkItem(item.story_id)}
agentRoster={agentRoster}
busyAgentNames={busyAgentNames}
onStartAgent={onStartAgent}
onStopAgent={onStopAgent}
onDeleteItem={onDeleteItem}
/>
<StagePanel
title="Backlog"
items={pipeline.backlog}
costs={storyTokenCosts}
onItemClick={(item) => onSelectWorkItem(item.story_id)}
agentRoster={agentRoster}
busyAgentNames={busyAgentNames}
onStartAgent={onStartAgent}
onStopAgent={onStopAgent}
onDeleteItem={onDeleteItem}
/>
{(() => {
const mergesInFlight = new Set(
pipeline.deterministic_merges_in_flight ?? [],
);
return (
<>
<StagePanel
title="Done"
items={pipeline.done ?? []}
costs={storyTokenCosts}
onItemClick={(item) => onSelectWorkItem(item.story_id)}
onStopAgent={onStopAgent}
onDeleteItem={onDeleteItem}
mergesInFlight={mergesInFlight}
/>
<StagePanel
title="To Merge"
items={pipeline.merge}
costs={storyTokenCosts}
onItemClick={(item) => onSelectWorkItem(item.story_id)}
onStopAgent={onStopAgent}
onDeleteItem={onDeleteItem}
mergesInFlight={mergesInFlight}
/>
<StagePanel
title="QA"
items={pipeline.qa}
costs={storyTokenCosts}
onItemClick={(item) => onSelectWorkItem(item.story_id)}
onStopAgent={onStopAgent}
onDeleteItem={onDeleteItem}
mergesInFlight={mergesInFlight}
/>
<StagePanel
title="Current"
items={pipeline.current}
costs={storyTokenCosts}
onItemClick={(item) => onSelectWorkItem(item.story_id)}
agentRoster={agentRoster}
busyAgentNames={busyAgentNames}
onStartAgent={onStartAgent}
onStopAgent={onStopAgent}
onDeleteItem={onDeleteItem}
mergesInFlight={mergesInFlight}
/>
<StagePanel
title="Backlog"
items={pipeline.backlog}
costs={storyTokenCosts}
onItemClick={(item) => onSelectWorkItem(item.story_id)}
agentRoster={agentRoster}
busyAgentNames={busyAgentNames}
onStartAgent={onStartAgent}
onStopAgent={onStopAgent}
onDeleteItem={onDeleteItem}
mergesInFlight={mergesInFlight}
/>
</>
);
})()}
<ServerLogsPanel logs={combinedLogs} />
</>
)}
@@ -14,6 +14,7 @@ function makePipeline(overrides: Partial<PipelineState> = {}): PipelineState {
qa: [],
merge: [],
done: [],
deterministic_merges_in_flight: [],
...overrides,
};
}
@@ -14,6 +14,7 @@ function makePipeline(overrides: Partial<PipelineState> = {}): PipelineState {
qa: [],
merge: [],
done: [],
deterministic_merges_in_flight: [],
...overrides,
};
}
@@ -14,6 +14,7 @@ function makePipeline(overrides: Partial<PipelineState> = {}): PipelineState {
qa: [],
merge: [],
done: [],
deterministic_merges_in_flight: [],
...overrides,
};
}
@@ -14,6 +14,7 @@ function makePipeline(overrides: Partial<PipelineState> = {}): PipelineState {
qa: [],
merge: [],
done: [],
deterministic_merges_in_flight: [],
...overrides,
};
}
@@ -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(
<StagePanel title="To Merge" items={items} mergesInFlight={mergesInFlight} />,
);
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(
<StagePanel
title="To Merge"
items={items}
mergesInFlight={mergesInFlight}
/>,
);
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(<StagePanel title="To Merge" items={items} />);
expect(
screen.queryByTestId("merge-in-flight-icon-42_story_no_prop"),
).not.toBeInTheDocument();
});
});
+16
View File
@@ -53,6 +53,8 @@ interface StagePanelProps {
busyAgentNames?: Set<string>;
/** 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<string>;
}
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({
</span>
)}
{mergesInFlight?.has(item.story_id) && (
<span
data-testid={`merge-in-flight-icon-${item.story_id}`}
title="Deterministic merge in progress"
style={{
display: "inline-block",
marginRight: "6px",
animation: "spin 1s linear infinite",
}}
>
</span>
)}
{itemNumber && (
<span
style={{
+1
View File
@@ -103,6 +103,7 @@ export function useChatWebSocket({
qa: [],
merge: [],
done: [],
deterministic_merges_in_flight: [],
});
const [pipelineVersion, setPipelineVersion] = useState(0);
const [reconciliationActive, setReconciliationActive] = useState(false);