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:
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
333
packages/dashboard/app/components/AgentLogViewer.css
Normal file
333
packages/dashboard/app/components/AgentLogViewer.css
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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**" }),
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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)}`);
|
||||||
|
|||||||
Reference in New Issue
Block a user