Show JSON values as trees, and keep a read-only graph's connections unselectable

Outputs, intermediate inputs, loop rounds and outcome payloads that are
JSON - objects, arrays, or JSON a model wrote as text, fenced or not -
are shown with the JSON tree instead of their text. A preview opens the
first level and a few entries, summing up the rest; the full view opens
the whole tree. The JSON viewer gains the depth and entry limits this
takes.

In a read-only graph, such as an execution's, connections can no longer
be picked or shown as selected: there is nothing to do with one there.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lucio Lelii 2026-09-25 14:14:20 +02:00
parent 022df1909a
commit 3ffaf6e596
12 changed files with 180 additions and 25 deletions

View File

@ -20,6 +20,7 @@ import { BiasComparisonViewStateService } from '@services/bias/bias-comparison-v
template: `
<svg data-testid="connection" [attr.data-connection-id]="connectionId" [attr.data-loop-back]="isLoopBack || null">
<path
[class.connection-readonly]="isReadonly"
[attr.d]="drawnPath"
[attr.stroke]="strokeColor"
[attr.stroke-width]="strokeWidth"
@ -35,7 +36,8 @@ import { BiasComparisonViewStateService } from '@services/bias/bias-comparison-v
[attr.y]="loopLabelY"
width="190"
height="34">
<div xmlns="http://www.w3.org/1999/xhtml" class="loop-label" (pointerdown)="selectConnection($event)">
<div xmlns="http://www.w3.org/1999/xhtml" class="loop-label" [class.loop-label-readonly]="isReadonly"
(pointerdown)="selectConnection($event)">
<span class="loop-glyph" aria-hidden="true">↻</span>
@if (isSelected && !isReadonly) {
<label class="loop-limit">
@ -101,6 +103,16 @@ import { BiasComparisonViewStateService } from '@services/bias/bias-comparison-v
stroke-linejoin: round;
}
/* Read-only graphs - an execution's - show connections, they do not let anyone pick one. */
:host path.connection-readonly {
pointer-events: none;
cursor: default;
}
:host .loop-label.loop-label-readonly {
cursor: default;
}
:host .connection-delete {
display: inline-flex;
align-items: center;
@ -252,7 +264,7 @@ export class CustomConnectionComponent {
}
get isSelected(): boolean {
return this.graphSelection.selectedConnectionId() === this.connectionId;
return !this.isReadonly && this.graphSelection.selectedConnectionId() === this.connectionId;
}
get isBiasRoutingChange(): boolean {
@ -293,6 +305,7 @@ export class CustomConnectionComponent {
}
selectConnection(event: Event) {
if (this.isReadonly) return;
event.preventDefault();
event.stopPropagation();
this.graphSelection.selectConnection(this.connectionId);

View File

@ -16,3 +16,4 @@
.json-viewer__entry { align-items: flex-start; display: flex; gap: .45rem; line-height: 1.55; }
.json-viewer__key { color: #7c3aed; white-space: nowrap; }
.json-viewer__primitive { color: #0f172a; flex: 1 1 auto; font-family: inherit; min-width: 0; white-space: pre-wrap; word-break: break-word; }
.json-viewer__more { color: #64748b; font-style: italic; line-height: 1.55; }

View File

@ -9,26 +9,29 @@
<div class="json-viewer__label">{{ label }}</div>
}
<ng-template #renderValue let-value let-path="path">
<ng-template #renderValue let-value let-path="path" let-depth="depth">
@if (isExpandable(value)) {
<details class="json-viewer__group" [open]="isExpanded(path)" (toggle)="onToggle(path, $event)">
<details class="json-viewer__group" [open]="isExpanded(path, depth)" (toggle)="onToggle(path, $event)">
<summary>{{ summary(value) }}</summary>
<div class="json-viewer__children">
@if (isArray(value)) {
@for (item of value; track $index) {
@for (item of items(value); track $index) {
<div class="json-viewer__entry">
<span class="json-viewer__key">[{{ $index }}]</span>
<ng-container *ngTemplateOutlet="renderValue; context: { $implicit: item, path: path + '[' + $index + ']' }" />
<ng-container *ngTemplateOutlet="renderValue; context: { $implicit: item, path: path + '[' + $index + ']', depth: depth + 1 }" />
</div>
}
} @else if (isObject(value)) {
@for (entry of entries(value); track entry.key) {
<div class="json-viewer__entry">
<span class="json-viewer__key">{{ entry.key }}</span>
<ng-container *ngTemplateOutlet="renderValue; context: { $implicit: entry.value, path: path + '.' + entry.key }" />
<ng-container *ngTemplateOutlet="renderValue; context: { $implicit: entry.value, path: path + '.' + entry.key, depth: depth + 1 }" />
</div>
}
}
@if (hiddenCount(value); as hidden) {
<div class="json-viewer__more">… {{ hidden }} more</div>
}
</div>
</details>
} @else {
@ -36,5 +39,5 @@
}
</ng-template>
<ng-container *ngTemplateOutlet="renderValue; context: { $implicit: value, path: '$' }" />
<ng-container *ngTemplateOutlet="renderValue; context: { $implicit: value, path: '$', depth: 0 }" />
</section>

View File

@ -25,6 +25,18 @@ describe('JsonViewerComponent', () => {
expect(text).toContain('[0]');
});
it('as a preview opens only the first level and sums up what it leaves out', () => {
fixture.componentInstance.value = { a: 1, b: 2, c: { deep: 'hidden' }, d: 4 };
fixture.componentInstance.expandDepth = 1;
fixture.componentInstance.maxEntries = 3;
fixture.detectChanges();
const groups = [...fixture.nativeElement.querySelectorAll('details')] as HTMLDetailsElement[];
expect(groups.map((group) => group.open)).toEqual([true, false]);
expect(fixture.nativeElement.textContent).toContain('… 1 more');
expect(fixture.nativeElement.textContent).not.toContain('"d"');
});
it('formats primitive strings as JSON strings', () => {
expect(fixture.componentInstance.formatPrimitive('hello')).toBe('"hello"');
expect(fixture.componentInstance.formatPrimitive(null)).toBe('null');

View File

@ -18,8 +18,13 @@ export class JsonViewerComponent {
@Input() value: unknown;
@Input() label: string | null = null;
@Input() initiallyExpanded = true;
/** How many levels start open; deeper ones start closed. The root is level 0. */
@Input() expandDepth = Number.POSITIVE_INFINITY;
/** At most this many entries per object or array, the rest summed up as "N more". */
@Input() maxEntries = Number.POSITIVE_INFINITY;
private readonly collapsedPaths = new Set<string>();
/** What the person opened or closed by hand, which wins over where it started. */
private readonly toggledPaths = new Map<string, boolean>();
isArray(value: unknown): value is unknown[] {
return Array.isArray(value);
@ -34,7 +39,17 @@ export class JsonViewerComponent {
}
entries(value: Record<string, unknown>): Array<{ key: string; value: unknown }> {
return Object.entries(value).map(([key, entryValue]) => ({ key, value: entryValue }));
return Object.entries(value).slice(0, this.maxEntries).map(([key, entryValue]) => ({ key, value: entryValue }));
}
items(value: unknown[]): unknown[] {
return value.slice(0, this.maxEntries);
}
/** Entries left out by maxEntries, for the "N more" line. */
hiddenCount(value: unknown): number {
const size = this.isArray(value) ? value.length : this.isObject(value) ? Object.keys(value).length : 0;
return Math.max(0, size - this.maxEntries);
}
summary(value: unknown): string {
@ -54,13 +69,11 @@ export class JsonViewerComponent {
}
}
isExpanded(path: string): boolean {
return this.initiallyExpanded && !this.collapsedPaths.has(path);
isExpanded(path: string, depth: number): boolean {
return this.toggledPaths.get(path) ?? (this.initiallyExpanded && depth < this.expandDepth);
}
onToggle(path: string, event: Event) {
const expanded = (event.target as HTMLDetailsElement).open;
if (expanded) this.collapsedPaths.delete(path);
else this.collapsedPaths.add(path);
this.toggledPaths.set(path, (event.target as HTMLDetailsElement).open);
}
}

View File

@ -5,8 +5,10 @@
import { StepStatus, TaskExecution, TaskExecutionLoop, TaskExecutionStep } from '@models/task-execution';
import {
asJsonTree,
ExecutionIntermediateInputEntry,
getExecutionInputValues,
jsonPreviewIsPartial,
stepTitle,
stringifyOutputValue
} from './execution-viewer.utils';
@ -167,7 +169,8 @@ function inputEntries(keyPrefix: string, nodeTitle: string, values: Array<[strin
const items = Array.isArray(rawValue) ? rawValue : [rawValue];
return items.map((item, index) => {
const value = stringifyOutputValue(item);
const isLong = value.length > ROUND_INPUT_PREVIEW_LIMIT;
const json = asJsonTree(item);
const isLong = json ? jsonPreviewIsPartial(json) : value.length > ROUND_INPUT_PREVIEW_LIMIT;
return {
key: Array.isArray(rawValue) ? `${keyPrefix}:${inputName}:${index}` : `${keyPrefix}:${inputName}`,
nodeTitle,
@ -175,9 +178,10 @@ function inputEntries(keyPrefix: string, nodeTitle: string, values: Array<[strin
inputType: '',
sourceLabel: '',
value,
preview: isLong ? `${value.slice(0, ROUND_INPUT_PREVIEW_LIMIT)}...` : value,
preview: value.length > ROUND_INPUT_PREVIEW_LIMIT ? `${value.slice(0, ROUND_INPUT_PREVIEW_LIMIT)}...` : value,
isLong,
itemLabel: Array.isArray(rawValue) ? `Item ${index + 1}` : null
itemLabel: Array.isArray(rawValue) ? `Item ${index + 1}` : null,
json
};
});
}).filter((entry) => entry.value.trim().length > 0);

View File

@ -4,6 +4,7 @@
import { TaskExecution, TaskExecutionStep } from '@models/task-execution';
import {
asJsonTree,
buildAuthorizationGate,
buildVisibleExecutionLogs,
executionEventTitle,
@ -14,6 +15,7 @@ import {
inheritedSimulator,
inheritedSimulatorNotice,
isExecutionStartable,
jsonPreviewIsPartial,
planInputSaves,
preparedInputValue,
readSimulatorParameters
@ -450,3 +452,26 @@ describe('inheritedSimulator', () => {
expect(inheritedSimulatorNotice(null)).toBeNull();
});
});
describe('asJsonTree', () => {
it('takes objects and arrays, and JSON a model wrote as text, fenced or not', () => {
expect(asJsonTree({ a: 1 })).toEqual({ a: 1 });
expect(asJsonTree([1, 2])).toEqual([1, 2]);
expect(asJsonTree(' {"verdict": "pass"} ')).toEqual({ verdict: 'pass' });
expect(asJsonTree('```json\n[{"id": 1}]\n```')).toEqual([{ id: 1 }]);
});
it('leaves prose, bare values and broken JSON as text', () => {
expect(asJsonTree('Where every rhythm finds its perfect harmony')).toBeNull();
expect(asJsonTree('"quoted"')).toBeNull();
expect(asJsonTree('42')).toBeNull();
expect(asJsonTree('{not json}')).toBeNull();
expect(asJsonTree(null)).toBeNull();
});
it('calls a preview partial when it hides a level or entries past the limit', () => {
expect(jsonPreviewIsPartial({ a: 1, b: 2 })).toBe(false);
expect(jsonPreviewIsPartial({ a: { b: 1 } })).toBe(true);
expect(jsonPreviewIsPartial([1, 2, 3, 4, 5, 6])).toBe(true);
});
});

View File

@ -32,6 +32,8 @@ export type ExecutionOutputEntry = {
preview: string;
isLong: boolean;
itemLabel: string | null;
/** The value as a JSON tree when it is one, for a tree view instead of its text. */
json: unknown | null;
};
export type ExecutionIntermediateInputEntry = {
@ -44,6 +46,7 @@ export type ExecutionIntermediateInputEntry = {
preview: string;
isLong: boolean;
itemLabel: string | null;
json: unknown | null;
};
export type ExecutionOutputGroup = {
@ -79,6 +82,38 @@ export function stepNodeId(step: TaskExecutionStep | null | undefined): string {
return getTaskExecutionStepNode(step)?.id || step?.id || '';
}
/** Top-level entries a JSON preview shows before "N more"; the whole tree opens in the full view. */
export const JSON_PREVIEW_ENTRIES = 5;
/**
* The value as a JSON object or array, or null when it is not one. Models often answer with JSON
* as text, and often inside a ```json fence, so a string holding one counts as well. A bare number
* or quoted string is left as text: as a tree it would be a single leaf, no easier to read.
*/
export function asJsonTree(value: unknown): unknown | null {
if (Array.isArray(value)) return value;
if (value !== null && typeof value === 'object') return value;
if (typeof value !== 'string') return null;
let text = value.trim();
const fenced = /^```(?:json)?\s*([\s\S]*?)\s*```$/i.exec(text);
if (fenced) text = fenced[1].trim();
const looksLikeJson = (text.startsWith('{') && text.endsWith('}')) || (text.startsWith('[') && text.endsWith(']'));
if (!looksLikeJson) return null;
try {
const parsed = JSON.parse(text);
return parsed !== null && typeof parsed === 'object' ? parsed : null;
} catch {
return null;
}
}
/** Whether a JSON preview leaves something out - a nested level, or entries past the limit. */
export function jsonPreviewIsPartial(tree: unknown): boolean {
const children = Array.isArray(tree) ? tree : tree !== null && typeof tree === 'object' ? Object.values(tree) : [];
return children.length > JSON_PREVIEW_ENTRIES
|| children.some((child) => child !== null && typeof child === 'object');
}
export function stringifyOutputValue(value: unknown): string {
if (value == null) return '';
if (typeof value === 'string') return value;
@ -164,16 +199,18 @@ export function buildExecutionOutputs(execution: TaskExecution | null): Executio
const values = Array.isArray(rawValue) ? rawValue : [rawValue];
return values.map((item, index) => {
const value = stringifyOutputValue(item);
const isLong = value.length > OUTPUT_PREVIEW_LIMIT;
const json = asJsonTree(item);
const isLong = json ? jsonPreviewIsPartial(json) : value.length > OUTPUT_PREVIEW_LIMIT;
const isArrayItem = Array.isArray(rawValue);
return {
key: isArrayItem ? `${key}:${index}` : key,
nodeTitle: label.nodeTitle,
outputName: label.outputName,
value,
preview: isLong ? `${value.slice(0, OUTPUT_PREVIEW_LIMIT)}...` : value,
preview: value.length > OUTPUT_PREVIEW_LIMIT ? `${value.slice(0, OUTPUT_PREVIEW_LIMIT)}...` : value,
isLong,
itemLabel: isArrayItem ? `Item ${index + 1}` : null,
json,
};
});
})
@ -205,7 +242,8 @@ export function buildExecutionIntermediateInputs(execution: TaskExecution | null
return values.map((item, index) => {
const value = stringifyOutputValue(item);
const isLong = value.length > INTERMEDIATE_INPUT_PREVIEW_LIMIT;
const json = asJsonTree(item);
const isLong = json ? jsonPreviewIsPartial(json) : value.length > INTERMEDIATE_INPUT_PREVIEW_LIMIT;
return {
key: isArrayValue ? `${step.id}:${inputName}:${index}` : `${step.id}:${inputName}`,
nodeTitle: stepTitle(step),
@ -213,9 +251,12 @@ export function buildExecutionIntermediateInputs(execution: TaskExecution | null
inputType: String(input?.descriptor?.type ?? 'TEXT').toUpperCase(),
sourceLabel: input?.registered ? 'Connected input' : 'Prepared input',
value,
preview: isLong ? `${value.slice(0, INTERMEDIATE_INPUT_PREVIEW_LIMIT)}...` : value,
preview: value.length > INTERMEDIATE_INPUT_PREVIEW_LIMIT
? `${value.slice(0, INTERMEDIATE_INPUT_PREVIEW_LIMIT)}...`
: value,
isLong,
itemLabel: isArrayValue ? `Item ${index + 1}` : null,
json,
};
});
});

View File

@ -957,3 +957,14 @@
justify-content: space-between;
gap: 6px;
}
/* A JSON value in a preview: the first level, a few entries - the whole tree opens with the eye. */
.execution-json-preview {
display: block;
margin-top: 4px;
max-height: 14rem;
overflow: auto;
border-radius: 6px;
background: #f8fafc;
padding: 6px 8px;
}

View File

@ -161,7 +161,9 @@
@if (outcome.payload !== null && outcome.payload !== undefined) {
<div class="execution-outcome-payload">
@if (isTextPayload(outcome.payload)) {
@if (payloadJson(outcome.payload); as tree) {
<app-json-viewer [value]="tree" [initiallyExpanded]="true" />
} @else if (isTextPayload(outcome.payload)) {
<div class="execution-outcome-text">{{ outcome.payload }}</div>
} @else {
<app-json-viewer [value]="outcome.payload" [initiallyExpanded]="true" />
@ -443,7 +445,11 @@
</button>
}
</div>
@if (input.json) {
<app-json-viewer class="execution-json-preview" [value]="input.json" [expandDepth]="1" [maxEntries]="jsonPreviewEntries" />
} @else {
<div class="execution-intermediate-preview">{{ input.preview }}</div>
}
</div>
}
</div>
@ -495,7 +501,11 @@
</button>
}
</div>
<div class="execution-intermediate-preview">{{ input.preview }}</div>
@if (input.json) {
<app-json-viewer class="execution-json-preview" [value]="input.json" [expandDepth]="1" [maxEntries]="jsonPreviewEntries" />
} @else {
<div class="execution-intermediate-preview">{{ input.preview }}</div>
}
</div>
} @empty {
<div class="text-[11px] text-slate-500">No inputs recorded.</div>
@ -614,7 +624,11 @@
</button>
}
</div>
@if (output.json) {
<app-json-viewer class="execution-json-preview" [value]="output.json" [expandDepth]="1" [maxEntries]="jsonPreviewEntries" />
} @else {
<div class="execution-output-preview">{{ output.preview }}</div>
}
</div>
}
</div>
@ -648,7 +662,11 @@
<button type="button" mat-stroked-button (click)="closeOutputPreview($event)">Close</button>
</div>
<div class="min-h-0 flex-1 overflow-auto px-5 py-4">
@if (outputModal.json) {
<app-json-viewer [value]="outputModal.json" />
} @else {
<pre class="execution-output-modal">{{ outputModal.value }}</pre>
}
</div>
</div>
</div>
@ -665,7 +683,11 @@
<button type="button" mat-stroked-button (click)="closeIntermediateInputPreview($event)">Close</button>
</div>
<div class="min-h-0 flex-1 overflow-auto px-5 py-4">
@if (inputModal.json) {
<app-json-viewer [value]="inputModal.json" />
} @else {
<pre class="execution-output-modal">{{ inputModal.value }}</pre>
}
</div>
</div>
</div>

View File

@ -108,6 +108,8 @@ import {
VaultAuthorizationEntry,
buildAuthorizationGate,
isExecutionStartable,
asJsonTree,
JSON_PREVIEW_ENTRIES
} from './execution-viewer.utils';
import { buildExecutionLoopHistories, ExecutionLoopHistory } from './execution-rounds';
import {
@ -693,6 +695,13 @@ export class TaskExecutionViewerComponent implements OnDestroy {
return typeof payload === 'string';
}
/** A payload that is JSON, including JSON a model wrote as text, as a tree; null otherwise. */
payloadJson(payload: unknown): unknown | null {
return typeof payload === 'string' ? asJsonTree(payload) : null;
}
readonly jsonPreviewEntries = JSON_PREVIEW_ENTRIES;
stepNameForOutcome(stepId: string): string {
return this.execution()?.context.steps?.[stepId]?.node?.name ?? stepId;
}

View File

@ -776,7 +776,8 @@ export async function refreshLoopMarkers(
.filter((c) => getGraphConnectionKind(c.sourceOutput, c.targetInput) === 'dependency')
.map((c) => ({ sourceId: c.source, targetId: c.target }))
});
for (const connection of data) {
// Every connection, dependencies included: in a read-only graph none of them can be selected.
for (const connection of connections) {
const loopBack = backEdges.has(connection.id);
const readonly = resolvedRuntime?.readonly === true;
const top = loopBack ? loopTop(area, connection) : undefined;