huskies: merge 643_story_web_ui_consumer_for_the_unified_status_broadcaster

This commit is contained in:
dave
2026-04-26 11:26:20 +00:00
parent f88bb5f486
commit 8673e563a9
13 changed files with 375 additions and 25 deletions
+3 -3
View File
@@ -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}
+43 -2
View File
@@ -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>
+35 -15
View File
@@ -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,