From ed4dae1a396e1afeebdd78a1c6517b471f930e60 Mon Sep 17 00:00:00 2001 From: Lucio Lelii Date: Mon, 21 Sep 2026 11:55:51 +0200 Subject: [PATCH] Let the log be read backwards while it is still running The panel scrolled itself to the newest line on every poll, so a reader who scrolled up to follow something got yanked back five seconds later - exactly when a log is worth reading, and exactly the moment it became unusable. Following now stops as soon as the reader leaves the end, and resumes on its own when they come back to it, so the common case needs no button. The button is there to say which of the two is happening and to override it: a log that stops moving on its own otherwise looks like a log that stopped. The threshold lives in the utils with the rule, not in the component: not zero, because a list that grew by a line between the scroll and the handler would otherwise read as the reader having walked away. Co-Authored-By: Claude Opus 5 (1M context) --- .../execution-viewer.utils.spec.ts | 18 +++++++ .../execution-viewer.utils.ts | 14 ++++++ .../task-execution-viewer.css | 50 ++++++++++++++++++- .../task-execution-viewer.html | 18 ++++++- .../task-execution-viewer.ts | 33 +++++++++++- 5 files changed, 130 insertions(+), 3 deletions(-) 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. *