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:
@@ -478,6 +478,84 @@
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.run-logs-container {
|
||||
padding: var(--space-md);
|
||||
background: var(--bg-secondary);
|
||||
border-top: 1px solid var(--border);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.run-details-loading-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm) 0;
|
||||
}
|
||||
|
||||
.run-output-sections {
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.run-output-section {
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.run-output-label {
|
||||
margin-bottom: var(--space-xs);
|
||||
color: var(--text-muted);
|
||||
font-size: calc(var(--space-sm) + var(--space-xs));
|
||||
font-weight: 600;
|
||||
letter-spacing: calc(var(--space-xs) * 0.1);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.run-output-label--error {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.run-output-panel {
|
||||
margin: 0;
|
||||
max-height: calc(var(--space-2xl) * 6.25);
|
||||
overflow: auto;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-size: calc(var(--space-sm) + var(--space-xs));
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.run-output-panel--error {
|
||||
color: var(--color-error);
|
||||
border-color: color-mix(in srgb, var(--color-error) 35%, var(--border));
|
||||
background: color-mix(in srgb, var(--color-error) 10%, transparent);
|
||||
}
|
||||
|
||||
.run-context-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-xs) var(--space-md);
|
||||
font-size: calc(var(--space-sm) + var(--space-xs));
|
||||
}
|
||||
|
||||
.run-context-item {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.run-output-empty {
|
||||
padding: var(--space-sm) 0;
|
||||
font-style: italic;
|
||||
font-size: calc(var(--space-sm) + var(--space-xs));
|
||||
}
|
||||
|
||||
.run-agent-logs-section {
|
||||
margin-top: var(--space-xs);
|
||||
padding-top: var(--space-sm);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* --- Tasks Tab --- */
|
||||
.agent-tasks-list {
|
||||
display: flex;
|
||||
@@ -1211,6 +1289,19 @@
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.run-logs-container {
|
||||
padding: var(--space-sm);
|
||||
}
|
||||
|
||||
.run-output-panel {
|
||||
max-height: calc(var(--space-2xl) * 5);
|
||||
}
|
||||
|
||||
.run-context-grid {
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.run-details .text-muted {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
|
||||
@@ -1303,50 +1303,28 @@ function RunsTab({
|
||||
</div>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<div
|
||||
className="run-logs-container"
|
||||
style={{
|
||||
padding: "12px",
|
||||
background: "var(--bg-secondary)",
|
||||
borderBottom: "1px solid var(--border-color)",
|
||||
borderTop: "1px solid var(--border-color)",
|
||||
}}
|
||||
>
|
||||
<div className="run-logs-container">
|
||||
{/* Execution Details */}
|
||||
{isLoadingDetail ? (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "8px", padding: "8px 0" }}>
|
||||
<div className="run-details-loading-state">
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
<span className="text-muted">Loading details...</span>
|
||||
</div>
|
||||
) : detailRun && (
|
||||
<div style={{ marginBottom: "12px" }}>
|
||||
<div className="run-output-sections">
|
||||
{/* Token Usage */}
|
||||
{detailRun.usageJson && (
|
||||
<div style={{ marginBottom: "8px" }}>
|
||||
<div style={{ fontSize: "11px", fontWeight: 600, textTransform: "uppercase", color: "var(--text-secondary)", marginBottom: "4px" }}>
|
||||
Token Usage
|
||||
</div>
|
||||
<div className="run-output-section">
|
||||
<div className="run-output-label">Token Usage</div>
|
||||
{renderUsage(detailRun.usageJson)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Output */}
|
||||
{detailRun.stdoutExcerpt && (
|
||||
<div style={{ marginBottom: "8px" }}>
|
||||
<div style={{ fontSize: "11px", fontWeight: 600, textTransform: "uppercase", color: "var(--text-secondary)", marginBottom: "4px" }}>
|
||||
Output
|
||||
</div>
|
||||
<pre style={{
|
||||
background: "var(--bg-tertiary, #161b22)",
|
||||
padding: "8px 12px",
|
||||
borderRadius: "6px",
|
||||
fontSize: "12px",
|
||||
maxHeight: "200px",
|
||||
overflow: "auto",
|
||||
margin: 0,
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
}}>
|
||||
<div className="run-output-section">
|
||||
<div className="run-output-label">Output</div>
|
||||
<pre className="run-output-panel">
|
||||
{detailRun.stdoutExcerpt.length > 2000
|
||||
? `${detailRun.stdoutExcerpt.slice(0, 2000)}\n\n... (truncated, ${detailRun.stdoutExcerpt.length} chars total)`
|
||||
: detailRun.stdoutExcerpt}
|
||||
@@ -1356,58 +1334,27 @@ function RunsTab({
|
||||
|
||||
{/* Errors */}
|
||||
{detailRun.stderrExcerpt && (
|
||||
<div style={{ marginBottom: "8px" }}>
|
||||
<div style={{ fontSize: "11px", fontWeight: 600, textTransform: "uppercase", color: "var(--color-error, #f85149)", marginBottom: "4px" }}>
|
||||
Errors
|
||||
</div>
|
||||
<pre style={{
|
||||
background: "rgba(248, 81, 73, 0.1)",
|
||||
color: "var(--color-error, #f85149)",
|
||||
padding: "8px 12px",
|
||||
borderRadius: "6px",
|
||||
fontSize: "12px",
|
||||
maxHeight: "200px",
|
||||
overflow: "auto",
|
||||
margin: 0,
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
}}>
|
||||
{detailRun.stderrExcerpt}
|
||||
</pre>
|
||||
<div className="run-output-section">
|
||||
<div className="run-output-label run-output-label--error">Errors</div>
|
||||
<pre className="run-output-panel run-output-panel--error">{detailRun.stderrExcerpt}</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Result */}
|
||||
{detailRun.resultJson && (
|
||||
<div style={{ marginBottom: "8px" }}>
|
||||
<div style={{ fontSize: "11px", fontWeight: 600, textTransform: "uppercase", color: "var(--text-secondary)", marginBottom: "4px" }}>
|
||||
Result
|
||||
</div>
|
||||
<pre style={{
|
||||
background: "var(--bg-tertiary, #161b22)",
|
||||
padding: "8px 12px",
|
||||
borderRadius: "6px",
|
||||
fontSize: "12px",
|
||||
maxHeight: "200px",
|
||||
overflow: "auto",
|
||||
margin: 0,
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
}}>
|
||||
{JSON.stringify(detailRun.resultJson, null, 2)}
|
||||
</pre>
|
||||
<div className="run-output-section">
|
||||
<div className="run-output-label">Result</div>
|
||||
<pre className="run-output-panel">{JSON.stringify(detailRun.resultJson, null, 2)}</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Context */}
|
||||
{detailRun.contextSnapshot && Object.keys(detailRun.contextSnapshot).length > 0 && (
|
||||
<div style={{ marginBottom: "8px" }}>
|
||||
<div style={{ fontSize: "11px", fontWeight: 600, textTransform: "uppercase", color: "var(--text-secondary)", marginBottom: "4px" }}>
|
||||
Context
|
||||
</div>
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: "4px 12px", fontSize: "12px" }}>
|
||||
<div className="run-output-section">
|
||||
<div className="run-output-label">Context</div>
|
||||
<div className="run-context-grid">
|
||||
{Object.entries(detailRun.contextSnapshot).map(([key, value]) => (
|
||||
<span key={key}>
|
||||
<span key={key} className="run-context-item">
|
||||
<span className="text-muted">{key}:</span>{" "}
|
||||
<span>{String(value)}</span>
|
||||
</span>
|
||||
@@ -1418,27 +1365,21 @@ function RunsTab({
|
||||
|
||||
{/* No output state */}
|
||||
{!detailRun.stdoutExcerpt && !detailRun.stderrExcerpt && !detailRun.resultJson && (
|
||||
<div className="text-muted" style={{ padding: "8px 0", fontStyle: "italic", fontSize: "12px" }}>
|
||||
No output captured
|
||||
</div>
|
||||
<div className="text-muted run-output-empty">No output captured</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Run Logs */}
|
||||
<div style={{ borderTop: "1px solid var(--border-color)", paddingTop: "8px", marginTop: "4px" }}>
|
||||
<div style={{ fontSize: "11px", fontWeight: 600, textTransform: "uppercase", color: "var(--text-secondary)", marginBottom: "4px" }}>
|
||||
Agent Logs
|
||||
</div>
|
||||
<div className="run-agent-logs-section">
|
||||
<div className="run-output-label">Agent Logs</div>
|
||||
{isLoadingLogs ? (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "8px", padding: "8px 0" }}>
|
||||
<div className="run-details-loading-state">
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
<span className="text-muted">Loading logs...</span>
|
||||
</div>
|
||||
) : runLogs.length === 0 ? (
|
||||
<div className="text-muted" style={{ padding: "8px 0", fontStyle: "italic" }}>
|
||||
No logs available for this run
|
||||
</div>
|
||||
<div className="text-muted run-output-empty">No logs available for this run</div>
|
||||
) : (
|
||||
<AgentLogViewer entries={runLogs} loading={false} />
|
||||
)}
|
||||
|
||||
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 { ProviderIcon } from "./ProviderIcon";
|
||||
import { useRef, useEffect, useState, useCallback, useLayoutEffect, useMemo } from "react";
|
||||
import { useRef, useEffect, useState, useCallback, useLayoutEffect, useMemo, type ReactElement } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import type { Components } from "react-markdown";
|
||||
import { Maximize2, Minimize2, Loader2, ChevronDown, ChevronRight } from "lucide-react";
|
||||
import "./AgentLogViewer.css";
|
||||
|
||||
function formatTimestamp(iso: string): string {
|
||||
const date = new Date(iso);
|
||||
@@ -84,6 +85,11 @@ function isToolLikeType(type: AgentLogEntry["type"]): boolean {
|
||||
return type === "tool" || type === "tool_result" || type === "tool_error";
|
||||
}
|
||||
|
||||
function renderToolDetail(detail?: string): ReactElement | null {
|
||||
if (!detail) return null;
|
||||
return <pre className="agent-log-tool-detail">{detail}</pre>;
|
||||
}
|
||||
|
||||
function shouldShowBadge(entry: AgentLogEntry, previousEntry?: AgentLogEntry): boolean {
|
||||
if (!entry.agent) return false;
|
||||
if (isToolLikeType(entry.type)) return true;
|
||||
@@ -485,8 +491,9 @@ export function AgentLogViewer({
|
||||
if (entry.type === "tool") {
|
||||
return (
|
||||
<div key={group.key} className="agent-log-tool">
|
||||
{agentBadge}⚡ {entry.text}
|
||||
{entry.detail && <span className="agent-log-tool-detail">— {entry.detail}</span>}
|
||||
{agentBadge}
|
||||
<div className="agent-log-tool-title">⚡ {entry.text}</div>
|
||||
{renderToolDetail(entry.detail)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -494,8 +501,9 @@ export function AgentLogViewer({
|
||||
if (entry.type === "tool_result") {
|
||||
return (
|
||||
<div key={group.key} className="agent-log-tool-result">
|
||||
{agentBadge}✓ {entry.text}
|
||||
{entry.detail && <span className="agent-log-tool-detail">— {entry.detail}</span>}
|
||||
{agentBadge}
|
||||
<div className="agent-log-tool-title">✓ {entry.text}</div>
|
||||
{renderToolDetail(entry.detail)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -503,8 +511,9 @@ export function AgentLogViewer({
|
||||
if (entry.type === "tool_error") {
|
||||
return (
|
||||
<div key={group.key} className="agent-log-tool-error">
|
||||
{agentBadge}✗ {entry.text}
|
||||
{entry.detail && <span className="agent-log-tool-detail">— {entry.detail}</span>}
|
||||
{agentBadge}
|
||||
<div className="agent-log-tool-title">✗ {entry.text}</div>
|
||||
{renderToolDetail(entry.detail)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -525,7 +534,7 @@ export function AgentLogViewer({
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
groupedText
|
||||
<pre className="agent-log-plain-block">{groupedText}</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -541,7 +550,7 @@ export function AgentLogViewer({
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
groupedText
|
||||
<pre className="agent-log-plain-block">{groupedText}</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -359,300 +359,6 @@
|
||||
margin-top: var(--space-lg);
|
||||
}
|
||||
|
||||
.detail-section--agent-log .agent-log-viewer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.detail-section--agent-log .agent-log-viewer-scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
scrollbar-color: var(--border) transparent;
|
||||
scrollbar-width: thin;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--text);
|
||||
padding: var(--space-md);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.detail-section--agent-log .agent-log-viewer-scroll::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.detail-section--agent-log .agent-log-viewer-scroll::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.detail-section--agent-log .agent-log-viewer-scroll::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.detail-section--agent-log .agent-log-viewer-scroll::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-muted);
|
||||
}
|
||||
|
||||
.agent-log-model-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-lg);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
margin-bottom: var(--space-md);
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.agent-log-model-icons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.agent-log-model-expand-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-xs);
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
.agent-log-model-expand-btn:hover {
|
||||
color: var(--text);
|
||||
background: var(--card-hover);
|
||||
}
|
||||
|
||||
.agent-log-model-expand-btn:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring-strong);
|
||||
}
|
||||
|
||||
.agent-log-model-details {
|
||||
display: flex;
|
||||
flex-basis: 100%;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-lg);
|
||||
padding-top: var(--space-sm);
|
||||
margin-top: var(--space-sm);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.agent-log-model-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.agent-log-model-label {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.agent-log-model-value {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.agent-log-model-header-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.agent-log-badge-row {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.agent-log-tool {
|
||||
color: var(--accent);
|
||||
margin: 4px 0;
|
||||
padding: 2px 6px;
|
||||
border-left: 3px solid var(--accent);
|
||||
background: var(--log-tool-bg);
|
||||
}
|
||||
|
||||
.agent-log-tool-detail {
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.agent-log-tool-result {
|
||||
color: var(--color-success);
|
||||
margin: 2px 0;
|
||||
padding: 2px 6px;
|
||||
border-left: 3px solid var(--color-success);
|
||||
background: var(--log-success-bg);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.agent-log-tool-error {
|
||||
color: var(--color-error);
|
||||
margin: 2px 0;
|
||||
padding: 2px 6px;
|
||||
border-left: 3px solid var(--color-error);
|
||||
background: var(--log-error-bg);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.agent-log-text {
|
||||
display: block;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.agent-log-thinking {
|
||||
display: block;
|
||||
font-style: italic;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.agent-log-agent-badge {
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
margin-right: 6px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.agent-log-timestamp {
|
||||
color: var(--text-muted);
|
||||
font-size: 10px;
|
||||
margin-right: 6px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Agent Log markdown/plain-text mode toggle */
|
||||
.agent-log-mode-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: calc(var(--space-xl) + var(--space-xl) + var(--space-xs));
|
||||
min-height: calc(var(--space-lg) + var(--space-lg) + var(--space-xs));
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.agent-log-mode-toggle:hover {
|
||||
background: var(--card-hover);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.agent-log-mode-toggle[aria-pressed="true"] {
|
||||
background: var(--accent);
|
||||
color: var(--text-on-accent, #fff);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* Agent log pagination summary */
|
||||
.agent-log-summary {
|
||||
padding: var(--space-xs) var(--space-md);
|
||||
font-size: var(--text-xs, 12px);
|
||||
color: var(--text-muted, #8b949e);
|
||||
border-bottom: 1px solid var(--border);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Agent log load more button */
|
||||
.agent-log-load-more {
|
||||
padding: var(--space-md);
|
||||
text-align: center;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.agent-log-load-more .agent-log-mode-toggle {
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.agent-log-return-to-live {
|
||||
position: sticky;
|
||||
right: var(--space-md);
|
||||
bottom: var(--space-md);
|
||||
width: fit-content;
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
color: var(--text-muted);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-md);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.agent-log-return-to-live:hover {
|
||||
background: var(--card-hover);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.agent-log-return-to-live:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring-strong);
|
||||
}
|
||||
|
||||
/* Fullscreen mode for agent log viewer */
|
||||
.agent-log-viewer--fullscreen {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
background: var(--surface);
|
||||
/* iOS PWA: pad inwards from notches / status bar / home indicator. */
|
||||
padding: max(var(--space-lg), env(safe-area-inset-top, 0px))
|
||||
max(var(--space-lg), env(safe-area-inset-right, 0px))
|
||||
max(var(--space-lg), env(safe-area-inset-bottom, 0px))
|
||||
max(var(--space-lg), env(safe-area-inset-left, 0px));
|
||||
}
|
||||
|
||||
.agent-log-viewer--fullscreen .agent-log-model-header {
|
||||
margin-bottom: 0;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.agent-log-viewer--fullscreen .agent-log-viewer-scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.detail-spec-edit-trigger {
|
||||
margin-bottom: var(--space-md);
|
||||
@@ -1749,8 +1455,4 @@
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.agent-log-return-to-live {
|
||||
right: var(--space-sm);
|
||||
bottom: var(--space-sm);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1123,7 +1123,7 @@ describe("AgentLogViewer", () => {
|
||||
const detail = container.querySelector(".agent-log-tool-detail");
|
||||
expect(detail).toBeTruthy();
|
||||
expect(detail!.textContent).toContain(longDetail);
|
||||
expect(detail!.textContent!.length).toBeGreaterThan(5000); // " — " prefix adds a few chars
|
||||
expect(detail!.textContent!.length).toBe(5000);
|
||||
});
|
||||
|
||||
it("renders multiline text content without truncation", () => {
|
||||
@@ -1168,6 +1168,16 @@ describe("AgentLogViewer", () => {
|
||||
expect(detail).toBeTruthy();
|
||||
expect(detail!.textContent).toContain(longDetail);
|
||||
});
|
||||
|
||||
it("preserves raw whitespace in tool detail blocks", () => {
|
||||
const detailText = "stdout:\n line one\n indented line two\n";
|
||||
const entries = [makeEntry({ text: "Bash", type: "tool_result", detail: detailText })];
|
||||
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
|
||||
const detail = container.querySelector(".agent-log-tool-detail") as HTMLElement;
|
||||
expect(detail).toBeTruthy();
|
||||
expect(detail.tagName).toBe("PRE");
|
||||
expect(detail.textContent).toBe(detailText);
|
||||
});
|
||||
});
|
||||
|
||||
describe("markdown rendering", () => {
|
||||
@@ -1350,6 +1360,8 @@ describe("AgentLogViewer", () => {
|
||||
const textSpans = container.querySelectorAll(".agent-log-text");
|
||||
expect(textSpans).toHaveLength(1);
|
||||
expect(textSpans[0].textContent).toContain("**bold** and *italic*");
|
||||
const plainBlock = textSpans[0].querySelector(".agent-log-plain-block") as HTMLElement;
|
||||
expect(plainBlock).toBeTruthy();
|
||||
// Plain mode should remove markdown rendering/prose container
|
||||
expect(textSpans[0].querySelector(".markdown-body")).toBeNull();
|
||||
expect(textSpans[0].querySelector("strong")).toBeNull();
|
||||
@@ -1407,6 +1419,18 @@ describe("AgentLogViewer", () => {
|
||||
expect(strong!.textContent).toBe("bold");
|
||||
});
|
||||
|
||||
it("preserves line breaks in plain text mode for thinking entries", () => {
|
||||
const entries = [makeEntry({ text: "line1\nline2\nline3", type: "thinking", agent: "executor" })];
|
||||
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
|
||||
const toggle = container.querySelector("[data-testid='agent-log-mode-toggle']") as HTMLButtonElement;
|
||||
|
||||
fireEvent.click(toggle);
|
||||
|
||||
const plainThinking = container.querySelector(".agent-log-thinking .agent-log-plain-block") as HTMLElement;
|
||||
expect(plainThinking).toBeTruthy();
|
||||
expect(plainThinking.textContent).toContain("line1\nline2\nline3");
|
||||
});
|
||||
|
||||
it("shows raw markdown syntax literally in plain text mode for text entries", () => {
|
||||
const entries = [
|
||||
makeEntry({ text: "## Heading\n\n- item 1\n- item 2\n\n`code` and **bold**" }),
|
||||
|
||||
@@ -229,10 +229,10 @@ describe("TaskDetailModal", () => {
|
||||
it("styles agent log viewer scroll container scrollbar rules", () => {
|
||||
const css = loadAllAppCss();
|
||||
|
||||
expectBaseRule(css, ".detail-section--agent-log .agent-log-viewer-scroll", "scrollbar-color: var(--border) transparent;");
|
||||
expectBaseRule(css, ".detail-section--agent-log .agent-log-viewer-scroll", "scrollbar-width: thin;");
|
||||
expectBaseRule(css, ".detail-section--agent-log .agent-log-viewer-scroll::-webkit-scrollbar", "width: 6px;");
|
||||
expectBaseRule(css, ".detail-section--agent-log .agent-log-viewer-scroll::-webkit-scrollbar-thumb", "background: var(--border);");
|
||||
expectBaseRule(css, ".agent-log-viewer-scroll", "scrollbar-color: var(--border) transparent;");
|
||||
expectBaseRule(css, ".agent-log-viewer-scroll", "scrollbar-width: thin;");
|
||||
expectBaseRule(css, ".agent-log-viewer-scroll::-webkit-scrollbar", "width: 6px;");
|
||||
expectBaseRule(css, ".agent-log-viewer-scroll::-webkit-scrollbar-thumb", "background: var(--border);");
|
||||
});
|
||||
|
||||
it("renders markdown-body without detail-prompt class when prompt exists", () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act, waitFor } from "@testing-library/react";
|
||||
import { MAX_LOG_ENTRIES, useAgentLogs } from "../useAgentLogs";
|
||||
import { useAgentLogs } from "../useAgentLogs";
|
||||
import { fetchAgentLogsWithMeta } from "../../api";
|
||||
|
||||
// Mock the api module
|
||||
@@ -191,8 +191,9 @@ describe("useAgentLogs", () => {
|
||||
expect(es.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("truncates oversized historical logs to the most recent entries", async () => {
|
||||
const historicalLogs = Array.from({ length: MAX_LOG_ENTRIES + 25 }, (_, index) => ({
|
||||
it("keeps oversized historical logs without truncating older entries", async () => {
|
||||
const oversizedCount = 525;
|
||||
const historicalLogs = Array.from({ length: oversizedCount }, (_, index) => ({
|
||||
timestamp: `2026-01-01T00:${String(index).padStart(2, "0")}:00Z`,
|
||||
taskId: "FN-001",
|
||||
text: `entry-${index}`,
|
||||
@@ -207,15 +208,16 @@ describe("useAgentLogs", () => {
|
||||
const { result } = renderHook(() => useAgentLogs("FN-001", true));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.entries).toHaveLength(MAX_LOG_ENTRIES);
|
||||
expect(result.current.entries).toHaveLength(oversizedCount);
|
||||
});
|
||||
|
||||
expect(result.current.entries[0].text).toBe("entry-25");
|
||||
expect(result.current.entries.at(-1)?.text).toBe(`entry-${MAX_LOG_ENTRIES + 24}`);
|
||||
expect(result.current.entries[0].text).toBe("entry-0");
|
||||
expect(result.current.entries.at(-1)?.text).toBe(`entry-${oversizedCount - 1}`);
|
||||
});
|
||||
|
||||
it("truncates live SSE entries to the most recent entries", async () => {
|
||||
mockFetchAgentLogsWithMeta.mockResolvedValueOnce({ entries: [], total: MAX_LOG_ENTRIES + 20, hasMore: false });
|
||||
it("keeps oversized live SSE history without truncation", async () => {
|
||||
const streamedCount = 520;
|
||||
mockFetchAgentLogsWithMeta.mockResolvedValueOnce({ entries: [], total: streamedCount, hasMore: false });
|
||||
|
||||
const { result } = renderHook(() => useAgentLogs("FN-001", true));
|
||||
|
||||
@@ -225,7 +227,7 @@ describe("useAgentLogs", () => {
|
||||
|
||||
const es = MockEventSource.instances[0];
|
||||
act(() => {
|
||||
for (let index = 0; index < MAX_LOG_ENTRIES + 20; index++) {
|
||||
for (let index = 0; index < streamedCount; index++) {
|
||||
es._emit("agent:log", {
|
||||
timestamp: `2026-01-01T00:${String(index).padStart(2, "0")}:00Z`,
|
||||
taskId: "FN-001",
|
||||
@@ -236,11 +238,11 @@ describe("useAgentLogs", () => {
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.entries).toHaveLength(MAX_LOG_ENTRIES);
|
||||
expect(result.current.entries).toHaveLength(streamedCount);
|
||||
});
|
||||
|
||||
expect(result.current.entries[0].text).toBe("live-20");
|
||||
expect(result.current.entries.at(-1)?.text).toBe(`live-${MAX_LOG_ENTRIES + 19}`);
|
||||
expect(result.current.entries[0].text).toBe("live-0");
|
||||
expect(result.current.entries.at(-1)?.text).toBe(`live-${streamedCount - 1}`);
|
||||
});
|
||||
|
||||
it("does not fetch when taskId is null", () => {
|
||||
@@ -370,6 +372,56 @@ describe("useAgentLogs", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps full history across initial load, loadMore, and live streaming", async () => {
|
||||
const initialLogs = Array.from({ length: 300 }, (_, index) => ({
|
||||
timestamp: `2026-01-02T00:${String(index).padStart(2, "0")}:00Z`,
|
||||
taskId: "FN-001",
|
||||
text: `initial-${index}`,
|
||||
type: "text" as const,
|
||||
}));
|
||||
const olderLogs = Array.from({ length: 250 }, (_, index) => ({
|
||||
timestamp: `2026-01-01T00:${String(index).padStart(2, "0")}:00Z`,
|
||||
taskId: "FN-001",
|
||||
text: `older-${index}`,
|
||||
type: "text" as const,
|
||||
}));
|
||||
|
||||
mockFetchAgentLogsWithMeta
|
||||
.mockResolvedValueOnce({ entries: initialLogs, total: 550, hasMore: true })
|
||||
.mockResolvedValueOnce({ entries: olderLogs, total: 550, hasMore: false });
|
||||
|
||||
const { result } = renderHook(() => useAgentLogs("FN-001", true));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.entries).toHaveLength(300);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.loadMore();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.entries).toHaveLength(550);
|
||||
});
|
||||
|
||||
const es = MockEventSource.instances[0];
|
||||
act(() => {
|
||||
es._emit("agent:log", {
|
||||
timestamp: "2026-01-03T00:00:00Z",
|
||||
taskId: "FN-001",
|
||||
text: "live-after-large-history",
|
||||
type: "text",
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.entries).toHaveLength(551);
|
||||
});
|
||||
|
||||
expect(result.current.entries[0].text).toBe("older-0");
|
||||
expect(result.current.entries.at(-1)?.text).toBe("live-after-large-history");
|
||||
});
|
||||
|
||||
it("loadMore does not trigger when already loading more", async () => {
|
||||
mockFetchAgentLogsWithMeta.mockResolvedValue({ entries: [], total: 200, hasMore: true });
|
||||
|
||||
|
||||
@@ -3,23 +3,8 @@ import type { AgentLogEntry } from "@fusion/core";
|
||||
import { fetchAgentLogsWithMeta } from "../api";
|
||||
import { subscribeSse } from "../sse-bus";
|
||||
|
||||
export const MAX_LOG_ENTRIES = 500;
|
||||
const INITIAL_LOAD_LIMIT = 100;
|
||||
|
||||
/**
|
||||
* Cap the total number of log entries to `MAX_LOG_ENTRIES`.
|
||||
*
|
||||
* This is a **whole-list cap** — it limits how many entries are kept
|
||||
* in memory, not the content of any individual entry. Per-entry `text`
|
||||
* and `detail` fields are never truncated anywhere in the pipeline
|
||||
* (persistence → API → SSE → hook → rendering).
|
||||
*/
|
||||
function capLogEntries(entries: AgentLogEntry[]): AgentLogEntry[] {
|
||||
return entries.length > MAX_LOG_ENTRIES
|
||||
? entries.slice(-MAX_LOG_ENTRIES)
|
||||
: entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that manages agent log fetching and live SSE streaming for a task.
|
||||
*
|
||||
@@ -127,7 +112,7 @@ export function useAgentLogs(taskId: string | null, enabled: boolean, projectId?
|
||||
requestVersionRef.current !== requestVersion) {
|
||||
return;
|
||||
}
|
||||
setEntries(capLogEntries(result.entries));
|
||||
setEntries(result.entries);
|
||||
setHasMore(result.hasMore);
|
||||
setTotal(result.total);
|
||||
} catch {
|
||||
@@ -162,7 +147,7 @@ export function useAgentLogs(taskId: string | null, enabled: boolean, projectId?
|
||||
}
|
||||
try {
|
||||
const entry: AgentLogEntry = JSON.parse(e.data);
|
||||
setEntries((prev) => capLogEntries([...prev, entry]));
|
||||
setEntries((prev) => [...prev, entry]);
|
||||
setTotal((prev) => (prev !== null ? prev + 1 : null));
|
||||
} catch {
|
||||
// skip malformed events
|
||||
@@ -209,10 +194,7 @@ export function useAgentLogs(taskId: string | null, enabled: boolean, projectId?
|
||||
}
|
||||
|
||||
// Prepend older entries to the existing list
|
||||
setEntries((prev) => {
|
||||
const combined = [...result.entries, ...prev];
|
||||
return capLogEntries(combined);
|
||||
});
|
||||
setEntries((prev) => [...result.entries, ...prev]);
|
||||
setHasMore(result.hasMore);
|
||||
setTotal(result.total);
|
||||
} catch {
|
||||
|
||||
Reference in New Issue
Block a user