Files
huskies/frontend/src/components/ChatPipelinePanel.tsx
T

180 lines
5.5 KiB
TypeScript
Raw Normal View History

import type { AgentConfigInfo } from "../api/agents";
import type {
PipelineStageItem,
PipelineState,
StatusEvent,
} from "../api/client";
import { AgentPanel } from "./AgentPanel";
import { LozengeFlyProvider } from "./LozengeFlyContext";
import type { LogEntry } from "./ServerLogsPanel";
import { ServerLogsPanel } from "./ServerLogsPanel";
import { StagePanel } from "./StagePanel";
import { WorkItemDetailPanel } from "./WorkItemDetailPanel";
/** Format a structured StatusEvent into a human-readable display string.
* This conversion happens at render time, not at the WebSocket boundary,
* so the original StatusEvent structure is preserved in state. */
function formatStatusEventMessage(event: StatusEvent): string {
2026-05-13 14:51:39 +00:00
const name = event.story_name || event.story_id;
switch (event.type) {
case "stage_transition":
return `${name}${event.from_stage}${event.to_stage}`;
case "merge_failure":
return `${name}${event.reason}`;
case "story_blocked":
return `${name} — BLOCKED: ${event.reason}`;
case "rate_limit_warning":
return `${name}${event.agent_name} hit an API rate limit`;
case "rate_limit_hard_block":
return `${name}${event.agent_name} hard rate-limited until ${event.reset_at}`;
}
}
interface ChatPipelinePanelProps {
isNarrowScreen: boolean;
pipeline: PipelineState;
pipelineVersion: number;
agentConfigVersion: number;
agentStateVersion: number;
storyTokenCosts: Map<string, number>;
agentRoster: AgentConfigInfo[];
busyAgentNames: Set<string>;
selectedWorkItemId: string | null;
serverLogs: LogEntry[];
/** Structured pipeline status events forwarded from the status broadcaster. */
statusEvents: Array<{ receivedAt: string; event: StatusEvent }>;
onSelectWorkItem: (id: string) => void;
onCloseWorkItem: () => void;
onStartAgent: (storyId: string, agentName?: string) => void;
onStopAgent: (storyId: string, agentName: string) => void;
onDeleteItem: (item: PipelineStageItem) => void;
}
export function ChatPipelinePanel({
isNarrowScreen,
pipeline,
pipelineVersion,
agentConfigVersion,
agentStateVersion,
storyTokenCosts,
agentRoster,
busyAgentNames,
selectedWorkItemId,
serverLogs,
statusEvents,
onSelectWorkItem,
onCloseWorkItem,
onStartAgent,
onStopAgent,
onDeleteItem,
}: ChatPipelinePanelProps) {
// Convert structured status events to LogEntry format for display in the
// existing log area. Structure is preserved in the statusEvents array itself.
const statusLogEntries: LogEntry[] = statusEvents.map(
({ receivedAt, event }) => ({
timestamp: receivedAt,
level:
event.type === "merge_failure" ||
event.type === "story_blocked" ||
event.type === "rate_limit_hard_block"
? "WARN"
: "INFO",
message: formatStatusEventMessage(event),
}),
);
const combinedLogs = [...statusLogEntries, ...serverLogs];
return (
<div
data-testid="chat-right-column"
style={{
flex: "0 0 40%",
overflowY: "auto",
borderLeft: isNarrowScreen ? "none" : "1px solid #333",
borderTop: isNarrowScreen ? "1px solid #333" : "none",
padding: "12px",
display: "flex",
flexDirection: "column",
gap: "12px",
}}
>
<LozengeFlyProvider pipeline={pipeline}>
{selectedWorkItemId ? (
<WorkItemDetailPanel
storyId={selectedWorkItemId}
pipelineVersion={pipelineVersion}
onClose={onCloseWorkItem}
/>
) : (
<>
<AgentPanel
configVersion={agentConfigVersion}
stateVersion={agentStateVersion}
/>
2026-04-29 17:15:01 +00:00
{(() => {
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} />
</>
)}
</LozengeFlyProvider>
</div>
);
}