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
17 changes: 15 additions & 2 deletions frontend/ai.client/src/app/app.html
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,9 @@

<!-- Desktop controls (when sidenav collapsed) -->
@if (sidenavService.isCollapsed() && !chromeHidden()) {
<div class="hidden lg:flex fixed top-4 left-4 z-50 gap-2">
<div
class="hidden lg:flex fixed left-4 z-50 gap-2"
[style.top]="'calc(1rem + var(--announcement-banner-height, 0px))'">
<!-- Expand sidebar button -->
<button
type="button"
Expand Down Expand Up @@ -72,7 +74,9 @@

<!-- Mobile controls (when header content hidden and sidenav not visible) -->
@if (!headerService.showContent() && !sidenavService.isVisible() && !chromeHidden()) {
<div class="flex lg:hidden fixed top-4 left-4 z-50 gap-2">
<div
class="flex lg:hidden fixed left-4 z-50 gap-2"
[style.top]="'calc(1rem + var(--announcement-banner-height, 0px))'">
<!-- Open sidenav button -->
<button
type="button"
Expand Down Expand Up @@ -108,6 +112,15 @@
[class.lg:pl-0]="sidenavService.isCollapsed() || chromeHidden()"
[class.artifact-pane-open]="artifactPanelOpen()">
<main class="flex h-dvh flex-col">
<!-- Ambient announcement strip (spec §D1). A flex child rather than an
overlay, so the scrolling content below reflows instead of hiding
under it. It publishes its measured height as
--announcement-banner-height; the fixed chat topnav and the
floating sidenav controls offset against that. -->
@if (showAnnouncementBanner()) {
<app-announcement-banner />
}

<!-- 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: 18 additions & 0 deletions frontend/ai.client/src/app/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ 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 @@ -24,6 +25,7 @@ import { BrandingService } from '../branding/branding.service';
ErrorToastComponent,
ToastComponent,
BackgroundTaskToastsComponent,
AnnouncementBannerComponent,
TooltipDirective
],
templateUrl: './app.html',
Expand Down Expand Up @@ -72,6 +74,22 @@ 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
@@ -0,0 +1,232 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { TestBed } from '@angular/core/testing';
import { signal } from '@angular/core';
import { AnnouncementsService } from '../../services/announcements/announcements.service';
import { Announcement } from '../../services/announcements/announcement.model';
import { AnnouncementBannerComponent } from './announcement-banner.component';

function makeAnnouncement(overrides: Partial<Announcement> = {}): Announcement {
return {
announcement_id: 'a1',
title: 'Skills are here',
body_markdown: '# Skills\n\n- one',
summary: null,
surfaces: ['panel', 'banner'],
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('AnnouncementBannerComponent', () => {
let bannerItem: ReturnType<typeof signal<Announcement | null>>;
let ack: ReturnType<typeof vi.fn>;
let observed: Element[];

beforeEach(() => {
TestBed.resetTestingModule();
bannerItem = signal<Announcement | null>(null);
ack = vi.fn(async () => true);
observed = [];

// jsdom has no ResizeObserver. Stub one that records what it watches so
// the height-publishing path is actually exercised rather than skipped.
(globalThis as any).ResizeObserver = class {
constructor(private cb: ResizeObserverCallback) {}
observe(el: Element) {
observed.push(el);
}
disconnect() {}
unobserve() {}
emit(height: number) {
this.cb(
[{ contentRect: { height } } as unknown as ResizeObserverEntry],
this as unknown as ResizeObserver,
);
}
};

// DI-token override rather than vi.mock, per house convention.
TestBed.configureTestingModule({
providers: [
{ provide: AnnouncementsService, useValue: { bannerItem, ack } },
],
});
});

afterEach(() => {
TestBed.resetTestingModule();
document.documentElement.style.removeProperty('--announcement-banner-height');
});

function create() {
const fixture = TestBed.createComponent(AnnouncementBannerComponent);
fixture.detectChanges();
return fixture;
}

function text(fixture: ReturnType<typeof create>) {
return (fixture.nativeElement as HTMLElement).textContent?.trim() ?? '';
}

function strip(fixture: ReturnType<typeof create>): HTMLElement | null {
return (fixture.nativeElement as HTMLElement).querySelector('[role="status"]');
}

it('renders nothing when the server sent no banner', () => {
const fixture = create();
expect(strip(fixture)).toBeNull();
});

it('renders the strip once the feed resolves', () => {
// The dependency-set regression from #974: read the derived state while
// the feed is still EMPTY first, then populate it. A computed that
// guard-clauses out before touching its signal tracks nothing on that
// first evaluation and never recomputes — which is how the submit button
// on the admin form shipped permanently disabled.
const fixture = create();
const component = fixture.componentInstance;
expect(component.bannerText()).toBe('');
expect(strip(fixture)).toBeNull();

bannerItem.set(makeAnnouncement());
fixture.detectChanges();

expect(component.bannerText()).toBe('Skills are here');
expect(strip(fixture)).not.toBeNull();
expect(text(fixture)).toContain('Skills are here');
});

it('prefers the summary over the title — the strip is one line', () => {
bannerItem.set(
makeAnnouncement({ title: 'A'.repeat(140), summary: 'Short version' }),
);
const fixture = create();
expect(text(fixture)).toContain('Short version');
expect(text(fixture)).not.toContain('A'.repeat(140));
});

it('falls back to the title when the summary is blank whitespace', () => {
bannerItem.set(makeAnnouncement({ summary: ' ' }));
const fixture = create();
expect(fixture.componentInstance.bannerText()).toBe('Skills are here');
});

it('writes `seen` on render, and only once per announcement', () => {
bannerItem.set(makeAnnouncement());
const fixture = create();

expect(ack).toHaveBeenCalledWith('a1', 'seen', 'banner');
expect(ack).toHaveBeenCalledTimes(1);

fixture.detectChanges();
fixture.detectChanges();
expect(ack).toHaveBeenCalledTimes(1);
});

it('writes `seen` again when a different announcement takes the slot', () => {
bannerItem.set(makeAnnouncement());
const fixture = create();
ack.mockClear();

bannerItem.set(makeAnnouncement({ announcement_id: 'a2' }));
fixture.detectChanges();

expect(ack).toHaveBeenCalledWith('a2', 'seen', 'banner');
});

it('records a durable `dismissed` ack on ✕, scoped to the banner surface', () => {
bannerItem.set(makeAnnouncement());
const fixture = create();
ack.mockClear();

const dismiss = (fixture.nativeElement as HTMLElement).querySelector(
'button',
) as HTMLButtonElement;
expect(dismiss.getAttribute('aria-label')).toBe(
'Dismiss announcement: Skills are here',
);
dismiss.click();

expect(ack).toHaveBeenCalledWith('a1', 'dismissed', 'banner');
});

it('is a polite live region, never assertive', () => {
bannerItem.set(makeAnnouncement());
const fixture = create();
const el = strip(fixture)!;
expect(el.getAttribute('aria-live')).toBe('polite');
expect(el.getAttribute('role')).toBe('status');
});

it.each([
['info' as const, 'heroInformationCircle', 'state-info'],
['success' as const, 'heroCheckCircle', 'state-success'],
['warning' as const, 'heroExclamationTriangle', 'state-warning'],
])('maps %s severity to its icon and token scale', (severity, icon, scale) => {
bannerItem.set(makeAnnouncement({ severity }));
const fixture = create();
const component = fixture.componentInstance;

expect(component.iconName()).toBe(icon);
// Full literal class strings — a concatenated `bg-state-${severity}-50`
// would compile to nothing, because Tailwind scans source text.
expect(component.severityClass()).toContain(`bg-${scale}-50`);
expect(component.severityClass()).toContain(`dark:bg-${scale}-900/30`);
});

it('renders the CTA only when both label and url are present', () => {
bannerItem.set(makeAnnouncement({ cta_label: 'Read more' }));
let fixture = create();
expect(
(fixture.nativeElement as HTMLElement).querySelector('a'),
).toBeNull();

TestBed.resetTestingModule();
TestBed.configureTestingModule({
providers: [
{ provide: AnnouncementsService, useValue: { bannerItem, ack } },
],
});
bannerItem.set(
makeAnnouncement({ cta_label: 'Read more', cta_url: 'https://example.edu' }),
);
fixture = create();
const link = (fixture.nativeElement as HTMLElement).querySelector('a')!;
expect(link.getAttribute('href')).toBe('https://example.edu');
expect(link.getAttribute('rel')).toBe('noopener noreferrer');
});

it('publishes its measured height so the fixed topnav can clear it', () => {
bannerItem.set(makeAnnouncement());
const fixture = create();

expect(observed).toHaveLength(1);
expect(observed[0]).toBe(fixture.nativeElement);
});

it('clears the height variable on destroy, so nothing is left offset', () => {
bannerItem.set(makeAnnouncement());
const fixture = create();
expect(
document.documentElement.style.getPropertyValue(
'--announcement-banner-height',
),
).not.toBe('');

fixture.destroy();

expect(
document.documentElement.style.getPropertyValue(
'--announcement-banner-height',
),
).toBe('');
});
});
Loading