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') { -
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 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.
*