From 360ed5bd82586c24cb3f34e44ff6576f2fef285a Mon Sep 17 00:00:00 2001 From: Lucio Lelii Date: Thu, 3 Sep 2026 22:17:25 +0200 Subject: [PATCH] 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) --- .../tasks-executor/tasks-executor.html | 11 +- .../layouts/tasks-executor/tasks-executor.ts | 26 ++- .../execution-compare-view.css | 190 ++++++++++++++++++ .../execution-compare-view.html | 95 +++++++++ .../execution-compare-view.spec.ts | 112 +++++++++++ .../execution-compare-view.ts | 67 ++++++ .../tasks-executions-list.css | 56 ++++++ .../tasks-executions-list.html | 28 +++ .../tasks-executions-list.spec.ts | 121 +++++++++++ .../tasks-executions-list.ts | 53 +++++ 10 files changed, 757 insertions(+), 2 deletions(-) create mode 100644 src/app/shared/execution-compare/execution-compare-view.css create mode 100644 src/app/shared/execution-compare/execution-compare-view.html create mode 100644 src/app/shared/execution-compare/execution-compare-view.spec.ts create mode 100644 src/app/shared/execution-compare/execution-compare-view.ts create mode 100644 src/app/shared/tasks-executions-list/tasks-executions-list.spec.ts diff --git a/src/app/layouts/tasks-executor/tasks-executor.html b/src/app/layouts/tasks-executor/tasks-executor.html index 2c92436..75f679c 100644 --- a/src/app/layouts/tasks-executor/tasks-executor.html +++ b/src/app/layouts/tasks-executor/tasks-executor.html @@ -6,7 +6,8 @@ [groups]="groups()" [selectedExecutionId]="selectedExecutionId()" (executionSelected)="selectExecution($event)" - (executionDeleteRequested)="removeExecution($event)"> + (executionDeleteRequested)="removeExecution($event)" + (compareRequested)="openComparison($event)"> @if (selectedExecution(); as run) { @@ -75,11 +76,19 @@ @if (childExecutionError(); as childError) {
{{ childError }}
} + @if (comparing()) { + + + } @else { + } diff --git a/src/app/layouts/tasks-executor/tasks-executor.ts b/src/app/layouts/tasks-executor/tasks-executor.ts index 7343f9d..4cccc26 100644 --- a/src/app/layouts/tasks-executor/tasks-executor.ts +++ b/src/app/layouts/tasks-executor/tasks-executor.ts @@ -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(null); readonly childExecutionLoading = signal(false); readonly childExecutionError = signal(null); diff --git a/src/app/shared/execution-compare/execution-compare-view.css b/src/app/shared/execution-compare/execution-compare-view.css new file mode 100644 index 0000000..baa4c69 --- /dev/null +++ b/src/app/shared/execution-compare/execution-compare-view.css @@ -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; +} diff --git a/src/app/shared/execution-compare/execution-compare-view.html b/src/app/shared/execution-compare/execution-compare-view.html new file mode 100644 index 0000000..94076bb --- /dev/null +++ b/src/app/shared/execution-compare/execution-compare-view.html @@ -0,0 +1,95 @@ +
+
+
+ {{ runLabel(left()) }} + + {{ runLabel(right()) }} +
+
+ + +
+
+ + @if (comparison(); as comparison) { + @if (comparison.disjoint) { +

+ 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. +

+ } @else { +

+ {{ 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. +

+ + @for (outcome of visibleOutcomes(); track outcome.code) { +
+

+ Outcome + {{ outcome.label || outcome.code }} + {{ outcome.state }} +

+
+
+ @for (part of parts(outcome.left, outcome.right, 'left'); track $index) { + {{ part.text }} + } +
+
+ @for (part of parts(outcome.left, outcome.right, 'right'); track $index) { + {{ part.text }} + } +
+
+
+ } + + @for (node of visibleNodes(); track node.stepId) { + @if (hasContent(node)) { +
+

+ {{ node.title }} + {{ node.state }} +

+ @if (node.statusChanged) { +

Status: {{ node.leftStatus }} → {{ node.rightStatus }}

+ } + @for (value of visibleValues(node); track value.key) { +
+
+ {{ value.kind }} + {{ value.name }} +
+
+
+ @for (part of parts(value.left, value.right, 'left'); track $index) { + {{ part.text }} + } +
+
+ @for (part of parts(value.left, value.right, 'right'); track $index) { + {{ part.text }} + } +
+
+
+ } +
+ } + } + + @if (!visibleNodes().length && !visibleOutcomes().length) { +

+ These two runs produced the same values on every node. +

+ } + } + } @else { +

Pick two runs to compare.

+ } +
diff --git a/src/app/shared/execution-compare/execution-compare-view.spec.ts b/src/app/shared/execution-compare/execution-compare-view.spec.ts new file mode 100644 index 0000000..fabd1b9 --- /dev/null +++ b/src/app/shared/execution-compare/execution-compare-view.spec.ts @@ -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; + 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(); + }); +}); diff --git a/src/app/shared/execution-compare/execution-compare-view.ts b/src/app/shared/execution-compare/execution-compare-view.ts new file mode 100644 index 0000000..e1438fc --- /dev/null +++ b/src/app/shared/execution-compare/execution-compare-view.ts @@ -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(null); + readonly right = input(null); + readonly closed = output(); + + /** 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(() => { + 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; + } +} diff --git a/src/app/shared/tasks-executions-list/tasks-executions-list.css b/src/app/shared/tasks-executions-list/tasks-executions-list.css index 5776200..97fc24e 100644 --- a/src/app/shared/tasks-executions-list/tasks-executions-list.css +++ b/src/app/shared/tasks-executions-list/tasks-executions-list.css @@ -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; +} diff --git a/src/app/shared/tasks-executions-list/tasks-executions-list.html b/src/app/shared/tasks-executions-list/tasks-executions-list.html index 0218dbd..0654152 100644 --- a/src/app/shared/tasks-executions-list/tasks-executions-list.html +++ b/src/app/shared/tasks-executions-list/tasks-executions-list.html @@ -66,8 +66,28 @@
Latest {{ group.lastExecutionTimeLabel }} Run {{ runNumberLabel(group.latestRunNumber) }} + @if (group.executions.length > 1) { + + }
+ @if (isComparing(group.id)) { +
+ {{ comparePickCount() }} of 2 runs picked + +
+ } + @if (isGroupExpanded(group.id)) {
@for (execution of group.executions; track execution.id) { @@ -77,6 +97,14 @@ (click)="selectExecution(execution.id)">
+ @if (isComparing(group.id)) { + + } {{ runNumberLabel(execution.runNumber) }} ({ + 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; + 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(); + }); +}); diff --git a/src/app/shared/tasks-executions-list/tasks-executions-list.ts b/src/app/shared/tasks-executions-list/tasks-executions-list.ts index 22bfb31..21edb84 100644 --- a/src/app/shared/tasks-executions-list/tasks-executions-list.ts +++ b/src/app/shared/tasks-executions-list/tasks-executions-list.ts @@ -74,6 +74,19 @@ export class TasksExecutionsListComponent { readonly executionSelected = output(); readonly executionDeleteRequested = output(); readonly executionRerunRequested = output(); + /** 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(null); + private readonly comparePicks = signal([]); + readonly searchTerm = model(''); readonly filter = signal('all'); readonly orderBy = signal('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 }); + } }