feat(FN-3037): add focus-visible styling to AgentLogViewer log action butto

Adds `focus-visible` styling to action buttons in the AgentLogViewer component, improving keyboard accessibility for interactive button elements.

Fusion-Task-Id: FN-3037
This commit is contained in:
Fusion
2026-04-30 19:16:41 -07:00
committed by gsxdsm
parent c553920b40
commit 135b0f0b16
13 changed files with 593 additions and 432 deletions

View File

@@ -478,6 +478,84 @@
color: var(--text-muted);
}
.run-logs-container {
padding: var(--space-md);
background: var(--bg-secondary);
border-top: 1px solid var(--border);
border-bottom: 1px solid var(--border);
}
.run-details-loading-state {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-sm) 0;
}
.run-output-sections {
margin-bottom: var(--space-md);
}
.run-output-section {
margin-bottom: var(--space-sm);
}
.run-output-label {
margin-bottom: var(--space-xs);
color: var(--text-muted);
font-size: calc(var(--space-sm) + var(--space-xs));
font-weight: 600;
letter-spacing: calc(var(--space-xs) * 0.1);
text-transform: uppercase;
}
.run-output-label--error {
color: var(--color-error);
}
.run-output-panel {
margin: 0;
max-height: calc(var(--space-2xl) * 6.25);
overflow: auto;
border-radius: var(--radius-sm);
padding: var(--space-sm) var(--space-md);
background: var(--surface);
border: 1px solid var(--border);
white-space: pre-wrap;
word-break: break-word;
font-size: calc(var(--space-sm) + var(--space-xs));
line-height: 1.5;
}
.run-output-panel--error {
color: var(--color-error);
border-color: color-mix(in srgb, var(--color-error) 35%, var(--border));
background: color-mix(in srgb, var(--color-error) 10%, transparent);
}
.run-context-grid {
display: flex;
flex-wrap: wrap;
gap: var(--space-xs) var(--space-md);
font-size: calc(var(--space-sm) + var(--space-xs));
}
.run-context-item {
line-height: 1.5;
}
.run-output-empty {
padding: var(--space-sm) 0;
font-style: italic;
font-size: calc(var(--space-sm) + var(--space-xs));
}
.run-agent-logs-section {
margin-top: var(--space-xs);
padding-top: var(--space-sm);
border-top: 1px solid var(--border);
}
/* --- Tasks Tab --- */
.agent-tasks-list {
display: flex;
@@ -1211,6 +1289,19 @@
flex-wrap: wrap;
}
.run-logs-container {
padding: var(--space-sm);
}
.run-output-panel {
max-height: calc(var(--space-2xl) * 5);
}
.run-context-grid {
flex-direction: column;
gap: var(--space-xs);
}
.run-details .text-muted {
min-width: 0;
overflow-wrap: anywhere;

View File

@@ -1303,50 +1303,28 @@ function RunsTab({
</div>
</div>
{isSelected && (
<div
className="run-logs-container"
style={{
padding: "12px",
background: "var(--bg-secondary)",
borderBottom: "1px solid var(--border-color)",
borderTop: "1px solid var(--border-color)",
}}
>
<div className="run-logs-container">
{/* Execution Details */}
{isLoadingDetail ? (
<div style={{ display: "flex", alignItems: "center", gap: "8px", padding: "8px 0" }}>
<div className="run-details-loading-state">
<Loader2 size={14} className="animate-spin" />
<span className="text-muted">Loading details...</span>
</div>
) : detailRun && (
<div style={{ marginBottom: "12px" }}>
<div className="run-output-sections">
{/* Token Usage */}
{detailRun.usageJson && (
<div style={{ marginBottom: "8px" }}>
<div style={{ fontSize: "11px", fontWeight: 600, textTransform: "uppercase", color: "var(--text-secondary)", marginBottom: "4px" }}>
Token Usage
</div>
<div className="run-output-section">
<div className="run-output-label">Token Usage</div>
{renderUsage(detailRun.usageJson)}
</div>
)}
{/* Output */}
{detailRun.stdoutExcerpt && (
<div style={{ marginBottom: "8px" }}>
<div style={{ fontSize: "11px", fontWeight: 600, textTransform: "uppercase", color: "var(--text-secondary)", marginBottom: "4px" }}>
Output
</div>
<pre style={{
background: "var(--bg-tertiary, #161b22)",
padding: "8px 12px",
borderRadius: "6px",
fontSize: "12px",
maxHeight: "200px",
overflow: "auto",
margin: 0,
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}>
<div className="run-output-section">
<div className="run-output-label">Output</div>
<pre className="run-output-panel">
{detailRun.stdoutExcerpt.length > 2000
? `${detailRun.stdoutExcerpt.slice(0, 2000)}\n\n... (truncated, ${detailRun.stdoutExcerpt.length} chars total)`
: detailRun.stdoutExcerpt}
@@ -1356,58 +1334,27 @@ function RunsTab({
{/* Errors */}
{detailRun.stderrExcerpt && (
<div style={{ marginBottom: "8px" }}>
<div style={{ fontSize: "11px", fontWeight: 600, textTransform: "uppercase", color: "var(--color-error, #f85149)", marginBottom: "4px" }}>
Errors
</div>
<pre style={{
background: "rgba(248, 81, 73, 0.1)",
color: "var(--color-error, #f85149)",
padding: "8px 12px",
borderRadius: "6px",
fontSize: "12px",
maxHeight: "200px",
overflow: "auto",
margin: 0,
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}>
{detailRun.stderrExcerpt}
</pre>
<div className="run-output-section">
<div className="run-output-label run-output-label--error">Errors</div>
<pre className="run-output-panel run-output-panel--error">{detailRun.stderrExcerpt}</pre>
</div>
)}
{/* Result */}
{detailRun.resultJson && (
<div style={{ marginBottom: "8px" }}>
<div style={{ fontSize: "11px", fontWeight: 600, textTransform: "uppercase", color: "var(--text-secondary)", marginBottom: "4px" }}>
Result
</div>
<pre style={{
background: "var(--bg-tertiary, #161b22)",
padding: "8px 12px",
borderRadius: "6px",
fontSize: "12px",
maxHeight: "200px",
overflow: "auto",
margin: 0,
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}>
{JSON.stringify(detailRun.resultJson, null, 2)}
</pre>
<div className="run-output-section">
<div className="run-output-label">Result</div>
<pre className="run-output-panel">{JSON.stringify(detailRun.resultJson, null, 2)}</pre>
</div>
)}
{/* Context */}
{detailRun.contextSnapshot && Object.keys(detailRun.contextSnapshot).length > 0 && (
<div style={{ marginBottom: "8px" }}>
<div style={{ fontSize: "11px", fontWeight: 600, textTransform: "uppercase", color: "var(--text-secondary)", marginBottom: "4px" }}>
Context
</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: "4px 12px", fontSize: "12px" }}>
<div className="run-output-section">
<div className="run-output-label">Context</div>
<div className="run-context-grid">
{Object.entries(detailRun.contextSnapshot).map(([key, value]) => (
<span key={key}>
<span key={key} className="run-context-item">
<span className="text-muted">{key}:</span>{" "}
<span>{String(value)}</span>
</span>
@@ -1418,27 +1365,21 @@ function RunsTab({
{/* No output state */}
{!detailRun.stdoutExcerpt && !detailRun.stderrExcerpt && !detailRun.resultJson && (
<div className="text-muted" style={{ padding: "8px 0", fontStyle: "italic", fontSize: "12px" }}>
No output captured
</div>
<div className="text-muted run-output-empty">No output captured</div>
)}
</div>
)}
{/* Run Logs */}
<div style={{ borderTop: "1px solid var(--border-color)", paddingTop: "8px", marginTop: "4px" }}>
<div style={{ fontSize: "11px", fontWeight: 600, textTransform: "uppercase", color: "var(--text-secondary)", marginBottom: "4px" }}>
Agent Logs
</div>
<div className="run-agent-logs-section">
<div className="run-output-label">Agent Logs</div>
{isLoadingLogs ? (
<div style={{ display: "flex", alignItems: "center", gap: "8px", padding: "8px 0" }}>
<div className="run-details-loading-state">
<Loader2 size={14} className="animate-spin" />
<span className="text-muted">Loading logs...</span>
</div>
) : runLogs.length === 0 ? (
<div className="text-muted" style={{ padding: "8px 0", fontStyle: "italic" }}>
No logs available for this run
</div>
<div className="text-muted run-output-empty">No logs available for this run</div>
) : (
<AgentLogViewer entries={runLogs} loading={false} />
)}

View File

@@ -0,0 +1,333 @@
.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;
overflow-x: hidden;
scrollbar-color: var(--border) transparent;
scrollbar-width: thin;
font-family: var(--font-mono);
font-size: 13px;
line-height: 1.5;
color: var(--text);
padding: var(--space-md);
white-space: pre-wrap;
word-break: break-word;
overflow-wrap: break-word;
}
.agent-log-viewer-scroll::-webkit-scrollbar {
width: 6px;
}
.agent-log-viewer-scroll::-webkit-scrollbar-track {
background: transparent;
}
.agent-log-viewer-scroll::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: var(--radius-sm);
}
.agent-log-viewer-scroll::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
.agent-log-model-header {
display: flex;
flex-wrap: wrap;
gap: var(--space-lg);
padding: var(--space-sm) var(--space-md);
margin-bottom: var(--space-md);
background: var(--bg-tertiary);
border-radius: var(--radius-sm);
font-size: 12px;
color: var(--text-muted);
overflow: hidden;
min-width: 0;
align-items: center;
justify-content: space-between;
}
.agent-log-model-icons {
display: flex;
align-items: center;
gap: var(--space-xs);
}
.agent-log-model-expand-btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: var(--space-xs);
border: none;
background: transparent;
color: var(--text-muted);
cursor: pointer;
border-radius: var(--radius-sm);
transition: color var(--transition-fast);
}
.agent-log-model-expand-btn:hover {
color: var(--text);
background: var(--card-hover);
}
.agent-log-model-expand-btn:focus-visible {
outline: none;
box-shadow: var(--focus-ring-strong);
}
.agent-log-model-details {
display: flex;
flex-basis: 100%;
flex-wrap: wrap;
gap: var(--space-lg);
padding-top: var(--space-sm);
margin-top: var(--space-sm);
border-top: 1px solid var(--border);
}
.agent-log-model-group {
display: flex;
align-items: center;
gap: 6px;
}
.agent-log-model-label {
font-weight: 600;
}
.agent-log-model-value {
display: flex;
align-items: center;
gap: 4px;
color: var(--text-muted);
}
.agent-log-model-header-toggle {
display: flex;
align-items: center;
gap: var(--space-xs);
margin-left: auto;
}
.agent-log-badge-row {
display: inline-flex;
align-items: center;
}
.agent-log-tool {
color: var(--accent);
margin: var(--space-xs) 0;
padding: var(--space-xs) var(--space-sm);
border-left: var(--space-xs) solid var(--accent);
background: var(--log-tool-bg);
}
.agent-log-tool-result {
color: var(--color-success);
margin: calc(var(--space-xs) / 2) 0;
padding: var(--space-xs) var(--space-sm);
border-left: var(--space-xs) solid var(--color-success);
background: var(--log-success-bg);
font-size: 12px;
}
.agent-log-tool-error {
color: var(--color-error);
margin: calc(var(--space-xs) / 2) 0;
padding: var(--space-xs) var(--space-sm);
border-left: var(--space-xs) solid var(--color-error);
background: var(--log-error-bg);
font-size: 12px;
}
.agent-log-tool-title {
white-space: pre-wrap;
word-break: break-word;
overflow-wrap: anywhere;
}
.agent-log-tool-detail {
margin: var(--space-xs) 0 0;
padding: var(--space-xs) var(--space-sm);
color: var(--text-muted);
font-size: 12px;
font-family: var(--font-mono);
white-space: pre-wrap;
word-break: break-word;
overflow-wrap: anywhere;
background: color-mix(in srgb, var(--surface) 70%, transparent);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
}
.agent-log-plain-block {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
overflow-wrap: anywhere;
font-family: inherit;
}
.agent-log-text {
display: block;
color: var(--text);
}
.agent-log-thinking {
display: block;
font-style: italic;
color: var(--text);
}
.agent-log-agent-badge {
color: var(--text-muted);
font-size: 11px;
margin-right: 6px;
font-weight: 600;
text-transform: uppercase;
}
.agent-log-timestamp {
color: var(--text-muted);
font-size: 10px;
margin-right: 6px;
opacity: 0.7;
}
.agent-log-mode-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: calc(var(--space-xl) + var(--space-xl) + var(--space-xs));
min-height: calc(var(--space-lg) + var(--space-lg) + var(--space-xs));
padding: var(--space-xs) var(--space-sm);
font-size: 11px;
font-weight: 500;
color: var(--text-muted);
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
cursor: pointer;
transition: all var(--transition-fast);
line-height: 1;
}
.agent-log-mode-toggle:hover {
background: var(--card-hover);
color: var(--text);
}
.agent-log-mode-toggle[aria-pressed="true"] {
background: var(--accent);
color: var(--text-on-accent);
border-color: var(--accent);
}
.agent-log-mode-toggle:focus-visible {
outline: none;
box-shadow: var(--focus-ring-strong);
}
.agent-log-mode-toggle:disabled {
opacity: 0.7;
cursor: not-allowed;
}
.agent-log-mode-toggle:disabled:hover {
background: var(--card);
color: var(--text-muted);
}
.agent-log-summary {
padding: var(--space-xs) var(--space-md);
font-size: var(--text-xs, 12px);
color: var(--text-muted);
border-bottom: 1px solid var(--border);
text-align: center;
}
.agent-log-load-more {
padding: var(--space-md);
text-align: center;
border-bottom: 1px solid var(--border);
}
.agent-log-load-more .agent-log-mode-toggle {
min-width: 120px;
}
.agent-log-return-to-live {
position: sticky;
right: var(--space-md);
bottom: var(--space-md);
width: fit-content;
margin-left: auto;
display: 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);
}
.agent-log-viewer--fullscreen {
position: fixed;
inset: 0;
z-index: 10000;
display: flex;
flex-direction: column;
min-height: 0;
background: var(--surface);
padding: max(var(--space-lg), env(safe-area-inset-top, 0px))
max(var(--space-lg), env(safe-area-inset-right, 0px))
max(var(--space-lg), env(safe-area-inset-bottom, 0px))
max(var(--space-lg), env(safe-area-inset-left, 0px));
}
.agent-log-viewer--fullscreen .agent-log-model-header {
margin-bottom: 0;
background: var(--surface);
border: 1px solid var(--border);
}
.agent-log-viewer--fullscreen .agent-log-viewer-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
}
@media (max-width: 768px) {
.agent-log-return-to-live {
right: var(--space-sm);
bottom: var(--space-sm);
}
}

View File

@@ -1,10 +1,11 @@
import type { AgentLogEntry } from "@fusion/core";
import { ProviderIcon } from "./ProviderIcon";
import { useRef, useEffect, useState, useCallback, useLayoutEffect, useMemo } from "react";
import { useRef, useEffect, useState, useCallback, useLayoutEffect, useMemo, type ReactElement } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import type { Components } from "react-markdown";
import { Maximize2, Minimize2, Loader2, ChevronDown, ChevronRight } from "lucide-react";
import "./AgentLogViewer.css";
function formatTimestamp(iso: string): string {
const date = new Date(iso);
@@ -84,6 +85,11 @@ function isToolLikeType(type: AgentLogEntry["type"]): boolean {
return type === "tool" || type === "tool_result" || type === "tool_error";
}
function renderToolDetail(detail?: string): ReactElement | null {
if (!detail) return null;
return <pre className="agent-log-tool-detail">{detail}</pre>;
}
function shouldShowBadge(entry: AgentLogEntry, previousEntry?: AgentLogEntry): boolean {
if (!entry.agent) return false;
if (isToolLikeType(entry.type)) return true;
@@ -485,8 +491,9 @@ export function AgentLogViewer({
if (entry.type === "tool") {
return (
<div key={group.key} className="agent-log-tool">
{agentBadge} {entry.text}
{entry.detail && <span className="agent-log-tool-detail"> {entry.detail}</span>}
{agentBadge}
<div className="agent-log-tool-title"> {entry.text}</div>
{renderToolDetail(entry.detail)}
</div>
);
}
@@ -494,8 +501,9 @@ export function AgentLogViewer({
if (entry.type === "tool_result") {
return (
<div key={group.key} className="agent-log-tool-result">
{agentBadge} {entry.text}
{entry.detail && <span className="agent-log-tool-detail"> {entry.detail}</span>}
{agentBadge}
<div className="agent-log-tool-title"> {entry.text}</div>
{renderToolDetail(entry.detail)}
</div>
);
}
@@ -503,8 +511,9 @@ export function AgentLogViewer({
if (entry.type === "tool_error") {
return (
<div key={group.key} className="agent-log-tool-error">
{agentBadge} {entry.text}
{entry.detail && <span className="agent-log-tool-detail"> {entry.detail}</span>}
{agentBadge}
<div className="agent-log-tool-title"> {entry.text}</div>
{renderToolDetail(entry.detail)}
</div>
);
}
@@ -525,7 +534,7 @@ export function AgentLogViewer({
</ReactMarkdown>
</div>
) : (
groupedText
<pre className="agent-log-plain-block">{groupedText}</pre>
)}
</div>
);
@@ -541,7 +550,7 @@ export function AgentLogViewer({
</ReactMarkdown>
</div>
) : (
groupedText
<pre className="agent-log-plain-block">{groupedText}</pre>
)}
</div>
);

View File

@@ -359,300 +359,6 @@
margin-top: var(--space-lg);
}
.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;
}
.detail-section--agent-log .agent-log-viewer-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
scrollbar-color: var(--border) transparent;
scrollbar-width: thin;
font-family: var(--font-mono);
font-size: 13px;
line-height: 1.5;
color: var(--text);
padding: var(--space-md);
white-space: pre-wrap;
word-break: break-word;
overflow-wrap: break-word;
}
.detail-section--agent-log .agent-log-viewer-scroll::-webkit-scrollbar {
width: 6px;
}
.detail-section--agent-log .agent-log-viewer-scroll::-webkit-scrollbar-track {
background: transparent;
}
.detail-section--agent-log .agent-log-viewer-scroll::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: var(--radius-sm);
}
.detail-section--agent-log .agent-log-viewer-scroll::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
.agent-log-model-header {
display: flex;
flex-wrap: wrap;
gap: var(--space-lg);
padding: var(--space-sm) var(--space-md);
margin-bottom: var(--space-md);
background: var(--bg-tertiary);
border-radius: var(--radius-sm);
font-size: 12px;
color: var(--text-muted);
overflow: hidden;
min-width: 0;
align-items: center;
justify-content: space-between;
}
.agent-log-model-icons {
display: flex;
align-items: center;
gap: var(--space-xs);
}
.agent-log-model-expand-btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: var(--space-xs);
border: none;
background: transparent;
color: var(--text-muted);
cursor: pointer;
border-radius: var(--radius-sm);
transition: color var(--transition-fast);
}
.agent-log-model-expand-btn:hover {
color: var(--text);
background: var(--card-hover);
}
.agent-log-model-expand-btn:focus-visible {
outline: none;
box-shadow: var(--focus-ring-strong);
}
.agent-log-model-details {
display: flex;
flex-basis: 100%;
flex-wrap: wrap;
gap: var(--space-lg);
padding-top: var(--space-sm);
margin-top: var(--space-sm);
border-top: 1px solid var(--border);
}
.agent-log-model-group {
display: flex;
align-items: center;
gap: 6px;
}
.agent-log-model-label {
font-weight: 600;
}
.agent-log-model-value {
display: flex;
align-items: center;
gap: 4px;
color: var(--text-muted);
}
.agent-log-model-header-toggle {
display: flex;
align-items: center;
gap: var(--space-xs);
margin-left: auto;
}
.agent-log-badge-row {
display: inline-flex;
align-items: center;
}
.agent-log-tool {
color: var(--accent);
margin: 4px 0;
padding: 2px 6px;
border-left: 3px solid var(--accent);
background: var(--log-tool-bg);
}
.agent-log-tool-detail {
color: var(--text-muted);
font-size: 12px;
margin-left: 6px;
}
.agent-log-tool-result {
color: var(--color-success);
margin: 2px 0;
padding: 2px 6px;
border-left: 3px solid var(--color-success);
background: var(--log-success-bg);
font-size: 12px;
}
.agent-log-tool-error {
color: var(--color-error);
margin: 2px 0;
padding: 2px 6px;
border-left: 3px solid var(--color-error);
background: var(--log-error-bg);
font-size: 12px;
}
.agent-log-text {
display: block;
color: var(--text);
}
.agent-log-thinking {
display: block;
font-style: italic;
color: var(--text);
}
.agent-log-agent-badge {
color: var(--text-muted);
font-size: 11px;
margin-right: 6px;
font-weight: 600;
text-transform: uppercase;
}
.agent-log-timestamp {
color: var(--text-muted);
font-size: 10px;
margin-right: 6px;
opacity: 0.7;
}
/* Agent Log markdown/plain-text mode toggle */
.agent-log-mode-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: calc(var(--space-xl) + var(--space-xl) + var(--space-xs));
min-height: calc(var(--space-lg) + var(--space-lg) + var(--space-xs));
padding: var(--space-xs) var(--space-sm);
font-size: 11px;
font-weight: 500;
color: var(--text-muted);
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
cursor: pointer;
transition: all var(--transition-fast);
line-height: 1;
}
.agent-log-mode-toggle:hover {
background: var(--card-hover);
color: var(--text);
}
.agent-log-mode-toggle[aria-pressed="true"] {
background: var(--accent);
color: var(--text-on-accent, #fff);
border-color: var(--accent);
}
/* Agent log pagination summary */
.agent-log-summary {
padding: var(--space-xs) var(--space-md);
font-size: var(--text-xs, 12px);
color: var(--text-muted, #8b949e);
border-bottom: 1px solid var(--border);
text-align: center;
}
/* Agent log load more button */
.agent-log-load-more {
padding: var(--space-md);
text-align: center;
border-bottom: 1px solid var(--border);
}
.agent-log-load-more .agent-log-mode-toggle {
min-width: 120px;
}
.agent-log-return-to-live {
position: sticky;
right: var(--space-md);
bottom: var(--space-md);
width: fit-content;
margin-left: auto;
display: 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 */
.agent-log-viewer--fullscreen {
position: fixed;
inset: 0;
z-index: 10000;
display: flex;
flex-direction: column;
min-height: 0;
background: var(--surface);
/* iOS PWA: pad inwards from notches / status bar / home indicator. */
padding: max(var(--space-lg), env(safe-area-inset-top, 0px))
max(var(--space-lg), env(safe-area-inset-right, 0px))
max(var(--space-lg), env(safe-area-inset-bottom, 0px))
max(var(--space-lg), env(safe-area-inset-left, 0px));
}
.agent-log-viewer--fullscreen .agent-log-model-header {
margin-bottom: 0;
background: var(--surface);
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 {
margin-bottom: var(--space-md);
@@ -1749,8 +1455,4 @@
min-height: 36px;
}
.agent-log-return-to-live {
right: var(--space-sm);
bottom: var(--space-sm);
}
}

View File

@@ -1123,7 +1123,7 @@ describe("AgentLogViewer", () => {
const detail = container.querySelector(".agent-log-tool-detail");
expect(detail).toBeTruthy();
expect(detail!.textContent).toContain(longDetail);
expect(detail!.textContent!.length).toBeGreaterThan(5000); // " — " prefix adds a few chars
expect(detail!.textContent!.length).toBe(5000);
});
it("renders multiline text content without truncation", () => {
@@ -1168,6 +1168,16 @@ describe("AgentLogViewer", () => {
expect(detail).toBeTruthy();
expect(detail!.textContent).toContain(longDetail);
});
it("preserves raw whitespace in tool detail blocks", () => {
const detailText = "stdout:\n line one\n indented line two\n";
const entries = [makeEntry({ text: "Bash", type: "tool_result", detail: detailText })];
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
const detail = container.querySelector(".agent-log-tool-detail") as HTMLElement;
expect(detail).toBeTruthy();
expect(detail.tagName).toBe("PRE");
expect(detail.textContent).toBe(detailText);
});
});
describe("markdown rendering", () => {
@@ -1350,6 +1360,8 @@ describe("AgentLogViewer", () => {
const textSpans = container.querySelectorAll(".agent-log-text");
expect(textSpans).toHaveLength(1);
expect(textSpans[0].textContent).toContain("**bold** and *italic*");
const plainBlock = textSpans[0].querySelector(".agent-log-plain-block") as HTMLElement;
expect(plainBlock).toBeTruthy();
// Plain mode should remove markdown rendering/prose container
expect(textSpans[0].querySelector(".markdown-body")).toBeNull();
expect(textSpans[0].querySelector("strong")).toBeNull();
@@ -1407,6 +1419,18 @@ describe("AgentLogViewer", () => {
expect(strong!.textContent).toBe("bold");
});
it("preserves line breaks in plain text mode for thinking entries", () => {
const entries = [makeEntry({ text: "line1\nline2\nline3", type: "thinking", agent: "executor" })];
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
const toggle = container.querySelector("[data-testid='agent-log-mode-toggle']") as HTMLButtonElement;
fireEvent.click(toggle);
const plainThinking = container.querySelector(".agent-log-thinking .agent-log-plain-block") as HTMLElement;
expect(plainThinking).toBeTruthy();
expect(plainThinking.textContent).toContain("line1\nline2\nline3");
});
it("shows raw markdown syntax literally in plain text mode for text entries", () => {
const entries = [
makeEntry({ text: "## Heading\n\n- item 1\n- item 2\n\n`code` and **bold**" }),

View File

@@ -229,10 +229,10 @@ describe("TaskDetailModal", () => {
it("styles agent log viewer scroll container scrollbar rules", () => {
const css = loadAllAppCss();
expectBaseRule(css, ".detail-section--agent-log .agent-log-viewer-scroll", "scrollbar-color: var(--border) transparent;");
expectBaseRule(css, ".detail-section--agent-log .agent-log-viewer-scroll", "scrollbar-width: thin;");
expectBaseRule(css, ".detail-section--agent-log .agent-log-viewer-scroll::-webkit-scrollbar", "width: 6px;");
expectBaseRule(css, ".detail-section--agent-log .agent-log-viewer-scroll::-webkit-scrollbar-thumb", "background: var(--border);");
expectBaseRule(css, ".agent-log-viewer-scroll", "scrollbar-color: var(--border) transparent;");
expectBaseRule(css, ".agent-log-viewer-scroll", "scrollbar-width: thin;");
expectBaseRule(css, ".agent-log-viewer-scroll::-webkit-scrollbar", "width: 6px;");
expectBaseRule(css, ".agent-log-viewer-scroll::-webkit-scrollbar-thumb", "background: var(--border);");
});
it("renders markdown-body without detail-prompt class when prompt exists", () => {

View File

@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act, waitFor } from "@testing-library/react";
import { MAX_LOG_ENTRIES, useAgentLogs } from "../useAgentLogs";
import { useAgentLogs } from "../useAgentLogs";
import { fetchAgentLogsWithMeta } from "../../api";
// Mock the api module
@@ -191,8 +191,9 @@ describe("useAgentLogs", () => {
expect(es.close).toHaveBeenCalled();
});
it("truncates oversized historical logs to the most recent entries", async () => {
const historicalLogs = Array.from({ length: MAX_LOG_ENTRIES + 25 }, (_, index) => ({
it("keeps oversized historical logs without truncating older entries", async () => {
const oversizedCount = 525;
const historicalLogs = Array.from({ length: oversizedCount }, (_, index) => ({
timestamp: `2026-01-01T00:${String(index).padStart(2, "0")}:00Z`,
taskId: "FN-001",
text: `entry-${index}`,
@@ -207,15 +208,16 @@ describe("useAgentLogs", () => {
const { result } = renderHook(() => useAgentLogs("FN-001", true));
await waitFor(() => {
expect(result.current.entries).toHaveLength(MAX_LOG_ENTRIES);
expect(result.current.entries).toHaveLength(oversizedCount);
});
expect(result.current.entries[0].text).toBe("entry-25");
expect(result.current.entries.at(-1)?.text).toBe(`entry-${MAX_LOG_ENTRIES + 24}`);
expect(result.current.entries[0].text).toBe("entry-0");
expect(result.current.entries.at(-1)?.text).toBe(`entry-${oversizedCount - 1}`);
});
it("truncates live SSE entries to the most recent entries", async () => {
mockFetchAgentLogsWithMeta.mockResolvedValueOnce({ entries: [], total: MAX_LOG_ENTRIES + 20, hasMore: false });
it("keeps oversized live SSE history without truncation", async () => {
const streamedCount = 520;
mockFetchAgentLogsWithMeta.mockResolvedValueOnce({ entries: [], total: streamedCount, hasMore: false });
const { result } = renderHook(() => useAgentLogs("FN-001", true));
@@ -225,7 +227,7 @@ describe("useAgentLogs", () => {
const es = MockEventSource.instances[0];
act(() => {
for (let index = 0; index < MAX_LOG_ENTRIES + 20; index++) {
for (let index = 0; index < streamedCount; index++) {
es._emit("agent:log", {
timestamp: `2026-01-01T00:${String(index).padStart(2, "0")}:00Z`,
taskId: "FN-001",
@@ -236,11 +238,11 @@ describe("useAgentLogs", () => {
});
await waitFor(() => {
expect(result.current.entries).toHaveLength(MAX_LOG_ENTRIES);
expect(result.current.entries).toHaveLength(streamedCount);
});
expect(result.current.entries[0].text).toBe("live-20");
expect(result.current.entries.at(-1)?.text).toBe(`live-${MAX_LOG_ENTRIES + 19}`);
expect(result.current.entries[0].text).toBe("live-0");
expect(result.current.entries.at(-1)?.text).toBe(`live-${streamedCount - 1}`);
});
it("does not fetch when taskId is null", () => {
@@ -370,6 +372,56 @@ describe("useAgentLogs", () => {
]);
});
it("keeps full history across initial load, loadMore, and live streaming", async () => {
const initialLogs = Array.from({ length: 300 }, (_, index) => ({
timestamp: `2026-01-02T00:${String(index).padStart(2, "0")}:00Z`,
taskId: "FN-001",
text: `initial-${index}`,
type: "text" as const,
}));
const olderLogs = Array.from({ length: 250 }, (_, index) => ({
timestamp: `2026-01-01T00:${String(index).padStart(2, "0")}:00Z`,
taskId: "FN-001",
text: `older-${index}`,
type: "text" as const,
}));
mockFetchAgentLogsWithMeta
.mockResolvedValueOnce({ entries: initialLogs, total: 550, hasMore: true })
.mockResolvedValueOnce({ entries: olderLogs, total: 550, hasMore: false });
const { result } = renderHook(() => useAgentLogs("FN-001", true));
await waitFor(() => {
expect(result.current.entries).toHaveLength(300);
});
await act(async () => {
await result.current.loadMore();
});
await waitFor(() => {
expect(result.current.entries).toHaveLength(550);
});
const es = MockEventSource.instances[0];
act(() => {
es._emit("agent:log", {
timestamp: "2026-01-03T00:00:00Z",
taskId: "FN-001",
text: "live-after-large-history",
type: "text",
});
});
await waitFor(() => {
expect(result.current.entries).toHaveLength(551);
});
expect(result.current.entries[0].text).toBe("older-0");
expect(result.current.entries.at(-1)?.text).toBe("live-after-large-history");
});
it("loadMore does not trigger when already loading more", async () => {
mockFetchAgentLogsWithMeta.mockResolvedValue({ entries: [], total: 200, hasMore: true });

View File

@@ -3,23 +3,8 @@ import type { AgentLogEntry } from "@fusion/core";
import { fetchAgentLogsWithMeta } from "../api";
import { subscribeSse } from "../sse-bus";
export const MAX_LOG_ENTRIES = 500;
const INITIAL_LOAD_LIMIT = 100;
/**
* Cap the total number of log entries to `MAX_LOG_ENTRIES`.
*
* This is a **whole-list cap** — it limits how many entries are kept
* in memory, not the content of any individual entry. Per-entry `text`
* and `detail` fields are never truncated anywhere in the pipeline
* (persistence → API → SSE → hook → rendering).
*/
function capLogEntries(entries: AgentLogEntry[]): AgentLogEntry[] {
return entries.length > MAX_LOG_ENTRIES
? entries.slice(-MAX_LOG_ENTRIES)
: entries;
}
/**
* Hook that manages agent log fetching and live SSE streaming for a task.
*
@@ -127,7 +112,7 @@ export function useAgentLogs(taskId: string | null, enabled: boolean, projectId?
requestVersionRef.current !== requestVersion) {
return;
}
setEntries(capLogEntries(result.entries));
setEntries(result.entries);
setHasMore(result.hasMore);
setTotal(result.total);
} catch {
@@ -162,7 +147,7 @@ export function useAgentLogs(taskId: string | null, enabled: boolean, projectId?
}
try {
const entry: AgentLogEntry = JSON.parse(e.data);
setEntries((prev) => capLogEntries([...prev, entry]));
setEntries((prev) => [...prev, entry]);
setTotal((prev) => (prev !== null ? prev + 1 : null));
} catch {
// skip malformed events
@@ -209,10 +194,7 @@ export function useAgentLogs(taskId: string | null, enabled: boolean, projectId?
}
// Prepend older entries to the existing list
setEntries((prev) => {
const combined = [...result.entries, ...prev];
return capLogEntries(combined);
});
setEntries((prev) => [...result.entries, ...prev]);
setHasMore(result.hasMore);
setTotal(result.total);
} catch {