diff --git a/frontend/ai.client/src/app/app.config.ts b/frontend/ai.client/src/app/app.config.ts index 66b6db32..15753e14 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 00000000..00db445b --- /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 00000000..b93ab17c --- /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 00000000..0784bf83 --- /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 00000000..082c3eb7 --- /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 3cfcc85b..166e961f 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 31865d2e..1499cee8 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), );