Let a value be copied from its full view and from the outcome

The full view of an output or input gains a Copy button - Copy JSON for
a JSON value - and the outcome payload a copy button in its corner, which
stays put while a long payload scrolls. JSON is copied indented, whether
it came as an object or as a model's text in a fence, so it pastes as
clean JSON elsewhere; text is copied as shown.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lucio Lelii 2026-09-25 14:32:19 +02:00
parent 6134590bd8
commit 90a04dc480
5 changed files with 100 additions and 2 deletions

View File

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

View File

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

View File

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

View File

@ -161,6 +161,14 @@
@if (outcome.payload !== null && outcome.payload !== undefined) {
<div class="execution-outcome-payload">
<button
type="button"
class="execution-outcome-copy"
[matTooltip]="copiedValueKey() === 'outcome:' + outcome.stepId ? 'Copied' : 'Copy the outcome'"
aria-label="Copy the outcome"
(click)="copyValue('outcome:' + outcome.stepId, payloadJson(outcome.payload) ?? (isTextPayload(outcome.payload) ? null : outcome.payload), isTextPayload(outcome.payload) ? outcome.payload : '', $event)">
<mat-icon [fontIcon]="copiedValueKey() === 'outcome:' + outcome.stepId ? 'check' : 'content_copy'"></mat-icon>
</button>
@if (payloadJson(outcome.payload); as tree) {
<app-json-viewer [value]="tree" [initiallyExpanded]="true" />
} @else if (isTextPayload(outcome.payload)) {
@ -660,7 +668,17 @@
<h3 class="text-lg font-semibold text-slate-900">{{ outputPreviewTitle(outputModal) }}</h3>
<p class="mt-1 break-all text-xs text-slate-500">{{ outputPreviewSubtitle(outputModal) }}</p>
</div>
<button type="button" mat-stroked-button (click)="closeOutputPreview($event)">Close</button>
<div class="flex shrink-0 items-center gap-2">
<button
type="button"
mat-stroked-button
[attr.aria-label]="outputModal.json ? 'Copy the JSON' : 'Copy the text'"
(click)="copyValue(outputModal.key, outputModal.json, outputModal.value, $event)">
<mat-icon [fontIcon]="copiedValueKey() === outputModal.key ? 'check' : 'content_copy'"></mat-icon>
<span>{{ copiedValueKey() === outputModal.key ? 'Copied' : (outputModal.json ? 'Copy JSON' : 'Copy') }}</span>
</button>
<button type="button" mat-stroked-button (click)="closeOutputPreview($event)">Close</button>
</div>
</div>
<div class="min-h-0 flex-1 overflow-auto px-5 py-4">
@if (outputModal.json) {
@ -682,7 +700,17 @@
<h3 class="text-lg font-semibold text-slate-900">{{ intermediateInputPreviewTitle(inputModal) }}</h3>
<p class="mt-1 break-all text-xs text-slate-500">{{ intermediateInputPreviewSubtitle(inputModal) }}</p>
</div>
<button type="button" mat-stroked-button (click)="closeIntermediateInputPreview($event)">Close</button>
<div class="flex shrink-0 items-center gap-2">
<button
type="button"
mat-stroked-button
[attr.aria-label]="inputModal.json ? 'Copy the JSON' : 'Copy the text'"
(click)="copyValue(inputModal.key, inputModal.json, inputModal.value, $event)">
<mat-icon [fontIcon]="copiedValueKey() === inputModal.key ? 'check' : 'content_copy'"></mat-icon>
<span>{{ copiedValueKey() === inputModal.key ? 'Copied' : (inputModal.json ? 'Copy JSON' : 'Copy') }}</span>
</button>
<button type="button" mat-stroked-button (click)="closeIntermediateInputPreview($event)">Close</button>
</div>
</div>
<div class="min-h-0 flex-1 overflow-auto px-5 py-4">
@if (inputModal.json) {

View File

@ -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<string | null>(null);
private copiedValueReset: ReturnType<typeof setTimeout> | 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;
}