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

@@ -313,6 +313,8 @@ fn task show FN-001
fn task logs FN-001 --follow --limit 50 --type tool fn task logs FN-001 --follow --limit 50 --type tool
``` ```
`fn task logs` now exposes full agent-log content for each entry type. In particular, `thinking`, `tool_result`, and `tool_error` entries preserve full multiline output (including stderr/stack details) so you can inspect raw tool responses directly from the CLI stream.
`fn task show <id>` includes routing and provenance context when available: `fn task show <id>` includes routing and provenance context when available:
- task node override - task node override
- project default node fallback - project default node fallback

View File

@@ -138,6 +138,16 @@ Memory view provides a multi-file editor for project and daily memory files.
Inspect task definition, logs, comments, documents, workflow outcomes, model overrides, and task routing from a single modal. Inspect task definition, logs, comments, documents, workflow outcomes, model overrides, and task routing from a single modal.
### Logs → Agent Log view
The **Logs** tab includes an **Agent Log** subview designed for debugging long-running and tool-heavy sessions:
- Full `thinking`, `tool_result`, and `tool_error` payloads are shown without entry-content truncation.
- Raw tool output is rendered as multiline blocks, preserving line breaks and indentation.
- The initial load fetches a recent page, then **Load More** progressively prepends older history.
- Live streaming appends new entries in chronological order while preserving your scroll position when loading older pages.
- The **Markdown / Plain** toggle lets you switch between formatted markdown and literal/raw text rendering.
The **Routing** tab shows: The **Routing** tab shows:
- effective node - effective node
- routing source (task override vs project default vs local) - routing source (task override vs project default vs local)

View File

@@ -478,6 +478,84 @@
color: var(--text-muted); 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 --- */ /* --- Tasks Tab --- */
.agent-tasks-list { .agent-tasks-list {
display: flex; display: flex;
@@ -1211,6 +1289,19 @@
flex-wrap: wrap; 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 { .run-details .text-muted {
min-width: 0; min-width: 0;
overflow-wrap: anywhere; overflow-wrap: anywhere;

View File

@@ -1303,50 +1303,28 @@ function RunsTab({
</div> </div>
</div> </div>
{isSelected && ( {isSelected && (
<div <div className="run-logs-container">
className="run-logs-container"
style={{
padding: "12px",
background: "var(--bg-secondary)",
borderBottom: "1px solid var(--border-color)",
borderTop: "1px solid var(--border-color)",
}}
>
{/* Execution Details */} {/* Execution Details */}
{isLoadingDetail ? ( {isLoadingDetail ? (
<div style={{ display: "flex", alignItems: "center", gap: "8px", padding: "8px 0" }}> <div className="run-details-loading-state">
<Loader2 size={14} className="animate-spin" /> <Loader2 size={14} className="animate-spin" />
<span className="text-muted">Loading details...</span> <span className="text-muted">Loading details...</span>
</div> </div>
) : detailRun && ( ) : detailRun && (
<div style={{ marginBottom: "12px" }}> <div className="run-output-sections">
{/* Token Usage */} {/* Token Usage */}
{detailRun.usageJson && ( {detailRun.usageJson && (
<div style={{ marginBottom: "8px" }}> <div className="run-output-section">
<div style={{ fontSize: "11px", fontWeight: 600, textTransform: "uppercase", color: "var(--text-secondary)", marginBottom: "4px" }}> <div className="run-output-label">Token Usage</div>
Token Usage
</div>
{renderUsage(detailRun.usageJson)} {renderUsage(detailRun.usageJson)}
</div> </div>
)} )}
{/* Output */} {/* Output */}
{detailRun.stdoutExcerpt && ( {detailRun.stdoutExcerpt && (
<div style={{ marginBottom: "8px" }}> <div className="run-output-section">
<div style={{ fontSize: "11px", fontWeight: 600, textTransform: "uppercase", color: "var(--text-secondary)", marginBottom: "4px" }}> <div className="run-output-label">Output</div>
Output <pre className="run-output-panel">
</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",
}}>
{detailRun.stdoutExcerpt.length > 2000 {detailRun.stdoutExcerpt.length > 2000
? `${detailRun.stdoutExcerpt.slice(0, 2000)}\n\n... (truncated, ${detailRun.stdoutExcerpt.length} chars total)` ? `${detailRun.stdoutExcerpt.slice(0, 2000)}\n\n... (truncated, ${detailRun.stdoutExcerpt.length} chars total)`
: detailRun.stdoutExcerpt} : detailRun.stdoutExcerpt}
@@ -1356,58 +1334,27 @@ function RunsTab({
{/* Errors */} {/* Errors */}
{detailRun.stderrExcerpt && ( {detailRun.stderrExcerpt && (
<div style={{ marginBottom: "8px" }}> <div className="run-output-section">
<div style={{ fontSize: "11px", fontWeight: 600, textTransform: "uppercase", color: "var(--color-error, #f85149)", marginBottom: "4px" }}> <div className="run-output-label run-output-label--error">Errors</div>
Errors <pre className="run-output-panel run-output-panel--error">{detailRun.stderrExcerpt}</pre>
</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> </div>
)} )}
{/* Result */} {/* Result */}
{detailRun.resultJson && ( {detailRun.resultJson && (
<div style={{ marginBottom: "8px" }}> <div className="run-output-section">
<div style={{ fontSize: "11px", fontWeight: 600, textTransform: "uppercase", color: "var(--text-secondary)", marginBottom: "4px" }}> <div className="run-output-label">Result</div>
Result <pre className="run-output-panel">{JSON.stringify(detailRun.resultJson, null, 2)}</pre>
</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> </div>
)} )}
{/* Context */} {/* Context */}
{detailRun.contextSnapshot && Object.keys(detailRun.contextSnapshot).length > 0 && ( {detailRun.contextSnapshot && Object.keys(detailRun.contextSnapshot).length > 0 && (
<div style={{ marginBottom: "8px" }}> <div className="run-output-section">
<div style={{ fontSize: "11px", fontWeight: 600, textTransform: "uppercase", color: "var(--text-secondary)", marginBottom: "4px" }}> <div className="run-output-label">Context</div>
Context <div className="run-context-grid">
</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: "4px 12px", fontSize: "12px" }}>
{Object.entries(detailRun.contextSnapshot).map(([key, value]) => ( {Object.entries(detailRun.contextSnapshot).map(([key, value]) => (
<span key={key}> <span key={key} className="run-context-item">
<span className="text-muted">{key}:</span>{" "} <span className="text-muted">{key}:</span>{" "}
<span>{String(value)}</span> <span>{String(value)}</span>
</span> </span>
@@ -1418,27 +1365,21 @@ function RunsTab({
{/* No output state */} {/* No output state */}
{!detailRun.stdoutExcerpt && !detailRun.stderrExcerpt && !detailRun.resultJson && ( {!detailRun.stdoutExcerpt && !detailRun.stderrExcerpt && !detailRun.resultJson && (
<div className="text-muted" style={{ padding: "8px 0", fontStyle: "italic", fontSize: "12px" }}> <div className="text-muted run-output-empty">No output captured</div>
No output captured
</div>
)} )}
</div> </div>
)} )}
{/* Run Logs */} {/* Run Logs */}
<div style={{ borderTop: "1px solid var(--border-color)", paddingTop: "8px", marginTop: "4px" }}> <div className="run-agent-logs-section">
<div style={{ fontSize: "11px", fontWeight: 600, textTransform: "uppercase", color: "var(--text-secondary)", marginBottom: "4px" }}> <div className="run-output-label">Agent Logs</div>
Agent Logs
</div>
{isLoadingLogs ? ( {isLoadingLogs ? (
<div style={{ display: "flex", alignItems: "center", gap: "8px", padding: "8px 0" }}> <div className="run-details-loading-state">
<Loader2 size={14} className="animate-spin" /> <Loader2 size={14} className="animate-spin" />
<span className="text-muted">Loading logs...</span> <span className="text-muted">Loading logs...</span>
</div> </div>
) : runLogs.length === 0 ? ( ) : runLogs.length === 0 ? (
<div className="text-muted" style={{ padding: "8px 0", fontStyle: "italic" }}> <div className="text-muted run-output-empty">No logs available for this run</div>
No logs available for this run
</div>
) : ( ) : (
<AgentLogViewer entries={runLogs} loading={false} /> <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 type { AgentLogEntry } from "@fusion/core";
import { ProviderIcon } from "./ProviderIcon"; 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 ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
import type { Components } from "react-markdown"; import type { Components } from "react-markdown";
import { Maximize2, Minimize2, Loader2, ChevronDown, ChevronRight } from "lucide-react"; import { Maximize2, Minimize2, Loader2, ChevronDown, ChevronRight } from "lucide-react";
import "./AgentLogViewer.css";
function formatTimestamp(iso: string): string { function formatTimestamp(iso: string): string {
const date = new Date(iso); const date = new Date(iso);
@@ -84,6 +85,11 @@ function isToolLikeType(type: AgentLogEntry["type"]): boolean {
return type === "tool" || type === "tool_result" || type === "tool_error"; 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 { function shouldShowBadge(entry: AgentLogEntry, previousEntry?: AgentLogEntry): boolean {
if (!entry.agent) return false; if (!entry.agent) return false;
if (isToolLikeType(entry.type)) return true; if (isToolLikeType(entry.type)) return true;
@@ -485,8 +491,9 @@ export function AgentLogViewer({
if (entry.type === "tool") { if (entry.type === "tool") {
return ( return (
<div key={group.key} className="agent-log-tool"> <div key={group.key} className="agent-log-tool">
{agentBadge} {entry.text} {agentBadge}
{entry.detail && <span className="agent-log-tool-detail"> {entry.detail}</span>} <div className="agent-log-tool-title"> {entry.text}</div>
{renderToolDetail(entry.detail)}
</div> </div>
); );
} }
@@ -494,8 +501,9 @@ export function AgentLogViewer({
if (entry.type === "tool_result") { if (entry.type === "tool_result") {
return ( return (
<div key={group.key} className="agent-log-tool-result"> <div key={group.key} className="agent-log-tool-result">
{agentBadge} {entry.text} {agentBadge}
{entry.detail && <span className="agent-log-tool-detail"> {entry.detail}</span>} <div className="agent-log-tool-title"> {entry.text}</div>
{renderToolDetail(entry.detail)}
</div> </div>
); );
} }
@@ -503,8 +511,9 @@ export function AgentLogViewer({
if (entry.type === "tool_error") { if (entry.type === "tool_error") {
return ( return (
<div key={group.key} className="agent-log-tool-error"> <div key={group.key} className="agent-log-tool-error">
{agentBadge} {entry.text} {agentBadge}
{entry.detail && <span className="agent-log-tool-detail"> {entry.detail}</span>} <div className="agent-log-tool-title"> {entry.text}</div>
{renderToolDetail(entry.detail)}
</div> </div>
); );
} }
@@ -525,7 +534,7 @@ export function AgentLogViewer({
</ReactMarkdown> </ReactMarkdown>
</div> </div>
) : ( ) : (
groupedText <pre className="agent-log-plain-block">{groupedText}</pre>
)} )}
</div> </div>
); );
@@ -541,7 +550,7 @@ export function AgentLogViewer({
</ReactMarkdown> </ReactMarkdown>
</div> </div>
) : ( ) : (
groupedText <pre className="agent-log-plain-block">{groupedText}</pre>
)} )}
</div> </div>
); );

View File

@@ -359,300 +359,6 @@
margin-top: var(--space-lg); 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 { .detail-spec-edit-trigger {
margin-bottom: var(--space-md); margin-bottom: var(--space-md);
@@ -1749,8 +1455,4 @@
min-height: 36px; 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"); const detail = container.querySelector(".agent-log-tool-detail");
expect(detail).toBeTruthy(); expect(detail).toBeTruthy();
expect(detail!.textContent).toContain(longDetail); 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", () => { it("renders multiline text content without truncation", () => {
@@ -1168,6 +1168,16 @@ describe("AgentLogViewer", () => {
expect(detail).toBeTruthy(); expect(detail).toBeTruthy();
expect(detail!.textContent).toContain(longDetail); 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", () => { describe("markdown rendering", () => {
@@ -1350,6 +1360,8 @@ describe("AgentLogViewer", () => {
const textSpans = container.querySelectorAll(".agent-log-text"); const textSpans = container.querySelectorAll(".agent-log-text");
expect(textSpans).toHaveLength(1); expect(textSpans).toHaveLength(1);
expect(textSpans[0].textContent).toContain("**bold** and *italic*"); 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 // Plain mode should remove markdown rendering/prose container
expect(textSpans[0].querySelector(".markdown-body")).toBeNull(); expect(textSpans[0].querySelector(".markdown-body")).toBeNull();
expect(textSpans[0].querySelector("strong")).toBeNull(); expect(textSpans[0].querySelector("strong")).toBeNull();
@@ -1407,6 +1419,18 @@ describe("AgentLogViewer", () => {
expect(strong!.textContent).toBe("bold"); 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", () => { it("shows raw markdown syntax literally in plain text mode for text entries", () => {
const entries = [ const entries = [
makeEntry({ text: "## Heading\n\n- item 1\n- item 2\n\n`code` and **bold**" }), 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", () => { it("styles agent log viewer scroll container scrollbar rules", () => {
const css = loadAllAppCss(); const css = loadAllAppCss();
expectBaseRule(css, ".detail-section--agent-log .agent-log-viewer-scroll", "scrollbar-color: var(--border) transparent;"); expectBaseRule(css, ".agent-log-viewer-scroll", "scrollbar-color: var(--border) transparent;");
expectBaseRule(css, ".detail-section--agent-log .agent-log-viewer-scroll", "scrollbar-width: thin;"); expectBaseRule(css, ".agent-log-viewer-scroll", "scrollbar-width: thin;");
expectBaseRule(css, ".detail-section--agent-log .agent-log-viewer-scroll::-webkit-scrollbar", "width: 6px;"); expectBaseRule(css, ".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::-webkit-scrollbar-thumb", "background: var(--border);");
}); });
it("renders markdown-body without detail-prompt class when prompt exists", () => { 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 { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act, waitFor } from "@testing-library/react"; import { renderHook, act, waitFor } from "@testing-library/react";
import { MAX_LOG_ENTRIES, useAgentLogs } from "../useAgentLogs"; import { useAgentLogs } from "../useAgentLogs";
import { fetchAgentLogsWithMeta } from "../../api"; import { fetchAgentLogsWithMeta } from "../../api";
// Mock the api module // Mock the api module
@@ -191,8 +191,9 @@ describe("useAgentLogs", () => {
expect(es.close).toHaveBeenCalled(); expect(es.close).toHaveBeenCalled();
}); });
it("truncates oversized historical logs to the most recent entries", async () => { it("keeps oversized historical logs without truncating older entries", async () => {
const historicalLogs = Array.from({ length: MAX_LOG_ENTRIES + 25 }, (_, index) => ({ const oversizedCount = 525;
const historicalLogs = Array.from({ length: oversizedCount }, (_, index) => ({
timestamp: `2026-01-01T00:${String(index).padStart(2, "0")}:00Z`, timestamp: `2026-01-01T00:${String(index).padStart(2, "0")}:00Z`,
taskId: "FN-001", taskId: "FN-001",
text: `entry-${index}`, text: `entry-${index}`,
@@ -207,15 +208,16 @@ describe("useAgentLogs", () => {
const { result } = renderHook(() => useAgentLogs("FN-001", true)); const { result } = renderHook(() => useAgentLogs("FN-001", true));
await waitFor(() => { 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[0].text).toBe("entry-0");
expect(result.current.entries.at(-1)?.text).toBe(`entry-${MAX_LOG_ENTRIES + 24}`); expect(result.current.entries.at(-1)?.text).toBe(`entry-${oversizedCount - 1}`);
}); });
it("truncates live SSE entries to the most recent entries", async () => { it("keeps oversized live SSE history without truncation", async () => {
mockFetchAgentLogsWithMeta.mockResolvedValueOnce({ entries: [], total: MAX_LOG_ENTRIES + 20, hasMore: false }); const streamedCount = 520;
mockFetchAgentLogsWithMeta.mockResolvedValueOnce({ entries: [], total: streamedCount, hasMore: false });
const { result } = renderHook(() => useAgentLogs("FN-001", true)); const { result } = renderHook(() => useAgentLogs("FN-001", true));
@@ -225,7 +227,7 @@ describe("useAgentLogs", () => {
const es = MockEventSource.instances[0]; const es = MockEventSource.instances[0];
act(() => { act(() => {
for (let index = 0; index < MAX_LOG_ENTRIES + 20; index++) { for (let index = 0; index < streamedCount; index++) {
es._emit("agent:log", { es._emit("agent:log", {
timestamp: `2026-01-01T00:${String(index).padStart(2, "0")}:00Z`, timestamp: `2026-01-01T00:${String(index).padStart(2, "0")}:00Z`,
taskId: "FN-001", taskId: "FN-001",
@@ -236,11 +238,11 @@ describe("useAgentLogs", () => {
}); });
await waitFor(() => { 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[0].text).toBe("live-0");
expect(result.current.entries.at(-1)?.text).toBe(`live-${MAX_LOG_ENTRIES + 19}`); expect(result.current.entries.at(-1)?.text).toBe(`live-${streamedCount - 1}`);
}); });
it("does not fetch when taskId is null", () => { 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 () => { it("loadMore does not trigger when already loading more", async () => {
mockFetchAgentLogsWithMeta.mockResolvedValue({ entries: [], total: 200, hasMore: true }); mockFetchAgentLogsWithMeta.mockResolvedValue({ entries: [], total: 200, hasMore: true });

View File

@@ -3,23 +3,8 @@ import type { AgentLogEntry } from "@fusion/core";
import { fetchAgentLogsWithMeta } from "../api"; import { fetchAgentLogsWithMeta } from "../api";
import { subscribeSse } from "../sse-bus"; import { subscribeSse } from "../sse-bus";
export const MAX_LOG_ENTRIES = 500;
const INITIAL_LOAD_LIMIT = 100; 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. * 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) { requestVersionRef.current !== requestVersion) {
return; return;
} }
setEntries(capLogEntries(result.entries)); setEntries(result.entries);
setHasMore(result.hasMore); setHasMore(result.hasMore);
setTotal(result.total); setTotal(result.total);
} catch { } catch {
@@ -162,7 +147,7 @@ export function useAgentLogs(taskId: string | null, enabled: boolean, projectId?
} }
try { try {
const entry: AgentLogEntry = JSON.parse(e.data); const entry: AgentLogEntry = JSON.parse(e.data);
setEntries((prev) => capLogEntries([...prev, entry])); setEntries((prev) => [...prev, entry]);
setTotal((prev) => (prev !== null ? prev + 1 : null)); setTotal((prev) => (prev !== null ? prev + 1 : null));
} catch { } catch {
// skip malformed events // skip malformed events
@@ -209,10 +194,7 @@ export function useAgentLogs(taskId: string | null, enabled: boolean, projectId?
} }
// Prepend older entries to the existing list // Prepend older entries to the existing list
setEntries((prev) => { setEntries((prev) => [...result.entries, ...prev]);
const combined = [...result.entries, ...prev];
return capLogEntries(combined);
});
setHasMore(result.hasMore); setHasMore(result.hasMore);
setTotal(result.total); setTotal(result.total);
} catch { } catch {

View File

@@ -327,7 +327,23 @@ describe("AgentLogger", () => {
expect(store.appendAgentLog).toHaveBeenCalledWith("FN-016", "Read", "tool_error", "file not found", "executor"); expect(store.appendAgentLog).toHaveBeenCalledWith("FN-016", "Read", "tool_error", "file not found", "executor");
}); });
it("truncates long tool results to 500 chars", async () => { it("preserves long tool errors without truncation", async () => {
const store = createMockStore();
const logger = new AgentLogger({
store,
taskId: "FN-016B",
agent: "executor",
});
const longError = "error:" + "y".repeat(1200);
logger.onToolEnd("Read", true, longError);
await vi.advanceTimersByTimeAsync(0);
const call = (store.appendAgentLog as ReturnType<typeof vi.fn>).mock.calls[0];
expect(call[3]).toBe(longError);
});
it("preserves long tool results without truncation", async () => {
const store = createMockStore(); const store = createMockStore();
const logger = new AgentLogger({ const logger = new AgentLogger({
store, store,
@@ -340,7 +356,7 @@ describe("AgentLogger", () => {
await vi.advanceTimersByTimeAsync(0); await vi.advanceTimersByTimeAsync(0);
const call = (store.appendAgentLog as ReturnType<typeof vi.fn>).mock.calls[0]; const call = (store.appendAgentLog as ReturnType<typeof vi.fn>).mock.calls[0];
expect(call[3]).toBe("x".repeat(500) + "…"); expect(call[3]).toBe(longResult);
}); });
it("handles undefined result in onToolEnd", async () => { it("handles undefined result in onToolEnd", async () => {

View File

@@ -160,14 +160,13 @@ export class AgentLogger {
* *
* @param name - The tool name * @param name - The tool name
* @param isError - Whether the tool execution resulted in an error * @param isError - Whether the tool execution resulted in an error
* @param result - Optional result value (truncated for persistence) * @param result - Optional result value (persisted in full)
*/ */
onToolEnd(name: string, isError: boolean, result?: unknown): void { onToolEnd(name: string, isError: boolean, result?: unknown): void {
const type = isError ? "tool_error" : "tool_result"; const type = isError ? "tool_error" : "tool_result";
let detail: string | undefined; let detail: string | undefined;
if (result !== undefined && result !== null) { if (result !== undefined && result !== null) {
const str = typeof result === "string" ? result : JSON.stringify(result); detail = typeof result === "string" ? result : JSON.stringify(result);
detail = str.length > 500 ? str.slice(0, 500) + "…" : str;
} }
this.store.appendAgentLog(this.taskId, name, type, detail, this.agent).catch((err) => { this.store.appendAgentLog(this.taskId, name, type, detail, this.agent).catch((err) => {
this.log.warn(`Failed to log tool end "${name}" (${type}) for ${this.taskId}: ${err instanceof Error ? err.message : String(err)}`); this.log.warn(`Failed to log tool end "${name}" (${type}) for ${this.taskId}: ${err instanceof Error ? err.message : String(err)}`);