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:
Fusion
2026-05-01 17:43:10 -07:00
committed by gsxdsm
parent 4d13ba01ea
commit 6da8a4e650
3 changed files with 128 additions and 16 deletions

View File

@@ -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));
}
} }

View File

@@ -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>
); );
} }

View File

@@ -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");