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) <noreply@anthropic.com>
This commit is contained in:
Lucio Lelii 2026-09-21 11:55:51 +02:00
parent 469099dffc
commit ed4dae1a39
5 changed files with 130 additions and 3 deletions

View File

@ -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.

View File

@ -258,6 +258,20 @@ export function buildVisibleExecutionLogs(logs: ExecutionEventLogEntry[]): Execu
});
}
/**
* How close to the end still counts as being at the end, in pixels.
*
* <p>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']);

View File

@ -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;
}

View File

@ -443,7 +443,22 @@
}
</div>
} @else if (activeAsideTab() === 'logs') {
<div #logsScrollViewport class="execution-logs-scroll p-3 space-y-3">
<div class="execution-logs-panel">
<div class="execution-logs-toolbar">
<button
type="button"
class="execution-logs-follow"
[class.execution-logs-follow-paused]="!followLogs()"
[attr.aria-pressed]="!followLogs()"
[matTooltip]="followLogs()
? 'Following the newest events - click to hold the position'
: 'Scrolling is held - click to follow the newest events again'"
(click)="toggleFollowLogs($event)">
<mat-icon [fontIcon]="followLogs() ? 'vertical_align_bottom' : 'pause'"></mat-icon>
<span>{{ followLogs() ? 'Following' : 'Paused' }}</span>
</button>
</div>
<div #logsScrollViewport class="execution-logs-scroll p-3 space-y-3" (scroll)="onLogsScrolled()">
@if (logsLoading()) {
<div class="text-xs text-slate-500">Loading execution log...</div>
} @else if (logsError()) {
@ -503,6 +518,7 @@
</div>
}
}
</div>
</div>
} @else if (activeAsideTab() === 'output') {
<div class="p-3 space-y-3">

View File

@ -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<string | null>(null);
private readonly logsScrollViewport = viewChild<ElementRef<HTMLDivElement>>('logsScrollViewport');
/**
* Whether the log sticks to the newest line.
*
* <p>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<string, FlowData>();
private readonly requestedCredentialProviders = new Set<string>();
@ -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.
*
* <p>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.
*