diff --git a/packages/dashboard/app/components/AgentLogViewer.tsx b/packages/dashboard/app/components/AgentLogViewer.tsx index 932ef682c..a1f1577de 100644 --- a/packages/dashboard/app/components/AgentLogViewer.tsx +++ b/packages/dashboard/app/components/AgentLogViewer.tsx @@ -268,10 +268,8 @@ export function AgentLogViewer({ return (
{/* Model info header */}
@@ -357,66 +355,106 @@ export function AgentLogViewer({ )}
- {/* Pagination summary */} - {totalCount !== null && ( -
- Showing {entries.length} of {totalCount} entries -
- )} +
+ {/* Pagination summary */} + {totalCount !== null && ( +
+ Showing {entries.length} of {totalCount} entries +
+ )} - {hasMore && onLoadMore && ( -
- -
- )} + {hasMore && onLoadMore && ( +
+ +
+ )} - {entries.map((entry, i) => { - const rowKey = chronologicalEntryKeys[i] ?? `${getEntrySignature(entry)}|fallback`; - const prev = entries[i - 1]; - const isBlockLevel = entry.type === "tool" || entry.type === "tool_result" || entry.type === "tool_error"; - const showBadge = entry.agent - ? isBlockLevel || !prev || prev.agent !== entry.agent || prev.type !== entry.type - : false; + {entries.map((entry, i) => { + const rowKey = chronologicalEntryKeys[i] ?? `${getEntrySignature(entry)}|fallback`; + const prev = entries[i - 1]; + const isBlockLevel = entry.type === "tool" || entry.type === "tool_result" || entry.type === "tool_error"; + const showBadge = entry.agent + ? isBlockLevel || !prev || prev.agent !== entry.agent || prev.type !== entry.type + : false; - const timestampSpan = showBadge ? ( - - {formatTimestamp(entry.timestamp)} - - ) : null; + const timestampSpan = showBadge ? ( + + {formatTimestamp(entry.timestamp)} + + ) : null; - const agentBadge = showBadge ? ( - - [{entry.agent}] - {timestampSpan} - - ) : null; + const agentBadge = showBadge ? ( + + [{entry.agent}] + {timestampSpan} + + ) : null; - if (entry.type === "tool") { + if (entry.type === "tool") { + return ( +
+ {agentBadge}⚡ {entry.text} + {entry.detail && — {entry.detail}} +
+ ); + } + + if (entry.type === "thinking") { + return ( +
+ {agentBadge} + {renderMarkdown ? ( +
+ + {entry.text} + +
+ ) : ( + entry.text + )} +
+ ); + } + + if (entry.type === "tool_result") { + return ( +
+ {agentBadge}✓ {entry.text} + {entry.detail && — {entry.detail}} +
+ ); + } + + if (entry.type === "tool_error") { + return ( +
+ {agentBadge}✗ {entry.text} + {entry.detail && — {entry.detail}} +
+ ); + } + + // Default: text entries return ( -
- {agentBadge}⚡ {entry.text} - {entry.detail && — {entry.detail}} -
- ); - } - - if (entry.type === "thinking") { - return ( -
+
{agentBadge} {renderMarkdown ? (
@@ -429,54 +467,20 @@ export function AgentLogViewer({ )}
); - } + })} - if (entry.type === "tool_result") { - return ( -
- {agentBadge}✓ {entry.text} - {entry.detail && — {entry.detail}} -
- ); - } - - if (entry.type === "tool_error") { - return ( -
- {agentBadge}✗ {entry.text} - {entry.detail && — {entry.detail}} -
- ); - } - - // Default: text entries - return ( -
- {agentBadge} - {renderMarkdown ? ( -
- - {entry.text} - -
- ) : ( - entry.text - )} -
- ); - })} - - {!isFollowing && ( - - )} + {!isFollowing && ( + + )} +
); } diff --git a/packages/dashboard/app/components/TaskDetailModal.css b/packages/dashboard/app/components/TaskDetailModal.css index d990ca935..8d82b38d2 100644 --- a/packages/dashboard/app/components/TaskDetailModal.css +++ b/packages/dashboard/app/components/TaskDetailModal.css @@ -232,6 +232,16 @@ } .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; min-height: 0; overflow-y: auto; @@ -241,8 +251,6 @@ line-height: 1.5; color: var(--text); padding: var(--space-md); - background: var(--bg-secondary); - border-radius: var(--radius-sm); white-space: pre-wrap; word-break: break-word; overflow-wrap: break-word; @@ -474,20 +482,23 @@ position: fixed; inset: 0; z-index: 10000; + display: flex; + flex-direction: column; + min-height: 0; background: var(--surface); - overflow-y: auto; - overflow-x: hidden; padding: var(--space-lg); } .agent-log-viewer--fullscreen .agent-log-model-header { - position: sticky; - top: 0; + margin-bottom: 0; background: var(--surface); - z-index: 1; - padding-bottom: var(--space-md); - margin-bottom: var(--space-md); - border-bottom: 1px solid var(--border); + border: 1px solid var(--border); +} + +.agent-log-viewer--fullscreen .agent-log-viewer-scroll { + flex: 1; + min-height: 0; + overflow-y: auto; } .detail-spec-edit-trigger { diff --git a/packages/dashboard/app/components/__tests__/AgentLogViewer.test.tsx b/packages/dashboard/app/components/__tests__/AgentLogViewer.test.tsx index c5999fa5e..d23f52b9a 100644 --- a/packages/dashboard/app/components/__tests__/AgentLogViewer.test.tsx +++ b/packages/dashboard/app/components/__tests__/AgentLogViewer.test.tsx @@ -25,6 +25,10 @@ function makeEntry(overrides: Partial = {}): AgentLogEntry { }; } +function getScrollContainer(container: HTMLElement): HTMLDivElement { + return container.querySelector(".agent-log-viewer-scroll") as HTMLDivElement; +} + describe("AgentLogViewer", () => { it("shows loading message when loading with no entries", () => { render(); @@ -736,21 +740,21 @@ describe("AgentLogViewer", () => { }); 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 entries = [makeEntry({ text: longString })]; const { container } = render(); - const viewer = container.querySelector("[data-testid='agent-log-viewer']") as HTMLElement; - expect(viewer.classList.contains("agent-log-viewer")).toBe(true); - expect(viewer.style.overflowX).toBe(""); + const scrollContainer = getScrollContainer(container); + expect(scrollContainer.classList.contains("agent-log-viewer-scroll")).toBe(true); + 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 { container } = render(); - const viewer = container.querySelector("[data-testid='agent-log-viewer']") as HTMLElement; - expect(viewer.classList.contains("agent-log-viewer")).toBe(true); - expect(viewer.style.overflowWrap).toBe(""); + const scrollContainer = getScrollContainer(container); + expect(scrollContainer.classList.contains("agent-log-viewer-scroll")).toBe(true); + expect(scrollContainer.style.overflowWrap).toBe(""); }); it("renders pre elements with overflow-x auto for internal scrolling", () => { @@ -783,13 +787,13 @@ describe("AgentLogViewer", () => { 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 { container } = render(); - const viewer = container.querySelector("[data-testid='agent-log-viewer']") as HTMLElement; + const scrollContainer = getScrollContainer(container); // Scrolling behavior is now defined in CSS. - expect(viewer.classList.contains("agent-log-viewer")).toBe(true); - expect(viewer.style.overflowY).toBe(""); + expect(scrollContainer.classList.contains("agent-log-viewer-scroll")).toBe(true); + expect(scrollContainer.style.overflowY).toBe(""); }); 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(); + 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(); + 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( + {}} + />, + ); + 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(); + 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", () => { it("scrolls to bottom when streaming updates arrive and user is near the bottom", () => { const initialEntries = [ @@ -819,7 +884,7 @@ describe("AgentLogViewer", () => { ]; const { rerender, container } = render(); - const viewer = container.querySelector("[data-testid='agent-log-viewer']") as HTMLDivElement; + const viewer = getScrollContainer(container); let scrollHeight = 600; Object.defineProperty(viewer, "scrollHeight", { @@ -846,7 +911,7 @@ describe("AgentLogViewer", () => { ]; const { rerender, container } = render(); - const viewer = container.querySelector("[data-testid='agent-log-viewer']") as HTMLDivElement; + const viewer = getScrollContainer(container); let scrollHeight = 1000; Object.defineProperty(viewer, "scrollHeight", { @@ -873,7 +938,7 @@ describe("AgentLogViewer", () => { ]; const { rerender, container } = render(); - const viewer = container.querySelector("[data-testid='agent-log-viewer']") as HTMLDivElement; + const viewer = getScrollContainer(container); let scrollHeight = 900; Object.defineProperty(viewer, "scrollHeight", { @@ -897,7 +962,7 @@ describe("AgentLogViewer", () => { makeEntry({ text: "second", timestamp: "2026-01-01T00:00:01Z" }), ]; const { container } = render(); - 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, "clientHeight", { configurable: true, value: 200 }); @@ -914,7 +979,7 @@ describe("AgentLogViewer", () => { makeEntry({ text: "second", timestamp: "2026-01-01T00:00:01Z" }), ]; const { container } = render(); - 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, "clientHeight", { configurable: true, value: 200 }); @@ -931,7 +996,7 @@ describe("AgentLogViewer", () => { makeEntry({ text: "second", timestamp: "2026-01-01T00:00:01Z" }), ]; const { container } = render(); - 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, "clientHeight", { configurable: true, value: 200 });