diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 8e2d4b9e..8b78b224 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -108,7 +108,50 @@ export type WsResponse = /** Final signal that the /btw side question has been fully answered. */ | { type: "side_question_done"; response: string } /** A single server log entry (bulk on connect, then live). */ - | { type: "log_entry"; timestamp: string; level: string; message: string }; + | { type: "log_entry"; timestamp: string; level: string; message: string } + /** A structured pipeline status event from the status broadcaster. */ + | { type: "status_update"; event: StatusEvent }; + +/** + * A structured pipeline status event emitted by the status broadcaster. + * + * The discriminant `type` field enables per-event-type rendering without + * parsing strings. All fields from the original event are preserved so + * future UI stories can add dedicated icons, banners, or filters. + */ +export type StatusEvent = + | { + type: "stage_transition"; + story_id: string; + story_name: string | null; + from_stage: string; + to_stage: string; + } + | { + type: "merge_failure"; + story_id: string; + story_name: string | null; + reason: string; + } + | { + type: "story_blocked"; + story_id: string; + story_name: string | null; + reason: string; + } + | { + type: "rate_limit_warning"; + story_id: string; + story_name: string | null; + agent_name: string; + } + | { + type: "rate_limit_hard_block"; + story_id: string; + story_name: string | null; + agent_name: string; + reset_at: string; + }; export interface ProviderConfig { provider: string; @@ -478,6 +521,7 @@ export class ChatWebSocket { level: string, message: string, ) => void; + private onStatusUpdate?: (event: StatusEvent) => void; private onConnected?: () => void; private connected = false; private closeTimer?: number; @@ -573,6 +617,7 @@ export class ChatWebSocket { this.onSideQuestionDone?.(data.response); if (data.type === "log_entry") this.onLogEntry?.(data.timestamp, data.level, data.message); + if (data.type === "status_update") this.onStatusUpdate?.(data.event); if (data.type === "pong") { window.clearTimeout(this.heartbeatTimeout); this.heartbeatTimeout = undefined; @@ -630,6 +675,7 @@ export class ChatWebSocket { onSideQuestionToken?: (content: string) => void; onSideQuestionDone?: (response: string) => void; onLogEntry?: (timestamp: string, level: string, message: string) => void; + onStatusUpdate?: (event: StatusEvent) => void; onConnected?: () => void; }, wsPath = DEFAULT_WS_PATH, @@ -650,6 +696,7 @@ export class ChatWebSocket { this.onSideQuestionToken = handlers.onSideQuestionToken; this.onSideQuestionDone = handlers.onSideQuestionDone; this.onLogEntry = handlers.onLogEntry; + this.onStatusUpdate = handlers.onStatusUpdate; this.onConnected = handlers.onConnected; this.wsPath = wsPath; this.shouldReconnect = true; diff --git a/frontend/src/components/Chat.tsx b/frontend/src/components/Chat.tsx index ee2e81be..2b02f4c3 100644 --- a/frontend/src/components/Chat.tsx +++ b/frontend/src/components/Chat.tsx @@ -106,6 +106,7 @@ export function Chat({ setSideQuestion, serverLogs, storyTokenCosts, + statusEvents, } = useChatWebSocket({ setMessages, setLoading, @@ -384,9 +385,7 @@ export function Chat({ setView("chat")} /> )} - {view === "settings" && ( - setView("chat")} /> - )} + {view === "settings" && setView("chat")} />}
setSelectedWorkItemId(null)} onStartAgent={handleStartAgent} diff --git a/frontend/src/components/ChatPipelinePanel.tsx b/frontend/src/components/ChatPipelinePanel.tsx index ba4fc6d3..d095722a 100644 --- a/frontend/src/components/ChatPipelinePanel.tsx +++ b/frontend/src/components/ChatPipelinePanel.tsx @@ -1,5 +1,9 @@ import type { AgentConfigInfo } from "../api/agents"; -import type { PipelineStageItem, PipelineState } from "../api/client"; +import type { + PipelineStageItem, + PipelineState, + StatusEvent, +} from "../api/client"; import { AgentPanel } from "./AgentPanel"; import { LozengeFlyProvider } from "./LozengeFlyContext"; import type { LogEntry } from "./ServerLogsPanel"; @@ -7,6 +11,25 @@ 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 { + 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; @@ -18,6 +41,8 @@ interface ChatPipelinePanelProps { busyAgentNames: Set; 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; @@ -36,12 +61,28 @@ export function ChatPipelinePanel({ 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 (
- + )} diff --git a/frontend/src/components/SettingsPage.tsx b/frontend/src/components/SettingsPage.tsx index fa4ac5bf..a446de20 100644 --- a/frontend/src/components/SettingsPage.tsx +++ b/frontend/src/components/SettingsPage.tsx @@ -64,7 +64,13 @@ interface TextFieldProps { placeholder?: string; } -function TextField({ label, description, value, onChange, placeholder }: TextFieldProps) { +function TextField({ + label, + description, + value, + onChange, + placeholder, +}: TextFieldProps) { return (
@@ -90,7 +96,14 @@ interface NumberFieldProps { placeholder?: string; } -function NumberField({ label, description, value, onChange, min, placeholder }: NumberFieldProps) { +function NumberField({ + label, + description, + value, + onChange, + min, + placeholder, +}: NumberFieldProps) { return (
@@ -122,7 +135,12 @@ interface CheckboxFieldProps { onChange: (v: boolean) => void; } -function CheckboxField({ label, description, checked, onChange }: CheckboxFieldProps) { +function CheckboxField({ + label, + description, + checked, + onChange, +}: CheckboxFieldProps) { return (
{description && {description}} @@ -152,9 +170,13 @@ const QA_MODES = ["server", "agent", "human"] as const; /** Settings page — form-based editor for project.toml scalar settings. */ export function SettingsPage({ onBack }: SettingsPageProps) { const [settings, setSettings] = useState(null); - const [status, setStatus] = useState<"idle" | "loading" | "saving" | "saved" | "error">("loading"); + const [status, setStatus] = useState< + "idle" | "loading" | "saving" | "saved" | "error" + >("loading"); const [errorMsg, setErrorMsg] = useState(null); - const [validationErrors, setValidationErrors] = useState>({}); + const [validationErrors, setValidationErrors] = useState< + Record + >({}); useEffect(() => { settingsApi @@ -251,7 +273,9 @@ export function SettingsPage({ onBack }: SettingsPageProps) { > ← Back - Project Settings + + Project Settings +
{/* Body */} @@ -284,8 +308,8 @@ export function SettingsPage({ onBack }: SettingsPageProps) {
- How stories are QA-reviewed after the coder stage. - Default: server. + How stories are QA-reviewed after the coder stage. Default: + server.