story-83: remove active work list from Agents panel

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>
This commit is contained in:
Dave
2026-02-23 16:00:14 +00:00
parent f380a41fce
commit 1a73b88d85
3 changed files with 13 additions and 749 deletions

View File

@@ -20,20 +20,6 @@ interface AgentState {
terminalAt: number | null;
}
const STATUS_COLORS: Record<AgentStatusValue, string> = {
pending: "#e3b341",
running: "#58a6ff",
completed: "#7ee787",
failed: "#ff7b72",
};
const STATUS_LABELS: Record<AgentStatusValue, string> = {
pending: "Pending",
running: "Running",
completed: "Completed",
failed: "Failed",
};
const formatTimestamp = (value: Date | null): string => {
if (!value) return "";
return value.toLocaleTimeString([], {
@@ -43,38 +29,6 @@ const formatTimestamp = (value: Date | null): string => {
});
};
function StatusBadge({ status }: { status: AgentStatusValue }) {
return (
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: "4px",
padding: "2px 8px",
borderRadius: "999px",
fontSize: "0.75em",
fontWeight: 600,
background: `${STATUS_COLORS[status]}22`,
color: STATUS_COLORS[status],
border: `1px solid ${STATUS_COLORS[status]}44`,
}}
>
{status === "running" && (
<span
style={{
width: "6px",
height: "6px",
borderRadius: "50%",
background: STATUS_COLORS[status],
animation: "pulse 1.5s infinite",
}}
/>
)}
{STATUS_LABELS[status]}
</span>
);
}
function RosterBadge({
agent,
activeStoryId,
@@ -168,157 +122,15 @@ function agentKey(storyId: string, agentName: string): string {
return `${storyId}:${agentName}`;
}
function DiffCommand({
worktreePath,
baseBranch,
}: {
worktreePath: string;
baseBranch: string;
}) {
const [copied, setCopied] = useState(false);
const command = `cd "${worktreePath}" && git difftool ${baseBranch}...HEAD`;
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(command);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// Fallback: select text for manual copy
}
};
return (
<div
style={{
display: "flex",
alignItems: "center",
gap: "6px",
marginBottom: "6px",
}}
>
<code
style={{
flex: 1,
fontSize: "0.7em",
color: "#8b949e",
background: "#0d1117",
padding: "4px 8px",
borderRadius: "4px",
border: "1px solid #21262d",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{command}
</code>
<button
type="button"
onClick={handleCopy}
style={{
padding: "3px 8px",
borderRadius: "4px",
border: "1px solid #30363d",
background: copied ? "#238636" : "#21262d",
color: copied ? "#fff" : "#8b949e",
cursor: "pointer",
fontSize: "0.7em",
fontWeight: 600,
whiteSpace: "nowrap",
}}
>
{copied ? "Copied" : "Copy"}
</button>
</div>
);
}
export function EditorCommand({
worktreePath,
editorCommand,
}: {
worktreePath: string;
editorCommand: string;
}) {
const [copied, setCopied] = useState(false);
const command = `${editorCommand} "${worktreePath}"`;
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(command);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// Fallback: select text for manual copy
}
};
return (
<div
style={{
display: "flex",
alignItems: "center",
gap: "6px",
marginBottom: "6px",
}}
>
<code
style={{
flex: 1,
fontSize: "0.7em",
color: "#8b949e",
background: "#0d1117",
padding: "4px 8px",
borderRadius: "4px",
border: "1px solid #21262d",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{command}
</code>
<button
type="button"
onClick={handleCopy}
style={{
padding: "3px 8px",
borderRadius: "4px",
border: "1px solid #30363d",
background: copied ? "#238636" : "#21262d",
color: copied ? "#fff" : "#8b949e",
cursor: "pointer",
fontSize: "0.7em",
fontWeight: 600,
whiteSpace: "nowrap",
}}
>
{copied ? "Copied" : "Open"}
</button>
</div>
);
}
const FADE_DURATION_MS = 60_000;
export function AgentPanel() {
const [agents, setAgents] = useState<Record<string, AgentState>>({});
const [roster, setRoster] = useState<AgentConfigInfo[]>([]);
const [expandedKey, setExpandedKey] = useState<string | null>(null);
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>>({});
const logEndRefs = useRef<Record<string, HTMLDivElement | null>>({});
// Refs for fade-out timers (pause/resume on expand/collapse)
const fadeTimerRef = useRef<Record<string, ReturnType<typeof setTimeout>>>(
{},
);
const fadeElapsedRef = useRef<Record<string, number>>({});
const fadeTimerStartRef = useRef<Record<string, number | null>>({});
// Load roster, existing agents, and editor preference on mount
useEffect(() => {
@@ -449,79 +261,6 @@ export function AgentPanel() {
cleanupRefs.current[key] = cleanup;
}, []);
// Auto-scroll log when expanded
useEffect(() => {
if (expandedKey) {
const el = logEndRefs.current[expandedKey];
el?.scrollIntoView({ behavior: "smooth" });
}
}, [expandedKey, agents]);
// Manage fade-out timers for terminal agents.
// Timers are paused when an entry is expanded and resumed on collapse.
useEffect(() => {
for (const [key, agent] of Object.entries(agents)) {
if (!agent.terminalAt) continue;
const isExpanded = expandedKey === key;
const hasTimer = key in fadeTimerRef.current;
if (isExpanded && hasTimer) {
// Pause: clear timer and accumulate elapsed time
clearTimeout(fadeTimerRef.current[key]);
const started = fadeTimerStartRef.current[key];
if (started !== null && started !== undefined) {
fadeElapsedRef.current[key] =
(fadeElapsedRef.current[key] ?? 0) + (Date.now() - started);
}
delete fadeTimerRef.current[key];
fadeTimerStartRef.current[key] = null;
} else if (!isExpanded && !hasTimer) {
// Start or resume timer with remaining time
const elapsed = fadeElapsedRef.current[key] ?? 0;
const remaining = Math.max(0, FADE_DURATION_MS - elapsed);
fadeTimerStartRef.current[key] = Date.now();
fadeTimerRef.current[key] = setTimeout(() => {
setAgents((prev) => {
const next = { ...prev };
delete next[key];
return next;
});
delete fadeTimerRef.current[key];
delete fadeTimerStartRef.current[key];
delete fadeElapsedRef.current[key];
}, remaining);
}
}
}, [agents, expandedKey]);
// Clean up fade timers on unmount
useEffect(() => {
return () => {
for (const timer of Object.values(fadeTimerRef.current)) {
clearTimeout(timer);
}
fadeTimerRef.current = {};
};
}, []);
const handleStop = async (storyId: string, agentName: string) => {
setActionError(null);
const key = agentKey(storyId, agentName);
try {
await agentsApi.stopAgent(storyId, agentName);
cleanupRefs.current[key]?.();
delete cleanupRefs.current[key];
setAgents((prev) => {
const next = { ...prev };
delete next[key];
return next;
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setActionError(`Failed to stop agent for ${storyId}: ${message}`);
}
};
const handleSaveEditor = async () => {
try {
const trimmed = editorInput.trim() || null;
@@ -699,175 +438,6 @@ export function AgentPanel() {
{actionError}
</div>
)}
{/* Active agents */}
{Object.entries(agents).length > 0 && (
<div
style={{
display: "flex",
flexDirection: "column",
gap: "6px",
}}
>
{Object.entries(agents).map(([key, a]) => (
<div
key={`agent-${key}`}
data-testid={`agent-entry-${key}`}
style={{
border: "1px solid #2a2a2a",
borderRadius: "8px",
background: "#191919",
overflow: "hidden",
...(a.terminalAt
? {
animationName: "agentFadeOut",
animationDuration: "60s",
animationTimingFunction: "linear",
animationFillMode: "forwards",
animationPlayState:
expandedKey === key ? "paused" : "running",
}
: {}),
}}
>
<div
style={{
padding: "8px 12px",
display: "flex",
alignItems: "center",
gap: "8px",
}}
>
<button
type="button"
onClick={() =>
setExpandedKey(expandedKey === key ? null : key)
}
style={{
background: "none",
border: "none",
color: "#aaa",
cursor: "pointer",
fontSize: "0.8em",
padding: "0 4px",
transform:
expandedKey === key ? "rotate(90deg)" : "rotate(0deg)",
transition: "transform 0.15s",
}}
>
&#9654;
</button>
<div
style={{
flex: 1,
fontWeight: 600,
fontSize: "0.9em",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
<span style={{ color: "#888" }}>{a.agentName}</span>
<span style={{ color: "#555", margin: "0 6px" }}>
{key.split(":")[0]}
</span>
</div>
<StatusBadge status={a.status} />
{(a.status === "running" || a.status === "pending") && (
<button
type="button"
onClick={() => handleStop(key.split(":")[0], a.agentName)}
style={{
padding: "4px 10px",
borderRadius: "999px",
border: "1px solid #ff7b7244",
background: "#ff7b7211",
color: "#ff7b72",
cursor: "pointer",
fontSize: "0.75em",
fontWeight: 600,
}}
>
Stop
</button>
)}
</div>
{expandedKey === key && (
<div
style={{
borderTop: "1px solid #2a2a2a",
padding: "8px 12px",
}}
>
{a.worktreePath && (
<div
style={{
fontSize: "0.75em",
color: "#666",
fontFamily: "monospace",
marginBottom: "6px",
}}
>
Worktree: {a.worktreePath}
</div>
)}
{a.worktreePath && (
<DiffCommand
worktreePath={a.worktreePath}
baseBranch={a.baseBranch ?? "master"}
/>
)}
<div
style={{
maxHeight: "300px",
overflowY: "auto",
background: "#111",
borderRadius: "6px",
padding: "8px",
fontFamily: "monospace",
fontSize: "0.8em",
lineHeight: "1.5",
color: "#ccc",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}
>
{a.log.length === 0 ? (
<span style={{ color: "#555" }}>
{a.status === "pending" || a.status === "running"
? "Waiting for output..."
: "No output captured."}
</span>
) : (
a.log.map((line, i) => (
<div
key={`log-${key}-${i.toString()}`}
style={{
color: line.startsWith("[ERROR]")
? "#ff7b72"
: "#ccc",
}}
>
{line}
</div>
))
)}
<div
ref={(el) => {
logEndRefs.current[key] = el;
}}
/>
</div>
</div>
)}
</div>
))}
</div>
)}
</div>
);
}