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 }); + } }