feat(FN-2600): merge fusion/fn-2600 (auto-resolved)
- feat(FN-2600): finalize return-to-live button styling - test(FN-2600): cover return-to-live and top load-more placement - feat(FN-2600): add live-follow state and return-to-live control - fix(FN-2600): clean chronological log documentation and test wording - test(FN-2600): align log viewer assertions to chronological rendering - fix(FN-2600): correct chronological badge transition detection - feat(FN-2600): complete Step 1 — reverse log rendering chronology
This commit is contained in:
@@ -49,7 +49,11 @@ const markdownComponents: Components = {
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
const TOP_FOLLOW_THRESHOLD_PX = 50;
|
const BOTTOM_FOLLOW_THRESHOLD_PX = 50;
|
||||||
|
|
||||||
|
function isNearBottom(container: HTMLDivElement): boolean {
|
||||||
|
return container.scrollHeight - (container.scrollTop + container.clientHeight) <= BOTTOM_FOLLOW_THRESHOLD_PX;
|
||||||
|
}
|
||||||
|
|
||||||
function getEntrySignature(entry: AgentLogEntry): string {
|
function getEntrySignature(entry: AgentLogEntry): string {
|
||||||
return [
|
return [
|
||||||
@@ -97,7 +101,7 @@ interface AgentLogViewerProps {
|
|||||||
* Renders agent log entries in a scrollable, monospace container.
|
* Renders agent log entries in a scrollable, monospace container.
|
||||||
*
|
*
|
||||||
* Features:
|
* Features:
|
||||||
* - Displays entries in reverse chronological order (newest first)
|
* - Displays entries in chronological order (oldest first, newest last)
|
||||||
* - Auto-scrolls to keep latest entries visible when streaming
|
* - Auto-scrolls to keep latest entries visible when streaming
|
||||||
* - Supports toggling between markdown-formatted and plain-text rendering
|
* - Supports toggling between markdown-formatted and plain-text rendering
|
||||||
* - "Load More" button to fetch older entries when pagination is enabled
|
* - "Load More" button to fetch older entries when pagination is enabled
|
||||||
@@ -124,38 +128,47 @@ export function AgentLogViewer({
|
|||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const previousEntryCountRef = useRef<number>(0);
|
const previousEntryCountRef = useRef<number>(0);
|
||||||
const previousScrollHeightRef = useRef<number>(0);
|
const previousScrollHeightRef = useRef<number>(0);
|
||||||
|
const previousOldestEntryKeyRef = useRef<string | null>(null);
|
||||||
const previousNewestEntryKeyRef = useRef<string | null>(null);
|
const previousNewestEntryKeyRef = useRef<string | null>(null);
|
||||||
const [renderMarkdown, setRenderMarkdown] = useState(true);
|
const [renderMarkdown, setRenderMarkdown] = useState(true);
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
const [modelHeaderExpanded, setModelHeaderExpanded] = useState(false);
|
const [modelHeaderExpanded, setModelHeaderExpanded] = useState(false);
|
||||||
|
const [isFollowing, setIsFollowing] = useState(true);
|
||||||
|
|
||||||
const chronologicalEntryKeys = useMemo(
|
const chronologicalEntryKeys = useMemo(
|
||||||
() => buildEntryRenderKeys(entries),
|
() => buildEntryRenderKeys(entries),
|
||||||
[entries],
|
[entries],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Newest entries render first. When streaming prepends content while the reader is away
|
// Keep live-follow pinned to the bottom when new streamed entries append.
|
||||||
// from the top, keep the viewport anchored by offsetting scrollTop with the added height.
|
// When older history is prepended (load more), preserve viewport position.
|
||||||
// Near the top, preserve live-follow behavior by snapping back to the latest output.
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
const newEntryCount = entries.length;
|
const newEntryCount = entries.length;
|
||||||
const previousCount = previousEntryCountRef.current;
|
const previousCount = previousEntryCountRef.current;
|
||||||
const previousScrollHeight = previousScrollHeightRef.current;
|
const previousScrollHeight = previousScrollHeightRef.current || container.scrollHeight;
|
||||||
|
const oldestEntryKey = chronologicalEntryKeys[0] ?? null;
|
||||||
const newestEntryKey = chronologicalEntryKeys[chronologicalEntryKeys.length - 1] ?? null;
|
const newestEntryKey = chronologicalEntryKeys[chronologicalEntryKeys.length - 1] ?? null;
|
||||||
|
const oldestEntryChanged = previousOldestEntryKeyRef.current !== oldestEntryKey;
|
||||||
const newestEntryChanged = previousNewestEntryKeyRef.current !== newestEntryKey;
|
const newestEntryChanged = previousNewestEntryKeyRef.current !== newestEntryKey;
|
||||||
|
|
||||||
// Only adjust scroll for streaming updates (which append to chronological data
|
|
||||||
// and therefore prepend in this reversed viewer).
|
|
||||||
if (newEntryCount > previousCount) {
|
if (newEntryCount > previousCount) {
|
||||||
const isNearTop = container.scrollTop <= TOP_FOLLOW_THRESHOLD_PX;
|
if (previousCount === 0) {
|
||||||
|
container.scrollTop = container.scrollHeight;
|
||||||
|
} else {
|
||||||
|
const wasNearBottom =
|
||||||
|
previousScrollHeight - (container.scrollTop + container.clientHeight) <=
|
||||||
|
BOTTOM_FOLLOW_THRESHOLD_PX;
|
||||||
|
const appendedLiveEntry = newestEntryChanged && !oldestEntryChanged;
|
||||||
|
const prependedOlderEntries = oldestEntryChanged && !newestEntryChanged;
|
||||||
|
|
||||||
if (newestEntryChanged) {
|
if (appendedLiveEntry && wasNearBottom) {
|
||||||
if (isNearTop) {
|
container.scrollTop = container.scrollHeight;
|
||||||
container.scrollTop = 0;
|
}
|
||||||
} else {
|
|
||||||
|
if (prependedOlderEntries) {
|
||||||
const heightDelta = container.scrollHeight - previousScrollHeight;
|
const heightDelta = container.scrollHeight - previousScrollHeight;
|
||||||
if (heightDelta > 0) {
|
if (heightDelta > 0) {
|
||||||
container.scrollTop += heightDelta;
|
container.scrollTop += heightDelta;
|
||||||
@@ -166,9 +179,24 @@ export function AgentLogViewer({
|
|||||||
|
|
||||||
previousEntryCountRef.current = newEntryCount;
|
previousEntryCountRef.current = newEntryCount;
|
||||||
previousScrollHeightRef.current = container.scrollHeight;
|
previousScrollHeightRef.current = container.scrollHeight;
|
||||||
|
previousOldestEntryKeyRef.current = oldestEntryKey;
|
||||||
previousNewestEntryKeyRef.current = newestEntryKey;
|
previousNewestEntryKeyRef.current = newestEntryKey;
|
||||||
|
setIsFollowing(isNearBottom(container));
|
||||||
}, [entries, chronologicalEntryKeys]);
|
}, [entries, chronologicalEntryKeys]);
|
||||||
|
|
||||||
|
const handleScroll = useCallback(() => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
setIsFollowing(isNearBottom(container));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const scrollToLive = useCallback(() => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
container.scrollTop = container.scrollHeight;
|
||||||
|
setIsFollowing(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Escape key handler to exit fullscreen mode
|
// Escape key handler to exit fullscreen mode
|
||||||
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
||||||
if (e.key === "Escape" && isFullscreen) {
|
if (e.key === "Escape" && isFullscreen) {
|
||||||
@@ -238,15 +266,12 @@ export function AgentLogViewer({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reverse entries so newest appear first
|
|
||||||
const reversedEntries = [...entries].reverse();
|
|
||||||
const reversedEntryKeys = [...chronologicalEntryKeys].reverse();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
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">
|
||||||
@@ -339,13 +364,32 @@ export function AgentLogViewer({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{reversedEntries.map((entry, i) => {
|
{hasMore && onLoadMore && (
|
||||||
const rowKey = reversedEntryKeys[i] ?? `${getEntrySignature(entry)}|fallback`;
|
<div className="agent-log-load-more" data-testid="agent-log-load-more">
|
||||||
// Look at previous entry in reversed array (= next chronologically) for deduplication
|
<button
|
||||||
const prev = reversedEntries[i - 1];
|
className="agent-log-mode-toggle"
|
||||||
|
onClick={onLoadMore}
|
||||||
|
disabled={loadingMore}
|
||||||
|
data-testid="agent-log-load-more-button"
|
||||||
|
>
|
||||||
|
{loadingMore ? (
|
||||||
|
<>
|
||||||
|
<Loader2 size={14} className="animate-spin" />
|
||||||
|
Loading…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Load More"
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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 isBlockLevel = entry.type === "tool" || entry.type === "tool_result" || entry.type === "tool_error";
|
||||||
const showBadge = entry.agent
|
const showBadge = entry.agent
|
||||||
? isBlockLevel || i === 0 || (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 ? (
|
||||||
@@ -422,25 +466,16 @@ export function AgentLogViewer({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Load More button */}
|
{!isFollowing && (
|
||||||
{hasMore && onLoadMore && (
|
<button
|
||||||
<div className="agent-log-load-more" data-testid="agent-log-load-more">
|
type="button"
|
||||||
<button
|
className="agent-log-return-to-live"
|
||||||
className="agent-log-mode-toggle"
|
onClick={scrollToLive}
|
||||||
onClick={onLoadMore}
|
data-testid="agent-log-return-to-live"
|
||||||
disabled={loadingMore}
|
>
|
||||||
data-testid="agent-log-load-more-button"
|
<ChevronDown size={12} />
|
||||||
>
|
<span>Live</span>
|
||||||
{loadingMore ? (
|
</button>
|
||||||
<>
|
|
||||||
<Loader2 size={14} className="animate-spin" />
|
|
||||||
Loading…
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
"Load More"
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -433,13 +433,42 @@
|
|||||||
.agent-log-load-more {
|
.agent-log-load-more {
|
||||||
padding: var(--space-md);
|
padding: var(--space-md);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
border-top: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.agent-log-load-more .agent-log-mode-toggle {
|
.agent-log-load-more .agent-log-mode-toggle {
|
||||||
min-width: 120px;
|
min-width: 120px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.agent-log-return-to-live {
|
||||||
|
position: sticky;
|
||||||
|
bottom: var(--space-md);
|
||||||
|
margin-left: auto;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
padding: var(--space-xs) var(--space-sm);
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-log-return-to-live:hover {
|
||||||
|
background: var(--card-hover);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-log-return-to-live:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: var(--focus-ring-strong);
|
||||||
|
}
|
||||||
|
|
||||||
/* Fullscreen mode for agent log viewer */
|
/* Fullscreen mode for agent log viewer */
|
||||||
.agent-log-viewer--fullscreen {
|
.agent-log-viewer--fullscreen {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -1480,4 +1509,8 @@
|
|||||||
min-width: 36px;
|
min-width: 36px;
|
||||||
min-height: 36px;
|
min-height: 36px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.agent-log-return-to-live {
|
||||||
|
bottom: var(--space-sm);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ describe("AgentLogViewer", () => {
|
|||||||
consoleErrorSpy.mockRestore();
|
consoleErrorSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders text entries as spans in reverse order (newest first)", () => {
|
it("renders text entries in chronological order (oldest first)", () => {
|
||||||
const entries = [
|
const entries = [
|
||||||
makeEntry({ text: "first chunk" }),
|
makeEntry({ text: "first chunk" }),
|
||||||
makeEntry({ text: "second chunk" }),
|
makeEntry({ text: "second chunk" }),
|
||||||
@@ -57,12 +57,11 @@ describe("AgentLogViewer", () => {
|
|||||||
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
|
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
|
||||||
const textSpans = container.querySelectorAll(".agent-log-text");
|
const textSpans = container.querySelectorAll(".agent-log-text");
|
||||||
expect(textSpans).toHaveLength(2);
|
expect(textSpans).toHaveLength(2);
|
||||||
// Reversed order: second chunk first, then first chunk
|
expect(textSpans[0].textContent).toContain("first chunk");
|
||||||
expect(textSpans[0].textContent).toContain("second chunk");
|
expect(textSpans[1].textContent).toContain("second chunk");
|
||||||
expect(textSpans[1].textContent).toContain("first chunk");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps existing DOM rows stable when a new live entry appears at the top", () => {
|
it("keeps existing DOM rows stable when a new live entry appears at the bottom", () => {
|
||||||
const initialEntries = [
|
const initialEntries = [
|
||||||
makeEntry({ text: "first chunk", timestamp: "2026-01-01T00:00:00Z" }),
|
makeEntry({ text: "first chunk", timestamp: "2026-01-01T00:00:00Z" }),
|
||||||
makeEntry({ text: "second chunk", timestamp: "2026-01-01T00:00:01Z" }),
|
makeEntry({ text: "second chunk", timestamp: "2026-01-01T00:00:01Z" }),
|
||||||
@@ -73,10 +72,10 @@ describe("AgentLogViewer", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const initialTextRows = container.querySelectorAll(".agent-log-text");
|
const initialTextRows = container.querySelectorAll(".agent-log-text");
|
||||||
const secondChunkNode = initialTextRows[0] as HTMLElement;
|
const firstChunkNode = initialTextRows[0] as HTMLElement;
|
||||||
const firstChunkNode = initialTextRows[1] as HTMLElement;
|
const secondChunkNode = initialTextRows[1] as HTMLElement;
|
||||||
expect(secondChunkNode.textContent).toContain("second chunk");
|
|
||||||
expect(firstChunkNode.textContent).toContain("first chunk");
|
expect(firstChunkNode.textContent).toContain("first chunk");
|
||||||
|
expect(secondChunkNode.textContent).toContain("second chunk");
|
||||||
|
|
||||||
const withLiveUpdate = [
|
const withLiveUpdate = [
|
||||||
...initialEntries,
|
...initialEntries,
|
||||||
@@ -87,11 +86,11 @@ describe("AgentLogViewer", () => {
|
|||||||
|
|
||||||
const updatedTextRows = container.querySelectorAll(".agent-log-text");
|
const updatedTextRows = container.querySelectorAll(".agent-log-text");
|
||||||
expect(updatedTextRows).toHaveLength(3);
|
expect(updatedTextRows).toHaveLength(3);
|
||||||
expect(updatedTextRows[0].textContent).toContain("third chunk");
|
expect(updatedTextRows[0].textContent).toContain("first chunk");
|
||||||
expect(updatedTextRows[1].textContent).toContain("second chunk");
|
expect(updatedTextRows[1].textContent).toContain("second chunk");
|
||||||
expect(updatedTextRows[2].textContent).toContain("first chunk");
|
expect(updatedTextRows[2].textContent).toContain("third chunk");
|
||||||
|
expect(updatedTextRows[0]).toBe(firstChunkNode);
|
||||||
expect(updatedTextRows[1]).toBe(secondChunkNode);
|
expect(updatedTextRows[1]).toBe(secondChunkNode);
|
||||||
expect(updatedTextRows[2]).toBe(firstChunkNode);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("avoids duplicate-key collisions when entries are exact duplicates", () => {
|
it("avoids duplicate-key collisions when entries are exact duplicates", () => {
|
||||||
@@ -136,18 +135,17 @@ describe("AgentLogViewer", () => {
|
|||||||
expect(toolDiv!.textContent).toContain("Read");
|
expect(toolDiv!.textContent).toContain("Read");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders a mix of text and tool entries in reverse order", () => {
|
it("renders a mix of text and tool entries in chronological order", () => {
|
||||||
const entries = [
|
const entries = [
|
||||||
makeEntry({ text: "Starting...", type: "text" }),
|
makeEntry({ text: "Starting...", type: "text" }),
|
||||||
makeEntry({ text: "Bash", type: "tool" }),
|
makeEntry({ text: "Bash", type: "tool" }),
|
||||||
makeEntry({ text: "Done!", type: "text" }),
|
makeEntry({ text: "Done!", type: "text" }),
|
||||||
];
|
];
|
||||||
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
|
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
|
||||||
// Reversed order: Done! (text), Bash (tool), Starting... (text)
|
|
||||||
const textSpans = container.querySelectorAll(".agent-log-text");
|
const textSpans = container.querySelectorAll(".agent-log-text");
|
||||||
expect(textSpans).toHaveLength(2);
|
expect(textSpans).toHaveLength(2);
|
||||||
expect(textSpans[0].textContent).toContain("Done!");
|
expect(textSpans[0].textContent).toContain("Starting...");
|
||||||
expect(textSpans[1].textContent).toContain("Starting...");
|
expect(textSpans[1].textContent).toContain("Done!");
|
||||||
|
|
||||||
const toolDivs = container.querySelectorAll(".agent-log-tool");
|
const toolDivs = container.querySelectorAll(".agent-log-tool");
|
||||||
expect(toolDivs).toHaveLength(1);
|
expect(toolDivs).toHaveLength(1);
|
||||||
@@ -196,7 +194,7 @@ describe("AgentLogViewer", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("agent badge deduplication", () => {
|
describe("agent badge deduplication", () => {
|
||||||
it("shows badge only on the first (newest) of consecutive text entries from the same agent", () => {
|
it("shows badge only on the first (oldest) of consecutive text entries from the same agent", () => {
|
||||||
const entries = [
|
const entries = [
|
||||||
makeEntry({ text: "chunk 1", type: "text", agent: "executor" }),
|
makeEntry({ text: "chunk 1", type: "text", agent: "executor" }),
|
||||||
makeEntry({ text: "chunk 2", type: "text", agent: "executor" }),
|
makeEntry({ text: "chunk 2", type: "text", agent: "executor" }),
|
||||||
@@ -205,11 +203,11 @@ describe("AgentLogViewer", () => {
|
|||||||
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
|
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
|
||||||
const badges = container.querySelectorAll(".agent-log-agent-badge");
|
const badges = container.querySelectorAll(".agent-log-agent-badge");
|
||||||
expect(badges).toHaveLength(1);
|
expect(badges).toHaveLength(1);
|
||||||
// In reversed order, the newest (chunk 3) gets the badge
|
// In chronological order, the oldest (chunk 1) gets the badge
|
||||||
expect(badges[0].textContent).toBe("[executor]");
|
expect(badges[0].textContent).toBe("[executor]");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows badge on each agent transition in reversed order", () => {
|
it("shows badge on each agent transition in chronological order", () => {
|
||||||
const entries = [
|
const entries = [
|
||||||
makeEntry({ text: "hello", type: "text", agent: "triage" }),
|
makeEntry({ text: "hello", type: "text", agent: "triage" }),
|
||||||
makeEntry({ text: "world", type: "text", agent: "triage" }),
|
makeEntry({ text: "world", type: "text", agent: "triage" }),
|
||||||
@@ -219,13 +217,11 @@ describe("AgentLogViewer", () => {
|
|||||||
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
|
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
|
||||||
const badges = container.querySelectorAll(".agent-log-agent-badge");
|
const badges = container.querySelectorAll(".agent-log-agent-badge");
|
||||||
expect(badges).toHaveLength(2);
|
expect(badges).toHaveLength(2);
|
||||||
// Reversed order: done (executor), starting (executor), world (triage), hello (triage)
|
expect(badges[0].textContent).toBe("[triage]");
|
||||||
// Badge on done (i=0) and world (transition from executor to triage)
|
expect(badges[1].textContent).toBe("[executor]");
|
||||||
expect(badges[0].textContent).toBe("[executor]");
|
|
||||||
expect(badges[1].textContent).toBe("[triage]");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows badge on text, tool, and text-after-tool (same agent, type change) in reversed order", () => {
|
it("shows badge on text, tool, and text-after-tool (same agent, type change) in chronological order", () => {
|
||||||
const entries = [
|
const entries = [
|
||||||
makeEntry({ text: "reading...", type: "text", agent: "executor" }),
|
makeEntry({ text: "reading...", type: "text", agent: "executor" }),
|
||||||
makeEntry({ text: "Read", type: "tool", agent: "executor" }),
|
makeEntry({ text: "Read", type: "tool", agent: "executor" }),
|
||||||
@@ -233,12 +229,12 @@ describe("AgentLogViewer", () => {
|
|||||||
];
|
];
|
||||||
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
|
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
|
||||||
const badges = container.querySelectorAll(".agent-log-agent-badge");
|
const badges = container.querySelectorAll(".agent-log-agent-badge");
|
||||||
// Reversed: got it (text), Read (tool), reading... (text)
|
// Chronological: reading... (text), Read (tool), got it (text)
|
||||||
// Badge on got it (i=0), Read (always block-level), reading... (type changed from tool)
|
// Badge on reading... (i=0), Read (always block-level), got it (type changed from tool)
|
||||||
expect(badges).toHaveLength(3);
|
expect(badges).toHaveLength(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows badge only on the first (newest) of consecutive thinking entries from the same agent", () => {
|
it("shows badge only on the first (oldest) of consecutive thinking entries from the same agent", () => {
|
||||||
const entries = [
|
const entries = [
|
||||||
makeEntry({ text: "hmm", type: "thinking", agent: "triage" }),
|
makeEntry({ text: "hmm", type: "thinking", agent: "triage" }),
|
||||||
makeEntry({ text: "let me think", type: "thinking", agent: "triage" }),
|
makeEntry({ text: "let me think", type: "thinking", agent: "triage" }),
|
||||||
@@ -247,7 +243,7 @@ describe("AgentLogViewer", () => {
|
|||||||
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
|
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
|
||||||
const badges = container.querySelectorAll(".agent-log-agent-badge");
|
const badges = container.querySelectorAll(".agent-log-agent-badge");
|
||||||
expect(badges).toHaveLength(1);
|
expect(badges).toHaveLength(1);
|
||||||
// In reversed order, the newest (ok) gets the badge
|
// In chronological order, the oldest (hmm) gets the badge
|
||||||
expect(badges[0].textContent).toBe("[triage]");
|
expect(badges[0].textContent).toBe("[triage]");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -721,7 +717,7 @@ describe("AgentLogViewer", () => {
|
|||||||
|
|
||||||
expect(badges).toHaveLength(3);
|
expect(badges).toHaveLength(3);
|
||||||
expect(timestamps).toHaveLength(3);
|
expect(timestamps).toHaveLength(3);
|
||||||
expect(badges.map((badge) => badge.textContent)).toEqual(["[reviewer]", "[executor]", "[triage]"]);
|
expect(badges.map((badge) => badge.textContent)).toEqual(["[triage]", "[executor]", "[reviewer]"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("badge container includes both badge text and timestamp text", () => {
|
it("badge container includes both badge text and timestamp text", () => {
|
||||||
@@ -813,7 +809,7 @@ describe("AgentLogViewer", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("auto-scroll behavior", () => {
|
describe("auto-scroll behavior", () => {
|
||||||
it("scrolls to top when streaming updates arrive and user is near the top", () => {
|
it("scrolls to bottom when streaming updates arrive and user is near the bottom", () => {
|
||||||
const initialEntries = [
|
const initialEntries = [
|
||||||
makeEntry({ text: "first", timestamp: "2026-01-01T00:00:00Z" }),
|
makeEntry({ text: "first", timestamp: "2026-01-01T00:00:00Z" }),
|
||||||
];
|
];
|
||||||
@@ -831,16 +827,16 @@ describe("AgentLogViewer", () => {
|
|||||||
get: () => scrollHeight,
|
get: () => scrollHeight,
|
||||||
});
|
});
|
||||||
|
|
||||||
viewer.scrollTop = 20;
|
viewer.scrollTop = 560;
|
||||||
rerender(<AgentLogViewer entries={[...initialEntries]} loading={false} />);
|
rerender(<AgentLogViewer entries={[...initialEntries]} loading={false} />);
|
||||||
|
|
||||||
scrollHeight = 720;
|
scrollHeight = 720;
|
||||||
rerender(<AgentLogViewer entries={streamedEntries} loading={false} />);
|
rerender(<AgentLogViewer entries={streamedEntries} loading={false} />);
|
||||||
|
|
||||||
expect(viewer.scrollTop).toBe(0);
|
expect(viewer.scrollTop).toBe(720);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps the viewport anchored when streaming updates arrive and user is reading older output", () => {
|
it("does not auto-scroll when streaming updates arrive and user is reading older output", () => {
|
||||||
const initialEntries = [
|
const initialEntries = [
|
||||||
makeEntry({ text: "first", timestamp: "2026-01-01T00:00:00Z" }),
|
makeEntry({ text: "first", timestamp: "2026-01-01T00:00:00Z" }),
|
||||||
];
|
];
|
||||||
@@ -864,11 +860,10 @@ describe("AgentLogViewer", () => {
|
|||||||
scrollHeight = 1120;
|
scrollHeight = 1120;
|
||||||
rerender(<AgentLogViewer entries={streamedEntries} loading={false} />);
|
rerender(<AgentLogViewer entries={streamedEntries} loading={false} />);
|
||||||
|
|
||||||
// Anchored by delta (1120 - 1000): 220 + 120
|
expect(viewer.scrollTop).toBe(220);
|
||||||
expect(viewer.scrollTop).toBe(340);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not offset scroll when loading older history at the bottom", () => {
|
it("keeps viewport anchored when older history is prepended", () => {
|
||||||
const initialEntries = [
|
const initialEntries = [
|
||||||
makeEntry({ text: "recent", timestamp: "2026-01-01T00:00:00Z" }),
|
makeEntry({ text: "recent", timestamp: "2026-01-01T00:00:00Z" }),
|
||||||
];
|
];
|
||||||
@@ -892,7 +887,87 @@ describe("AgentLogViewer", () => {
|
|||||||
scrollHeight = 1030;
|
scrollHeight = 1030;
|
||||||
rerender(<AgentLogViewer entries={olderLoadedEntries} loading={false} />);
|
rerender(<AgentLogViewer entries={olderLoadedEntries} loading={false} />);
|
||||||
|
|
||||||
expect(viewer.scrollTop).toBe(260);
|
// Anchored by delta (1030 - 900): 260 + 130
|
||||||
|
expect(viewer.scrollTop).toBe(390);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows return-to-live button when user scrolls away from bottom", () => {
|
||||||
|
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 viewer = container.querySelector("[data-testid='agent-log-viewer']") as HTMLDivElement;
|
||||||
|
|
||||||
|
Object.defineProperty(viewer, "scrollHeight", { configurable: true, value: 1000 });
|
||||||
|
Object.defineProperty(viewer, "clientHeight", { configurable: true, value: 200 });
|
||||||
|
|
||||||
|
viewer.scrollTop = 300;
|
||||||
|
fireEvent.scroll(viewer);
|
||||||
|
|
||||||
|
expect(screen.getByTestId("agent-log-return-to-live")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides return-to-live button when user is following live output", () => {
|
||||||
|
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 viewer = container.querySelector("[data-testid='agent-log-viewer']") as HTMLDivElement;
|
||||||
|
|
||||||
|
Object.defineProperty(viewer, "scrollHeight", { configurable: true, value: 1000 });
|
||||||
|
Object.defineProperty(viewer, "clientHeight", { configurable: true, value: 200 });
|
||||||
|
|
||||||
|
viewer.scrollTop = 760;
|
||||||
|
fireEvent.scroll(viewer);
|
||||||
|
|
||||||
|
expect(screen.queryByTestId("agent-log-return-to-live")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns to bottom and resumes following when return-to-live is clicked", () => {
|
||||||
|
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 viewer = container.querySelector("[data-testid='agent-log-viewer']") as HTMLDivElement;
|
||||||
|
|
||||||
|
Object.defineProperty(viewer, "scrollHeight", { configurable: true, value: 1000 });
|
||||||
|
Object.defineProperty(viewer, "clientHeight", { configurable: true, value: 200 });
|
||||||
|
|
||||||
|
viewer.scrollTop = 280;
|
||||||
|
fireEvent.scroll(viewer);
|
||||||
|
|
||||||
|
const returnButton = screen.getByTestId("agent-log-return-to-live");
|
||||||
|
fireEvent.click(returnButton);
|
||||||
|
|
||||||
|
expect(viewer.scrollTop).toBe(1000);
|
||||||
|
expect(screen.queryByTestId("agent-log-return-to-live")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("pagination placement", () => {
|
||||||
|
it("renders the load-more control above the first log entry", () => {
|
||||||
|
const entries = [
|
||||||
|
makeEntry({ text: "oldest", timestamp: "2026-01-01T00:00:00Z" }),
|
||||||
|
makeEntry({ text: "newest", timestamp: "2026-01-01T00:00:01Z" }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const { container } = render(
|
||||||
|
<AgentLogViewer
|
||||||
|
entries={entries}
|
||||||
|
loading={false}
|
||||||
|
hasMore={true}
|
||||||
|
onLoadMore={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const loadMore = screen.getByTestId("agent-log-load-more");
|
||||||
|
const firstRow = container.querySelector(".agent-log-text") as HTMLElement;
|
||||||
|
expect(firstRow).toBeTruthy();
|
||||||
|
|
||||||
|
expect(loadMore.compareDocumentPosition(firstRow) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ function capLogEntries(entries: AgentLogEntry[]): AgentLogEntry[] {
|
|||||||
* **Pagination semantics**:
|
* **Pagination semantics**:
|
||||||
* - Entries are returned in chronological order (oldest first) from the API
|
* - Entries are returned in chronological order (oldest first) from the API
|
||||||
* - Entries are stored in chronological order
|
* - Entries are stored in chronological order
|
||||||
* - The UI displays newest first by reversing the array
|
* - The UI displays entries in chronological order (oldest first)
|
||||||
* - `loadMore()` fetches the next 100 older entries and prepends them
|
* - `loadMore()` fetches the next 100 older entries and prepends them
|
||||||
*
|
*
|
||||||
* When `enabled` is true:
|
* When `enabled` is true:
|
||||||
|
|||||||
Reference in New Issue
Block a user