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

@@ -223,6 +223,7 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
} | null>(null);
const [showHelp, setShowHelp] = useState(false);
const [serverLogs, setServerLogs] = useState<LogEntry[]>([]);
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 */}

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 { 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> = {}): ChatHeaderProps {
@@ -29,6 +36,7 @@ function makeProps(overrides: Partial<ChatHeaderProps> = {}): 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(<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 {
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<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 (
<div
style={{
padding: "12px 24px",
borderBottom: "1px solid #333",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
background: "#171717",
flexShrink: 0,
fontSize: "0.9rem",
color: "#ececec",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: "12px",
overflow: "hidden",
flex: 1,
marginRight: "20px",
}}
>
<span
style={{
fontWeight: "700",
fontSize: "1em",
color: "#ececec",
flexShrink: 0,
letterSpacing: "0.02em",
}}
>
Storkit
</span>
<div
title={projectPath}
style={{
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
fontWeight: "500",
color: "#aaa",
direction: "rtl",
textAlign: "left",
fontFamily: "monospace",
fontSize: "0.85em",
}}
>
{projectPath}
</div>
<button
type="button"
onClick={onCloseProject}
style={{
background: "transparent",
border: "none",
cursor: "pointer",
color: "#999",
fontSize: "0.8em",
padding: "4px 8px",
borderRadius: "4px",
}}
onMouseOver={(e) => {
e.currentTarget.style.background = "#333";
}}
onMouseOut={(e) => {
e.currentTarget.style.background = "transparent";
}}
onFocus={(e) => {
e.currentTarget.style.background = "#333";
}}
onBlur={(e) => {
e.currentTarget.style.background = "transparent";
}}
>
</button>
</div>
<div style={{ display: "flex", alignItems: "center", gap: "16px" }}>
<>
{/* Confirmation dialog overlay */}
{showConfirm && (
<div
style={{
fontSize: "0.75em",
color: "#555",
whiteSpace: "nowrap",
fontFamily: "monospace",
}}
title={__BUILD_TIME__}
>
{formatBuildTime(__BUILD_TIME__)}
</div>
<div
style={{
fontSize: "0.9em",
color: "#ccc",
whiteSpace: "nowrap",
}}
title={`Context: ${contextUsage.used.toLocaleString()} / ${contextUsage.total.toLocaleString()} tokens (${contextUsage.percentage}%)`}
>
{getContextEmoji(contextUsage.percentage)} {contextUsage.percentage}%
</div>
<button
type="button"
onClick={onClearSession}
style={{
padding: "6px 12px",
borderRadius: "99px",
border: "none",
fontSize: "0.85em",
backgroundColor: "#2f2f2f",
color: "#888",
cursor: "pointer",
outline: "none",
transition: "all 0.2s",
}}
onMouseOver={(e) => {
e.currentTarget.style.backgroundColor = "#3f3f3f";
e.currentTarget.style.color = "#ccc";
}}
onMouseOut={(e) => {
e.currentTarget.style.backgroundColor = "#2f2f2f";
e.currentTarget.style.color = "#888";
}}
onFocus={(e) => {
e.currentTarget.style.backgroundColor = "#3f3f3f";
e.currentTarget.style.color = "#ccc";
}}
onBlur={(e) => {
e.currentTarget.style.backgroundColor = "#2f2f2f";
e.currentTarget.style.color = "#888";
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.6)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 1000,
}}
>
🔄 New Session
</button>
{hasModelOptions ? (
<select
value={model}
onChange={(e) => onModelChange(e.target.value)}
<div
style={{
padding: "6px 32px 6px 16px",
borderRadius: "99px",
border: "none",
fontSize: "0.9em",
backgroundColor: "#2f2f2f",
background: "#1e1e1e",
border: "1px solid #444",
borderRadius: "8px",
padding: "24px",
maxWidth: "400px",
width: "90%",
color: "#ececec",
cursor: "pointer",
outline: "none",
appearance: "none",
WebkitAppearance: "none",
backgroundImage: `url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22292.4%22%20height%3D%22292.4%22%3E%3Cpath%20fill%3D%22%23ececec%22%20d%3D%22M287%2069.4a17.6%2017.6%200%200%200-13-5.4H18.4c-5%200-9.3%201.8-12.9%205.4A17.6%2017.6%200%200%200%200%2082.2c0%205%201.8%209.3%205.4%2012.9l128%20127.9c3.6%203.6%207.8%205.4%2012.8%205.4s9.2-1.8%2012.8-5.4L287%2095c3.5-3.5%205.4-7.8%205.4-12.8%200-5-1.9-9.2-5.5-12.8z%22%2F%3E%3C%2Fsvg%3E")`,
backgroundRepeat: "no-repeat",
backgroundPosition: "right 12px center",
backgroundSize: "10px",
}}
>
<optgroup label="Claude Code">
<option value="claude-code-pty">claude-code-pty</option>
</optgroup>
{(claudeModels.length > 0 || !hasAnthropicKey) && (
<optgroup label="Anthropic API">
{claudeModels.length > 0 ? (
claudeModels.map((m: string) => (
<option key={m} value={m}>
{m}
</option>
))
) : (
<option value="" disabled>
Add Anthropic API key to load models
</option>
)}
</optgroup>
)}
{availableModels.length > 0 && (
<optgroup label="Ollama">
{availableModels.map((m: string) => (
<option key={m} value={m}>
{m}
</option>
))}
</optgroup>
)}
</select>
) : (
<input
value={model}
onChange={(e) => onModelChange(e.target.value)}
placeholder="Model"
<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
style={{
padding: "12px 24px",
borderBottom: "1px solid #333",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
background: "#171717",
flexShrink: 0,
fontSize: "0.9rem",
color: "#ececec",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: "12px",
overflow: "hidden",
flex: 1,
marginRight: "20px",
}}
>
<span
style={{
fontWeight: "700",
fontSize: "1em",
color: "#ececec",
flexShrink: 0,
letterSpacing: "0.02em",
}}
>
Storkit
</span>
<div
title={projectPath}
style={{
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
fontWeight: "500",
color: "#aaa",
direction: "rtl",
textAlign: "left",
fontFamily: "monospace",
fontSize: "0.85em",
}}
>
{projectPath}
</div>
<button
type="button"
onClick={onCloseProject}
style={{
background: "transparent",
border: "none",
cursor: "pointer",
color: "#999",
fontSize: "0.8em",
padding: "4px 8px",
borderRadius: "4px",
}}
onMouseOver={(e) => {
e.currentTarget.style.background = "#333";
}}
onMouseOut={(e) => {
e.currentTarget.style.background = "transparent";
}}
onFocus={(e) => {
e.currentTarget.style.background = "#333";
}}
onBlur={(e) => {
e.currentTarget.style.background = "transparent";
}}
>
</button>
</div>
<div style={{ display: "flex", alignItems: "center", gap: "16px" }}>
<div
style={{
fontSize: "0.75em",
color: "#555",
whiteSpace: "nowrap",
fontFamily: "monospace",
}}
title={__BUILD_TIME__}
>
{formatBuildTime(__BUILD_TIME__)}
</div>
<div
style={{
fontSize: "0.9em",
color: "#ccc",
whiteSpace: "nowrap",
}}
title={`Context: ${contextUsage.used.toLocaleString()} / ${contextUsage.total.toLocaleString()} tokens (${contextUsage.percentage}%)`}
>
{getContextEmoji(contextUsage.percentage)} {contextUsage.percentage}
%
</div>
<button
type="button"
onClick={handleRebuildClick}
disabled={rebuildButtonDisabled}
title="Rebuild and restart the server"
style={{
padding: "6px 12px",
borderRadius: "99px",
border: "none",
fontSize: "0.9em",
background: "#2f2f2f",
color: "#ececec",
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>
<label
style={{
display: "flex",
alignItems: "center",
gap: "6px",
cursor: "pointer",
fontSize: "0.9em",
color: "#aaa",
}}
title="Allow the Agent to read/write files"
>
<input
type="checkbox"
checked={enableTools}
onChange={(e) => onToggleTools(e.target.checked)}
style={{ accentColor: "#000" }}
/>
<span>Tools</span>
</label>
<button
type="button"
onClick={onClearSession}
style={{
padding: "6px 12px",
borderRadius: "99px",
border: "none",
fontSize: "0.85em",
backgroundColor: "#2f2f2f",
color: "#888",
cursor: "pointer",
outline: "none",
transition: "all 0.2s",
}}
onMouseOver={(e) => {
e.currentTarget.style.backgroundColor = "#3f3f3f";
e.currentTarget.style.color = "#ccc";
}}
onMouseOut={(e) => {
e.currentTarget.style.backgroundColor = "#2f2f2f";
e.currentTarget.style.color = "#888";
}}
onFocus={(e) => {
e.currentTarget.style.backgroundColor = "#3f3f3f";
e.currentTarget.style.color = "#ccc";
}}
onBlur={(e) => {
e.currentTarget.style.backgroundColor = "#2f2f2f";
e.currentTarget.style.color = "#888";
}}
>
🔄 New Session
</button>
{hasModelOptions ? (
<select
value={model}
onChange={(e) => onModelChange(e.target.value)}
style={{
padding: "6px 32px 6px 16px",
borderRadius: "99px",
border: "none",
fontSize: "0.9em",
backgroundColor: "#2f2f2f",
color: "#ececec",
cursor: "pointer",
outline: "none",
appearance: "none",
WebkitAppearance: "none",
backgroundImage: `url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22292.4%22%20height%3D%22292.4%22%3E%3Cpath%20fill%3D%22%23ececec%22%20d%3D%22M287%2069.4a17.6%2017.6%200%200%200-13-5.4H18.4c-5%200-9.3%201.8-12.9%205.4A17.6%2017.6%200%200%200%200%2082.2c0%205%201.8%209.3%205.4%2012.9l128%20127.9c3.6%203.6%207.8%205.4%2012.8%205.4s9.2-1.8%2012.8-5.4L287%2095c3.5-3.5%205.4-7.8%205.4-12.8%200-5-1.9-9.2-5.5-12.8z%22%2F%3E%3C%2Fsvg%3E")`,
backgroundRepeat: "no-repeat",
backgroundPosition: "right 12px center",
backgroundSize: "10px",
}}
>
<optgroup label="Claude Code">
<option value="claude-code-pty">claude-code-pty</option>
</optgroup>
{(claudeModels.length > 0 || !hasAnthropicKey) && (
<optgroup label="Anthropic API">
{claudeModels.length > 0 ? (
claudeModels.map((m: string) => (
<option key={m} value={m}>
{m}
</option>
))
) : (
<option value="" disabled>
Add Anthropic API key to load models
</option>
)}
</optgroup>
)}
{availableModels.length > 0 && (
<optgroup label="Ollama">
{availableModels.map((m: string) => (
<option key={m} value={m}>
{m}
</option>
))}
</optgroup>
)}
</select>
) : (
<input
value={model}
onChange={(e) => onModelChange(e.target.value)}
placeholder="Model"
style={{
padding: "6px 12px",
borderRadius: "99px",
border: "none",
fontSize: "0.9em",
background: "#2f2f2f",
color: "#ececec",
outline: "none",
}}
/>
)}
<label
style={{
display: "flex",
alignItems: "center",
gap: "6px",
cursor: "pointer",
fontSize: "0.9em",
color: "#aaa",
}}
title="Allow the Agent to read/write files"
>
<input
type="checkbox"
checked={enableTools}
onChange={(e) => onToggleTools(e.target.checked)}
style={{ accentColor: "#000" }}
/>
<span>Tools</span>
</label>
</div>
</div>
</div>
</>
);
}