2026-04-04 15:12:03 +00:00
|
|
|
import type { AgentConfigInfo } from "../api/agents";
|
2026-04-26 11:26:20 +00:00
|
|
|
import type {
|
|
|
|
|
PipelineStageItem,
|
|
|
|
|
PipelineState,
|
|
|
|
|
StatusEvent,
|
|
|
|
|
} from "../api/client";
|
2026-04-04 15:12:03 +00:00
|
|
|
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";
|
|
|
|
|
|
2026-04-26 11:26:20 +00:00
|
|
|
/** 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 {
|
|
|
|
|
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}`;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-04 15:12:03 +00:00
|
|
|
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[];
|
2026-04-26 11:26:20 +00:00
|
|
|
/** Structured pipeline status events forwarded from the status broadcaster. */
|
|
|
|
|
statusEvents: Array<{ receivedAt: string; event: StatusEvent }>;
|
2026-04-04 15:12:03 +00:00
|
|
|
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,
|
2026-04-26 11:26:20 +00:00
|
|
|
statusEvents,
|
2026-04-04 15:12:03 +00:00
|
|
|
onSelectWorkItem,
|
|
|
|
|
onCloseWorkItem,
|
|
|
|
|
onStartAgent,
|
|
|
|
|
onStopAgent,
|
|
|
|
|
onDeleteItem,
|
|
|
|
|
}: ChatPipelinePanelProps) {
|
2026-04-26 11:26:20 +00:00
|
|
|
// 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];
|
2026-04-04 15:12:03 +00:00
|
|
|
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}
|
|
|
|
|
/>
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
})()}
|
2026-04-26 11:26:20 +00:00
|
|
|
<ServerLogsPanel logs={combinedLogs} />
|
2026-04-04 15:12:03 +00:00
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</LozengeFlyProvider>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|