Files
storkit/frontend/src/components/AgentPanel.tsx

442 lines
11 KiB
TypeScript
Raw Normal View History

import * as React from "react";
import type {
AgentConfigInfo,
AgentEvent,
AgentStatusValue,
} from "../api/agents";
import { agentsApi, subscribeAgentStream } from "../api/agents";
import { settingsApi } from "../api/settings";
import { useLozengeFly } from "./LozengeFlyContext";
const { useCallback, useEffect, useRef, useState } = React;
interface AgentState {
agentName: string;
status: AgentStatusValue;
log: string[];
sessionId: string | null;
worktreePath: string | null;
baseBranch: string | null;
terminalAt: number | null;
}
const formatTimestamp = (value: Date | null): string => {
if (!value) return "";
return value.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
};
function RosterBadge({
agent,
activeStoryId,
}: {
agent: AgentConfigInfo;
activeStoryId: string | null;
}) {
const { registerRosterEl } = useLozengeFly();
const badgeRef = useRef<HTMLSpanElement>(null);
const isActive = activeStoryId !== null;
const storyNumber = activeStoryId?.match(/^(\d+)/)?.[1];
// Register this element so fly animations know where to start/end
useEffect(() => {
const el = badgeRef.current;
if (el) registerRosterEl(agent.name, el);
return () => registerRosterEl(agent.name, null);
}, [agent.name, registerRosterEl]);
return (
<span
ref={badgeRef}
data-testid={`roster-badge-${agent.name}`}
style={{
display: "inline-flex",
alignItems: "center",
gap: "4px",
padding: "2px 8px",
borderRadius: "6px",
fontSize: "0.7em",
background: isActive ? "#3fb95018" : "#aaaaaa18",
color: isActive ? "#3fb950" : "#aaa",
border: isActive ? "1px solid #3fb95044" : "1px solid #aaaaaa44",
transition: "background 0.3s, color 0.3s, border-color 0.3s",
}}
title={
isActive
? `Working on #${storyNumber ?? activeStoryId}`
: `${agent.role || agent.name} — available`
}
>
{isActive && (
<span
data-testid={`roster-dot-${agent.name}`}
style={{
width: "5px",
height: "5px",
borderRadius: "50%",
background: "#3fb950",
animation: "pulse 1.5s infinite",
flexShrink: 0,
}}
/>
)}
{!isActive && (
<span
data-testid={`roster-dot-${agent.name}`}
style={{
width: "5px",
height: "5px",
borderRadius: "50%",
background: "#3fb950",
flexShrink: 0,
}}
/>
)}
<span style={{ fontWeight: 600, color: isActive ? "#3fb950" : "#aaa" }}>
{agent.name}
</span>
{agent.model && (
<span style={{ color: isActive ? "#5ab96a" : "#888" }}>
{agent.model}
</span>
)}
{isActive && storyNumber && (
<span style={{ color: "#5ab96a", marginLeft: "2px" }}>
#{storyNumber}
</span>
)}
{!isActive && (
<span style={{ color: "#888", fontStyle: "italic" }}>available</span>
)}
</span>
);
}
/** Build a composite key for tracking agent state. */
function agentKey(storyId: string, agentName: string): string {
return `${storyId}:${agentName}`;
}
export function AgentPanel() {
const [agents, setAgents] = useState<Record<string, AgentState>>({});
const [roster, setRoster] = useState<AgentConfigInfo[]>([]);
const [actionError, setActionError] = useState<string | null>(null);
const [lastRefresh, setLastRefresh] = useState<Date | null>(null);
const [editorCommand, setEditorCommand] = useState<string | null>(null);
const [editorInput, setEditorInput] = useState<string>("");
const [editingEditor, setEditingEditor] = useState(false);
const cleanupRefs = useRef<Record<string, () => void>>({});
// Load roster, existing agents, and editor preference on mount
useEffect(() => {
agentsApi
.getAgentConfig()
.then(setRoster)
.catch((err) => console.error("Failed to load agent config:", err));
agentsApi
.listAgents()
.then((agentList) => {
const agentMap: Record<string, AgentState> = {};
const now = Date.now();
for (const a of agentList) {
const key = agentKey(a.story_id, a.agent_name);
const isTerminal = a.status === "completed" || a.status === "failed";
agentMap[key] = {
agentName: a.agent_name,
status: a.status,
log: [],
sessionId: a.session_id,
worktreePath: a.worktree_path,
baseBranch: a.base_branch,
terminalAt: isTerminal ? now : null,
};
if (a.status === "running" || a.status === "pending") {
subscribeToAgent(a.story_id, a.agent_name);
}
}
setAgents(agentMap);
setLastRefresh(new Date());
})
.catch((err) => console.error("Failed to load agents:", err));
settingsApi
.getEditorCommand()
.then((s) => {
setEditorCommand(s.editor_command);
setEditorInput(s.editor_command ?? "");
})
.catch((err) => console.error("Failed to load editor command:", err));
return () => {
for (const cleanup of Object.values(cleanupRefs.current)) {
cleanup();
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const subscribeToAgent = useCallback((storyId: string, agentName: string) => {
const key = agentKey(storyId, agentName);
cleanupRefs.current[key]?.();
const cleanup = subscribeAgentStream(
storyId,
agentName,
(event: AgentEvent) => {
setAgents((prev) => {
const current = prev[key] ?? {
agentName,
status: "pending" as AgentStatusValue,
log: [],
sessionId: null,
worktreePath: null,
baseBranch: null,
terminalAt: null,
};
switch (event.type) {
case "status": {
const newStatus =
(event.status as AgentStatusValue) ?? current.status;
const isTerminal =
newStatus === "completed" || newStatus === "failed";
return {
...prev,
[key]: {
...current,
status: newStatus,
terminalAt: isTerminal
? (current.terminalAt ?? Date.now())
: current.terminalAt,
},
};
}
case "output":
return {
...prev,
[key]: {
...current,
log: [...current.log, event.text ?? ""],
},
};
case "done":
return {
...prev,
[key]: {
...current,
status: "completed",
sessionId: event.session_id ?? current.sessionId,
terminalAt: current.terminalAt ?? Date.now(),
},
};
case "error":
return {
...prev,
[key]: {
...current,
status: "failed",
log: [
...current.log,
`[ERROR] ${event.message ?? "Unknown error"}`,
],
terminalAt: current.terminalAt ?? Date.now(),
},
};
default:
return prev;
}
});
},
() => {
// SSE error — agent may not be streaming yet
},
);
cleanupRefs.current[key] = cleanup;
}, []);
const handleSaveEditor = async () => {
try {
const trimmed = editorInput.trim() || null;
const result = await settingsApi.setEditorCommand(trimmed);
setEditorCommand(result.editor_command);
setEditorInput(result.editor_command ?? "");
setEditingEditor(false);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setActionError(`Failed to save editor: ${message}`);
}
};
return (
<div
style={{
border: "1px solid #333",
borderRadius: "10px",
padding: "12px 16px",
background: "#1f1f1f",
display: "flex",
flexDirection: "column",
gap: "8px",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "12px",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: "8px",
}}
>
<div style={{ fontWeight: 600 }}>Agents</div>
<div
style={{
fontSize: "0.75em",
color: "#777",
fontFamily: "monospace",
}}
>
{Object.values(agents).filter((a) => a.status === "running").length}{" "}
running
</div>
</div>
{lastRefresh && (
<div style={{ fontSize: "0.7em", color: "#555" }}>
Loaded {formatTimestamp(lastRefresh)}
</div>
)}
</div>
{/* Editor preference */}
<div style={{ display: "flex", alignItems: "center", gap: "6px" }}>
<span style={{ fontSize: "0.75em", color: "#666" }}>Editor:</span>
{editingEditor ? (
<>
<input
type="text"
value={editorInput}
onChange={(e) => setEditorInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleSaveEditor();
if (e.key === "Escape") setEditingEditor(false);
}}
placeholder="zed, code, cursor..."
style={{
fontSize: "0.75em",
background: "#111",
border: "1px solid #444",
borderRadius: "4px",
color: "#ccc",
padding: "2px 6px",
width: "120px",
}}
/>
<button
type="button"
onClick={handleSaveEditor}
style={{
fontSize: "0.7em",
padding: "2px 8px",
borderRadius: "4px",
border: "1px solid #238636",
background: "#238636",
color: "#fff",
cursor: "pointer",
}}
>
Save
</button>
<button
type="button"
onClick={() => setEditingEditor(false)}
style={{
fontSize: "0.7em",
padding: "2px 8px",
borderRadius: "4px",
border: "1px solid #444",
background: "none",
color: "#888",
cursor: "pointer",
}}
>
Cancel
</button>
</>
) : (
<button
type="button"
onClick={() => setEditingEditor(true)}
style={{
fontSize: "0.75em",
background: "none",
border: "1px solid #333",
borderRadius: "4px",
color: editorCommand ? "#aaa" : "#555",
cursor: "pointer",
padding: "2px 8px",
fontFamily: editorCommand ? "monospace" : "inherit",
}}
>
{editorCommand ?? "Set editor..."}
</button>
)}
</div>
{/* Roster badges — show all configured agents with idle/active state */}
{roster.length > 0 && (
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: "4px",
}}
>
{roster.map((a) => {
// Find the story this roster agent is currently working on (if any)
const activeEntry = Object.entries(agents).find(
([, state]) =>
state.agentName === a.name &&
(state.status === "running" || state.status === "pending"),
);
const activeStoryId = activeEntry
? activeEntry[0].split(":")[0]
: null;
return (
<RosterBadge
key={`roster-${a.name}`}
agent={a}
activeStoryId={activeStoryId}
/>
);
})}
</div>
)}
{actionError && (
<div
style={{
fontSize: "0.85em",
color: "#ff7b72",
padding: "4px 8px",
background: "#ff7b7211",
borderRadius: "6px",
}}
>
{actionError}
</div>
)}
</div>
);
}