feat(FN-2619): merge fusion/fn-2619

This commit is contained in:
gsxdsm
2026-04-26 17:56:09 -07:00
parent 8a6a5ea5eb
commit 4e159cd028
3 changed files with 211 additions and 131 deletions

View File

@@ -268,10 +268,8 @@ export function AgentLogViewer({
return ( return (
<div <div
ref={containerRef}
className={`agent-log-viewer agent-log-viewer--streaming${isFullscreen ? " agent-log-viewer--fullscreen" : ""}`} className={`agent-log-viewer agent-log-viewer--streaming${isFullscreen ? " agent-log-viewer--fullscreen" : ""}`}
data-testid="agent-log-viewer" data-testid="agent-log-viewer"
onScroll={handleScroll}
> >
{/* Model info header */} {/* Model info header */}
<div className="agent-log-model-header" data-testid="agent-log-model-header"> <div className="agent-log-model-header" data-testid="agent-log-model-header">
@@ -357,66 +355,106 @@ export function AgentLogViewer({
)} )}
</div> </div>
{/* Pagination summary */} <div
{totalCount !== null && ( ref={containerRef}
<div className="agent-log-summary" data-testid="agent-log-summary"> className="agent-log-viewer-scroll"
Showing {entries.length} of {totalCount} entries onScroll={handleScroll}
</div> >
)} {/* Pagination summary */}
{totalCount !== null && (
<div className="agent-log-summary" data-testid="agent-log-summary">
Showing {entries.length} of {totalCount} entries
</div>
)}
{hasMore && onLoadMore && ( {hasMore && onLoadMore && (
<div className="agent-log-load-more" data-testid="agent-log-load-more"> <div className="agent-log-load-more" data-testid="agent-log-load-more">
<button <button
className="agent-log-mode-toggle" className="agent-log-mode-toggle"
onClick={onLoadMore} onClick={onLoadMore}
disabled={loadingMore} disabled={loadingMore}
data-testid="agent-log-load-more-button" data-testid="agent-log-load-more-button"
> >
{loadingMore ? ( {loadingMore ? (
<> <>
<Loader2 size={14} className="animate-spin" /> <Loader2 size={14} className="animate-spin" />
Loading Loading
</> </>
) : ( ) : (
"Load More" "Load More"
)} )}
</button> </button>
</div> </div>
)} )}
{entries.map((entry, i) => { {entries.map((entry, i) => {
const rowKey = chronologicalEntryKeys[i] ?? `${getEntrySignature(entry)}|fallback`; const rowKey = chronologicalEntryKeys[i] ?? `${getEntrySignature(entry)}|fallback`;
const prev = entries[i - 1]; const prev = entries[i - 1];
const isBlockLevel = entry.type === "tool" || entry.type === "tool_result" || entry.type === "tool_error"; const isBlockLevel = entry.type === "tool" || entry.type === "tool_result" || entry.type === "tool_error";
const showBadge = entry.agent const showBadge = entry.agent
? isBlockLevel || !prev || prev.agent !== entry.agent || prev.type !== entry.type ? isBlockLevel || !prev || prev.agent !== entry.agent || prev.type !== entry.type
: false; : false;
const timestampSpan = showBadge ? ( const timestampSpan = showBadge ? (
<span className="agent-log-timestamp" data-testid="agent-log-timestamp"> <span className="agent-log-timestamp" data-testid="agent-log-timestamp">
{formatTimestamp(entry.timestamp)} {formatTimestamp(entry.timestamp)}
</span> </span>
) : null; ) : null;
const agentBadge = showBadge ? ( const agentBadge = showBadge ? (
<span className="agent-log-badge-row"> <span className="agent-log-badge-row">
<span className="agent-log-agent-badge">[{entry.agent}]</span> <span className="agent-log-agent-badge">[{entry.agent}]</span>
{timestampSpan} {timestampSpan}
</span> </span>
) : null; ) : null;
if (entry.type === "tool") { if (entry.type === "tool") {
return (
<div key={rowKey} className="agent-log-tool">
{agentBadge} {entry.text}
{entry.detail && <span className="agent-log-tool-detail"> {entry.detail}</span>}
</div>
);
}
if (entry.type === "thinking") {
return (
<div key={rowKey} className="agent-log-thinking">
{agentBadge}
{renderMarkdown ? (
<div className="markdown-body">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{entry.text}
</ReactMarkdown>
</div>
) : (
entry.text
)}
</div>
);
}
if (entry.type === "tool_result") {
return (
<div key={rowKey} className="agent-log-tool-result">
{agentBadge} {entry.text}
{entry.detail && <span className="agent-log-tool-detail"> {entry.detail}</span>}
</div>
);
}
if (entry.type === "tool_error") {
return (
<div key={rowKey} className="agent-log-tool-error">
{agentBadge} {entry.text}
{entry.detail && <span className="agent-log-tool-detail"> {entry.detail}</span>}
</div>
);
}
// Default: text entries
return ( return (
<div key={rowKey} className="agent-log-tool"> <div key={rowKey} className="agent-log-text">
{agentBadge} {entry.text}
{entry.detail && <span className="agent-log-tool-detail"> {entry.detail}</span>}
</div>
);
}
if (entry.type === "thinking") {
return (
<div key={rowKey} className="agent-log-thinking">
{agentBadge} {agentBadge}
{renderMarkdown ? ( {renderMarkdown ? (
<div className="markdown-body"> <div className="markdown-body">
@@ -429,54 +467,20 @@ export function AgentLogViewer({
)} )}
</div> </div>
); );
} })}
if (entry.type === "tool_result") { {!isFollowing && (
return ( <button
<div key={rowKey} className="agent-log-tool-result"> type="button"
{agentBadge} {entry.text} className="agent-log-return-to-live"
{entry.detail && <span className="agent-log-tool-detail"> {entry.detail}</span>} onClick={scrollToLive}
</div> data-testid="agent-log-return-to-live"
); >
} <ChevronDown size={12} />
<span>Live</span>
if (entry.type === "tool_error") { </button>
return ( )}
<div key={rowKey} className="agent-log-tool-error"> </div>
{agentBadge} {entry.text}
{entry.detail && <span className="agent-log-tool-detail"> {entry.detail}</span>}
</div>
);
}
// Default: text entries
return (
<div key={rowKey} className="agent-log-text">
{agentBadge}
{renderMarkdown ? (
<div className="markdown-body">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{entry.text}
</ReactMarkdown>
</div>
) : (
entry.text
)}
</div>
);
})}
{!isFollowing && (
<button
type="button"
className="agent-log-return-to-live"
onClick={scrollToLive}
data-testid="agent-log-return-to-live"
>
<ChevronDown size={12} />
<span>Live</span>
</button>
)}
</div> </div>
); );
} }

View File

@@ -232,6 +232,16 @@
} }
.detail-section--agent-log .agent-log-viewer { .detail-section--agent-log .agent-log-viewer {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
background: var(--bg-secondary);
border-radius: var(--radius-sm);
overflow: hidden;
}
.agent-log-viewer-scroll {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
overflow-y: auto; overflow-y: auto;
@@ -241,8 +251,6 @@
line-height: 1.5; line-height: 1.5;
color: var(--text); color: var(--text);
padding: var(--space-md); padding: var(--space-md);
background: var(--bg-secondary);
border-radius: var(--radius-sm);
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-word; word-break: break-word;
overflow-wrap: break-word; overflow-wrap: break-word;
@@ -474,20 +482,23 @@
position: fixed; position: fixed;
inset: 0; inset: 0;
z-index: 10000; z-index: 10000;
display: flex;
flex-direction: column;
min-height: 0;
background: var(--surface); background: var(--surface);
overflow-y: auto;
overflow-x: hidden;
padding: var(--space-lg); padding: var(--space-lg);
} }
.agent-log-viewer--fullscreen .agent-log-model-header { .agent-log-viewer--fullscreen .agent-log-model-header {
position: sticky; margin-bottom: 0;
top: 0;
background: var(--surface); background: var(--surface);
z-index: 1; border: 1px solid var(--border);
padding-bottom: var(--space-md); }
margin-bottom: var(--space-md);
border-bottom: 1px solid var(--border); .agent-log-viewer--fullscreen .agent-log-viewer-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
} }
.detail-spec-edit-trigger { .detail-spec-edit-trigger {

View File

@@ -25,6 +25,10 @@ function makeEntry(overrides: Partial<AgentLogEntry> = {}): AgentLogEntry {
}; };
} }
function getScrollContainer(container: HTMLElement): HTMLDivElement {
return container.querySelector(".agent-log-viewer-scroll") as HTMLDivElement;
}
describe("AgentLogViewer", () => { describe("AgentLogViewer", () => {
it("shows loading message when loading with no entries", () => { it("shows loading message when loading with no entries", () => {
render(<AgentLogViewer entries={[]} loading={true} />); render(<AgentLogViewer entries={[]} loading={true} />);
@@ -736,21 +740,21 @@ describe("AgentLogViewer", () => {
}); });
describe("horizontal overflow prevention", () => { describe("horizontal overflow prevention", () => {
it("uses the viewer class for overflow-x handling", () => { it("uses the scroll container class for overflow-x handling", () => {
const longString = "A".repeat(300); const longString = "A".repeat(300);
const entries = [makeEntry({ text: longString })]; const entries = [makeEntry({ text: longString })];
const { container } = render(<AgentLogViewer entries={entries} loading={false} />); const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
const viewer = container.querySelector("[data-testid='agent-log-viewer']") as HTMLElement; const scrollContainer = getScrollContainer(container);
expect(viewer.classList.contains("agent-log-viewer")).toBe(true); expect(scrollContainer.classList.contains("agent-log-viewer-scroll")).toBe(true);
expect(viewer.style.overflowX).toBe(""); expect(scrollContainer.style.overflowX).toBe("");
}); });
it("uses the viewer class for overflow-wrap handling", () => { it("uses the scroll container class for overflow-wrap handling", () => {
const entries = [makeEntry({ text: "x".repeat(250) })]; const entries = [makeEntry({ text: "x".repeat(250) })];
const { container } = render(<AgentLogViewer entries={entries} loading={false} />); const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
const viewer = container.querySelector("[data-testid='agent-log-viewer']") as HTMLElement; const scrollContainer = getScrollContainer(container);
expect(viewer.classList.contains("agent-log-viewer")).toBe(true); expect(scrollContainer.classList.contains("agent-log-viewer-scroll")).toBe(true);
expect(viewer.style.overflowWrap).toBe(""); expect(scrollContainer.style.overflowWrap).toBe("");
}); });
it("renders pre elements with overflow-x auto for internal scrolling", () => { it("renders pre elements with overflow-x auto for internal scrolling", () => {
@@ -783,13 +787,13 @@ describe("AgentLogViewer", () => {
expect(viewer.style.maxHeight).toBe(""); expect(viewer.style.maxHeight).toBe("");
}); });
it("uses class-based overflow-y scrolling", () => { it("uses class-based overflow-y scrolling on the entries container", () => {
const entries = [makeEntry()]; const entries = [makeEntry()];
const { container } = render(<AgentLogViewer entries={entries} loading={false} />); const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
const viewer = container.querySelector("[data-testid='agent-log-viewer']") as HTMLElement; const scrollContainer = getScrollContainer(container);
// Scrolling behavior is now defined in CSS. // Scrolling behavior is now defined in CSS.
expect(viewer.classList.contains("agent-log-viewer")).toBe(true); expect(scrollContainer.classList.contains("agent-log-viewer-scroll")).toBe(true);
expect(viewer.style.overflowY).toBe(""); expect(scrollContainer.style.overflowY).toBe("");
}); });
it("uses agent-log-viewer--streaming class when entries are present", () => { it("uses agent-log-viewer--streaming class when entries are present", () => {
@@ -808,6 +812,67 @@ describe("AgentLogViewer", () => {
}); });
}); });
describe("sticky header layout", () => {
it("renders the model header as a sibling of the scroll container", () => {
const entries = [makeEntry()];
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
const viewer = container.querySelector("[data-testid='agent-log-viewer']") as HTMLElement;
const header = screen.getByTestId("agent-log-model-header");
const scrollContainer = getScrollContainer(container);
expect(header.parentElement).toBe(viewer);
expect(scrollContainer.parentElement).toBe(viewer);
expect(scrollContainer.contains(header)).toBe(false);
});
it("renders log entry rows inside the scroll container", () => {
const entries = [
makeEntry({ type: "text", text: "hello" }),
makeEntry({ type: "tool", text: "Bash" }),
];
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
const scrollContainer = getScrollContainer(container);
expect(scrollContainer.querySelector(".agent-log-text")).toBeTruthy();
expect(scrollContainer.querySelector(".agent-log-tool")).toBeTruthy();
});
it("renders pagination summary and load-more controls inside the scroll container", () => {
const entries = [makeEntry({ text: "hello" })];
const { container } = render(
<AgentLogViewer
entries={entries}
loading={false}
totalCount={42}
hasMore={true}
onLoadMore={() => {}}
/>,
);
const scrollContainer = getScrollContainer(container);
expect(scrollContainer.querySelector("[data-testid='agent-log-summary']")).toBeTruthy();
expect(scrollContainer.querySelector("[data-testid='agent-log-load-more']")).toBeTruthy();
});
it("renders the return-to-live button inside the scroll container", () => {
const entries = [
makeEntry({ text: "first", timestamp: "2026-01-01T00:00:00Z" }),
makeEntry({ text: "second", timestamp: "2026-01-01T00:00:01Z" }),
];
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
const scrollContainer = getScrollContainer(container);
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1000 });
Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 200 });
scrollContainer.scrollTop = 300;
fireEvent.scroll(scrollContainer);
const returnToLive = screen.getByTestId("agent-log-return-to-live");
expect(returnToLive.parentElement).toBe(scrollContainer);
});
});
describe("auto-scroll behavior", () => { describe("auto-scroll behavior", () => {
it("scrolls to bottom when streaming updates arrive and user is near the bottom", () => { it("scrolls to bottom when streaming updates arrive and user is near the bottom", () => {
const initialEntries = [ const initialEntries = [
@@ -819,7 +884,7 @@ describe("AgentLogViewer", () => {
]; ];
const { rerender, container } = render(<AgentLogViewer entries={initialEntries} loading={false} />); const { rerender, container } = render(<AgentLogViewer entries={initialEntries} loading={false} />);
const viewer = container.querySelector("[data-testid='agent-log-viewer']") as HTMLDivElement; const viewer = getScrollContainer(container);
let scrollHeight = 600; let scrollHeight = 600;
Object.defineProperty(viewer, "scrollHeight", { Object.defineProperty(viewer, "scrollHeight", {
@@ -846,7 +911,7 @@ describe("AgentLogViewer", () => {
]; ];
const { rerender, container } = render(<AgentLogViewer entries={initialEntries} loading={false} />); const { rerender, container } = render(<AgentLogViewer entries={initialEntries} loading={false} />);
const viewer = container.querySelector("[data-testid='agent-log-viewer']") as HTMLDivElement; const viewer = getScrollContainer(container);
let scrollHeight = 1000; let scrollHeight = 1000;
Object.defineProperty(viewer, "scrollHeight", { Object.defineProperty(viewer, "scrollHeight", {
@@ -873,7 +938,7 @@ describe("AgentLogViewer", () => {
]; ];
const { rerender, container } = render(<AgentLogViewer entries={initialEntries} loading={false} />); const { rerender, container } = render(<AgentLogViewer entries={initialEntries} loading={false} />);
const viewer = container.querySelector("[data-testid='agent-log-viewer']") as HTMLDivElement; const viewer = getScrollContainer(container);
let scrollHeight = 900; let scrollHeight = 900;
Object.defineProperty(viewer, "scrollHeight", { Object.defineProperty(viewer, "scrollHeight", {
@@ -897,7 +962,7 @@ describe("AgentLogViewer", () => {
makeEntry({ text: "second", timestamp: "2026-01-01T00:00:01Z" }), makeEntry({ text: "second", timestamp: "2026-01-01T00:00:01Z" }),
]; ];
const { container } = render(<AgentLogViewer entries={entries} loading={false} />); const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
const viewer = container.querySelector("[data-testid='agent-log-viewer']") as HTMLDivElement; const viewer = getScrollContainer(container);
Object.defineProperty(viewer, "scrollHeight", { configurable: true, value: 1000 }); Object.defineProperty(viewer, "scrollHeight", { configurable: true, value: 1000 });
Object.defineProperty(viewer, "clientHeight", { configurable: true, value: 200 }); Object.defineProperty(viewer, "clientHeight", { configurable: true, value: 200 });
@@ -914,7 +979,7 @@ describe("AgentLogViewer", () => {
makeEntry({ text: "second", timestamp: "2026-01-01T00:00:01Z" }), makeEntry({ text: "second", timestamp: "2026-01-01T00:00:01Z" }),
]; ];
const { container } = render(<AgentLogViewer entries={entries} loading={false} />); const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
const viewer = container.querySelector("[data-testid='agent-log-viewer']") as HTMLDivElement; const viewer = getScrollContainer(container);
Object.defineProperty(viewer, "scrollHeight", { configurable: true, value: 1000 }); Object.defineProperty(viewer, "scrollHeight", { configurable: true, value: 1000 });
Object.defineProperty(viewer, "clientHeight", { configurable: true, value: 200 }); Object.defineProperty(viewer, "clientHeight", { configurable: true, value: 200 });
@@ -931,7 +996,7 @@ describe("AgentLogViewer", () => {
makeEntry({ text: "second", timestamp: "2026-01-01T00:00:01Z" }), makeEntry({ text: "second", timestamp: "2026-01-01T00:00:01Z" }),
]; ];
const { container } = render(<AgentLogViewer entries={entries} loading={false} />); const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
const viewer = container.querySelector("[data-testid='agent-log-viewer']") as HTMLDivElement; const viewer = getScrollContainer(container);
Object.defineProperty(viewer, "scrollHeight", { configurable: true, value: 1000 }); Object.defineProperty(viewer, "scrollHeight", { configurable: true, value: 1000 });
Object.defineProperty(viewer, "clientHeight", { configurable: true, value: 200 }); Object.defineProperty(viewer, "clientHeight", { configurable: true, value: 200 });