+ {/* 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 });