story-kit: merge 340_story_web_ui_rebuild_and_restart_button
This commit is contained in:
@@ -357,6 +357,10 @@ export const api = {
|
||||
getAllTokenUsage(baseUrl?: string) {
|
||||
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. */
|
||||
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;
|
||||
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user