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:
parent
6134590bd8
commit
90a04dc480
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue