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;
|
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 {
|
.agent-log-tool-detail {
|
||||||
margin: var(--space-xs) 0 0;
|
margin: var(--space-xs) 0 0;
|
||||||
padding: var(--space-xs) var(--space-sm);
|
padding: var(--space-xs) var(--space-sm);
|
||||||
@@ -330,4 +364,8 @@
|
|||||||
right: var(--space-sm);
|
right: var(--space-sm);
|
||||||
bottom: 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 type { AgentLogEntry } from "@fusion/core";
|
||||||
import { ProviderIcon } from "./ProviderIcon";
|
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 ReactMarkdown from "react-markdown";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
import type { Components } from "react-markdown";
|
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";
|
return type === "tool" || type === "tool_result" || type === "tool_error";
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderToolDetail(detail?: string): ReactElement | null {
|
interface CollapsibleToolDetailProps {
|
||||||
if (!detail) return null;
|
detail: string;
|
||||||
return <pre className="agent-log-tool-detail">{detail}</pre>;
|
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 {
|
function shouldShowBadge(entry: AgentLogEntry, previousEntry?: AgentLogEntry): boolean {
|
||||||
@@ -493,7 +525,7 @@ export function AgentLogViewer({
|
|||||||
<div key={group.key} className="agent-log-tool">
|
<div key={group.key} className="agent-log-tool">
|
||||||
{agentBadge}
|
{agentBadge}
|
||||||
<div className="agent-log-tool-title">⚡ {entry.text}</div>
|
<div className="agent-log-tool-title">⚡ {entry.text}</div>
|
||||||
{renderToolDetail(entry.detail)}
|
{entry.detail ? <CollapsibleToolDetail detail={entry.detail} type="tool" /> : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -503,7 +535,7 @@ export function AgentLogViewer({
|
|||||||
<div key={group.key} className="agent-log-tool-result">
|
<div key={group.key} className="agent-log-tool-result">
|
||||||
{agentBadge}
|
{agentBadge}
|
||||||
<div className="agent-log-tool-title">✓ {entry.text}</div>
|
<div className="agent-log-tool-title">✓ {entry.text}</div>
|
||||||
{renderToolDetail(entry.detail)}
|
{entry.detail ? <CollapsibleToolDetail detail={entry.detail} type="tool_result" /> : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -513,7 +545,7 @@ export function AgentLogViewer({
|
|||||||
<div key={group.key} className="agent-log-tool-error">
|
<div key={group.key} className="agent-log-tool-error">
|
||||||
{agentBadge}
|
{agentBadge}
|
||||||
<div className="agent-log-tool-title">✗ {entry.text}</div>
|
<div className="agent-log-tool-title">✗ {entry.text}</div>
|
||||||
{renderToolDetail(entry.detail)}
|
{entry.detail ? <CollapsibleToolDetail detail={entry.detail} type="tool_error" /> : null}
|
||||||
</div>
|
</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 = [
|
const entries = [
|
||||||
makeEntry({ text: "Bash", type: "tool", detail: "ls -la packages/" }),
|
makeEntry({ text: "Bash", type: "tool", detail: "ls -la packages/" }),
|
||||||
];
|
];
|
||||||
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
|
render(<AgentLogViewer entries={entries} loading={false} />);
|
||||||
const detail = container.querySelector(".agent-log-tool-detail");
|
|
||||||
expect(detail).toBeTruthy();
|
const toggle = screen.getByTestId("tool-detail-toggle");
|
||||||
expect(detail!.textContent).toContain("ls -la packages/");
|
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 = [
|
const entries = [
|
||||||
makeEntry({ text: "Bash", type: "tool" }),
|
makeEntry({ text: "Bash", type: "tool" }),
|
||||||
];
|
];
|
||||||
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
|
render(<AgentLogViewer entries={entries} loading={false} />);
|
||||||
const detail = container.querySelector(".agent-log-tool-detail");
|
expect(screen.queryByTestId("tool-detail-toggle")).toBeNull();
|
||||||
expect(detail).toBeNull();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders long detail text without breaking layout", () => {
|
it("renders long detail text without breaking layout", () => {
|
||||||
@@ -250,6 +252,7 @@ describe("AgentLogViewer", () => {
|
|||||||
makeEntry({ text: "Read", type: "tool", detail: longDetail }),
|
makeEntry({ text: "Read", type: "tool", detail: longDetail }),
|
||||||
];
|
];
|
||||||
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
|
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
|
||||||
|
fireEvent.click(screen.getByTestId("tool-detail-toggle"));
|
||||||
const detail = container.querySelector(".agent-log-tool-detail");
|
const detail = container.querySelector(".agent-log-tool-detail");
|
||||||
expect(detail).toBeTruthy();
|
expect(detail).toBeTruthy();
|
||||||
expect(detail!.textContent).toContain(longDetail);
|
expect(detail!.textContent).toContain(longDetail);
|
||||||
@@ -258,6 +261,41 @@ describe("AgentLogViewer", () => {
|
|||||||
expect(toolDiv).toBeTruthy();
|
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", () => {
|
it("applies the viewer styling via the agent-log-viewer class", () => {
|
||||||
const entries = [makeEntry()];
|
const entries = [makeEntry()];
|
||||||
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
|
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
|
||||||
@@ -1120,6 +1158,7 @@ describe("AgentLogViewer", () => {
|
|||||||
const longDetail = "B".repeat(5000);
|
const longDetail = "B".repeat(5000);
|
||||||
const entries = [makeEntry({ text: "Read", type: "tool", detail: longDetail })];
|
const entries = [makeEntry({ text: "Read", type: "tool", detail: longDetail })];
|
||||||
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
|
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
|
||||||
|
fireEvent.click(screen.getByTestId("tool-detail-toggle"));
|
||||||
const detail = container.querySelector(".agent-log-tool-detail");
|
const detail = container.querySelector(".agent-log-tool-detail");
|
||||||
expect(detail).toBeTruthy();
|
expect(detail).toBeTruthy();
|
||||||
expect(detail!.textContent).toContain(longDetail);
|
expect(detail!.textContent).toContain(longDetail);
|
||||||
@@ -1155,6 +1194,7 @@ describe("AgentLogViewer", () => {
|
|||||||
const longDetail = "D".repeat(5000);
|
const longDetail = "D".repeat(5000);
|
||||||
const entries = [makeEntry({ text: "Bash", type: "tool_result", detail: longDetail })];
|
const entries = [makeEntry({ text: "Bash", type: "tool_result", detail: longDetail })];
|
||||||
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
|
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
|
||||||
|
fireEvent.click(screen.getByTestId("tool-detail-toggle"));
|
||||||
const detail = container.querySelector(".agent-log-tool-detail");
|
const detail = container.querySelector(".agent-log-tool-detail");
|
||||||
expect(detail).toBeTruthy();
|
expect(detail).toBeTruthy();
|
||||||
expect(detail!.textContent).toContain(longDetail);
|
expect(detail!.textContent).toContain(longDetail);
|
||||||
@@ -1164,6 +1204,7 @@ describe("AgentLogViewer", () => {
|
|||||||
const longDetail = "E".repeat(5000);
|
const longDetail = "E".repeat(5000);
|
||||||
const entries = [makeEntry({ text: "Write", type: "tool_error", detail: longDetail })];
|
const entries = [makeEntry({ text: "Write", type: "tool_error", detail: longDetail })];
|
||||||
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
|
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
|
||||||
|
fireEvent.click(screen.getByTestId("tool-detail-toggle"));
|
||||||
const detail = container.querySelector(".agent-log-tool-detail");
|
const detail = container.querySelector(".agent-log-tool-detail");
|
||||||
expect(detail).toBeTruthy();
|
expect(detail).toBeTruthy();
|
||||||
expect(detail!.textContent).toContain(longDetail);
|
expect(detail!.textContent).toContain(longDetail);
|
||||||
@@ -1173,6 +1214,7 @@ describe("AgentLogViewer", () => {
|
|||||||
const detailText = "stdout:\n line one\n indented line two\n";
|
const detailText = "stdout:\n line one\n indented line two\n";
|
||||||
const entries = [makeEntry({ text: "Bash", type: "tool_result", detail: detailText })];
|
const entries = [makeEntry({ text: "Bash", type: "tool_result", detail: detailText })];
|
||||||
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
|
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;
|
const detail = container.querySelector(".agent-log-tool-detail") as HTMLElement;
|
||||||
expect(detail).toBeTruthy();
|
expect(detail).toBeTruthy();
|
||||||
expect(detail.tagName).toBe("PRE");
|
expect(detail.tagName).toBe("PRE");
|
||||||
|
|||||||
Reference in New Issue
Block a user