huskies: merge 643_story_web_ui_consumer_for_the_unified_status_broadcaster
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -106,6 +106,7 @@ export function Chat({
|
||||
setSideQuestion,
|
||||
serverLogs,
|
||||
storyTokenCosts,
|
||||
statusEvents,
|
||||
} = useChatWebSocket({
|
||||
setMessages,
|
||||
setLoading,
|
||||
@@ -384,9 +385,7 @@ export function Chat({
|
||||
<BotConfigPage onBack={() => setView("chat")} />
|
||||
)}
|
||||
|
||||
{view === "settings" && (
|
||||
<SettingsPage onBack={() => setView("chat")} />
|
||||
)}
|
||||
{view === "settings" && <SettingsPage onBack={() => setView("chat")} />}
|
||||
|
||||
<div
|
||||
data-testid="chat-content-area"
|
||||
@@ -449,6 +448,7 @@ export function Chat({
|
||||
busyAgentNames={busyAgentNames}
|
||||
selectedWorkItemId={selectedWorkItemId}
|
||||
serverLogs={serverLogs}
|
||||
statusEvents={statusEvents}
|
||||
onSelectWorkItem={setSelectedWorkItemId}
|
||||
onCloseWorkItem={() => setSelectedWorkItemId(null)}
|
||||
onStartAgent={handleStartAgent}
|
||||
|
||||
@@ -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<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;
|
||||
@@ -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 (
|
||||
<div
|
||||
data-testid="chat-right-column"
|
||||
@@ -115,7 +156,7 @@ export function ChatPipelinePanel({
|
||||
onStopAgent={onStopAgent}
|
||||
onDeleteItem={onDeleteItem}
|
||||
/>
|
||||
<ServerLogsPanel logs={serverLogs} />
|
||||
<ServerLogsPanel logs={combinedLogs} />
|
||||
</>
|
||||
)}
|
||||
</LozengeFlyProvider>
|
||||
|
||||
@@ -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 (
|
||||
<div style={fieldStyle}>
|
||||
<label style={labelStyle}>{label}</label>
|
||||
@@ -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 (
|
||||
<div style={fieldStyle}>
|
||||
<label style={labelStyle}>{label}</label>
|
||||
@@ -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 (
|
||||
<div style={fieldStyle}>
|
||||
{description && <span style={descStyle}>{description}</span>}
|
||||
@@ -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<ProjectSettings | null>(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<string | null>(null);
|
||||
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
|
||||
const [validationErrors, setValidationErrors] = useState<
|
||||
Record<string, string>
|
||||
>({});
|
||||
|
||||
useEffect(() => {
|
||||
settingsApi
|
||||
@@ -251,7 +273,9 @@ export function SettingsPage({ onBack }: SettingsPageProps) {
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
<span style={{ fontWeight: 700, fontSize: "1em" }}>Project Settings</span>
|
||||
<span style={{ fontWeight: 700, fontSize: "1em" }}>
|
||||
Project Settings
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
@@ -284,8 +308,8 @@ export function SettingsPage({ onBack }: SettingsPageProps) {
|
||||
<div style={fieldStyle}>
|
||||
<label style={labelStyle}>Default QA Mode</label>
|
||||
<span style={descStyle}>
|
||||
How stories are QA-reviewed after the coder stage.
|
||||
Default: server.
|
||||
How stories are QA-reviewed after the coder stage. Default:
|
||||
server.
|
||||
</span>
|
||||
<select
|
||||
value={s.default_qa}
|
||||
@@ -346,9 +370,7 @@ export function SettingsPage({ onBack }: SettingsPageProps) {
|
||||
label="Base Branch"
|
||||
description="Overrides auto-detection of the merge target branch (e.g. main, master, develop)."
|
||||
value={s.base_branch ?? ""}
|
||||
onChange={(v) =>
|
||||
patch({ base_branch: v.trim() || null })
|
||||
}
|
||||
onChange={(v) => patch({ base_branch: v.trim() || null })}
|
||||
placeholder="e.g. master"
|
||||
/>
|
||||
</div>
|
||||
@@ -431,11 +453,9 @@ export function SettingsPage({ onBack }: SettingsPageProps) {
|
||||
padding: "8px 24px",
|
||||
borderRadius: "6px",
|
||||
border: "none",
|
||||
background:
|
||||
status === "saved" ? "#1a5c2a" : "#2563eb",
|
||||
background: status === "saved" ? "#1a5c2a" : "#2563eb",
|
||||
color: "#fff",
|
||||
cursor:
|
||||
status === "saving" ? "not-allowed" : "pointer",
|
||||
cursor: status === "saving" ? "not-allowed" : "pointer",
|
||||
fontSize: "0.9em",
|
||||
fontWeight: 600,
|
||||
opacity: status === "saving" ? 0.7 : 1,
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import * as React from "react";
|
||||
import type { PipelineState, WizardStateData } from "../api/client";
|
||||
import type {
|
||||
PipelineState,
|
||||
StatusEvent,
|
||||
WizardStateData,
|
||||
} from "../api/client";
|
||||
import { api, ChatWebSocket } from "../api/client";
|
||||
import type { LogEntry } from "../components/ServerLogsPanel";
|
||||
import type { Message } from "../types";
|
||||
@@ -68,6 +72,9 @@ export interface UseChatWebSocketResult {
|
||||
} | null>;
|
||||
serverLogs: LogEntry[];
|
||||
storyTokenCosts: Map<string, number>;
|
||||
/** Structured pipeline status events. Each entry preserves the full
|
||||
* StatusEvent so future UI stories can render per-type icons or filters. */
|
||||
statusEvents: Array<{ receivedAt: string; event: StatusEvent }>;
|
||||
}
|
||||
|
||||
export function useChatWebSocket({
|
||||
@@ -116,6 +123,9 @@ export function useChatWebSocket({
|
||||
const [storyTokenCosts, setStoryTokenCosts] = useState<Map<string, number>>(
|
||||
new Map(),
|
||||
);
|
||||
const [statusEvents, setStatusEvents] = useState<
|
||||
Array<{ receivedAt: string; event: StatusEvent }>
|
||||
>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const ws = new ChatWebSocket();
|
||||
@@ -240,6 +250,14 @@ export function useChatWebSocket({
|
||||
onLogEntry: (timestamp, level, message) => {
|
||||
setServerLogs((prev) => [...prev, { timestamp, level, message }]);
|
||||
},
|
||||
onStatusUpdate: (event) => {
|
||||
// Preserve the structured event and receive timestamp so future stories
|
||||
// can render per-type icons, banners, or filters without format changes.
|
||||
setStatusEvents((prev) => [
|
||||
...prev,
|
||||
{ receivedAt: new Date().toISOString(), event },
|
||||
]);
|
||||
},
|
||||
onConnected: () => {
|
||||
setWsConnected(true);
|
||||
},
|
||||
@@ -276,5 +294,6 @@ export function useChatWebSocket({
|
||||
setSideQuestion,
|
||||
serverLogs,
|
||||
storyTokenCosts,
|
||||
statusEvents,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user