diff --git a/src/app/shared/task-execution-viewer/execution-viewer.utils.spec.ts b/src/app/shared/task-execution-viewer/execution-viewer.utils.spec.ts index d2ff3f3..e277c3a 100644 --- a/src/app/shared/task-execution-viewer/execution-viewer.utils.spec.ts +++ b/src/app/shared/task-execution-viewer/execution-viewer.utils.spec.ts @@ -7,6 +7,7 @@ import { buildAuthorizationGate, buildVisibleExecutionLogs, executionEventTitle, + isScrolledToEnd, getExecutionInputValues, getExecutionOutputValues, hasStoredValue, @@ -60,6 +61,23 @@ describe('execution viewer runtime values', () => { expect(event.detailsText).toContain('"budgetChars": 60000'); }); + it('keeps following while the end is in view, and lets go once the reader scrolls back', () => { + // The whole point: a log that yanks itself to the newest line every poll cannot be read backwards. + expect(isScrolledToEnd({ scrollHeight: 1000, scrollTop: 800, clientHeight: 200 })).toBe(true); + expect(isScrolledToEnd({ scrollHeight: 1000, scrollTop: 0, clientHeight: 200 })).toBe(false); + }); + + it('tolerates a line arriving between the scroll and the check', () => { + // 20px short of the end: a list that just grew must not read as "the reader scrolled away". + expect(isScrolledToEnd({ scrollHeight: 1020, scrollTop: 800, clientHeight: 200 })).toBe(true); + // 200px short is a deliberate move back, and following stops. + expect(isScrolledToEnd({ scrollHeight: 1200, scrollTop: 800, clientHeight: 200 })).toBe(false); + }); + + it('counts a list shorter than its viewport as being at the end', () => { + expect(isScrolledToEnd({ scrollHeight: 120, scrollTop: 0, clientHeight: 400 })).toBe(true); + }); + it('names an event in two words, so a paragraph does not end up as a title', () => { // "Still 6332 character(s) over budget after pruning everything available..." set as a heading // wraps five lines and reads as an error page; the type says the same thing short. diff --git a/src/app/shared/task-execution-viewer/execution-viewer.utils.ts b/src/app/shared/task-execution-viewer/execution-viewer.utils.ts index 558be8c..2179120 100644 --- a/src/app/shared/task-execution-viewer/execution-viewer.utils.ts +++ b/src/app/shared/task-execution-viewer/execution-viewer.utils.ts @@ -258,6 +258,20 @@ export function buildVisibleExecutionLogs(logs: ExecutionEventLogEntry[]): Execu }); } +/** + * How close to the end still counts as being at the end, in pixels. + * + *

Not zero: a list that grew by a line between the scroll and the handler would otherwise read as + * "the reader scrolled away", and following would stop on its own while nobody touched anything. + */ +export const LOGS_FOLLOW_THRESHOLD_PX = 32; + +/** Whether a scrolling viewport is close enough to its end for a log to keep following it. */ +export function isScrolledToEnd(viewport: { scrollHeight: number; scrollTop: number; clientHeight: number }): boolean { + const distanceFromBottom = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight; + return distanceFromBottom <= LOGS_FOLLOW_THRESHOLD_PX; +} + /** Initialisms that read as shouting when title-cased, and as typos when lower-cased. */ const EVENT_TITLE_INITIALISMS = new Set(['llm', 'mcp', 'http', 'id']); diff --git a/src/app/shared/task-execution-viewer/task-execution-viewer.css b/src/app/shared/task-execution-viewer/task-execution-viewer.css index a4afd9a..2ce611c 100644 --- a/src/app/shared/task-execution-viewer/task-execution-viewer.css +++ b/src/app/shared/task-execution-viewer/task-execution-viewer.css @@ -529,8 +529,56 @@ color: #991b1b; } -.execution-logs-scroll { +.execution-logs-panel { + display: flex; + flex-direction: column; height: 100%; + min-height: 0; +} + +.execution-logs-toolbar { + display: flex; + justify-content: flex-end; + padding: 6px 12px 0; + flex: 0 0 auto; +} + +.execution-logs-follow { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 9px; + border: 1px solid #cbd5e1; + border-radius: 999px; + background: #fff; + color: #475569; + font-size: 11px; + font-weight: 600; + cursor: pointer; +} + +.execution-logs-follow:hover { + border-color: #94a3b8; + background: #f8fafc; +} + +/* Held position: stated plainly, since a log that stopped moving on its own looks like a log that stopped. */ +.execution-logs-follow-paused { + border-color: #fcd34d; + background: #fffbeb; + color: #92400e; +} + +.execution-logs-follow mat-icon { + font-size: 14px; + width: 14px; + height: 14px; + line-height: 14px; +} + +.execution-logs-scroll { + flex: 1 1 auto; + min-height: 0; overflow-y: auto; overscroll-behavior: contain; } diff --git a/src/app/shared/task-execution-viewer/task-execution-viewer.html b/src/app/shared/task-execution-viewer/task-execution-viewer.html index 4121e6d..6aa3e15 100644 --- a/src/app/shared/task-execution-viewer/task-execution-viewer.html +++ b/src/app/shared/task-execution-viewer/task-execution-viewer.html @@ -443,7 +443,22 @@ } } @else if (activeAsideTab() === 'logs') { -

+
+
+ +
+
@if (logsLoading()) {
Loading execution log...
} @else if (logsError()) { @@ -503,6 +518,7 @@
} } +
} @else if (activeAsideTab() === 'output') {
diff --git a/src/app/shared/task-execution-viewer/task-execution-viewer.ts b/src/app/shared/task-execution-viewer/task-execution-viewer.ts index 02adb95..1ea3db7 100644 --- a/src/app/shared/task-execution-viewer/task-execution-viewer.ts +++ b/src/app/shared/task-execution-viewer/task-execution-viewer.ts @@ -83,6 +83,7 @@ import { formatDuration, fallbackExecutionLogMessage, executionEventTitle, + isScrolledToEnd, logLevelClass as _logLevelClass, logTypeIcon as _logTypeIcon, inheritedSimulator as _inheritedSimulator, @@ -142,6 +143,7 @@ function uploadFailureMessage(failure: unknown): string { }) export class TaskExecutionViewerComponent implements OnDestroy { private static readonly EVENTS_POLL_INTERVAL_MS = 5000; + private taskExecutionsService = inject(TaskExecutionsService); private flowsService = inject(FlowsService); private humanInteractionDialog = inject(HumanInteractionDialogService); @@ -252,6 +254,14 @@ export class TaskExecutionViewerComponent implements OnDestroy { readonly logsLoading = signal(false); readonly logsError = signal(null); private readonly logsScrollViewport = viewChild>('logsScrollViewport'); + + /** + * Whether the log sticks to the newest line. + * + *

On while the bottom is in view, off as soon as the reader scrolls away from it: a panel that + * yanks itself back every poll cannot be read backwards, which is exactly when a log matters. + */ + readonly followLogs = signal(true); private sourceFlowRequestVersion = 0; private readonly sourceFlowCache = new Map(); private readonly requestedCredentialProviders = new Set(); @@ -1558,10 +1568,31 @@ export class TaskExecutionViewerComponent implements OnDestroy { private scrollLogsToBottom() { const element = this.logsScrollViewport()?.nativeElement; - if (!element || this.activeAsideTab() !== 'logs') return; + if (!element || this.activeAsideTab() !== 'logs' || !this.followLogs()) return; element.scrollTop = element.scrollHeight; } + /** + * Keeps the toggle honest about what the panel is doing. + * + *

Reaching the bottom by hand resumes following, so the common case - read back, then return to + * the live end - needs no button at all. The margin covers a list that grew by a line between the + * scroll and this handler. + */ + onLogsScrolled() { + const element = this.logsScrollViewport()?.nativeElement; + if (!element) return; + this.followLogs.set(isScrolledToEnd(element)); + } + + toggleFollowLogs(event?: Event) { + event?.preventDefault(); + event?.stopPropagation(); + const following = !this.followLogs(); + this.followLogs.set(following); + if (following) queueMicrotask(() => this.scrollLogsToBottom()); + } + /** * Opens what an event recorded beyond its one-line message. *