diff --git a/src/app/app.html b/src/app/app.html
index 501c7f9..69ca37e 100644
--- a/src/app/app.html
+++ b/src/app/app.html
@@ -7,6 +7,7 @@
+
diff --git a/src/app/app.ts b/src/app/app.ts
index 2630d20..71434be 100644
--- a/src/app/app.ts
+++ b/src/app/app.ts
@@ -8,13 +8,14 @@ import { SubflowPreviewDialogHostComponent } from '@shared/subflow-preview-dialo
import { BiasImpactExperimentDialogHostComponent } from '@shared/bias-impact-experiment-dialog/bias-impact-experiment-dialog';
import { BiasRerunDialogHostComponent } from '@shared/bias-rerun-dialog/bias-rerun-dialog';
import { BiasCompareDialogHostComponent } from '@shared/bias-compare-dialog/bias-compare-dialog';
+import { BiasReportDialogHostComponent } from '@shared/bias-report-dialog/bias-report-dialog';
import { ProjectContextDialogComponent } from '@shared/project-context-dialog/project-context-dialog';
import { ProjectDeleteDialogComponent } from '@shared/project-delete-dialog/project-delete-dialog';
import { ProjectDialogComponent } from '@shared/project-dialog/project-dialog';
@Component({
selector: 'app-root',
- imports: [RouterOutlet, ConfirmDialogHostComponent, GlobalNotificationComponent, HumanInteractionDialogHostComponent, NodeSettingsDialogHostComponent, SubflowPreviewDialogHostComponent, BiasImpactExperimentDialogHostComponent, BiasRerunDialogHostComponent, BiasCompareDialogHostComponent, ProjectDialogComponent, ProjectDeleteDialogComponent, ProjectContextDialogComponent],
+ imports: [RouterOutlet, ConfirmDialogHostComponent, GlobalNotificationComponent, HumanInteractionDialogHostComponent, NodeSettingsDialogHostComponent, SubflowPreviewDialogHostComponent, BiasImpactExperimentDialogHostComponent, BiasRerunDialogHostComponent, BiasCompareDialogHostComponent, BiasReportDialogHostComponent, ProjectDialogComponent, ProjectDeleteDialogComponent, ProjectContextDialogComponent],
templateUrl: './app.html',
styleUrl: './app.css',
changeDetection: ChangeDetectionStrategy.OnPush
diff --git a/src/app/services/dialogs/bias-report-dialog.ts b/src/app/services/dialogs/bias-report-dialog.ts
new file mode 100644
index 0000000..49375f2
--- /dev/null
+++ b/src/app/services/dialogs/bias-report-dialog.ts
@@ -0,0 +1,26 @@
+import { Injectable, signal } from '@angular/core';
+
+export type BiasReportDialogInput = {
+ reportId: string;
+};
+
+/**
+ * Opens a persisted bias impact report on its own.
+ *
+ *
A report is a wide, two-column document: it was rendered inside the run's side panel, where the
+ * columns collapsed into a ribbon a few words wide and the heading wrapped one word per line. It
+ * belongs in a modal, the same one the comparison already opens itself in.
+ */
+@Injectable({ providedIn: 'root' })
+export class BiasReportDialogService {
+ private readonly _state = signal(null);
+ readonly state = this._state.asReadonly();
+
+ open(input: BiasReportDialogInput) {
+ this._state.set(input);
+ }
+
+ close() {
+ this._state.set(null);
+ }
+}
diff --git a/src/app/shared/bias-impact-report-list/bias-impact-report-list.css b/src/app/shared/bias-impact-report-list/bias-impact-report-list.css
index 0d5ecac..decef27 100644
--- a/src/app/shared/bias-impact-report-list/bias-impact-report-list.css
+++ b/src/app/shared/bias-impact-report-list/bias-impact-report-list.css
@@ -29,31 +29,46 @@
line-height: 1.45;
}
+/*
+ * One row, read downwards. It used to be six columns on one line, which in the run's side panel -
+ * a few hundred pixels wide - pushed the summary off the right edge and gave the whole tab a
+ * horizontal scrollbar.
+ */
.bias-report-list__row {
- align-items: center;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: .5rem;
cursor: pointer;
display: grid;
- gap: .2rem .75rem;
- grid-template-columns: auto auto 1fr auto auto auto;
+ gap: .3rem;
+ min-width: 0;
padding: .6rem .75rem;
text-align: left;
transition: background-color 120ms ease;
+ width: 100%;
}
.bias-report-list__row:hover { background: #f1f5f9; }
+.bias-report-list__row-head { align-items: center; display: flex; flex-wrap: wrap; gap: .4rem; }
+.bias-report-list__row-meta { align-items: center; display: flex; flex-wrap: wrap; gap: .35rem .6rem; min-width: 0; }
+
.bias-report-list__kind { background: #e0e7ff; border-radius: 999px; color: #3730a3; font-size: .68rem; font-weight: 700; padding: .15rem .45rem; white-space: nowrap; }
-.bias-report-list__date { color: #64748b; font-size: .78rem; white-space: nowrap; }
-.bias-report-list__summary { color: #1e293b; font-size: .84rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
-.bias-report-list__node { color: #64748b; font-size: .72rem; overflow-wrap: anywhere; }
-.bias-report-list__annotations { color: #64748b; font-size: .76rem; white-space: nowrap; }
-.bias-report-list__changed { color: #64748b; font-size: .76rem; font-weight: 600; white-space: nowrap; }
-.bias-report-list__changed.changed { color: #b45309; }
+.bias-report-list__date { color: #64748b; font-size: .74rem; margin-left: auto; white-space: nowrap; }
-.bias-report-list__detail { display: grid; gap: .9rem; padding: .75rem; }
-
-@media (max-width: 640px) {
- .bias-report-list__row { grid-template-columns: 1fr; }
+/* Two lines of the summary: enough to tell two reports apart, not enough to bury the next one. */
+.bias-report-list__summary {
+ color: #1e293b;
+ display: -webkit-box;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 2;
+ line-clamp: 2;
+ font-size: .84rem;
+ line-height: 1.4;
+ overflow: hidden;
}
+
+.bias-report-list__node { color: #94a3b8; font-size: .7rem; max-width: 12rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.bias-report-list__annotations { color: #64748b; font-size: .74rem; white-space: nowrap; }
+.bias-report-list__judged { background: #ede9fe; border-radius: 999px; color: #5b21b6; font-size: .68rem; font-weight: 700; padding: .1rem .4rem; white-space: nowrap; }
+.bias-report-list__changed { color: #64748b; font-size: .72rem; font-weight: 700; letter-spacing: .03em; text-transform: uppercase; white-space: nowrap; }
+.bias-report-list__changed.changed { color: #b45309; }
diff --git a/src/app/shared/bias-impact-report-list/bias-impact-report-list.html b/src/app/shared/bias-impact-report-list/bias-impact-report-list.html
index 0cfbf88..47651fc 100644
--- a/src/app/shared/bias-impact-report-list/bias-impact-report-list.html
+++ b/src/app/shared/bias-impact-report-list/bias-impact-report-list.html
@@ -1,63 +1,64 @@
-@if (detailMode()) {
-
-
- @if (detailLoading()) {
-
Loading report…
- }
- @if (detailError(); as error) {
+
+ @if (loading()) {
+
Loading bias impact reports…
+ } @else if (listError(); as error) {
+
{{ error }}
- }
- @if (selectedReport(); as report) {
-
- }
-
-} @else {
-
- @if (loading()) {
-
Loading bias impact reports…
- } @else if (listError(); as error) {
-
-
{{ error }}
-
-
- } @else if (!reports().length) {
-
-
No bias impact reports for this run yet.
+
+
+ } @else if (!reports().length) {
+
+
No bias impact reports for this run yet.
+ @if (comparableVariant()) {
+
+ This run is the variant: its probes were active while it ran. Comparing it with the run it
+ repeats is what produces the report, and the report then appears here.
+
+
+ } @else {
+
+ A report compares this run against the same run with its bias or mitigation probes turned
+ on, and shows which node outputs it changed. Reports appear here once you produce one.
+
+ @if (blockedReason(); as reason) {
+
{{ reason }}
+ } @else {
- A report compares this run against the same run with its bias or mitigation probes turned
- on, and shows which node outputs it changed. Reports appear here once you produce one.
+ {{ annotatedNodeCount() }}
+ {{ annotatedNodeCount() === 1 ? 'node carries a probe' : 'nodes carry a probe' }}
+ that can be activated on this run.
- @if (blockedReason(); as reason) {
-
{{ reason }}
- } @else {
-
- {{ annotatedNodeCount() }}
- {{ annotatedNodeCount() === 1 ? 'node carries a probe' : 'nodes carry a probe' }}
- that can be activated on this run.
-
diff --git a/src/app/shared/bias-impact-report-list/bias-impact-report-list.spec.ts b/src/app/shared/bias-impact-report-list/bias-impact-report-list.spec.ts
index d6d62d4..23e9652 100644
--- a/src/app/shared/bias-impact-report-list/bias-impact-report-list.spec.ts
+++ b/src/app/shared/bias-impact-report-list/bias-impact-report-list.spec.ts
@@ -5,6 +5,7 @@ import { BiasImpactReportListComponent } from './bias-impact-report-list';
import { TaskExecutionsService } from '@services/task-executions/task-executions';
import { BiasImpactReport } from '@models/bias-impact';
import { BiasReportsRevisionService } from '@services/bias/bias-reports-revision';
+import { BiasReportDialogService } from '@services/dialogs/bias-report-dialog';
function makeReport(overrides: Partial = {}): BiasImpactReport {
return {
@@ -116,24 +117,6 @@ describe('BiasImpactReportListComponent', () => {
expect(listBiasImpactReports).toHaveBeenCalledTimes(1);
});
- it('keeps an open report open across a reload, and closes it when the execution changes', () => {
- fixture.componentRef.setInput('executionId', 'execution-1');
- fixture.detectChanges();
- fixture.componentInstance.openDetail('report-1');
- fixture.detectChanges();
- expect(fixture.componentInstance.selectedReport()).not.toBeNull();
-
- // A reload of the same run must not yank away what the user is reading.
- TestBed.inject(BiasReportsRevisionService).reportProduced();
- fixture.detectChanges();
- expect(fixture.componentInstance.selectedReport()).not.toBeNull();
-
- // A different run is a clean slate.
- fixture.componentRef.setInput('executionId', 'execution-2');
- fixture.detectChanges();
- expect(fixture.componentInstance.selectedReport()).toBeNull();
- });
-
it('loads and renders the reports for the given execution', () => {
fixture.componentRef.setInput('executionId', 'execution-1');
fixture.detectChanges();
@@ -165,37 +148,49 @@ describe('BiasImpactReportListComponent', () => {
expect(fixture.componentInstance.reports().length).toBe(1);
});
- it('opens the report detail via getBiasImpactReport and can navigate back to the list', () => {
+ it('opens a report in a window of its own rather than inside the panel', () => {
fixture.componentRef.setInput('executionId', 'execution-1');
fixture.detectChanges();
- fixture.componentInstance.openDetail('report-1');
- fixture.detectChanges();
+ (fixture.nativeElement.querySelector('.bias-report-list__row') as HTMLButtonElement).click();
- expect(getBiasImpactReport).toHaveBeenCalledWith('report-1');
- expect(fixture.componentInstance.detailMode()).toBe(true);
- expect(fixture.nativeElement.querySelector('app-bias-impact-report-viewer')).not.toBeNull();
-
- fixture.componentInstance.closeDetail();
- fixture.detectChanges();
-
- expect(fixture.componentInstance.detailMode()).toBe(false);
+ // The report is a two-column document and this panel is a few words wide; the dialog host owns
+ // the loading, so the list itself never fetches one.
+ expect(TestBed.inject(BiasReportDialogService).state()).toEqual({ reportId: 'report-1' });
+ expect(getBiasImpactReport).not.toHaveBeenCalled();
+ expect(fixture.nativeElement.querySelector('app-bias-impact-report-viewer')).toBeNull();
});
- it('shows the same inline message for a report that is missing or not accessible', () => {
- getBiasImpactReport.mockReturnValue(throwError(() => ({ status: 404 })));
- fixture.componentRef.setInput('executionId', 'execution-1');
+ it('offers the comparison, not another rerun, when the run on screen is the variant', async () => {
+ listBiasImpactReports.mockReturnValue(of([]));
+ fixture.componentRef.setInput('executionId', 'variant-1');
+ fixture.componentRef.setInput('comparableVariant', true);
+ fixture.detectChanges();
+ await fixture.whenStable();
fixture.detectChanges();
- fixture.componentInstance.openDetail('missing-report');
+ const text = fixture.nativeElement.textContent;
+ expect(text).toContain('This run is the variant');
+ expect(text).not.toContain('Run a biased rerun');
+
+ let compareAsked = 0;
+ fixture.componentInstance.compareRequested.subscribe(() => { compareAsked += 1; });
+ const button = fixture.nativeElement.querySelector('.bias-report-list__empty button') as HTMLButtonElement;
+ expect(button.textContent).toContain('Compare with baseline');
+ button.click();
+ expect(compareAsked).toBe(1);
+ });
+
+ it('still offers a rerun on a run that is not a variant', async () => {
+ listBiasImpactReports.mockReturnValue(of([]));
+ fixture.componentRef.setInput('executionId', 'baseline-1');
+ fixture.componentRef.setInput('annotatedNodeCount', 2);
+ fixture.detectChanges();
+ await fixture.whenStable();
fixture.detectChanges();
- expect(fixture.componentInstance.detailError()).toBe('Report not found or not accessible.');
-
- getBiasImpactReport.mockReturnValue(throwError(() => ({ status: 403 })));
- fixture.componentInstance.openDetail('someone-elses-report');
- fixture.detectChanges();
-
- expect(fixture.componentInstance.detailError()).toBe('Report not found or not accessible.');
+ const text = fixture.nativeElement.textContent;
+ expect(text).toContain('Run a biased rerun');
+ expect(text).not.toContain('This run is the variant');
});
});
diff --git a/src/app/shared/bias-impact-report-list/bias-impact-report-list.ts b/src/app/shared/bias-impact-report-list/bias-impact-report-list.ts
index 1ad6de5..45ed865 100644
--- a/src/app/shared/bias-impact-report-list/bias-impact-report-list.ts
+++ b/src/app/shared/bias-impact-report-list/bias-impact-report-list.ts
@@ -1,27 +1,23 @@
import { CommonModule } from '@angular/common';
-import { ChangeDetectionStrategy, Component, computed, effect, inject, input, output, signal } from '@angular/core';
+import { ChangeDetectionStrategy, Component, effect, inject, input, output, signal } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { BiasImpactReport } from '@models/bias-impact';
import { TaskExecutionsService } from '@services/task-executions/task-executions';
-import { BiasComparisonViewStateService } from '@services/bias/bias-comparison-view-state';
import { BiasReportsRevisionService } from '@services/bias/bias-reports-revision';
-import { BiasReportJudgeService } from '@services/bias/bias-report-judge';
-import { extractBiasErrorMessage } from '@services/bias/bias-error.util';
-import { BiasImpactReportViewerComponent } from '@shared/bias-impact-report-viewer/bias-impact-report-viewer';
+import { BiasReportDialogService } from '@services/dialogs/bias-report-dialog';
@Component({
selector: 'app-bias-impact-report-list',
standalone: true,
- imports: [CommonModule, MatButtonModule, BiasImpactReportViewerComponent],
+ imports: [CommonModule, MatButtonModule],
templateUrl: './bias-impact-report-list.html',
styleUrl: './bias-impact-report-list.css',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class BiasImpactReportListComponent {
private readonly executions = inject(TaskExecutionsService);
- private readonly comparisonViewState = inject(BiasComparisonViewStateService);
private readonly reportsRevision = inject(BiasReportsRevisionService);
- private readonly reportJudge = inject(BiasReportJudgeService);
+ private readonly reportDialog = inject(BiasReportDialogService);
private lastExecutionId: string | null = null;
private lastReloadToken = 0;
@@ -30,24 +26,25 @@ export class BiasImpactReportListComponent {
readonly annotatedNodeCount = input(0);
/** Why an experiment cannot be started, or null when it can; supplied by the host. */
readonly blockedReason = input(null);
+ /**
+ * True when the run on screen is itself a bias variant with a baseline to compare against.
+ *
+ *
Without this the empty tab offered "Run a biased rerun" on a run that already is one, which
+ * reads as an instruction to make a variant of a variant. What such a run needs is the comparison
+ * with the run it repeats - which is also the thing that produces the report this tab is waiting
+ * for.
+ */
+ readonly comparableVariant = input(false);
/** The user wants to start one. The host owns the dialog, so it decides what "start" means. */
readonly startExperimentRequested = output();
+ /** The user wants this variant compared with its baseline. The host owns that dialog too. */
+ readonly compareRequested = output();
readonly reports = signal([]);
readonly loading = signal(false);
readonly listError = signal(null);
- readonly selectedReport = signal(null);
- readonly detailLoading = signal(false);
- readonly detailError = signal(null);
- readonly judging = signal(false);
- readonly judgeError = signal(null);
-
- readonly detailMode = computed(() =>
- this.selectedReport() !== null || this.detailLoading() || this.detailError() !== null
- );
-
constructor() {
effect(() => {
const executionId = this.executionId();
@@ -57,9 +54,6 @@ export class BiasImpactReportListComponent {
if (!executionChanged && !reloadRequested) return;
this.lastExecutionId = executionId;
this.lastReloadToken = reloadToken;
- // A different execution starts from a clean slate; a reload of the same one keeps whatever
- // report the user was reading open.
- if (executionChanged) this.closeDetail();
this.loadReports(executionId);
});
}
@@ -68,50 +62,16 @@ export class BiasImpactReportListComponent {
this.loadReports(this.executionId());
}
+ /**
+ * Reads a report in a window of its own.
+ *
+ *
It used to open in place, inside the run's side panel: the report is a two-column document
+ * and the panel is a few words wide, so the two sides collapsed into a ribbon and the heading
+ * wrapped one word per line. The dialog host owns the loading, the LLM assessment and the canvas
+ * highlight, exactly as it does for a comparison.
+ */
openDetail(reportId: string) {
- this.selectedReport.set(null);
- this.detailError.set(null);
- this.detailLoading.set(true);
- this.executions.getBiasImpactReport(reportId).subscribe({
- next: (report) => {
- this.detailLoading.set(false);
- this.selectedReport.set(report);
- },
- error: () => {
- this.detailLoading.set(false);
- this.detailError.set('Report not found or not accessible.');
- }
- });
- }
-
- closeDetail() {
- this.selectedReport.set(null);
- this.detailLoading.set(false);
- this.detailError.set(null);
- this.judgeError.set(null);
- }
-
- /** The assessment is stored on the report, so the open detail is replaced with the judged one. */
- async evaluateWithLlm() {
- const report = this.selectedReport();
- if (!report || this.judging()) return;
-
- this.judging.set(true);
- this.judgeError.set(null);
- try {
- const judged = await this.reportJudge.assess(report.id);
- if (judged) this.selectedReport.set(judged);
- } catch (error) {
- this.judgeError.set(extractBiasErrorMessage(error, 'Unable to evaluate this comparison with an LLM.'));
- } finally {
- this.judging.set(false);
- }
- }
-
- highlightOnCanvas() {
- const report = this.selectedReport();
- if (!report) return;
- this.comparisonViewState.show({ report });
+ this.reportDialog.open({ reportId });
}
annotationCount(report: BiasImpactReport): number {
diff --git a/src/app/shared/bias-report-dialog/bias-report-dialog.css b/src/app/shared/bias-report-dialog/bias-report-dialog.css
new file mode 100644
index 0000000..66c1a3b
--- /dev/null
+++ b/src/app/shared/bias-report-dialog/bias-report-dialog.css
@@ -0,0 +1,3 @@
+.bias-report-dialog__progress { background: #eff6ff; border-left: 3px solid #2563eb; color: #1e3a8a; margin: 0; padding: .6rem .7rem; }
+.bias-report-dialog__error { background: #fff1f2; border-left: 3px solid #e11d48; color: #9f1239; margin: 0; padding: .6rem .7rem; }
+.bias-report-dialog__error-row { align-items: center; display: flex; gap: .6rem; justify-content: space-between; }
diff --git a/src/app/shared/bias-report-dialog/bias-report-dialog.html b/src/app/shared/bias-report-dialog/bias-report-dialog.html
new file mode 100644
index 0000000..41be28c
--- /dev/null
+++ b/src/app/shared/bias-report-dialog/bias-report-dialog.html
@@ -0,0 +1,27 @@
+@if (state()) {
+
+ @if (loading()) {
+
Loading report…
+ }
+ @if (inlineError(); as error) {
+
+
{{ error }}
+ Retry
+
+ }
+ @if (report(); as loadedReport) {
+
+ }
+
+}
diff --git a/src/app/shared/bias-report-dialog/bias-report-dialog.spec.ts b/src/app/shared/bias-report-dialog/bias-report-dialog.spec.ts
new file mode 100644
index 0000000..6249001
--- /dev/null
+++ b/src/app/shared/bias-report-dialog/bias-report-dialog.spec.ts
@@ -0,0 +1,187 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { of, throwError } from 'rxjs';
+import { vi } from 'vitest';
+import { BiasImpactReport } from '@models/bias-impact';
+import { BiasComparisonViewStateService } from '@services/bias/bias-comparison-view-state';
+import { BiasReportJudgeService } from '@services/bias/bias-report-judge';
+import { BiasReportDialogService } from '@services/dialogs/bias-report-dialog';
+import { TaskExecutionsService } from '@services/task-executions/task-executions';
+import { BiasReportDialogHostComponent } from './bias-report-dialog';
+
+function makeReport(overrides: Partial = {}): BiasImpactReport {
+ return {
+ id: 'report-1',
+ experimentId: 'experiment-1',
+ kind: 'FULL_FLOW',
+ interventionDirection: 'BIAS',
+ baselineExecutionId: 'baseline-1',
+ biasedExecutionId: 'variant-1',
+ nodeId: null,
+ annotationIds: ['annotation-1'],
+ repetitions: 1,
+ createdAt: '2026-09-08T11:02:32.000Z',
+ rawOutputsIncluded: true,
+ immediateImpact: {
+ outputChanged: true,
+ maximumTextDifference: 0.9,
+ changeRate: 1,
+ baselineOutput: {},
+ biasedOutputs: [],
+ values: [],
+ iterations: []
+ },
+ downstreamImpact: [],
+ routingChanges: [],
+ mockedSideEffects: [],
+ summary: '3 of 3 evaluated subjects changed.',
+ warnings: [],
+ ...overrides
+ };
+}
+
+describe('BiasReportDialogHostComponent', () => {
+ let fixture: ComponentFixture;
+ let getBiasImpactReport: ReturnType;
+ let assess: ReturnType;
+ let show: ReturnType;
+ let dialog: BiasReportDialogService;
+
+ beforeEach(async () => {
+ getBiasImpactReport = vi.fn().mockReturnValue(of(makeReport()));
+ assess = vi.fn();
+ show = vi.fn();
+
+ await TestBed.configureTestingModule({
+ imports: [BiasReportDialogHostComponent],
+ providers: [
+ { provide: TaskExecutionsService, useValue: { getBiasImpactReport } },
+ { provide: BiasReportJudgeService, useValue: { assess } },
+ { provide: BiasComparisonViewStateService, useValue: { show } }
+ ]
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(BiasReportDialogHostComponent);
+ dialog = TestBed.inject(BiasReportDialogService);
+ });
+
+ it('shows nothing until a report is asked for', () => {
+ fixture.detectChanges();
+
+ expect(fixture.nativeElement.querySelector('app-modal-shell')).toBeNull();
+ expect(getBiasImpactReport).not.toHaveBeenCalled();
+ });
+
+ it('loads the report it was opened on and renders it at full width', () => {
+ dialog.open({ reportId: 'report-1' });
+ fixture.detectChanges();
+
+ expect(getBiasImpactReport).toHaveBeenCalledWith('report-1');
+ expect(fixture.componentInstance.report()?.id).toBe('report-1');
+ expect(fixture.nativeElement.textContent).toContain('3 of 3 evaluated subjects changed.');
+ });
+
+ it('offers a retry on a report that could not be loaded', () => {
+ getBiasImpactReport.mockReturnValue(throwError(() => ({ status: 404 })));
+ dialog.open({ reportId: 'missing' });
+ fixture.detectChanges();
+
+ expect(fixture.componentInstance.inlineError()).toContain('not found');
+
+ getBiasImpactReport.mockReturnValue(of(makeReport({ id: 'missing' })));
+ fixture.componentInstance.retry();
+ fixture.detectChanges();
+
+ expect(fixture.componentInstance.inlineError()).toBeNull();
+ expect(fixture.componentInstance.report()?.id).toBe('missing');
+ });
+
+ it('replaces the report on screen with the assessed one', async () => {
+ const judged = makeReport({ summary: 'assessed' });
+ assess.mockResolvedValue(judged);
+ dialog.open({ reportId: 'report-1' });
+ fixture.detectChanges();
+
+ await fixture.componentInstance.evaluateWithLlm();
+
+ expect(assess).toHaveBeenCalledWith('report-1');
+ expect(fixture.componentInstance.report()).toBe(judged);
+ expect(fixture.componentInstance.judging()).toBe(false);
+ });
+
+ it('keeps the report readable when the assessment fails', async () => {
+ assess.mockRejectedValue(new Error('the judge provider is unreachable'));
+ dialog.open({ reportId: 'report-1' });
+ fixture.detectChanges();
+
+ await fixture.componentInstance.evaluateWithLlm();
+
+ expect(fixture.componentInstance.judgeError()).toContain('unreachable');
+ expect(fixture.componentInstance.report()?.id).toBe('report-1');
+ });
+
+ it('closes itself when the report is sent to the canvas, which is behind it', () => {
+ dialog.open({ reportId: 'report-1' });
+ fixture.detectChanges();
+
+ fixture.componentInstance.highlightOnCanvas();
+
+ expect(show).toHaveBeenCalledWith({ report: expect.objectContaining({ id: 'report-1' }) });
+ expect(dialog.state()).toBeNull();
+ });
+
+ it('drops a verdict that arrives after the dialog was closed, and does not stay stuck evaluating', async () => {
+ let resolveAssessment: (report: BiasImpactReport) => void = () => undefined;
+ assess.mockReturnValue(new Promise((resolve) => { resolveAssessment = resolve; }));
+ dialog.open({ reportId: 'report-1' });
+ fixture.detectChanges();
+
+ const running = fixture.componentInstance.evaluateWithLlm();
+ expect(fixture.componentInstance.judging()).toBe(true);
+
+ // The job keeps running on the server and stores its verdicts on the report; the dialog that
+ // asked for it is simply no longer there.
+ fixture.componentInstance.close();
+ fixture.detectChanges();
+ resolveAssessment(makeReport({ summary: 'assessed late' }));
+ await running;
+
+ expect(fixture.componentInstance.judging()).toBe(false);
+ expect(fixture.componentInstance.report()).toBeNull();
+ });
+
+ it('does not let a late verdict land on the next report opened', async () => {
+ let resolveAssessment: (report: BiasImpactReport) => void = () => undefined;
+ assess.mockReturnValue(new Promise((resolve) => { resolveAssessment = resolve; }));
+ dialog.open({ reportId: 'report-1' });
+ fixture.detectChanges();
+ const running = fixture.componentInstance.evaluateWithLlm();
+
+ getBiasImpactReport.mockReturnValue(of(makeReport({ id: 'report-2', summary: 'another report' })));
+ dialog.open({ reportId: 'report-2' });
+ fixture.detectChanges();
+ resolveAssessment(makeReport({ id: 'report-1', summary: 'assessed late' }));
+ await running;
+
+ expect(fixture.componentInstance.report()?.id).toBe('report-2');
+ expect(fixture.componentInstance.judging()).toBe(false);
+ });
+
+ it('reports an assessment stored earlier when the report is opened again', () => {
+ getBiasImpactReport.mockReturnValue(of(makeReport({
+ judgements: [{
+ judge: { provider: 'InternalOllama', model: 'gemma:7b' },
+ judgedAt: '2026-09-08T11:30:00.000Z',
+ impact: 'SUBSTANTIVE',
+ attribution: 'INJECTION',
+ narrative: 'Two subjects were assessed differently.',
+ judgedPairs: 2,
+ skippedPairs: 1,
+ errors: []
+ }]
+ })));
+ dialog.open({ reportId: 'report-1' });
+ fixture.detectChanges();
+
+ expect(fixture.nativeElement.textContent).toContain('Two subjects were assessed differently.');
+ });
+});
diff --git a/src/app/shared/bias-report-dialog/bias-report-dialog.ts b/src/app/shared/bias-report-dialog/bias-report-dialog.ts
new file mode 100644
index 0000000..c055173
--- /dev/null
+++ b/src/app/shared/bias-report-dialog/bias-report-dialog.ts
@@ -0,0 +1,107 @@
+import { ChangeDetectionStrategy, Component, effect, inject, signal } from '@angular/core';
+import { MatButtonModule } from '@angular/material/button';
+import { BiasImpactReportViewerComponent } from '@shared/bias-impact-report-viewer/bias-impact-report-viewer';
+import { ModalShellComponent } from '@shared/modal-shell/modal-shell';
+import { BiasReportDialogService } from '@services/dialogs/bias-report-dialog';
+import { BiasComparisonViewStateService } from '@services/bias/bias-comparison-view-state';
+import { BiasReportJudgeService } from '@services/bias/bias-report-judge';
+import { extractBiasErrorMessage } from '@services/bias/bias-error.util';
+import { TaskExecutionsService } from '@services/task-executions/task-executions';
+import { BiasImpactReport } from '@models/bias-impact';
+
+/** Reads one persisted report in the width it needs, and offers the same actions the compare does. */
+@Component({
+ selector: 'app-bias-report-dialog-host',
+ standalone: true,
+ imports: [MatButtonModule, BiasImpactReportViewerComponent, ModalShellComponent],
+ templateUrl: './bias-report-dialog.html',
+ styleUrl: './bias-report-dialog.css',
+ changeDetection: ChangeDetectionStrategy.OnPush
+})
+export class BiasReportDialogHostComponent {
+ private readonly dialog = inject(BiasReportDialogService);
+ private readonly executions = inject(TaskExecutionsService);
+ private readonly comparisonViewState = inject(BiasComparisonViewStateService);
+ private readonly reportJudge = inject(BiasReportJudgeService);
+
+ readonly state = this.dialog.state;
+ readonly loading = signal(false);
+ readonly inlineError = signal(null);
+ readonly report = signal(null);
+ readonly judging = signal(false);
+ readonly judgeError = signal(null);
+
+ /**
+ * Which assessment is the one still worth showing.
+ *
+ *
Closing the dialog does not stop the job - it runs on the server and stores its verdicts on
+ * the report, so nothing is lost and reopening shows them. What must not happen is a late answer
+ * landing on a dialog that has since been closed, or reopened on a different report.
+ */
+ private judgeToken = 0;
+
+ constructor() {
+ effect(() => {
+ const state = this.state();
+ this.report.set(null);
+ this.inlineError.set(null);
+ this.judgeError.set(null);
+ this.loading.set(false);
+ this.judging.set(false);
+ this.judgeToken++;
+ if (!state) return;
+
+ this.load(state.reportId);
+ });
+ }
+
+ retry() {
+ const state = this.state();
+ if (state) this.load(state.reportId);
+ }
+
+ close() {
+ this.dialog.close();
+ }
+
+ highlightOnCanvas() {
+ const report = this.report();
+ if (!report) return;
+ this.comparisonViewState.show({ report });
+ this.close();
+ }
+
+ async evaluateWithLlm() {
+ const report = this.report();
+ if (!report || this.judging()) return;
+
+ const token = ++this.judgeToken;
+ this.judging.set(true);
+ this.judgeError.set(null);
+ try {
+ const judged = await this.reportJudge.assess(report.id);
+ if (token !== this.judgeToken) return;
+ if (judged) this.report.set(judged);
+ } catch (error) {
+ if (token !== this.judgeToken) return;
+ this.judgeError.set(extractBiasErrorMessage(error, 'Unable to evaluate this comparison with an LLM.'));
+ } finally {
+ if (token === this.judgeToken) this.judging.set(false);
+ }
+ }
+
+ private load(reportId: string) {
+ this.inlineError.set(null);
+ this.loading.set(true);
+ this.executions.getBiasImpactReport(reportId).subscribe({
+ next: (report) => {
+ this.loading.set(false);
+ this.report.set(report);
+ },
+ error: (error) => {
+ this.loading.set(false);
+ this.inlineError.set(extractBiasErrorMessage(error, 'Report not found or not accessible.'));
+ }
+ });
+ }
+}