From b54d2d69313c9deb3d7d143aa92033432039c6f5 Mon Sep 17 00:00:00 2001 From: Phil Merrell Date: Sun, 6 Sep 2026 08:57:22 -0600 Subject: [PATCH] =?UTF-8?q?feat(announcements):=20render=20the=20modal=20s?= =?UTF-8?q?urface=20with=20the=20=C2=A7D8=20gate=20(PR-5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The interruptive surface, and the last one. `components/announcement-modal` renders the single modal the server picked; `AnnouncementModalService` decides whether interrupting is acceptable at all. The gate is the substance of this PR. The modal opens on route settle and only when there is no active stream, no pending tool-approval / OAuth-consent / MCP-App-consent prompt, no draft in a focused composer, and the route is not a minimal-chrome page. The consent checks are not redundant with the stream check: per mid-turn-steering (#934) `isLoading()` is FALSE while a turn is paused on an interrupt, so a stream-only gate would throw a dialog over an OAuth consent prompt and steal its focus. The prompt services are asked directly. Every gate input is read `untracked`. Read reactively, the effect would re-run the instant a stream ended or a consent was answered and fire a modal seconds after the user finished a thought — which §D8 forbids in as many words: a failed gate leaves the announcement eligible for the next clean load, it does not queue it. So the effect tracks only the announcement and a navigation counter, and snapshots the rest. `requiresAck` makes the confirm button the only exit: no ✕, `disableClose` on the overlay, and the in-component Escape and backdrop handlers return without writing an ack. Belt and braces on purpose — the CDK option and the guards fail independently. The button label follows the ack it writes, "I understand" → `acknowledged` and "Got it" → `dismissed`, so it cannot misdescribe the record. Started via `provideAppInitializer` rather than mounted in app.html: a CDK overlay is not a layout element, and nothing else would ever inject the service. Same shape as ThemeService. It also means this PR does not touch the app shell, so it does not conflict with PR-4. Body uses `.message-block`, not `prose` — the typography plugin is not installed, so the older user-menu-link-modal's classes are inert and strip list markers. Sanitization stays on (§D10): `admin.announcements` is delegable, so this body may be authored by a non-admin and reaches every user. Verified end to end against dev data with a local app-api. With `requiresAck`: opens on load, no ✕, Escape and backdrop clicks leave it open, and the button writes `acknowledged` at rank 3 — upgrading the `seen` row in place rather than duplicating it. Without it: ✕ and Escape both write `dismissed` at rank 2. Afterwards the server returns `modal: null` while both panel entries survive (§D1/§D2). Light, dark, and 375px with no horizontal overflow. Co-Authored-By: Claude Opus 5 --- frontend/ai.client/src/app/app.config.ts | 7 + .../announcement-modal.component.spec.ts | 186 +++++++++++++ .../announcement-modal.component.ts | 209 +++++++++++++++ .../announcement-modal.service.spec.ts | 249 ++++++++++++++++++ .../announcement-modal.service.ts | 150 +++++++++++ .../announcements/announcement.model.ts | 4 +- .../announcements/announcements.service.ts | 2 +- 7 files changed, 804 insertions(+), 3 deletions(-) create mode 100644 frontend/ai.client/src/app/components/announcement-modal/announcement-modal.component.spec.ts create mode 100644 frontend/ai.client/src/app/components/announcement-modal/announcement-modal.component.ts create mode 100644 frontend/ai.client/src/app/services/announcements/announcement-modal.service.spec.ts create mode 100644 frontend/ai.client/src/app/services/announcements/announcement-modal.service.ts diff --git a/frontend/ai.client/src/app/app.config.ts b/frontend/ai.client/src/app/app.config.ts index 66b6db323..15753e14f 100644 --- a/frontend/ai.client/src/app/app.config.ts +++ b/frontend/ai.client/src/app/app.config.ts @@ -10,6 +10,7 @@ import { MARKED_OPTIONS, MarkedOptions, MarkedRenderer, provideMarkdown } from ' import { SessionService } from './auth/session.service'; import { ThemeService } from './components/topnav/components/theme-toggle/theme.service'; import { provideBuiltInToolRenderers } from './session/components/message-list/components/tool-use/built-in-renderers'; +import { AnnouncementModalService } from './services/announcements/announcement-modal.service'; function markedOptionsFactory(): MarkedOptions { const renderer = new MarkedRenderer(); @@ -62,5 +63,11 @@ export const appConfig: ApplicationConfig = { // plus the migrated proof-point renderers) into the renderer registry // before the first message renders. provideBuiltInToolRenderers(), + + // AnnouncementModalService owns the §D8 turn-safety gate and opens the + // announcement modal itself. It is started here rather than mounted in + // app.html because a CDK overlay is not a layout element — and because + // nothing else would ever inject it. Same pattern as ThemeService above. + provideAppInitializer(() => { inject(AnnouncementModalService); }), ] }; diff --git a/frontend/ai.client/src/app/components/announcement-modal/announcement-modal.component.spec.ts b/frontend/ai.client/src/app/components/announcement-modal/announcement-modal.component.spec.ts new file mode 100644 index 000000000..00db445bc --- /dev/null +++ b/frontend/ai.client/src/app/components/announcement-modal/announcement-modal.component.spec.ts @@ -0,0 +1,186 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { DIALOG_DATA, DialogRef } from '@angular/cdk/dialog'; +import { provideMarkdown } from 'ngx-markdown'; +import { AnnouncementsService } from '../../services/announcements/announcements.service'; +import { Announcement } from '../../services/announcements/announcement.model'; +import { + AnnouncementModalComponent, + AnnouncementModalData, +} from './announcement-modal.component'; + +function makeAnnouncement(overrides: Partial = {}): Announcement { + return { + announcement_id: 'a1', + title: 'Acceptable use policy update', + body_markdown: '## Policy\n\n- one\n- two', + summary: null, + surfaces: ['panel', 'modal'], + severity: 'info', + publish_at: '2026-01-01T00:00:00Z', + expires_at: '2026-02-01T00:00:00Z', + requires_ack: false, + cta_label: null, + cta_url: null, + revision: 1, + is_unread: true, + is_updated: false, + ...overrides, + }; +} + +describe('AnnouncementModalComponent', () => { + let ack: ReturnType; + let close: ReturnType; + + function setup(announcement: Announcement) { + TestBed.resetTestingModule(); + ack = vi.fn(async () => true); + close = vi.fn(); + TestBed.configureTestingModule({ + providers: [ + // The template renders , so the real MarkdownService is needed. + provideMarkdown(), + { provide: AnnouncementsService, useValue: { ack } }, + { provide: DialogRef, useValue: { close, closed: { subscribe: vi.fn() } } }, + { + provide: DIALOG_DATA, + useValue: { announcement } satisfies AnnouncementModalData, + }, + ], + }); + const fixture = TestBed.createComponent(AnnouncementModalComponent); + fixture.detectChanges(); + return fixture; + } + + afterEach(() => TestBed.resetTestingModule()); + + function el(fixture: ReturnType) { + return fixture.nativeElement as HTMLElement; + } + + function confirmButton(fixture: ReturnType) { + const buttons = [...el(fixture).querySelectorAll('button')]; + return buttons.find(b => /Got it|I understand/.test(b.textContent ?? ''))!; + } + + it('records `seen` as soon as it renders', () => { + setup(makeAnnouncement()); + expect(ack).toHaveBeenCalledWith('a1', 'seen', 'modal'); + }); + + it('renders the title and the markdown body', () => { + const fixture = setup(makeAnnouncement()); + expect(el(fixture).textContent).toContain('Acceptable use policy update'); + const body = el(fixture).querySelector('.message-block'); + expect(body).not.toBeNull(); + // `prose` is inert in this app — the typography plugin is not installed. + expect(el(fixture).querySelector('.prose')).toBeNull(); + }); + + describe('without requiresAck', () => { + it('labels the confirm button "Got it" and records `dismissed`', () => { + const fixture = setup(makeAnnouncement()); + ack.mockClear(); + + const button = confirmButton(fixture); + expect(button.textContent?.trim()).toBe('Got it'); + button.click(); + + expect(ack).toHaveBeenCalledWith('a1', 'dismissed', 'modal'); + expect(close).toHaveBeenCalled(); + }); + + it('offers a ✕, and it records `dismissed` too', () => { + const fixture = setup(makeAnnouncement()); + ack.mockClear(); + + const dismiss = el(fixture).querySelector( + 'button[aria-label="Close announcement"]', + ) as HTMLButtonElement; + expect(dismiss).not.toBeNull(); + dismiss.click(); + + expect(ack).toHaveBeenCalledWith('a1', 'dismissed', 'modal'); + expect(close).toHaveBeenCalled(); + }); + + it('closes on Escape and on a backdrop click', () => { + const fixture = setup(makeAnnouncement()); + const component = fixture.componentInstance as unknown as { + onEscape(): void; + onBackdropDismiss(): void; + }; + + component.onEscape(); + expect(close).toHaveBeenCalledTimes(1); + + component.onBackdropDismiss(); + expect(close).toHaveBeenCalledTimes(2); + }); + }); + + describe('with requiresAck', () => { + it('labels the confirm button "I understand" and records `acknowledged`', () => { + const fixture = setup(makeAnnouncement({ requires_ack: true })); + ack.mockClear(); + + const button = confirmButton(fixture); + expect(button.textContent?.trim()).toBe('I understand'); + button.click(); + + expect(ack).toHaveBeenCalledWith('a1', 'acknowledged', 'modal'); + expect(close).toHaveBeenCalled(); + }); + + it('has no ✕ — the button is the only exit', () => { + const fixture = setup(makeAnnouncement({ requires_ack: true })); + expect( + el(fixture).querySelector('button[aria-label="Close announcement"]'), + ).toBeNull(); + }); + + it('ignores Escape and backdrop clicks, writing no ack', () => { + const fixture = setup(makeAnnouncement({ requires_ack: true })); + ack.mockClear(); + const component = fixture.componentInstance as unknown as { + onEscape(): void; + onBackdropDismiss(): void; + }; + + component.onEscape(); + component.onBackdropDismiss(); + + expect(close).not.toHaveBeenCalled(); + expect(ack).not.toHaveBeenCalled(); + }); + }); + + it('renders the CTA only when both label and url are present', () => { + let fixture = setup(makeAnnouncement({ cta_label: 'Read the policy' })); + expect(el(fixture).querySelector('a')).toBeNull(); + + fixture = setup( + makeAnnouncement({ + cta_label: 'Read the policy', + cta_url: 'https://example.edu/policy', + }), + ); + const link = el(fixture).querySelector('a')!; + expect(link.getAttribute('href')).toBe('https://example.edu/policy'); + expect(link.getAttribute('rel')).toBe('noopener noreferrer'); + }); + + it('is a labelled modal dialog', () => { + const fixture = setup(makeAnnouncement()); + const panel = el(fixture).querySelector('[role="dialog"]')!; + expect(panel.getAttribute('aria-modal')).toBe('true'); + const labelledBy = panel.getAttribute('aria-labelledby')!; + // Attribute selector, not `#id`: the id is a UUID that can start with a + // digit, and jsdom has no `CSS.escape` to fix that up. + expect( + el(fixture).querySelector(`[id="${labelledBy}"]`)?.textContent, + ).toContain('Acceptable use policy update'); + }); +}); diff --git a/frontend/ai.client/src/app/components/announcement-modal/announcement-modal.component.ts b/frontend/ai.client/src/app/components/announcement-modal/announcement-modal.component.ts new file mode 100644 index 000000000..b93ab17c6 --- /dev/null +++ b/frontend/ai.client/src/app/components/announcement-modal/announcement-modal.component.ts @@ -0,0 +1,209 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + inject, +} from '@angular/core'; +import { DIALOG_DATA, DialogRef } from '@angular/cdk/dialog'; +import { MarkdownComponent } from 'ngx-markdown'; +import { NgIcon, provideIcons } from '@ng-icons/core'; +import { heroXMark } from '@ng-icons/heroicons/outline'; +import { DialogDismissDirective } from '../dialog/dialog-dismiss.directive'; +import { AnnouncementsService } from '../../services/announcements/announcements.service'; +import { Announcement } from '../../services/announcements/announcement.model'; + +export interface AnnouncementModalData { + announcement: Announcement; +} + +/** + * The interruptive announcement surface (§D1) — a dialog on next load. + * + * This is the only surface that can demand a real acknowledgement. When the + * announcement carries `requiresAck`, **the confirm button is the only exit**: + * the backdrop-dismiss directive's output is ignored, Escape is swallowed, and + * the dialog is opened with CDK's `disableClose`. There is no ✕ and no + * "Later". That is deliberate and it is also the reason `requiresAck` should + * be rare — see the fatigue note in §11. + * + * Without `requiresAck` it behaves like any other dialog: ✕, Escape, backdrop + * click and "Got it" all converge on the same `dismissed` ack. + * + * **Whether this opens at all is not decided here.** `AnnouncementModalService` + * owns the §D8 turn-safety gate; by the time this component exists, the + * decision to interrupt has already been made. + * + * Markdown renders through `ngx-markdown` **with sanitization on**. Do not add + * `[disableSanitizer]`: `admin.announcements` is a delegable scope, so this + * body may be authored by someone who is not a platform admin and it is + * broadcast to every user (§D10). Body styling uses `.message-block`, the + * app's real markdown stylesheet — the `prose` classes on the older + * `user-menu-link-modal` are inert, because the Tailwind typography plugin is + * not installed. + */ +@Component({ + selector: 'app-announcement-modal', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [DialogDismissDirective, MarkdownComponent, NgIcon], + providers: [provideIcons({ heroXMark })], + host: { + class: 'block', + '(keydown.escape)': 'onEscape()', + }, + template: ` + + +
+ +
+ `, + styles: ` + @reference "../../../styles/theme.css"; + + .dialog-backdrop { + animation: backdrop-fade-in 200ms ease-out; + } + @keyframes backdrop-fade-in { + from { opacity: 0; } + to { opacity: 1; } + } + .dialog-panel { + animation: dialog-fade-in-up 200ms ease-out; + } + @keyframes dialog-fade-in-up { + from { opacity: 0; transform: translateY(1rem) scale(0.98); } + to { opacity: 1; transform: translateY(0) scale(1); } + } + `, +}) +export class AnnouncementModalComponent { + private readonly dialogRef = inject(DialogRef); + private readonly data = inject(DIALOG_DATA); + private readonly announcements = inject(AnnouncementsService); + + protected readonly titleId = `announcement-modal-title-${crypto.randomUUID()}`; + protected readonly announcement = this.data.announcement; + + protected readonly requiresAck = computed( + () => this.announcement.requires_ack, + ); + + /** + * "I understand" reads as a commitment; "Got it" reads as a dismissal. The + * ack that gets recorded differs too, so the label should not lie about + * which one the click writes. + */ + protected readonly confirmLabel = computed(() => + this.requiresAck() ? 'I understand' : 'Got it', + ); + + constructor() { + // Rendering is the `seen` (§D2). It is superseded moments later by the + // `dismissed`/`acknowledged` the exit writes — but a user who reads this + // and then closes the tab has still seen it, and the monotonic rank makes + // the redundant write free. + void this.announcements.ack( + this.announcement.announcement_id, + 'seen', + 'modal', + ); + } + + /** The confirm button — the only exit when `requiresAck`. */ + protected onConfirm(): void { + void this.announcements.ack( + this.announcement.announcement_id, + this.requiresAck() ? 'acknowledged' : 'dismissed', + 'modal', + ); + this.dialogRef.close(); + } + + protected onDismiss(): void { + if (this.requiresAck()) return; + void this.announcements.ack( + this.announcement.announcement_id, + 'dismissed', + 'modal', + ); + this.dialogRef.close(); + } + + /** + * Backdrop click and Escape are the same "not now" gesture, and both are + * inert on a `requiresAck` announcement. CDK's `disableClose` already stops + * Escape from closing the overlay; this guard exists so the ack is not + * written either, and so the behaviour survives someone opening this dialog + * without that option. + */ + protected onBackdropDismiss(): void { + this.onDismiss(); + } + + protected onEscape(): void { + this.onDismiss(); + } +} diff --git a/frontend/ai.client/src/app/services/announcements/announcement-modal.service.spec.ts b/frontend/ai.client/src/app/services/announcements/announcement-modal.service.spec.ts new file mode 100644 index 000000000..0784bf831 --- /dev/null +++ b/frontend/ai.client/src/app/services/announcements/announcement-modal.service.spec.ts @@ -0,0 +1,249 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { signal } from '@angular/core'; +import { DOCUMENT } from '@angular/common'; +import { Dialog } from '@angular/cdk/dialog'; +import { NavigationEnd, Router } from '@angular/router'; +import { Subject } from 'rxjs'; +import { AnnouncementsService } from './announcements.service'; +import { Announcement } from './announcement.model'; +import { AnnouncementModalService } from './announcement-modal.service'; +import { SessionService } from '../../auth/session.service'; +import { MessageMapService } from '../../session/services/session/message-map.service'; +import { ToolApprovalService } from '../tool-approval/tool-approval.service'; +import { OAuthConsentService } from '../oauth-consent/oauth-consent.service'; +import { McpAppConsentService } from '../../session/services/mcp-apps/mcp-app-consent.service'; +import { MINIMAL_CHROME } from '../../shared/utils/route-chrome'; + +function makeAnnouncement(overrides: Partial = {}): Announcement { + return { + announcement_id: 'a1', + title: 'Policy update', + body_markdown: '## Policy', + summary: null, + surfaces: ['panel', 'modal'], + severity: 'info', + publish_at: '2026-01-01T00:00:00Z', + expires_at: '2026-02-01T00:00:00Z', + requires_ack: false, + cta_label: null, + cta_url: null, + revision: 1, + is_unread: true, + is_updated: false, + ...overrides, + }; +} + +describe('AnnouncementModalService (§D8 turn-safety gate)', () => { + let modalItem: ReturnType>; + let isLoadingSession: ReturnType>; + let toolApprovalPending: ReturnType>; + let oauthPending: ReturnType>; + let mcpAppPending: ReturnType>; + let isAuthenticated: ReturnType>; + let routeData: Record; + let events: Subject; + let open: ReturnType; + let activeElement: unknown; + + beforeEach(() => { + TestBed.resetTestingModule(); + modalItem = signal(null); + isLoadingSession = signal(null); + toolApprovalPending = signal(false); + oauthPending = signal(false); + mcpAppPending = signal([]); + isAuthenticated = signal(true); + routeData = {}; + events = new Subject(); + activeElement = null; + open = vi.fn(() => ({ closed: { subscribe: vi.fn() } })); + + // DI-token overrides rather than vi.mock, per house convention. + TestBed.configureTestingModule({ + providers: [ + { provide: AnnouncementsService, useValue: { modalItem, ack: vi.fn() } }, + { provide: Dialog, useValue: { open } }, + { + provide: Router, + useValue: { + events, + routerState: { snapshot: { root: { data: routeData, firstChild: null } } }, + }, + }, + { + provide: DOCUMENT, + useValue: { + get activeElement() { + return activeElement; + }, + }, + }, + { provide: SessionService, useValue: { isAuthenticated } }, + { provide: MessageMapService, useValue: { isLoadingSession } }, + { provide: ToolApprovalService, useValue: { hasPending: toolApprovalPending } }, + { provide: OAuthConsentService, useValue: { hasPending: oauthPending } }, + { provide: McpAppConsentService, useValue: { pending: mcpAppPending } }, + ], + }); + }); + + afterEach(() => TestBed.resetTestingModule()); + + /** Instantiate the service and flush its effect. */ + function start() { + const service = TestBed.inject(AnnouncementModalService); + TestBed.tick(); + return service; + } + + function navigate() { + // A real instance, not a cast: the service filters with `instanceof + // NavigationEnd`, so a plain object is silently dropped and every + // navigation assertion below would pass without testing anything. + events.next(new NavigationEnd(1, '/somewhere', '/somewhere')); + TestBed.tick(); + } + + /** A focused composer textarea holding `value`. */ + function focusComposer(value: string) { + activeElement = { + value, + closest: (selector: string) => (selector === 'app-chat-input' ? {} : null), + }; + } + + it('opens the modal once the feed produces one and the route settles', () => { + start(); + expect(open).not.toHaveBeenCalled(); + + modalItem.set(makeAnnouncement()); + TestBed.tick(); + + expect(open).toHaveBeenCalledTimes(1); + const [, config] = open.mock.calls[0]; + expect(config.data.announcement.announcement_id).toBe('a1'); + }); + + it('passes disableClose only for a requiresAck announcement', () => { + start(); + modalItem.set(makeAnnouncement({ requires_ack: true })); + TestBed.tick(); + + expect(open.mock.calls[0][1].disableClose).toBe(true); + }); + + it('does not open twice for the same announcement', () => { + start(); + modalItem.set(makeAnnouncement()); + TestBed.tick(); + expect(open).toHaveBeenCalledTimes(1); + + navigate(); + navigate(); + expect(open).toHaveBeenCalledTimes(1); + }); + + describe('refuses to interrupt', () => { + it('while a stream is running', () => { + isLoadingSession.set('session-1'); + start(); + modalItem.set(makeAnnouncement()); + TestBed.tick(); + expect(open).not.toHaveBeenCalled(); + }); + + it('while a tool-approval prompt is pending', () => { + toolApprovalPending.set(true); + start(); + modalItem.set(makeAnnouncement()); + TestBed.tick(); + expect(open).not.toHaveBeenCalled(); + }); + + it('while an OAuth consent is pending and the stream reads as idle', () => { + // The #934 shape: `isLoading()` is FALSE while a turn is paused on an + // interrupt, so a stream-only gate would throw a modal over the consent + // dialog and steal its focus. + isLoadingSession.set(null); + oauthPending.set(true); + start(); + modalItem.set(makeAnnouncement()); + TestBed.tick(); + expect(open).not.toHaveBeenCalled(); + }); + + it('while an MCP App consent is pending', () => { + mcpAppPending.set([{}]); + start(); + modalItem.set(makeAnnouncement()); + TestBed.tick(); + expect(open).not.toHaveBeenCalled(); + }); + + it('while the composer holds a draft', () => { + focusComposer('half a thought'); + start(); + modalItem.set(makeAnnouncement()); + TestBed.tick(); + expect(open).not.toHaveBeenCalled(); + }); + + it('on a minimal-chrome route', () => { + routeData['chrome'] = MINIMAL_CHROME; + start(); + modalItem.set(makeAnnouncement()); + TestBed.tick(); + expect(open).not.toHaveBeenCalled(); + }); + + it('when the session is not authenticated', () => { + isAuthenticated.set(false); + start(); + modalItem.set(makeAnnouncement()); + TestBed.tick(); + expect(open).not.toHaveBeenCalled(); + }); + }); + + it('opens for an empty but focused composer — focus alone is not a draft', () => { + focusComposer(' '); + start(); + modalItem.set(makeAnnouncement()); + TestBed.tick(); + expect(open).toHaveBeenCalledTimes(1); + }); + + it('does NOT fire when the blocker merely clears mid-session (§D8)', () => { + // The whole point of reading the gate inputs untracked. A stream ending is + // not a route settle: firing here would put a dialog in front of someone + // seconds after they finished a thought. It stays eligible instead. + isLoadingSession.set('session-1'); + start(); + modalItem.set(makeAnnouncement()); + TestBed.tick(); + expect(open).not.toHaveBeenCalled(); + + isLoadingSession.set(null); + TestBed.tick(); + oauthPending.set(false); + TestBed.tick(); + + expect(open).not.toHaveBeenCalled(); + }); + + it('opens on the next settled navigation after a failed gate', () => { + isLoadingSession.set('session-1'); + start(); + modalItem.set(makeAnnouncement()); + TestBed.tick(); + expect(open).not.toHaveBeenCalled(); + + // The turn finished and the user moved to another page — a clean load. + isLoadingSession.set(null); + navigate(); + + expect(open).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/ai.client/src/app/services/announcements/announcement-modal.service.ts b/frontend/ai.client/src/app/services/announcements/announcement-modal.service.ts new file mode 100644 index 000000000..082c3eb7f --- /dev/null +++ b/frontend/ai.client/src/app/services/announcements/announcement-modal.service.ts @@ -0,0 +1,150 @@ +import { DOCUMENT } from '@angular/common'; +import { Injectable, effect, inject, signal, untracked } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { Dialog, DialogRef } from '@angular/cdk/dialog'; +import { NavigationEnd, Router } from '@angular/router'; +import { filter } from 'rxjs/operators'; +import { AnnouncementsService } from './announcements.service'; +import { + AnnouncementModalComponent, + AnnouncementModalData, +} from '../../components/announcement-modal/announcement-modal.component'; +import { SessionService } from '../../auth/session.service'; +import { MessageMapService } from '../../session/services/session/message-map.service'; +import { ToolApprovalService } from '../tool-approval/tool-approval.service'; +import { OAuthConsentService } from '../oauth-consent/oauth-consent.service'; +import { McpAppConsentService } from '../../session/services/mcp-apps/mcp-app-consent.service'; +import { isMinimalChromeRoute } from '../../shared/utils/route-chrome'; + +/** + * Decides *whether* to interrupt with an announcement modal (§D8). + * + * A service rather than a component in the app shell, for two reasons: the + * modal is a CDK overlay and has no place in the layout, and self-starting via + * `provideAppInitializer` keeps the trigger out of `app.html` entirely — the + * same shape `ThemeService` already uses. + * + * ## The gate + * + * The modal opens on route settle, and only when all of these hold: + * + * - no active stream (`MessageMapService.isLoadingSession()` is null) + * - no pending tool-approval, OAuth-consent, or MCP-App consent prompt + * - the composer is not focused with a non-empty draft + * - the route is not a minimal-chrome page (a shared artifact opened from a + * link is the whole point of that visit; do not put a dialog over it) + * + * **The consent checks are not belt-and-braces.** Per + * `docs/specs/mid-turn-steering.md` (#934), `isLoading()` is `false` while a + * turn is paused on an interrupt — so a stream-only check would happily throw + * a modal over an OAuth consent dialog and steal its focus. The prompt + * services are asked directly. (The spec names tool-approval and + * oauth-consent; MCP-App consent is the same class of prompt and is included + * for the same reason.) + * + * ## Why the gate inputs are read untracked + * + * `isLoadingSession` and the three `hasPending` signals are all reactive, so + * reading them normally inside the effect would re-run it the instant a stream + * ends or a consent is answered — and fire a modal into the middle of the + * user's session, seconds after they finished a thought. §D8 is explicit that + * a failed gate must **not** queue the modal for later: it stays eligible and + * opens on the next clean load. So the effect tracks only the announcement + * itself and the navigation counter, and takes everything else as a snapshot. + * Deferred modals that fire minutes later, mid-thought, are the worst version + * of this feature. + */ +@Injectable({ providedIn: 'root' }) +export class AnnouncementModalService { + private readonly announcements = inject(AnnouncementsService); + private readonly dialog = inject(Dialog); + private readonly router = inject(Router); + private readonly document = inject(DOCUMENT); + private readonly session = inject(SessionService); + private readonly messageMap = inject(MessageMapService); + private readonly toolApproval = inject(ToolApprovalService); + private readonly oauthConsent = inject(OAuthConsentService); + private readonly mcpAppConsent = inject(McpAppConsentService); + + /** + * Bumped on every completed navigation. The effect tracks this rather than + * the router directly, so "route settle" is a reactive input and a gate that + * failed on one page gets a fresh chance on the next. + */ + private readonly navigations = signal(0); + + /** Announcements already shown in this tab, so a re-navigation is not a re-open. */ + private readonly shown = new Set(); + + private openRef: DialogRef | null = null; + + constructor() { + this.router.events + .pipe( + filter((e): e is NavigationEnd => e instanceof NavigationEnd), + takeUntilDestroyed(), + ) + .subscribe(() => this.navigations.update(n => n + 1)); + + effect(() => { + const item = this.announcements.modalItem(); + // Tracked deliberately: a settled navigation is the retry opportunity. + this.navigations(); + + if (!item) return; + untracked(() => { + if (this.shown.has(item.announcement_id)) return; + if (!this.canInterrupt()) return; + + this.shown.add(item.announcement_id); + this.openRef = this.dialog.open( + AnnouncementModalComponent, + { + data: { announcement: item }, + hasBackdrop: false, // the dialog component owns its own backdrop + // The only exit from a `requiresAck` announcement is its button. + disableClose: item.requires_ack, + panelClass: 'announcement-modal', + }, + ); + this.openRef.closed.subscribe(() => { + this.openRef = null; + }); + }); + }); + } + + /** + * The §D8 gate. Every read here is a snapshot — see the class comment for + * why this must not be reactive. + */ + private canInterrupt(): boolean { + if (this.openRef !== null) return false; + if (!this.session.isAuthenticated()) return false; + if (isMinimalChromeRoute(this.router.routerState.snapshot.root)) return false; + + if (this.messageMap.isLoadingSession() !== null) return false; + if (this.toolApproval.hasPending()) return false; + if (this.oauthConsent.hasPending()) return false; + if (this.mcpAppConsent.pending().length > 0) return false; + + return !this.composerHasDraft(); + } + + /** + * Whether the user is mid-sentence in the composer. + * + * Read from the DOM rather than a shared signal because the draft has no + * home outside `chat-input` — it lives in that component's own state. The + * question is inherently "what is focused right now", which is a DOM + * question, and asking it at gate time is exactly when the answer matters. + * Scoped by `closest('app-chat-input')` rather than the textarea's id, so a + * template rename cannot silently turn this check off. + */ + private composerHasDraft(): boolean { + const active = this.document.activeElement as HTMLElement | null; + if (!active || !active.closest('app-chat-input')) return false; + const value = (active as HTMLTextAreaElement | HTMLInputElement).value; + return typeof value === 'string' && value.trim().length > 0; + } +} diff --git a/frontend/ai.client/src/app/services/announcements/announcement.model.ts b/frontend/ai.client/src/app/services/announcements/announcement.model.ts index 3cfcc85b3..166e961f3 100644 --- a/frontend/ai.client/src/app/services/announcements/announcement.model.ts +++ b/frontend/ai.client/src/app/services/announcements/announcement.model.ts @@ -37,8 +37,8 @@ export interface Announcement { /** * `GET /announcements` — already filtered and capped by the server (§D5/§D7). * - * `banner` is rendered by `components/announcement-banner`; `modal` is - * populated from the backend but has no SPA surface until PR-5. + * `banner` is rendered by `components/announcement-banner`; `modal` by + * `components/announcement-modal`, gated by `AnnouncementModalService`. */ export interface AnnouncementFeed { panel: Announcement[]; diff --git a/frontend/ai.client/src/app/services/announcements/announcements.service.ts b/frontend/ai.client/src/app/services/announcements/announcements.service.ts index 31865d2e1..1499cee85 100644 --- a/frontend/ai.client/src/app/services/announcements/announcements.service.ts +++ b/frontend/ai.client/src/app/services/announcements/announcements.service.ts @@ -72,7 +72,7 @@ export class AnnouncementsService { this.visibleOrNull(this.feed().banner), ); - /** At most one, chosen by the server. Consumed by PR-5. */ + /** At most one, chosen by the server. Rendered by `AnnouncementModalComponent`. */ readonly modalItem = computed(() => this.visibleOrNull(this.feed().modal), );