From 8c3d9c6ed3128d505e816d4e9006b1bb81572858 Mon Sep 17 00:00:00 2001 From: Lucio Lelii Date: Mon, 21 Sep 2026 11:07:38 +0200 Subject: [PATCH 1/4] Let an event's own numbers be opened from the log The log showed each event's sentence and threw away everything behind it. A pruning warning saying "6332 characters over budget" could not tell you what the total actually was, a tool call could not say which tool: those numbers reached the browser and were dropped, leaving the API as the only way to read them. Rows that carry details now offer an eye, which opens them read-only. Rows that carry none offer nothing - a button opening an empty box is worse than no button - so the decision of what is worth opening lives in the view model, where it can be tested, rather than in the template. Co-Authored-By: Claude Opus 5 (1M context) --- .../execution-viewer.utils.spec.ts | 32 +++++++++++++++++ .../execution-viewer.utils.ts | 27 +++++++++++++++ .../task-execution-viewer.css | 34 +++++++++++++++++++ .../task-execution-viewer.html | 18 ++++++++-- .../task-execution-viewer.ts | 29 ++++++++++++++++ 5 files changed, 137 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 7f7b8ad..2ac5055 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 @@ -45,6 +45,38 @@ describe('execution viewer runtime values', () => { })).toEqual({ approve: 'Candidate evidence' }); }); + it('keeps the numbers an event recorded readable, so a warning can be opened instead of guessed at', () => { + // The characters-over-budget in a pruning warning only lived in the API until now. + const [event] = buildVisibleExecutionLogs([{ + id: 'event-1', + timestamp: 1, + type: 'LLM_CONTEXT_PRUNED', + message: 'Still 6332 character(s) over budget', + details: { iteration: 4, totalCharsAfter: 66332, budgetChars: 60000 } + }]); + + expect(event.detailsText).toContain('"totalCharsAfter": 66332'); + expect(event.detailsText).toContain('"budgetChars": 60000'); + }); + + it('offers nothing to open when an event carries no details', () => { + // A button that opens an empty box is worse than no button. + const [noDetails] = buildVisibleExecutionLogs([{ id: 'a', timestamp: 1, type: 'STEP_STARTED' }]); + const [emptyDetails] = buildVisibleExecutionLogs([{ id: 'b', timestamp: 1, type: 'STEP_STARTED', details: {} }]); + + expect(noDetails.detailsText).toBeNull(); + expect(emptyDetails.detailsText).toBeNull(); + }); + + it('still shows something for a payload that will not serialise', () => { + const cyclic: Record = { tool: 'read_file' }; + cyclic['self'] = cyclic; + + const [event] = buildVisibleExecutionLogs([{ id: 'c', timestamp: 1, type: 'MCP_TOOL_CALL', details: cyclic }]); + + expect(event.detailsText).toBeTruthy(); + }); + it('surfaces inner subflow identifiers from bias experiment events', () => { const [event] = buildVisibleExecutionLogs([{ id: 'event-1', 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 394e04f..45fe4a0 100644 --- a/src/app/shared/task-execution-viewer/execution-viewer.utils.ts +++ b/src/app/shared/task-execution-viewer/execution-viewer.utils.ts @@ -58,6 +58,8 @@ export type ExecutionIntermediateInputGroup = { export type ExecutionLogEntryView = ExecutionEventLogEntry & { messageText: string; + /** The event's own details, formatted for reading, or null when it carries none worth opening. */ + detailsText: string | null; levelText: string; innerExecutionId: string | null; innerNodeId: string | null; @@ -245,6 +247,7 @@ export function buildVisibleExecutionLogs(logs: ExecutionEventLogEntry[]): Execu return { ...entry, messageText: String(entry.message ?? '').trim() || fallbackExecutionLogMessage(entry), + detailsText: formatExecutionLogDetails(entry.details), levelText: String(entry.level ?? 'INFO').toUpperCase(), innerExecutionId: nonEmptyLogDetail(details['innerExecutionId']), innerNodeId: nonEmptyLogDetail(details['innerNodeId']), @@ -255,6 +258,30 @@ export function buildVisibleExecutionLogs(logs: ExecutionEventLogEntry[]): Execu }); } +/** + * What an event carries beyond its sentence - iteration numbers, character counts, tool names. + * + *

Formatted here rather than in the template because the decision of what is worth opening + * belongs with the data: an event with no details, or an empty map, must not offer a button that + * opens nothing. + */ +export function formatExecutionLogDetails(details: unknown): string | null { + if (details == null) return null; + if (typeof details !== 'object') { + const text = String(details).trim(); + return text || null; + } + if (Array.isArray(details) ? details.length === 0 : Object.keys(details).length === 0) { + return null; + } + try { + return JSON.stringify(details, null, 2); + } catch { + // A cyclic or otherwise unserialisable payload is still worth something to a reader. + return String(details); + } +} + function nonEmptyLogDetail(value: unknown): string | null { const normalized = value == null ? '' : String(value).trim(); return normalized || null; 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 d245e0c..a4afd9a 100644 --- a/src/app/shared/task-execution-viewer/task-execution-viewer.css +++ b/src/app/shared/task-execution-viewer/task-execution-viewer.css @@ -465,6 +465,40 @@ font-size: 11px; } +.execution-log-actions { + display: flex; + align-items: center; + gap: 6px; + flex: 0 0 auto; +} + +/* Quiet until hovered: one of these sits on every row that carries details. */ +.execution-log-details-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + border: none; + border-radius: 6px; + background: transparent; + color: #64748b; + cursor: pointer; +} + +.execution-log-details-btn:hover { + background: #e2e8f0; + color: #0f172a; +} + +.execution-log-details-btn mat-icon { + font-size: 16px; + width: 16px; + height: 16px; + line-height: 16px; +} + .execution-log-level { display: inline-flex; align-items: center; 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 08f74a7..4121e6d 100644 --- a/src/app/shared/task-execution-viewer/task-execution-viewer.html +++ b/src/app/shared/task-execution-viewer/task-execution-viewer.html @@ -484,9 +484,21 @@ - - {{ event.levelText }} - +

+ @if (event.detailsText) { + + } + + {{ event.levelText }} + +
} 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 c67a922..3244633 100644 --- a/src/app/shared/task-execution-viewer/task-execution-viewer.ts +++ b/src/app/shared/task-execution-viewer/task-execution-viewer.ts @@ -1559,6 +1559,35 @@ export class TaskExecutionViewerComponent implements OnDestroy { element.scrollTop = element.scrollHeight; } + /** + * Opens what an event recorded beyond its one-line message. + * + *

The numbers that explain a warning - how many characters were over budget, which tool was + * called, how long a step waited - were already reaching the browser and being dropped on the + * floor, leaving the API as the only way to read them. + */ + async openEventDetails(event: ExecutionLogEntryView, domEvent?: Event) { + domEvent?.preventDefault(); + domEvent?.stopPropagation(); + if (!event.detailsText) return; + + await this.settingsDialog.open({ + title: event.messageText, + previewOnly: true, + fields: [ + { + key: 'details', + label: event.type ?? 'Event details', + type: 'textarea', + readonly: true, + rows: 18, + copyable: true + } + ], + initial: { details: event.detailsText } + }); + } + logLevelClass(level: string | null | undefined): string { return _logLevelClass(level); } From 469099dffc29fb2fee74e80e14d772ff1003cb9f Mon Sep 17 00:00:00 2001 From: Lucio Lelii Date: Mon, 21 Sep 2026 11:24:17 +0200 Subject: [PATCH 2/4] Read an event's details as an object, under a title that fits The first cut put the event's own sentence in the heading and its details in a textarea of raw JSON. A pruning warning runs to a full paragraph, so the title wrapped five lines and read as an error page, and finding one number in the body meant counting braces. The event now opens in its own dialog: a two-word name from the event type, the node and time beneath it, the sentence as prose, and the details as the JSON tree the app already had. The name is derived, not tabulated - a type nobody has seen yet still reads as words - with the initialisms that would otherwise shout or look like typos spelled out. Co-Authored-By: Claude Opus 5 (1M context) --- src/app/app.html | 1 + src/app/app.ts | 3 +- .../dialogs/execution-event-dialog.ts | 38 +++++++++++++ .../execution-event-dialog.css | 37 +++++++++++++ .../execution-event-dialog.html | 38 +++++++++++++ .../execution-event-dialog.ts | 55 +++++++++++++++++++ .../execution-viewer.utils.spec.ts | 15 +++++ .../execution-viewer.utils.ts | 26 +++++++++ .../task-execution-viewer.ts | 25 ++++----- 9 files changed, 222 insertions(+), 16 deletions(-) create mode 100644 src/app/services/dialogs/execution-event-dialog.ts create mode 100644 src/app/shared/execution-event-dialog/execution-event-dialog.css create mode 100644 src/app/shared/execution-event-dialog/execution-event-dialog.html create mode 100644 src/app/shared/execution-event-dialog/execution-event-dialog.ts diff --git a/src/app/app.html b/src/app/app.html index 487f984..7d89607 100644 --- a/src/app/app.html +++ b/src/app/app.html @@ -8,6 +8,7 @@ + diff --git a/src/app/app.ts b/src/app/app.ts index c23b90d..dd24a1e 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -6,6 +6,7 @@ import { ChangeDetectionStrategy, Component, signal } from '@angular/core'; import { RouterOutlet } from '@angular/router'; import { ConfirmDialogHostComponent } from '@shared/confirm-dialog/confirm-dialog'; import { ExecutionErrorDialogHostComponent } from '@shared/execution-error-dialog/execution-error-dialog'; +import { ExecutionEventDialogHostComponent } from '@shared/execution-event-dialog/execution-event-dialog'; import { GlobalNotificationComponent } from '@shared/global-notification/global-notification'; import { HumanInteractionDialogHostComponent } from '@shared/human-interaction-dialog/human-interaction-dialog'; import { NodeSettingsDialogHostComponent } from '@shared/node-settings-dialog/node-settings-dialog'; @@ -20,7 +21,7 @@ import { ProjectDialogComponent } from '@shared/project-dialog/project-dialog'; @Component({ selector: 'app-root', - imports: [RouterOutlet, ConfirmDialogHostComponent, ExecutionErrorDialogHostComponent, GlobalNotificationComponent, HumanInteractionDialogHostComponent, NodeSettingsDialogHostComponent, SubflowPreviewDialogHostComponent, BiasImpactExperimentDialogHostComponent, BiasRerunDialogHostComponent, BiasCompareDialogHostComponent, BiasReportDialogHostComponent, ProjectDialogComponent, ProjectDeleteDialogComponent, ProjectContextDialogComponent], + imports: [RouterOutlet, ConfirmDialogHostComponent, ExecutionErrorDialogHostComponent, ExecutionEventDialogHostComponent, GlobalNotificationComponent, HumanInteractionDialogHostComponent, NodeSettingsDialogHostComponent, SubflowPreviewDialogHostComponent, BiasImpactExperimentDialogHostComponent, BiasRerunDialogHostComponent, BiasCompareDialogHostComponent, BiasReportDialogHostComponent, ProjectDialogComponent, ProjectDeleteDialogComponent, ProjectContextDialogComponent], templateUrl: './app.html', styleUrl: './app.css', changeDetection: ChangeDetectionStrategy.OnPush diff --git a/src/app/services/dialogs/execution-event-dialog.ts b/src/app/services/dialogs/execution-event-dialog.ts new file mode 100644 index 0000000..6a9d38a --- /dev/null +++ b/src/app/services/dialogs/execution-event-dialog.ts @@ -0,0 +1,38 @@ +// SPDX-FileCopyrightText: 2025-2026 Lucio Lelii - ISTI-CNR +// SPDX-License-Identifier: AGPL-3.0-or-later +// Attribution term under AGPL-3.0 section 7(b): see LICENSE-ADDENDUM. + +import { Injectable, signal } from '@angular/core'; + +export type ExecutionEventDialogState = { + /** A short name for the kind of event, used as the dialog title. */ + title: string; + /** Where and when it happened: node name and time, when the event says. */ + subtitle: string; + /** The event's own sentence, which is often a paragraph and belongs in the body, not the title. */ + message: string; + /** Whatever the event recorded beyond that sentence, rendered as a tree. */ + details: unknown; +}; + +/** + * Holds the execution event currently opened for reading. + * + *

Separate from the settings dialog, which can only render fields of text: what an event carries + * is a small object - counts, names, nested values - and reading it as a wall of JSON means counting + * braces to find one number. + */ +@Injectable({ providedIn: 'root' }) +export class ExecutionEventDialogService { + private readonly _state = signal(null); + + readonly state = this._state.asReadonly(); + + open(state: ExecutionEventDialogState) { + this._state.set(state); + } + + close() { + this._state.set(null); + } +} diff --git a/src/app/shared/execution-event-dialog/execution-event-dialog.css b/src/app/shared/execution-event-dialog/execution-event-dialog.css new file mode 100644 index 0000000..f8e66d7 --- /dev/null +++ b/src/app/shared/execution-event-dialog/execution-event-dialog.css @@ -0,0 +1,37 @@ +/* + * SPDX-FileCopyrightText: 2025-2026 Lucio Lelii - ISTI-CNR + * SPDX-License-Identifier: AGPL-3.0-or-later + * Attribution term under AGPL-3.0 section 7(b): see LICENSE-ADDENDUM. + */ + +.execution-event__body { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +/* The event's sentence: prose, so it reads as prose rather than as a heading. */ +.execution-event__message { + margin: 0; + color: #334155; + font-size: 0.875rem; + line-height: 1.5; +} + +.execution-event__actions { + display: flex; + justify-content: flex-end; +} + +.execution-event__copy { + --mdc-outlined-button-label-text-color: #475569; +} + +.execution-event__details { + max-height: 55vh; + overflow: auto; + padding: 0.75rem; + border: 1px solid #e2e8f0; + border-radius: 8px; + background: #f8fafc; +} diff --git a/src/app/shared/execution-event-dialog/execution-event-dialog.html b/src/app/shared/execution-event-dialog/execution-event-dialog.html new file mode 100644 index 0000000..0cf93c2 --- /dev/null +++ b/src/app/shared/execution-event-dialog/execution-event-dialog.html @@ -0,0 +1,38 @@ + + +@if (state(); as dialogState) { + + +

+ @if (dialogState.message) { +

{{ dialogState.message }}

+ } + +
+ +
+ +
+ +
+
+ +} diff --git a/src/app/shared/execution-event-dialog/execution-event-dialog.ts b/src/app/shared/execution-event-dialog/execution-event-dialog.ts new file mode 100644 index 0000000..aeccc1c --- /dev/null +++ b/src/app/shared/execution-event-dialog/execution-event-dialog.ts @@ -0,0 +1,55 @@ +// SPDX-FileCopyrightText: 2025-2026 Lucio Lelii - ISTI-CNR +// SPDX-License-Identifier: AGPL-3.0-or-later +// Attribution term under AGPL-3.0 section 7(b): see LICENSE-ADDENDUM. + +import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { ExecutionEventDialogService } from '@services/dialogs/execution-event-dialog'; +import { JsonViewerComponent } from '@shared/json-viewer/json-viewer'; +import { ModalShellComponent } from '@shared/modal-shell/modal-shell'; + +/** + * Reads what one execution event recorded. + * + *

The numbers behind an event - characters over budget, tool names, durations - are a small + * object, so they are shown as one: a tree that can be scanned, not a block of JSON to be parsed by + * eye. The event's own sentence sits in the body rather than the title, because several of them run + * to a full paragraph and a title that wraps five lines reads as an error page. + */ +@Component({ + selector: 'app-execution-event-dialog-host', + imports: [MatButtonModule, MatIconModule, ModalShellComponent, JsonViewerComponent], + templateUrl: './execution-event-dialog.html', + styleUrl: './execution-event-dialog.css', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class ExecutionEventDialogHostComponent { + private readonly dialog = inject(ExecutionEventDialogService); + + readonly state = this.dialog.state; + readonly copied = signal(false); + + private copiedReset: ReturnType | null = null; + + close() { + this.dialog.close(); + } + + /** Copies the details as JSON: what gets pasted into an issue is the data, not the tree drawing. */ + async copyDetails(event?: Event) { + event?.preventDefault(); + event?.stopPropagation(); + const details = this.state()?.details; + if (details === undefined) return; + + try { + await navigator.clipboard.writeText(JSON.stringify(details, null, 2)); + this.copied.set(true); + if (this.copiedReset) clearTimeout(this.copiedReset); + this.copiedReset = setTimeout(() => this.copied.set(false), 2000); + } catch { + // Clipboard access can be refused; the tree is still there to read and select by hand. + } + } +} 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 2ac5055..d2ff3f3 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 @@ -6,6 +6,7 @@ import { TaskExecution, TaskExecutionStep } from '@models/task-execution'; import { buildAuthorizationGate, buildVisibleExecutionLogs, + executionEventTitle, getExecutionInputValues, getExecutionOutputValues, hasStoredValue, @@ -59,6 +60,20 @@ describe('execution viewer runtime values', () => { expect(event.detailsText).toContain('"budgetChars": 60000'); }); + 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. + expect(executionEventTitle('LLM_CONTEXT_PRUNED')).toBe('LLM context pruned'); + expect(executionEventTitle('MCP_TOOL_CALL')).toBe('MCP tool call'); + expect(executionEventTitle('HUMAN_EVALUATION_RECORDED')).toBe('Human evaluation recorded'); + }); + + it('falls back to something sayable for an event type it has never seen', () => { + expect(executionEventTitle('SOME_FUTURE_EVENT')).toBe('Some future event'); + expect(executionEventTitle(null)).toBe('Execution event'); + expect(executionEventTitle(' ')).toBe('Execution event'); + }); + it('offers nothing to open when an event carries no details', () => { // A button that opens an empty box is worse than no button. const [noDetails] = buildVisibleExecutionLogs([{ id: 'a', timestamp: 1, type: 'STEP_STARTED' }]); 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 45fe4a0..558be8c 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,32 @@ export function buildVisibleExecutionLogs(logs: ExecutionEventLogEntry[]): Execu }); } +/** Initialisms that read as shouting when title-cased, and as typos when lower-cased. */ +const EVENT_TITLE_INITIALISMS = new Set(['llm', 'mcp', 'http', 'id']); + +/** + * A short name for a kind of event, for the head of a dialog. + * + *

The event's own message is regularly a full paragraph - "Still 6332 character(s) over budget + * after pruning everything available..." - and a paragraph set as a title wraps five lines and reads + * as an error page. The type says the same thing in two words, and the message keeps its place in + * the body. + */ +export function executionEventTitle(type: string | null | undefined): string { + const raw = String(type ?? '').trim(); + if (!raw) return 'Execution event'; + + const words = raw.toLowerCase().split(/[_\s]+/).filter(Boolean); + if (!words.length) return 'Execution event'; + + return words + .map((word, index) => { + if (EVENT_TITLE_INITIALISMS.has(word)) return word.toUpperCase(); + return index === 0 ? word.charAt(0).toUpperCase() + word.slice(1) : word; + }) + .join(' '); +} + /** * What an event carries beyond its sentence - iteration numbers, character counts, tool names. * 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 3244633..02adb95 100644 --- a/src/app/shared/task-execution-viewer/task-execution-viewer.ts +++ b/src/app/shared/task-execution-viewer/task-execution-viewer.ts @@ -47,6 +47,7 @@ import { HumanInteractionDialogService } from '@services/dialogs/human-interaction-dialog'; import { NodeSettingField, NodeSettingsDialogService } from '@services/dialogs/node-settings-dialog'; +import { ExecutionEventDialogService } from '@services/dialogs/execution-event-dialog'; import { LLMDescriptorWithCredential, openLLMDescriptorSettingsWithCredential @@ -81,6 +82,7 @@ import { stringifyOutputValue, formatDuration, fallbackExecutionLogMessage, + executionEventTitle, logLevelClass as _logLevelClass, logTypeIcon as _logTypeIcon, inheritedSimulator as _inheritedSimulator, @@ -144,6 +146,7 @@ export class TaskExecutionViewerComponent implements OnDestroy { private flowsService = inject(FlowsService); private humanInteractionDialog = inject(HumanInteractionDialogService); private settingsDialog = inject(NodeSettingsDialogService); + private eventDialog = inject(ExecutionEventDialogService); private fieldRetriever = inject(FieldRetriever); private containersService = inject(ContainersService); private blocksService = inject(BlocksService); @@ -1566,25 +1569,17 @@ export class TaskExecutionViewerComponent implements OnDestroy { * called, how long a step waited - were already reaching the browser and being dropped on the * floor, leaving the API as the only way to read them. */ - async openEventDetails(event: ExecutionLogEntryView, domEvent?: Event) { + openEventDetails(event: ExecutionLogEntryView, domEvent?: Event) { domEvent?.preventDefault(); domEvent?.stopPropagation(); if (!event.detailsText) return; - await this.settingsDialog.open({ - title: event.messageText, - previewOnly: true, - fields: [ - { - key: 'details', - label: event.type ?? 'Event details', - type: 'textarea', - readonly: true, - rows: 18, - copyable: true - } - ], - initial: { details: event.detailsText } + const when = new Date(event.timestamp).toLocaleString(); + this.eventDialog.open({ + title: executionEventTitle(event.type), + subtitle: event.nodeName ? `${event.nodeName} ยท ${when}` : when, + message: event.messageText, + details: event.details }); } From ed4dae1a396e1afeebdd78a1c6517b471f930e60 Mon Sep 17 00:00:00 2001 From: Lucio Lelii Date: Mon, 21 Sep 2026 11:55:51 +0200 Subject: [PATCH 3/4] 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. * From fd30de2b1d07a0fc1bb6e50615b61029419a65f9 Mon Sep 17 00:00:00 2001 From: Lucio Lelii Date: Mon, 21 Sep 2026 15:03:11 +0200 Subject: [PATCH 4/4] Point the web interface at its home on the ISTI Gitea Seven references followed the move: the licence addendum, the NOTICE, the README attribution, the citation metadata, the package repository field and the two links the About dialog shows a user - one of which names the backend service, so it moves to the service's new home too. The working notes that used to sit in docs/ are gone from the repository and named in .gitignore, together with .claude. What remains under docs/ is the user guide, which is written for whoever runs the application rather than for us. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 9 +++++++++ CITATION.cff | 2 +- LICENSE-ADDENDUM | 2 +- NOTICE | 2 +- README.md | 2 +- package.json | 2 +- src/app/shared/about-dialog/about-dialog.ts | 4 ++-- 7 files changed, 16 insertions(+), 7 deletions(-) diff --git a/.gitignore b/.gitignore index b1d225e..fdc57c2 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,12 @@ __screenshots__/ # System files .DS_Store Thumbs.db + +# Working notes and local tooling, not part of the published source +.claude/ +docs/project-memory.md +docs/assistant-store-implementation-note.md +docs/assistant-ui-guide.md +docs/bias-impact-experiments-plan.md +docs/vault-credential-gate-frontend.md +docs/vault-credential-gate-tasks.md diff --git a/CITATION.cff b/CITATION.cff index 14fd0a1..3fec0b0 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -13,7 +13,7 @@ authors: affiliation: >- Institute of Information Science and Technologies "A. Faedo" (ISTI-CNR), Pisa, Italy license: AGPL-3.0-or-later -repository-code: "https://github.com/luciolelii/humanaiFlow-angular" +repository-code: "https://gitea-s2i2s.isti.cnr.it/lelii/humainflow-web" keywords: - workflow - human-in-the-loop diff --git a/LICENSE-ADDENDUM b/LICENSE-ADDENDUM index e64ce79..d776f10 100644 --- a/LICENSE-ADDENDUM +++ b/LICENSE-ADDENDUM @@ -19,7 +19,7 @@ or unmodified, you must preserve the following attribution: Based on HumAIn Flow, originally developed by Lucio Lelii (Institute of Information Science and Technologies "A. Faedo" (ISTI-CNR), Pisa, Italy) - https://github.com/luciolelii/humanaiFlow-angular + https://gitea-s2i2s.isti.cnr.it/lelii/humainflow-web The attribution must be preserved in both of the following places: diff --git a/NOTICE b/NOTICE index 37ad955..28d349f 100644 --- a/NOTICE +++ b/NOTICE @@ -23,4 +23,4 @@ one: Based on HumAIn Flow, originally developed by Lucio Lelii (Institute of Information Science and Technologies "A. Faedo" (ISTI-CNR), Pisa, Italy) - https://github.com/luciolelii/humanaiFlow-angular + https://gitea-s2i2s.isti.cnr.it/lelii/humainflow-web diff --git a/README.md b/README.md index 6f768bf..cc36f8a 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ In practice this means: and where the interface shows its legal notices: > Based on HumAIn Flow, originally developed by Lucio Lelii (ISTI-CNR) - > โ€” https://github.com/luciolelii/humanaiFlow-angular + > โ€” https://gitea-s2i2s.isti.cnr.it/lelii/humainflow-web If you use HumAIn Flow in academic work, please cite it โ€” see [CITATION.cff](CITATION.cff). diff --git a/package.json b/package.json index 7d83f95..79ba31e 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "author": "Lucio Lelii (ISTI-CNR)", "repository": { "type": "git", - "url": "git+https://github.com/luciolelii/humanaiFlow-angular.git" + "url": "git+https://gitea-s2i2s.isti.cnr.it/lelii/humainflow-web.git" }, "scripts": { "ng": "ng", diff --git a/src/app/shared/about-dialog/about-dialog.ts b/src/app/shared/about-dialog/about-dialog.ts index 60796cb..f4dcbd2 100644 --- a/src/app/shared/about-dialog/about-dialog.ts +++ b/src/app/shared/about-dialog/about-dialog.ts @@ -36,8 +36,8 @@ export class AboutDialogComponent { readonly licenseUrl = 'https://www.gnu.org/licenses/agpl-3.0.html'; readonly sources = [ - { label: 'Web interface', url: 'https://github.com/luciolelii/humanaiFlow-angular' }, - { label: 'Backend service', url: 'https://github.com/luciolelii/humainflow-service' } + { label: 'Web interface', url: 'https://gitea-s2i2s.isti.cnr.it/lelii/humainflow-web' }, + { label: 'Backend service', url: 'https://gitea-s2i2s.isti.cnr.it/lelii/humainflow-service' } ]; close(event?: Event) {