feat(FN-3184): add collapsible tool detail panel in agent log viewer
This merge adds a collapsible tool detail panel to the AgentLogViewer, introduces a plugin-runner module with cached contribution accessors in the core package, refactors the heartbeat and agent-tools engine modules, improves QuickChatFAB mobile alignment and chat streaming feedback, adds provider v Fusion-Task-Id: FN-3184
This commit is contained in:
@@ -158,6 +158,40 @@
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.agent-log-tool-detail-wrapper {
|
||||
margin-top: var(--space-xs);
|
||||
}
|
||||
|
||||
.agent-log-tool-detail-toggle {
|
||||
width: 100%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
color: var(--text-muted);
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast), color var(--transition-fast);
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.agent-log-tool-detail-toggle:hover {
|
||||
background: var(--card-hover);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.agent-log-tool-detail-toggle:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring-strong);
|
||||
}
|
||||
|
||||
.agent-log-tool-detail-content--collapsed {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.agent-log-tool-detail {
|
||||
margin: var(--space-xs) 0 0;
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
@@ -330,4 +364,8 @@
|
||||
right: var(--space-sm);
|
||||
bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.agent-log-tool-detail-toggle {
|
||||
min-height: calc(var(--space-lg) + var(--space-lg) + var(--space-xs));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { AgentLogEntry } from "@fusion/core";
|
||||
import { ProviderIcon } from "./ProviderIcon";
|
||||
import { useRef, useEffect, useState, useCallback, useLayoutEffect, useMemo, type ReactElement } from "react";
|
||||
import { useRef, useEffect, useState, useCallback, useLayoutEffect, useMemo, useId, type ReactElement } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import type { Components } from "react-markdown";
|
||||
@@ -85,9 +85,41 @@ function isToolLikeType(type: AgentLogEntry["type"]): boolean {
|
||||
return type === "tool" || type === "tool_result" || type === "tool_error";
|
||||
}
|
||||
|
||||
function renderToolDetail(detail?: string): ReactElement | null {
|
||||
if (!detail) return null;
|
||||
return <pre className="agent-log-tool-detail">{detail}</pre>;
|
||||
interface CollapsibleToolDetailProps {
|
||||
detail: string;
|
||||
type?: "tool" | "tool_result" | "tool_error";
|
||||
}
|
||||
|
||||
function CollapsibleToolDetail({ detail }: CollapsibleToolDetailProps): ReactElement {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const contentId = useId();
|
||||
const lineCount = detail.split("\n").length;
|
||||
const toggleLabel = expanded
|
||||
? "Hide output"
|
||||
: `Show output${lineCount > 1 ? ` (${lineCount} lines)` : ""}`;
|
||||
|
||||
return (
|
||||
<div className="agent-log-tool-detail-wrapper">
|
||||
<button
|
||||
type="button"
|
||||
className="agent-log-tool-detail-toggle"
|
||||
onClick={() => setExpanded((prev) => !prev)}
|
||||
aria-expanded={expanded}
|
||||
aria-controls={contentId}
|
||||
data-testid="tool-detail-toggle"
|
||||
>
|
||||
{expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
<span>{toggleLabel}</span>
|
||||
</button>
|
||||
<div
|
||||
id={contentId}
|
||||
className={expanded ? "agent-log-tool-detail-content" : "agent-log-tool-detail-content agent-log-tool-detail-content--collapsed"}
|
||||
data-testid="tool-detail-content"
|
||||
>
|
||||
<pre className="agent-log-tool-detail">{detail}</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function shouldShowBadge(entry: AgentLogEntry, previousEntry?: AgentLogEntry): boolean {
|
||||
@@ -493,7 +525,7 @@ export function AgentLogViewer({
|
||||
<div key={group.key} className="agent-log-tool">
|
||||
{agentBadge}
|
||||
<div className="agent-log-tool-title">⚡ {entry.text}</div>
|
||||
{renderToolDetail(entry.detail)}
|
||||
{entry.detail ? <CollapsibleToolDetail detail={entry.detail} type="tool" /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -503,7 +535,7 @@ export function AgentLogViewer({
|
||||
<div key={group.key} className="agent-log-tool-result">
|
||||
{agentBadge}
|
||||
<div className="agent-log-tool-title">✓ {entry.text}</div>
|
||||
{renderToolDetail(entry.detail)}
|
||||
{entry.detail ? <CollapsibleToolDetail detail={entry.detail} type="tool_result" /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -513,7 +545,7 @@ export function AgentLogViewer({
|
||||
<div key={group.key} className="agent-log-tool-error">
|
||||
{agentBadge}
|
||||
<div className="agent-log-tool-title">✗ {entry.text}</div>
|
||||
{renderToolDetail(entry.detail)}
|
||||
{entry.detail ? <CollapsibleToolDetail detail={entry.detail} type="tool_error" /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -225,23 +225,25 @@ describe("AgentLogViewer", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("renders tool entry detail when present", () => {
|
||||
it("renders tool entry detail toggle collapsed by default when detail is present", () => {
|
||||
const entries = [
|
||||
makeEntry({ text: "Bash", type: "tool", detail: "ls -la packages/" }),
|
||||
];
|
||||
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
|
||||
const detail = container.querySelector(".agent-log-tool-detail");
|
||||
expect(detail).toBeTruthy();
|
||||
expect(detail!.textContent).toContain("ls -la packages/");
|
||||
render(<AgentLogViewer entries={entries} loading={false} />);
|
||||
|
||||
const toggle = screen.getByTestId("tool-detail-toggle");
|
||||
expect(toggle).toBeTruthy();
|
||||
expect(toggle.getAttribute("aria-expanded")).toBe("false");
|
||||
const content = screen.getByTestId("tool-detail-content");
|
||||
expect(content.classList.contains("agent-log-tool-detail-content--collapsed")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not render detail span when detail is absent", () => {
|
||||
it("does not render detail toggle when detail is absent", () => {
|
||||
const entries = [
|
||||
makeEntry({ text: "Bash", type: "tool" }),
|
||||
];
|
||||
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
|
||||
const detail = container.querySelector(".agent-log-tool-detail");
|
||||
expect(detail).toBeNull();
|
||||
render(<AgentLogViewer entries={entries} loading={false} />);
|
||||
expect(screen.queryByTestId("tool-detail-toggle")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders long detail text without breaking layout", () => {
|
||||
@@ -250,6 +252,7 @@ describe("AgentLogViewer", () => {
|
||||
makeEntry({ text: "Read", type: "tool", detail: longDetail }),
|
||||
];
|
||||
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
|
||||
fireEvent.click(screen.getByTestId("tool-detail-toggle"));
|
||||
const detail = container.querySelector(".agent-log-tool-detail");
|
||||
expect(detail).toBeTruthy();
|
||||
expect(detail!.textContent).toContain(longDetail);
|
||||
@@ -258,6 +261,41 @@ describe("AgentLogViewer", () => {
|
||||
expect(toolDiv).toBeTruthy();
|
||||
});
|
||||
|
||||
it("collapses tool-like detail by default across tool, tool_result, and tool_error", () => {
|
||||
const entries = [
|
||||
makeEntry({ text: "Read", type: "tool", detail: "tool output" }),
|
||||
makeEntry({ text: "Done", type: "tool_result", detail: "result output" }),
|
||||
makeEntry({ text: "Oops", type: "tool_error", detail: "error output" }),
|
||||
];
|
||||
render(<AgentLogViewer entries={entries} loading={false} />);
|
||||
|
||||
const toggles = screen.getAllByTestId("tool-detail-toggle");
|
||||
expect(toggles).toHaveLength(3);
|
||||
const contents = screen.getAllByTestId("tool-detail-content");
|
||||
expect(contents).toHaveLength(3);
|
||||
for (const content of contents) {
|
||||
expect(content.classList.contains("agent-log-tool-detail-content--collapsed")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("expands and collapses tool detail on toggle click", () => {
|
||||
const entries = [
|
||||
makeEntry({ text: "Bash", type: "tool", detail: "line 1\nline 2" }),
|
||||
];
|
||||
render(<AgentLogViewer entries={entries} loading={false} />);
|
||||
|
||||
const toggle = screen.getByTestId("tool-detail-toggle");
|
||||
expect(toggle.getAttribute("aria-expanded")).toBe("false");
|
||||
fireEvent.click(toggle);
|
||||
expect(toggle.getAttribute("aria-expanded")).toBe("true");
|
||||
const content = screen.getByTestId("tool-detail-content");
|
||||
expect(content.textContent).toContain("line 1");
|
||||
expect(content.classList.contains("agent-log-tool-detail-content--collapsed")).toBe(false);
|
||||
fireEvent.click(toggle);
|
||||
expect(toggle.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(content.classList.contains("agent-log-tool-detail-content--collapsed")).toBe(true);
|
||||
});
|
||||
|
||||
it("applies the viewer styling via the agent-log-viewer class", () => {
|
||||
const entries = [makeEntry()];
|
||||
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
|
||||
@@ -1120,6 +1158,7 @@ describe("AgentLogViewer", () => {
|
||||
const longDetail = "B".repeat(5000);
|
||||
const entries = [makeEntry({ text: "Read", type: "tool", detail: longDetail })];
|
||||
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
|
||||
fireEvent.click(screen.getByTestId("tool-detail-toggle"));
|
||||
const detail = container.querySelector(".agent-log-tool-detail");
|
||||
expect(detail).toBeTruthy();
|
||||
expect(detail!.textContent).toContain(longDetail);
|
||||
@@ -1155,6 +1194,7 @@ describe("AgentLogViewer", () => {
|
||||
const longDetail = "D".repeat(5000);
|
||||
const entries = [makeEntry({ text: "Bash", type: "tool_result", detail: longDetail })];
|
||||
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
|
||||
fireEvent.click(screen.getByTestId("tool-detail-toggle"));
|
||||
const detail = container.querySelector(".agent-log-tool-detail");
|
||||
expect(detail).toBeTruthy();
|
||||
expect(detail!.textContent).toContain(longDetail);
|
||||
@@ -1164,6 +1204,7 @@ describe("AgentLogViewer", () => {
|
||||
const longDetail = "E".repeat(5000);
|
||||
const entries = [makeEntry({ text: "Write", type: "tool_error", detail: longDetail })];
|
||||
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
|
||||
fireEvent.click(screen.getByTestId("tool-detail-toggle"));
|
||||
const detail = container.querySelector(".agent-log-tool-detail");
|
||||
expect(detail).toBeTruthy();
|
||||
expect(detail!.textContent).toContain(longDetail);
|
||||
@@ -1173,6 +1214,7 @@ describe("AgentLogViewer", () => {
|
||||
const detailText = "stdout:\n line one\n indented line two\n";
|
||||
const entries = [makeEntry({ text: "Bash", type: "tool_result", detail: detailText })];
|
||||
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
|
||||
fireEvent.click(screen.getByTestId("tool-detail-toggle"));
|
||||
const detail = container.querySelector(".agent-log-tool-detail") as HTMLElement;
|
||||
expect(detail).toBeTruthy();
|
||||
expect(detail.tagName).toBe("PRE");
|
||||
|
||||
Reference in New Issue
Block a user