diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index fa6e146..c468d24 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -357,6 +357,10 @@ export const api = { getAllTokenUsage(baseUrl?: string) { return requestJson("/token-usage", {}, baseUrl); }, + /** Trigger a server rebuild and restart. */ + rebuildAndRestart() { + return callMcpTool("rebuild_and_restart", {}); + }, /** Approve a story in QA, moving it to merge. */ approveQa(storyId: string) { return callMcpTool("approve_qa", { story_id: storyId }); @@ -424,6 +428,7 @@ export class ChatWebSocket { level: string, message: string, ) => void; + private onConnected?: () => void; private connected = false; private closeTimer?: number; private wsPath = DEFAULT_WS_PATH; @@ -470,6 +475,7 @@ export class ChatWebSocket { this.socket.onopen = () => { this.reconnectDelay = 1000; this._startHeartbeat(); + this.onConnected?.(); }; this.socket.onmessage = (event) => { try { @@ -567,6 +573,7 @@ export class ChatWebSocket { onSideQuestionToken?: (content: string) => void; onSideQuestionDone?: (response: string) => void; onLogEntry?: (timestamp: string, level: string, message: string) => void; + onConnected?: () => void; }, wsPath = DEFAULT_WS_PATH, ) { @@ -585,6 +592,7 @@ export class ChatWebSocket { this.onSideQuestionToken = handlers.onSideQuestionToken; this.onSideQuestionDone = handlers.onSideQuestionDone; this.onLogEntry = handlers.onLogEntry; + 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 02ec9e3..5f7ce55 100644 --- a/frontend/src/components/Chat.tsx +++ b/frontend/src/components/Chat.tsx @@ -223,6 +223,7 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) { } | null>(null); const [showHelp, setShowHelp] = useState(false); const [serverLogs, setServerLogs] = useState([]); + const [wsConnected, setWsConnected] = useState(false); // Ref so stale WebSocket callbacks can read the current queued messages const queuedMessagesRef = useRef<{ id: string; text: string }[]>([]); const queueIdCounterRef = useRef(0); @@ -457,6 +458,9 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) { onLogEntry: (timestamp, level, message) => { setServerLogs((prev) => [...prev, { timestamp, level, message }]); }, + onConnected: () => { + setWsConnected(true); + }, }); return () => { @@ -830,6 +834,7 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) { }} enableTools={enableTools} onToggleTools={setEnableTools} + wsConnected={wsConnected} /> {/* Two-column content area */} diff --git a/frontend/src/components/ChatHeader.test.tsx b/frontend/src/components/ChatHeader.test.tsx index ee49d96..814858e 100644 --- a/frontend/src/components/ChatHeader.test.tsx +++ b/frontend/src/components/ChatHeader.test.tsx @@ -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 { ChatHeader } from "./ChatHeader"; +vi.mock("../api/client", () => ({ + api: { + rebuildAndRestart: vi.fn(), + }, +})); + interface ChatHeaderProps { projectPath: string; onCloseProject: () => void; @@ -14,6 +20,7 @@ interface ChatHeaderProps { onModelChange: (model: string) => void; enableTools: boolean; onToggleTools: (enabled: boolean) => void; + wsConnected: boolean; } function makeProps(overrides: Partial = {}): ChatHeaderProps { @@ -29,6 +36,7 @@ function makeProps(overrides: Partial = {}): ChatHeaderProps { onModelChange: vi.fn(), enableTools: true, onToggleTools: vi.fn(), + wsConnected: false, ...overrides, }; } @@ -211,4 +219,96 @@ describe("ChatHeader", () => { expect(sessionBtn.style.backgroundColor).toBe("rgb(47, 47, 47)"); expect(sessionBtn.style.color).toBe("rgb(136, 136, 136)"); }); + + // ── Rebuild button ──────────────────────────────────────────────────────── + + it("renders rebuild button", () => { + render(); + expect( + screen.getByTitle("Rebuild and restart the server"), + ).toBeInTheDocument(); + }); + + it("shows confirmation dialog when rebuild button is clicked", () => { + render(); + 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(); + 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(); + 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(); + 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(); + 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( + , + ); + fireEvent.click(screen.getByTitle("Rebuild and restart the server")); + fireEvent.click(screen.getByText("Rebuild")); + + await waitFor(() => { + expect(screen.getByText("Reconnecting...")).toBeInTheDocument(); + }); + + rerender(); + + await waitFor(() => { + expect(screen.getByText("↺ Rebuild")).toBeInTheDocument(); + }); + }); }); diff --git a/frontend/src/components/ChatHeader.tsx b/frontend/src/components/ChatHeader.tsx index 0833e69..5bed248 100644 --- a/frontend/src/components/ChatHeader.tsx +++ b/frontend/src/components/ChatHeader.tsx @@ -1,3 +1,8 @@ +import * as React from "react"; +import { api } from "../api/client"; + +const { useState, useEffect } = React; + function formatBuildTime(isoString: string): string { const d = new Date(isoString); const year = d.getUTCFullYear(); @@ -26,6 +31,7 @@ interface ChatHeaderProps { onModelChange: (model: string) => void; enableTools: boolean; onToggleTools: (enabled: boolean) => void; + wsConnected: boolean; } const getContextEmoji = (percentage: number): string => { @@ -34,6 +40,8 @@ const getContextEmoji = (percentage: number): string => { return "🟢"; }; +type RebuildStatus = "idle" | "building" | "reconnecting" | "error"; + export function ChatHeader({ projectPath, onCloseProject, @@ -46,233 +54,492 @@ export function ChatHeader({ onModelChange, enableTools, onToggleTools, + wsConnected, }: ChatHeaderProps) { const hasModelOptions = availableModels.length > 0 || claudeModels.length > 0; + const [showConfirm, setShowConfirm] = useState(false); + const [rebuildStatus, setRebuildStatus] = useState("idle"); + const [rebuildError, setRebuildError] = useState(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 ( -
-
- - Storkit - -
- {projectPath} -
- -
- -
+ <> + {/* Confirmation dialog overlay */} + {showConfirm && (
- {formatBuildTime(__BUILD_TIME__)} -
- -
- {getContextEmoji(contextUsage.percentage)} {contextUsage.percentage}% -
- - - - {hasModelOptions ? ( - - ) : ( - onModelChange(e.target.value)} - placeholder="Model" +
+ Rebuild and restart? +
+
+ This will run cargo build and replace the running + server. All agents will be stopped. The page will reconnect + automatically when the new server is ready. +
+
+ + +
+
+
+ )} + + {/* Error toast */} + {rebuildStatus === "error" && rebuildError && ( +
+
+
+
+ ⚠ Rebuild failed +
+
+								{rebuildError}
+							
+
+ +
+
+ )} + +
+
+ + Storkit + +
+ {projectPath} +
+ +
+ +
+
+ {formatBuildTime(__BUILD_TIME__)} +
+ +
+ {getContextEmoji(contextUsage.percentage)} {contextUsage.percentage} + % +
+ + - + + + {hasModelOptions ? ( + + ) : ( + onModelChange(e.target.value)} + placeholder="Model" + style={{ + padding: "6px 12px", + borderRadius: "99px", + border: "none", + fontSize: "0.9em", + background: "#2f2f2f", + color: "#ececec", + outline: "none", + }} + /> + )} + + +
- + ); }