Remove the "agents-at-work" list section from AgentPanel. The roster badges with flying lozenge animations already convey which agents are active, making the redundant list unnecessary. - Remove StatusBadge, DiffCommand, EditorCommand components - Remove expandedKey, logEndRefs, fade-timer state and effects - Remove handleStop (no more Stop buttons) - Keep agents state + SSE subscriptions (roster badges still need it) - Delete diff-command and fade-out tests (feature removed) - Add test asserting no agent-entry divs are rendered Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
444 lines
11 KiB
TypeScript
444 lines
11 KiB
TypeScript
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 ? "#58a6ff18" : "#3fb95015",
|
|
color: isActive ? "#58a6ff" : "#3fb950",
|
|
border: isActive ? "1px solid #58a6ff44" : "1px solid #3fb95040",
|
|
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: "#58a6ff",
|
|
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 ? "#58a6ff" : "#3fb950" }}
|
|
>
|
|
{agent.name}
|
|
</span>
|
|
{agent.model && (
|
|
<span style={{ color: isActive ? "#7ab8ff" : "#5ab96a" }}>
|
|
{agent.model}
|
|
</span>
|
|
)}
|
|
{isActive && storyNumber && (
|
|
<span style={{ color: "#7ab8ff", marginLeft: "2px" }}>
|
|
#{storyNumber}
|
|
</span>
|
|
)}
|
|
{!isActive && (
|
|
<span style={{ color: "#5ab96a", 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>
|
|
);
|
|
}
|