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 f14a495..fbff9f0 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 { asJsonTree, buildAuthorizationGate, + copyableText, buildVisibleExecutionLogs, executionEventTitle, isScrolledToEnd, @@ -475,3 +476,11 @@ describe('asJsonTree', () => { expect(jsonPreviewIsPartial([1, 2, 3, 4, 5, 6])).toBe(true); }); }); + +describe('copyableText', () => { + it('copies JSON indented, however it arrived, and text as it is', () => { + expect(copyableText(asJsonTree('```json\n{"a":[1,2]}\n```'), 'ignored')).toBe('{\n "a": [\n 1,\n 2\n ]\n}'); + expect(copyableText(null, 'Where every rhythm finds its perfect harmony')) + .toBe('Where every rhythm finds its perfect harmony'); + }); +}); 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 797edef..999e3c5 100644 --- a/src/app/shared/task-execution-viewer/execution-viewer.utils.ts +++ b/src/app/shared/task-execution-viewer/execution-viewer.utils.ts @@ -114,6 +114,19 @@ export function jsonPreviewIsPartial(tree: unknown): boolean { || children.some((child) => child !== null && typeof child === 'object'); } +/** + * A value as it goes to the clipboard. JSON is indented, so what a model wrote on one line - or + * inside a ```json fence - pastes as clean JSON; anything else is copied as shown. + */ +export function copyableText(json: unknown | null, text: string): string { + if (json == null) return text; + try { + return JSON.stringify(json, null, 2); + } catch { + return text; + } +} + export function stringifyOutputValue(value: unknown): string { if (value == null) return ''; if (typeof value === 'string') return value; 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 df24a5d..c32ba72 100644 --- a/src/app/shared/task-execution-viewer/task-execution-viewer.css +++ b/src/app/shared/task-execution-viewer/task-execution-viewer.css @@ -976,3 +976,32 @@ .execution-json-modal { height: 90vh; } + +/* Stays in the corner while a long payload scrolls under it. */ +.execution-outcome-copy { + position: sticky; + top: 0; + float: right; + z-index: 1; + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + margin-left: 6px; + border: 1px solid #d1fae5; + border-radius: 6px; + background: #fff; + color: #475569; + cursor: pointer; +} + +.execution-outcome-copy:hover { + background: #f0fdf4; +} + +.execution-outcome-copy .mat-icon { + width: 16px; + height: 16px; + font-size: 16px; +} 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 c1bf5b1..aa39dd8 100644 --- a/src/app/shared/task-execution-viewer/task-execution-viewer.html +++ b/src/app/shared/task-execution-viewer/task-execution-viewer.html @@ -161,6 +161,14 @@ @if (outcome.payload !== null && outcome.payload !== undefined) {
+ @if (payloadJson(outcome.payload); as tree) { } @else if (isTextPayload(outcome.payload)) { @@ -660,7 +668,17 @@

{{ outputPreviewTitle(outputModal) }}

{{ outputPreviewSubtitle(outputModal) }}

- +
+ + +
@if (outputModal.json) { @@ -682,7 +700,17 @@

{{ intermediateInputPreviewTitle(inputModal) }}

{{ intermediateInputPreviewSubtitle(inputModal) }}

- +
+ + +
@if (inputModal.json) { 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 4be8e6b..5fa6555 100644 --- a/src/app/shared/task-execution-viewer/task-execution-viewer.ts +++ b/src/app/shared/task-execution-viewer/task-execution-viewer.ts @@ -109,6 +109,7 @@ import { buildAuthorizationGate, isExecutionStartable, asJsonTree, + copyableText, JSON_PREVIEW_ENTRIES } from './execution-viewer.utils'; import { buildExecutionLoopHistories, ExecutionLoopHistory } from './execution-rounds'; @@ -702,6 +703,24 @@ export class TaskExecutionViewerComponent implements OnDestroy { readonly jsonPreviewEntries = JSON_PREVIEW_ENTRIES; + /** What was last copied, for its button to say so for a moment. */ + readonly copiedValueKey = signal(null); + private copiedValueReset: ReturnType | null = null; + + /** Copies a value to paste elsewhere: JSON indented, whatever form it arrived in; text as it is. */ + async copyValue(key: string, json: unknown | null, text: string, event?: Event) { + event?.preventDefault(); + event?.stopPropagation(); + try { + await navigator.clipboard.writeText(copyableText(json, text)); + this.copiedValueKey.set(key); + if (this.copiedValueReset) clearTimeout(this.copiedValueReset); + this.copiedValueReset = setTimeout(() => this.copiedValueKey.set(null), 2000); + } catch { + // Clipboard access can be refused; the value is still there to select by hand. + } + } + stepNameForOutcome(stepId: string): string { return this.execution()?.context.steps?.[stepId]?.node?.name ?? stepId; }