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/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/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) { 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 7f7b8ad..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 @@ -6,6 +6,8 @@ import { TaskExecution, TaskExecutionStep } from '@models/task-execution'; import { buildAuthorizationGate, buildVisibleExecutionLogs, + executionEventTitle, + isScrolledToEnd, getExecutionInputValues, getExecutionOutputValues, hasStoredValue, @@ -45,6 +47,69 @@ 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('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. + 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' }]); + 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..2179120 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,70 @@ 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']); + +/** + * 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. + * + *

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..2ce611c 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; @@ -495,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 08f74a7..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()) { @@ -484,13 +499,26 @@
- - {{ event.levelText }} - +
+ @if (event.detailsText) { + + } + + {{ event.levelText }} + +
} } + } @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 c67a922..1ea3db7 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,8 @@ import { stringifyOutputValue, formatDuration, fallbackExecutionLogMessage, + executionEventTitle, + isScrolledToEnd, logLevelClass as _logLevelClass, logTypeIcon as _logTypeIcon, inheritedSimulator as _inheritedSimulator, @@ -140,10 +143,12 @@ 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); private settingsDialog = inject(NodeSettingsDialogService); + private eventDialog = inject(ExecutionEventDialogService); private fieldRetriever = inject(FieldRetriever); private containersService = inject(ContainersService); private blocksService = inject(BlocksService); @@ -249,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(); @@ -1555,10 +1568,52 @@ 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. + * + *

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. + */ + openEventDetails(event: ExecutionLogEntryView, domEvent?: Event) { + domEvent?.preventDefault(); + domEvent?.stopPropagation(); + if (!event.detailsText) return; + + 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 + }); + } + logLevelClass(level: string | null | undefined): string { return _logLevelClass(level); }