feat(FN-3355): add agent delegation and org hierarchy tools to pi extension

This merge adds agent delegation tools (list_agents, delegate_task) and org hierarchy tools to the pi extension, along with corresponding skill documentation and tests; it also cleans up AgentDetailView by removing its dedicated CSS and reducing the component, collapses agent tool output in logs by

Fusion-Task-Id: FN-3355
This commit is contained in:
Fusion
2026-05-04 02:47:15 -07:00
committed by gsxdsm
parent cc12b801d4
commit 089f840d41
4 changed files with 43 additions and 339 deletions

View File

@@ -222,14 +222,7 @@ describe("Agent CSS classes", () => {
expect(hasClass(".logs-count")).toBe(true);
expect(hasClass(".streaming-indicator")).toBe(true);
expect(hasClass(".streaming-dot")).toBe(true);
expect(hasClass(".logs-container")).toBe(true);
expect(hasClass(".logs-empty")).toBe(true);
expect(hasClass(".log-entry")).toBe(true);
expect(hasClass(".log-timestamp")).toBe(true);
expect(hasClass(".log-agent")).toBe(true);
expect(hasClass(".log-icon")).toBe(true);
expect(hasClass(".log-text")).toBe(true);
expect(hasClass(".log-detail")).toBe(true);
expect(hasClass(".runs-tab")).toBe(true);
expect(hasClass(".runs-empty")).toBe(true);
expect(hasClass(".run-card")).toBe(true);

View File

@@ -452,15 +452,6 @@
animation: pulse 1.5s infinite;
}
.logs-container {
flex: 1;
overflow-y: auto;
font-family: var(--font-mono);
font-size: calc(var(--space-md) + var(--space-xs) * 0.25);
line-height: 1.6;
max-height: calc(var(--space-2xl) * 12 + var(--space-lg));
}
.logs-empty {
display: flex;
flex-direction: column;
@@ -475,41 +466,6 @@
margin: var(--space-sm) 0 0 0;
}
.log-entry {
display: flex;
gap: var(--space-sm);
padding: var(--space-xs) var(--space-sm);
margin: calc(var(--space-xs) * 0.5) 0;
border-radius: var(--radius-sm);
}
.log-timestamp {
color: var(--text-muted);
font-size: calc(var(--space-sm) + var(--space-xs) * 0.75);
flex-shrink: 0;
}
.log-agent {
color: var(--text-muted);
font-size: calc(var(--space-sm) + var(--space-xs) * 0.75);
font-weight: 600;
text-transform: uppercase;
flex-shrink: 0;
}
.log-icon {
flex-shrink: 0;
}
.log-text {
word-break: break-word;
}
.log-detail {
color: var(--text-muted);
font-size: var(--space-md);
}
/* --- Runs Tab --- */
.runs-tab {
display: flex;
@@ -1267,37 +1223,6 @@
font-size: calc(var(--space-sm) + var(--space-xs));
}
.logs-return-to-live {
position: sticky;
right: var(--space-md);
bottom: var(--space-md);
width: fit-content;
margin-left: auto;
display: flex;
align-items: center;
gap: var(--space-xs);
padding: var(--space-xs) var(--space-sm);
color: var(--text-muted);
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-md);
font-size: var(--space-md);
cursor: pointer;
transition: all var(--transition-fast);
z-index: 2;
}
.logs-return-to-live:hover {
background: var(--card-hover);
color: var(--text);
}
.logs-return-to-live:focus-visible {
outline: none;
box-shadow: var(--focus-ring-strong);
}
.config-textarea-mono {
font-family: var(--font-mono);
font-size: calc(var(--space-md) + var(--space-xs) * 0.25);
@@ -1533,29 +1458,10 @@
}
/* Logs Tab mobile */
.logs-container {
max-height: 50vh;
}
.logs-return-to-live {
right: var(--space-sm);
bottom: var(--space-sm);
}
.logs-empty {
padding: var(--space-2xl) var(--space-lg);
}
.log-entry {
padding: var(--space-xs) calc(var(--space-xs) * 0.5);
font-size: var(--space-md);
}
.log-text {
min-width: 0;
overflow-wrap: anywhere;
}
/* Runs Tab mobile */
.run-card {
padding: var(--space-md);

View File

@@ -1,5 +1,5 @@
import "./AgentDetailView.css";
import { useState, useEffect, useCallback, useRef, useMemo, useLayoutEffect } from "react";
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import {
Bot, Heart, Activity, Pause, Play, Square, Trash2, RefreshCw,
Settings, FileText, ActivitySquare, X, Copy,
@@ -131,7 +131,6 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
const [isStreaming, setIsStreaming] = useState(false);
const [isTransitioning, setIsTransitioning] = useState(false);
const [latestRun, setLatestRun] = useState<AgentHeartbeatRun | null>(null);
const logContainerRef = useRef<HTMLDivElement>(null);
const agentDetailModalRef = useRef<HTMLDivElement>(null);
const overlayMouseDownRef = useRef(false);
useModalResizePersist(agentDetailModalRef, !inline, "fusion:agent-detail-modal-size");
@@ -623,7 +622,6 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
<LogsTab
logs={logs}
isStreaming={isStreaming}
containerRef={logContainerRef}
hasTask={!!agent.taskId || logs.length > 0 || latestRun !== null}
fallbackLabel={!agent.taskId && latestRun ? `Latest run · ${latestRun.id.slice(0, 8)}` : null}
/>
@@ -965,54 +963,17 @@ function DashboardTab({
// ── Logs Tab ──────────────────────────────────────────────────────────────
const BOTTOM_FOLLOW_THRESHOLD_PX = 50;
function isNearBottom(container: HTMLDivElement): boolean {
return container.scrollHeight - (container.scrollTop + container.clientHeight) <= BOTTOM_FOLLOW_THRESHOLD_PX;
}
function LogsTab({
logs,
isStreaming,
containerRef,
hasTask,
fallbackLabel,
}: {
logs: AgentLogEntry[];
isStreaming: boolean;
containerRef: React.RefObject<HTMLDivElement | null>;
hasTask: boolean;
fallbackLabel?: string | null;
}) {
const [isFollowing, setIsFollowing] = useState(true);
const previousLogCountRef = useRef(0);
// Auto-scroll to bottom when new entries arrive and user is near the bottom
useLayoutEffect(() => {
const container = containerRef.current;
if (!container) return;
const previousCount = previousLogCountRef.current;
previousLogCountRef.current = logs.length;
if (logs.length > previousCount && isFollowing) {
container.scrollTop = container.scrollHeight;
}
}, [logs.length, isFollowing, containerRef]);
const handleScroll = useCallback(() => {
const container = containerRef.current;
if (!container) return;
setIsFollowing(isNearBottom(container));
}, [containerRef]);
const scrollToLive = useCallback(() => {
const container = containerRef.current;
if (!container) return;
container.scrollTop = container.scrollHeight;
setIsFollowing(true);
}, [containerRef]);
if (!hasTask) {
return (
<div className="logs-tab">
@@ -1041,95 +1002,17 @@ function LogsTab({
</span>
)}
</div>
<div ref={containerRef} className="logs-container" onScroll={handleScroll}>
{logs.length === 0 ? (
<div className="logs-empty">
<FileText size={48} opacity={0.3} />
<p>No log entries yet</p>
<p className="text-muted">
{isStreaming ? "Waiting for activity..." : "Logs will appear here when the agent is active"}
</p>
</div>
) : (
logs.map((entry, i) => {
const prevEntry = i > 0 ? logs[i - 1] : undefined;
const showTimestamp = !prevEntry || prevEntry.agent !== entry.agent;
return (
<LogEntry key={`${entry.timestamp}-${i}`} entry={entry} showTimestamp={showTimestamp} />
);
})
)}
{!isFollowing && logs.length > 0 && (
<button
type="button"
className="logs-return-to-live"
onClick={scrollToLive}
data-testid="logs-return-to-live"
>
<ChevronDown size={12} />
<span>Live</span>
</button>
)}
</div>
</div>
);
}
function LogEntry({ entry, showTimestamp }: { entry: AgentLogEntry; showTimestamp: boolean }) {
const getEntryStyles = () => {
switch (entry.type) {
case "tool":
return {
color: "var(--accent)",
borderLeft: "3px solid var(--accent)",
background: "var(--log-tool-bg)",
};
case "tool_result":
return {
color: "var(--color-success)",
borderLeft: "3px solid var(--color-success)",
background: "var(--log-success-bg)",
};
case "tool_error":
return {
color: "var(--color-error)",
borderLeft: "3px solid var(--color-error)",
background: "var(--log-error-bg)",
};
case "thinking":
return {
color: "var(--text-muted)",
fontStyle: "italic" as const,
opacity: 0.7,
};
default:
return {
color: "var(--text)",
};
}
};
const styles = getEntryStyles();
const timestamp = new Date(entry.timestamp).toLocaleTimeString();
return (
<div className="log-entry" style={styles}>
{showTimestamp && (
<span className="log-timestamp">[{timestamp}]</span>
{logs.length === 0 ? (
<div className="logs-empty">
<FileText size={48} opacity={0.3} />
<p>No log entries yet</p>
<p className="text-muted">
{isStreaming ? "Waiting for activity..." : "Logs will appear here when the agent is active"}
</p>
</div>
) : (
<AgentLogViewer entries={logs} loading={false} />
)}
{entry.agent && (
<span className="log-agent">[{entry.agent}]</span>
)}
{entry.type === "tool" && <span className="log-icon">⚡</span>}
{entry.type === "tool_result" && <span className="log-icon">✓</span>}
{entry.type === "tool_error" && <span className="log-icon">✗</span>}
<span className="log-text">
{entry.text}
{entry.detail && (
<span className="log-detail"> — {entry.detail}</span>
)}
</span>
</div>
);
}

View File

@@ -39,9 +39,18 @@ vi.mock("../../api", () => ({
}));
vi.mock("../AgentLogViewer", () => ({
AgentLogViewer: ({ entries }: { entries: Array<{ text: string }> }) => (
AgentLogViewer: ({ entries }: { entries: Array<{ text: string; detail?: string }> }) => (
<div data-testid="agent-log-viewer">
{entries.map((e, i) => <span key={i}>{e.text}</span>)}
{entries.map((e, i) => (
<div key={i}>
<span>{e.text}</span>
{e.detail ? (
<button type="button" data-testid="tool-detail-toggle" aria-expanded="false">
Show output
</button>
) : null}
</div>
))}
</div>
),
}));
@@ -1241,9 +1250,10 @@ describe("AgentDetailView", () => {
});
expect(screen.getByText("Latest run · run-1001")).toBeInTheDocument();
expect(
Array.from(document.querySelectorAll(".log-text")).map((node) => node.textContent?.trim()),
).toEqual(["First entry", "Second entry"]);
await waitFor(() => {
expect(screen.getByText("First entry")).toBeInTheDocument();
expect(screen.getByText("Second entry")).toBeInTheDocument();
});
});
it("renders log entries in chronological order (oldest first)", async () => {
@@ -1284,13 +1294,12 @@ describe("AgentDetailView", () => {
expect(screen.getByText("Oldest entry")).toBeInTheDocument();
});
const logTexts = Array.from(document.querySelectorAll(".log-text")).map(
(node) => node.textContent?.trim(),
);
expect(logTexts).toEqual(["Oldest entry", "Middle entry", "Newest entry"]);
const viewerText = screen.getByTestId("agent-log-viewer").textContent ?? "";
expect(viewerText.indexOf("Oldest entry")).toBeLessThan(viewerText.indexOf("Middle entry"));
expect(viewerText.indexOf("Middle entry")).toBeLessThan(viewerText.indexOf("Newest entry"));
});
it("shows Live button when scrolled away from bottom and hides when at bottom", async () => {
it("renders tool details collapsed by default", async () => {
const latestRun = {
id: "run-1003",
agentId: "agent-001",
@@ -1304,14 +1313,15 @@ describe("AgentDetailView", () => {
completedRuns: [],
}));
mockFetchAgentRuns.mockResolvedValue([latestRun]);
mockFetchAgentRunLogs.mockResolvedValue(
Array.from({ length: 20 }, (_, i) => ({
timestamp: `2024-01-01T00:${String(i).padStart(2, "0")}:00.000Z`,
mockFetchAgentRunLogs.mockResolvedValue([
{
timestamp: "2024-01-01T00:00:00.000Z",
taskId: "agent-run",
text: `Log line ${i}`,
type: "text" as const,
})),
);
type: "tool",
text: "ls -la packages/",
detail: "very long tool output",
},
]);
render(
<AgentDetailView
@@ -1321,99 +1331,11 @@ describe("AgentDetailView", () => {
/>
);
await waitFor(() => {
expect(screen.getByText("Dashboard")).toBeInTheDocument();
});
fireEvent.click(screen.getByText("Logs"));
await waitFor(() => {
expect(screen.getByText("Log line 0")).toBeInTheDocument();
});
// No Live button when initially at bottom (isFollowing defaults to true and
// useLayoutEffect scrolls to bottom on first render with entries)
expect(screen.queryByTestId("logs-return-to-live")).not.toBeInTheDocument();
// Simulate scrolling up away from bottom
const container = document.querySelector(".logs-container") as HTMLDivElement;
expect(container).toBeTruthy();
Object.defineProperty(container, "scrollTop", { value: 0, writable: true, configurable: true });
Object.defineProperty(container, "scrollHeight", { value: 1000, writable: true, configurable: true });
Object.defineProperty(container, "clientHeight", { value: 200, writable: true, configurable: true });
fireEvent.scroll(container);
await waitFor(() => {
expect(screen.getByTestId("logs-return-to-live")).toBeInTheDocument();
});
// Simulate scrolling back to bottom
Object.defineProperty(container, "scrollTop", { value: 800, writable: true, configurable: true });
fireEvent.scroll(container);
await waitFor(() => {
expect(screen.queryByTestId("logs-return-to-live")).not.toBeInTheDocument();
});
});
it("scrolls to bottom when Live button is clicked", async () => {
const latestRun = {
id: "run-1004",
agentId: "agent-001",
startedAt: "2024-01-01T00:00:00.000Z",
endedAt: null,
status: "active",
} as AgentHeartbeatRun;
mockFetchAgent.mockResolvedValue(createMockAgent({
taskId: undefined,
activeRun: latestRun,
completedRuns: [],
}));
mockFetchAgentRuns.mockResolvedValue([latestRun]);
mockFetchAgentRunLogs.mockResolvedValue(
Array.from({ length: 20 }, (_, i) => ({
timestamp: `2024-01-01T00:${String(i).padStart(2, "0")}:00.000Z`,
taskId: "agent-run",
text: `Log line ${i}`,
type: "text" as const,
})),
);
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
await waitFor(() => {
expect(screen.getByText("Dashboard")).toBeInTheDocument();
});
fireEvent.click(screen.getByText("Logs"));
await waitFor(() => {
expect(screen.getByText("Log line 0")).toBeInTheDocument();
});
// Simulate scrolling up
const container = document.querySelector(".logs-container") as HTMLDivElement;
expect(container).toBeTruthy();
Object.defineProperty(container, "scrollTop", { value: 0, writable: true, configurable: true });
Object.defineProperty(container, "scrollHeight", { value: 1000, writable: true, configurable: true });
Object.defineProperty(container, "clientHeight", { value: 200, writable: true, configurable: true });
fireEvent.scroll(container);
const liveButton = await screen.findByTestId("logs-return-to-live");
// Set up scroll position to accept writes so scrollToLive can set scrollTop
Object.defineProperty(container, "scrollTop", { value: 0, writable: true, configurable: true });
fireEvent.click(liveButton);
// After clicking, scrollTop should be set to scrollHeight (1000)
expect(container.scrollTop).toBe(1000);
fireEvent.click(await screen.findByText("Logs"));
await screen.findByText("ls -la packages/");
const toggle = await screen.findByTestId("tool-detail-toggle");
expect(toggle).toHaveAttribute("aria-expanded", "false");
expect(screen.getByTestId("agent-log-viewer")).toBeInTheDocument();
});
});