diff --git a/apps/website/content/docs/chat/api/api-docs.json b/apps/website/content/docs/chat/api/api-docs.json index 1ea5dfd14..2ab3e7a01 100644 --- a/apps/website/content/docs/chat/api/api-docs.json +++ b/apps/website/content/docs/chat/api/api-docs.json @@ -3249,6 +3249,12 @@ "description": "", "optional": false }, + { + "name": "closeOnEscape", + "type": "InputSignal", + "description": "Close the sidebar on Escape (default true).", + "optional": false + }, { "name": "forkRequested", "type": "OutputEmitterRef", diff --git a/examples/chat/angular/e2e/README.md b/examples/chat/angular/e2e/README.md index de15b6330..154d8c290 100644 --- a/examples/chat/angular/e2e/README.md +++ b/examples/chat/angular/e2e/README.md @@ -33,7 +33,32 @@ Re-records each committed fixture against real OpenAI and reports byte-level dif - `scripts/record.ts` — dev-only fixture-capture CLI. - `scripts/drift.ts` — CI fixture-drift comparison. - `playwright.config.ts` — Playwright config with globalSetup that boots aimock + LangGraph + Angular dev server. -- `smoke.spec.ts` — Phase 2a smoke test (one scenario: "hi"). +- `initial-render.spec.ts` — checklist pre-flight browser hygiene and welcome-state render. +- `send-receive.spec.ts` — basic deterministic send/receive and stream completion. +- `stop-streaming.spec.ts` — skipped harness pilot for stop-button abort behavior. +- `markdown-surfaces.spec.ts` — final-state markdown matrix. +- `regenerate.spec.ts` — regenerate replacement and server-state invariants. +- `mode-routing.spec.ts` — embed/popup/sidebar routing and cross-mode persistence. +- `model-picker.spec.ts` — model picker persistence and backend state. +- `debug-devtools.spec.ts` — chat-debug accessibility plus sidebar coexistence. +- `control-palette.spec.ts` — palette default/collapsed state and route controls. +- `color-scheme.spec.ts` — light/dark persistence and A2UI theme sync. +- `keyboard-accessibility.spec.ts` — keyboard send/newline, Escape, and core button names. +- `error-handling.spec.ts` — network-failure alert and recovery. +- `lifecycle.spec.ts` — reload reconnect, new conversation, welcome suggestion submit. +- `browser-hygiene.spec.ts` — pilot automation for repeated mode-switch hygiene. +- `visual-polish.spec.ts` — responsive overflow checks at checklist widths. +- `a2ui-single-bubble.spec.ts`, `interrupt-approval.spec.ts`, `research-subagent.spec.ts` — capability smoke coverage for GenUI, HITL, and subagents. + +## Checklist coverage + +The suite mirrors `examples/chat/smoke/CHECKLIST.md` by section. Items that +need live-model semantics or visual judgment stay represented by deterministic +proxies here and by the smoke checklist for manual release validation. The +browser-hygiene coverage has graduated into CI-grade assertions using Chromium +performance metrics and repeated route churn. The remaining skipped pilot is +stop-streaming, which needs the harness to expose an in-flight stream state +reliably enough for deterministic abort assertions. ## Env vars diff --git a/examples/chat/angular/e2e/browser-hygiene.spec.ts b/examples/chat/angular/e2e/browser-hygiene.spec.ts new file mode 100644 index 000000000..88186d076 --- /dev/null +++ b/examples/chat/angular/e2e/browser-hygiene.spec.ts @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: MIT +import { test, expect } from '@playwright/test'; +import { attachBrowserHygiene, openDemo } from './test-helpers'; + +test('browser hygiene pilot: repeated mode switches do not leak visible chat DOM', async ({ + page, +}) => { + await openDemo(page, '/embed'); + const hygiene = attachBrowserHygiene(page); + + const client = await page.context().newCDPSession(page); + await client.send('Performance.enable'); + const before = await client.send('Performance.getMetrics'); + + for (let i = 0; i < 10; i++) { + await page + .locator('.demo-shell__segmented-button', { hasText: 'Popup' }) + .click(); + await expect(page).toHaveURL(/\/popup$/); + await page + .locator('.demo-shell__segmented-button', { hasText: 'Sidebar' }) + .click(); + await expect(page).toHaveURL(/\/sidebar$/); + await page + .locator('.demo-shell__segmented-button', { hasText: 'Embed' }) + .click(); + await expect(page).toHaveURL(/\/embed$/); + } + + const after = await client.send('Performance.getMetrics'); + const jsHeapBefore = + before.metrics.find((m) => m.name === 'JSHeapUsedSize')?.value ?? 0; + const jsHeapAfter = + after.metrics.find((m) => m.name === 'JSHeapUsedSize')?.value ?? 0; + + await expect(page.locator('embed-mode')).toHaveCount(1); + await expect(page.locator('popup-mode')).toHaveCount(0); + await expect(page.locator('sidebar-mode')).toHaveCount(0); + await expect(page.locator('chat-message')).toHaveCount(0); + expect(jsHeapAfter).toBeLessThan(jsHeapBefore + 20_000_000); + expect(hygiene.consoleErrors).toEqual([]); + expect(hygiene.failedRequests).toEqual([]); +}); diff --git a/examples/chat/angular/e2e/color-scheme.spec.ts b/examples/chat/angular/e2e/color-scheme.spec.ts new file mode 100644 index 000000000..8d5da70a2 --- /dev/null +++ b/examples/chat/angular/e2e/color-scheme.spec.ts @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: MIT +import { test, expect } from '@playwright/test'; +import { + attachBrowserHygiene, + openDemo, + selectToolbarOption, +} from './test-helpers'; + +test('color scheme: dark default, light toggle persists and syncs default A2UI theme', async ({ + page, +}) => { + await openDemo(page, '/embed'); + const hygiene = attachBrowserHygiene(page); + + await expect(page.locator('html')).toHaveAttribute( + 'data-color-scheme', + 'dark' + ); + await expect(page.locator('html')).toHaveAttribute( + 'data-ngaf-chat-theme', + 'dark' + ); + await expect(page.locator('html')).toHaveAttribute( + 'data-theme', + 'default-dark' + ); + + await page.getByRole('button', { name: 'Switch to light theme' }).click(); + + await expect(page.locator('html')).toHaveAttribute( + 'data-color-scheme', + 'light' + ); + await expect(page.locator('html')).toHaveAttribute( + 'data-ngaf-chat-theme', + 'light' + ); + await expect(page.locator('html')).toHaveAttribute( + 'data-theme', + 'default-light' + ); + + await page.reload(); + await expect(page.locator('html')).toHaveAttribute( + 'data-color-scheme', + 'light' + ); + await expect(page.locator('html')).toHaveAttribute( + 'data-ngaf-chat-theme', + 'light' + ); + + expect(hygiene.consoleErrors).toEqual([]); +}); + +test('color scheme: material A2UI theme override wins over scheme sync', async ({ + page, +}) => { + await openDemo(page, '/embed'); + + await selectToolbarOption(page, 'Theme', 'Material dark'); + await expect(page.locator('html')).toHaveAttribute( + 'data-theme', + 'material-dark' + ); + + await page.getByRole('button', { name: 'Switch to light theme' }).click(); + + await expect(page.locator('html')).toHaveAttribute( + 'data-color-scheme', + 'light' + ); + await expect(page.locator('html')).toHaveAttribute( + 'data-theme', + 'material-dark' + ); +}); diff --git a/examples/chat/angular/e2e/control-palette.spec.ts b/examples/chat/angular/e2e/control-palette.spec.ts new file mode 100644 index 000000000..eb45495d6 --- /dev/null +++ b/examples/chat/angular/e2e/control-palette.spec.ts @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: MIT +import { test, expect } from '@playwright/test'; +import { + openChatDevtools, + openDemo, + selectToolbarOption, + toolbarSelect, +} from './test-helpers'; + +test('control palette: toolbar renders defaults and persists selected controls', async ({ + page, +}) => { + await openDemo(page, '/embed'); + + await expect( + page.getByRole('toolbar', { name: 'Demo controls' }) + ).toBeVisible(); + await expect( + page.locator('.demo-shell__segmented-button.is-active', { + hasText: 'Embed', + }) + ).toBeVisible(); + await expect(toolbarSelect(page, 'Model')).toHaveValue('gpt-5-mini'); + await expect(toolbarSelect(page, 'Effort')).toHaveValue('minimal'); + await expect(toolbarSelect(page, 'Gen UI')).toHaveValue('a2ui'); + await expect(toolbarSelect(page, 'Theme')).toHaveValue('default-dark'); + + await selectToolbarOption(page, 'Model', 'gpt-5-nano'); + await selectToolbarOption(page, 'Effort', 'low'); + await selectToolbarOption(page, 'Gen UI', 'json-render'); + await selectToolbarOption(page, 'Theme', 'Material dark'); + + await page.reload(); + await expect(toolbarSelect(page, 'Model')).toHaveValue('gpt-5-nano'); + await expect(toolbarSelect(page, 'Effort')).toHaveValue('low'); + await expect(toolbarSelect(page, 'Gen UI')).toHaveValue('json-render'); + await expect(toolbarSelect(page, 'Theme')).toHaveValue('material-dark'); +}); + +test('control palette: devtools opens on demand and closes back to launcher', async ({ + page, +}) => { + await openDemo(page, '/embed'); + + await openChatDevtools(page); + await expect( + page.getByRole('region', { name: 'Chat devtools' }) + ).toBeVisible(); + await expect(page.locator('.panel.panel--right')).toBeVisible(); + + await page.getByRole('button', { name: 'Close' }).click(); + await expect(page.locator('chat-debug .panel')).toHaveCount(0); + await expect( + page.getByRole('button', { name: 'Open chat devtools' }) + ).toBeVisible(); +}); + +test('control palette: mode segmented control changes routes', async ({ + page, +}) => { + await openDemo(page, '/embed'); + await page + .locator('.demo-shell__segmented-button', { hasText: 'Sidebar' }) + .click(); + await expect(page).toHaveURL(/\/sidebar$/); + await expect( + page.locator('.demo-shell__segmented-button.is-active', { + hasText: 'Sidebar', + }) + ).toBeVisible(); +}); diff --git a/examples/chat/angular/e2e/sidebar-mode-coexistence.spec.ts b/examples/chat/angular/e2e/debug-devtools.spec.ts similarity index 55% rename from examples/chat/angular/e2e/sidebar-mode-coexistence.spec.ts rename to examples/chat/angular/e2e/debug-devtools.spec.ts index 239e0c28b..ef438b93a 100644 --- a/examples/chat/angular/e2e/sidebar-mode-coexistence.spec.ts +++ b/examples/chat/angular/e2e/debug-devtools.spec.ts @@ -1,26 +1,52 @@ // SPDX-License-Identifier: MIT import { test, expect } from '@playwright/test'; +import { openChatDevtools, openDemo } from './test-helpers'; + +test('chat-debug devtools: opens from the sidenav with accessible controls and closes cleanly', async ({ + page, +}) => { + await openDemo(page, '/embed'); + await openChatDevtools(page); + + const panel = page.getByRole('region', { name: 'Chat devtools' }); + await expect(panel).toBeVisible(); + await expect(panel.getByRole('tab', { name: 'Timeline' })).toBeVisible(); + await expect(panel.getByRole('tab', { name: 'State' })).toBeVisible(); + + await page.getByRole('button', { name: 'Close' }).click(); + await expect(page.locator('chat-debug .panel')).toHaveCount(0); + await expect( + page.getByRole('button', { name: 'Open chat devtools' }) + ).toBeVisible(); +}); test.describe('chat-debug × chat-sidebar coexistence', () => { - test('sidebar launcher remains reachable while chat-debug is open', async ({ page }) => { - await page.goto('/sidebar'); + test('sidebar launcher remains reachable while chat-debug is open', async ({ + page, + }) => { + await openDemo(page, '/sidebar'); await expect(page.locator('chat-sidebar')).toBeAttached(); // Open chat-debug from the sidenav footer. - await page.locator('.chat-sidenav__debug').click(); + await openChatDevtools(page); // Debug auto-picks bottom dock because is present. const debugPanel = page.locator('.panel.panel--bottom'); await expect(debugPanel).toBeVisible(); // The edge-claim attribute on reflects the dock. - await expect(page.locator('html')).toHaveAttribute('data-ngaf-chat-debug', 'bottom'); + await expect(page.locator('html')).toHaveAttribute( + 'data-ngaf-chat-debug', + 'bottom' + ); // Sidebar launcher remains visible (the bottom dock did not cover it). // Click the actual + } @if (colorScheme() === 'dark') { - + } @else { - + } @@ -65,67 +91,106 @@ @if (agent.interrupt && agent.interrupt()) { -
- -
- } - @if (agent.subagents && agent.subagents().size > 0) { -
- -
+
+ +
+ } @if (agent.subagents && agent.subagents().size > 0) { +
+ +
} diff --git a/libs/chat/debug/src/lib/compositions/chat-debug/chat-debug.component.ts b/libs/chat/debug/src/lib/compositions/chat-debug/chat-debug.component.ts index 343d96857..9e807fc04 100644 --- a/libs/chat/debug/src/lib/compositions/chat-debug/chat-debug.component.ts +++ b/libs/chat/debug/src/lib/compositions/chat-debug/chat-debug.component.ts @@ -6,9 +6,9 @@ import { effect, ElementRef, HostListener, + afterNextRender, inject, input, - OnInit, output, signal, } from '@angular/core'; @@ -27,7 +27,9 @@ interface TabEntry { readonly kind: 'builtin-timeline' | 'builtin-state'; } -function hasHistory(agent: DebugAgent | DebugAgentWithHistory): agent is DebugAgentWithHistory { +function hasHistory( + agent: DebugAgent | DebugAgentWithHistory +): agent is DebugAgentWithHistory { return typeof (agent as DebugAgentWithHistory).history === 'function'; } @@ -39,299 +41,403 @@ function hasHistory(agent: DebugAgent | DebugAgentWithHistory): agent is DebugAg styles: [ CHAT_DEBUG_TOKENS, ` - :host { display: contents; } + :host { + display: contents; + } - /* ── Status pill launcher ─────────────────────────────────────── */ - .launcher { - position: fixed; - top: 20px; - right: 20px; - display: inline-flex; - align-items: center; - justify-content: center; - width: 36px; - height: 36px; - border-radius: var(--ngaf-chat-debug-radius-pill); - background: var(--ngaf-chat-debug-bg); - border: 1px solid var(--ngaf-chat-debug-border); - color: var(--ngaf-chat-debug-text); - cursor: pointer; - z-index: 990; - box-shadow: var(--ngaf-chat-debug-shadow-pill); - transition: background 120ms ease, border-color 120ms ease; - padding: 0; - } - .launcher:hover { - background: var(--ngaf-chat-debug-surface); - border-color: var(--ngaf-chat-debug-border-strong); - } - .launcher__dot { - width: 8px; - height: 8px; - border-radius: 50%; - background: var(--ngaf-chat-debug-success); - box-shadow: 0 0 8px color-mix(in srgb, var(--ngaf-chat-debug-success) 60%, transparent); - } - .launcher__dot--streaming { - background: var(--ngaf-chat-debug-accent); - box-shadow: 0 0 8px color-mix(in srgb, var(--ngaf-chat-debug-accent) 70%, transparent); - animation: chat-debug-pill-pulse 1.2s ease-in-out infinite; - } - @keyframes chat-debug-pill-pulse { - 0%, 100% { opacity: 1; transform: scale(1); } - 50% { opacity: 0.6; transform: scale(0.85); } - } + /* ── Status pill launcher ─────────────────────────────────────── */ + .launcher { + position: fixed; + top: 20px; + right: 20px; + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border-radius: var(--ngaf-chat-debug-radius-pill); + background: var(--ngaf-chat-debug-bg); + border: 1px solid var(--ngaf-chat-debug-border); + color: var(--ngaf-chat-debug-text); + cursor: pointer; + z-index: 990; + box-shadow: var(--ngaf-chat-debug-shadow-pill); + transition: background 120ms ease, border-color 120ms ease; + padding: 0; + } + .launcher:hover { + background: var(--ngaf-chat-debug-surface); + border-color: var(--ngaf-chat-debug-border-strong); + } + .launcher__dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--ngaf-chat-debug-success); + box-shadow: 0 0 8px + color-mix(in srgb, var(--ngaf-chat-debug-success) 60%, transparent); + } + .launcher__dot--streaming { + background: var(--ngaf-chat-debug-accent); + box-shadow: 0 0 8px + color-mix(in srgb, var(--ngaf-chat-debug-accent) 70%, transparent); + animation: chat-debug-pill-pulse 1.2s ease-in-out infinite; + } + @keyframes chat-debug-pill-pulse { + 0%, + 100% { + opacity: 1; + transform: scale(1); + } + 50% { + opacity: 0.6; + transform: scale(0.85); + } + } - /* ── Docked panel ─────────────────────────────────────────────── */ - .panel { - position: fixed; - background: var(--ngaf-chat-debug-bg); - color: var(--ngaf-chat-debug-text); - border: 1px solid var(--ngaf-chat-debug-border); - z-index: 991; - display: flex; - flex-direction: column; - box-shadow: var(--ngaf-chat-debug-shadow-panel); - animation: chat-debug-panel-enter 120ms ease; - } - .panel--right { - top: 0; - right: var(--ngaf-chat-sidebar-claim-right, 0); - bottom: 0; - width: var(--panel-size, 420px); - border-right: 0; - border-top-left-radius: var(--ngaf-chat-debug-radius-panel); - border-bottom-left-radius: var(--ngaf-chat-debug-radius-panel); - transform-origin: bottom right; - transition: right 200ms ease-out; - } - .panel--left { - top: 0; left: 0; bottom: 0; - width: var(--panel-size, 420px); - border-left: 0; - border-top-right-radius: var(--ngaf-chat-debug-radius-panel); - border-bottom-right-radius: var(--ngaf-chat-debug-radius-panel); - transform-origin: bottom left; - } - .panel--bottom { - left: 0; - right: var(--ngaf-chat-sidebar-claim-right, 0); - bottom: 0; - height: var(--panel-size, 40vh); - border-bottom: 0; - border-top-left-radius: var(--ngaf-chat-debug-radius-panel); - border-top-right-radius: var(--ngaf-chat-debug-radius-panel); - transform-origin: bottom right; - transition: right 200ms ease-out; - } - /* Mobile breakpoint: when an edge-claimer occupies the right and + /* ── Docked panel ─────────────────────────────────────────────── */ + .panel { + position: fixed; + background: var(--ngaf-chat-debug-bg); + color: var(--ngaf-chat-debug-text); + border: 1px solid var(--ngaf-chat-debug-border); + z-index: 991; + display: flex; + flex-direction: column; + box-shadow: var(--ngaf-chat-debug-shadow-panel); + animation: chat-debug-panel-enter 120ms ease; + } + .panel--right { + top: 0; + right: var(--ngaf-chat-sidebar-claim-right, 0); + bottom: 0; + width: var(--panel-size, 420px); + border-right: 0; + border-top-left-radius: var(--ngaf-chat-debug-radius-panel); + border-bottom-left-radius: var(--ngaf-chat-debug-radius-panel); + transform-origin: bottom right; + transition: right 200ms ease-out; + } + .panel--left { + top: 0; + left: 0; + bottom: 0; + width: var(--panel-size, 420px); + border-left: 0; + border-top-right-radius: var(--ngaf-chat-debug-radius-panel); + border-bottom-right-radius: var(--ngaf-chat-debug-radius-panel); + transform-origin: bottom left; + } + .panel--bottom { + left: 0; + right: var(--ngaf-chat-sidebar-claim-right, 0); + bottom: 0; + height: var(--panel-size, 40vh); + border-bottom: 0; + border-top-left-radius: var(--ngaf-chat-debug-radius-panel); + border-top-right-radius: var(--ngaf-chat-debug-radius-panel); + transform-origin: bottom right; + transition: right 200ms ease-out; + } + /* Mobile breakpoint: when an edge-claimer occupies the right and the device is narrow, the bottom strip's effective width is ~zero. Explicitly hide it so it doesn't intercept pointer events on the sidebar drawer. The chat-debug launcher remains visible. */ - @media (max-width: 767px) { - .panel--bottom { display: none; } - } - @keyframes chat-debug-panel-enter { - from { opacity: 0; transform: scale(0.96); } - to { opacity: 1; transform: scale(1); } - } + @media (max-width: 767px) { + .panel--bottom { + display: none; + } + } + @keyframes chat-debug-panel-enter { + from { + opacity: 0; + transform: scale(0.96); + } + to { + opacity: 1; + transform: scale(1); + } + } - .panel__header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 12px 16px; - border-bottom: 1px solid var(--ngaf-chat-debug-border); - min-height: 44px; - box-sizing: border-box; - } - .panel__title { - margin: 0; - font-size: 13px; - font-weight: 600; - letter-spacing: -0.01em; - color: var(--ngaf-chat-debug-text); - } - .panel__actions { display: flex; align-items: center; gap: 4px; } + .panel__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid var(--ngaf-chat-debug-border); + min-height: 44px; + box-sizing: border-box; + } + .panel__title { + margin: 0; + font-size: 13px; + font-weight: 600; + letter-spacing: -0.01em; + color: var(--ngaf-chat-debug-text); + } + .panel__actions { + display: flex; + align-items: center; + gap: 4px; + } - .panel__dock-group { - display: inline-flex; - gap: 0; - padding: 2px; - background: var(--ngaf-chat-debug-bg-deep); - border: 1px solid var(--ngaf-chat-debug-border); - border-radius: 6px; - } - .panel__dock-btn { - appearance: none; - background: transparent; - border: 0; - border-radius: 4px; - width: 24px; - height: 22px; - padding: 0; - color: var(--ngaf-chat-debug-text-subtle); - cursor: pointer; - display: inline-flex; - align-items: center; - justify-content: center; - transition: background 120ms ease, color 120ms ease; - } - .panel__dock-btn:hover { color: var(--ngaf-chat-debug-text); } - .panel__dock-btn.is-active { - background: var(--ngaf-chat-debug-border); - color: var(--ngaf-chat-debug-text); - } - .panel__dock-btn svg { display: block; } + .panel__dock-group { + display: inline-flex; + gap: 0; + padding: 2px; + background: var(--ngaf-chat-debug-bg-deep); + border: 1px solid var(--ngaf-chat-debug-border); + border-radius: 6px; + } + .panel__dock-btn { + appearance: none; + background: transparent; + border: 0; + border-radius: 4px; + width: 24px; + height: 22px; + padding: 0; + color: var(--ngaf-chat-debug-text-subtle); + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + transition: background 120ms ease, color 120ms ease; + } + .panel__dock-btn:hover { + color: var(--ngaf-chat-debug-text); + } + .panel__dock-btn.is-active { + background: var(--ngaf-chat-debug-border); + color: var(--ngaf-chat-debug-text); + } + .panel__dock-btn svg { + display: block; + } - .panel__close { - appearance: none; - background: transparent; - border: 0; - border-radius: 6px; - width: 26px; - height: 26px; - margin-left: 4px; - color: var(--ngaf-chat-debug-text-subtle); - cursor: pointer; - display: inline-flex; - align-items: center; - justify-content: center; - transition: background 120ms ease, color 120ms ease; - } - .panel__close:hover { - background: var(--ngaf-chat-debug-surface); - color: var(--ngaf-chat-debug-text); - } + .panel__close { + appearance: none; + background: transparent; + border: 0; + border-radius: 6px; + width: 26px; + height: 26px; + margin-left: 4px; + color: var(--ngaf-chat-debug-text-subtle); + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + transition: background 120ms ease, color 120ms ease; + } + .panel__close:hover { + background: var(--ngaf-chat-debug-surface); + color: var(--ngaf-chat-debug-text); + } - .panel__controls { - border-bottom: 1px solid var(--ngaf-chat-debug-border); - overflow-y: auto; - max-height: 50%; - background: var(--ngaf-chat-debug-bg); - } - .panel__controls:empty { display: none; } + .panel__controls { + border-bottom: 1px solid var(--ngaf-chat-debug-border); + overflow-y: auto; + max-height: 50%; + background: var(--ngaf-chat-debug-bg); + } + .panel__controls:empty { + display: none; + } - .panel__tabs { - display: flex; - gap: 4px; - border-bottom: 1px solid var(--ngaf-chat-debug-border); - padding: 0 12px; - background: var(--ngaf-chat-debug-bg); - } - .panel__tab { - appearance: none; - background: transparent; - border: 0; - border-bottom: 2px solid transparent; - padding: 10px 8px; - font: inherit; - font-size: 13px; - font-weight: 500; - color: var(--ngaf-chat-debug-text-muted); - cursor: pointer; - transition: color 120ms ease, border-color 120ms ease; - margin-bottom: -1px; - } - .panel__tab:hover { color: var(--ngaf-chat-debug-text); } - .panel__tab.is-active { - color: var(--ngaf-chat-debug-text); - border-bottom-color: var(--ngaf-chat-debug-accent); - } + .panel__tabs { + display: flex; + gap: 4px; + border-bottom: 1px solid var(--ngaf-chat-debug-border); + padding: 0 12px; + background: var(--ngaf-chat-debug-bg); + } + .panel__tab { + appearance: none; + background: transparent; + border: 0; + border-bottom: 2px solid transparent; + padding: 10px 8px; + font: inherit; + font-size: 13px; + font-weight: 500; + color: var(--ngaf-chat-debug-text-muted); + cursor: pointer; + transition: color 120ms ease, border-color 120ms ease; + margin-bottom: -1px; + } + .panel__tab:hover { + color: var(--ngaf-chat-debug-text); + } + .panel__tab.is-active { + color: var(--ngaf-chat-debug-text); + border-bottom-color: var(--ngaf-chat-debug-accent); + } - .panel__body { flex: 1; min-height: 0; overflow: hidden; display: flex; flex-direction: column; background: var(--ngaf-chat-debug-bg); } + .panel__body { + flex: 1; + min-height: 0; + overflow: hidden; + display: flex; + flex-direction: column; + background: var(--ngaf-chat-debug-bg); + } `, ], template: ` @if (!open() && launcher() === 'floating') { - - } @else if (agent(); as currentAgent) { -
-
-

Chat Debug

-
-
- - - -
- + } @else if (open() && agent(); as currentAgent) { +
+
+

Chat Devtools

+
+
+ + +
+
+
- @if (tabs().length > 1) { -
- @for (tab of tabs(); track tab.id) { - - } -
+ @if (tabs().length > 1) { +
+ @for (tab of tabs(); track tab.id) { + } +
+ } -
- @switch (activeTab()?.kind) { - @case ('builtin-timeline') { - @if (historyAgent(); as history) { - - } - } - @case ('builtin-state') { - - } - } -
+
+ @switch (activeTab()?.kind) { @case ('builtin-timeline') { @if + (historyAgent(); as history) { + + } } @case ('builtin-state') { + + } }
+
} `, }) -export class ChatDebugComponent implements OnInit { +export class ChatDebugComponent { readonly agent = input(null); readonly dock = input('right'); readonly defaultOpen = input(false); @@ -354,6 +460,7 @@ export class ChatDebugComponent implements OnInit { const agent = this.agent(); return agent && hasHistory(agent) ? agent : null; }); + private readonly hydrated = signal(false); /** Reads `agent.status()` reactively for the launcher dot. */ protected readonly isStreaming = computed(() => { @@ -365,14 +472,20 @@ export class ChatDebugComponent implements OnInit { if (!this.agent()) return []; return [ ...(this.historyAgent() - ? [{ id: 'timeline', label: 'Timeline', kind: 'builtin-timeline' } satisfies TabEntry] + ? [ + { + id: 'timeline', + label: 'Timeline', + kind: 'builtin-timeline', + } satisfies TabEntry, + ] : []), { id: 'state', label: 'State', kind: 'builtin-state' }, ]; }); protected readonly activeTab = computed(() => - this.tabs().find((t) => t.id === this.activeTabId()), + this.tabs().find((t) => t.id === this.activeTabId()) ); private readonly hostEl: ElementRef = inject(ElementRef); @@ -390,9 +503,27 @@ export class ChatDebugComponent implements OnInit { this.activeTabId.set(tabs[0].id); }); + // Restore once after Angular has assigned bound input values. Doing this + // synchronously in the constructor reads input defaults, then immediately + // writes those defaults to storage and masks a consumer-provided + // defaultOpen=true on first load. + afterNextRender(() => { + const restore = createPersistence(this.storageKey()); + const persistedOpen = restore.read('open'); + if (!this.open()) { + this.open.set(persistedOpen ?? this.defaultOpen()); + } + const persistedDock = restore.read('dock'); + this.dockState.set(persistedDock ?? this.dock()); + const persistedTab = restore.read('tab'); + if (persistedTab) this.activeTabId.set(persistedTab); + this.hydrated.set(true); + }); + // Write-through effect — reads each writable signal so subsequent // changes trigger a fresh run that writes them all to storage. effect(() => { + if (!this.hydrated()) return; const p = createPersistence(this.storageKey()); p.write('open', this.open()); p.write('dock', this.dockState()); @@ -426,18 +557,6 @@ export class ChatDebugComponent implements OnInit { }); } - ngOnInit(): void { - // Restore once after inputs are initialized; rebinding storageKey/defaults - // later is intentionally not supported. - const restore = createPersistence(this.storageKey()); - const persistedOpen = restore.read('open'); - this.open.set(persistedOpen ?? this.defaultOpen()); - const persistedDock = restore.read('dock'); - this.dockState.set(persistedDock ?? this.dock()); - const persistedTab = restore.read('tab'); - if (persistedTab) this.activeTabId.set(persistedTab); - } - setOpen(value: boolean): void { this.open.set(value); this.openChange.emit(value); diff --git a/libs/chat/src/lib/compositions/chat-debug-secondary.spec.ts b/libs/chat/src/lib/compositions/chat-debug-secondary.spec.ts index 588823e0c..6a85d5f19 100644 --- a/libs/chat/src/lib/compositions/chat-debug-secondary.spec.ts +++ b/libs/chat/src/lib/compositions/chat-debug-secondary.spec.ts @@ -30,6 +30,6 @@ describe('chat debug secondary entrypoint', () => { const fixture = TestBed.createComponent(Host); fixture.detectChanges(); - expect(fixture.nativeElement.querySelector('[aria-label="Chat debug"]')).not.toBeNull(); + expect(fixture.nativeElement.querySelector('[aria-label="Chat devtools"]')).not.toBeNull(); }); }); diff --git a/libs/chat/src/lib/compositions/chat-sidebar/chat-sidebar.component.ts b/libs/chat/src/lib/compositions/chat-sidebar/chat-sidebar.component.ts index a791b9fc2..2d17169c3 100644 --- a/libs/chat/src/lib/compositions/chat-sidebar/chat-sidebar.component.ts +++ b/libs/chat/src/lib/compositions/chat-sidebar/chat-sidebar.component.ts @@ -1,6 +1,15 @@ // libs/chat/src/lib/compositions/chat-sidebar/chat-sidebar.component.ts // SPDX-License-Identifier: MIT -import { Component, ChangeDetectionStrategy, effect, input, model, output } from '@angular/core'; +import { + Component, + ChangeDetectionStrategy, + DOCUMENT, + effect, + inject, + input, + model, + output, +} from '@angular/core'; import type { Agent } from '../../agent'; import type { ViewRegistry } from '@ngaf/render'; import { ChatComponent } from '../chat/chat.component'; @@ -99,10 +108,14 @@ export class ChatSidebarComponent { /** Two-way bound current model value. */ readonly selectedModel = model(''); readonly open = model(false); + /** Close the sidebar on Escape (default true). */ + readonly closeOnEscape = input(true); readonly pushContent = input(false); readonly replayRequested = output(); readonly forkRequested = output(); + private readonly document = inject(DOCUMENT); + constructor() { // Inject chat lib root CSS custom properties — see ChatComponent // for the full rationale. Idempotent + lifecycle-guaranteed. @@ -118,6 +131,18 @@ export class ChatSidebarComponent { delete html.dataset['ngafChatSidebar']; } }); + effect((onCleanup) => { + const closeOnEscape = this.closeOnEscape(); + const win = this.document.defaultView; + if (!win) return; + const handler = (e: KeyboardEvent): void => { + if (closeOnEscape && this.open() && e.key === 'Escape') { + this.closeWindow(); + } + }; + win.addEventListener('keydown', handler); + onCleanup(() => win.removeEventListener('keydown', handler)); + }); } toggle(): void { this.open.update((v) => !v); } diff --git a/libs/chat/src/lib/compositions/chat-sidenav/chat-sidenav.component.spec.ts b/libs/chat/src/lib/compositions/chat-sidenav/chat-sidenav.component.spec.ts index 8a0375168..bb1e0f594 100644 --- a/libs/chat/src/lib/compositions/chat-sidenav/chat-sidenav.component.spec.ts +++ b/libs/chat/src/lib/compositions/chat-sidenav/chat-sidenav.component.spec.ts @@ -6,35 +6,48 @@ import { describe, expect, it } from 'vitest'; import { ChatSidenavComponent } from './chat-sidenav.component'; import { mockAgent } from '../../testing/mock-agent'; -function render(opts: { - mode?: 'expanded' | 'collapsed' | 'drawer'; - open?: boolean; - threads?: unknown[] | null; - agent?: ReturnType | null; - debug?: boolean; -} = {}) { +function render( + opts: { + mode?: 'expanded' | 'collapsed' | 'drawer'; + open?: boolean; + threads?: unknown[] | null; + agent?: ReturnType | null; + debug?: boolean; + } = {} +) { const fixture = TestBed.createComponent(ChatSidenavComponent); if (opts.mode) fixture.componentRef.setInput('mode', opts.mode); if (opts.open !== undefined) fixture.componentRef.setInput('open', opts.open); - if (opts.threads !== undefined) fixture.componentRef.setInput('threads', opts.threads); - if (opts.agent !== undefined) fixture.componentRef.setInput('agent', opts.agent); - if (opts.debug !== undefined) fixture.componentRef.setInput('debug', opts.debug); + if (opts.threads !== undefined) + fixture.componentRef.setInput('threads', opts.threads); + if (opts.agent !== undefined) + fixture.componentRef.setInput('agent', opts.agent); + if (opts.debug !== undefined) + fixture.componentRef.setInput('debug', opts.debug); fixture.detectChanges(); return fixture; } describe('ChatSidenavComponent', () => { it('reflects mode via data-mode attribute', () => { - expect(render({ mode: 'expanded' }).nativeElement.getAttribute('data-mode')).toBe('expanded'); - expect(render({ mode: 'collapsed' }).nativeElement.getAttribute('data-mode')).toBe('collapsed'); - expect(render({ mode: 'drawer' }).nativeElement.getAttribute('data-mode')).toBe('drawer'); + expect( + render({ mode: 'expanded' }).nativeElement.getAttribute('data-mode') + ).toBe('expanded'); + expect( + render({ mode: 'collapsed' }).nativeElement.getAttribute('data-mode') + ).toBe('collapsed'); + expect( + render({ mode: 'drawer' }).nativeElement.getAttribute('data-mode') + ).toBe('drawer'); }); it('emits newChat when new-chat button clicked', () => { const fixture = render(); let emits = 0; fixture.componentInstance.newChat.subscribe(() => emits++); - const btn = fixture.nativeElement.querySelector('.chat-sidenav__action--new') as HTMLButtonElement; + const btn = fixture.nativeElement.querySelector( + '.chat-sidenav__action--new' + ) as HTMLButtonElement; btn.click(); expect(emits).toBe(1); }); @@ -43,7 +56,9 @@ describe('ChatSidenavComponent', () => { const fixture = render(); let emits = 0; fixture.componentInstance.searchOpened.subscribe(() => emits++); - const btn = fixture.nativeElement.querySelector('.chat-sidenav__action--search') as HTMLButtonElement; + const btn = fixture.nativeElement.querySelector( + '.chat-sidenav__action--search' + ) as HTMLButtonElement; btn.click(); expect(emits).toBe(1); }); @@ -52,7 +67,9 @@ describe('ChatSidenavComponent', () => { const fixture = render(); let emits = 0; fixture.componentInstance.searchOpened.subscribe(() => emits++); - window.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true })); + window.dispatchEvent( + new KeyboardEvent('keydown', { key: 'k', metaKey: true }) + ); expect(emits).toBe(1); }); @@ -60,7 +77,9 @@ describe('ChatSidenavComponent', () => { const fixture = render(); let emits = 0; fixture.componentInstance.searchOpened.subscribe(() => emits++); - window.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', ctrlKey: true })); + window.dispatchEvent( + new KeyboardEvent('keydown', { key: 'k', ctrlKey: true }) + ); expect(emits).toBe(1); }); @@ -71,14 +90,18 @@ describe('ChatSidenavComponent', () => { input.focus(); let emits = 0; fixture.componentInstance.searchOpened.subscribe(() => emits++); - input.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true, bubbles: true })); + input.dispatchEvent( + new KeyboardEvent('keydown', { key: 'k', metaKey: true, bubbles: true }) + ); expect(emits).toBe(0); document.body.removeChild(input); }); it('renders threads section when threads input is non-null', () => { const fixture = render({ threads: [{ id: 't1', title: 'First' }] }); - expect(fixture.nativeElement.querySelector('chat-thread-list')).not.toBeNull(); + expect( + fixture.nativeElement.querySelector('chat-thread-list') + ).not.toBeNull(); }); it('suppresses threads section when threads input is null', () => { @@ -89,40 +112,57 @@ describe('ChatSidenavComponent', () => { it('drawer mode: scrim click emits openChange(false)', () => { const fixture = render({ mode: 'drawer', open: true }); let lastOpen: boolean | undefined; - fixture.componentInstance.openChange.subscribe((v: boolean) => { lastOpen = v; }); - const scrim = fixture.nativeElement.querySelector('.chat-sidenav__scrim') as HTMLButtonElement; + fixture.componentInstance.openChange.subscribe((v: boolean) => { + lastOpen = v; + }); + const scrim = fixture.nativeElement.querySelector( + '.chat-sidenav__scrim' + ) as HTMLButtonElement; scrim.click(); expect(lastOpen).toBe(false); }); it('drawer mode: scrim NOT rendered when open is false', () => { const fixture = render({ mode: 'drawer', open: false }); - expect(fixture.nativeElement.querySelector('.chat-sidenav__scrim')).toBeNull(); + expect( + fixture.nativeElement.querySelector('.chat-sidenav__scrim') + ).toBeNull(); }); it('archivedThreads=null renders no archived heading', () => { const fixture = render({ threads: [{ id: 't1' }] }); - expect(fixture.nativeElement.querySelector('.chat-sidenav__archived')).toBeNull(); + expect( + fixture.nativeElement.querySelector('.chat-sidenav__archived') + ).toBeNull(); }); it('archivedThreads=[] renders the heading; clicking expands to show empty state', () => { const fixture = render({ threads: [{ id: 't1' }] }); fixture.componentRef.setInput('archivedThreads', []); fixture.detectChanges(); - const heading = fixture.nativeElement.querySelector('.chat-sidenav__archived-heading') as HTMLButtonElement; + const heading = fixture.nativeElement.querySelector( + '.chat-sidenav__archived-heading' + ) as HTMLButtonElement; expect(heading).not.toBeNull(); expect(heading.getAttribute('aria-expanded')).toBe('false'); heading.click(); fixture.detectChanges(); expect(heading.getAttribute('aria-expanded')).toBe('true'); - expect(fixture.nativeElement.querySelector('.chat-sidenav__archived-empty')).not.toBeNull(); + expect( + fixture.nativeElement.querySelector('.chat-sidenav__archived-empty') + ).not.toBeNull(); }); it('archivedThreads=[t1,t2] renders the heading; expanding shows a chat-thread-list with mode="archived"', () => { const fixture = render({ threads: [{ id: 't1' }] }); - fixture.componentRef.setInput('archivedThreads', [{ id: 'a1', title: 'A1' }, { id: 'a2', title: 'A2' }]); + fixture.componentRef.setInput('archivedThreads', [ + { id: 'a1', title: 'A1' }, + { id: 'a2', title: 'A2' }, + ]); fixture.detectChanges(); - const heading = fixture.nativeElement.querySelector('.chat-sidenav__archived-heading') as HTMLButtonElement; + const heading = fixture.nativeElement.querySelector( + '.chat-sidenav__archived-heading' + ) as HTMLButtonElement; heading.click(); fixture.detectChanges(); const lists = fixture.nativeElement.querySelectorAll('chat-thread-list'); @@ -132,60 +172,90 @@ describe('ChatSidenavComponent', () => { it('renders the collapse chevron in expanded mode with "Collapse sidenav" label', () => { const fixture = render({ mode: 'expanded' }); - const btn = fixture.nativeElement.querySelector('.chat-sidenav__toggle') as HTMLButtonElement; + const btn = fixture.nativeElement.querySelector( + '.chat-sidenav__toggle' + ) as HTMLButtonElement; expect(btn).not.toBeNull(); expect(btn.getAttribute('aria-label')).toBe('Collapse sidenav'); }); it('renders the expand chevron in collapsed mode with "Expand sidenav" label', () => { const fixture = render({ mode: 'collapsed' }); - const btn = fixture.nativeElement.querySelector('.chat-sidenav__toggle') as HTMLButtonElement; + const btn = fixture.nativeElement.querySelector( + '.chat-sidenav__toggle' + ) as HTMLButtonElement; expect(btn).not.toBeNull(); expect(btn.getAttribute('aria-label')).toBe('Expand sidenav'); }); it('omits the collapse chevron in drawer mode', () => { const fixture = render({ mode: 'drawer' }); - expect(fixture.nativeElement.querySelector('.chat-sidenav__toggle')).toBeNull(); + expect( + fixture.nativeElement.querySelector('.chat-sidenav__toggle') + ).toBeNull(); }); it('renders a topbar containing the new-chat button in expanded mode', () => { const fixture = render({ mode: 'expanded' }); - const topbar = fixture.nativeElement.querySelector('.chat-sidenav__topbar') as HTMLElement; + const topbar = fixture.nativeElement.querySelector( + '.chat-sidenav__topbar' + ) as HTMLElement; expect(topbar).not.toBeNull(); expect(topbar.querySelector('.chat-sidenav__action--new')).not.toBeNull(); }); it('search button is the only action in .chat-sidenav__actions row', () => { const fixture = render({ mode: 'expanded' }); - const actions = fixture.nativeElement.querySelector('.chat-sidenav__actions') as HTMLElement; + const actions = fixture.nativeElement.querySelector( + '.chat-sidenav__actions' + ) as HTMLElement; const buttons = actions.querySelectorAll('button'); expect(buttons.length).toBe(1); - expect(buttons[0].classList.contains('chat-sidenav__action--search')).toBe(true); + expect(buttons[0].classList.contains('chat-sidenav__action--search')).toBe( + true + ); }); it('drawer mode: renders a close button in the topbar that emits openChange(false)', () => { const fixture = render({ mode: 'drawer', open: true }); - const topbar = fixture.nativeElement.querySelector('.chat-sidenav__topbar') as HTMLElement; - const close = topbar.querySelector('.chat-sidenav__action--close') as HTMLButtonElement; + const topbar = fixture.nativeElement.querySelector( + '.chat-sidenav__topbar' + ) as HTMLElement; + const close = topbar.querySelector( + '.chat-sidenav__action--close' + ) as HTMLButtonElement; expect(close).not.toBeNull(); expect(close.getAttribute('aria-label')).toBe('Close conversations'); let lastOpen: boolean | undefined; - fixture.componentInstance.openChange.subscribe((v: boolean) => { lastOpen = v; }); + fixture.componentInstance.openChange.subscribe((v: boolean) => { + lastOpen = v; + }); close.click(); expect(lastOpen).toBe(false); }); it('non-drawer modes: no close button is rendered', () => { - expect(render({ mode: 'expanded' }).nativeElement.querySelector('.chat-sidenav__action--close')).toBeNull(); - expect(render({ mode: 'collapsed' }).nativeElement.querySelector('.chat-sidenav__action--close')).toBeNull(); + expect( + render({ mode: 'expanded' }).nativeElement.querySelector( + '.chat-sidenav__action--close' + ) + ).toBeNull(); + expect( + render({ mode: 'collapsed' }).nativeElement.querySelector( + '.chat-sidenav__action--close' + ) + ).toBeNull(); }); it('clicking the chevron in expanded mode emits modeChange="collapsed"', () => { const fixture = render({ mode: 'expanded' }); let last: string | undefined; - fixture.componentInstance.modeChange.subscribe((m: string) => { last = m; }); - const btn = fixture.nativeElement.querySelector('.chat-sidenav__toggle') as HTMLButtonElement; + fixture.componentInstance.modeChange.subscribe((m: string) => { + last = m; + }); + const btn = fixture.nativeElement.querySelector( + '.chat-sidenav__toggle' + ) as HTMLButtonElement; btn.click(); expect(last).toBe('collapsed'); }); @@ -193,8 +263,12 @@ describe('ChatSidenavComponent', () => { it('clicking the chevron in collapsed mode emits modeChange="expanded"', () => { const fixture = render({ mode: 'collapsed' }); let last: string | undefined; - fixture.componentInstance.modeChange.subscribe((m: string) => { last = m; }); - const btn = fixture.nativeElement.querySelector('.chat-sidenav__toggle') as HTMLButtonElement; + fixture.componentInstance.modeChange.subscribe((m: string) => { + last = m; + }); + const btn = fixture.nativeElement.querySelector( + '.chat-sidenav__toggle' + ) as HTMLButtonElement; btn.click(); expect(last).toBe('expanded'); }); @@ -202,24 +276,36 @@ describe('ChatSidenavComponent', () => { it('Cmd+B in expanded mode emits modeChange="collapsed"', () => { const fixture = render({ mode: 'expanded' }); let last: string | undefined; - fixture.componentInstance.modeChange.subscribe((m: string) => { last = m; }); - window.dispatchEvent(new KeyboardEvent('keydown', { key: 'b', metaKey: true })); + fixture.componentInstance.modeChange.subscribe((m: string) => { + last = m; + }); + window.dispatchEvent( + new KeyboardEvent('keydown', { key: 'b', metaKey: true }) + ); expect(last).toBe('collapsed'); }); it('Cmd+B in collapsed mode emits modeChange="expanded"', () => { const fixture = render({ mode: 'collapsed' }); let last: string | undefined; - fixture.componentInstance.modeChange.subscribe((m: string) => { last = m; }); - window.dispatchEvent(new KeyboardEvent('keydown', { key: 'b', metaKey: true })); + fixture.componentInstance.modeChange.subscribe((m: string) => { + last = m; + }); + window.dispatchEvent( + new KeyboardEvent('keydown', { key: 'b', metaKey: true }) + ); expect(last).toBe('expanded'); }); it('Cmd+B is a no-op in drawer mode', () => { const fixture = render({ mode: 'drawer' }); let emits = 0; - fixture.componentInstance.modeChange.subscribe(() => { emits++; }); - window.dispatchEvent(new KeyboardEvent('keydown', { key: 'b', metaKey: true })); + fixture.componentInstance.modeChange.subscribe(() => { + emits++; + }); + window.dispatchEvent( + new KeyboardEvent('keydown', { key: 'b', metaKey: true }) + ); expect(emits).toBe(0); }); @@ -227,7 +313,9 @@ describe('ChatSidenavComponent', () => { const fixture = render({ threads: [{ id: 't1' }] }); fixture.componentRef.setInput('archivedThreads', []); fixture.detectChanges(); - const heading = fixture.nativeElement.querySelector('.chat-sidenav__archived-heading') as HTMLButtonElement; + const heading = fixture.nativeElement.querySelector( + '.chat-sidenav__archived-heading' + ) as HTMLButtonElement; expect(heading.getAttribute('aria-expanded')).toBe('false'); heading.click(); fixture.detectChanges(); @@ -239,24 +327,38 @@ describe('ChatSidenavComponent', () => { it('projects=null renders no Projects section', () => { const fixture = render({ threads: [{ id: 't1' }] }); - expect(fixture.nativeElement.querySelector('.chat-sidenav__projects')).toBeNull(); + expect( + fixture.nativeElement.querySelector('.chat-sidenav__projects') + ).toBeNull(); }); it('projects=[p1,p2] renders the Projects section with two rows', () => { const fixture = render({ threads: [{ id: 't1' }] }); - fixture.componentRef.setInput('projects', [{ id: 'p1', name: 'Work' }, { id: 'p2', name: 'Personal' }]); + fixture.componentRef.setInput('projects', [ + { id: 'p1', name: 'Work' }, + { id: 'p2', name: 'Personal' }, + ]); fixture.detectChanges(); - expect(fixture.nativeElement.querySelector('.chat-sidenav__projects')).not.toBeNull(); - const rows = fixture.nativeElement.querySelectorAll('.chat-project-list__item'); + expect( + fixture.nativeElement.querySelector('.chat-sidenav__projects') + ).not.toBeNull(); + const rows = fixture.nativeElement.querySelectorAll( + '.chat-project-list__item' + ); expect(rows.length).toBe(2); }); it('selectedProjectId highlights the matching project row', () => { const fixture = render({ threads: [{ id: 't1' }] }); - fixture.componentRef.setInput('projects', [{ id: 'p1', name: 'Work' }, { id: 'p2', name: 'Personal' }]); + fixture.componentRef.setInput('projects', [ + { id: 'p1', name: 'Work' }, + { id: 'p2', name: 'Personal' }, + ]); fixture.componentRef.setInput('selectedProjectId', 'p2'); fixture.detectChanges(); - const rows = fixture.nativeElement.querySelectorAll('.chat-project-list__item'); + const rows = fixture.nativeElement.querySelectorAll( + '.chat-project-list__item' + ); expect(rows[0].getAttribute('data-active')).toBeNull(); expect(rows[1].getAttribute('data-active')).toBe('true'); }); @@ -264,11 +366,17 @@ describe('ChatSidenavComponent', () => { it('projectActions.create shows "+ New project" and emits newProjectRequested on click', () => { const fixture = render({ threads: [{ id: 't1' }] }); fixture.componentRef.setInput('projects', []); - fixture.componentRef.setInput('projectActions', { create: async () => ({ id: 'x' }) }); + fixture.componentRef.setInput('projectActions', { + create: async () => ({ id: 'x' }), + }); fixture.detectChanges(); let emits = 0; - fixture.componentInstance.newProjectRequested.subscribe(() => { emits++; }); - const btn = fixture.nativeElement.querySelector('.chat-project-list__new') as HTMLButtonElement; + fixture.componentInstance.newProjectRequested.subscribe(() => { + emits++; + }); + const btn = fixture.nativeElement.querySelector( + '.chat-project-list__new' + ) as HTMLButtonElement; expect(btn).not.toBeNull(); btn.click(); fixture.detectChanges(); @@ -281,39 +389,54 @@ describe('ChatSidenavComponent — footer slots', () => { @Component({ standalone: true, imports: [ChatSidenavComponent], - template: `L`, + template: `L`, }) class HostLeft {} TestBed.configureTestingModule({}); const fx = TestBed.createComponent(HostLeft); fx.detectChanges(); - const leftContainer = fx.nativeElement.querySelector('.chat-sidenav__footer-left'); + const leftContainer = fx.nativeElement.querySelector( + '.chat-sidenav__footer-left' + ); expect(leftContainer).toBeTruthy(); - expect(leftContainer.querySelector('[data-test="left-slot"]')?.textContent).toBe('L'); + expect( + leftContainer.querySelector('[data-test="left-slot"]')?.textContent + ).toBe('L'); }); it('renders [sidenavFooterRight] projected content in the right footer position', () => { @Component({ standalone: true, imports: [ChatSidenavComponent], - template: `R`, + template: `R`, }) class HostRight {} TestBed.configureTestingModule({}); const fx = TestBed.createComponent(HostRight); fx.detectChanges(); - const rightContainer = fx.nativeElement.querySelector('.chat-sidenav__footer-right'); + const rightContainer = fx.nativeElement.querySelector( + '.chat-sidenav__footer-right' + ); expect(rightContainer).toBeTruthy(); - expect(rightContainer.querySelector('[data-test="right-slot"]')?.textContent).toBe('R'); + expect( + rightContainer.querySelector('[data-test="right-slot"]')?.textContent + ).toBe('R'); }); it('renders the collapse toggle as the last child of the right footer container', () => { TestBed.configureTestingModule({ imports: [ChatSidenavComponent] }); const fx = TestBed.createComponent(ChatSidenavComponent); fx.detectChanges(); - const rightContainer = fx.nativeElement.querySelector('.chat-sidenav__footer-right'); + const rightContainer = fx.nativeElement.querySelector( + '.chat-sidenav__footer-right' + ); expect(rightContainer).toBeTruthy(); - const lastChild = rightContainer.children[rightContainer.children.length - 1]; + const lastChild = + rightContainer.children[rightContainer.children.length - 1]; expect(lastChild?.classList?.contains('chat-sidenav__toggle')).toBe(true); }); @@ -322,7 +445,9 @@ describe('ChatSidenavComponent — footer slots', () => { const fx = TestBed.createComponent(ChatSidenavComponent); fx.detectChanges(); const topbar = fx.nativeElement.querySelector('.chat-sidenav__topbar'); - expect(topbar?.querySelector('.chat-sidenav__action--collapse')).toBeFalsy(); + expect( + topbar?.querySelector('.chat-sidenav__action--collapse') + ).toBeFalsy(); }); it('clicking the new footer toggle emits modeChange', () => { @@ -331,7 +456,9 @@ describe('ChatSidenavComponent — footer slots', () => { fx.detectChanges(); let captured: string | null = null; fx.componentInstance.modeChange.subscribe((m) => (captured = m)); - const toggle = fx.nativeElement.querySelector('.chat-sidenav__toggle') as HTMLButtonElement; + const toggle = fx.nativeElement.querySelector( + '.chat-sidenav__toggle' + ) as HTMLButtonElement; toggle.click(); expect(captured).toBe('collapsed'); }); @@ -340,25 +467,33 @@ describe('ChatSidenavComponent — footer slots', () => { describe('ChatSidenavComponent — debug footer affordance', () => { it('omits the debug footer button when no agent is provided', () => { const fixture = render(); - expect(fixture.nativeElement.querySelector('.chat-sidenav__debug')).toBeNull(); + expect( + fixture.nativeElement.querySelector('.chat-sidenav__debug') + ).toBeNull(); }); it('omits the debug footer button when debug is disabled', () => { const fixture = render({ agent: mockAgent(), debug: false }); - expect(fixture.nativeElement.querySelector('.chat-sidenav__debug')).toBeNull(); + expect( + fixture.nativeElement.querySelector('.chat-sidenav__debug') + ).toBeNull(); }); it('renders a labeled debug button in expanded mode when an agent is provided', () => { const fixture = render({ mode: 'expanded', agent: mockAgent() }); - const button = fixture.nativeElement.querySelector('.chat-sidenav__debug') as HTMLButtonElement | null; + const button = fixture.nativeElement.querySelector( + '.chat-sidenav__debug' + ) as HTMLButtonElement | null; expect(button).not.toBeNull(); - expect(button?.getAttribute('aria-label')).toBe('Open chat debug'); - expect(button?.textContent?.trim()).toBe('Debug'); + expect(button?.getAttribute('aria-label')).toBe('Open chat devtools'); + expect(button?.textContent?.trim()).toBe('Devtools'); }); it('renders the debug button without visible label in collapsed mode', () => { const fixture = render({ mode: 'collapsed', agent: mockAgent() }); - const button = fixture.nativeElement.querySelector('.chat-sidenav__debug') as HTMLButtonElement | null; + const button = fixture.nativeElement.querySelector( + '.chat-sidenav__debug' + ) as HTMLButtonElement | null; expect(button).not.toBeNull(); expect(button?.textContent?.trim()).toBe(''); }); @@ -366,16 +501,24 @@ describe('ChatSidenavComponent — debug footer affordance', () => { it('marks the debug status dot as streaming while the agent is running', () => { const agent = mockAgent({ status: 'running' }); const fixture = render({ agent }); - expect(fixture.nativeElement.querySelector('.chat-sidenav__debug-dot--streaming')).not.toBeNull(); + expect( + fixture.nativeElement.querySelector('.chat-sidenav__debug-dot--streaming') + ).not.toBeNull(); }); }); describe('ChatSidenavComponent — New chat primary CTA', () => { it('renders the new-chat button with a monochrome text-color CTA token', () => { // Styles array is the second member of @Component decorator metadata. - const styles = (ChatSidenavComponent as unknown as { ɵcmp: { styles: string[] } }).ɵcmp.styles.join('\n'); + const styles = ( + ChatSidenavComponent as unknown as { ɵcmp: { styles: string[] } } + ).ɵcmp.styles.join('\n'); // Monochrome CTA: late-cascade block uses text/bg for contrast. - expect(styles).toMatch(/\.chat-sidenav__action\.chat-sidenav__action--new[^{]*\{[^}]*background:\s*var\(--ngaf-chat-text/); - expect(styles).toMatch(/\.chat-sidenav__action--new[^{]*\{[^}]*border-radius:\s*8px/); + expect(styles).toMatch( + /\.chat-sidenav__action\.chat-sidenav__action--new[^{]*\{[^}]*background:\s*var\(--ngaf-chat-text/ + ); + expect(styles).toMatch( + /\.chat-sidenav__action--new[^{]*\{[^}]*border-radius:\s*8px/ + ); }); }); diff --git a/libs/chat/src/lib/compositions/chat-sidenav/chat-sidenav.component.ts b/libs/chat/src/lib/compositions/chat-sidenav/chat-sidenav.component.ts index 752456167..792d42af4 100644 --- a/libs/chat/src/lib/compositions/chat-sidenav/chat-sidenav.component.ts +++ b/libs/chat/src/lib/compositions/chat-sidenav/chat-sidenav.component.ts @@ -39,10 +39,18 @@ type ChatDebugDock = 'right' | 'bottom' | 'left'; interface ChatDebugInstance { setOpen(value: boolean): void; setDock?(dock: ChatDebugDock): void; - replayRequested?: { subscribe(callback: (checkpointId: string) => void): OutputRefSubscription }; - forkRequested?: { subscribe(callback: (checkpointId: string) => void): OutputRefSubscription }; - openChange?: { subscribe(callback: (open: boolean) => void): OutputRefSubscription }; - dockChange?: { subscribe(callback: (dock: ChatDebugDock) => void): OutputRefSubscription }; + replayRequested?: { + subscribe(callback: (checkpointId: string) => void): OutputRefSubscription; + }; + forkRequested?: { + subscribe(callback: (checkpointId: string) => void): OutputRefSubscription; + }; + openChange?: { + subscribe(callback: (open: boolean) => void): OutputRefSubscription; + }; + dockChange?: { + subscribe(callback: (dock: ChatDebugDock) => void): OutputRefSubscription; + }; } @Component({ @@ -57,12 +65,12 @@ interface ChatDebugInstance { styles: [CHAT_HOST_TOKENS, CHAT_SIDENAV_STYLES], template: ` @if (mode() === 'drawer' && open()) { - + }
@@ -113,9 +139,18 @@ interface ChatDebugInstance { aria-label="Search conversations" title="Search conversations (⌘K)" > - Search @@ -126,66 +161,73 @@ interface ChatDebugInstance {
@if (projects() !== null) { -
-
Projects
- -
- } - - @if (threads() !== null) { -
-
Recent
+
+
Projects
+ +
+ } @if (threads() !== null) { +
+
Recent
+ +
+ } @if (archivedThreads() !== null) { +
+ + @if (archivedOpen()) { +
+ @if (archivedThreads()!.length === 0) { +
+ No archived conversations. +
+ } @else { -
- } - - @if (archivedThreads() !== null) { -
- - @if (archivedOpen()) { -
- @if (archivedThreads()!.length === 0) { -
No archived conversations.
- } @else { - - } -
}
+ } +
}
@@ -195,45 +237,68 @@ interface ChatDebugInstance {