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
18 changes: 15 additions & 3 deletions docs/specs/feature-announcements.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,20 @@ An announcement carries `surfaces: list[Literal["panel", "banner", "modal"]]`.
never interrupts, browsable forever. **Implied on every announcement**: the
service adds `"panel"` if the admin omits it, so dismissing a loud surface
can never destroy the information.
- **`banner`** — a strip below the top nav, rendered by a sibling of
`quota-warning-banner`. One line plus an optional CTA and a ✕.
- **`banner`** — a compact pill floating just above the chat composer,
rendered by a sibling of `quota-warning-banner`. One line plus an optional
CTA and a ✕.

*Revised after PR-4 shipped.* It was first built as a full-bleed strip below
the top nav. Two things moved it. Dismissing a strip that occupied layout
reflowed the whole view, so it became an overlay; and what a banner
announces — a new model, a new capability — is acted on **in the composer**,
so the notice belongs where the decision is made rather than in a corner the
eye has already left. The consequence to keep in mind: the banner is now a
**chat-view surface only**, and it is deliberately suppressed in the
embedded preview panes (agent preview, marketplace test-drive). The panel
remains the everywhere-record, which is why `panel` is forced onto every
announcement server-side.
- **`modal`** — a dialog on next load. This is the only surface that can
demand a real acknowledgement (`requiresAck`).

Expand Down Expand Up @@ -474,7 +486,7 @@ from §D7.
| Component | Location | Notes |
|---|---|---|
| Whats-new panel | `components/topnav/components/whats-new-panel/` | Dialog listing panel items newest-first, relative dates, **New** / **Updated** pills, markdown body. Opens from the user dropdown; unread dot on the avatar and the menu row. Mirrors `user-menu-link-modal`. |
| Announcement banner | `components/announcement-banner/` | Sibling of `quota-warning-banner`. `role="status"`, `aria-live="polite"`, severity colours from the `state-*` token scale that banner already uses, ✕ + optional CTA. |
| Announcement banner | `components/announcement-banner/` | Mounted by `chat-input` beside `quota-warning-banner`, floated `bottom-full` so dismissing it never moves the composer. `role="status"`, `aria-live="polite"`, severity colours from the `state-*` token scale, ✕ + optional CTA. Gated off in embedded panes via `[showAnnouncements]="false"`. |
| Announcement modal | `components/announcement-modal/` | `user-menu-link-modal` plus a primary ack button. When `requiresAck`, `appDialogDismiss` and the escape handler are disabled so the only exit is the button. |
| Admin list | `admin/manage-announcements/manage-announcements.page.ts` | Mirrors `manage-user-menu-links`. State chips, surface icons, ack counts, "Show again" action. |
| Admin form | `admin/manage-announcements/announcement-form.page.ts` | Title, markdown body with live preview, surface checkboxes, severity, schedule, role picker, `showToNewUsers` (with the §D6 warning text), `requiresAck`, CTA. |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ function byteLength(value: string): number {
class="mt-1 size-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
<label for="surface-banner" class="text-sm/6 text-gray-700 dark:text-gray-300">
<span class="font-medium">Banner</span> — a strip below the top nav
<span class="font-medium">Banner</span> — a pill above the chat composer
<span class="block text-xs text-gray-500 dark:text-gray-400">
Ambient. Dismissible with a ✕.
</span>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -305,7 +305,8 @@ export class ManageAnnouncementsPage {
}

protected surfaceHint(surface: string): string {
if (surface === 'banner') return 'A strip below the top nav. At most one at a time.';
if (surface === 'banner')
return 'A pill above the chat composer, in the chat view only. At most one at a time.';
if (surface === 'modal') return 'A dialog on next load. At most one at a time.';
return "Always on. The What's New entry in the user menu.";
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ import { PreviewChatService } from '../../../assistants/assistant-form/services/
[showSettingsControl]="false"
[autoFocus]="false"
[showAgentMentions]="false"
[showAnnouncements]="false"
(messageSubmitted)="onMessageSubmitted($event)"
(messageCancelled)="onMessageCancelled()"
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ import { ModelService } from '../../../session/services/model/model.service';
[showSettingsControl]="false"
[autoFocus]="false"
[showAgentMentions]="false"
[showAnnouncements]="false"
(messageSubmitted)="onMessageSubmitted($event)"
(messageCancelled)="onMessageCancelled()"
/>
Expand Down
10 changes: 1 addition & 9 deletions frontend/ai.client/src/app/app.html
Original file line number Diff line number Diff line change
Expand Up @@ -107,15 +107,7 @@
[class.lg:pl-72]="!sidenavService.isCollapsed() && !chromeHidden()"
[class.lg:pl-0]="sidenavService.isCollapsed() || chromeHidden()"
[class.artifact-pane-open]="artifactPanelOpen()">
<main class="relative flex h-dvh flex-col">
<!-- Ambient announcement strip (spec §D1). Positioned `absolute` against
this `relative` main, so showing or dismissing it never reflows the
page. `relative` is load-bearing: without it the pill would anchor
to the viewport and drift out from under the sidenav padding. -->
@if (showAnnouncementBanner()) {
<app-announcement-banner />
}

<main class="flex h-dvh flex-col">
<!-- Scrollable Content. The id is a stable hook for code that reads
or sets the scroll position (session scroll save/restore) — the
window itself no longer scrolls now that this container is real. -->
Expand Down
18 changes: 0 additions & 18 deletions frontend/ai.client/src/app/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import { Sidenav } from './components/sidenav/sidenav';
import { ErrorToastComponent } from './components/error-toast/error-toast.component';
import { ToastComponent } from './components/toast';
import { BackgroundTaskToastsComponent } from './components/background-task-toasts/background-task-toasts.component';
import { AnnouncementBannerComponent } from './components/announcement-banner/announcement-banner.component';
import { SidenavService } from './services/sidenav/sidenav.service';
import { HeaderService } from './services/header/header.service';
import { TooltipDirective } from './components/tooltip/tooltip.directive';
Expand All @@ -25,7 +24,6 @@ import { BrandingService } from '../branding/branding.service';
ErrorToastComponent,
ToastComponent,
BackgroundTaskToastsComponent,
AnnouncementBannerComponent,
TooltipDirective
],
templateUrl: './app.html',
Expand Down Expand Up @@ -74,22 +72,6 @@ export class App {
() => this.sidenavService.isHidden() || this.minimalChrome(),
);

/**
* Whether the ambient announcement strip renders.
*
* Gated on the session, not just the chrome. `AnnouncementsService` loads
* its feed lazily on the first read of `bannerItem()`, and `resource()`
* loads exactly once — so instantiating the banner on the login screen
* would fire `GET /announcements` unauthenticated, take the 401's
* empty-feed fallback, and then never retry. The user would land in the
* app with announcements permanently missing for the life of the tab.
* Waiting for `isAuthenticated()` also keeps the server's role-based
* targeting honest: it needs the session to evaluate it.
*/
protected readonly showAnnouncementBanner = computed(
() => this.session.isAuthenticated() && !this.minimalChrome(),
);

/** True while an artifact pane is docked — content reserves right-side
* space for it (desktop only) so the fixed panel doesn't occlude chat. */
protected readonly artifactPanelOpen = computed(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -197,9 +197,9 @@ describe('AnnouncementBannerComponent', () => {
expect(host.className).not.toContain('block');
});

it('lets clicks through the positioning strip to the chrome beneath', () => {
// The strip spans the full content width. Without this, it would
// swallow clicks aimed at the topnav and the sidebar buttons under it.
it('lets clicks through the positioning strip to whatever is beneath', () => {
// The strip spans the composer's full width. Without this, it would
// swallow clicks aimed at the message list behind it.
bannerItem.set(makeAnnouncement());
const fixture = create();
const host = fixture.nativeElement as HTMLElement;
Expand All @@ -208,23 +208,42 @@ describe('AnnouncementBannerComponent', () => {
expect(strip(fixture)!.className).toContain('pointer-events-auto');
});

it('clears the topnav and the floating sidenav controls', () => {
// `top-16` puts the pill just below a chat route's fixed topnav, and
// below the shell's `top-4` sidebar buttons everywhere else — one
// constant instead of route awareness.
it('floats above the composer rather than stacking in flow with it', () => {
// `bottom-full` against the `relative` chat-input host: the pill sits
// clear of the quota tabs, which stay attached to the input, and
// dismissing it cannot move the composer under the user's cursor.
bannerItem.set(makeAnnouncement());
const fixture = create();
expect((fixture.nativeElement as HTMLElement).className).toContain('top-16');
const host = (fixture.nativeElement as HTMLElement).className;
expect(host).toContain('bottom-full');
expect(host).not.toContain('top-16');
});

it('reads as a floating card, not a full-bleed strip', () => {
it('takes the other side when the composer is centred', () => {
// The empty state puts the greeting immediately above a centred
// composer, so `above` would float the pill over it — visibly so at
// narrow widths, where the greeting wraps.
bannerItem.set(makeAnnouncement());
const fixture = TestBed.createComponent(AnnouncementBannerComponent);
fixture.componentRef.setInput('placement', 'below');
fixture.detectChanges();

const host = (fixture.nativeElement as HTMLElement).className;
expect(host).toContain('top-full');
expect(host).not.toContain('bottom-full');
});

it('reads as a compact tab beside the quota warning, not a full-bleed strip', () => {
bannerItem.set(makeAnnouncement());
const fixture = create();
const pill = strip(fixture)!;

expect(pill.className).toContain('rounded-2xl');
expect(pill.className).toContain('shadow-lg');
expect(pill.className).toContain('max-w-2xl');
expect(pill.className).toContain('shadow-md');
// Shrink-to-fit, so it reads as a sibling of the quota tab rather than
// a bar spanning the composer.
expect(pill.className).toContain('inline-flex');
expect(pill.className).toContain('text-xs');
// The old full-bleed look leaned on a bottom border instead.
expect(pill.className).not.toContain('border-b');
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
computed,
effect,
inject,
input,
} from '@angular/core';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
Expand Down Expand Up @@ -49,11 +50,28 @@ import {
* `--announcement-banner-height`: nothing has to move any more, so that
* variable, its `ResizeObserver`, and all three offsets are gone.
*
* `top-16` is not arbitrary. On a chat route it lands the pill immediately
* below the fixed topnav — the placement §D1 asks for — and everywhere else
* it clears the shell's floating sidenav controls, which sit at `top-4` and
* would otherwise be overlapped by a centred pill on any viewport narrow
* enough for the two to meet. One constant, no route awareness.
* **It lives above the composer, not at the top of the shell.** A deviation
* from §D1's "strip below the top nav", and a deliberate one: what these
* announce — a new model, a new capability — is acted on in the composer, so
* the notice belongs where the decision is made rather than in a corner the
* eye has already left. It mounts from `chat-input` beside
* `quota-warning-banner` for that reason, which also means it is a **chat
* surface only**; What's New remains the everywhere-record, which is why
* `panel` is forced onto every announcement server-side.
*
* **Which side of the composer it takes follows the composer.** In a
* conversation the composer is pinned to the bottom of the viewport, so the
* pill goes above it. On the empty state the composer is centred with the
* greeting directly above it, so the pill goes below instead — otherwise it
* floats over the greeting, which is exactly what it does at narrow widths.
* The caller passes `placement`; it is derived from the container's
* `isEmptyState()`, not measured, because that signal is what decides which
* layout branch renders in the first place. Measuring viewport position would
* re-derive the same fact less reliably, and would have to be recomputed on
* resize, on scroll, and when the artifact pane opens.
*
* Either way it floats clear of the quota tabs, which stay visually attached
* to the input.
*
* The wrapper is `pointer-events-none` and only the pill itself takes events,
* so the full-width positioning strip cannot swallow clicks aimed at the
Expand All @@ -79,23 +97,27 @@ import {
// The positioning strip. `pointer-events-none` here (and `auto` on the
// pill) keeps it from swallowing clicks meant for the chrome beneath.
class:
'pointer-events-none absolute inset-x-0 top-16 z-50 flex justify-center px-4',
'pointer-events-none absolute inset-x-0 z-30 flex justify-center px-4',
'[class.bottom-full]': "placement() === 'above'",
'[class.mb-2]': "placement() === 'above'",
'[class.top-full]': "placement() === 'below'",
'[class.mt-2]': "placement() === 'below'",
},
template: `
@if (announcement(); as item) {
<div
class="pointer-events-auto flex w-full max-w-2xl items-center gap-x-3 rounded-2xl border px-4 py-2.5 shadow-lg sm:px-5"
class="pointer-events-auto inline-flex max-w-full items-center gap-x-2 rounded-2xl border px-3 py-1.5 text-xs shadow-md"
[class]="severityClass()"
role="status"
aria-live="polite"
>
<ng-icon
[name]="iconName()"
class="size-5 shrink-0"
class="size-4 shrink-0"
aria-hidden="true"
/>

<p class="min-w-0 flex-1 truncate text-sm/6 font-medium">
<p class="min-w-0 truncate font-medium">
{{ bannerText() }}
</p>

Expand All @@ -104,7 +126,7 @@ import {
[href]="item.cta_url"
target="_blank"
rel="noopener noreferrer"
class="shrink-0 text-sm/6 font-semibold underline underline-offset-2 hover:no-underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current"
class="shrink-0 font-semibold underline underline-offset-2 hover:no-underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current"
>
{{ item.cta_label }}
</a>
Expand All @@ -114,9 +136,9 @@ import {
type="button"
(click)="onDismiss()"
[attr.aria-label]="'Dismiss announcement: ' + item.title"
class="-mr-1 flex size-7 shrink-0 items-center justify-center rounded-2xl hover:bg-black/10 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current dark:hover:bg-white/10"
class="-mr-1 flex size-5 shrink-0 items-center justify-center rounded-full hover:bg-black/10 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current dark:hover:bg-white/10"
>
<ng-icon name="heroXMark" class="size-4" aria-hidden="true" />
<ng-icon name="heroXMark" class="size-3.5" aria-hidden="true" />
</button>
</div>
}
Expand All @@ -125,6 +147,13 @@ import {
export class AnnouncementBannerComponent {
private readonly announcements = inject(AnnouncementsService);

/**
* Which side of the composer to take. `'above'` suits a bottom-pinned
* composer; `'below'` keeps the pill off the greeting when the composer is
* centred on the empty state.
*/
readonly placement = input<'above' | 'below'>('above');

readonly announcement = computed<Announcement | null>(() =>
this.announcements.bannerItem(),
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ class ChatInputStub {
readonly showFileControls = input<boolean>(true);
readonly showVoiceControl = input<boolean>(true);
readonly showSettingsControl = input<boolean>(true);
readonly showAnnouncements = input<boolean>(true);
readonly announcementPlacement = input<'above' | 'below'>('above');
readonly messageSubmitted = output<unknown>();
readonly messageCancelled = output<void>();
readonly fileAttached = output<File>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@
<!-- Fixed input at bottom for embedded empty state -->
<div class="chat-input-footer embedded">
<app-chat-input
[showAnnouncements]="showAnnouncements()"
[announcementPlacement]="announcementPlacement()"
[sessionId]="sessionId()"
[isChatLoading]="isChatLoading()"
[showFileControls]="resolvedConfig().showFileControls"
Expand Down Expand Up @@ -123,6 +125,8 @@
[class.artifact-pane-open]="artifactPanelOpen()">
<div class="mx-auto px-4 max-w-[720px]">
<app-chat-input
[showAnnouncements]="showAnnouncements()"
[announcementPlacement]="announcementPlacement()"
[sessionId]="sessionId()"
[isChatLoading]="isChatLoading()"
[showFileControls]="resolvedConfig().showFileControls"
Expand Down Expand Up @@ -171,6 +175,8 @@
[class.artifact-pane-open]="artifactPanelOpen()">
<div class="mx-auto px-4 max-w-[720px]">
<app-chat-input
[showAnnouncements]="showAnnouncements()"
[announcementPlacement]="announcementPlacement()"
[sessionId]="sessionId()"
[isChatLoading]="isChatLoading()"
[showFileControls]="resolvedConfig().showFileControls"
Expand Down Expand Up @@ -224,6 +230,8 @@

<div class="relative">
<app-chat-input
[showAnnouncements]="showAnnouncements()"
[announcementPlacement]="announcementPlacement()"
[sessionId]="sessionId()"
[isChatLoading]="isChatLoading()"
[showFileControls]="resolvedConfig().showFileControls"
Expand Down Expand Up @@ -294,6 +302,8 @@
</div>
}
<app-chat-input
[showAnnouncements]="showAnnouncements()"
[announcementPlacement]="announcementPlacement()"
[sessionId]="sessionId()"
[isChatLoading]="isChatLoading()"
[showFileControls]="resolvedConfig().showFileControls"
Expand Down Expand Up @@ -339,6 +349,8 @@
<app-session-cost-badge />
</div>
<app-chat-input
[showAnnouncements]="showAnnouncements()"
[announcementPlacement]="announcementPlacement()"
[sessionId]="sessionId()"
[isChatLoading]="isChatLoading()"
[showFileControls]="resolvedConfig().showFileControls"
Expand Down
Loading