Files
huskies/frontend/src/components/ChatHeader.tsx
T

546 lines
13 KiB
TypeScript

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();
const month = String(d.getUTCMonth() + 1).padStart(2, "0");
const day = String(d.getUTCDate()).padStart(2, "0");
const hours = String(d.getUTCHours()).padStart(2, "0");
const minutes = String(d.getUTCMinutes()).padStart(2, "0");
return `Built: ${year}-${month}-${day} ${hours}:${minutes}`;
}
interface ContextUsage {
used: number;
total: number;
percentage: number;
}
interface ChatHeaderProps {
projectPath: string;
onCloseProject: () => void;
contextUsage: ContextUsage;
onClearSession: () => void;
model: string;
availableModels: string[];
claudeModels: string[];
hasAnthropicKey: boolean;
onModelChange: (model: string) => void;
enableTools: boolean;
onToggleTools: (enabled: boolean) => void;
wsConnected: boolean;
}
const getContextEmoji = (percentage: number): string => {
if (percentage >= 90) return "🔴";
if (percentage >= 75) return "🟡";
return "🟢";
};
type RebuildStatus = "idle" | "building" | "reconnecting" | "error";
export function ChatHeader({
projectPath,
onCloseProject,
contextUsage,
onClearSession,
model,
availableModels,
claudeModels,
hasAnthropicKey,
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 (
<>
{/* 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
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.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
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>
</>
);
}