Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,26 @@ describe('SubmissionReviewPage', () => {
expect(text(fixture)).toContain('no marketplace listing to review');
});

it('reports a refused decision beside the buttons, not at the top of the page', async () => {
// The decision bar is sticky and the read above it is not, so on a submission with
// real instructions a message rendered at the top is off-screen at the moment the
// reviewer presses the button. That gap is what the global toast used to cover.
mockService.review.mockRejectedValue({ error: { detail: 'Visibility is now Private.' } });
const fixture = await render();
button(fixture, 'Approve').click();
await fixture.whenStable();
fixture.detectChanges();

const el = fixture.nativeElement as HTMLElement;
const alert = [...el.querySelectorAll('[role="alert"]')].find((a) =>
a.textContent?.includes('Visibility is now Private.'),
);
expect(alert).toBeTruthy();
// In the same sticky container as the decision buttons.
const bar = button(fixture, 'Approve').closest('.sticky');
expect(bar?.contains(alert!)).toBe(true);
});

it('keeps the reviewer on the page when a decision fails', async () => {
mockService.review.mockRejectedValue({ error: { detail: 'Visibility is now Private.' } });
const fixture = await render();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@ import {
Review queue
</a>

<!-- Load failures only. A *decision* failure renders down by the decision bar
instead — see the note there. -->
@if (error(); as message) {
<div
role="alert"
Expand Down Expand Up @@ -277,8 +279,25 @@ import {
<!-- Decision. Pinned at the foot so the reviewer acts where they finished
reading, rather than scrolling back to the header. -->
<div
class="sticky bottom-0 mt-8 flex flex-col gap-3 border-t border-gray-200 bg-white/95 py-4 backdrop-blur sm:flex-row sm:items-center sm:justify-between dark:border-gray-700 dark:bg-gray-900/95"
class="sticky bottom-0 mt-8 flex flex-col gap-3 border-t border-gray-200 bg-white/95 py-4 backdrop-blur dark:border-gray-700 dark:bg-gray-900/95"
>
<!-- ⚠️ A refused decision reports itself HERE, not at the top of the page.
The decision bar is sticky and the read above it is not, so on a submission
with real instructions the reviewer presses Approve at the bottom of a
scrolled page and a message rendered at the top is simply off-screen. That
is the gap the global toast used to paper over; removing the toast without
moving the message would have made the failure silent rather than
duplicated. -->
@if (decisionError(); as message) {
<p
role="alert"
class="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-2 text-sm/6 text-rose-800 dark:border-rose-900 dark:bg-rose-900/20 dark:text-rose-300"
>
{{ message }}
</p>
}

<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p class="text-sm/6 text-gray-500 dark:text-gray-400">
@if (isWithdrawal()) {
The author asked to pull this listing. Decide it from the queue.
Expand Down Expand Up @@ -319,6 +338,7 @@ import {
</button>
</div>
}
</div>
</div>
}
</div>
Expand All @@ -334,6 +354,15 @@ export class SubmissionReviewPage implements OnInit {
readonly submission = signal<AdminSubmissionReview | null>(null);
readonly loading = signal(true);
readonly error = signal<string | null>(null);
/**
* A refused decision, kept apart from ``error`` so it can render beside the buttons.
*
* Two error regions rather than one because they are read at different moments and from
* different scroll positions: a load failure is the first thing on an otherwise empty
* page, while a decision failure answers a button in a sticky bar the reviewer may have
* scrolled a long way to reach.
*/
readonly decisionError = signal<string | null>(null);
readonly busy = signal(false);

private readonly agentId = signal('');
Expand Down Expand Up @@ -399,6 +428,7 @@ export class SubmissionReviewPage implements OnInit {
}
this.loading.set(true);
this.error.set(null);
this.decisionError.set(null);
try {
this.submission.set(await this.service.loadSubmission(id));
} catch (err) {
Expand Down Expand Up @@ -459,12 +489,12 @@ export class SubmissionReviewPage implements OnInit {
note?: string;
}): Promise<void> {
this.busy.set(true);
this.error.set(null);
this.decisionError.set(null);
try {
await this.service.review(this.agentId(), request);
await this.router.navigate(['/admin/marketplace/review']);
} catch (err) {
this.error.set(this.detail(err) ?? 'Failed to record the decision.');
this.decisionError.set(this.detail(err) ?? 'Failed to record the decision.');
this.busy.set(false);
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import {
HttpTestingController,
provideHttpClientTesting,
TestRequest,
} from '@angular/common/http/testing';

import { AdminMarketplaceService } from './admin-marketplace.service';
import { ConfigService } from '../../../services/config.service';
import { SUPPRESS_ERROR_TOAST } from '../../../auth/error.interceptor';

/**
* The review flow opts out of the global error toast.
*
* Every failure on these calls already renders inline, next to the control that caused it.
* Without the opt-out an admin whose Approve is refused reads the backend's message twice —
* once where they are looking, once in a corner toast that is further away and disappears
* on its own.
*
* Asserted per call rather than as "the service suppresses toasts", because the rule is
* *not* service-wide: silencing a call whose caller renders nothing would turn a visible
* failure into a silent one. A new call has to earn the opt-out by having inline UI, and
* the list below is the record of which ones have.
*/
describe('AdminMarketplaceService — error surfacing', () => {
let service: AdminMarketplaceService;
let http: HttpTestingController;

beforeEach(() => {
TestBed.resetTestingModule();
TestBed.configureTestingModule({
providers: [
provideHttpClient(),
provideHttpClientTesting(),
AdminMarketplaceService,
{ provide: ConfigService, useValue: { appApiUrl: () => 'https://api.test' } },
],
});
service = TestBed.inject(AdminMarketplaceService);
http = TestBed.inject(HttpTestingController);
});

afterEach(() => {
http.verify();
TestBed.resetTestingModule();
});

function suppressed(req: TestRequest): boolean {
return req.request.context.get(SUPPRESS_ERROR_TOAST);
}

it('suppresses the toast when reading a submission', () => {
void service.loadSubmission('ast-001').catch(() => undefined);
const req = http.expectOne('https://api.test/admin/agents/ast-001/submission');
expect(suppressed(req)).toBe(true);
req.flush({});
});

it('suppresses the toast when reading the diff', () => {
// The backend distinguishes "predates snapshots" from a transport failure, and the
// diff component renders that distinction. A toast would flatten it.
void service.loadDiff('ast-001').catch(() => undefined);
const req = http.expectOne('https://api.test/admin/agents/ast-001/diff');
expect(suppressed(req)).toBe(true);
req.flush({});
});

it('suppresses the toast when recording a decision', () => {
// The case that prompted this: Approve refused on a private agent showed the same
// message inline and in a toast.
void service.review('ast-001', { decision: 'approve' }).catch(() => undefined);
const req = http.expectOne('https://api.test/admin/agents/ast-001/review');
expect(suppressed(req)).toBe(true);
req.flush({});
});

it('suppresses the toast when deciding a withdrawal', () => {
void service
.decideWithdrawal('ast-001', { decision: 'grant' })
.catch(() => undefined);
const req = http.expectOne('https://api.test/admin/agents/ast-001/withdrawal');
expect(suppressed(req)).toBe(true);
req.flush({});
});

it('leaves the toast on for calls with no inline error surface', () => {
// The guard against a well-meaning sweep that suppresses everything. A takedown
// failure has no inline region on the Listings page, so the toast is its only
// surface — silencing it would turn a visible failure into a silent one.
void service.takedown('ast-001', 'Because.').catch(() => undefined);
const req = http.expectOne('https://api.test/admin/agents/ast-001/takedown');
expect(suppressed(req)).toBe(false);
req.flush({});
});
});
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { Injectable, inject, signal, computed } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { HttpClient, HttpContext } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import { ConfigService } from '../../../services/config.service';
import { SUPPRESS_ERROR_TOAST } from '../../../auth/error.interceptor';
import {
AgentVersionDiff,
AgentVersionsResponse,
Expand Down Expand Up @@ -48,6 +49,24 @@ export class AdminMarketplaceService {

private readonly baseUrl = computed(() => `${this.config.appApiUrl()}/admin/agents`);

/**
* Opts a request out of the global error toast.
*
* Applied to the **review flow** only, where every failure already renders inline next
* to the control that caused it. Without this an admin whose Approve is refused gets the
* backend's message twice — once in the page's own error region, where they are looking,
* and once in a toast in the corner — and the toast is the worse copy of the two: it is
* further from the button they pressed and it disappears on its own.
*
* ⚠️ Deliberately **not** applied service-wide. The toast is the only error surface some
* of these calls have, and silencing one whose caller renders nothing would turn a
* visible failure into a silent one. Add it per call, only after checking that the caller
* actually shows the error.
*/
private inlineErrors(): { context: HttpContext } {
return { context: new HttpContext().set(SUPPRESS_ERROR_TOAST, true) };
}

private _loading = signal(false);
private _error = signal<string | null>(null);
private _pendingCount = signal(0);
Expand Down Expand Up @@ -76,7 +95,7 @@ export class AdminMarketplaceService {
*/
async loadDiff(agentId: string): Promise<AgentVersionDiff> {
return firstValueFrom(
this.http.get<AgentVersionDiff>(`${this.baseUrl()}/${agentId}/diff`),
this.http.get<AgentVersionDiff>(`${this.baseUrl()}/${agentId}/diff`, this.inlineErrors()),
);
}

Expand All @@ -91,6 +110,7 @@ export class AdminMarketplaceService {
return firstValueFrom(
this.http.get<AdminSubmissionReview>(
`${this.baseUrl()}/${encodeURIComponent(agentId)}/submission`,
this.inlineErrors(),
),
);
}
Expand Down Expand Up @@ -118,7 +138,9 @@ export class AdminMarketplaceService {

/** Approve a submission, return it with a reason, or decline it for the store. */
async review(agentId: string, request: ReviewListingRequest): Promise<void> {
await firstValueFrom(this.http.post(`${this.baseUrl()}/${agentId}/review`, request));
await firstValueFrom(
this.http.post(`${this.baseUrl()}/${agentId}/review`, request, this.inlineErrors()),
);
}

/**
Expand All @@ -130,7 +152,9 @@ export class AdminMarketplaceService {
* author while actually re-publishing over their request.
*/
async decideWithdrawal(agentId: string, request: WithdrawalDecisionRequest): Promise<void> {
await firstValueFrom(this.http.post(`${this.baseUrl()}/${agentId}/withdrawal`, request));
await firstValueFrom(
this.http.post(`${this.baseUrl()}/${agentId}/withdrawal`, request, this.inlineErrors()),
);
}

/** Every snapshot this agent has, newest first — the rollback picker's source (§8). */
Expand Down