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:
@@ -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",
|
||||
}}
|
||||
>
|
||||
▶
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user