feat(announcements): render the modal surface with the §D8 gate (PR-5) - #977
Merged
Conversation
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 <noreply@anthropic.com>
philmerrell
force-pushed
the
feature/announcement-modal
branch
from
September 6, 2026 15:00
5d9d91e to
b54d2d6
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fifth and last surface PR in the feature-announcements epic (spec:
docs/specs/feature-announcements.md). The spec calls this the highest-risk PR — "everything that can annoy a user lives here." Only PR-6 (/stats) remains after it.Independent of #976 (PR-4). Branched from
developand touches no file PR-4 touches — the modal is started fromapp.config.ts, not mounted inapp.html. Either can merge first. That is deliberate: per the #419 post-mortem, stacking a PR whose base merges first is how a commit gets stranded.The gate is the substance
AnnouncementModalServiceopens the modal on route settle, and only when:MessageMapService.isLoadingSession()is null)The consent checks are not redundant with the stream check. Per
docs/specs/mid-turn-steering.md(#934),isLoading()isfalsewhile a turn is paused on an interrupt — so a stream-only gate would happily throw a dialog over an OAuth consent prompt 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.Every gate input is read
untracked, and that is load-bearing. Read reactively, the effect would re-run the instant a stream ended or a consent was answered — firing a modal into the middle of the session, seconds after the user finished a thought. §D8 forbids exactly that: a failed gate leaves the announcement eligible, it does not queue it. The effect therefore tracks only the announcement and a navigation counter and snapshots the rest. There is a spec for this specifically (does NOT fire when the blocker merely clears mid-session), because it is the kind of thing a later refactor would "clean up" by deleting theuntrackedcall.requiresAck
The confirm button is the only exit: no ✕,
disableCloseon 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, and being stuck under an undismissable dialog is the worst outcome this feature can produce.The button label follows the ack it writes — "I understand" →
acknowledged, "Got it" →dismissed— so it cannot misdescribe the record it creates.Smaller things
provideAppInitializer, same shape asThemeService. A CDK overlay is not a layout element, and nothing else would ever inject the service..message-block, notprose— the typography plugin is not installed, so the olderuser-menu-link-modal's classes are inert and preflight strips list markers.admin.announcementsis delegable, so this body may be authored by someone who is not a platform admin and it is broadcast to every user.Verification
Unit: 23 specs across the component and the gate, including the #934 shape (consent pending while the stream reads idle) and the mid-session-clear case above.
End to end against dev data with a local app-api:
requiresAckon loadacknowledgedat rank 3, upgrading theseenrow in place rather than duplicatingrequiresAckoffdismissedat rank 2modal: null, both panel entries survive (§D1/§D2)Full SPA suite: no new failures. The 4 in
admin/marketplace/pages/submission-review.page.spec.tsare pre-existing — cleandevelopfails the same file with 5, the knownisolate: falsecross-file flake.Test data created in dev was deleted afterwards; the table is back to just the "Welcome to Dev" announcement.
Still open in the epic
/stats+ ack counters + admin reporting.admin.audittrail;AuditActionis a closed namespace of role-only actions and extending it touches the non-delegableadmin.auditscope. Worth a decision beforerequiresAckis used for anything policy-shaped — an acknowledgement record with no audit trail behind the authoring side is a weaker record than it looks.🤖 Generated with Claude Code