story-kit: merge 340_story_web_ui_rebuild_and_restart_button

This commit is contained in:
Dave
2026-03-20 09:08:13 +00:00
parent 0897b36cc1
commit 594114d671
4 changed files with 589 additions and 209 deletions

View File

@@ -357,6 +357,10 @@ export const api = {
getAllTokenUsage(baseUrl?: string) { getAllTokenUsage(baseUrl?: string) {
return requestJson<AllTokenUsageResponse>("/token-usage", {}, baseUrl); return requestJson<AllTokenUsageResponse>("/token-usage", {}, baseUrl);
}, },
/** Trigger a server rebuild and restart. */
rebuildAndRestart() {
return callMcpTool("rebuild_and_restart", {});
},
/** Approve a story in QA, moving it to merge. */ /** Approve a story in QA, moving it to merge. */
approveQa(storyId: string) { approveQa(storyId: string) {
return callMcpTool("approve_qa", { story_id: storyId }); return callMcpTool("approve_qa", { story_id: storyId });
@@ -424,6 +428,7 @@ export class ChatWebSocket {
level: string, level: string,
message: string, message: string,
) => void; ) => void;
private onConnected?: () => void;
private connected = false; private connected = false;
private closeTimer?: number; private closeTimer?: number;
private wsPath = DEFAULT_WS_PATH; private wsPath = DEFAULT_WS_PATH;
@@ -470,6 +475,7 @@ export class ChatWebSocket {
this.socket.onopen = () => { this.socket.onopen = () => {
this.reconnectDelay = 1000; this.reconnectDelay = 1000;
this._startHeartbeat(); this._startHeartbeat();
this.onConnected?.();
}; };
this.socket.onmessage = (event) => { this.socket.onmessage = (event) => {
try { try {
@@ -567,6 +573,7 @@ export class ChatWebSocket {
onSideQuestionToken?: (content: string) => void; onSideQuestionToken?: (content: string) => void;
onSideQuestionDone?: (response: string) => void; onSideQuestionDone?: (response: string) => void;
onLogEntry?: (timestamp: string, level: string, message: string) => void; onLogEntry?: (timestamp: string, level: string, message: string) => void;
onConnected?: () => void;
}, },
wsPath = DEFAULT_WS_PATH, wsPath = DEFAULT_WS_PATH,
) { ) {
@@ -585,6 +592,7 @@ export class ChatWebSocket {
this.onSideQuestionToken = handlers.onSideQuestionToken; this.onSideQuestionToken = handlers.onSideQuestionToken;
this.onSideQuestionDone = handlers.onSideQuestionDone; this.onSideQuestionDone = handlers.onSideQuestionDone;
this.onLogEntry = handlers.onLogEntry; this.onLogEntry = handlers.onLogEntry;
this.onConnected = handlers.onConnected;
this.wsPath = wsPath; this.wsPath = wsPath;
this.shouldReconnect = true; this.shouldReconnect = true;

View File

@@ -223,6 +223,7 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
} | null>(null); } | null>(null);
const [showHelp, setShowHelp] = useState(false); const [showHelp, setShowHelp] = useState(false);
const [serverLogs, setServerLogs] = useState<LogEntry[]>([]); const [serverLogs, setServerLogs] = useState<LogEntry[]>([]);
const [wsConnected, setWsConnected] = useState(false);
// Ref so stale WebSocket callbacks can read the current queued messages // Ref so stale WebSocket callbacks can read the current queued messages
const queuedMessagesRef = useRef<{ id: string; text: string }[]>([]); const queuedMessagesRef = useRef<{ id: string; text: string }[]>([]);
const queueIdCounterRef = useRef(0); const queueIdCounterRef = useRef(0);
@@ -457,6 +458,9 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
onLogEntry: (timestamp, level, message) => { onLogEntry: (timestamp, level, message) => {
setServerLogs((prev) => [...prev, { timestamp, level, message }]); setServerLogs((prev) => [...prev, { timestamp, level, message }]);
}, },
onConnected: () => {
setWsConnected(true);
},
}); });
return () => { return () => {
@@ -830,6 +834,7 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
}} }}
enableTools={enableTools} enableTools={enableTools}
onToggleTools={setEnableTools} onToggleTools={setEnableTools}
wsConnected={wsConnected}
/> />
{/* Two-column content area */} {/* Two-column content area */}

View File

@@ -1,7 +1,13 @@
import { fireEvent, render, screen } from "@testing-library/react"; import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { ChatHeader } from "./ChatHeader"; import { ChatHeader } from "./ChatHeader";
vi.mock("../api/client", () => ({
api: {
rebuildAndRestart: vi.fn(),
},
}));
interface ChatHeaderProps { interface ChatHeaderProps {
projectPath: string; projectPath: string;
onCloseProject: () => void; onCloseProject: () => void;
@@ -14,6 +20,7 @@ interface ChatHeaderProps {
onModelChange: (model: string) => void; onModelChange: (model: string) => void;
enableTools: boolean; enableTools: boolean;
onToggleTools: (enabled: boolean) => void; onToggleTools: (enabled: boolean) => void;
wsConnected: boolean;
} }
function makeProps(overrides: Partial<ChatHeaderProps> = {}): ChatHeaderProps { function makeProps(overrides: Partial<ChatHeaderProps> = {}): ChatHeaderProps {
@@ -29,6 +36,7 @@ function makeProps(overrides: Partial<ChatHeaderProps> = {}): ChatHeaderProps {
onModelChange: vi.fn(), onModelChange: vi.fn(),
enableTools: true, enableTools: true,
onToggleTools: vi.fn(), onToggleTools: vi.fn(),
wsConnected: false,
...overrides, ...overrides,
}; };
} }
@@ -211,4 +219,96 @@ describe("ChatHeader", () => {
expect(sessionBtn.style.backgroundColor).toBe("rgb(47, 47, 47)"); expect(sessionBtn.style.backgroundColor).toBe("rgb(47, 47, 47)");
expect(sessionBtn.style.color).toBe("rgb(136, 136, 136)"); expect(sessionBtn.style.color).toBe("rgb(136, 136, 136)");
}); });
// ── Rebuild button ────────────────────────────────────────────────────────
it("renders rebuild button", () => {
render(<ChatHeader {...makeProps()} />);
expect(
screen.getByTitle("Rebuild and restart the server"),
).toBeInTheDocument();
});
it("shows confirmation dialog when rebuild button is clicked", () => {
render(<ChatHeader {...makeProps()} />);
fireEvent.click(screen.getByTitle("Rebuild and restart the server"));
expect(screen.getByText("Rebuild and restart?")).toBeInTheDocument();
});
it("hides confirmation dialog when cancel is clicked", () => {
render(<ChatHeader {...makeProps()} />);
fireEvent.click(screen.getByTitle("Rebuild and restart the server"));
fireEvent.click(screen.getByText("Cancel"));
expect(screen.queryByText("Rebuild and restart?")).not.toBeInTheDocument();
});
it("calls api.rebuildAndRestart and shows Building... when confirmed", async () => {
const { api } = await import("../api/client");
vi.mocked(api.rebuildAndRestart).mockReturnValue(new Promise(() => {}));
render(<ChatHeader {...makeProps()} />);
fireEvent.click(screen.getByTitle("Rebuild and restart the server"));
fireEvent.click(screen.getByText("Rebuild"));
await waitFor(() => {
expect(screen.getByText("Building...")).toBeInTheDocument();
});
expect(api.rebuildAndRestart).toHaveBeenCalled();
});
it("shows Reconnecting... when rebuild triggers a network error", async () => {
const { api } = await import("../api/client");
vi.mocked(api.rebuildAndRestart).mockRejectedValue(
new TypeError("Failed to fetch"),
);
render(<ChatHeader {...makeProps()} />);
fireEvent.click(screen.getByTitle("Rebuild and restart the server"));
fireEvent.click(screen.getByText("Rebuild"));
await waitFor(() => {
expect(screen.getByText("Reconnecting...")).toBeInTheDocument();
});
});
it("shows error when rebuild returns a failure message", async () => {
const { api } = await import("../api/client");
vi.mocked(api.rebuildAndRestart).mockResolvedValue(
"error[E0308]: mismatched types",
);
render(<ChatHeader {...makeProps()} />);
fireEvent.click(screen.getByTitle("Rebuild and restart the server"));
fireEvent.click(screen.getByText("Rebuild"));
await waitFor(() => {
expect(screen.getByText("⚠ Rebuild failed")).toBeInTheDocument();
expect(
screen.getByText("error[E0308]: mismatched types"),
).toBeInTheDocument();
});
});
it("clears reconnecting state when wsConnected transitions to true", async () => {
const { api } = await import("../api/client");
vi.mocked(api.rebuildAndRestart).mockRejectedValue(
new TypeError("Failed to fetch"),
);
const { rerender } = render(
<ChatHeader {...makeProps({ wsConnected: false })} />,
);
fireEvent.click(screen.getByTitle("Rebuild and restart the server"));
fireEvent.click(screen.getByText("Rebuild"));
await waitFor(() => {
expect(screen.getByText("Reconnecting...")).toBeInTheDocument();
});
rerender(<ChatHeader {...makeProps({ wsConnected: true })} />);
await waitFor(() => {
expect(screen.getByText("↺ Rebuild")).toBeInTheDocument();
});
});
}); });

View File

@@ -1,3 +1,8 @@
import * as React from "react";
import { api } from "../api/client";
const { useState, useEffect } = React;
function formatBuildTime(isoString: string): string { function formatBuildTime(isoString: string): string {
const d = new Date(isoString); const d = new Date(isoString);
const year = d.getUTCFullYear(); const year = d.getUTCFullYear();
@@ -26,6 +31,7 @@ interface ChatHeaderProps {
onModelChange: (model: string) => void; onModelChange: (model: string) => void;
enableTools: boolean; enableTools: boolean;
onToggleTools: (enabled: boolean) => void; onToggleTools: (enabled: boolean) => void;
wsConnected: boolean;
} }
const getContextEmoji = (percentage: number): string => { const getContextEmoji = (percentage: number): string => {
@@ -34,6 +40,8 @@ const getContextEmoji = (percentage: number): string => {
return "🟢"; return "🟢";
}; };
type RebuildStatus = "idle" | "building" | "reconnecting" | "error";
export function ChatHeader({ export function ChatHeader({
projectPath, projectPath,
onCloseProject, onCloseProject,
@@ -46,10 +54,212 @@ export function ChatHeader({
onModelChange, onModelChange,
enableTools, enableTools,
onToggleTools, onToggleTools,
wsConnected,
}: ChatHeaderProps) { }: ChatHeaderProps) {
const hasModelOptions = availableModels.length > 0 || claudeModels.length > 0; const hasModelOptions = availableModels.length > 0 || claudeModels.length > 0;
const [showConfirm, setShowConfirm] = useState(false);
const [rebuildStatus, setRebuildStatus] = useState<RebuildStatus>("idle");
const [rebuildError, setRebuildError] = useState<string | null>(null);
// When WS reconnects after a rebuild, clear the reconnecting status.
useEffect(() => {
if (rebuildStatus === "reconnecting" && wsConnected) {
setRebuildStatus("idle");
}
}, [wsConnected, rebuildStatus]);
function handleRebuildClick() {
setRebuildError(null);
setShowConfirm(true);
}
function handleRebuildConfirm() {
setShowConfirm(false);
setRebuildStatus("building");
api
.rebuildAndRestart()
.then((result) => {
// Got a response = build failed (server still running).
setRebuildStatus("error");
setRebuildError(result || "Rebuild failed");
})
.catch(() => {
// Network error = server is restarting (build succeeded).
setRebuildStatus("reconnecting");
});
}
function handleRebuildCancel() {
setShowConfirm(false);
}
function handleDismissError() {
setRebuildStatus("idle");
setRebuildError(null);
}
const rebuildButtonLabel =
rebuildStatus === "building"
? "Building..."
: rebuildStatus === "reconnecting"
? "Reconnecting..."
: rebuildStatus === "error"
? "⚠ Rebuild Failed"
: "↺ Rebuild";
const rebuildButtonDisabled =
rebuildStatus === "building" || rebuildStatus === "reconnecting";
return ( return (
<>
{/* Confirmation dialog overlay */}
{showConfirm && (
<div
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.6)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 1000,
}}
>
<div
style={{
background: "#1e1e1e",
border: "1px solid #444",
borderRadius: "8px",
padding: "24px",
maxWidth: "400px",
width: "90%",
color: "#ececec",
}}
>
<div
style={{
fontWeight: "600",
fontSize: "1em",
marginBottom: "8px",
}}
>
Rebuild and restart?
</div>
<div
style={{
fontSize: "0.85em",
color: "#aaa",
marginBottom: "20px",
lineHeight: "1.5",
}}
>
This will run <code>cargo build</code> and replace the running
server. All agents will be stopped. The page will reconnect
automatically when the new server is ready.
</div>
<div
style={{
display: "flex",
gap: "10px",
justifyContent: "flex-end",
}}
>
<button
type="button"
onClick={handleRebuildCancel}
style={{
padding: "6px 16px",
borderRadius: "6px",
border: "1px solid #444",
background: "transparent",
color: "#aaa",
cursor: "pointer",
fontSize: "0.9em",
}}
>
Cancel
</button>
<button
type="button"
onClick={handleRebuildConfirm}
style={{
padding: "6px 16px",
borderRadius: "6px",
border: "none",
background: "#c0392b",
color: "#fff",
cursor: "pointer",
fontSize: "0.9em",
fontWeight: "600",
}}
>
Rebuild
</button>
</div>
</div>
</div>
)}
{/* Error toast */}
{rebuildStatus === "error" && rebuildError && (
<div
style={{
position: "fixed",
bottom: "20px",
right: "20px",
background: "#3a1010",
border: "1px solid #c0392b",
borderRadius: "8px",
padding: "12px 16px",
maxWidth: "480px",
color: "#ececec",
zIndex: 1000,
fontSize: "0.85em",
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "flex-start",
gap: "12px",
}}
>
<div>
<div style={{ fontWeight: "600", marginBottom: "4px" }}>
Rebuild failed
</div>
<pre
style={{
margin: 0,
whiteSpace: "pre-wrap",
wordBreak: "break-word",
color: "#f08080",
maxHeight: "120px",
overflowY: "auto",
}}
>
{rebuildError}
</pre>
</div>
<button
type="button"
onClick={handleDismissError}
style={{
background: "transparent",
border: "none",
color: "#aaa",
cursor: "pointer",
fontSize: "1em",
flexShrink: 0,
}}
>
</button>
</div>
</div>
)}
<div <div
style={{ style={{
padding: "12px 24px", padding: "12px 24px",
@@ -150,9 +360,65 @@ export function ChatHeader({
}} }}
title={`Context: ${contextUsage.used.toLocaleString()} / ${contextUsage.total.toLocaleString()} tokens (${contextUsage.percentage}%)`} title={`Context: ${contextUsage.used.toLocaleString()} / ${contextUsage.total.toLocaleString()} tokens (${contextUsage.percentage}%)`}
> >
{getContextEmoji(contextUsage.percentage)} {contextUsage.percentage}% {getContextEmoji(contextUsage.percentage)} {contextUsage.percentage}
%
</div> </div>
<button
type="button"
onClick={handleRebuildClick}
disabled={rebuildButtonDisabled}
title="Rebuild and restart the server"
style={{
padding: "6px 12px",
borderRadius: "99px",
border: "none",
fontSize: "0.85em",
backgroundColor:
rebuildStatus === "error" ? "#5a1010" : "#2f2f2f",
color:
rebuildStatus === "error"
? "#f08080"
: rebuildButtonDisabled
? "#555"
: "#888",
cursor: rebuildButtonDisabled ? "not-allowed" : "pointer",
outline: "none",
transition: "all 0.2s",
opacity: rebuildButtonDisabled ? 0.7 : 1,
}}
onMouseOver={(e) => {
if (!rebuildButtonDisabled) {
e.currentTarget.style.backgroundColor = "#3f3f3f";
e.currentTarget.style.color = "#ccc";
}
}}
onMouseOut={(e) => {
if (!rebuildButtonDisabled) {
e.currentTarget.style.backgroundColor =
rebuildStatus === "error" ? "#5a1010" : "#2f2f2f";
e.currentTarget.style.color =
rebuildStatus === "error" ? "#f08080" : "#888";
}
}}
onFocus={(e) => {
if (!rebuildButtonDisabled) {
e.currentTarget.style.backgroundColor = "#3f3f3f";
e.currentTarget.style.color = "#ccc";
}
}}
onBlur={(e) => {
if (!rebuildButtonDisabled) {
e.currentTarget.style.backgroundColor =
rebuildStatus === "error" ? "#5a1010" : "#2f2f2f";
e.currentTarget.style.color =
rebuildStatus === "error" ? "#f08080" : "#888";
}
}}
>
{rebuildButtonLabel}
</button>
<button <button
type="button" type="button"
onClick={onClearSession} onClick={onClearSession}
@@ -274,5 +540,6 @@ export function ChatHeader({
</label> </label>
</div> </div>
</div> </div>
</>
); );
} }