Compare two runs of a flow, picked from the run list

The second half: picking the pair, and showing the join.

Selection is a mode of one group, and its own state. It is deliberately not
folded into selectedExecutionId - that drives which run the main panel shows, so
ticking a box would navigate away from what the user is reading. Confining it to
a group is not tidiness either: runs of different flows share no step ids, so the
join would report every node as replaced. A third pick replaces the older one
rather than refusing the click, which would leave the user hunting for which box
to clear.

The view puts the two values side by side with the changed words marked, shows
only the differing nodes by default, and compares outcomes alongside nodes -
which is where the answer lives on a flow ending in an End node. An identical
value is not diffed at all: running the table over text known to be the same can
only output "all the same".

Two things it says out loud rather than leaving to be inferred: that model output
varies between runs on its own, so a difference is not by itself evidence of
changed behaviour; and that two runs sharing no node are almost certainly runs of
different versions of the flow, rather than a flow that changed entirely.

540 frontend tests green. The initial bundle grew 0.02 kB - the view lands in the
lazy tasks-executor chunk - leaving it 2.88 kB over its budget.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lucio Lelii 2026-09-03 22:17:25 +02:00
parent 3afaf9a169
commit 360ed5bd82
10 changed files with 757 additions and 2 deletions

View File

@ -6,7 +6,8 @@
[groups]="groups()"
[selectedExecutionId]="selectedExecutionId()"
(executionSelected)="selectExecution($event)"
(executionDeleteRequested)="removeExecution($event)">
(executionDeleteRequested)="removeExecution($event)"
(compareRequested)="openComparison($event)">
</app-tasks-executions-list>
@if (selectedExecution(); as run) {
@ -75,11 +76,19 @@
@if (childExecutionError(); as childError) {
<div class="interactive-subflow-error">{{ childError }}</div>
}
@if (comparing()) {
<app-execution-compare-view
[left]="compareLeft()"
[right]="compareRight()"
(closed)="closeComparison()">
</app-execution-compare-view>
} @else {
<app-task-execution-viewer
[execution]="displayedExecution()"
[parentExecution]="displayedParentExecution()"
[parentContainerStep]="displayedParentContainerStep()">
</app-task-execution-viewer>
}
</mat-card>
</div>
</div>

View File

@ -25,6 +25,7 @@ import { TaskExecutionViewerComponent } from '@shared/task-execution-viewer/task
import { MatTooltipModule } from '@angular/material/tooltip';
import { biasInterventionMix, isBiasVariantContext } from '@models/bias-impact';
import { ExecutionTreeComponent, ExecutionTreeSelection, executionTreeHasContent } from '@shared/execution-tree/execution-tree';
import { ExecutionCompareViewComponent } from '@shared/execution-compare/execution-compare-view';
import { formatDuration } from '@shared/task-execution-viewer/execution-viewer.utils';
import { BlocksService } from '@services/blocks/blocks';
import { ContainersService } from '@services/containers/containers';
@ -67,7 +68,7 @@ export function findInteractiveSubflowTargets(
@Component({
selector: 'app-tasks-executor',
imports: [TasksExecutionsListComponent, TaskExecutionViewerComponent, ExecutionTreeComponent, MatCardModule, MatTooltipModule],
imports: [TasksExecutionsListComponent, TaskExecutionViewerComponent, ExecutionTreeComponent, ExecutionCompareViewComponent, MatCardModule, MatTooltipModule],
templateUrl: './tasks-executor.html',
styleUrl: './tasks-executor.css',
changeDetection: ChangeDetectionStrategy.OnPush
@ -119,6 +120,29 @@ export class TasksExecutor {
findInteractiveSubflowTargets(this.selectedExecution())
);
/**
* The pair being compared, or null. Held apart from selectedExecutionId so that opening a
* comparison does not disturb which run the user had open, and closing it returns them there.
*/
private readonly comparePair = signal<{ leftId: string; rightId: string } | null>(null);
readonly compareLeft = computed(() => this.executionById(this.comparePair()?.leftId));
readonly compareRight = computed(() => this.executionById(this.comparePair()?.rightId));
readonly comparing = computed(() => !!this.compareLeft() && !!this.compareRight());
private executionById(id: string | undefined): TaskExecution | null {
if (!id) return null;
return this.executionDetails().find((execution) => execution.id === id) ?? null;
}
openComparison(pair: { leftId: string; rightId: string }) {
this.comparePair.set(pair);
}
closeComparison() {
this.comparePair.set(null);
}
readonly selectedChildExecutionId = signal<string | null>(null);
readonly childExecutionLoading = signal(false);
readonly childExecutionError = signal<string | null>(null);

View File

@ -0,0 +1,190 @@
:host {
display: block;
height: 100%;
overflow: auto;
}
.compare {
padding: 12px;
}
.compare-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 10px;
}
.compare-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 700;
color: #0f172a;
}
.compare-title .mat-icon {
color: #64748b;
font-size: 18px;
width: 18px;
height: 18px;
line-height: 18px;
}
.compare-actions {
display: flex;
align-items: center;
gap: 8px;
}
.compare-filter {
padding: 3px 10px;
border: 1px solid #e2e8f0;
border-radius: 999px;
background: #ffffff;
color: #475569;
font-size: 11px;
font-weight: 600;
cursor: pointer;
}
.compare-filter.active {
border-color: #c7d2fe;
background: #eef2ff;
color: #3730a3;
}
/*
* Model output differs between runs on its own. Saying so next to the count is the difference
* between a comparison that informs and one that invites false conclusions.
*/
.compare-note {
margin: 0 0 10px;
color: #64748b;
font-size: 11.5px;
line-height: 1.45;
}
.compare-warning {
margin: 0 0 10px;
padding: 8px 10px;
border: 1px solid #fed7aa;
border-radius: 6px;
background: #fff7ed;
color: #9a3412;
font-size: 12px;
line-height: 1.45;
}
.compare-empty {
color: #64748b;
font-size: 12px;
}
.compare-node {
margin-bottom: 12px;
padding: 8px 10px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #ffffff;
}
.compare-node-title {
display: flex;
align-items: center;
gap: 6px;
margin: 0 0 6px;
font-size: 12.5px;
font-weight: 700;
color: #0f172a;
}
.compare-status {
margin: 0 0 6px;
color: #92400e;
font-size: 11px;
font-weight: 600;
}
.compare-badge {
padding: 0 5px;
border-radius: 3px;
background: #f1f5f9;
color: #64748b;
font-size: 9.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.compare-badge.outcome {
background: #dcfce7;
color: #15803d;
}
.compare-state {
margin-left: auto;
padding: 0 6px;
border-radius: 999px;
font-size: 9.5px;
font-weight: 700;
text-transform: uppercase;
}
.compare-state.changed { background: #fef3c7; color: #92400e; }
.compare-state.equal { background: #f1f5f9; color: #64748b; }
.compare-state.only-left { background: #fee2e2; color: #b91c1c; }
.compare-state.only-right { background: #dcfce7; color: #15803d; }
.compare-value + .compare-value {
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid #f1f5f9;
}
.compare-value-name {
display: flex;
align-items: center;
gap: 5px;
margin-bottom: 4px;
font-size: 11.5px;
font-weight: 600;
color: #334155;
}
/* Two columns, each scrolling its own long value rather than the page scrolling sideways. */
.compare-row {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 8px;
}
.compare-cell {
min-width: 0;
max-height: 320px;
overflow: auto;
padding: 6px 8px;
border: 1px solid #e2e8f0;
border-radius: 6px;
background: #f8fafc;
color: #0f172a;
font-size: 12px;
line-height: 1.5;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.compare-cell .added {
background: #dcfce7;
color: #14532d;
border-radius: 2px;
}
.compare-cell .removed {
background: #fee2e2;
color: #7f1d1d;
border-radius: 2px;
}

View File

@ -0,0 +1,95 @@
<div class="compare">
<header class="compare-header">
<div class="compare-title">
<span class="compare-run">{{ runLabel(left()) }}</span>
<mat-icon fontIcon="compare_arrows"></mat-icon>
<span class="compare-run">{{ runLabel(right()) }}</span>
</div>
<div class="compare-actions">
<button type="button" class="compare-filter" [class.active]="onlyDifferences()"
(click)="toggleOnlyDifferences()">
{{ onlyDifferences() ? 'Only differences' : 'All values' }}
</button>
<button type="button" mat-stroked-button (click)="closed.emit()">Close</button>
</div>
</header>
@if (comparison(); as comparison) {
@if (comparison.disjoint) {
<p class="compare-warning">
These two runs have no node in common, so there is nothing to line up. They are almost
certainly runs of different versions of the flow.
</p>
} @else {
<p class="compare-note">
{{ comparison.changedNodeCount }} of {{ comparison.nodes.length }}
{{ comparison.nodes.length === 1 ? 'node differs' : 'nodes differ' }}. Model output varies
between runs on its own, so a difference is not by itself evidence of a change in behaviour.
</p>
@for (outcome of visibleOutcomes(); track outcome.code) {
<section class="compare-node">
<h3 class="compare-node-title">
<span class="compare-badge outcome">Outcome</span>
{{ outcome.label || outcome.code }}
<span class="compare-state" [ngClass]="outcome.state">{{ outcome.state }}</span>
</h3>
<div class="compare-row">
<div class="compare-cell">
@for (part of parts(outcome.left, outcome.right, 'left'); track $index) {
<span [ngClass]="part.kind">{{ part.text }}</span>
}
</div>
<div class="compare-cell">
@for (part of parts(outcome.left, outcome.right, 'right'); track $index) {
<span [ngClass]="part.kind">{{ part.text }}</span>
}
</div>
</div>
</section>
}
@for (node of visibleNodes(); track node.stepId) {
@if (hasContent(node)) {
<section class="compare-node">
<h3 class="compare-node-title">
{{ node.title }}
<span class="compare-state" [ngClass]="node.state">{{ node.state }}</span>
</h3>
@if (node.statusChanged) {
<p class="compare-status">Status: {{ node.leftStatus }} → {{ node.rightStatus }}</p>
}
@for (value of visibleValues(node); track value.key) {
<div class="compare-value">
<div class="compare-value-name">
<span class="compare-badge" [ngClass]="value.kind">{{ value.kind }}</span>
{{ value.name }}
</div>
<div class="compare-row">
<div class="compare-cell">
@for (part of parts(value.left, value.right, 'left'); track $index) {
<span [ngClass]="part.kind">{{ part.text }}</span>
}
</div>
<div class="compare-cell">
@for (part of parts(value.left, value.right, 'right'); track $index) {
<span [ngClass]="part.kind">{{ part.text }}</span>
}
</div>
</div>
</div>
}
</section>
}
}
@if (!visibleNodes().length && !visibleOutcomes().length) {
<p class="compare-empty">
These two runs produced the same values on every node.
</p>
}
}
} @else {
<p class="compare-empty">Pick two runs to compare.</p>
}
</div>

View File

@ -0,0 +1,112 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { vi } from 'vitest';
import { TaskExecution } from '@models/task-execution';
import { ExecutionCompareViewComponent } from './execution-compare-view';
function execution(id: string, response: string, runNumber: number): TaskExecution {
return {
id,
name: id,
creationTime: 1,
runNumber,
context: {
inputs: {},
result: { 's1:response': response, 's2:response': 'identical' },
errors: {},
warnings: {},
waitingSteps: [],
status: 'SUCCESS',
steps: {
s1: {
id: 's1', status: 'COMPLETED', simulated: false,
node: { id: 's1', name: 'Evaluate', nodeFamily: 'block', typeName: 'LLMBlock', inputs: [], outputs: [{ name: 'response', type: 'TEXT' }], specificConfiguration: {} },
inputs: [], outputs: [{ descriptor: { name: 'response', type: 'TEXT' }, connected: false }]
},
s2: {
id: 's2', status: 'COMPLETED', simulated: false,
node: { id: 's2', name: 'Steady', nodeFamily: 'block', typeName: 'LLMBlock', inputs: [], outputs: [{ name: 'response', type: 'TEXT' }], specificConfiguration: {} },
inputs: [], outputs: [{ descriptor: { name: 'response', type: 'TEXT' }, connected: false }]
}
}
}
} as unknown as TaskExecution;
}
describe('ExecutionCompareViewComponent', () => {
let fixture: ComponentFixture<ExecutionCompareViewComponent>;
let component: ExecutionCompareViewComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({ imports: [ExecutionCompareViewComponent] }).compileComponents();
fixture = TestBed.createComponent(ExecutionCompareViewComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput('left', execution('e1', 'Score 7 of 10', 1));
fixture.componentRef.setInput('right', execution('e2', 'Score 5 of 10', 2));
fixture.detectChanges();
});
it('shows only the differing node by default, and all of them on request', () => {
// The differences are what the view exists for; on a long flow the rest is noise.
expect(component.visibleNodes().map((node) => node.title)).toEqual(['Evaluate']);
component.toggleOnlyDifferences();
fixture.detectChanges();
expect(component.visibleNodes().map((node) => node.title).sort()).toEqual(['Evaluate', 'Steady']);
});
it('names both runs by their run number', () => {
const text = fixture.nativeElement.textContent;
expect(text).toContain('Run #1');
expect(text).toContain('Run #2');
});
it('marks the changed words on each side, and only those', () => {
const parts = component.parts('Score 7 of 10', 'Score 5 of 10', 'left');
expect(parts.filter((part) => part.kind === 'added')).toHaveLength(0);
expect(parts.some((part) => part.kind === 'removed' && part.text.includes('7'))).toBe(true);
const right = component.parts('Score 7 of 10', 'Score 5 of 10', 'right');
expect(right.filter((part) => part.kind === 'removed')).toHaveLength(0);
expect(right.some((part) => part.kind === 'added' && part.text.includes('5'))).toBe(true);
});
it('does not diff a value that is identical on both sides', () => {
expect(component.parts('same', 'same', 'left')).toEqual([{ kind: 'same', text: 'same' }]);
});
it('warns that model output varies on its own', () => {
// Without this the view invites reading every difference as a change in behaviour.
expect(fixture.nativeElement.textContent).toContain('varies');
});
it('says so when two runs share no node, instead of showing everything as replaced', () => {
const other = execution('e3', 'x', 3);
(other.context as any).steps = {
z9: {
id: 'z9', status: 'COMPLETED', simulated: false,
node: { id: 'z9', name: 'New node', nodeFamily: 'block', typeName: 'LLMBlock', inputs: [], outputs: [], specificConfiguration: {} },
inputs: [], outputs: []
}
};
fixture.componentRef.setInput('right', other);
fixture.detectChanges();
expect(component.comparison()?.disjoint).toBe(true);
expect(fixture.nativeElement.textContent).toContain('no node in common');
});
it('reports two identical runs as such rather than showing an empty page', () => {
fixture.componentRef.setInput('right', execution('e2', 'Score 7 of 10', 2));
fixture.detectChanges();
expect(component.visibleNodes()).toEqual([]);
expect(fixture.nativeElement.textContent).toContain('same values on every node');
});
it('emits on close so the host can restore the run the user had open', () => {
const closed = vi.fn();
component.closed.subscribe(closed);
fixture.nativeElement.querySelectorAll('button')[1].click();
expect(closed).toHaveBeenCalled();
});
});

View File

@ -0,0 +1,67 @@
import { CommonModule } from '@angular/common';
import { ChangeDetectionStrategy, Component, computed, input, output, signal } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip';
import { TaskExecution } from '@models/task-execution';
import { ComparedNode, ComparedValue, compareExecutions } from './execution-compare';
import { DiffPart, diffSide, diffWords } from './execution-compare-diff';
@Component({
selector: 'app-execution-compare-view',
imports: [CommonModule, MatButtonModule, MatIconModule, MatTooltipModule],
templateUrl: './execution-compare-view.html',
styleUrl: './execution-compare-view.css',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ExecutionCompareViewComponent {
readonly left = input<TaskExecution | null>(null);
readonly right = input<TaskExecution | null>(null);
readonly closed = output<void>();
/** On by default: the differences are what the view exists to show. */
readonly onlyDifferences = signal(true);
readonly comparison = computed(() => {
const left = this.left();
const right = this.right();
return left && right ? compareExecutions(left, right) : null;
});
readonly visibleNodes = computed<ComparedNode[]>(() => {
const nodes = this.comparison()?.nodes ?? [];
return this.onlyDifferences() ? nodes.filter((node) => node.changed) : nodes;
});
readonly visibleOutcomes = computed(() => {
const outcomes = this.comparison()?.outcomes ?? [];
return this.onlyDifferences() ? outcomes.filter((one) => one.state !== 'equal') : outcomes;
});
runLabel(execution: TaskExecution | null): string {
if (!execution) return '—';
return execution.runNumber ? `Run #${execution.runNumber}` : execution.name || execution.id;
}
toggleOnlyDifferences() {
this.onlyDifferences.update((only) => !only);
}
visibleValues(node: ComparedNode): ComparedValue[] {
return this.onlyDifferences() ? node.values.filter((value) => value.state !== 'equal') : node.values;
}
/**
* The parts to render for one side. Equal values are not diffed at all: running the table over
* text known to be identical is work whose only possible output is "all the same".
*/
parts(left: string | null, right: string | null, side: 'left' | 'right'): DiffPart[] {
const own = (side === 'left' ? left : right) ?? '';
if (left === right) return own.length ? [{ kind: 'same', text: own }] : [];
return diffSide(diffWords(left ?? '', right ?? ''), side);
}
hasContent(node: ComparedNode): boolean {
return this.visibleValues(node).length > 0 || node.statusChanged;
}
}

View File

@ -367,3 +367,59 @@
background: #dcfce7;
color: #15803d;
}
/* Comparing is a mode of the group, so its controls live with the group's own facts. */
.tasks-list-compare-toggle {
display: inline-flex;
align-items: center;
gap: 3px;
margin-left: auto;
padding: 1px 6px;
border: 1px solid #e2e8f0;
border-radius: 999px;
background: #ffffff;
color: #475569;
font-size: 10px;
font-weight: 600;
cursor: pointer;
}
.tasks-list-compare-toggle:hover {
border-color: #cbd5e1;
color: #1e293b;
}
.tasks-list-compare-toggle.active {
border-color: #c7d2fe;
background: #eef2ff;
color: #3730a3;
}
.tasks-list-compare-toggle .mat-icon {
font-size: 13px;
width: 13px;
height: 13px;
line-height: 13px;
}
.tasks-list-compare-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-top: 8px;
padding: 6px 8px;
border: 1px solid #c7d2fe;
border-radius: 6px;
background: #eef2ff;
color: #3730a3;
font-size: 11px;
font-weight: 600;
}
.tasks-list-compare-pick {
flex: 0 0 auto;
margin: 0 2px 0 0;
accent-color: #4338ca;
cursor: pointer;
}

View File

@ -66,8 +66,28 @@
<div class="tasks-list-group-meta">
<span>Latest {{ group.lastExecutionTimeLabel }}</span>
<span>Run {{ runNumberLabel(group.latestRunNumber) }}</span>
@if (group.executions.length > 1) {
<button
type="button"
class="tasks-list-compare-toggle"
[class.active]="isComparing(group.id)"
[matTooltip]="isComparing(group.id) ? 'Cancel comparison' : 'Compare two runs of this flow'"
(click)="toggleCompareMode(group.id, $event)">
<mat-icon fontIcon="compare_arrows"></mat-icon>
<span>{{ isComparing(group.id) ? 'Cancel' : 'Compare' }}</span>
</button>
}
</div>
@if (isComparing(group.id)) {
<div class="tasks-list-compare-bar">
<span>{{ comparePickCount() }} of 2 runs picked</span>
<button type="button" mat-flat-button [disabled]="!canCompare()" (click)="submitCompare($event)">
Compare
</button>
</div>
}
@if (isGroupExpanded(group.id)) {
<div class="tasks-list-executions">
@for (execution of group.executions; track execution.id) {
@ -77,6 +97,14 @@
(click)="selectExecution(execution.id)">
<div class="tasks-list-execution-head">
<div class="tasks-list-execution-title">
@if (isComparing(group.id)) {
<input
type="checkbox"
class="tasks-list-compare-pick"
[attr.aria-label]="'Pick run ' + runNumberLabel(execution.runNumber) + ' for comparison'"
[checked]="isComparePick(execution.id)"
(click)="toggleComparePick(execution.id, $event)" />
}
<span class="tasks-list-run">{{ runNumberLabel(execution.runNumber) }}</span>
<span
class="tasks-list-kind"

View File

@ -0,0 +1,121 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { vi } from 'vitest';
import { TaskExecutionGroupListItem, TasksExecutionsListComponent } from './tasks-executions-list';
function group(id: string, runIds: string[]): TaskExecutionGroupListItem {
return {
id,
sourceFlowId: `flow-${id}`,
name: `Group ${id}`,
executionCount: runIds.length,
lastExecutionTime: 1,
lastExecutionTimeLabel: 'now',
latestRunNumber: runIds.length,
latestExecutionId: runIds[runIds.length - 1],
executions: runIds.map((runId, index) => ({
id: runId,
title: runId,
flowName: 'Flow',
status: 'SUCCESS',
startedAt: 'now',
creationTime: index + 1,
runNumber: index + 1,
kind: 'RUN'
}))
} as unknown as TaskExecutionGroupListItem;
}
describe('TasksExecutionsListComponent comparison picking', () => {
let fixture: ComponentFixture<TasksExecutionsListComponent>;
let component: TasksExecutionsListComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({ imports: [TasksExecutionsListComponent] }).compileComponents();
fixture = TestBed.createComponent(TasksExecutionsListComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput('groups', [group('g1', ['e1', 'e2', 'e3'])]);
fixture.componentRef.setInput('selectedExecutionId', 'e1');
fixture.detectChanges();
});
it('needs two runs before it will compare', () => {
component.toggleCompareMode('g1');
expect(component.canCompare()).toBe(false);
component.toggleComparePick('e1');
expect(component.canCompare()).toBe(false);
component.toggleComparePick('e2');
expect(component.canCompare()).toBe(true);
});
it('emits the pair, oldest pick first', () => {
const compared = vi.fn();
component.compareRequested.subscribe(compared);
component.toggleCompareMode('g1');
component.toggleComparePick('e1');
component.toggleComparePick('e2');
component.submitCompare();
expect(compared).toHaveBeenCalledWith({ leftId: 'e1', rightId: 'e2' });
});
it('replaces the older pick on a third click rather than refusing it', () => {
// Refusing would leave the user hunting for which box to clear.
component.toggleCompareMode('g1');
component.toggleComparePick('e1');
component.toggleComparePick('e2');
component.toggleComparePick('e3');
const compared = vi.fn();
component.compareRequested.subscribe(compared);
component.submitCompare();
expect(compared).toHaveBeenCalledWith({ leftId: 'e2', rightId: 'e3' });
});
it('unticks a pick when it is clicked again', () => {
component.toggleCompareMode('g1');
component.toggleComparePick('e1');
component.toggleComparePick('e1');
expect(component.isComparePick('e1')).toBe(false);
expect(component.comparePickCount()).toBe(0);
});
it('does not disturb which run is open', () => {
// Ticking a box must not navigate away from what the user is reading.
const selected = vi.fn();
component.executionSelected.subscribe(selected);
component.toggleCompareMode('g1');
component.toggleComparePick('e2');
expect(selected).not.toHaveBeenCalled();
expect(component.selectedExecutionId()).toBe('e1');
});
it('confines comparison to one group, and clears the picks on leaving', () => {
// Runs of different flows share no node ids, so comparing across groups is meaningless.
component.toggleCompareMode('g1');
component.toggleComparePick('e1');
expect(component.isComparing('g1')).toBe(true);
expect(component.isComparing('g2')).toBe(false);
component.toggleCompareMode('g1');
expect(component.isComparing('g1')).toBe(false);
expect(component.comparePickCount()).toBe(0);
});
it('refuses to emit without a full pair', () => {
const compared = vi.fn();
component.compareRequested.subscribe(compared);
component.toggleCompareMode('g1');
component.toggleComparePick('e1');
component.submitCompare();
expect(compared).not.toHaveBeenCalled();
});
});

View File

@ -74,6 +74,19 @@ export class TasksExecutionsListComponent {
readonly executionSelected = output<string>();
readonly executionDeleteRequested = output<string>();
readonly executionRerunRequested = output<string>();
/** Two runs of one group, picked to be compared. */
readonly compareRequested = output<{ leftId: string; rightId: string }>();
/**
* Which group is in compare mode, and which of its runs are ticked.
*
* Kept apart from `selectedExecutionId` on purpose: that one drives which run the main panel
* shows, and folding the two together would make ticking a box navigate away from what the user
* is reading. Confined to one group because comparing runs of different flows is meaningless -
* they share no node ids.
*/
private readonly compareGroupId = signal<string | null>(null);
private readonly comparePicks = signal<string[]>([]);
readonly searchTerm = model<string>('');
readonly filter = signal<TaskExecutionFilter>('all');
readonly orderBy = signal<string | null>('lastExecutionTime');
@ -246,4 +259,44 @@ export class TasksExecutionsListComponent {
if (filter === 'all') return true;
return getExecutionStatusGroup(status) === filter;
}
isComparing(groupId: string): boolean {
return this.compareGroupId() === groupId;
}
toggleCompareMode(groupId: string, event?: Event) {
event?.stopPropagation();
const leaving = this.compareGroupId() === groupId;
this.compareGroupId.set(leaving ? null : groupId);
this.comparePicks.set([]);
}
isComparePick(executionId: string): boolean {
return this.comparePicks().includes(executionId);
}
toggleComparePick(executionId: string, event?: Event) {
event?.stopPropagation();
this.comparePicks.update((current) => {
if (current.includes(executionId)) return current.filter((id) => id !== executionId);
// Two at a time: a third pick replaces the older one rather than refusing the click, which
// would leave the user hunting for which box to clear.
return current.length < 2 ? [...current, executionId] : [current[1], executionId];
});
}
comparePickCount(): number {
return this.comparePicks().length;
}
canCompare(): boolean {
return this.comparePicks().length === 2;
}
submitCompare(event?: Event) {
event?.stopPropagation();
const [leftId, rightId] = this.comparePicks();
if (!leftId || !rightId) return;
this.compareRequested.emit({ leftId, rightId });
}
}