From 614cfd6ddceb7825a1e26a98e14eb8ef360e12f9 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Fri, 1 May 2026 10:10:29 -0700 Subject: [PATCH 01/38] chore(workflow): upgrade npm CLI for trusted publishing OIDC support The 0.0.2 publish workflow run failed with 'error retrieving identity token' on @ngaf/licensing and @ngaf/partial-json, and a 404 on @ngaf/a2ui. Root cause: actions/setup-node@v6.3.0 with node-version: 22 ships npm 10.9.2, which has partial OIDC code paths but doesn't fully implement the trusted-publishing flow against npm registry's OIDC endpoint. npm 11.5.1+ is required for trusted publishing. Adding 'npm install -g npm@latest' before the publish step bumps the runner to a current release. Sources: - https://philna.sh/blog/2026/01/28/trusted-publishing-npm/ - https://github.com/npm/cli/issues/8730 - https://docs.npmjs.com/trusted-publishers/ Co-Authored-By: Claude Opus 4.7 --- .github/workflows/publish.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index eef1883eb..7272a60c1 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -29,6 +29,12 @@ jobs: - run: npm ci + # Trusted publishing requires npm CLI 11.5.1+. Node 22's bundled npm + # is 10.x which has partial OIDC support but doesn't fully implement + # the trusted-publishing flow against npm registry's OIDC endpoint. + - name: Upgrade npm to support trusted publishing + run: npm install -g npm@latest + - name: Lint, test, build publishable projects run: npx nx run-many -t lint,test,build --projects=$NPM_PUBLISHABLE_PROJECTS --skip-nx-cache From 570b031e0ba7c0e89b651f7397f3e27ee9e60c2e Mon Sep 17 00:00:00 2001 From: Brian Love Date: Fri, 1 May 2026 10:53:55 -0700 Subject: [PATCH 02/38] docs(chat): document Tailwind v4 requirement for compositions Verified against a fresh-install consumer of @ngaf/chat@0.0.2: without Tailwind configured (and without `@source "../node_modules/@ngaf/chat"`), ChatComponent's utility classes (flex, gap-3, max-w-[75%], md:flex, ...) are tree-shaken away and the chat collapses to a column of unstyled full-width blocks. The library does not ship a precompiled stylesheet, so this is a hard consumer-side requirement. Surface it explicitly: - Quickstart gets a Tailwind setup step between install and provider config. - Installation Requirements step calls out Tailwind v4 alongside Angular 20+ and Node 18+. - Tailwind CSS section is rewritten with concrete steps (postcss config, @import, @source) rather than just an `npm install` line. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../chat/getting-started/installation.mdx | 52 ++++++++++++++++++- .../docs/chat/getting-started/quickstart.mdx | 28 ++++++++++ 2 files changed, 78 insertions(+), 2 deletions(-) diff --git a/apps/website/content/docs/chat/getting-started/installation.mdx b/apps/website/content/docs/chat/getting-started/installation.mdx index a4fe8d4a1..d55ca2f01 100644 --- a/apps/website/content/docs/chat/getting-started/installation.mdx +++ b/apps/website/content/docs/chat/getting-started/installation.mdx @@ -14,6 +14,9 @@ The chat components read streaming state from `LangGraphAgent`, which is returne Required for the build toolchain and package installation. + +The `ChatComponent` and `ChatDebugComponent` compositions use Tailwind utility classes for layout. Consumers must have Tailwind v4 configured in their project and `@source "../node_modules/@ngaf/chat"` declared in their stylesheet. See [Tailwind CSS](#tailwind-css) below. Primitives-only consumers can skip this. + ## Install the package @@ -77,13 +80,58 @@ export const appConfig: ApplicationConfig = { ## Tailwind CSS -The composition components use Tailwind CSS utility classes for layout. If you are using compositions like `ChatComponent` or `ChatDebugComponent`, make sure Tailwind is configured in your project. + +Compositions like `ChatComponent` and `ChatDebugComponent` use Tailwind CSS utility classes (`flex`, `gap-3`, `max-w-[75%]`, `md:flex`, ...) for layout. **The library does not ship a precompiled stylesheet**, so without Tailwind the chat renders as a column of full-width boxes with no spacing or alignment. + +The primitives layer (e.g. `ChatMessagesComponent`, `ChatInputComponent` used directly) does not depend on Tailwind — it uses inline styles and CSS custom properties only. + + +If your project already uses Tailwind v4, skip ahead to [Scan the chat package](#scan-the-ngafchat-package). Otherwise: + + + ```bash npm install -D tailwindcss @tailwindcss/postcss ``` -The primitives layer does not depend on Tailwind -- it uses only inline styles and content projection. + + + +Create `.postcssrc.json` at the project root: + +```json +{ + "plugins": { + "@tailwindcss/postcss": {} + } +} +``` + + + + +```css +@import "tailwindcss"; + +/* Tell Tailwind to scan @ngaf/chat for utility classes used in the + composition templates. Without this, layout classes from the + library are tree-shaken away. */ +@source "../node_modules/@ngaf/chat"; +``` + + + + +### Scan the @ngaf/chat package + +Tailwind v4 auto-discovers source files in your project but ignores `node_modules` by default. Because the chat compositions ship their utility classes inside the published bundle, you must opt those files in with `@source`: + +```css +@source "../node_modules/@ngaf/chat"; +``` + +Without this directive, classes like `flex`, `w-10`, `gap-3`, and `max-w-[75%]` will not appear in your compiled CSS, and the chat will visibly collapse to an unstyled column. Restart `ng serve` after adding `.postcssrc.json` so the new PostCSS pipeline takes effect. ## Verify the Setup diff --git a/apps/website/content/docs/chat/getting-started/quickstart.mdx b/apps/website/content/docs/chat/getting-started/quickstart.mdx index ce0a2738e..bef42295e 100644 --- a/apps/website/content/docs/chat/getting-started/quickstart.mdx +++ b/apps/website/content/docs/chat/getting-started/quickstart.mdx @@ -13,6 +13,34 @@ Angular 20+ project with `@ngaf/agent` already configured. If you need setup hel npm install @ngaf/chat ``` + + + +`ChatComponent` ships its layout as Tailwind utility classes and does not include a precompiled stylesheet. If your project does not already use Tailwind v4, add it now: + +```bash +npm install -D tailwindcss @tailwindcss/postcss +``` + +Create `.postcssrc.json` at the project root: + +```json +{ + "plugins": { + "@tailwindcss/postcss": {} + } +} +``` + +Add the following to `src/styles.css`: + +```css +@import "tailwindcss"; +@source "../node_modules/@ngaf/chat"; +``` + +The `@source` directive opts the published `@ngaf/chat` bundle into Tailwind's class scan; without it the chat renders as a column of unstyled blocks. Restart `ng serve` after creating `.postcssrc.json`. See [Installation → Tailwind CSS](/docs/chat/getting-started/installation#tailwind-css) for details. + From 1b6b63033af8356ddda247fc8f979e0ace5e8a6c Mon Sep 17 00:00:00 2001 From: Brian Love Date: Fri, 1 May 2026 11:25:21 -0700 Subject: [PATCH 03/38] docs(specs): add chat library redesign design doc Design for production-ready chat UI overhaul: asymmetric message pattern (user bubble + assistant inline), three layout modes (embedded, popup, sidebar), shared chat-trace primitive driving tool calls / subagents / timeline, complete Tailwind removal with encapsulated component styles + optional global chat.css. Settled architecture decisions: - Three separate compositions over a unified mode-switching one. - Hybrid styling: component-encapsulated + CSS vars + optional global stylesheet for deep overrides. - In-place rewrite of ; coordinated breaking-change PR updates all 19 cockpit demos + libs/example-layouts + website docs in one shot. Ships as 0.0.3 (patch-only policy). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../specs/2026-05-01-chat-redesign-design.md | 470 ++++++++++++++++++ 1 file changed, 470 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-01-chat-redesign-design.md diff --git a/docs/superpowers/specs/2026-05-01-chat-redesign-design.md b/docs/superpowers/specs/2026-05-01-chat-redesign-design.md new file mode 100644 index 000000000..7d089ffae --- /dev/null +++ b/docs/superpowers/specs/2026-05-01-chat-redesign-design.md @@ -0,0 +1,470 @@ +# Chat library redesign — production-ready visual overhaul + +## Summary + +Replace the current `@ngaf/chat` visual surface with a redesigned, Tailwind-free, production-grade chat UI. The redesign: + +- Adopts an asymmetric message pattern (user = filled bubble, assistant = inline text with hover-revealed controls). +- Ships three top-level layout modes: embedded (``), floating popup (``), slide-in sidebar (``). +- Replaces all Tailwind utility-class usage with hand-written component-encapsulated CSS plus an optional global stylesheet. +- Restyles every adjacent surface — tool calls, subagents, timeline, threads, interrupt panel, debug composition, demo layouts — to a single coherent design system. +- Fixes the v0.0.2 publish-time friction (consumers no longer need Tailwind set up; chat renders correctly out of the box). + +This is a coordinated breaking change shipped as `@ngaf/chat@0.0.3`. All first-party consumers (cockpit demos, `libs/example-layouts`, website docs) are updated in the same PR. There is no compatibility shim. + +## Goals + +1. Production-grade visual quality across every chat surface (messages, input, traces, timeline, threads, debug, layout shells, popup/sidebar chrome). +2. Zero consumer setup to render correctly: `npm install @ngaf/chat`, drop `` in a template, it works. +3. Single coherent design language across all chat-adjacent components and demos. No mixed bordered cards / bubbles / Tailwind tokens. +4. Three layout modes covering the dominant chat-UI shapes (full-window, floating launcher, slide-in panel). +5. Theme override surface that scales: 90% of consumers tune CSS custom properties; deep restyling available via opt-in global stylesheet. + +## Non-Goals + +- Voice / push-to-talk wiring (CSS hook only). +- Attachment upload pipeline (UI scaffold only). +- Smart suggestions / agent-driven prompt generation. +- Code-block syntax highlighting (base styling + copy button only). +- Right-to-left language layout. +- Header chrome beyond title + close-X. +- Major-version semantics; release stays on patch (0.0.3) per project policy. + +## Design + +### Architecture decisions + +Three foundational decisions, settled during brainstorming: + +| # | Decision | Rationale | +|---|---|---| +| 1 | Three separate compositions (``, ``, ``) over one component with a `mode` input. | Tree-shakable, mirrors existing repo pattern (``, ``). Each component has a single layout responsibility. | +| 2 | Hybrid styling: component-encapsulated styles + CSS custom properties on `:host` + optional global `chat.css`. | Renders out of the box (Layer 1 + 2), exposes copilotkit-style global-class deep-override surface (Layer 3) without making it a setup step. Improves on copilotkit's React-bundler-side-effect model. | +| 3 | In-place rewrite of ``, no compatibility shim, ship as 0.0.3. | At 0.0.x stage breaking changes are expected. Cockpit demos updated in the same PR. Patch-only versioning policy applies regardless of break magnitude. | + +### Component inventory + +#### Top-level compositions + +| Selector | Mode | Replaces | +|---|---|---| +| `` | embedded — fills its container | Existing `` (rewritten in place) | +| `` | floating — bottom-right launcher + window (24rem × 600px) | New | +| `` | slide-in — right-edge panel (28rem) | New | +| `` | unchanged surface; restyled internals | Existing | +| `` | vertical history walk; horizontal-slider variant **dropped** | Existing | +| `` | restyled to match trace aesthetic | Existing | + +#### Internal layout primitives + +| Selector | Purpose | New / changed | +|---|---|---| +| `chat-window` | Header + body + input footer slot. Used by all three top-level modes. | New | +| `chat-launcher-button` | Floating circular button for popup mode. | New | +| `chat-message` | Single message renderer with user-bubble / assistant-inline variants. | New | +| `chat-trace` | Collapsible label + left-border indented content. Used for tool calls, subagents, timeline entries. | New | +| `chat-suggestions` | Render initial prompt chips when empty. | New | + +#### Existing primitives — kept, all restyled, all Tailwind-free + +| Old name | New name | Notes | +|---|---|---| +| `chat-messages` | `chat-message-list` | Selector + class renamed for clarity. | +| `chat-input` | `chat-input` | Rewritten to pill design (20px radius, 75px min-height, inline send + control slots). | +| `chat-typing-indicator` | `chat-typing-indicator` | Replaced with 3-dot animation. | +| `chat-error` | `chat-error` | Restyled error callout. | +| `chat-interrupt` | `chat-interrupt` | Restyled warning callout. | +| `chat-thread-list` | `chat-thread-list` | Restyled list rows; optional "+ New thread" header button. | +| `chat-tool-calls` | `chat-tool-calls` | Headless data primitive unchanged; default projected template now uses `chat-trace`. | +| `chat-subagents` | `chat-subagents` | Same. | +| `chat-tool-call-card` | `chat-tool-call-card` | Composition rewritten to use `chat-trace` as visual base. Tailwind removed. | +| `chat-subagent-card` | `chat-subagent-card` | Same. | +| `chat-timeline` | `chat-timeline` | Headless data primitive unchanged; default visualization adopts vertical history walk. | +| `chat-generative-ui` | `chat-generative-ui` | Behavior unchanged; wrapper restyled. | + +#### Removed + +- All Tailwind utility-class usage in chat library output. +- `CHAT_THEME_STYLES`, `CHAT_MARKDOWN_STYLES` public exports (replaced by per-component scoped styles + token file + optional global `chat.css`). +- Avatar block in assistant messages (assistant goes bubble-less, no avatar — matches copilotkit philosophy). +- Horizontal-slider variant of `chat-timeline-slider` (vertical history walk supersedes it). + +#### Styles directory restructure + +``` +libs/chat/src/lib/styles/ +├── chat-tokens.ts # CSS custom property declarations + keyframes +├── chat-window.styles.ts # per-component scoped CSS strings +├── chat-message.styles.ts +├── chat-input.styles.ts +├── chat-trace.styles.ts +├── chat-message-list.styles.ts +├── chat-thread-list.styles.ts +├── chat-typing-indicator.styles.ts +├── chat-error.styles.ts +├── chat-interrupt.styles.ts +├── chat-launcher-button.styles.ts +├── chat-suggestions.styles.ts +├── chat-generative-ui.styles.ts +├── chat-markdown.styles.ts # markdown rendering rules +└── chat.css # Layer-3 optional global stylesheet (shipped via package exports map) +``` + +The `:host`-applied tokens are emitted via a `CHAT_HOST_TOKENS` constant imported into every chat component's `styles` array. The same tokens (with `:root` selector) appear in `chat.css` for consumers who want app-level overrides. + +### Visual design system + +#### Color tokens + +``` +Light theme (default): + --ngaf-chat-bg: rgb(255, 255, 255) + --ngaf-chat-surface: rgb(255, 255, 255) + --ngaf-chat-surface-alt: rgb(251, 251, 251) + --ngaf-chat-primary: rgb(28, 28, 28) + --ngaf-chat-on-primary: rgb(255, 255, 255) + --ngaf-chat-text: rgb(28, 28, 28) + --ngaf-chat-text-muted: rgb(115, 115, 115) + --ngaf-chat-separator: rgb(229, 229, 229) + --ngaf-chat-muted: rgb(200, 200, 200) + --ngaf-chat-error-bg: #fef2f2 + --ngaf-chat-error-border: #fecaca + --ngaf-chat-error-text: #dc2626 + --ngaf-chat-warning-bg: #fffbeb + --ngaf-chat-warning-text: #b45309 + --ngaf-chat-success: #16a34a + --ngaf-chat-shadow-sm: 0 1px 2px rgba(0,0,0,.05) + --ngaf-chat-shadow-md: 0 4px 6px -1px rgba(0,0,0,.10), 0 2px 4px -1px rgba(0,0,0,.06) + --ngaf-chat-shadow-lg: 0 10px 15px -3px rgba(0,0,0,.10), 0 4px 6px -2px rgba(0,0,0,.05) + +Dark theme (auto via prefers-color-scheme; opt-out via [data-ngaf-chat-theme="light"]; opt-in via [data-ngaf-chat-theme="dark"]): + --ngaf-chat-bg: rgb(17, 17, 17) + --ngaf-chat-surface: rgb(28, 28, 28) + --ngaf-chat-surface-alt: rgb(44, 44, 44) + --ngaf-chat-primary: rgb(255, 255, 255) + --ngaf-chat-on-primary: rgb(28, 28, 28) + --ngaf-chat-text: rgb(245, 245, 245) + --ngaf-chat-text-muted: rgb(160, 160, 160) + --ngaf-chat-separator: rgb(45, 45, 45) + --ngaf-chat-muted: rgb(60, 60, 60) + --ngaf-chat-error-bg: rgb(45, 21, 21) + --ngaf-chat-error-text: #fca5a5 + --ngaf-chat-warning-bg: rgb(45, 35, 21) + --ngaf-chat-warning-text: #fbbf24 + --ngaf-chat-success: #4ade80 +``` + +#### Geometry tokens + +``` +--ngaf-chat-radius-bubble: 15px +--ngaf-chat-radius-input: 20px +--ngaf-chat-radius-card: 8px +--ngaf-chat-radius-button: 8px +--ngaf-chat-radius-launcher: 9999px +--ngaf-chat-max-width: 48rem +``` + +#### Typography tokens + +``` +--ngaf-chat-font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji" +--ngaf-chat-font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace +--ngaf-chat-font-size: 1rem +--ngaf-chat-font-size-sm: 0.875rem +--ngaf-chat-font-size-xs: 0.75rem +--ngaf-chat-line-height: 1.6 +--ngaf-chat-line-height-tight: 1.5 +``` + +#### Spacing scale + +``` +--ngaf-chat-space-1: 4px +--ngaf-chat-space-2: 8px +--ngaf-chat-space-3: 12px +--ngaf-chat-space-4: 16px +--ngaf-chat-space-5: 20px +--ngaf-chat-space-6: 24px +--ngaf-chat-space-8: 32px +``` + +#### Animations (defined in `chat-tokens.ts` as keyframes) + +- `ngaf-chat-spin` — 1s linear infinite (spinners) +- `ngaf-chat-typing-dot` — 1.4s ease-in-out infinite (3-dot indicator) +- `ngaf-chat-pulse` — 2s cubic-bezier infinite (recording / loading state hooks) + +### Message rendering + +#### User message +- Right-aligned filled bubble. +- `background: var(--ngaf-chat-primary); color: var(--ngaf-chat-on-primary)`. +- `max-width: 80%; padding: 8px 12px; border-radius: var(--ngaf-chat-radius-bubble)` (15px all corners, no asymmetric tail). +- `white-space: pre-wrap; line-height: var(--ngaf-chat-line-height-tight)`. +- 1.5rem top margin when following an assistant message; 0.5rem otherwise. + +#### Assistant message +- No bubble, no avatar, no left-padding offset. Full-width text up to `--ngaf-chat-max-width`. +- `color: var(--ngaf-chat-text); line-height: var(--ngaf-chat-line-height)`. +- `position: relative` wrapper hosts hover-revealed controls. +- 1.5rem top margin between turns. + +#### Hover-revealed assistant controls +- Container: `position: absolute; left: 0; bottom: -28px; display: flex; gap: 1rem; opacity: 0; transition: opacity 200ms ease`. +- `opacity: 1` on `.assistant-message:hover`, on `.assistant-message:focus-within`, and unconditionally on viewports `<= 768px`. +- Always-visible on the most recent assistant message. +- Default buttons: copy, regenerate. Slot via `` for replacement/extension. +- Buttons: 20×20, transparent bg, primary-color stroke, `transform: scale(1.05)` on hover. + +#### Streaming behavior +- `chat-streaming-md` unchanged structurally. +- Subtle blinking caret (`▍`, CSS `::after`, 1.2s blink) appended on the **last** assistant message while `agent.isLoading()`. +- Tool-call traces and subagent-activity traces render **above** the markdown body, inside the assistant message wrapper, so the read order is "thinking → answer". + +#### Tool / system messages +- Tool messages route through `chat-trace`; never render as standalone rows. +- System messages: centered, italic, `--ngaf-chat-text-muted`, `--ngaf-chat-font-size-xs`, generous vertical padding. + +#### Empty state +- Vertically + horizontally centered. +- Optional headline + subline + suggestion chips, all content-projected via ``. + +#### Markdown +- h1 1.5em bold, h2 1.25em 600, h3 1.1em, paragraph line-height 1.75, links underlined. +- Code blocks: `--ngaf-chat-surface-alt` background, `--ngaf-chat-font-mono`, copy button top-right, language label top-left. +- Inline code: subtle bg tint, monospace. + +### Input composer + +#### Container + pill +- Wrapper `.chat-input-container`: `background: var(--ngaf-chat-bg); padding: 0 0 15px 0`. Hosts banners, attachment queue, the pill, and an optional powered-by footer. +- Pill `.chat-input`: `background: var(--ngaf-chat-surface-alt); border: 1px solid var(--ngaf-chat-separator); border-radius: var(--ngaf-chat-radius-input); padding: 12px 14px; min-height: 75px; width: 95%; margin: 0 auto; cursor: text`. +- `:focus-within` deepens border to `--ngaf-chat-text-muted`. + +#### Textarea +- Auto-resizes (1 row min, 6 rows max, scroll after). CSS `field-sizing: content` with JS fallback observing scrollHeight. +- `border: 0; outline: 0; background: transparent; resize: none; width: 100%`. +- `font-size: var(--ngaf-chat-font-size-sm); line-height: 1.5rem; color: var(--ngaf-chat-text)`. +- Placeholder: `var(--ngaf-chat-text-muted)`, `opacity: 1`. +- Custom scrollbar (6px, rounded, transparent track). + +#### Controls row (absolute bottom-right inside pill) +- Send (default 24×24, primary stroke, scale-up hover, disabled → muted). +- Optional left-side controls via `[chatInputLeading]` slot. +- Optional right-side controls via `[chatInputTrailing]` slot. +- `.recording` state hooks (red bg + `ngaf-chat-pulse`) wired but inactive — gated by `enableVoice` input default `false`. + +#### Submit +- `submitOnEnter` input default `true`. Enter submits; Shift+Enter inserts newline. +- Disabled computed from `agent.isLoading()` and empty content. +- On submit emits `submit` output and calls `agent.submit({ message })` if bound. + +#### Attachments queue (slot) +- Renders above the pill when files are queued. Horizontal scroll-row of 64×64 thumbnail tiles. Dismiss-X on hover. Pure UI scaffold; no upload pipeline. + +#### Banners +- ``: `--ngaf-chat-error-bg`, `--ngaf-chat-error-border`, `--ngaf-chat-error-text`, card radius, padding 8/12. Icon + message + dismiss-X. +- ``: `--ngaf-chat-warning-bg`, `--ngaf-chat-warning-text`, same shape, action-button slot (Resume / Cancel default templates). +- Both render immediately above the pill, inside the input container. + +#### Powered-by footer (optional, off by default) +- Centered 12px text, `--ngaf-chat-muted`. Reserved hook. + +### Window primitive + three layout modes + +#### `` (internal) +``` +chat-window +├─ .chat-window__header (optional, [chatHeader] slot) +├─ .chat-window__body (flex: 1; min-height: 0; overflow hidden) +│ ├─ chat-message-list +│ └─ chat-typing-indicator +└─ .chat-window__footer (interrupt/error banners + chat-input) +``` +- Header 56px, hairline bottom border, padding `0 24px`, font-weight 500, optional 35×35 close-X at right. +- Body: scrolls internally with the auto-scroll-to-bottom logic from current ``. +- Custom 6px scrollbar. + +#### `` (embedded) +- `:host { display: flex; flex-direction: column; height: 100%; }`. Consumer controls sizing. +- No chrome around ``. +- Header opt-in via `[chatHeader]` slot (default no header). + +```html + +``` + +#### `` +- Fixed bottom-right of viewport. Two visible elements: + 1. `` — circular 56×56 at `bottom: 1rem; right: 1rem`, primary background, chat icon. Toggles open state. + 2. Window — 24rem × 600px on `≥640px`, full-screen below. `position: fixed; bottom: 5rem; right: 1rem; border-radius: 0.75rem; box-shadow: 0 5px 40px rgba(0,0,0,.16)`. +- Open animation 200ms ease-out: `transform: scale(.95) translateY(20px); opacity: 0` → `transform: scale(1) translateY(0); opacity: 1`. Closing reverses, then `pointer-events: none`. +- Header default: title + close-X (`[chatHeader]` slot for title content). + +```html + + My Assistant + +``` + +#### `` +- Fixed right edge. Window 28rem wide on `≥640px`, full-width below. +- `position: fixed; top: 0; right: 0; bottom: 0; border-radius: 0`. Full-height shadow on left side. +- Open animation 200ms ease-out: `transform: translateX(100%)` → `transform: translateX(0)`. No opacity fade. +- `pushContent` input (default `false`). When `true`, the sidebar wraps the user's app content via `` and shifts it leftward via `margin-right` transition when opened. + +```html + + Assistant +
...app content...
+
+``` + +#### Shared API (popup + sidebar) +- `[(open)]` two-way binding (`WritableSignal` consumer side). +- Imperative `toggle()`, `openWindow()`, `closeWindow()` methods. +- Default closed. + +#### Mobile +- `<640px`: popup goes full-screen; sidebar goes full-width slide-in. +- `<=768px`: hover-revealed message controls become always-visible (matches copilotkit's `@media` rule). + +### Trace primitive — drives tool calls, subagents, timeline + +#### `` +Single primitive serving three uses. + +``` +chat-trace +├─ .chat-trace__header (button, click toggles) +│ ├─ chevron icon (rotates 90° expanded, 200ms ease) +│ ├─ status icon (pending/running/done/error — content-projected) +│ ├─ +│ └─ optional metadata badge (right-aligned) +└─ .chat-trace__content + └─ +``` + +- Header: `display: flex; align-items: center; gap: 0.25rem; cursor: pointer; user-select: none; font-size: var(--ngaf-chat-font-size-sm); color: var(--ngaf-chat-text-muted)`. +- Content: `padding-left: 1rem; padding-top: 0.375rem; margin-left: 0.375rem; border-left: 1px solid var(--ngaf-chat-separator); max-height: 250px; overflow: auto`. +- States: `running` adds `ngaf-chat-pulse` to label; `done` shows muted check; `error` switches to error-color text + icon. No outer border, no background fill. + +Inputs: +- `expanded: WritableSignal` — defaults `false`; auto-opens to `true` on `state="running"`; auto-collapses 200ms after transitioning to `done`. +- `state: 'pending' | 'running' | 'done' | 'error'`. + +#### Tool calls +`` renders ``: +- Label: tool icon + monospace `{toolName}` + status word. +- Expanded: stacked `Inputs` / `Output` sections, each with 11px UPPERCASE label, `
` body using `--ngaf-chat-font-mono` and `--ngaf-chat-font-size-xs`, `whitespace: pre-wrap`. No background fill.
+
+`` headless primitive renders one card per tool call by default.
+
+In assistant turns containing `tool_use` blocks, traces stack **above** the markdown body, inside the assistant message wrapper.
+
+#### Subagents
+`` renders ``:
+- Label: agent icon + "Subagent" + monospace tool-call id chip + status pill.
+- Pill colors: pending=neutral, running=warning + pulse, complete=success, error=error.
+- Expanded: message count line + "Latest message" block matching tool-call layout.
+
+`` headless primitive default-renders these cards.
+
+Active (non-complete, non-error) subagents render stacked above the assistant turn currently producing them.
+
+#### Timeline
+`` becomes a vertical history walk:
+- Each checkpoint = a ``-shaped entry: chevron + timestamp + short summary, indented continuation showing state diff when expanded.
+- A single shared left-border runs through all checkpoints (container `border-left`).
+- Active checkpoint: primary-color chevron + bold label.
+- Hover/click emits `checkpointSelected`.
+- Optional 32px footer below the list with text-button controls (jump-to-latest, replay).
+- Horizontal-slider variant **dropped**.
+
+#### Thread list
+`` data API kept; default projected template restyled:
+- 36px row, `padding: 8px 12px; border-radius: var(--ngaf-chat-radius-button); cursor: pointer`.
+- Active: `background: var(--ngaf-chat-surface-alt); font-weight: 500`.
+- Hover: `background: color-mix(in srgb, var(--ngaf-chat-text) 5%, transparent)`.
+- Single-line ellipsis. No outer borders, no chevrons.
+- Optional "+ New thread" button at top: full-width, dashed border, primary-color text, hover-fills.
+
+In ``, optional collapsed thread-list slot (above the message body), labeled "Threads ▾".
+
+#### Interrupt panel
+`` becomes a content card matching trace aesthetic:
+- `--ngaf-chat-warning-bg`, `border-left: 3px solid var(--ngaf-chat-warning-text)`, `padding: 12px 16px; border-radius: var(--ngaf-chat-radius-card)`.
+- Title row: warning icon + "Agent paused" + dismiss-X.
+- Body: interrupt prompt.
+- Action row: content-projected buttons (Resume / Cancel defaults).
+
+#### Debug composition
+`` keeps its public surface. Inner cards (`debug-checkpoint-card`, `debug-controls`, `debug-detail`, `debug-state-diff`, `debug-state-inspector`, `debug-summary`, `debug-timeline`) all rewritten to use trace pattern + new tokens. Tailwind utility classes removed throughout.
+
+### Migration & downstream updates
+
+#### Version
+`@ngaf/chat@0.0.3` (patch only, per project policy). Other libs only bump if their source changed.
+
+#### Public API breaking changes (changelog)
+- `` template/visual API changed: no avatar slot; asymmetric message layout; slot names `chatEmptyState` / `chatMessageControls`.
+- `` selector + `ChatMessagesComponent` class renamed to `` / `ChatMessageListComponent`. Old name removed.
+- `` template restyled (visual change only, no API change).
+- `chat-timeline-slider` — horizontal-slider input/output API removed; vertical only.
+- `CHAT_THEME_STYLES` and `CHAT_MARKDOWN_STYLES` removed from public API. Migration: override `--ngaf-chat-*` custom properties or `import '@ngaf/chat/chat.css'`.
+- All Tailwind utility classes removed from chat output. Consumers without Tailwind setup (which were already broken on 0.0.2) now render correctly.
+
+#### Cockpit demos updated (19)
+- `cockpit/chat/{messages,input,threads,tool-calls,subagents,timeline,interrupts,theming,debug,generative-ui,a2ui}/angular`
+- `cockpit/langgraph/{streaming,persistence,memory,interrupts,durable-execution,time-travel,subgraphs,deployment-runtime}/angular`
+
+For each: replace Tailwind classes in local templates, swap `` → ``, drop dependence on `CHAT_THEME_STYLES` / `CHAT_MARKDOWN_STYLES`, regenerate any demo screenshots referenced by the website.
+
+#### libs/example-layouts
+- `example-chat-layout.component` and `example-split-layout.component` rewritten: Tailwind classes (`flex flex-col md:flex-row`, `border-gray-800`, `w-72`) replaced with hand-written CSS in `styles`, using `--ngaf-chat-*` tokens for colors/borders.
+
+#### Smoke app + PR #157 docs revert
+- The Tailwind-required documentation added in PR #157 is **superseded** by this redesign. As part of this work:
+  - Revert `apps/website/content/docs/chat/getting-started/installation.mdx` Tailwind section.
+  - Revert quickstart.mdx Tailwind step.
+  - New installation.mdx flow: `npm install @ngaf/chat`. Done.
+  - Optional consumer step: `import '@ngaf/chat/chat.css'` in global styles.
+
+#### Website docs
+- `getting-started/installation.mdx` — remove Tailwind section; add Theming section (CSS custom properties + optional global stylesheet).
+- `getting-started/quickstart.mdx` — remove Tailwind step.
+- `guides/theming.mdx` — rewrite to document `--ngaf-chat-*` tokens, light/dark modes, attribute override, optional layer-3 stylesheet.
+- Component reference pages — regenerated to match new APIs.
+- New pages: `components/chat-popup.mdx`, `components/chat-sidebar.mdx`, `components/chat-trace.mdx`, `guides/layout-modes.mdx`.
+
+#### Tests
+- All `*.component.spec.ts` for restructured/renamed components updated. Visual specs (computed style assertions) refreshed against new tokens.
+- New specs: ``, ``, ``, ``, asymmetric message templates.
+- Conformance tests against the public chat API kept passing.
+- `cockpit/chat/footprint.spec.ts` and `cockpit/chat/matrix.spec.ts` updated.
+
+## Risks & mitigations
+
+| Risk | Mitigation |
+|---|---|
+| Breaking-change blast radius across 19 demos + docs + tests is large; high merge-conflict risk if work is split. | Land as a single coordinated PR. Sequence within the PR: tokens → primitives → compositions → demos → docs. |
+| Removing Tailwind from chat library means cockpit demos that *also* use Tailwind elsewhere keep working, but `libs/example-layouts` cannot rely on Tailwind anymore. | `example-layouts` is the only remaining Tailwind-heavy first-party consumer; rewriting both layouts in the same PR contains the change. |
+| CSS custom property `--ngaf-chat-*` rename breaks any consumer overriding `--chat-*` from 0.0.2. | Document in changelog. Consumers running on 0.0.2 had a broken render anyway; this is a real but low-cost rename. |
+| Asymmetric message layout removes the assistant avatar consumers may expect. | Documented behavior change. Avatar can be re-added by a consumer via the `chatMessageControls` / `chatEmptyState` slots if needed. |
+| The new `chat-trace` indented-content pattern may not handle very long tool outputs gracefully. | Content has `max-height: 250px` and internal scroll, matching copilotkit. |
+| `field-sizing: content` for textarea autosize lacks support in older browsers. | JS fallback observing scrollHeight is part of the input rewrite. |
+
+## Out of scope (explicit)
+
+- Voice / push-to-talk (CSS hooks only, no integration).
+- Attachments upload pipeline (UI scaffold only).
+- Smart suggestion auto-generation.
+- Code-block syntax highlighting beyond base styling + copy button.
+- RTL layout support.
+- Header chrome beyond title + close-X.
+- Modifying the minting service (proprietary, excluded).
+
+## Approval gate
+
+Once this spec is approved, hand off to the `superpowers:writing-plans` skill to produce the implementation plan. The plan will sequence the work as: design tokens → primitive rewrites → composition rewrites → cockpit demo updates → docs → tests → release notes.

From a14184aff39feca01367fa59cabfe628de280741 Mon Sep 17 00:00:00 2001
From: Brian Love 
Date: Fri, 1 May 2026 11:37:11 -0700
Subject: [PATCH 04/38] docs(plans): add chat redesign implementation plan

56-task plan covering tokens (Phase 1), new primitives (Phase 2),
existing primitive rewrites (Phase 3), trace-based card rewrites
(Phase 4), top-level compositions (Phase 5), debug + interrupt
panel (Phase 6), library cleanup + 0.0.3 bump (Phase 7),
example-layouts (Phase 8), 19 cockpit demos (Phase 9), website
docs (Phase 10), and verification + PR (Phase 11).

Co-Authored-By: Claude Opus 4.7 (1M context) 
---
 .../plans/2026-05-01-chat-redesign.md         | 3571 +++++++++++++++++
 1 file changed, 3571 insertions(+)
 create mode 100644 docs/superpowers/plans/2026-05-01-chat-redesign.md

diff --git a/docs/superpowers/plans/2026-05-01-chat-redesign.md b/docs/superpowers/plans/2026-05-01-chat-redesign.md
new file mode 100644
index 000000000..3ac0b6d82
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-01-chat-redesign.md
@@ -0,0 +1,3571 @@
+# Chat Library Redesign Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Replace `@ngaf/chat`'s visual surface with a production-grade Tailwind-free design — asymmetric message pattern, three layout modes, shared trace primitive — and propagate the change through every cockpit demo, the example-layouts library, and the website docs. Ship as `0.0.3`.
+
+**Architecture:** Component-encapsulated styles import a single `CHAT_HOST_TOKENS` string into every chat component's `styles` array, so CSS custom properties resolve on `:host` with no consumer setup. An optional `@ngaf/chat/chat.css` exposes the same tokens at `:root` plus parallel global classes for deep override. All Tailwind utility classes are removed from chat library output and from `libs/example-layouts`. Cockpit demos consume the new surface unchanged-ish (rename `` → ``; drop manual Tailwind classes around ``).
+
+**Tech Stack:** Angular 21, standalone components with `ChangeDetectionStrategy.OnPush`, signals, content projection via `` directives, plain CSS in component `styles` arrays (no Tailwind), Vitest for tests.
+
+**Spec:** [docs/superpowers/specs/2026-05-01-chat-redesign-design.md](../specs/2026-05-01-chat-redesign-design.md)
+
+---
+
+## File Structure
+
+**New files (libs/chat):**
+- `libs/chat/src/lib/styles/chat-tokens.ts` — `CHAT_HOST_TOKENS` constant + keyframes
+- `libs/chat/src/lib/styles/chat.css` — Layer-3 global stylesheet
+- `libs/chat/src/lib/styles/chat-markdown.styles.ts` — markdown rules (replaces `chat-markdown.ts`)
+- `libs/chat/src/lib/primitives/chat-window/chat-window.component.ts` + spec
+- `libs/chat/src/lib/primitives/chat-message/chat-message.component.ts` + spec
+- `libs/chat/src/lib/primitives/chat-trace/chat-trace.component.ts` + spec
+- `libs/chat/src/lib/primitives/chat-launcher-button/chat-launcher-button.component.ts` + spec
+- `libs/chat/src/lib/primitives/chat-suggestions/chat-suggestions.component.ts` + spec
+- `libs/chat/src/lib/primitives/chat-message-list/chat-message-list.component.ts` (renamed from `chat-messages`) + spec + `message-template.directive.ts`
+- `libs/chat/src/lib/compositions/chat-popup/chat-popup.component.ts` + spec
+- `libs/chat/src/lib/compositions/chat-sidebar/chat-sidebar.component.ts` + spec
+
+**Rewritten in place:**
+- `libs/chat/src/lib/compositions/chat/chat.component.ts`
+- `libs/chat/src/lib/primitives/chat-input/chat-input.component.ts`
+- `libs/chat/src/lib/primitives/chat-typing-indicator/chat-typing-indicator.component.ts`
+- `libs/chat/src/lib/primitives/chat-error/chat-error.component.ts`
+- `libs/chat/src/lib/primitives/chat-interrupt/chat-interrupt.component.ts`
+- `libs/chat/src/lib/primitives/chat-thread-list/chat-thread-list.component.ts`
+- `libs/chat/src/lib/primitives/chat-generative-ui/chat-generative-ui.component.ts`
+- `libs/chat/src/lib/compositions/chat-tool-call-card/chat-tool-call-card.component.ts`
+- `libs/chat/src/lib/compositions/chat-subagent-card/chat-subagent-card.component.ts`
+- `libs/chat/src/lib/compositions/chat-timeline-slider/chat-timeline-slider.component.ts`
+- `libs/chat/src/lib/compositions/chat-interrupt-panel/chat-interrupt-panel.component.ts`
+- `libs/chat/src/lib/compositions/chat-debug/*.component.ts` (8 files)
+- `libs/chat/src/public-api.ts`
+- `libs/chat/package.json` (version + exports map)
+- `libs/example-layouts/src/lib/example-chat-layout.component.ts`
+- `libs/example-layouts/src/lib/example-split-layout.component.ts`
+
+**Deleted:**
+- `libs/chat/src/lib/styles/chat-theme.ts`
+- `libs/chat/src/lib/styles/chat-markdown.ts`
+- `libs/chat/src/lib/primitives/chat-messages/` directory (replaced by `chat-message-list/`)
+
+**Cockpit demos updated (19):**
+- `cockpit/chat/{messages,input,threads,tool-calls,subagents,timeline,interrupts,theming,debug,generative-ui,a2ui}/angular`
+- `cockpit/langgraph/{streaming,persistence,memory,interrupts,durable-execution,time-travel,subgraphs,deployment-runtime}/angular`
+
+**Website docs updated:**
+- `apps/website/content/docs/chat/getting-started/installation.mdx`
+- `apps/website/content/docs/chat/getting-started/quickstart.mdx`
+- `apps/website/content/docs/chat/guides/theming.mdx`
+- `apps/website/content/docs/chat/components/chat-popup.mdx` (NEW)
+- `apps/website/content/docs/chat/components/chat-sidebar.mdx` (NEW)
+- `apps/website/content/docs/chat/components/chat-trace.mdx` (NEW)
+- `apps/website/content/docs/chat/guides/layout-modes.mdx` (NEW)
+
+---
+
+## Phase 1 — Design tokens & global stylesheet
+
+### Task 1: Create `chat-tokens.ts`
+
+**Files:**
+- Create: `libs/chat/src/lib/styles/chat-tokens.ts`
+
+- [ ] **Step 1: Write the file**
+
+```ts
+// libs/chat/src/lib/styles/chat-tokens.ts
+// SPDX-License-Identifier: MIT
+
+const LIGHT_TOKENS = `
+  --ngaf-chat-bg: rgb(255, 255, 255);
+  --ngaf-chat-surface: rgb(255, 255, 255);
+  --ngaf-chat-surface-alt: rgb(251, 251, 251);
+  --ngaf-chat-primary: rgb(28, 28, 28);
+  --ngaf-chat-on-primary: rgb(255, 255, 255);
+  --ngaf-chat-text: rgb(28, 28, 28);
+  --ngaf-chat-text-muted: rgb(115, 115, 115);
+  --ngaf-chat-separator: rgb(229, 229, 229);
+  --ngaf-chat-muted: rgb(200, 200, 200);
+  --ngaf-chat-error-bg: #fef2f2;
+  --ngaf-chat-error-border: #fecaca;
+  --ngaf-chat-error-text: #dc2626;
+  --ngaf-chat-warning-bg: #fffbeb;
+  --ngaf-chat-warning-text: #b45309;
+  --ngaf-chat-success: #16a34a;
+  --ngaf-chat-shadow-sm: 0 1px 2px rgba(0,0,0,.05);
+  --ngaf-chat-shadow-md: 0 4px 6px -1px rgba(0,0,0,.10), 0 2px 4px -1px rgba(0,0,0,.06);
+  --ngaf-chat-shadow-lg: 0 10px 15px -3px rgba(0,0,0,.10), 0 4px 6px -2px rgba(0,0,0,.05);
+`;
+
+const DARK_TOKENS = `
+  --ngaf-chat-bg: rgb(17, 17, 17);
+  --ngaf-chat-surface: rgb(28, 28, 28);
+  --ngaf-chat-surface-alt: rgb(44, 44, 44);
+  --ngaf-chat-primary: rgb(255, 255, 255);
+  --ngaf-chat-on-primary: rgb(28, 28, 28);
+  --ngaf-chat-text: rgb(245, 245, 245);
+  --ngaf-chat-text-muted: rgb(160, 160, 160);
+  --ngaf-chat-separator: rgb(45, 45, 45);
+  --ngaf-chat-muted: rgb(60, 60, 60);
+  --ngaf-chat-error-bg: rgb(45, 21, 21);
+  --ngaf-chat-error-border: #dc2626;
+  --ngaf-chat-error-text: #fca5a5;
+  --ngaf-chat-warning-bg: rgb(45, 35, 21);
+  --ngaf-chat-warning-text: #fbbf24;
+  --ngaf-chat-success: #4ade80;
+`;
+
+const GEOMETRY_TOKENS = `
+  --ngaf-chat-radius-bubble: 15px;
+  --ngaf-chat-radius-input: 20px;
+  --ngaf-chat-radius-card: 8px;
+  --ngaf-chat-radius-button: 8px;
+  --ngaf-chat-radius-launcher: 9999px;
+  --ngaf-chat-max-width: 48rem;
+`;
+
+const TYPOGRAPHY_TOKENS = `
+  --ngaf-chat-font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
+  --ngaf-chat-font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
+  --ngaf-chat-font-size: 1rem;
+  --ngaf-chat-font-size-sm: 0.875rem;
+  --ngaf-chat-font-size-xs: 0.75rem;
+  --ngaf-chat-line-height: 1.6;
+  --ngaf-chat-line-height-tight: 1.5;
+`;
+
+const SPACING_TOKENS = `
+  --ngaf-chat-space-1: 4px;
+  --ngaf-chat-space-2: 8px;
+  --ngaf-chat-space-3: 12px;
+  --ngaf-chat-space-4: 16px;
+  --ngaf-chat-space-5: 20px;
+  --ngaf-chat-space-6: 24px;
+  --ngaf-chat-space-8: 32px;
+`;
+
+const KEYFRAMES = `
+  @keyframes ngaf-chat-spin {
+    0% { transform: rotate(0deg); }
+    100% { transform: rotate(360deg); }
+  }
+  @keyframes ngaf-chat-typing-dot {
+    0%, 80%, 100% { transform: scale(0.5); opacity: 0.5; }
+    40% { transform: scale(1); opacity: 1; }
+  }
+  @keyframes ngaf-chat-pulse {
+    0%, 100% { opacity: 1; }
+    50% { opacity: 0.6; }
+  }
+  @keyframes ngaf-chat-caret-blink {
+    0%, 50% { opacity: 1; }
+    50.01%, 100% { opacity: 0; }
+  }
+`;
+
+/**
+ * Component-host design tokens. Import into every chat component's `styles`
+ * array so CSS custom properties resolve on `:host` without consumer setup.
+ * Light tokens are default; dark applies via prefers-color-scheme OR via the
+ * `[data-ngaf-chat-theme="dark"]` attribute on the host. Consumers can force
+ * light by setting `[data-ngaf-chat-theme="light"]`.
+ */
+export const CHAT_HOST_TOKENS = `
+  :host {
+    ${LIGHT_TOKENS}
+    ${GEOMETRY_TOKENS}
+    ${TYPOGRAPHY_TOKENS}
+    ${SPACING_TOKENS}
+    font-family: var(--ngaf-chat-font-family);
+    color: var(--ngaf-chat-text);
+  }
+  @media (prefers-color-scheme: dark) {
+    :host:not([data-ngaf-chat-theme="light"]) { ${DARK_TOKENS} }
+  }
+  :host([data-ngaf-chat-theme="dark"]) { ${DARK_TOKENS} }
+  ${KEYFRAMES}
+`;
+```
+
+- [ ] **Step 2: Verify TypeScript compiles**
+
+Run: `npx nx build chat --configuration=development 2>&1 | tail -20`
+Expected: build succeeds (constant is unused so far, that's fine).
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add libs/chat/src/lib/styles/chat-tokens.ts
+git commit -m "feat(chat): add CHAT_HOST_TOKENS design-token constant"
+```
+
+---
+
+### Task 2: Create `chat.css` Layer-3 global stylesheet
+
+**Files:**
+- Create: `libs/chat/src/lib/styles/chat.css`
+
+- [ ] **Step 1: Write the file**
+
+```css
+/* libs/chat/src/lib/styles/chat.css */
+/* SPDX-License-Identifier: MIT */
+
+/*
+ * Optional global stylesheet for @ngaf/chat. Import once in your global
+ * styles to:
+ *   1. Override design tokens at :root (instead of per :host).
+ *   2. Reach into chat components with parallel global selectors.
+ *
+ * Usage:
+ *   /* in src/styles.css *​/
+ *   @import '@ngaf/chat/chat.css';
+ *
+ *   :root { --ngaf-chat-primary: oklch(0.55 0.22 264); }
+ */
+
+:root {
+  --ngaf-chat-bg: rgb(255, 255, 255);
+  --ngaf-chat-surface: rgb(255, 255, 255);
+  --ngaf-chat-surface-alt: rgb(251, 251, 251);
+  --ngaf-chat-primary: rgb(28, 28, 28);
+  --ngaf-chat-on-primary: rgb(255, 255, 255);
+  --ngaf-chat-text: rgb(28, 28, 28);
+  --ngaf-chat-text-muted: rgb(115, 115, 115);
+  --ngaf-chat-separator: rgb(229, 229, 229);
+  --ngaf-chat-muted: rgb(200, 200, 200);
+  --ngaf-chat-error-bg: #fef2f2;
+  --ngaf-chat-error-border: #fecaca;
+  --ngaf-chat-error-text: #dc2626;
+  --ngaf-chat-warning-bg: #fffbeb;
+  --ngaf-chat-warning-text: #b45309;
+  --ngaf-chat-success: #16a34a;
+  --ngaf-chat-shadow-sm: 0 1px 2px rgba(0,0,0,.05);
+  --ngaf-chat-shadow-md: 0 4px 6px -1px rgba(0,0,0,.10), 0 2px 4px -1px rgba(0,0,0,.06);
+  --ngaf-chat-shadow-lg: 0 10px 15px -3px rgba(0,0,0,.10), 0 4px 6px -2px rgba(0,0,0,.05);
+  --ngaf-chat-radius-bubble: 15px;
+  --ngaf-chat-radius-input: 20px;
+  --ngaf-chat-radius-card: 8px;
+  --ngaf-chat-radius-button: 8px;
+  --ngaf-chat-radius-launcher: 9999px;
+  --ngaf-chat-max-width: 48rem;
+  --ngaf-chat-font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
+  --ngaf-chat-font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
+  --ngaf-chat-font-size: 1rem;
+  --ngaf-chat-font-size-sm: 0.875rem;
+  --ngaf-chat-font-size-xs: 0.75rem;
+  --ngaf-chat-line-height: 1.6;
+  --ngaf-chat-line-height-tight: 1.5;
+}
+
+@media (prefers-color-scheme: dark) {
+  :root:not([data-ngaf-chat-theme="light"]) {
+    --ngaf-chat-bg: rgb(17, 17, 17);
+    --ngaf-chat-surface: rgb(28, 28, 28);
+    --ngaf-chat-surface-alt: rgb(44, 44, 44);
+    --ngaf-chat-primary: rgb(255, 255, 255);
+    --ngaf-chat-on-primary: rgb(28, 28, 28);
+    --ngaf-chat-text: rgb(245, 245, 245);
+    --ngaf-chat-text-muted: rgb(160, 160, 160);
+    --ngaf-chat-separator: rgb(45, 45, 45);
+    --ngaf-chat-muted: rgb(60, 60, 60);
+    --ngaf-chat-error-bg: rgb(45, 21, 21);
+    --ngaf-chat-error-border: #dc2626;
+    --ngaf-chat-error-text: #fca5a5;
+    --ngaf-chat-warning-bg: rgb(45, 35, 21);
+    --ngaf-chat-warning-text: #fbbf24;
+    --ngaf-chat-success: #4ade80;
+  }
+}
+
+[data-ngaf-chat-theme="dark"] {
+  --ngaf-chat-bg: rgb(17, 17, 17);
+  --ngaf-chat-surface: rgb(28, 28, 28);
+  --ngaf-chat-surface-alt: rgb(44, 44, 44);
+  --ngaf-chat-primary: rgb(255, 255, 255);
+  --ngaf-chat-on-primary: rgb(28, 28, 28);
+  --ngaf-chat-text: rgb(245, 245, 245);
+  --ngaf-chat-text-muted: rgb(160, 160, 160);
+  --ngaf-chat-separator: rgb(45, 45, 45);
+  --ngaf-chat-muted: rgb(60, 60, 60);
+  --ngaf-chat-error-bg: rgb(45, 21, 21);
+  --ngaf-chat-error-border: #dc2626;
+  --ngaf-chat-error-text: #fca5a5;
+  --ngaf-chat-warning-bg: rgb(45, 35, 21);
+  --ngaf-chat-warning-text: #fbbf24;
+  --ngaf-chat-success: #4ade80;
+}
+```
+
+- [ ] **Step 2: Wire export in package.json**
+
+Update `libs/chat/package.json`:
+
+```jsonc
+{
+  "name": "@ngaf/chat",
+  "version": "0.0.2",
+  "exports": {
+    ".": {
+      "types": "./index.d.ts",
+      "default": "./fesm2022/ngaf-chat.mjs"
+    },
+    "./testing": {
+      "types": "./testing.d.ts",
+      "default": "./fesm2022/ngaf-chat-testing.mjs"
+    },
+    "./chat.css": "./chat.css"
+  },
+  "peerDependencies": { /* unchanged */ }
+}
+```
+
+(Note: leave version at 0.0.2 here — the bump to 0.0.3 lands in Task 35.)
+
+- [ ] **Step 3: Wire ng-package.json to copy chat.css to dist**
+
+Read `libs/chat/ng-package.json`. Add an `assets` array if not present:
+
+```jsonc
+{
+  "$schema": "../../node_modules/ng-packagr/ng-package.schema.json",
+  "dest": "../../dist/libs/chat",
+  "lib": { "entryFile": "src/public-api.ts" },
+  "allowedNonPeerDependencies": [],
+  "assets": [
+    { "input": "src/lib/styles", "glob": "chat.css", "output": "." }
+  ]
+}
+```
+
+- [ ] **Step 4: Build to verify chat.css ships to dist**
+
+Run: `npx nx build chat 2>&1 | tail -10 && ls dist/libs/chat/chat.css 2>&1`
+Expected: build succeeds, file `dist/libs/chat/chat.css` exists.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add libs/chat/src/lib/styles/chat.css libs/chat/package.json libs/chat/ng-package.json
+git commit -m "feat(chat): ship optional chat.css global stylesheet"
+```
+
+---
+
+## Phase 2 — New primitive components
+
+### Task 3: `` primitive
+
+**Files:**
+- Create: `libs/chat/src/lib/primitives/chat-trace/chat-trace.component.ts`
+- Create: `libs/chat/src/lib/primitives/chat-trace/chat-trace.component.spec.ts`
+- Create: `libs/chat/src/lib/styles/chat-trace.styles.ts`
+
+- [ ] **Step 1: Write styles**
+
+```ts
+// libs/chat/src/lib/styles/chat-trace.styles.ts
+// SPDX-License-Identifier: MIT
+export const CHAT_TRACE_STYLES = `
+  :host { display: block; font-size: var(--ngaf-chat-font-size-sm); }
+  .chat-trace__header {
+    display: flex;
+    align-items: center;
+    gap: 0.25rem;
+    cursor: pointer;
+    user-select: none;
+    color: var(--ngaf-chat-text-muted);
+    background: transparent;
+    border: 0;
+    padding: 0;
+    width: 100%;
+    text-align: left;
+    font: inherit;
+  }
+  .chat-trace__chevron {
+    width: 12px;
+    height: 12px;
+    transition: transform 200ms ease;
+    flex-shrink: 0;
+  }
+  :host([data-expanded="true"]) .chat-trace__chevron { transform: rotate(90deg); }
+  .chat-trace__label { display: flex; align-items: center; gap: 0.5rem; flex: 1; min-width: 0; }
+  :host([data-state="running"]) .chat-trace__label { animation: ngaf-chat-pulse 1.5s ease-in-out infinite; }
+  :host([data-state="error"]) .chat-trace__label { color: var(--ngaf-chat-error-text); }
+  .chat-trace__content {
+    padding-left: 1rem;
+    padding-top: 0.375rem;
+    margin-left: 0.375rem;
+    border-left: 1px solid var(--ngaf-chat-separator);
+    max-height: 250px;
+    overflow: auto;
+  }
+`;
+```
+
+- [ ] **Step 2: Write the failing test**
+
+```ts
+// libs/chat/src/lib/primitives/chat-trace/chat-trace.component.spec.ts
+// SPDX-License-Identifier: MIT
+import { describe, it, expect } from 'vitest';
+import { TestBed } from '@angular/core/testing';
+import { Component } from '@angular/core';
+import { ChatTraceComponent } from './chat-trace.component';
+
+@Component({
+  standalone: true,
+  imports: [ChatTraceComponent],
+  template: `Working
body
`, +}) +class Host {} + +describe('ChatTraceComponent', () => { + it('toggles expanded on header click', () => { + const fx = TestBed.createComponent(Host); + fx.detectChanges(); + const trace: HTMLElement = fx.nativeElement.querySelector('chat-trace'); + expect(trace.getAttribute('data-expanded')).toBe('true'); // running auto-expands + const header = trace.querySelector('.chat-trace__header')!; + header.click(); + fx.detectChanges(); + expect(trace.getAttribute('data-expanded')).toBe('false'); + }); + + it('renders label and content via projection', () => { + const fx = TestBed.createComponent(Host); + fx.detectChanges(); + const trace: HTMLElement = fx.nativeElement.querySelector('chat-trace'); + expect(trace.querySelector('[traceLabel]')!.textContent).toBe('Working'); + }); +}); +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `npx nx test chat -- --run chat-trace.component.spec` +Expected: FAIL — module not found. + +- [ ] **Step 4: Implement the component** + +```ts +// libs/chat/src/lib/primitives/chat-trace/chat-trace.component.ts +// SPDX-License-Identifier: MIT +import { Component, ChangeDetectionStrategy, input, signal, effect, HostBinding } from '@angular/core'; +import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens'; +import { CHAT_TRACE_STYLES } from '../../styles/chat-trace.styles'; + +export type TraceState = 'pending' | 'running' | 'done' | 'error'; + +@Component({ + selector: 'chat-trace', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + styles: [CHAT_HOST_TOKENS, CHAT_TRACE_STYLES], + template: ` + + @if (expanded()) { +
+ } + `, +}) +export class ChatTraceComponent { + readonly state = input('pending'); + + readonly expanded = signal(false); + + @HostBinding('attr.data-state') get stateAttr() { return this.state(); } + @HostBinding('attr.data-expanded') get expandedAttr() { return String(this.expanded()); } + + constructor() { + effect(() => { + const s = this.state(); + if (s === 'running') { + this.expanded.set(true); + } else if (s === 'done') { + // collapse 200ms after done + setTimeout(() => this.expanded.set(false), 200); + } + }); + } + + toggle(): void { + this.expanded.update((v) => !v); + } +} +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `npx nx test chat -- --run chat-trace.component.spec` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add libs/chat/src/lib/primitives/chat-trace libs/chat/src/lib/styles/chat-trace.styles.ts +git commit -m "feat(chat): add chat-trace primitive" +``` + +--- + +### Task 4: `` primitive + +**Files:** +- Create: `libs/chat/src/lib/primitives/chat-window/chat-window.component.ts` +- Create: `libs/chat/src/lib/primitives/chat-window/chat-window.component.spec.ts` +- Create: `libs/chat/src/lib/styles/chat-window.styles.ts` + +- [ ] **Step 1: Write styles** + +```ts +// libs/chat/src/lib/styles/chat-window.styles.ts +// SPDX-License-Identifier: MIT +export const CHAT_WINDOW_STYLES = ` + :host { + display: flex; + flex-direction: column; + min-height: 0; + height: 100%; + background: var(--ngaf-chat-bg); + color: var(--ngaf-chat-text); + } + .chat-window__header { + height: 56px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 var(--ngaf-chat-space-6); + border-bottom: 1px solid var(--ngaf-chat-separator); + font-weight: 500; + color: var(--ngaf-chat-primary); + } + .chat-window__header:empty { display: none; } + .chat-window__body { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; + } + .chat-window__footer { + flex-shrink: 0; + } + .chat-window__footer:empty { display: none; } +`; +``` + +- [ ] **Step 2: Write failing test** + +```ts +// libs/chat/src/lib/primitives/chat-window/chat-window.component.spec.ts +// SPDX-License-Identifier: MIT +import { describe, it, expect } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { Component } from '@angular/core'; +import { ChatWindowComponent } from './chat-window.component'; + +@Component({ + standalone: true, + imports: [ChatWindowComponent], + template: ` + + My Chat +
messages here
+
input here
+
+ `, +}) +class Host {} + +describe('ChatWindowComponent', () => { + it('projects header / body / footer slots', () => { + const fx = TestBed.createComponent(Host); + fx.detectChanges(); + const win = fx.nativeElement.querySelector('chat-window'); + expect(win.querySelector('.chat-window__header')!.textContent).toContain('My Chat'); + expect(win.querySelector('.chat-window__body')!.textContent).toContain('messages here'); + expect(win.querySelector('.chat-window__footer')!.textContent).toContain('input here'); + }); +}); +``` + +- [ ] **Step 3: Run test, verify FAIL** + +Run: `npx nx test chat -- --run chat-window.component.spec` +Expected: FAIL — module not found. + +- [ ] **Step 4: Implement** + +```ts +// libs/chat/src/lib/primitives/chat-window/chat-window.component.ts +// SPDX-License-Identifier: MIT +import { Component, ChangeDetectionStrategy } from '@angular/core'; +import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens'; +import { CHAT_WINDOW_STYLES } from '../../styles/chat-window.styles'; + +@Component({ + selector: 'chat-window', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + styles: [CHAT_HOST_TOKENS, CHAT_WINDOW_STYLES], + template: ` +
+
+ + `, +}) +export class ChatWindowComponent {} +``` + +- [ ] **Step 5: Run test, verify PASS** + +Run: `npx nx test chat -- --run chat-window.component.spec` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add libs/chat/src/lib/primitives/chat-window libs/chat/src/lib/styles/chat-window.styles.ts +git commit -m "feat(chat): add chat-window primitive" +``` + +--- + +### Task 5: `` primitive + +**Files:** +- Create: `libs/chat/src/lib/primitives/chat-launcher-button/chat-launcher-button.component.ts` +- Create: `libs/chat/src/lib/primitives/chat-launcher-button/chat-launcher-button.component.spec.ts` +- Create: `libs/chat/src/lib/styles/chat-launcher-button.styles.ts` + +- [ ] **Step 1: Write styles** + +```ts +// libs/chat/src/lib/styles/chat-launcher-button.styles.ts +// SPDX-License-Identifier: MIT +export const CHAT_LAUNCHER_BUTTON_STYLES = ` + :host { display: inline-block; } + .chat-launcher-button { + width: 56px; + height: 56px; + border-radius: var(--ngaf-chat-radius-launcher); + background: var(--ngaf-chat-primary); + color: var(--ngaf-chat-on-primary); + border: 0; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + box-shadow: var(--ngaf-chat-shadow-md); + transition: transform 200ms ease; + } + .chat-launcher-button:hover { transform: scale(1.05); } + .chat-launcher-button svg { width: 24px; height: 24px; } +`; +``` + +- [ ] **Step 2: Write failing test** + +```ts +// libs/chat/src/lib/primitives/chat-launcher-button/chat-launcher-button.component.spec.ts +// SPDX-License-Identifier: MIT +import { describe, it, expect } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { Component } from '@angular/core'; +import { ChatLauncherButtonComponent } from './chat-launcher-button.component'; + +@Component({ + standalone: true, + imports: [ChatLauncherButtonComponent], + template: ``, +}) +class Host { clicked = false; } + +describe('ChatLauncherButtonComponent', () => { + it('emits click', () => { + const fx = TestBed.createComponent(Host); + fx.detectChanges(); + const btn = fx.nativeElement.querySelector('.chat-launcher-button'); + btn.click(); + expect(fx.componentInstance.clicked).toBe(true); + }); +}); +``` + +- [ ] **Step 3: Run test, FAIL** + +Run: `npx nx test chat -- --run chat-launcher-button.component.spec` + +- [ ] **Step 4: Implement** + +```ts +// libs/chat/src/lib/primitives/chat-launcher-button/chat-launcher-button.component.ts +// SPDX-License-Identifier: MIT +import { Component, ChangeDetectionStrategy } from '@angular/core'; +import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens'; +import { CHAT_LAUNCHER_BUTTON_STYLES } from '../../styles/chat-launcher-button.styles'; + +@Component({ + selector: 'chat-launcher-button', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + styles: [CHAT_HOST_TOKENS, CHAT_LAUNCHER_BUTTON_STYLES], + template: ` + + `, +}) +export class ChatLauncherButtonComponent {} +``` + +- [ ] **Step 5: Run test, PASS** + +Run: `npx nx test chat -- --run chat-launcher-button.component.spec` + +- [ ] **Step 6: Commit** + +```bash +git add libs/chat/src/lib/primitives/chat-launcher-button libs/chat/src/lib/styles/chat-launcher-button.styles.ts +git commit -m "feat(chat): add chat-launcher-button primitive" +``` + +--- + +### Task 6: `` primitive + +**Files:** +- Create: `libs/chat/src/lib/primitives/chat-suggestions/chat-suggestions.component.ts` +- Create: `libs/chat/src/lib/primitives/chat-suggestions/chat-suggestions.component.spec.ts` +- Create: `libs/chat/src/lib/styles/chat-suggestions.styles.ts` + +- [ ] **Step 1: Styles** + +```ts +// libs/chat/src/lib/styles/chat-suggestions.styles.ts +// SPDX-License-Identifier: MIT +export const CHAT_SUGGESTIONS_STYLES = ` + :host { display: block; } + .chat-suggestions { display: flex; flex-wrap: wrap; gap: 6px; justify-content: center; } + .chat-suggestion { + padding: 6px 10px; + font-size: var(--ngaf-chat-font-size-xs); + border-radius: var(--ngaf-chat-radius-bubble); + border: 1px solid var(--ngaf-chat-muted); + background: transparent; + color: var(--ngaf-chat-text); + cursor: pointer; + transition: transform 200ms ease; + } + .chat-suggestion:hover { transform: scale(1.03); } + .chat-suggestion:disabled { cursor: wait; opacity: 0.6; } +`; +``` + +- [ ] **Step 2: Failing test** + +```ts +// libs/chat/src/lib/primitives/chat-suggestions/chat-suggestions.component.spec.ts +// SPDX-License-Identifier: MIT +import { describe, it, expect } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { Component } from '@angular/core'; +import { ChatSuggestionsComponent } from './chat-suggestions.component'; + +@Component({ + standalone: true, + imports: [ChatSuggestionsComponent], + template: ``, +}) +class Host { picked = ''; } + +describe('ChatSuggestionsComponent', () => { + it('renders one button per suggestion and emits on click', () => { + const fx = TestBed.createComponent(Host); + fx.detectChanges(); + const buttons = fx.nativeElement.querySelectorAll('.chat-suggestion'); + expect(buttons.length).toBe(2); + (buttons[1] as HTMLButtonElement).click(); + expect(fx.componentInstance.picked).toBe('Two'); + }); +}); +``` + +- [ ] **Step 3: Run, FAIL.** `npx nx test chat -- --run chat-suggestions.component.spec` + +- [ ] **Step 4: Implement** + +```ts +// libs/chat/src/lib/primitives/chat-suggestions/chat-suggestions.component.ts +// SPDX-License-Identifier: MIT +import { Component, ChangeDetectionStrategy, input, output } from '@angular/core'; +import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens'; +import { CHAT_SUGGESTIONS_STYLES } from '../../styles/chat-suggestions.styles'; + +@Component({ + selector: 'chat-suggestions', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + styles: [CHAT_HOST_TOKENS, CHAT_SUGGESTIONS_STYLES], + template: ` +
+ @for (s of suggestions(); track s) { + + } +
+ `, +}) +export class ChatSuggestionsComponent { + readonly suggestions = input([]); + readonly selected = output(); +} +``` + +- [ ] **Step 5: Run, PASS.** + +- [ ] **Step 6: Commit** + +```bash +git add libs/chat/src/lib/primitives/chat-suggestions libs/chat/src/lib/styles/chat-suggestions.styles.ts +git commit -m "feat(chat): add chat-suggestions primitive" +``` + +--- + +### Task 7: `` primitive (asymmetric variants) + +**Files:** +- Create: `libs/chat/src/lib/primitives/chat-message/chat-message.component.ts` +- Create: `libs/chat/src/lib/primitives/chat-message/chat-message.component.spec.ts` +- Create: `libs/chat/src/lib/styles/chat-message.styles.ts` + +- [ ] **Step 1: Styles** — covers user-bubble + assistant-inline + hover controls + caret. + +```ts +// libs/chat/src/lib/styles/chat-message.styles.ts +// SPDX-License-Identifier: MIT +export const CHAT_MESSAGE_STYLES = ` + :host { display: block; } + :host([data-role="user"]) { + display: flex; + justify-content: flex-end; + margin-top: 0.5rem; + } + :host([data-role="user"][data-prev-role="assistant"]) { margin-top: 1.5rem; } + :host([data-role="assistant"]) { + display: block; + position: relative; + margin-top: 1.5rem; + color: var(--ngaf-chat-text); + line-height: var(--ngaf-chat-line-height); + font-size: var(--ngaf-chat-font-size); + max-width: 100%; + } + :host([data-role="assistant"]):first-child { margin-top: 0; } + + .chat-message__bubble { + max-width: 80%; + padding: 8px 12px; + border-radius: var(--ngaf-chat-radius-bubble); + background: var(--ngaf-chat-primary); + color: var(--ngaf-chat-on-primary); + white-space: pre-wrap; + line-height: var(--ngaf-chat-line-height-tight); + font-size: var(--ngaf-chat-font-size); + overflow-wrap: break-word; + } + + .chat-message__assistant-body { + overflow-wrap: break-word; + } + + .chat-message__caret { + display: inline-block; + margin-left: 2px; + width: 0.6ch; + color: var(--ngaf-chat-text-muted); + animation: ngaf-chat-caret-blink 1.2s step-end infinite; + } + + .chat-message__controls { + position: absolute; + left: 0; + bottom: -28px; + display: flex; + gap: 1rem; + opacity: 0; + transition: opacity 200ms ease; + pointer-events: none; + } + :host([data-role="assistant"]:hover) .chat-message__controls, + :host([data-role="assistant"]:focus-within) .chat-message__controls, + :host([data-current="true"]) .chat-message__controls { + opacity: 1; + pointer-events: auto; + } + @media (max-width: 768px) { + :host([data-role="assistant"]) .chat-message__controls { opacity: 1; pointer-events: auto; } + } + .chat-message__control-btn { + width: 20px; + height: 20px; + border: 0; + background: transparent; + color: var(--ngaf-chat-primary); + cursor: pointer; + padding: 0; + transition: transform 200ms ease; + } + .chat-message__control-btn:hover { transform: scale(1.05); } + .chat-message__control-btn svg { width: 16px; height: 16px; pointer-events: none; } +`; +``` + +- [ ] **Step 2: Failing test** + +```ts +// libs/chat/src/lib/primitives/chat-message/chat-message.component.spec.ts +// SPDX-License-Identifier: MIT +import { describe, it, expect } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { Component } from '@angular/core'; +import { ChatMessageComponent } from './chat-message.component'; + +@Component({ + standalone: true, + imports: [ChatMessageComponent], + template: `hello`, +}) +class UserHost {} + +@Component({ + standalone: true, + imports: [ChatMessageComponent], + template: `body`, +}) +class AssistantHost {} + +describe('ChatMessageComponent', () => { + it('renders user bubble', () => { + const fx = TestBed.createComponent(UserHost); + fx.detectChanges(); + const el = fx.nativeElement.querySelector('chat-message'); + expect(el.getAttribute('data-role')).toBe('user'); + expect(el.querySelector('.chat-message__bubble')).toBeTruthy(); + }); + + it('renders assistant inline with caret while streaming and current', () => { + const fx = TestBed.createComponent(AssistantHost); + fx.detectChanges(); + const el = fx.nativeElement.querySelector('chat-message'); + expect(el.getAttribute('data-role')).toBe('assistant'); + expect(el.getAttribute('data-current')).toBe('true'); + expect(el.querySelector('.chat-message__caret')).toBeTruthy(); + }); +}); +``` + +- [ ] **Step 3: Run, FAIL.** + +- [ ] **Step 4: Implement** + +```ts +// libs/chat/src/lib/primitives/chat-message/chat-message.component.ts +// SPDX-License-Identifier: MIT +import { Component, ChangeDetectionStrategy, input, HostBinding } from '@angular/core'; +import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens'; +import { CHAT_MESSAGE_STYLES } from '../../styles/chat-message.styles'; + +export type ChatMessageRole = 'user' | 'assistant' | 'system' | 'tool'; + +@Component({ + selector: 'chat-message', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + styles: [CHAT_HOST_TOKENS, CHAT_MESSAGE_STYLES], + template: ` + @switch (role()) { + @case ('user') { +
+ } + @case ('assistant') { +
+ + @if (streaming() && current()) { + + } +
+
+ +
+ } + @default { +
+ } + } + `, +}) +export class ChatMessageComponent { + readonly role = input.required(); + readonly current = input(false); + readonly streaming = input(false); + readonly prevRole = input(undefined); + + @HostBinding('attr.data-role') get roleAttr() { return this.role(); } + @HostBinding('attr.data-current') get currentAttr() { return String(this.current()); } + @HostBinding('attr.data-prev-role') get prevRoleAttr() { return this.prevRole() ?? null; } +} +``` + +- [ ] **Step 5: Run, PASS.** + +- [ ] **Step 6: Commit** + +```bash +git add libs/chat/src/lib/primitives/chat-message libs/chat/src/lib/styles/chat-message.styles.ts +git commit -m "feat(chat): add chat-message primitive (asymmetric user/assistant)" +``` + +--- + +## Phase 3 — Rewrite existing primitives + +### Task 8: Rename `chat-messages` → `chat-message-list` + +**Files:** +- Create: `libs/chat/src/lib/primitives/chat-message-list/chat-message-list.component.ts` +- Create: `libs/chat/src/lib/primitives/chat-message-list/chat-message-list.component.spec.ts` +- Create: `libs/chat/src/lib/primitives/chat-message-list/message-template.directive.ts` +- Delete: `libs/chat/src/lib/primitives/chat-messages/` + +- [ ] **Step 1: Read existing chat-messages** + +Read `libs/chat/src/lib/primitives/chat-messages/chat-messages.component.ts` and `message-template.directive.ts` to capture the existing API (selectors, inputs, content projection contract). + +- [ ] **Step 2: Copy + rename file content** + +```bash +mkdir -p libs/chat/src/lib/primitives/chat-message-list +cp libs/chat/src/lib/primitives/chat-messages/message-template.directive.ts libs/chat/src/lib/primitives/chat-message-list/message-template.directive.ts +``` + +Then create `chat-message-list.component.ts` based on the existing `chat-messages.component.ts`, applying: +1. Selector `chat-messages` → `chat-message-list` +2. Class `ChatMessagesComponent` → `ChatMessageListComponent` +3. Function `getMessageType` stays (still exported) +4. Add `styles: [CHAT_HOST_TOKENS, CHAT_MESSAGE_LIST_STYLES]` (next step adds the styles file) +5. Wrap each message in `` and project the existing template content inside. + +- [ ] **Step 3: Add styles** + +```ts +// libs/chat/src/lib/styles/chat-message-list.styles.ts +// SPDX-License-Identifier: MIT +export const CHAT_MESSAGE_LIST_STYLES = ` + :host { + display: flex; + flex-direction: column; + gap: 0; + padding: var(--ngaf-chat-space-4) var(--ngaf-chat-space-6); + max-width: var(--ngaf-chat-max-width); + margin: 0 auto; + width: 100%; + box-sizing: border-box; + } +`; +``` + +- [ ] **Step 4: Update message-template.directive.ts to use new selector** + +```ts +// libs/chat/src/lib/primitives/chat-message-list/message-template.directive.ts +// SPDX-License-Identifier: MIT +import { Directive, input, TemplateRef, inject } from '@angular/core'; +import type { MessageTemplateType } from '../../chat.types'; + +@Directive({ + selector: '[chatMessageTemplate]', + standalone: true, +}) +export class MessageTemplateDirective { + readonly chatMessageTemplate = input.required(); + readonly templateRef = inject(TemplateRef); +} +``` + +- [ ] **Step 5: Move + adapt the existing spec to new path** + +```bash +git mv libs/chat/src/lib/primitives/chat-messages/chat-messages.component.spec.ts \ + libs/chat/src/lib/primitives/chat-message-list/chat-message-list.component.spec.ts +``` + +Update the moved spec: replace `chat-messages` selector with `chat-message-list`, replace `ChatMessagesComponent` import/class with `ChatMessageListComponent`. Update assertions for `chat-message` wrapper presence (each message is now wrapped in ``). + +- [ ] **Step 6: Delete old directory** + +```bash +git rm -r libs/chat/src/lib/primitives/chat-messages/ +``` + +- [ ] **Step 7: Run tests** + +Run: `npx nx test chat -- --run chat-message-list` +Expected: PASS. + +- [ ] **Step 8: Commit** + +```bash +git add libs/chat/src/lib/primitives/chat-message-list libs/chat/src/lib/styles/chat-message-list.styles.ts +git commit -m "refactor(chat): rename chat-messages -> chat-message-list with chat-message wrapper" +``` + +--- + +### Task 9: Rewrite `` (pill design) + +**Files:** +- Modify: `libs/chat/src/lib/primitives/chat-input/chat-input.component.ts` +- Modify: `libs/chat/src/lib/primitives/chat-input/chat-input.component.spec.ts` +- Create: `libs/chat/src/lib/styles/chat-input.styles.ts` + +- [ ] **Step 1: Add styles** + +```ts +// libs/chat/src/lib/styles/chat-input.styles.ts +// SPDX-License-Identifier: MIT +export const CHAT_INPUT_STYLES = ` + :host { display: block; background: var(--ngaf-chat-bg); } + .chat-input__container { padding: 0 0 15px 0; background: var(--ngaf-chat-bg); } + .chat-input__pill { + cursor: text; + position: relative; + background: var(--ngaf-chat-surface-alt); + border: 1px solid var(--ngaf-chat-separator); + border-radius: var(--ngaf-chat-radius-input); + padding: 12px 14px; + padding-right: 56px; + min-height: 75px; + margin: 0 auto; + width: 95%; + box-sizing: border-box; + display: flex; + align-items: flex-start; + transition: border-color 200ms ease; + } + .chat-input__pill:focus-within { border-color: var(--ngaf-chat-text-muted); } + .chat-input__textarea { + flex: 1; + outline: 0; + border: 0; + resize: none; + background: transparent; + color: var(--ngaf-chat-text); + font-family: inherit; + font-size: var(--ngaf-chat-font-size-sm); + line-height: 1.5rem; + width: 100%; + margin: 0; + padding: 0; + field-sizing: content; + min-height: 1.5rem; + max-height: 9rem; + overflow-y: auto; + } + .chat-input__textarea::placeholder { color: var(--ngaf-chat-text-muted); opacity: 1; } + .chat-input__textarea::-webkit-scrollbar { width: 6px; } + .chat-input__textarea::-webkit-scrollbar-thumb { background: var(--ngaf-chat-separator); border-radius: 10px; } + .chat-input__controls { + position: absolute; + right: 14px; + bottom: 12px; + display: flex; + gap: 3px; + } + .chat-input__send { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border: 0; + background: var(--ngaf-chat-primary); + color: var(--ngaf-chat-on-primary); + border-radius: var(--ngaf-chat-radius-button); + cursor: pointer; + transition: transform 200ms ease, background 200ms ease; + } + .chat-input__send:hover:not(:disabled) { transform: scale(1.05); } + .chat-input__send:disabled { background: var(--ngaf-chat-muted); color: var(--ngaf-chat-on-primary); cursor: not-allowed; } + .chat-input__send svg { width: 16px; height: 16px; } +`; +``` + +- [ ] **Step 2: Read current `chat-input.component.ts`** to capture `submitMessage` helper export, current input/output API (`agent`, `submitOnEnter`, `placeholder`), and any directives consumed. + +- [ ] **Step 3: Rewrite component** + +```ts +// libs/chat/src/lib/primitives/chat-input/chat-input.component.ts +// SPDX-License-Identifier: MIT +import { + Component, ChangeDetectionStrategy, input, output, signal, viewChild, + ElementRef, computed, +} from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import type { Agent } from '../../agent'; +import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens'; +import { CHAT_INPUT_STYLES } from '../../styles/chat-input.styles'; + +export async function submitMessage(agent: Agent, text: string): Promise { + const trimmed = text.trim(); + if (!trimmed) return; + await agent.submit({ message: trimmed }); +} + +@Component({ + selector: 'chat-input', + standalone: true, + imports: [FormsModule], + changeDetection: ChangeDetectionStrategy.OnPush, + styles: [CHAT_HOST_TOKENS, CHAT_INPUT_STYLES], + template: ` +
+ + +
+ + +
+ + +
+
+ +
+ `, +}) +export class ChatInputComponent { + readonly agent = input(undefined); + readonly submitOnEnter = input(true); + readonly placeholder = input('Type a message...'); + readonly disabled = input(false); + + readonly submitted = output(); + + protected readonly value = signal(''); + private readonly textareaRef = viewChild>('textarea'); + + protected readonly canSubmit = computed(() => { + if (this.disabled()) return false; + const a = this.agent(); + if (a?.isLoading()) return false; + return this.value().trim().length > 0; + }); + + focusTextarea(): void { + this.textareaRef()?.nativeElement.focus(); + } + + onKeydown(event: KeyboardEvent): void { + if (this.submitOnEnter() && event.key === 'Enter' && !event.shiftKey) { + event.preventDefault(); + void this.onSubmit(); + } + } + + async onSubmit(): Promise { + if (!this.canSubmit()) return; + const text = this.value(); + this.value.set(''); + this.submitted.emit(text); + const a = this.agent(); + if (a) await submitMessage(a, text); + } +} +``` + +- [ ] **Step 4: Update existing spec** + +Read `chat-input.component.spec.ts`. Replace any references to old class names / templates / Tailwind. Add a new test asserting: +- `data-* attributes`: textarea has `placeholder="Type a message..."`. +- Send button is disabled when value is empty. +- Pressing Enter (no shift) calls `submit` on bound mock agent. + +(Adapt existing tests; do not delete unless they assert removed behavior.) + +- [ ] **Step 5: Run tests** + +Run: `npx nx test chat -- --run chat-input.component.spec` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add libs/chat/src/lib/primitives/chat-input libs/chat/src/lib/styles/chat-input.styles.ts +git commit -m "refactor(chat): rewrite chat-input with new pill design" +``` + +--- + +### Task 10: Restyle `` (3-dot) + +**Files:** +- Modify: `libs/chat/src/lib/primitives/chat-typing-indicator/chat-typing-indicator.component.ts` +- Create: `libs/chat/src/lib/styles/chat-typing-indicator.styles.ts` + +- [ ] **Step 1: Styles** + +```ts +// libs/chat/src/lib/styles/chat-typing-indicator.styles.ts +// SPDX-License-Identifier: MIT +export const CHAT_TYPING_INDICATOR_STYLES = ` + :host { display: block; padding: 0 var(--ngaf-chat-space-6) var(--ngaf-chat-space-3); } + .chat-typing__dots { display: inline-flex; gap: 4px; align-items: center; } + .chat-typing__dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--ngaf-chat-text-muted); + animation: ngaf-chat-typing-dot 1.4s ease-in-out infinite both; + } + .chat-typing__dot:nth-child(2) { animation-delay: 0.2s; } + .chat-typing__dot:nth-child(3) { animation-delay: 0.4s; } +`; +``` + +- [ ] **Step 2: Update component template + spec** + +```ts +// libs/chat/src/lib/primitives/chat-typing-indicator/chat-typing-indicator.component.ts +// SPDX-License-Identifier: MIT +import { Component, ChangeDetectionStrategy, input, computed } from '@angular/core'; +import type { Agent } from '../../agent'; +import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens'; +import { CHAT_TYPING_INDICATOR_STYLES } from '../../styles/chat-typing-indicator.styles'; + +export function isTyping(agent: Agent): boolean { + if (!agent.isLoading()) return false; + const messages = agent.messages(); + const last = messages[messages.length - 1]; + if (!last) return true; + return last.role !== 'assistant' || (typeof last.content === 'string' && last.content.length === 0); +} + +@Component({ + selector: 'chat-typing-indicator', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + styles: [CHAT_HOST_TOKENS, CHAT_TYPING_INDICATOR_STYLES], + template: ` + @if (typing()) { +
+ + + +
+ } + `, +}) +export class ChatTypingIndicatorComponent { + readonly agent = input.required(); + readonly typing = computed(() => isTyping(this.agent())); +} +``` + +- [ ] **Step 3: Run existing spec** + +Run: `npx nx test chat -- --run chat-typing-indicator.component.spec` +If it fails on Tailwind class assertions, update spec assertions to check `.chat-typing__dot` count instead. + +- [ ] **Step 4: Commit** + +```bash +git add libs/chat/src/lib/primitives/chat-typing-indicator libs/chat/src/lib/styles/chat-typing-indicator.styles.ts +git commit -m "refactor(chat): restyle chat-typing-indicator as 3-dot animation" +``` + +--- + +### Task 11: Restyle `` + +**Files:** +- Modify: `libs/chat/src/lib/primitives/chat-error/chat-error.component.ts` +- Create: `libs/chat/src/lib/styles/chat-error.styles.ts` + +- [ ] **Step 1: Styles** + +```ts +// libs/chat/src/lib/styles/chat-error.styles.ts +// SPDX-License-Identifier: MIT +export const CHAT_ERROR_STYLES = ` + :host { display: block; } + .chat-error { + display: flex; + align-items: flex-start; + gap: 0.5rem; + background: var(--ngaf-chat-error-bg); + border: 1px solid var(--ngaf-chat-error-border); + color: var(--ngaf-chat-error-text); + border-radius: var(--ngaf-chat-radius-card); + padding: 8px 12px; + font-size: var(--ngaf-chat-font-size-sm); + margin: 0 var(--ngaf-chat-space-6) var(--ngaf-chat-space-2); + } + .chat-error__icon { flex-shrink: 0; width: 16px; height: 16px; margin-top: 2px; } + .chat-error__msg { flex: 1; min-width: 0; word-break: break-word; } + .chat-error__dismiss { + background: transparent; + border: 0; + color: inherit; + cursor: pointer; + padding: 0; + width: 16px; + height: 16px; + flex-shrink: 0; + } +`; +``` + +- [ ] **Step 2: Read current component, then rewrite template** + +```ts +// libs/chat/src/lib/primitives/chat-error/chat-error.component.ts +// SPDX-License-Identifier: MIT +import { Component, ChangeDetectionStrategy, input, output, computed } from '@angular/core'; +import type { Agent } from '../../agent'; +import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens'; +import { CHAT_ERROR_STYLES } from '../../styles/chat-error.styles'; + +export function extractErrorMessage(agent: Agent): string | null { + const err = agent.error?.(); + if (!err) return null; + return err instanceof Error ? err.message : String(err); +} + +@Component({ + selector: 'chat-error', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + styles: [CHAT_HOST_TOKENS, CHAT_ERROR_STYLES], + template: ` + @if (message(); as msg) { + + } + `, +}) +export class ChatErrorComponent { + readonly agent = input.required(); + readonly dismissible = input(false); + readonly dismissed = output(); + readonly message = computed(() => extractErrorMessage(this.agent())); +} +``` + +- [ ] **Step 3: Update spec** + +Update existing `chat-error.component.spec.ts` to check `.chat-error` class presence instead of any Tailwind classes; the API is unchanged. + +- [ ] **Step 4: Run, PASS**, **Step 5: Commit** + +```bash +git add libs/chat/src/lib/primitives/chat-error libs/chat/src/lib/styles/chat-error.styles.ts +git commit -m "refactor(chat): restyle chat-error" +``` + +--- + +### Task 12: Restyle `` + +**Files:** +- Modify: `libs/chat/src/lib/primitives/chat-interrupt/chat-interrupt.component.ts` +- Create: `libs/chat/src/lib/styles/chat-interrupt.styles.ts` + +Same pattern as chat-error but warning-colored, with content-projection slot for action buttons. + +- [ ] **Step 1: Styles** + +```ts +// libs/chat/src/lib/styles/chat-interrupt.styles.ts +// SPDX-License-Identifier: MIT +export const CHAT_INTERRUPT_STYLES = ` + :host { display: block; } + .chat-interrupt { + background: var(--ngaf-chat-warning-bg); + color: var(--ngaf-chat-warning-text); + border-left: 3px solid var(--ngaf-chat-warning-text); + border-radius: var(--ngaf-chat-radius-card); + padding: 12px 16px; + margin: 0 var(--ngaf-chat-space-6) var(--ngaf-chat-space-2); + font-size: var(--ngaf-chat-font-size-sm); + } + .chat-interrupt__title { font-weight: 600; margin: 0 0 4px; display: flex; align-items: center; gap: 6px; } + .chat-interrupt__body { margin: 0 0 8px; opacity: 0.95; } + .chat-interrupt__actions { display: flex; gap: 8px; flex-wrap: wrap; } +`; +``` + +- [ ] **Step 2: Read current chat-interrupt.component.ts** to capture `getInterrupt()` helper and its content-projection contract (`` with `$implicit` interrupt value). + +- [ ] **Step 3: Rewrite component** + +```ts +// libs/chat/src/lib/primitives/chat-interrupt/chat-interrupt.component.ts +// SPDX-License-Identifier: MIT +import { Component, ChangeDetectionStrategy, input, contentChild, TemplateRef, computed } from '@angular/core'; +import { NgTemplateOutlet } from '@angular/common'; +import type { Agent, AgentInterrupt } from '../../agent'; +import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens'; +import { CHAT_INTERRUPT_STYLES } from '../../styles/chat-interrupt.styles'; + +export function getInterrupt(agent: Agent): AgentInterrupt | null { + return agent.interrupt?.() ?? null; +} + +@Component({ + selector: 'chat-interrupt', + standalone: true, + imports: [NgTemplateOutlet], + changeDetection: ChangeDetectionStrategy.OnPush, + styles: [CHAT_HOST_TOKENS, CHAT_INTERRUPT_STYLES], + template: ` + @if (interrupt(); as i) { +
+
+ + Agent paused +
+ @if (templateRef()) { + + } @else { +

{{ defaultText(i) }}

+ } +
+ } + `, +}) +export class ChatInterruptComponent { + readonly agent = input.required(); + readonly templateRef = contentChild(TemplateRef); + readonly interrupt = computed(() => getInterrupt(this.agent())); + + defaultText(i: AgentInterrupt): string { + const v = (i as { value?: unknown }).value; + return typeof v === 'string' ? v : JSON.stringify(v); + } +} +``` + +- [ ] **Step 4: Update spec, run, PASS, commit** + +```bash +git add libs/chat/src/lib/primitives/chat-interrupt libs/chat/src/lib/styles/chat-interrupt.styles.ts +git commit -m "refactor(chat): restyle chat-interrupt" +``` + +--- + +### Task 13: Restyle `` + +**Files:** +- Modify: `libs/chat/src/lib/primitives/chat-thread-list/chat-thread-list.component.ts` +- Create: `libs/chat/src/lib/styles/chat-thread-list.styles.ts` + +- [ ] **Step 1: Styles** + +```ts +// libs/chat/src/lib/styles/chat-thread-list.styles.ts +// SPDX-License-Identifier: MIT +export const CHAT_THREAD_LIST_STYLES = ` + :host { display: block; padding: var(--ngaf-chat-space-2); } + .chat-thread-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 2px; } + .chat-thread-list__item { + height: 36px; + padding: 8px 12px; + border-radius: var(--ngaf-chat-radius-button); + cursor: pointer; + color: var(--ngaf-chat-text); + font-size: var(--ngaf-chat-font-size-sm); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + background: transparent; + border: 0; + text-align: left; + width: 100%; + transition: background-color 150ms ease; + } + .chat-thread-list__item:hover { background: color-mix(in srgb, var(--ngaf-chat-text) 5%, transparent); } + .chat-thread-list__item[data-active="true"] { background: var(--ngaf-chat-surface-alt); font-weight: 500; } + .chat-thread-list__new { + width: 100%; + height: 36px; + margin-bottom: var(--ngaf-chat-space-2); + border: 1px dashed var(--ngaf-chat-separator); + border-radius: var(--ngaf-chat-radius-button); + background: transparent; + color: var(--ngaf-chat-primary); + cursor: pointer; + font-size: var(--ngaf-chat-font-size-sm); + transition: background 150ms ease; + } + .chat-thread-list__new:hover { background: var(--ngaf-chat-surface-alt); } +`; +``` + +- [ ] **Step 2: Read current component to capture API (Thread type, threads/activeThreadId inputs, threadSelected output, content-projected template).** + +- [ ] **Step 3: Rewrite template** — keep all inputs/outputs identical; replace inner Tailwind classes with the new `.chat-thread-list*` classes; add `+ New thread` button gated on a new optional `(newThreadRequested)` output that, if subscribed, makes the button render. + +```ts +// libs/chat/src/lib/primitives/chat-thread-list/chat-thread-list.component.ts +// SPDX-License-Identifier: MIT +import { + Component, ChangeDetectionStrategy, input, output, contentChild, TemplateRef, +} from '@angular/core'; +import { NgTemplateOutlet } from '@angular/common'; +import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens'; +import { CHAT_THREAD_LIST_STYLES } from '../../styles/chat-thread-list.styles'; + +export interface Thread { + id: string; + title?: string; +} + +@Component({ + selector: 'chat-thread-list', + standalone: true, + imports: [NgTemplateOutlet], + changeDetection: ChangeDetectionStrategy.OnPush, + styles: [CHAT_HOST_TOKENS, CHAT_THREAD_LIST_STYLES], + template: ` + @if (showNewThreadButton()) { + + } +
    + @for (t of threads(); track t.id) { +
  • + @if (templateRef()) { + + } @else { + + } +
  • + } +
+ `, +}) +export class ChatThreadListComponent { + readonly threads = input([]); + readonly activeThreadId = input(''); + readonly showNewThreadButton = input(false); + readonly threadSelected = output(); + readonly newThreadRequested = output(); + readonly templateRef = contentChild(TemplateRef); +} +``` + +- [ ] **Step 4: Update spec assertions to check the new `.chat-thread-list__item` selectors and `data-active` attribute, then run + commit.** + +```bash +git add libs/chat/src/lib/primitives/chat-thread-list libs/chat/src/lib/styles/chat-thread-list.styles.ts +git commit -m "refactor(chat): restyle chat-thread-list" +``` + +--- + +### Task 14: Restyle `` wrapper + +**Files:** +- Modify: `libs/chat/src/lib/primitives/chat-generative-ui/chat-generative-ui.component.ts` +- Create: `libs/chat/src/lib/styles/chat-generative-ui.styles.ts` + +- [ ] **Step 1: Read current component** — preserve API exactly. The change is purely a visual wrapper: drop Tailwind, add CSS, add token-driven host styles for the rendered registry surface. + +- [ ] **Step 2: Add styles** + +```ts +// libs/chat/src/lib/styles/chat-generative-ui.styles.ts +// SPDX-License-Identifier: MIT +export const CHAT_GENERATIVE_UI_STYLES = ` + :host { + display: block; + color: var(--ngaf-chat-text); + font-size: var(--ngaf-chat-font-size); + line-height: var(--ngaf-chat-line-height); + } + .chat-generative-ui__error { + color: var(--ngaf-chat-error-text); + background: var(--ngaf-chat-error-bg); + border: 1px solid var(--ngaf-chat-error-border); + border-radius: var(--ngaf-chat-radius-card); + padding: 8px 12px; + font-size: var(--ngaf-chat-font-size-sm); + } +`; +``` + +- [ ] **Step 3: Update component to import and apply styles array; replace inner Tailwind classes with the new selectors. Run spec, commit.** + +```bash +git add libs/chat/src/lib/primitives/chat-generative-ui libs/chat/src/lib/styles/chat-generative-ui.styles.ts +git commit -m "refactor(chat): restyle chat-generative-ui wrapper" +``` + +--- + +## Phase 4 — Trace-based card rewrites + +### Task 15: Rewrite `` to use `` + +**Files:** +- Modify: `libs/chat/src/lib/compositions/chat-tool-call-card/chat-tool-call-card.component.ts` + +- [ ] **Step 1: Replace template + styles** + +```ts +// libs/chat/src/lib/compositions/chat-tool-call-card/chat-tool-call-card.component.ts +// SPDX-License-Identifier: MIT +import { Component, ChangeDetectionStrategy, input, computed } from '@angular/core'; +import { ChatTraceComponent, type TraceState } from '../../primitives/chat-trace/chat-trace.component'; +import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens'; + +export interface ToolCallInfo { + id: string; + name: string; + args: unknown; + result?: unknown; +} + +@Component({ + selector: 'chat-tool-call-card', + standalone: true, + imports: [ChatTraceComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + styles: [CHAT_HOST_TOKENS, ` + :host { display: block; } + .tcc__name { font-family: var(--ngaf-chat-font-mono); color: var(--ngaf-chat-text); } + .tcc__status { font-size: var(--ngaf-chat-font-size-xs); } + .tcc__status[data-state="done"] { color: var(--ngaf-chat-success); } + .tcc__status[data-state="error"] { color: var(--ngaf-chat-error-text); } + .tcc__section { padding: 8px 0; } + .tcc__section + .tcc__section { border-top: 1px solid var(--ngaf-chat-separator); } + .tcc__section-label { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--ngaf-chat-text-muted); + margin: 0 0 4px; + } + .tcc__section-body { + font-family: var(--ngaf-chat-font-mono); + font-size: var(--ngaf-chat-font-size-xs); + color: var(--ngaf-chat-text); + white-space: pre-wrap; + overflow-x: auto; + margin: 0; + } + `], + template: ` + + + {{ toolCall().name }} + @if (state() === 'done') { + done + } @else if (state() === 'error') { + error + } @else if (state() === 'running') { + running… + } + +
+ +
{{ formatJson(toolCall().args) }}
+
+ @if (toolCall().result !== undefined) { +
+ +
{{ formatJson(toolCall().result) }}
+
+ } +
+ `, +}) +export class ChatToolCallCardComponent { + readonly toolCall = input.required(); + + readonly state = computed(() => { + const tc = this.toolCall(); + if (tc.result !== undefined) return 'done'; + return 'running'; + }); + + formatJson(value: unknown): string { + if (typeof value === 'string') return value; + try { return JSON.stringify(value, null, 2); } catch { return String(value); } + } +} +``` + +- [ ] **Step 2: Update existing spec** to assert `.tcc__name` text and `chat-trace` host present; remove Tailwind class assertions. + +- [ ] **Step 3: Run, commit** + +```bash +git add libs/chat/src/lib/compositions/chat-tool-call-card +git commit -m "refactor(chat): rewrite chat-tool-call-card on chat-trace" +``` + +--- + +### Task 16: Rewrite `` to use `` + +**Files:** +- Modify: `libs/chat/src/lib/compositions/chat-subagent-card/chat-subagent-card.component.ts` + +- [ ] **Step 1: Rewrite component (parallel to tool-call-card)** + +```ts +// libs/chat/src/lib/compositions/chat-subagent-card/chat-subagent-card.component.ts +// SPDX-License-Identifier: MIT +import { Component, ChangeDetectionStrategy, input, computed } from '@angular/core'; +import { ChatTraceComponent, type TraceState } from '../../primitives/chat-trace/chat-trace.component'; +import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens'; +import type { Subagent, SubagentStatus } from '../../agent/subagent'; + +function statusToTraceState(s: SubagentStatus): TraceState { + switch (s) { + case 'pending': return 'pending'; + case 'running': return 'running'; + case 'complete': return 'done'; + case 'error': return 'error'; + } +} + +@Component({ + selector: 'chat-subagent-card', + standalone: true, + imports: [ChatTraceComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + styles: [CHAT_HOST_TOKENS, ` + :host { display: block; } + .sac__name { color: var(--ngaf-chat-text); font-weight: 500; font-size: var(--ngaf-chat-font-size-sm); } + .sac__id { font-family: var(--ngaf-chat-font-mono); font-size: var(--ngaf-chat-font-size-xs); color: var(--ngaf-chat-text-muted); margin-left: 4px; } + .sac__pill { + padding: 1px 8px; + border-radius: 9999px; + font-size: 11px; + font-weight: 500; + margin-left: 4px; + } + .sac__pill[data-status="pending"] { background: var(--ngaf-chat-surface-alt); color: var(--ngaf-chat-text-muted); } + .sac__pill[data-status="running"] { background: var(--ngaf-chat-warning-bg); color: var(--ngaf-chat-warning-text); } + .sac__pill[data-status="complete"] { color: var(--ngaf-chat-success); } + .sac__pill[data-status="error"] { background: var(--ngaf-chat-error-bg); color: var(--ngaf-chat-error-text); } + .sac__count { font-size: var(--ngaf-chat-font-size-xs); color: var(--ngaf-chat-text-muted); } + .sac__latest-label { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: var(--ngaf-chat-text-muted); margin: 8px 0 4px; } + .sac__latest { + font-family: var(--ngaf-chat-font-mono); + font-size: var(--ngaf-chat-font-size-xs); + color: var(--ngaf-chat-text); + white-space: pre-wrap; + overflow-x: auto; + margin: 0; + } + `], + template: ` + + + Subagent + {{ subagent().toolCallId }} + {{ subagent().status() }} + +
{{ subagent().messages().length }} message(s)
+ @if (subagent().messages().length > 0) { +

Latest message

+
{{ latestMessageContent() }}
+ } +
+ `, +}) +export class ChatSubagentCardComponent { + readonly subagent = input.required(); + readonly state = computed(() => statusToTraceState(this.subagent().status())); + + readonly latestMessageContent = computed(() => { + const messages = this.subagent().messages(); + if (messages.length === 0) return ''; + const last = messages[messages.length - 1]; + const c = last.content; + return typeof c === 'string' ? c : JSON.stringify(c); + }); +} +``` + +- [ ] **Step 2: Update spec, run, commit** + +```bash +git add libs/chat/src/lib/compositions/chat-subagent-card +git commit -m "refactor(chat): rewrite chat-subagent-card on chat-trace" +``` + +--- + +### Task 17: Rewrite `` as vertical history walk + +**Files:** +- Modify: `libs/chat/src/lib/compositions/chat-timeline-slider/chat-timeline-slider.component.ts` + +- [ ] **Step 1: Read current component** to capture inputs/outputs (`agent`, `checkpointSelected`). + +- [ ] **Step 2: Rewrite as vertical list** + +```ts +// libs/chat/src/lib/compositions/chat-timeline-slider/chat-timeline-slider.component.ts +// SPDX-License-Identifier: MIT +import { + Component, ChangeDetectionStrategy, input, output, computed, signal, +} from '@angular/core'; +import type { AgentWithHistory, AgentCheckpoint } from '../../agent'; +import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens'; + +@Component({ + selector: 'chat-timeline-slider', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + styles: [CHAT_HOST_TOKENS, ` + :host { display: block; padding: var(--ngaf-chat-space-2); } + .timeline { list-style: none; padding-left: 12px; margin: 0; border-left: 1px solid var(--ngaf-chat-separator); } + .timeline__entry { + display: block; + width: 100%; + text-align: left; + padding: 4px 8px; + margin: 0 0 0 -1px; + background: transparent; + border: 0; + border-left: 2px solid transparent; + cursor: pointer; + color: var(--ngaf-chat-text-muted); + font-size: var(--ngaf-chat-font-size-sm); + transition: background 150ms ease; + } + .timeline__entry:hover { background: color-mix(in srgb, var(--ngaf-chat-text) 5%, transparent); } + .timeline__entry[data-active="true"] { + border-left-color: var(--ngaf-chat-primary); + color: var(--ngaf-chat-text); + font-weight: 500; + } + .timeline__index { font-family: var(--ngaf-chat-font-mono); font-size: var(--ngaf-chat-font-size-xs); color: var(--ngaf-chat-text-muted); margin-right: 6px; } + .timeline__footer { display: flex; gap: 8px; margin-top: 8px; } + .timeline__btn { + background: transparent; + border: 0; + color: var(--ngaf-chat-primary); + cursor: pointer; + font-size: var(--ngaf-chat-font-size-sm); + padding: 4px 6px; + } + .timeline__btn:hover { text-decoration: underline; } + `], + template: ` +
    + @for (cp of history(); track $index) { +
  • + +
  • + } +
+ @if (history().length > 0) { + + } + `, +}) +export class ChatTimelineSliderComponent { + readonly agent = input.required(); + readonly checkpointSelected = output(); + + readonly history = computed(() => this.agent().history()); + readonly activeIndex = signal(null); + + select(cp: AgentCheckpoint, index: number): void { + this.activeIndex.set(index); + this.checkpointSelected.emit(cp); + } + + jumpToLatest(): void { + const list = this.history(); + if (list.length === 0) return; + this.select(list[list.length - 1], list.length - 1); + } + + summarize(cp: AgentCheckpoint): string { + const id = (cp as { id?: string }).id ?? ''; + return id.length > 28 ? id.slice(0, 28) + '…' : id; + } +} +``` + +- [ ] **Step 3: Delete any horizontal-slider-specific spec assertions; rewrite spec to test vertical list rendering, active-state attribute, footer button.** + +- [ ] **Step 4: Run, commit** + +```bash +git add libs/chat/src/lib/compositions/chat-timeline-slider +git commit -m "refactor(chat): rewrite chat-timeline-slider as vertical history walk" +``` + +--- + +### Task 18: Update `` and `` default templates + +**Files:** +- Modify: `libs/chat/src/lib/primitives/chat-tool-calls/chat-tool-calls.component.ts` +- Modify: `libs/chat/src/lib/primitives/chat-subagents/chat-subagents.component.ts` + +- [ ] **Step 1: For each, change template** so that when no `templateRef` is content-projected, render the new card composition by default. Currently they only render when a template is projected; change to fall back to the card. + +For chat-tool-calls: + +```ts +// libs/chat/src/lib/primitives/chat-tool-calls/chat-tool-calls.component.ts +// SPDX-License-Identifier: MIT +import { Component, ChangeDetectionStrategy, computed, contentChild, input, TemplateRef } from '@angular/core'; +import { NgTemplateOutlet } from '@angular/common'; +import type { Agent, Message, ToolCall } from '../../agent'; +import { ChatToolCallCardComponent } from '../../compositions/chat-tool-call-card/chat-tool-call-card.component'; + +@Component({ + selector: 'chat-tool-calls', + standalone: true, + imports: [NgTemplateOutlet, ChatToolCallCardComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + @for (toolCall of toolCalls(); track toolCall.id) { + @if (templateRef()) { + + } @else { + + } + } + `, +}) +export class ChatToolCallsComponent { + readonly agent = input.required(); + readonly message = input(undefined); + readonly templateRef = contentChild(TemplateRef); + + readonly toolCalls = computed((): ToolCall[] => { + const msg = this.message(); + if (msg && msg.role === 'assistant' && Array.isArray(msg.content)) { + const blocks = msg.content.filter((b) => b.type === 'tool_use'); + const all = this.agent().toolCalls(); + return blocks.map(b => all.find(tc => tc.id === b.id)).filter((x): x is ToolCall => !!x); + } + return this.agent().toolCalls(); + }); +} +``` + +(Note: `ToolCall` from agent contract may or may not match `ToolCallInfo` shape exactly. If shapes differ, add a small mapper at the call site or extend `ToolCallInfo` to accept agent's shape. Verify by reading `libs/chat/src/lib/agent/index.ts`.) + +For chat-subagents: + +```ts +// libs/chat/src/lib/primitives/chat-subagents/chat-subagents.component.ts +// SPDX-License-Identifier: MIT +import { Component, ChangeDetectionStrategy, computed, contentChild, input, TemplateRef } from '@angular/core'; +import { NgTemplateOutlet } from '@angular/common'; +import type { Agent } from '../../agent'; +import type { Subagent } from '../../agent/subagent'; +import { ChatSubagentCardComponent } from '../../compositions/chat-subagent-card/chat-subagent-card.component'; + +export function activeSubagentsFromAgent(agent: Agent): Subagent[] { + const map = agent.subagents?.(); + if (!map) return []; + const out: Subagent[] = []; + map.forEach((sa) => { + const s = sa.status(); + if (s !== 'complete' && s !== 'error') out.push(sa); + }); + return out; +} + +@Component({ + selector: 'chat-subagents', + standalone: true, + imports: [NgTemplateOutlet, ChatSubagentCardComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + @for (subagent of activeSubagents(); track subagent.toolCallId) { + @if (templateRef()) { + + } @else { + + } + } + `, +}) +export class ChatSubagentsComponent { + readonly agent = input.required(); + readonly templateRef = contentChild(TemplateRef); + readonly activeSubagents = computed(() => activeSubagentsFromAgent(this.agent())); +} +``` + +- [ ] **Step 2: Run existing specs**, update to handle new default rendering path. + +- [ ] **Step 3: Commit** + +```bash +git add libs/chat/src/lib/primitives/chat-tool-calls libs/chat/src/lib/primitives/chat-subagents +git commit -m "refactor(chat): default-render trace-based cards for tool-calls/subagents" +``` + +--- + +## Phase 5 — Top-level compositions + +### Task 19: Rewrite `` (embedded mode) + +**Files:** +- Modify: `libs/chat/src/lib/compositions/chat/chat.component.ts` + +- [ ] **Step 1: Read current chat.component.ts** in full to map current props/outputs/contracts. Preserve the public API: inputs `agent`, `views`, `store`, `handlers`, `threads`, `activeThreadId`, output `threadSelected`, output `renderEvent`. Remove the old internal Tailwind structure. + +- [ ] **Step 2: Rewrite component** + +```ts +// libs/chat/src/lib/compositions/chat/chat.component.ts +// SPDX-License-Identifier: MIT +import { + Component, ChangeDetectionStrategy, input, output, computed, effect, viewChild, ElementRef, + DestroyRef, inject, signal, +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { KeyValuePipe } from '@angular/common'; +import type { Agent } from '../../agent'; +import type { ViewRegistry, RenderEvent } from '@ngaf/render'; +import type { A2uiActionMessage } from '@ngaf/a2ui'; +import type { StateStore } from '@json-render/core'; +import { toRenderRegistry, signalStateStore } from '@ngaf/render'; +import { ChatWindowComponent } from '../../primitives/chat-window/chat-window.component'; +import { ChatMessageListComponent } from '../../primitives/chat-message-list/chat-message-list.component'; +import { MessageTemplateDirective } from '../../primitives/chat-message-list/message-template.directive'; +import { ChatMessageComponent } from '../../primitives/chat-message/chat-message.component'; +import { ChatInputComponent } from '../../primitives/chat-input/chat-input.component'; +import { ChatTypingIndicatorComponent } from '../../primitives/chat-typing-indicator/chat-typing-indicator.component'; +import { ChatErrorComponent } from '../../primitives/chat-error/chat-error.component'; +import { ChatInterruptComponent } from '../../primitives/chat-interrupt/chat-interrupt.component'; +import { ChatThreadListComponent, type Thread } from '../../primitives/chat-thread-list/chat-thread-list.component'; +import { ChatGenerativeUiComponent } from '../../primitives/chat-generative-ui/chat-generative-ui.component'; +import { ChatSuggestionsComponent } from '../../primitives/chat-suggestions/chat-suggestions.component'; +import { ChatStreamingMdComponent } from '../../streaming/streaming-markdown.component'; +import { ChatToolCallsComponent } from '../../primitives/chat-tool-calls/chat-tool-calls.component'; +import { ChatSubagentsComponent } from '../../primitives/chat-subagents/chat-subagents.component'; +import { A2uiSurfaceComponent } from '../../a2ui/surface.component'; +import { createContentClassifier, type ContentClassifier } from '../../streaming/content-classifier'; +import { messageContent } from '../shared/message-utils'; +import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens'; +import type { ChatRenderEvent } from './chat-render-event'; + +@Component({ + selector: 'chat', + standalone: true, + imports: [ + KeyValuePipe, + ChatWindowComponent, ChatMessageListComponent, MessageTemplateDirective, ChatMessageComponent, + ChatInputComponent, ChatTypingIndicatorComponent, ChatErrorComponent, ChatInterruptComponent, + ChatThreadListComponent, ChatGenerativeUiComponent, ChatSuggestionsComponent, + ChatStreamingMdComponent, ChatToolCallsComponent, ChatSubagentsComponent, A2uiSurfaceComponent, + ], + changeDetection: ChangeDetectionStrategy.OnPush, + styles: [CHAT_HOST_TOKENS, ` + :host { display: flex; flex-direction: column; height: 100%; min-height: 0; background: var(--ngaf-chat-bg); } + .chat-shell { display: flex; flex: 1; min-height: 0; } + .chat-shell__sidebar { + width: 240px; + flex-shrink: 0; + border-right: 1px solid var(--ngaf-chat-separator); + background: var(--ngaf-chat-surface-alt); + overflow-y: auto; + display: none; + } + @media (min-width: 768px) { .chat-shell__sidebar { display: block; } } + .chat-shell__main { flex: 1; min-width: 0; display: flex; flex-direction: column; } + .chat-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + padding: 60px 20px; + color: var(--ngaf-chat-text-muted); + text-align: center; + } + .chat-empty__title { font-size: 1.125rem; font-weight: 500; color: var(--ngaf-chat-text); margin: 0; } + .chat-empty__sub { margin: 0; font-size: var(--ngaf-chat-font-size-sm); } + .chat-scroll { flex: 1; min-height: 0; overflow-y: auto; } + .chat-scroll::-webkit-scrollbar { width: 6px; } + .chat-scroll::-webkit-scrollbar-thumb { background: var(--ngaf-chat-separator); border-radius: 10px; } + `], + template: ` +
+ @if (threads().length > 0) { + + } +
+ + +
+ @if (agent().messages().length === 0 && !agent().isLoading()) { +
+ + +

How can I help?

+

Ask anything to get started.

+
+
+ } + + + + {{ messageContent(message) }} + + + + @let content = messageContent(message); + @let classified = classifyMessage(content, i); + + + + @if (classified.markdown(); as md) { + + } + @if (classified.spec(); as spec) { + + } + @if (classified.type() === 'a2ui' && views(); as catalog) { + @for (entry of classified.a2uiSurfaces() | keyvalue; track entry.key) { + + } + } + + + + + + + + + {{ messageContent(message) }} + + + + +
+
+ + + +
+
+
+
+ `, +}) +export class ChatComponent { + readonly agent = input.required(); + readonly views = input(undefined); + readonly store = input(undefined); + readonly handlers = input) => unknown | Promise>>({}); + readonly threads = input([]); + readonly activeThreadId = input(''); + readonly threadSelected = output(); + readonly renderEvent = output(); + + private readonly _internalStore = signalStateStore({}); + readonly resolvedStore = computed(() => { + const explicit = this.store(); + if (explicit) return explicit; + if (this.views()) return this._internalStore; + return undefined; + }); + + readonly renderRegistry = computed(() => { + const v = this.views(); + return v ? toRenderRegistry(v) : undefined; + }); + + readonly messageContent = messageContent; + private readonly classifiers = new Map(); + private readonly destroyRef = inject(DestroyRef); + private eventsSubscribed = false; + + private readonly scrollContainer = viewChild>('scrollContainer'); + private readonly messageCount = computed(() => this.agent().messages().length); + private prevMessageCount = 0; + + constructor() { + effect(() => { + if (this.eventsSubscribed) return; + let agent: ReturnType; + try { agent = this.agent(); } catch { return; } + this.eventsSubscribed = true; + agent.events$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((event) => { + if (event.type !== 'state_update') return; + const store = this.resolvedStore(); + if (!store) return; + store.update(event.data); + }); + }); + + effect(() => { + let count: number; + let msgs: ReturnType['messages']>; + try { count = this.messageCount(); msgs = this.agent().messages(); } catch { return; } + const lastContent = msgs.length > 0 ? (msgs[msgs.length - 1] as unknown as Record)['content'] : undefined; + void lastContent; + const el = this.scrollContainer()?.nativeElement; + if (!el) return; + const isNewMessage = count !== this.prevMessageCount; + this.prevMessageCount = count; + const isNearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 150; + if (isNewMessage || isNearBottom) { + requestAnimationFrame(() => { + el.scrollTo({ top: el.scrollHeight, behavior: isNewMessage ? 'instant' : 'smooth' }); + }); + } + }); + } + + prevRole(index: number): 'user' | 'assistant' | 'system' | 'tool' | undefined { + if (index === 0) return undefined; + const prev = this.agent().messages()[index - 1]; + if (!prev) return undefined; + const role = (prev as unknown as { role?: string }).role; + return role === 'human' ? 'user' : role === 'ai' ? 'assistant' : (role as 'user' | 'assistant' | 'system' | 'tool'); + } + + classifyMessage(content: string, index: number): ContentClassifier { + let c = this.classifiers.get(index); + if (!c) { c = createContentClassifier(); this.classifiers.set(index, c); } + c.update(content); + return c; + } + + onSpecEvent(event: RenderEvent, messageIndex: number): void { + this.renderEvent.emit({ messageIndex, event }); + } + + onA2uiAction(message: A2uiActionMessage): void { + void this.agent().submit({ message: JSON.stringify(message) }); + } + + onA2uiEvent(event: RenderEvent, messageIndex: number, surfaceId: string): void { + this.renderEvent.emit({ messageIndex, surfaceId, event }); + } +} +``` + +- [ ] **Step 3: Update existing chat.component.spec.ts** to: + - Drop assertions about avatar div presence + - Drop assertions about Tailwind classes on inner elements + - Add assertion that user messages render inside `chat-message[data-role="user"]` and assistant messages inside `chat-message[data-role="assistant"]` + - Keep behavior tests (auto-scroll on new message, handlers wiring, generative-ui projection) + +- [ ] **Step 4: Run, commit** + +```bash +git add libs/chat/src/lib/compositions/chat +git commit -m "refactor(chat): rewrite composition for new visual design" +``` + +--- + +### Task 20: Build `` composition + +**Files:** +- Create: `libs/chat/src/lib/compositions/chat-popup/chat-popup.component.ts` +- Create: `libs/chat/src/lib/compositions/chat-popup/chat-popup.component.spec.ts` + +- [ ] **Step 1: Failing test** + +```ts +// libs/chat/src/lib/compositions/chat-popup/chat-popup.component.spec.ts +// SPDX-License-Identifier: MIT +import { describe, it, expect } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { Component, signal } from '@angular/core'; +import { ChatPopupComponent } from './chat-popup.component'; +import { mockAgent } from '../../testing/mock-agent'; + +@Component({ + standalone: true, + imports: [ChatPopupComponent], + template: ``, +}) +class Host { + agent = mockAgent(); + isOpen = signal(false); +} + +describe('ChatPopupComponent', () => { + it('renders launcher button when closed and toggles open via click', () => { + const fx = TestBed.createComponent(Host); + fx.detectChanges(); + const launcher = fx.nativeElement.querySelector('chat-launcher-button button'); + expect(launcher).toBeTruthy(); + expect(fx.nativeElement.querySelector('.chat-popup__window[data-open="true"]')).toBeNull(); + launcher.click(); + fx.detectChanges(); + expect(fx.componentInstance.isOpen()).toBe(true); + expect(fx.nativeElement.querySelector('.chat-popup__window[data-open="true"]')).toBeTruthy(); + }); +}); +``` + +- [ ] **Step 2: Run, FAIL.** + +- [ ] **Step 3: Implement** + +```ts +// libs/chat/src/lib/compositions/chat-popup/chat-popup.component.ts +// SPDX-License-Identifier: MIT +import { Component, ChangeDetectionStrategy, input, output, model } from '@angular/core'; +import type { Agent } from '../../agent'; +import { ChatComponent } from '../chat/chat.component'; +import { ChatLauncherButtonComponent } from '../../primitives/chat-launcher-button/chat-launcher-button.component'; +import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens'; + +@Component({ + selector: 'chat-popup', + standalone: true, + imports: [ChatComponent, ChatLauncherButtonComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + styles: [CHAT_HOST_TOKENS, ` + :host { position: fixed; bottom: 1rem; right: 1rem; z-index: 30; } + .chat-popup__launcher { position: relative; } + .chat-popup__window { + position: fixed; + bottom: 5rem; + right: 1rem; + width: 24rem; + height: 600px; + max-height: calc(100vh - 6rem); + background: var(--ngaf-chat-bg); + border-radius: 0.75rem; + box-shadow: 0 5px 40px rgba(0,0,0,.16); + transform-origin: bottom right; + transform: scale(0.95) translateY(20px); + opacity: 0; + pointer-events: none; + transition: transform 200ms ease-out, opacity 100ms ease-out; + overflow: hidden; + display: flex; + flex-direction: column; + } + .chat-popup__window[data-open="true"] { + transform: scale(1) translateY(0); + opacity: 1; + pointer-events: auto; + } + @media (max-width: 640px) { + .chat-popup__window { inset: 0; width: 100vw; height: 100vh; max-height: 100vh; border-radius: 0; bottom: auto; right: auto; } + } + .chat-popup__close { + position: absolute; top: 8px; right: 8px; + width: 32px; height: 32px; + background: transparent; border: 0; cursor: pointer; + color: var(--ngaf-chat-text-muted); + border-radius: 50%; + } + .chat-popup__close:hover { background: var(--ngaf-chat-surface-alt); color: var(--ngaf-chat-text); } + `], + template: ` +
+ +
+ + `, +}) +export class ChatPopupComponent { + readonly agent = input.required(); + readonly open = model(false); + + toggle(): void { this.open.update((v) => !v); } + openWindow(): void { this.open.set(true); } + closeWindow(): void { this.open.set(false); } +} +``` + +- [ ] **Step 4: Run, PASS.** + +- [ ] **Step 5: Commit** + +```bash +git add libs/chat/src/lib/compositions/chat-popup +git commit -m "feat(chat): add chat-popup floating composition" +``` + +--- + +### Task 21: Build `` composition + +**Files:** +- Create: `libs/chat/src/lib/compositions/chat-sidebar/chat-sidebar.component.ts` +- Create: `libs/chat/src/lib/compositions/chat-sidebar/chat-sidebar.component.spec.ts` + +- [ ] **Step 1: Failing test** + +```ts +// libs/chat/src/lib/compositions/chat-sidebar/chat-sidebar.component.spec.ts +// SPDX-License-Identifier: MIT +import { describe, it, expect } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { Component, signal } from '@angular/core'; +import { ChatSidebarComponent } from './chat-sidebar.component'; +import { mockAgent } from '../../testing/mock-agent'; + +@Component({ + standalone: true, + imports: [ChatSidebarComponent], + template: `
app
`, +}) +class Host { agent = mockAgent(); isOpen = signal(false); } + +describe('ChatSidebarComponent', () => { + it('renders projected content and toggles open attribute', () => { + const fx = TestBed.createComponent(Host); + fx.detectChanges(); + expect(fx.nativeElement.querySelector('#content')).toBeTruthy(); + const panel = fx.nativeElement.querySelector('.chat-sidebar__panel'); + expect(panel.getAttribute('data-open')).toBe('false'); + fx.componentInstance.isOpen.set(true); + fx.detectChanges(); + expect(panel.getAttribute('data-open')).toBe('true'); + }); +}); +``` + +- [ ] **Step 2: Run, FAIL.** + +- [ ] **Step 3: Implement** + +```ts +// libs/chat/src/lib/compositions/chat-sidebar/chat-sidebar.component.ts +// SPDX-License-Identifier: MIT +import { Component, ChangeDetectionStrategy, input, model } from '@angular/core'; +import type { Agent } from '../../agent'; +import { ChatComponent } from '../chat/chat.component'; +import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens'; + +@Component({ + selector: 'chat-sidebar', + standalone: true, + imports: [ChatComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + styles: [CHAT_HOST_TOKENS, ` + :host { display: block; } + .chat-sidebar__content { transition: margin-right 300ms ease; min-height: 100vh; } + :host([data-push="true"][data-open="true"]) .chat-sidebar__content { margin-right: 28rem; } + @media (max-width: 640px) { + :host([data-push="true"][data-open="true"]) .chat-sidebar__content { margin-right: 0; } + } + .chat-sidebar__panel { + position: fixed; + top: 0; right: 0; bottom: 0; + width: 28rem; + background: var(--ngaf-chat-bg); + box-shadow: -8px 0 32px rgba(0,0,0,.12); + transform: translateX(100%); + transition: transform 200ms ease-out; + z-index: 30; + display: flex; + flex-direction: column; + } + .chat-sidebar__panel[data-open="true"] { transform: translateX(0); } + @media (max-width: 640px) { + .chat-sidebar__panel { width: 100vw; } + } + .chat-sidebar__close { + position: absolute; top: 8px; right: 8px; + width: 32px; height: 32px; + background: transparent; border: 0; cursor: pointer; + color: var(--ngaf-chat-text-muted); + border-radius: 50%; z-index: 1; + } + .chat-sidebar__close:hover { background: var(--ngaf-chat-surface-alt); color: var(--ngaf-chat-text); } + `], + host: { + '[attr.data-push]': 'pushContent() ? "true" : "false"', + '[attr.data-open]': 'open() ? "true" : "false"', + }, + template: ` +
+ + `, +}) +export class ChatSidebarComponent { + readonly agent = input.required(); + readonly open = model(false); + readonly pushContent = input(false); + + toggle(): void { this.open.update((v) => !v); } + openWindow(): void { this.open.set(true); } + closeWindow(): void { this.open.set(false); } +} +``` + +- [ ] **Step 4: Run, PASS.** + +- [ ] **Step 5: Commit** + +```bash +git add libs/chat/src/lib/compositions/chat-sidebar +git commit -m "feat(chat): add chat-sidebar slide-in composition" +``` + +--- + +## Phase 6 — Restyle remaining compositions + +### Task 22: Rewrite `` + +**Files:** +- Modify: `libs/chat/src/lib/compositions/chat-interrupt-panel/chat-interrupt-panel.component.ts` + +- [ ] **Step 1: Read current component** (101 lines) to capture API. Replace inner Tailwind with new styles using `chat-interrupt`-style card pattern + content-projected action buttons. + +- [ ] **Step 2: Rewrite** — apply same warning-card pattern as `chat-interrupt` primitive but with content-projection slots `[chatInterruptActions]`. Replace Tailwind classes with hand-written CSS using `--ngaf-chat-warning-*` tokens. Add `styles: [CHAT_HOST_TOKENS, ...]`. + +- [ ] **Step 3: Update spec, run, commit** + +```bash +git add libs/chat/src/lib/compositions/chat-interrupt-panel +git commit -m "refactor(chat): restyle chat-interrupt-panel" +``` + +--- + +### Task 23: Rewrite `` and helpers + +**Files:** +- Modify: `libs/chat/src/lib/compositions/chat-debug/chat-debug.component.ts` +- Modify: `libs/chat/src/lib/compositions/chat-debug/debug-checkpoint-card.component.ts` +- Modify: `libs/chat/src/lib/compositions/chat-debug/debug-controls.component.ts` +- Modify: `libs/chat/src/lib/compositions/chat-debug/debug-detail.component.ts` +- Modify: `libs/chat/src/lib/compositions/chat-debug/debug-state-diff.component.ts` +- Modify: `libs/chat/src/lib/compositions/chat-debug/debug-state-inspector.component.ts` +- Modify: `libs/chat/src/lib/compositions/chat-debug/debug-summary.component.ts` +- Modify: `libs/chat/src/lib/compositions/chat-debug/debug-timeline.component.ts` + +For each helper component: + +- [ ] **Step 1: Read the current file**, identify Tailwind classes and CSS-property style attributes (`var(--chat-*)`). +- [ ] **Step 2: Replace** Tailwind classes with hand-written CSS in `styles: [CHAT_HOST_TOKENS, '...']`. Replace `--chat-*` token names with `--ngaf-chat-*`. Where a card or trace pattern fits (debug-checkpoint-card, debug-timeline), use ``. +- [ ] **Step 3: Update specs to match new selectors / classes.** +- [ ] **Step 4: Run debug specs** + +Run: `npx nx test chat -- --run chat-debug` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add libs/chat/src/lib/compositions/chat-debug +git commit -m "refactor(chat): restyle chat-debug + helpers (no Tailwind, new tokens)" +``` + +--- + +## Phase 7 — Cleanup chat library + +### Task 24: Delete obsolete style files + +**Files:** +- Delete: `libs/chat/src/lib/styles/chat-theme.ts` +- Delete: `libs/chat/src/lib/styles/chat-markdown.ts` +- Verify: no remaining imports of `CHAT_THEME_STYLES` or `CHAT_MARKDOWN_STYLES` + +- [ ] **Step 1: Search for references** + +Run: `grep -rn "CHAT_THEME_STYLES\|CHAT_MARKDOWN_STYLES\|chat-theme\|chat-markdown'" libs/chat/src cockpit apps` +Expected: zero hits in `libs/chat`. Hits in cockpit demos/website docs are fine (next phase will update them, but the old import paths will break — expected). + +- [ ] **Step 2: Verify markdown rendering still works** + +The old `chat-markdown.ts` exported `renderMarkdown` and `CHAT_MARKDOWN_STYLES`. The new design moves markdown styling into `chat-streaming-md`'s component-encapsulated styles + the host markdown rules. Read current `streaming/streaming-markdown.component.ts`. If it references the old file, update its imports to the new `styles/chat-markdown.styles.ts` constant (create if not done in Task 14). + +Create `libs/chat/src/lib/styles/chat-markdown.styles.ts`: + +```ts +// SPDX-License-Identifier: MIT +export const CHAT_MARKDOWN_STYLES = ` + :host { display: block; color: var(--ngaf-chat-text); line-height: var(--ngaf-chat-line-height); } + :host h1, :host h2, :host h3, :host h4, :host h5, :host h6 { font-weight: bold; line-height: 1.2; margin: 0 0 1rem; } + :host h1 { font-size: 1.5em; } + :host h2 { font-size: 1.25em; font-weight: 600; } + :host h3 { font-size: 1.1em; } + :host h4 { font-size: 1em; } + :host p { margin: 0 0 1rem; line-height: 1.75; font-size: var(--ngaf-chat-font-size); } + :host p:last-child { margin-bottom: 0; } + :host a { color: var(--ngaf-chat-primary); text-decoration: underline; } + :host ul, :host ol { margin: 0 0 1rem; padding-left: 1.25rem; } + :host code { + background: var(--ngaf-chat-surface-alt); + color: var(--ngaf-chat-text); + padding: 1px 4px; + border-radius: 4px; + font-family: var(--ngaf-chat-font-mono); + font-size: 0.9em; + } + :host pre { + background: var(--ngaf-chat-surface-alt); + color: var(--ngaf-chat-text); + padding: 12px; + border-radius: var(--ngaf-chat-radius-card); + overflow-x: auto; + font-family: var(--ngaf-chat-font-mono); + font-size: var(--ngaf-chat-font-size-sm); + margin: 0 0 1rem; + } + :host pre code { background: transparent; padding: 0; border-radius: 0; } + :host blockquote { + border-left: 3px solid var(--ngaf-chat-separator); + padding-left: 12px; + margin: 0 0 1rem; + color: var(--ngaf-chat-text-muted); + } +`; +``` + +Update `streaming/streaming-markdown.component.ts` to import from `../styles/chat-markdown.styles` and apply `CHAT_HOST_TOKENS` + `CHAT_MARKDOWN_STYLES` in its styles array. + +- [ ] **Step 3: Delete files** + +```bash +git rm libs/chat/src/lib/styles/chat-theme.ts libs/chat/src/lib/styles/chat-markdown.ts +``` + +- [ ] **Step 4: Build to verify nothing left dangling** + +Run: `npx nx build chat 2>&1 | tail -20` +Expected: build succeeds. + +- [ ] **Step 5: Commit** + +```bash +git add libs/chat/src/lib/styles/chat-markdown.styles.ts libs/chat/src/lib/streaming/streaming-markdown.component.ts +git commit -m "refactor(chat): drop legacy chat-theme/chat-markdown style modules" +``` + +--- + +### Task 25: Update `public-api.ts` + +**Files:** +- Modify: `libs/chat/src/public-api.ts` + +- [ ] **Step 1: Apply edits** + +```ts +// libs/chat/src/public-api.ts +// SPDX-License-Identifier: MIT + +// Shared types +export type { ChatConfig } from './lib/provide-chat'; +export type { MessageTemplateType } from './lib/chat.types'; + +// Agent contract (runtime-neutral) +export type { + Agent, AgentWithHistory, Message, Role, ContentBlock, ToolCall, ToolCallStatus, + AgentStatus, AgentInterrupt, Subagent, SubagentStatus, AgentSubmitInput, + AgentSubmitOptions, AgentEvent, AgentStateUpdateEvent, AgentCustomEvent, AgentCheckpoint, +} from './lib/agent'; +export { isUserMessage, isAssistantMessage, isToolMessage, isSystemMessage } from './lib/agent'; + +// Primitives +export { ChatMessageListComponent, getMessageType } from './lib/primitives/chat-message-list/chat-message-list.component'; +export { MessageTemplateDirective } from './lib/primitives/chat-message-list/message-template.directive'; +export { ChatMessageComponent } from './lib/primitives/chat-message/chat-message.component'; +export type { ChatMessageRole } from './lib/primitives/chat-message/chat-message.component'; +export { ChatWindowComponent } from './lib/primitives/chat-window/chat-window.component'; +export { ChatTraceComponent } from './lib/primitives/chat-trace/chat-trace.component'; +export type { TraceState } from './lib/primitives/chat-trace/chat-trace.component'; +export { ChatLauncherButtonComponent } from './lib/primitives/chat-launcher-button/chat-launcher-button.component'; +export { ChatSuggestionsComponent } from './lib/primitives/chat-suggestions/chat-suggestions.component'; +export { ChatInputComponent, submitMessage } from './lib/primitives/chat-input/chat-input.component'; +export { ChatTypingIndicatorComponent, isTyping } from './lib/primitives/chat-typing-indicator/chat-typing-indicator.component'; +export { ChatErrorComponent, extractErrorMessage } from './lib/primitives/chat-error/chat-error.component'; +export { ChatInterruptComponent, getInterrupt } from './lib/primitives/chat-interrupt/chat-interrupt.component'; +export { ChatToolCallsComponent } from './lib/primitives/chat-tool-calls/chat-tool-calls.component'; +export { ChatSubagentsComponent } from './lib/primitives/chat-subagents/chat-subagents.component'; +export { ChatThreadListComponent } from './lib/primitives/chat-thread-list/chat-thread-list.component'; +export type { Thread } from './lib/primitives/chat-thread-list/chat-thread-list.component'; +export { ChatTimelineComponent } from './lib/primitives/chat-timeline/chat-timeline.component'; + +// Compositions +export { ChatComponent } from './lib/compositions/chat/chat.component'; +export { ChatPopupComponent } from './lib/compositions/chat-popup/chat-popup.component'; +export { ChatSidebarComponent } from './lib/compositions/chat-sidebar/chat-sidebar.component'; +export { ChatDebugComponent } from './lib/compositions/chat-debug/chat-debug.component'; +export { ChatTimelineSliderComponent } from './lib/compositions/chat-timeline-slider/chat-timeline-slider.component'; +export { ChatInterruptPanelComponent } from './lib/compositions/chat-interrupt-panel/chat-interrupt-panel.component'; +export { ChatToolCallCardComponent } from './lib/compositions/chat-tool-call-card/chat-tool-call-card.component'; +export type { ToolCallInfo } from './lib/compositions/chat-tool-call-card/chat-tool-call-card.component'; +export { ChatSubagentCardComponent } from './lib/compositions/chat-subagent-card/chat-subagent-card.component'; + +// Streaming +export { ChatStreamingMdComponent } from './lib/streaming/streaming-markdown.component'; + +// DI +export { provideChat, CHAT_CONFIG } from './lib/provide-chat'; + +// A2UI catalog (existing exports — KEEP all of them, abbreviated here for brevity in the plan) +// (Re-paste lines 60-90 of the previous public-api.ts verbatim — A2UI catalog + a2ui types + mockAgent.) +``` + +**IMPORTANT** when applying this edit: the section labeled "A2UI catalog" plus type re-exports plus `mockAgent` exports must be preserved exactly from the existing file. The plan abbreviates them — the engineer must paste from the existing file's lines 53-96. + +- [ ] **Step 2: Verify nothing references removed exports** + +Run: `grep -rn "CHAT_THEME_STYLES\|CHAT_MARKDOWN_STYLES\|ChatMessagesComponent" libs/chat/src apps cockpit | grep -v "node_modules\|dist\|\.spec\."` +Expected: only references in cockpit demos and website docs (those updated in Phase 8/9). + +- [ ] **Step 3: Build** + +Run: `npx nx build chat 2>&1 | tail -10` +Expected: succeeds. + +- [ ] **Step 4: Commit** + +```bash +git add libs/chat/src/public-api.ts +git commit -m "refactor(chat): update public-api for new components" +``` + +--- + +### Task 26: Bump @ngaf/chat to 0.0.3 + +**Files:** +- Modify: `libs/chat/package.json` + +- [ ] **Step 1: Bump** + +Edit `libs/chat/package.json` field `"version": "0.0.2"` → `"version": "0.0.3"`. + +- [ ] **Step 2: Commit (no other libs change unless their source changed — leave their versions alone)** + +```bash +git add libs/chat/package.json +git commit -m "chore(release): bump @ngaf/chat to 0.0.3" +``` + +--- + +## Phase 8 — example-layouts rewrite + +### Task 27: Rewrite `example-chat-layout` (no Tailwind) + +**Files:** +- Modify: `libs/example-layouts/src/lib/example-chat-layout.component.ts` + +- [ ] **Step 1: Replace component** + +```ts +// libs/example-layouts/src/lib/example-chat-layout.component.ts +// SPDX-License-Identifier: MIT +import { Component, ChangeDetectionStrategy, input } from '@angular/core'; + +@Component({ + selector: 'example-chat-layout', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + styles: ` + :host { + display: flex; + flex-direction: column; + height: 100vh; + height: 100dvh; + background: var(--ngaf-chat-bg, #fff); + color: var(--ngaf-chat-text, #1a1a1a); + font-family: var(--ngaf-chat-font-family, system-ui, sans-serif); + } + .layout { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + } + .layout__main { flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; } + .layout__sidebar { + width: 100%; + flex-shrink: 0; + border-top: 1px solid var(--ngaf-chat-separator, #e5e5e5); + overflow-y: auto; + } + .layout__sidebar:empty { display: none; } + @media (min-width: 768px) { + .layout { flex-direction: row; } + .layout--sidebar-left { flex-direction: row-reverse; } + .layout__sidebar { + width: var(--example-layout-sidebar-width, 18rem); + border-top: 0; + border-left: 1px solid var(--ngaf-chat-separator, #e5e5e5); + } + .layout--sidebar-left .layout__sidebar { + border-left: 0; + border-right: 1px solid var(--ngaf-chat-separator, #e5e5e5); + } + } + `, + template: ` +
+
+ +
+ `, +}) +export class ExampleChatLayoutComponent { + readonly sidebarPosition = input<'left' | 'right'>('right'); + readonly sidebarWidth = input('18rem'); +} +``` + +- [ ] **Step 2: Update spec** — assertion should check `.layout__sidebar:empty` collapse, sidebar position class, projected content; remove any Tailwind class assertions. + +- [ ] **Step 3: Run, commit** + +```bash +git add libs/example-layouts/src/lib/example-chat-layout.component.ts +git commit -m "refactor(example-layouts): rewrite example-chat-layout without Tailwind" +``` + +--- + +### Task 28: Rewrite `example-split-layout` (no Tailwind) + +**Files:** +- Modify: `libs/example-layouts/src/lib/example-split-layout.component.ts` + +- [ ] **Step 1: Read current component** to capture inputs/outputs. + +- [ ] **Step 2: Rewrite** — replace Tailwind classes with hand-written CSS using `--ngaf-chat-*` token fallbacks (same pattern as chat-layout). Two named slots remain. Run spec, commit. + +```bash +git add libs/example-layouts/src/lib/example-split-layout.component.ts +git commit -m "refactor(example-layouts): rewrite example-split-layout without Tailwind" +``` + +--- + +## Phase 9 — Cockpit demo updates + +### Task 29: Map all chat-touching demo files + +- [ ] **Step 1: Capture file list** + +Run: +```bash +grep -rln "@ngaf/chat\|/dev/null +``` + +Save the output list to `/tmp/demo-file-list.txt` (used as a checklist for subsequent tasks). + +- [ ] **Step 2: For each file, identify what to change.** Most demos will need: + - `` → `` (selector + class import rename). + - Drop `provideFakeAgUiAgent`-style Tailwind wrapper classes from local templates. + - Drop any direct imports of `CHAT_THEME_STYLES` / `CHAT_MARKDOWN_STYLES` from `@ngaf/chat`. + - Update consuming components' `styles` arrays to use `--ngaf-chat-*` tokens directly (no Tailwind utility classes). + +### Task 30: Update `cockpit/chat/messages/angular` + +**Files:** +- Modify: `cockpit/chat/messages/angular/src/app/app.ts` +- Modify: `cockpit/chat/messages/angular/src/app/app.html` (if used) + +- [ ] **Step 1: Read `app.ts`**, replace `` with `` and update import. Drop any local Tailwind classes. + +- [ ] **Step 2: Run demo build** + +Run: `npx nx build cockpit-chat-messages-angular --configuration=development 2>&1 | tail -10` +Expected: build succeeds. + +- [ ] **Step 3: Commit** + +```bash +git add cockpit/chat/messages +git commit -m "chore(cockpit): update chat/messages demo for chat 0.0.3" +``` + +### Task 31-47: Update remaining 18 demos + +For each of the demos below, repeat the pattern from Task 30 (read, update imports/templates, build to verify, commit). Each is its own task with these steps: + +- [ ] **Task 31:** `cockpit/chat/input/angular` +- [ ] **Task 32:** `cockpit/chat/threads/angular` +- [ ] **Task 33:** `cockpit/chat/tool-calls/angular` +- [ ] **Task 34:** `cockpit/chat/subagents/angular` +- [ ] **Task 35:** `cockpit/chat/timeline/angular` +- [ ] **Task 36:** `cockpit/chat/interrupts/angular` +- [ ] **Task 37:** `cockpit/chat/theming/angular` — special: this demo specifically tests theming. Add an example showing CSS-custom-property override at app level: `chat { --ngaf-chat-primary: oklch(0.55 0.22 264); }`. +- [ ] **Task 38:** `cockpit/chat/debug/angular` +- [ ] **Task 39:** `cockpit/chat/generative-ui/angular` +- [ ] **Task 40:** `cockpit/chat/a2ui/angular` +- [ ] **Task 41:** `cockpit/langgraph/streaming/angular` +- [ ] **Task 42:** `cockpit/langgraph/persistence/angular` +- [ ] **Task 43:** `cockpit/langgraph/memory/angular` +- [ ] **Task 44:** `cockpit/langgraph/interrupts/angular` +- [ ] **Task 45:** `cockpit/langgraph/durable-execution/angular` +- [ ] **Task 46:** `cockpit/langgraph/time-travel/angular` +- [ ] **Task 47:** `cockpit/langgraph/subgraphs/angular` +- [ ] **Task 48:** `cockpit/langgraph/deployment-runtime/angular` + +**For each task above, the steps are:** +- [ ] Read `app.ts` (and `app.html` if used) and any feature components. +- [ ] Apply renames: `` → ``; `ChatMessagesComponent` → `ChatMessageListComponent`. +- [ ] Drop direct imports of `CHAT_THEME_STYLES` / `CHAT_MARKDOWN_STYLES` if present; remove any consumer-side `styles: [CHAT_THEME_STYLES]` reference (the chat library now ships its own styles). +- [ ] Replace any Tailwind utility classes wrapping `` with hand-written CSS using the new tokens. +- [ ] Run: `npx nx build --configuration=development 2>&1 | tail -10` to verify. +- [ ] Commit: `git commit -m "chore(cockpit): update for chat 0.0.3"`. + +### Task 49: Update cockpit footprint + matrix specs + +**Files:** +- Modify: `cockpit/chat/footprint.spec.ts` +- Modify: `cockpit/chat/matrix.spec.ts` + +- [ ] **Step 1: Read both specs.** They likely assert package surface and demo presence. + +- [ ] **Step 2: Update assertions** — add `ChatPopupComponent`, `ChatSidebarComponent`, `ChatTraceComponent`, `ChatWindowComponent`, `ChatLauncherButtonComponent`, `ChatSuggestionsComponent`, `ChatMessageComponent` to the expected exports list. Rename `ChatMessagesComponent` → `ChatMessageListComponent`. Drop `CHAT_THEME_STYLES` / `CHAT_MARKDOWN_STYLES`. + +- [ ] **Step 3: Run** + +Run: `npx nx test cockpit-chat -- --run footprint matrix 2>&1 | tail -20` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add cockpit/chat/footprint.spec.ts cockpit/chat/matrix.spec.ts +git commit -m "test(cockpit): update chat footprint + matrix for 0.0.3 surface" +``` + +--- + +## Phase 10 — Website docs + +### Task 50: Revert PR #157 Tailwind docs and rewrite installation/quickstart + +**Files:** +- Modify: `apps/website/content/docs/chat/getting-started/installation.mdx` +- Modify: `apps/website/content/docs/chat/getting-started/quickstart.mdx` + +- [ ] **Step 1: Rewrite installation.mdx** + +```mdx +# Installation + +Detailed setup guide for `@ngaf/chat`. + +## Requirements + + + +`@ngaf/chat` uses Angular Signals, the `input()` function, and `contentChildren()`. Angular 20 or later is required. + + +Required for the build toolchain and package installation. + + + +## Install the package + +```bash +npm install @ngaf/chat +``` + +That's it. The chat components ship with their own design tokens and component-encapsulated styles. No PostCSS config, no global stylesheet import, no Tailwind required. + +## Peer Dependencies + +`@ngaf/chat` declares the following peer dependencies: + +| Package | Version | Required | +|---------|---------|----------| +| `@angular/core` | `^20.0.0 \|\| ^21.0.0` | Yes | +| `@angular/common` | `^20.0.0 \|\| ^21.0.0` | Yes | +| `@angular/forms` | `^20.0.0 \|\| ^21.0.0` | Yes | +| `@ngaf/render` | `^0.0.1` | Yes | +| `@ngaf/a2ui` | `^0.0.1` | Yes | +| `@ngaf/partial-json` | `^0.0.1` | Yes | +| `@json-render/core` | `^0.16.0` | Yes | +| `@langchain/core` | `^1.1.33` | Yes | +| `marked` | `^15.0.0 \|\| ^16.0.0` | Optional | + +## Configure provideChat() + +Add `provideChat()` alongside your agent provider. This registers `CHAT_CONFIG` for global chat configuration (assistant name, avatar label, render registry). + +```ts +import { ApplicationConfig } from '@angular/core'; +import { provideChat } from '@ngaf/chat'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideChat({ assistantName: 'Assistant' }), + ], +}; +``` + +`provideChat()` is optional — the chat components fall back to sensible defaults. + +## Theming + +The chat ships with a complete light/dark token system. Three ways to customize: + +### 1. Override a single token + +```css +/* src/styles.css */ +:root { + --ngaf-chat-primary: oklch(0.55 0.22 264); +} +``` + +### 2. Force a theme + +```html + +``` + +### 3. Deep override via the optional global stylesheet + +```css +/* src/styles.css */ +@import '@ngaf/chat/chat.css'; + +:root { --ngaf-chat-primary: oklch(0.55 0.22 264); } +``` + +See [Theming](/docs/chat/guides/theming) for the full token reference. +``` + +- [ ] **Step 2: Rewrite quickstart.mdx** — replace existing content with the install step + provider step + minimal usage step. No Tailwind step. + +```mdx +# Quick Start + +Build a streaming chat UI with `@ngaf/chat` in 5 minutes. + + +Angular 20+ project with an agent provider configured. See [Agent Installation](/docs/agent/getting-started/installation) if you need help. + + + + + +```bash +npm install @ngaf/chat +``` + + + + +```ts +import { ApplicationConfig } from '@angular/core'; +import { provideAgent } from '@ngaf/langgraph'; +import { provideChat } from '@ngaf/chat'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideAgent({ apiUrl: 'http://localhost:2024' }), + provideChat({ assistantName: 'Assistant' }), + ], +}; +``` + + + + +```ts +import { Component, ChangeDetectionStrategy, signal } from '@angular/core'; +import { agent } from '@ngaf/langgraph'; +import { ChatComponent } from '@ngaf/chat'; + +@Component({ + selector: 'app-chat-page', + standalone: true, + imports: [ChatComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + template: `
`, +}) +export class ChatPageComponent { + protected readonly chatAgent = agent({ + assistantId: 'chat_agent', + threadId: signal(null), + }); +} +``` + +
+
+ +That's it — no Tailwind setup, no PostCSS config, no global stylesheet import. The chat ships with its own design tokens and component-scoped styles. + +## What's Next + +- [Layout Modes](/docs/chat/guides/layout-modes) — embedded, popup, or sidebar? +- [Theming](/docs/chat/guides/theming) — customize colors, fonts, and shapes. +- [Components](/docs/chat/components) — full primitive and composition reference. +``` + +- [ ] **Step 3: Commit** + +```bash +git add apps/website/content/docs/chat/getting-started/installation.mdx apps/website/content/docs/chat/getting-started/quickstart.mdx +git commit -m "docs(chat): rewrite install + quickstart for 0.0.3 (no Tailwind)" +``` + +--- + +### Task 51: Rewrite `guides/theming.mdx` + +**Files:** +- Modify: `apps/website/content/docs/chat/guides/theming.mdx` + +- [ ] **Step 1: Read existing file** to gauge what stays. + +- [ ] **Step 2: Rewrite** with the full `--ngaf-chat-*` token reference table, light/dark explanation, three override paths (per-token at `:root`, attribute, optional global stylesheet), examples. + +- [ ] **Step 3: Commit** + +```bash +git add apps/website/content/docs/chat/guides/theming.mdx +git commit -m "docs(chat): rewrite theming guide for new token surface" +``` + +--- + +### Task 52: Add new component reference pages + +**Files:** +- Create: `apps/website/content/docs/chat/components/chat-popup.mdx` +- Create: `apps/website/content/docs/chat/components/chat-sidebar.mdx` +- Create: `apps/website/content/docs/chat/components/chat-trace.mdx` +- Create: `apps/website/content/docs/chat/guides/layout-modes.mdx` + +For each: + +- [ ] **Step 1: Match the existing reference page format** — read any existing `apps/website/content/docs/chat/components/*.mdx` to crib the frontmatter + section structure (Inputs / Outputs / Slots / Examples). + +- [ ] **Step 2: Write the page** with: + - One-line summary. + - Inputs table (name / type / default / description). + - Outputs table. + - Content-projection slots table. + - Minimal example. + - "When to use" pointer to the Layout Modes guide. + +- [ ] **Step 3: Update `apps/website/content/docs/chat/components/_meta.json`** (or whatever the docs index file is named — match repo convention) to surface the new pages in the sidebar. + +- [ ] **Step 4: Commit** + +```bash +git add apps/website/content/docs/chat/components/chat-popup.mdx \ + apps/website/content/docs/chat/components/chat-sidebar.mdx \ + apps/website/content/docs/chat/components/chat-trace.mdx \ + apps/website/content/docs/chat/guides/layout-modes.mdx \ + apps/website/content/docs/chat/components/_meta.json +git commit -m "docs(chat): add reference pages for chat-popup, chat-sidebar, chat-trace + layout-modes guide" +``` + +--- + +### Task 53: Update existing component reference pages for renames + +**Files:** +- Modify: `apps/website/content/docs/chat/components/*.mdx` (all existing pages) + +- [ ] **Step 1: Search** + +Run: `grep -ln "chat-messages\|ChatMessagesComponent\|CHAT_THEME_STYLES\|CHAT_MARKDOWN_STYLES" apps/website/content/docs/chat` + +- [ ] **Step 2: For each match, replace** old names with new ones; remove any `CHAT_THEME_STYLES`/`CHAT_MARKDOWN_STYLES` references; add note about removed avatar slot. + +- [ ] **Step 3: Build website to verify MDX still parses** + +Run: `npx nx build website 2>&1 | tail -20` +Expected: succeeds. + +- [ ] **Step 4: Commit** + +```bash +git add apps/website/content/docs/chat +git commit -m "docs(chat): update existing component pages for 0.0.3 renames" +``` + +--- + +## Phase 11 — Verification + +### Task 54: Full test + build sweep + +- [ ] **Step 1: Build the chat library** + +Run: `npx nx build chat 2>&1 | tail -10` +Expected: succeeds, `dist/libs/chat/chat.css` present. + +- [ ] **Step 2: Run all chat tests** + +Run: `npx nx test chat 2>&1 | tail -30` +Expected: all PASS. + +- [ ] **Step 3: Run all example-layouts tests** + +Run: `npx nx test example-layouts 2>&1 | tail -10` +Expected: all PASS. + +- [ ] **Step 4: Build all cockpit demos** + +Run: `npx nx run-many -t build -p $(grep -lE "@ngaf/chat" cockpit/chat/*/angular/package.json cockpit/langgraph/*/angular/package.json | xargs -I{} dirname {} | xargs -I{} basename {} | tr '\n' ',' | sed 's/,$//') --configuration=development 2>&1 | tail -30` +Expected: all build. + +- [ ] **Step 5: Run cockpit chat footprint + matrix tests** + +Run: `npx nx test cockpit-chat 2>&1 | tail -20` +Expected: PASS. + +- [ ] **Step 6: Run conformance tests against the published surface** + +Run: `npx nx test chat -- --run conformance 2>&1 | tail -10` +Expected: PASS. + +If anything fails, fix in place (do not skip), commit fix per failure with descriptive message, then continue. + +--- + +### Task 55: Manual smoke test against the smoke app + +**Files:** +- (External) `~/tmp/ngaf/` + +- [ ] **Step 1: Build chat lib** + +Run: `npx nx build chat 2>&1 | tail -5` + +- [ ] **Step 2: Pack and install into smoke app** + +```bash +cd dist/libs/chat && npm pack && cd - +cd ~/tmp/ngaf && npm install /Users/blove/repos/angular-agent-framework/dist/libs/chat/ngaf-chat-0.0.3.tgz +``` + +- [ ] **Step 3: Remove Tailwind setup from smoke app (it's no longer needed)** + +```bash +cd ~/tmp/ngaf +npm uninstall tailwindcss @tailwindcss/postcss +rm -f .postcssrc.json +cat > src/styles.css <`, ``, ``, ``, ``, ``, ``. + - Renamed: `` → ``. + - Removed: `CHAT_THEME_STYLES`, `CHAT_MARKDOWN_STYLES`, `chat-timeline-slider` horizontal-slider variant, all Tailwind utility-class usage. + - Migration: drop Tailwind setup; override `--ngaf-chat-*` tokens or import `@ngaf/chat/chat.css`. + +- [ ] **Step 3: Open PR** + +```bash +git push -u origin claude/dazzling-dewdney-887eac +gh pr create --title "feat(chat): production-ready visual redesign (0.0.3)" --body "$(cat <<'EOF' +## Summary + +Coordinated rewrite of @ngaf/chat for production-grade visual quality. Three new layout modes (embedded/popup/sidebar), asymmetric message pattern (user bubble + assistant inline), shared chat-trace primitive driving tool calls/subagents/timeline, complete Tailwind removal in favor of component-encapsulated styles + optional @ngaf/chat/chat.css. + +Closes the v0.0.2 publish friction (PR #157): consumers no longer need any Tailwind setup. `npm install @ngaf/chat` + drop `` in a template = working chat. + +## Changes + +- New: `` rewritten, ``, ``, ``, ``, ``, ``, ``. +- Restyled: `` (pill design), `` (3-dot), ``, ``, ``, ``, ``, `` (vertical), ``, `` + helpers, ``. +- Renamed: `` → `` (`ChatMessagesComponent` → `ChatMessageListComponent`). +- Removed: `CHAT_THEME_STYLES`, `CHAT_MARKDOWN_STYLES`, horizontal-slider timeline variant, all Tailwind utility classes. +- Updated: 19 cockpit demos, libs/example-layouts, website docs. +- Reverts the docs added in #157 (Tailwind setup is no longer required). + +## Migration + +```css +/* Before (0.0.2) — required */ +@import "tailwindcss"; +@source "../node_modules/@ngaf/chat"; + +/* After (0.0.3) — optional */ +@import '@ngaf/chat/chat.css'; /* only if you want global token / class overrides */ +``` + +Spec: docs/superpowers/specs/2026-05-01-chat-redesign-design.md +Plan: docs/superpowers/plans/2026-05-01-chat-redesign.md + +## Test plan + +- [x] All chat unit + conformance tests pass. +- [x] All cockpit chat + langgraph demos build cleanly. +- [x] Cockpit footprint + matrix specs assert new exports. +- [x] Smoke app renders correctly with no Tailwind setup. + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" +``` + +- [ ] **Step 4: Confirm PR URL** is returned and reachable. + +--- + +## Verification Checklist (run mentally before claiming done) + +- [ ] Every task in this plan checked off. +- [ ] No `CHAT_THEME_STYLES` / `CHAT_MARKDOWN_STYLES` references in libs/chat or apps or cockpit. +- [ ] No Tailwind utility classes in `libs/chat/src/lib/**`. +- [ ] No Tailwind utility classes in `libs/example-layouts/src/lib/**`. +- [ ] Every chat component imports `CHAT_HOST_TOKENS` in its styles array. +- [ ] `` selector returns 0 results in `grep -r ' Date: Fri, 1 May 2026 11:44:44 -0700 Subject: [PATCH 05/38] feat(chat): add CHAT_HOST_TOKENS design-token constant --- libs/chat/src/lib/styles/chat-tokens.ts | 112 ++++++++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 libs/chat/src/lib/styles/chat-tokens.ts diff --git a/libs/chat/src/lib/styles/chat-tokens.ts b/libs/chat/src/lib/styles/chat-tokens.ts new file mode 100644 index 000000000..17760ebd5 --- /dev/null +++ b/libs/chat/src/lib/styles/chat-tokens.ts @@ -0,0 +1,112 @@ +// libs/chat/src/lib/styles/chat-tokens.ts +// SPDX-License-Identifier: MIT + +const LIGHT_TOKENS = ` + --ngaf-chat-bg: rgb(255, 255, 255); + --ngaf-chat-surface: rgb(255, 255, 255); + --ngaf-chat-surface-alt: rgb(251, 251, 251); + --ngaf-chat-primary: rgb(28, 28, 28); + --ngaf-chat-on-primary: rgb(255, 255, 255); + --ngaf-chat-text: rgb(28, 28, 28); + --ngaf-chat-text-muted: rgb(115, 115, 115); + --ngaf-chat-separator: rgb(229, 229, 229); + --ngaf-chat-muted: rgb(200, 200, 200); + --ngaf-chat-error-bg: #fef2f2; + --ngaf-chat-error-border: #fecaca; + --ngaf-chat-error-text: #dc2626; + --ngaf-chat-warning-bg: #fffbeb; + --ngaf-chat-warning-text: #b45309; + --ngaf-chat-success: #16a34a; + --ngaf-chat-shadow-sm: 0 1px 2px rgba(0,0,0,.05); + --ngaf-chat-shadow-md: 0 4px 6px -1px rgba(0,0,0,.10), 0 2px 4px -1px rgba(0,0,0,.06); + --ngaf-chat-shadow-lg: 0 10px 15px -3px rgba(0,0,0,.10), 0 4px 6px -2px rgba(0,0,0,.05); +`; + +const DARK_TOKENS = ` + --ngaf-chat-bg: rgb(17, 17, 17); + --ngaf-chat-surface: rgb(28, 28, 28); + --ngaf-chat-surface-alt: rgb(44, 44, 44); + --ngaf-chat-primary: rgb(255, 255, 255); + --ngaf-chat-on-primary: rgb(28, 28, 28); + --ngaf-chat-text: rgb(245, 245, 245); + --ngaf-chat-text-muted: rgb(160, 160, 160); + --ngaf-chat-separator: rgb(45, 45, 45); + --ngaf-chat-muted: rgb(60, 60, 60); + --ngaf-chat-error-bg: rgb(45, 21, 21); + --ngaf-chat-error-border: #dc2626; + --ngaf-chat-error-text: #fca5a5; + --ngaf-chat-warning-bg: rgb(45, 35, 21); + --ngaf-chat-warning-text: #fbbf24; + --ngaf-chat-success: #4ade80; +`; + +const GEOMETRY_TOKENS = ` + --ngaf-chat-radius-bubble: 15px; + --ngaf-chat-radius-input: 20px; + --ngaf-chat-radius-card: 8px; + --ngaf-chat-radius-button: 8px; + --ngaf-chat-radius-launcher: 9999px; + --ngaf-chat-max-width: 48rem; +`; + +const TYPOGRAPHY_TOKENS = ` + --ngaf-chat-font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + --ngaf-chat-font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + --ngaf-chat-font-size: 1rem; + --ngaf-chat-font-size-sm: 0.875rem; + --ngaf-chat-font-size-xs: 0.75rem; + --ngaf-chat-line-height: 1.6; + --ngaf-chat-line-height-tight: 1.5; +`; + +const SPACING_TOKENS = ` + --ngaf-chat-space-1: 4px; + --ngaf-chat-space-2: 8px; + --ngaf-chat-space-3: 12px; + --ngaf-chat-space-4: 16px; + --ngaf-chat-space-5: 20px; + --ngaf-chat-space-6: 24px; + --ngaf-chat-space-8: 32px; +`; + +const KEYFRAMES = ` + @keyframes ngaf-chat-spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } + } + @keyframes ngaf-chat-typing-dot { + 0%, 80%, 100% { transform: scale(0.5); opacity: 0.5; } + 40% { transform: scale(1); opacity: 1; } + } + @keyframes ngaf-chat-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.6; } + } + @keyframes ngaf-chat-caret-blink { + 0%, 50% { opacity: 1; } + 50.01%, 100% { opacity: 0; } + } +`; + +/** + * Component-host design tokens. Import into every chat component's `styles` + * array so CSS custom properties resolve on `:host` without consumer setup. + * Light tokens are default; dark applies via prefers-color-scheme OR via the + * `[data-ngaf-chat-theme="dark"]` attribute on the host. Consumers can force + * light by setting `[data-ngaf-chat-theme="light"]`. + */ +export const CHAT_HOST_TOKENS = ` + :host { + ${LIGHT_TOKENS} + ${GEOMETRY_TOKENS} + ${TYPOGRAPHY_TOKENS} + ${SPACING_TOKENS} + font-family: var(--ngaf-chat-font-family); + color: var(--ngaf-chat-text); + } + @media (prefers-color-scheme: dark) { + :host:not([data-ngaf-chat-theme="light"]) { ${DARK_TOKENS} } + } + :host([data-ngaf-chat-theme="dark"]) { ${DARK_TOKENS} } + ${KEYFRAMES} +`; From 1db7ec83fbda9dca7f3adfa11027251577f629c9 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Fri, 1 May 2026 13:06:07 -0700 Subject: [PATCH 06/38] feat(chat): ship optional chat.css global stylesheet --- libs/chat/ng-package.json | 6 ++- libs/chat/package.json | 11 ++++ libs/chat/src/lib/styles/chat.css | 87 +++++++++++++++++++++++++++++++ 3 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 libs/chat/src/lib/styles/chat.css diff --git a/libs/chat/ng-package.json b/libs/chat/ng-package.json index 738d653d4..7f50f48d1 100644 --- a/libs/chat/ng-package.json +++ b/libs/chat/ng-package.json @@ -3,5 +3,9 @@ "dest": "../../dist/libs/chat", "lib": { "entryFile": "src/public-api.ts" - } + }, + "allowedNonPeerDependencies": [], + "assets": [ + { "input": "src/lib/styles", "glob": "chat.css", "output": "." } + ] } diff --git a/libs/chat/package.json b/libs/chat/package.json index c0a267691..2db28ad6d 100644 --- a/libs/chat/package.json +++ b/libs/chat/package.json @@ -1,6 +1,17 @@ { "name": "@ngaf/chat", "version": "0.0.2", + "exports": { + ".": { + "types": "./index.d.ts", + "default": "./fesm2022/ngaf-chat.mjs" + }, + "./testing": { + "types": "./testing.d.ts", + "default": "./fesm2022/ngaf-chat-testing.mjs" + }, + "./chat.css": "./chat.css" + }, "peerDependencies": { "@angular/core": "^20.0.0 || ^21.0.0", "@angular/common": "^20.0.0 || ^21.0.0", diff --git a/libs/chat/src/lib/styles/chat.css b/libs/chat/src/lib/styles/chat.css new file mode 100644 index 000000000..a06c858ed --- /dev/null +++ b/libs/chat/src/lib/styles/chat.css @@ -0,0 +1,87 @@ +/* libs/chat/src/lib/styles/chat.css */ +/* SPDX-License-Identifier: MIT */ + +/* + * Optional global stylesheet for @ngaf/chat. Import once in your global + * styles to: + * 1. Override design tokens at :root (instead of per :host). + * 2. Reach into chat components with parallel global selectors. + * + * Usage: + * /* in src/styles.css *​/ + * @import '@ngaf/chat/chat.css'; + * + * :root { --ngaf-chat-primary: oklch(0.55 0.22 264); } + */ + +:root { + --ngaf-chat-bg: rgb(255, 255, 255); + --ngaf-chat-surface: rgb(255, 255, 255); + --ngaf-chat-surface-alt: rgb(251, 251, 251); + --ngaf-chat-primary: rgb(28, 28, 28); + --ngaf-chat-on-primary: rgb(255, 255, 255); + --ngaf-chat-text: rgb(28, 28, 28); + --ngaf-chat-text-muted: rgb(115, 115, 115); + --ngaf-chat-separator: rgb(229, 229, 229); + --ngaf-chat-muted: rgb(200, 200, 200); + --ngaf-chat-error-bg: #fef2f2; + --ngaf-chat-error-border: #fecaca; + --ngaf-chat-error-text: #dc2626; + --ngaf-chat-warning-bg: #fffbeb; + --ngaf-chat-warning-text: #b45309; + --ngaf-chat-success: #16a34a; + --ngaf-chat-shadow-sm: 0 1px 2px rgba(0,0,0,.05); + --ngaf-chat-shadow-md: 0 4px 6px -1px rgba(0,0,0,.10), 0 2px 4px -1px rgba(0,0,0,.06); + --ngaf-chat-shadow-lg: 0 10px 15px -3px rgba(0,0,0,.10), 0 4px 6px -2px rgba(0,0,0,.05); + --ngaf-chat-radius-bubble: 15px; + --ngaf-chat-radius-input: 20px; + --ngaf-chat-radius-card: 8px; + --ngaf-chat-radius-button: 8px; + --ngaf-chat-radius-launcher: 9999px; + --ngaf-chat-max-width: 48rem; + --ngaf-chat-font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + --ngaf-chat-font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + --ngaf-chat-font-size: 1rem; + --ngaf-chat-font-size-sm: 0.875rem; + --ngaf-chat-font-size-xs: 0.75rem; + --ngaf-chat-line-height: 1.6; + --ngaf-chat-line-height-tight: 1.5; +} + +@media (prefers-color-scheme: dark) { + :root:not([data-ngaf-chat-theme="light"]) { + --ngaf-chat-bg: rgb(17, 17, 17); + --ngaf-chat-surface: rgb(28, 28, 28); + --ngaf-chat-surface-alt: rgb(44, 44, 44); + --ngaf-chat-primary: rgb(255, 255, 255); + --ngaf-chat-on-primary: rgb(28, 28, 28); + --ngaf-chat-text: rgb(245, 245, 245); + --ngaf-chat-text-muted: rgb(160, 160, 160); + --ngaf-chat-separator: rgb(45, 45, 45); + --ngaf-chat-muted: rgb(60, 60, 60); + --ngaf-chat-error-bg: rgb(45, 21, 21); + --ngaf-chat-error-border: #dc2626; + --ngaf-chat-error-text: #fca5a5; + --ngaf-chat-warning-bg: rgb(45, 35, 21); + --ngaf-chat-warning-text: #fbbf24; + --ngaf-chat-success: #4ade80; + } +} + +[data-ngaf-chat-theme="dark"] { + --ngaf-chat-bg: rgb(17, 17, 17); + --ngaf-chat-surface: rgb(28, 28, 28); + --ngaf-chat-surface-alt: rgb(44, 44, 44); + --ngaf-chat-primary: rgb(255, 255, 255); + --ngaf-chat-on-primary: rgb(28, 28, 28); + --ngaf-chat-text: rgb(245, 245, 245); + --ngaf-chat-text-muted: rgb(160, 160, 160); + --ngaf-chat-separator: rgb(45, 45, 45); + --ngaf-chat-muted: rgb(60, 60, 60); + --ngaf-chat-error-bg: rgb(45, 21, 21); + --ngaf-chat-error-border: #dc2626; + --ngaf-chat-error-text: #fca5a5; + --ngaf-chat-warning-bg: rgb(45, 35, 21); + --ngaf-chat-warning-text: #fbbf24; + --ngaf-chat-success: #4ade80; +} From a0a0093e1e15facb73194a393a40a5bd9614be55 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Fri, 1 May 2026 13:16:44 -0700 Subject: [PATCH 07/38] feat(chat): add chat-trace primitive Co-Authored-By: Claude Sonnet 4.6 --- .../chat-trace/chat-trace.component.spec.ts | 124 ++++++++++++++++++ .../chat-trace/chat-trace.component.ts | 69 ++++++++++ libs/chat/src/lib/styles/chat-trace.styles.ts | 37 ++++++ 3 files changed, 230 insertions(+) create mode 100644 libs/chat/src/lib/primitives/chat-trace/chat-trace.component.spec.ts create mode 100644 libs/chat/src/lib/primitives/chat-trace/chat-trace.component.ts create mode 100644 libs/chat/src/lib/styles/chat-trace.styles.ts diff --git a/libs/chat/src/lib/primitives/chat-trace/chat-trace.component.spec.ts b/libs/chat/src/lib/primitives/chat-trace/chat-trace.component.spec.ts new file mode 100644 index 000000000..501fca098 --- /dev/null +++ b/libs/chat/src/lib/primitives/chat-trace/chat-trace.component.spec.ts @@ -0,0 +1,124 @@ +// libs/chat/src/lib/primitives/chat-trace/chat-trace.component.spec.ts +// SPDX-License-Identifier: MIT +import { describe, it, expect } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { signal, computed } from '@angular/core'; +import type { TraceState } from './chat-trace.component'; + +// Tests verify the signal-computed logic that ChatTraceComponent exposes. +// We cannot use createComponent + setInput for signal inputs under vitest JIT +// (Angular's JIT compiler does not process signal-input metadata, so setInput +// throws NG0303). Instead we mirror the component's computed logic directly +// inside runInInjectionContext — the same pattern used by chat-typing-indicator +// and chat-timeline specs in this library. + +function makeTrace(initialState: TraceState = 'pending') { + const state = signal(initialState); + const expandedOverride = signal(null); + + const expanded = computed(() => { + const override = expandedOverride(); + if (override !== null) return override; + return state() === 'running'; + }); + + const expandedStr = computed(() => String(expanded())); + + function toggle() { + expandedOverride.set(!expanded()); + } + + function setState(s: TraceState) { + const prev = state(); + state.set(s); + if (s === 'running') { + expandedOverride.set(null); + } else if (s === 'done' && prev === 'running') { + setTimeout(() => expandedOverride.set(false), 200); + } + } + + return { state, expanded, expandedStr, toggle, setState }; +} + +describe('ChatTraceComponent — expanded computed', () => { + it('is false by default (pending state)', () => { + TestBed.configureTestingModule({}); + TestBed.runInInjectionContext(() => { + const { expanded } = makeTrace('pending'); + expect(expanded()).toBe(false); + }); + }); + + it('auto-expands when state is running', () => { + TestBed.configureTestingModule({}); + TestBed.runInInjectionContext(() => { + const { expanded } = makeTrace('running'); + expect(expanded()).toBe(true); + }); + }); + + it('is false when state is done', () => { + TestBed.configureTestingModule({}); + TestBed.runInInjectionContext(() => { + const { expanded } = makeTrace('done'); + expect(expanded()).toBe(false); + }); + }); + + it('is false when state is error', () => { + TestBed.configureTestingModule({}); + TestBed.runInInjectionContext(() => { + const { expanded } = makeTrace('error'); + expect(expanded()).toBe(false); + }); + }); +}); + +describe('ChatTraceComponent — toggle', () => { + it('flips collapsed to expanded', () => { + TestBed.configureTestingModule({}); + TestBed.runInInjectionContext(() => { + const { expanded, toggle } = makeTrace('pending'); + expect(expanded()).toBe(false); + toggle(); + expect(expanded()).toBe(true); + }); + }); + + it('flips expanded to collapsed', () => { + TestBed.configureTestingModule({}); + TestBed.runInInjectionContext(() => { + const { expanded, toggle } = makeTrace('running'); + expect(expanded()).toBe(true); + toggle(); + expect(expanded()).toBe(false); + }); + }); +}); + +describe('ChatTraceComponent — state transitions', () => { + it('clears manual override and auto-expands when transitioning to running', () => { + TestBed.configureTestingModule({}); + TestBed.runInInjectionContext(() => { + const { expanded, toggle, setState } = makeTrace('running'); + // Manually collapse while running + toggle(); + expect(expanded()).toBe(false); + // Transitioning away from running and back resets the override + setState('pending'); + setState('running'); + expect(expanded()).toBe(true); + }); + }); + + it('expandedStr reflects expanded as string', () => { + TestBed.configureTestingModule({}); + TestBed.runInInjectionContext(() => { + const { expandedStr, setState } = makeTrace('pending'); + expect(expandedStr()).toBe('false'); + setState('running'); + expect(expandedStr()).toBe('true'); + }); + }); +}); diff --git a/libs/chat/src/lib/primitives/chat-trace/chat-trace.component.ts b/libs/chat/src/lib/primitives/chat-trace/chat-trace.component.ts new file mode 100644 index 000000000..dfb125490 --- /dev/null +++ b/libs/chat/src/lib/primitives/chat-trace/chat-trace.component.ts @@ -0,0 +1,69 @@ +// libs/chat/src/lib/primitives/chat-trace/chat-trace.component.ts +// SPDX-License-Identifier: MIT +import { Component, ChangeDetectionStrategy, input, signal, effect, computed } from '@angular/core'; +import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens'; +import { CHAT_TRACE_STYLES } from '../../styles/chat-trace.styles'; + +export type TraceState = 'pending' | 'running' | 'done' | 'error'; + +@Component({ + selector: 'chat-trace', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + styles: [CHAT_HOST_TOKENS, CHAT_TRACE_STYLES], + host: { + '[attr.data-state]': 'state()', + '[attr.data-expanded]': 'expandedStr()', + }, + template: ` + + @if (expanded()) { +
+ } + `, +}) +export class ChatTraceComponent { + readonly state = input('pending'); + + /** null = follow auto state-driven logic; non-null = manual override */ + private readonly _expandedOverride = signal(null); + + readonly expanded = computed(() => { + const override = this._expandedOverride(); + if (override !== null) return override; + return this.state() === 'running'; + }); + + readonly expandedStr = computed(() => String(this.expanded())); + + constructor() { + let prevState: TraceState | undefined; + effect(() => { + const s = this.state(); + if (s === 'running') { + this._expandedOverride.set(null); + } else if (s === 'done' && prevState === 'running') { + setTimeout(() => this._expandedOverride.set(false), 200); + } + prevState = s; + }); + } + + toggle(): void { + this._expandedOverride.set(!this.expanded()); + } +} diff --git a/libs/chat/src/lib/styles/chat-trace.styles.ts b/libs/chat/src/lib/styles/chat-trace.styles.ts new file mode 100644 index 000000000..408a98357 --- /dev/null +++ b/libs/chat/src/lib/styles/chat-trace.styles.ts @@ -0,0 +1,37 @@ +// libs/chat/src/lib/styles/chat-trace.styles.ts +// SPDX-License-Identifier: MIT +export const CHAT_TRACE_STYLES = ` + :host { display: block; font-size: var(--ngaf-chat-font-size-sm); } + .chat-trace__header { + display: flex; + align-items: center; + gap: 0.25rem; + cursor: pointer; + user-select: none; + color: var(--ngaf-chat-text-muted); + background: transparent; + border: 0; + padding: 0; + width: 100%; + text-align: left; + font: inherit; + } + .chat-trace__chevron { + width: 12px; + height: 12px; + transition: transform 200ms ease; + flex-shrink: 0; + } + :host([data-expanded="true"]) .chat-trace__chevron { transform: rotate(90deg); } + .chat-trace__label { display: flex; align-items: center; gap: 0.5rem; flex: 1; min-width: 0; } + :host([data-state="running"]) .chat-trace__label { animation: ngaf-chat-pulse 1.5s ease-in-out infinite; } + :host([data-state="error"]) .chat-trace__label { color: var(--ngaf-chat-error-text); } + .chat-trace__content { + padding-left: 1rem; + padding-top: 0.375rem; + margin-left: 0.375rem; + border-left: 1px solid var(--ngaf-chat-separator); + max-height: 250px; + overflow: auto; + } +`; From 8362d7fdb52aef392ed41a352c7e4315cd70a3ed Mon Sep 17 00:00:00 2001 From: Brian Love Date: Fri, 1 May 2026 13:23:46 -0700 Subject: [PATCH 08/38] feat(chat): add chat-window primitive --- .../chat-window/chat-window.component.spec.ts | 31 ++++++++++++++++ .../chat-window/chat-window.component.ts | 18 ++++++++++ .../chat/src/lib/styles/chat-window.styles.ts | 35 +++++++++++++++++++ 3 files changed, 84 insertions(+) create mode 100644 libs/chat/src/lib/primitives/chat-window/chat-window.component.spec.ts create mode 100644 libs/chat/src/lib/primitives/chat-window/chat-window.component.ts create mode 100644 libs/chat/src/lib/styles/chat-window.styles.ts diff --git a/libs/chat/src/lib/primitives/chat-window/chat-window.component.spec.ts b/libs/chat/src/lib/primitives/chat-window/chat-window.component.spec.ts new file mode 100644 index 000000000..5054ed1b8 --- /dev/null +++ b/libs/chat/src/lib/primitives/chat-window/chat-window.component.spec.ts @@ -0,0 +1,31 @@ +// libs/chat/src/lib/primitives/chat-window/chat-window.component.spec.ts +// SPDX-License-Identifier: MIT +import { describe, it, expect } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { Component } from '@angular/core'; +import { ChatWindowComponent } from './chat-window.component'; + +@Component({ + standalone: true, + imports: [ChatWindowComponent], + template: ` + + My Chat +
messages here
+
input here
+
+ `, +}) +class Host {} + +describe('ChatWindowComponent', () => { + it('projects header / body / footer slots', () => { + TestBed.configureTestingModule({}); + const fx = TestBed.createComponent(Host); + fx.detectChanges(); + const win = fx.nativeElement.querySelector('chat-window') as HTMLElement; + expect(win.querySelector('.chat-window__header')!.textContent).toContain('My Chat'); + expect(win.querySelector('.chat-window__body')!.textContent).toContain('messages here'); + expect(win.querySelector('.chat-window__footer')!.textContent).toContain('input here'); + }); +}); diff --git a/libs/chat/src/lib/primitives/chat-window/chat-window.component.ts b/libs/chat/src/lib/primitives/chat-window/chat-window.component.ts new file mode 100644 index 000000000..17077e142 --- /dev/null +++ b/libs/chat/src/lib/primitives/chat-window/chat-window.component.ts @@ -0,0 +1,18 @@ +// libs/chat/src/lib/primitives/chat-window/chat-window.component.ts +// SPDX-License-Identifier: MIT +import { Component, ChangeDetectionStrategy } from '@angular/core'; +import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens'; +import { CHAT_WINDOW_STYLES } from '../../styles/chat-window.styles'; + +@Component({ + selector: 'chat-window', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + styles: [CHAT_HOST_TOKENS, CHAT_WINDOW_STYLES], + template: ` +
+
+ + `, +}) +export class ChatWindowComponent {} diff --git a/libs/chat/src/lib/styles/chat-window.styles.ts b/libs/chat/src/lib/styles/chat-window.styles.ts new file mode 100644 index 000000000..3ed251eeb --- /dev/null +++ b/libs/chat/src/lib/styles/chat-window.styles.ts @@ -0,0 +1,35 @@ +// libs/chat/src/lib/styles/chat-window.styles.ts +// SPDX-License-Identifier: MIT +export const CHAT_WINDOW_STYLES = ` + :host { + display: flex; + flex-direction: column; + min-height: 0; + height: 100%; + background: var(--ngaf-chat-bg); + color: var(--ngaf-chat-text); + } + .chat-window__header { + height: 56px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 var(--ngaf-chat-space-6); + border-bottom: 1px solid var(--ngaf-chat-separator); + font-weight: 500; + color: var(--ngaf-chat-primary); + } + .chat-window__header:empty { display: none; } + .chat-window__body { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; + } + .chat-window__footer { + flex-shrink: 0; + } + .chat-window__footer:empty { display: none; } +`; From 29446c26babeb212aba9e0d9cfc8588de0a575dc Mon Sep 17 00:00:00 2001 From: Brian Love Date: Fri, 1 May 2026 13:26:08 -0700 Subject: [PATCH 09/38] feat(chat): add chat-launcher-button primitive --- .../chat-launcher-button.component.spec.ts | 24 +++++++++++++++++++ .../chat-launcher-button.component.ts | 19 +++++++++++++++ .../lib/styles/chat-launcher-button.styles.ts | 21 ++++++++++++++++ 3 files changed, 64 insertions(+) create mode 100644 libs/chat/src/lib/primitives/chat-launcher-button/chat-launcher-button.component.spec.ts create mode 100644 libs/chat/src/lib/primitives/chat-launcher-button/chat-launcher-button.component.ts create mode 100644 libs/chat/src/lib/styles/chat-launcher-button.styles.ts diff --git a/libs/chat/src/lib/primitives/chat-launcher-button/chat-launcher-button.component.spec.ts b/libs/chat/src/lib/primitives/chat-launcher-button/chat-launcher-button.component.spec.ts new file mode 100644 index 000000000..3fbf9c9eb --- /dev/null +++ b/libs/chat/src/lib/primitives/chat-launcher-button/chat-launcher-button.component.spec.ts @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: MIT +import { describe, it, expect } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { ChatLauncherButtonComponent } from './chat-launcher-button.component'; + +describe('ChatLauncherButtonComponent', () => { + it('renders a button', () => { + TestBed.configureTestingModule({}); + const fx = TestBed.createComponent(ChatLauncherButtonComponent); + fx.detectChanges(); + const btn = (fx.nativeElement as HTMLElement).querySelector('.chat-launcher-button'); + expect(btn).toBeTruthy(); + expect(btn!.tagName).toBe('BUTTON'); + expect(btn!.getAttribute('aria-label')).toBe('Open chat'); + }); + + it('contains an svg icon', () => { + TestBed.configureTestingModule({}); + const fx = TestBed.createComponent(ChatLauncherButtonComponent); + fx.detectChanges(); + const svg = (fx.nativeElement as HTMLElement).querySelector('.chat-launcher-button svg'); + expect(svg).toBeTruthy(); + }); +}); diff --git a/libs/chat/src/lib/primitives/chat-launcher-button/chat-launcher-button.component.ts b/libs/chat/src/lib/primitives/chat-launcher-button/chat-launcher-button.component.ts new file mode 100644 index 000000000..e9300d890 --- /dev/null +++ b/libs/chat/src/lib/primitives/chat-launcher-button/chat-launcher-button.component.ts @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: MIT +import { Component, ChangeDetectionStrategy } from '@angular/core'; +import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens'; +import { CHAT_LAUNCHER_BUTTON_STYLES } from '../../styles/chat-launcher-button.styles'; + +@Component({ + selector: 'chat-launcher-button', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + styles: [CHAT_HOST_TOKENS, CHAT_LAUNCHER_BUTTON_STYLES], + template: ` + + `, +}) +export class ChatLauncherButtonComponent {} diff --git a/libs/chat/src/lib/styles/chat-launcher-button.styles.ts b/libs/chat/src/lib/styles/chat-launcher-button.styles.ts new file mode 100644 index 000000000..91b46e5fa --- /dev/null +++ b/libs/chat/src/lib/styles/chat-launcher-button.styles.ts @@ -0,0 +1,21 @@ +// libs/chat/src/lib/styles/chat-launcher-button.styles.ts +// SPDX-License-Identifier: MIT +export const CHAT_LAUNCHER_BUTTON_STYLES = ` + :host { display: inline-block; } + .chat-launcher-button { + width: 56px; + height: 56px; + border-radius: var(--ngaf-chat-radius-launcher); + background: var(--ngaf-chat-primary); + color: var(--ngaf-chat-on-primary); + border: 0; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + box-shadow: var(--ngaf-chat-shadow-md); + transition: transform 200ms ease; + } + .chat-launcher-button:hover { transform: scale(1.05); } + .chat-launcher-button svg { width: 24px; height: 24px; } +`; From 59ec087584153246230f577c7c25bb1ccd2dbb4e Mon Sep 17 00:00:00 2001 From: Brian Love Date: Fri, 1 May 2026 13:30:48 -0700 Subject: [PATCH 10/38] feat(chat): add chat-suggestions primitive --- .../chat-suggestions.component.spec.ts | 41 +++++++++++++++++++ .../chat-suggestions.component.ts | 22 ++++++++++ .../src/lib/styles/chat-suggestions.styles.ts | 17 ++++++++ 3 files changed, 80 insertions(+) create mode 100644 libs/chat/src/lib/primitives/chat-suggestions/chat-suggestions.component.spec.ts create mode 100644 libs/chat/src/lib/primitives/chat-suggestions/chat-suggestions.component.ts create mode 100644 libs/chat/src/lib/styles/chat-suggestions.styles.ts diff --git a/libs/chat/src/lib/primitives/chat-suggestions/chat-suggestions.component.spec.ts b/libs/chat/src/lib/primitives/chat-suggestions/chat-suggestions.component.spec.ts new file mode 100644 index 000000000..275073b1f --- /dev/null +++ b/libs/chat/src/lib/primitives/chat-suggestions/chat-suggestions.component.spec.ts @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: MIT +// NOTE: Angular signal-based inputs cannot be exercised via TestBed.createComponent + +// componentRef.setInput() under vitest JIT without the analogjs Angular vite plugin — +// setInput() throws NG0303 because JIT does not process signal-input metadata. +// We follow the same pattern used by chat-trace, chat-typing-indicator, and +// a2ui/catalog/button in this library: test signal/output logic via +// runInInjectionContext and verify the class structure directly. +import { describe, it, expect } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { OutputEmitterRef } from '@angular/core'; +import { ChatSuggestionsComponent } from './chat-suggestions.component'; + +describe('ChatSuggestionsComponent', () => { + it('exports the component class', () => { + expect(ChatSuggestionsComponent).toBeDefined(); + }); + + it('suggestions signal defaults to empty array', () => { + TestBed.configureTestingModule({}); + TestBed.runInInjectionContext(() => { + const instance = new ChatSuggestionsComponent(); + expect(instance.suggestions()).toEqual([]); + }); + }); + + it('has a selected output', () => { + TestBed.configureTestingModule({}); + TestBed.runInInjectionContext(() => { + const instance = new ChatSuggestionsComponent(); + expect(instance.selected).toBeInstanceOf(OutputEmitterRef); + }); + }); + + it('renders no buttons when suggestions is empty', () => { + TestBed.configureTestingModule({}); + const fx = TestBed.createComponent(ChatSuggestionsComponent); + fx.detectChanges(); + const buttons = (fx.nativeElement as HTMLElement).querySelectorAll('.chat-suggestion'); + expect(buttons.length).toBe(0); + }); +}); diff --git a/libs/chat/src/lib/primitives/chat-suggestions/chat-suggestions.component.ts b/libs/chat/src/lib/primitives/chat-suggestions/chat-suggestions.component.ts new file mode 100644 index 000000000..abe758bb6 --- /dev/null +++ b/libs/chat/src/lib/primitives/chat-suggestions/chat-suggestions.component.ts @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: MIT +import { Component, ChangeDetectionStrategy, input, output } from '@angular/core'; +import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens'; +import { CHAT_SUGGESTIONS_STYLES } from '../../styles/chat-suggestions.styles'; + +@Component({ + selector: 'chat-suggestions', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + styles: [CHAT_HOST_TOKENS, CHAT_SUGGESTIONS_STYLES], + template: ` +
+ @for (s of suggestions(); track s) { + + } +
+ `, +}) +export class ChatSuggestionsComponent { + readonly suggestions = input([]); + readonly selected = output(); +} diff --git a/libs/chat/src/lib/styles/chat-suggestions.styles.ts b/libs/chat/src/lib/styles/chat-suggestions.styles.ts new file mode 100644 index 000000000..a7c5d6915 --- /dev/null +++ b/libs/chat/src/lib/styles/chat-suggestions.styles.ts @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: MIT +export const CHAT_SUGGESTIONS_STYLES = ` + :host { display: block; } + .chat-suggestions { display: flex; flex-wrap: wrap; gap: 6px; justify-content: center; } + .chat-suggestion { + padding: 6px 10px; + font-size: var(--ngaf-chat-font-size-xs); + border-radius: var(--ngaf-chat-radius-bubble); + border: 1px solid var(--ngaf-chat-muted); + background: transparent; + color: var(--ngaf-chat-text); + cursor: pointer; + transition: transform 200ms ease; + } + .chat-suggestion:hover { transform: scale(1.03); } + .chat-suggestion:disabled { cursor: wait; opacity: 0.6; } +`; From 448df26e74995d35d1da323e72fbe54bf07998c5 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Fri, 1 May 2026 13:37:28 -0700 Subject: [PATCH 11/38] feat(chat): add chat-message primitive (asymmetric user/assistant) Co-Authored-By: Claude Sonnet 4.6 --- .../chat-message.component.spec.ts | 16 ++++ .../chat-message/chat-message.component.ts | 48 ++++++++++++ .../src/lib/styles/chat-message.styles.ts | 77 +++++++++++++++++++ 3 files changed, 141 insertions(+) create mode 100644 libs/chat/src/lib/primitives/chat-message/chat-message.component.spec.ts create mode 100644 libs/chat/src/lib/primitives/chat-message/chat-message.component.ts create mode 100644 libs/chat/src/lib/styles/chat-message.styles.ts diff --git a/libs/chat/src/lib/primitives/chat-message/chat-message.component.spec.ts b/libs/chat/src/lib/primitives/chat-message/chat-message.component.spec.ts new file mode 100644 index 000000000..231f50c7f --- /dev/null +++ b/libs/chat/src/lib/primitives/chat-message/chat-message.component.spec.ts @@ -0,0 +1,16 @@ +// libs/chat/src/lib/primitives/chat-message/chat-message.component.spec.ts +// SPDX-License-Identifier: MIT +import { describe, it, expect } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { ChatMessageComponent } from './chat-message.component'; + +describe('ChatMessageComponent', () => { + it('instantiates without error', () => { + TestBed.configureTestingModule({}); + let component!: ChatMessageComponent; + TestBed.runInInjectionContext(() => { + component = new ChatMessageComponent(); + }); + expect(component).toBeTruthy(); + }); +}); diff --git a/libs/chat/src/lib/primitives/chat-message/chat-message.component.ts b/libs/chat/src/lib/primitives/chat-message/chat-message.component.ts new file mode 100644 index 000000000..ff99ce66c --- /dev/null +++ b/libs/chat/src/lib/primitives/chat-message/chat-message.component.ts @@ -0,0 +1,48 @@ +// libs/chat/src/lib/primitives/chat-message/chat-message.component.ts +// SPDX-License-Identifier: MIT +import { Component, ChangeDetectionStrategy, input, computed } from '@angular/core'; +import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens'; +import { CHAT_MESSAGE_STYLES } from '../../styles/chat-message.styles'; + +export type ChatMessageRole = 'user' | 'assistant' | 'system' | 'tool'; + +@Component({ + selector: 'chat-message', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + styles: [CHAT_HOST_TOKENS, CHAT_MESSAGE_STYLES], + host: { + '[attr.data-role]': 'role()', + '[attr.data-current]': 'currentStr()', + '[attr.data-prev-role]': 'prevRole() ?? null', + }, + template: ` + @switch (role()) { + @case ('user') { +
+ } + @case ('assistant') { +
+ + @if (streaming() && current()) { + + } +
+
+ +
+ } + @default { +
+ } + } + `, +}) +export class ChatMessageComponent { + readonly role = input.required(); + readonly current = input(false); + readonly streaming = input(false); + readonly prevRole = input(undefined); + + readonly currentStr = computed(() => String(this.current())); +} diff --git a/libs/chat/src/lib/styles/chat-message.styles.ts b/libs/chat/src/lib/styles/chat-message.styles.ts new file mode 100644 index 000000000..dd9df0032 --- /dev/null +++ b/libs/chat/src/lib/styles/chat-message.styles.ts @@ -0,0 +1,77 @@ +// libs/chat/src/lib/styles/chat-message.styles.ts +// SPDX-License-Identifier: MIT +export const CHAT_MESSAGE_STYLES = ` + :host { display: block; } + :host([data-role="user"]) { + display: flex; + justify-content: flex-end; + margin-top: 0.5rem; + } + :host([data-role="user"][data-prev-role="assistant"]) { margin-top: 1.5rem; } + :host([data-role="assistant"]) { + display: block; + position: relative; + margin-top: 1.5rem; + color: var(--ngaf-chat-text); + line-height: var(--ngaf-chat-line-height); + font-size: var(--ngaf-chat-font-size); + max-width: 100%; + } + :host([data-role="assistant"]):first-child { margin-top: 0; } + + .chat-message__bubble { + max-width: 80%; + padding: 8px 12px; + border-radius: var(--ngaf-chat-radius-bubble); + background: var(--ngaf-chat-primary); + color: var(--ngaf-chat-on-primary); + white-space: pre-wrap; + line-height: var(--ngaf-chat-line-height-tight); + font-size: var(--ngaf-chat-font-size); + overflow-wrap: break-word; + } + + .chat-message__assistant-body { + overflow-wrap: break-word; + } + + .chat-message__caret { + display: inline-block; + margin-left: 2px; + width: 0.6ch; + color: var(--ngaf-chat-text-muted); + animation: ngaf-chat-caret-blink 1.2s step-end infinite; + } + + .chat-message__controls { + position: absolute; + left: 0; + bottom: -28px; + display: flex; + gap: 1rem; + opacity: 0; + transition: opacity 200ms ease; + pointer-events: none; + } + :host([data-role="assistant"]:hover) .chat-message__controls, + :host([data-role="assistant"]:focus-within) .chat-message__controls, + :host([data-current="true"]) .chat-message__controls { + opacity: 1; + pointer-events: auto; + } + @media (max-width: 768px) { + :host([data-role="assistant"]) .chat-message__controls { opacity: 1; pointer-events: auto; } + } + .chat-message__control-btn { + width: 20px; + height: 20px; + border: 0; + background: transparent; + color: var(--ngaf-chat-primary); + cursor: pointer; + padding: 0; + transition: transform 200ms ease; + } + .chat-message__control-btn:hover { transform: scale(1.05); } + .chat-message__control-btn svg { width: 16px; height: 16px; pointer-events: none; } +`; From 5d9acc71c42755bff14e11bc63c31c7c825a345f Mon Sep 17 00:00:00 2001 From: Brian Love Date: Fri, 1 May 2026 13:41:26 -0700 Subject: [PATCH 12/38] refactor(chat): rename chat-messages -> chat-message-list Renames the ChatMessagesComponent primitive to ChatMessageListComponent with selector chat-message-list, adds dedicated host styles, and updates all consumers (chat composition, chat-debug composition, public-api). Co-Authored-By: Claude Sonnet 4.6 --- .../chat-debug/chat-debug.component.ts | 10 +++++----- .../src/lib/compositions/chat/chat.component.ts | 10 +++++----- .../chat-message-list.component.spec.ts} | 6 +++--- .../chat-message-list.component.ts} | 7 +++++-- .../message-template.directive.ts | 0 .../src/lib/styles/chat-message-list.styles.ts | 14 ++++++++++++++ libs/chat/src/public-api.ts | 6 +++--- 7 files changed, 35 insertions(+), 18 deletions(-) rename libs/chat/src/lib/primitives/{chat-messages/chat-messages.component.spec.ts => chat-message-list/chat-message-list.component.spec.ts} (93%) rename libs/chat/src/lib/primitives/{chat-messages/chat-messages.component.ts => chat-message-list/chat-message-list.component.ts} (86%) rename libs/chat/src/lib/primitives/{chat-messages => chat-message-list}/message-template.directive.ts (100%) create mode 100644 libs/chat/src/lib/styles/chat-message-list.styles.ts diff --git a/libs/chat/src/lib/compositions/chat-debug/chat-debug.component.ts b/libs/chat/src/lib/compositions/chat-debug/chat-debug.component.ts index f21b2bba6..67b793d74 100644 --- a/libs/chat/src/lib/compositions/chat-debug/chat-debug.component.ts +++ b/libs/chat/src/lib/compositions/chat-debug/chat-debug.component.ts @@ -12,8 +12,8 @@ import { } from '@angular/core'; import { DomSanitizer } from '@angular/platform-browser'; import type { AgentWithHistory } from '../../agent'; -import { ChatMessagesComponent } from '../../primitives/chat-messages/chat-messages.component'; -import { MessageTemplateDirective } from '../../primitives/chat-messages/message-template.directive'; +import { ChatMessageListComponent } from '../../primitives/chat-message-list/chat-message-list.component'; +import { MessageTemplateDirective } from '../../primitives/chat-message-list/message-template.directive'; import { ChatInputComponent } from '../../primitives/chat-input/chat-input.component'; import { ChatTypingIndicatorComponent } from '../../primitives/chat-typing-indicator/chat-typing-indicator.component'; import { ChatErrorComponent } from '../../primitives/chat-error/chat-error.component'; @@ -31,7 +31,7 @@ import { toDebugCheckpoint, extractStateValues } from './debug-utils'; selector: 'chat-debug', standalone: true, imports: [ - ChatMessagesComponent, + ChatMessageListComponent, MessageTemplateDirective, ChatInputComponent, ChatTypingIndicatorComponent, @@ -55,7 +55,7 @@ import { toDebugCheckpoint, extractStateValues } from './debug-utils'; aria-live="polite" >
- +
@@ -97,7 +97,7 @@ import { toDebugCheckpoint, extractStateValues } from './debug-utils';
-
+
diff --git a/libs/chat/src/lib/compositions/chat/chat.component.ts b/libs/chat/src/lib/compositions/chat/chat.component.ts index 45b31cc39..98f9e51d8 100644 --- a/libs/chat/src/lib/compositions/chat/chat.component.ts +++ b/libs/chat/src/lib/compositions/chat/chat.component.ts @@ -18,8 +18,8 @@ import type { Agent } from '../../agent'; import type { ViewRegistry, RenderEvent } from '@ngaf/render'; import type { A2uiActionMessage } from '@ngaf/a2ui'; import type { StateStore } from '@json-render/core'; -import { ChatMessagesComponent } from '../../primitives/chat-messages/chat-messages.component'; -import { MessageTemplateDirective } from '../../primitives/chat-messages/message-template.directive'; +import { ChatMessageListComponent } from '../../primitives/chat-message-list/chat-message-list.component'; +import { MessageTemplateDirective } from '../../primitives/chat-message-list/message-template.directive'; import { ChatInputComponent } from '../../primitives/chat-input/chat-input.component'; import { ChatTypingIndicatorComponent } from '../../primitives/chat-typing-indicator/chat-typing-indicator.component'; import { ChatErrorComponent } from '../../primitives/chat-error/chat-error.component'; @@ -40,7 +40,7 @@ import { KeyValuePipe } from '@angular/common'; selector: 'chat', standalone: true, imports: [ - ChatMessagesComponent, + ChatMessageListComponent, MessageTemplateDirective, ChatInputComponent, ChatTypingIndicatorComponent, @@ -111,7 +111,7 @@ import { KeyValuePipe } from '@angular/common'; } - +
@@ -185,7 +185,7 @@ import { KeyValuePipe } from '@angular/common';
-
+
diff --git a/libs/chat/src/lib/primitives/chat-messages/chat-messages.component.spec.ts b/libs/chat/src/lib/primitives/chat-message-list/chat-message-list.component.spec.ts similarity index 93% rename from libs/chat/src/lib/primitives/chat-messages/chat-messages.component.spec.ts rename to libs/chat/src/lib/primitives/chat-message-list/chat-message-list.component.spec.ts index 2934edfdd..60443a764 100644 --- a/libs/chat/src/lib/primitives/chat-messages/chat-messages.component.spec.ts +++ b/libs/chat/src/lib/primitives/chat-message-list/chat-message-list.component.spec.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT import { describe, it, expect } from 'vitest'; import { signal } from '@angular/core'; -import { getMessageType } from './chat-messages.component'; +import { getMessageType } from './chat-message-list.component'; import { mockAgent } from '../../testing/mock-agent'; import type { Message } from '../../agent'; @@ -32,7 +32,7 @@ describe('getMessageType', () => { }); }); -describe('MessagesComponent — computed messages', () => { +describe('ChatMessageListComponent — computed messages', () => { it('messages() signal reflects the agent messages signal', () => { const msgs: Message[] = [ { id: '1', role: 'user', content: 'hi' }, @@ -64,7 +64,7 @@ describe('MessagesComponent — computed messages', () => { }); }); -describe('MessagesComponent — findTemplate logic', () => { +describe('ChatMessageListComponent — findTemplate logic', () => { it('findTemplate returns matching directive by type', () => { const templates = [ { chatMessageTemplate: () => 'human' as const, templateRef: {} }, diff --git a/libs/chat/src/lib/primitives/chat-messages/chat-messages.component.ts b/libs/chat/src/lib/primitives/chat-message-list/chat-message-list.component.ts similarity index 86% rename from libs/chat/src/lib/primitives/chat-messages/chat-messages.component.ts rename to libs/chat/src/lib/primitives/chat-message-list/chat-message-list.component.ts index acd87be05..6de3bf5f2 100644 --- a/libs/chat/src/lib/primitives/chat-messages/chat-messages.component.ts +++ b/libs/chat/src/lib/primitives/chat-message-list/chat-message-list.component.ts @@ -10,6 +10,8 @@ import { NgTemplateOutlet } from '@angular/common'; import type { Agent, Message } from '../../agent'; import { MessageTemplateDirective } from './message-template.directive'; import type { MessageTemplateType } from '../../chat.types'; +import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens'; +import { CHAT_MESSAGE_LIST_STYLES } from '../../styles/chat-message-list.styles'; /** * Maps a {@link Message} to a {@link MessageTemplateType}. @@ -31,10 +33,11 @@ export function getMessageType(message: Message): MessageTemplateType { } @Component({ - selector: 'chat-messages', + selector: 'chat-message-list', standalone: true, imports: [NgTemplateOutlet, MessageTemplateDirective], changeDetection: ChangeDetectionStrategy.OnPush, + styles: [CHAT_HOST_TOKENS, CHAT_MESSAGE_LIST_STYLES], template: ` @for (message of messages(); track $index) { @let template = findTemplate(getMessageType(message)); @@ -47,7 +50,7 @@ export function getMessageType(message: Message): MessageTemplateType { } `, }) -export class ChatMessagesComponent { +export class ChatMessageListComponent { readonly agent = input.required(); readonly messageTemplates = contentChildren(MessageTemplateDirective); diff --git a/libs/chat/src/lib/primitives/chat-messages/message-template.directive.ts b/libs/chat/src/lib/primitives/chat-message-list/message-template.directive.ts similarity index 100% rename from libs/chat/src/lib/primitives/chat-messages/message-template.directive.ts rename to libs/chat/src/lib/primitives/chat-message-list/message-template.directive.ts diff --git a/libs/chat/src/lib/styles/chat-message-list.styles.ts b/libs/chat/src/lib/styles/chat-message-list.styles.ts new file mode 100644 index 000000000..03d47b092 --- /dev/null +++ b/libs/chat/src/lib/styles/chat-message-list.styles.ts @@ -0,0 +1,14 @@ +// libs/chat/src/lib/styles/chat-message-list.styles.ts +// SPDX-License-Identifier: MIT +export const CHAT_MESSAGE_LIST_STYLES = ` + :host { + display: flex; + flex-direction: column; + gap: 0; + padding: var(--ngaf-chat-space-4) var(--ngaf-chat-space-6); + max-width: var(--ngaf-chat-max-width); + margin: 0 auto; + width: 100%; + box-sizing: border-box; + } +`; diff --git a/libs/chat/src/public-api.ts b/libs/chat/src/public-api.ts index 6741e021f..af43efcf8 100644 --- a/libs/chat/src/public-api.ts +++ b/libs/chat/src/public-api.ts @@ -32,9 +32,9 @@ export { } from './lib/agent'; // Primitives -export { ChatMessagesComponent } from './lib/primitives/chat-messages/chat-messages.component'; -export { MessageTemplateDirective } from './lib/primitives/chat-messages/message-template.directive'; -export { getMessageType } from './lib/primitives/chat-messages/chat-messages.component'; +export { ChatMessageListComponent } from './lib/primitives/chat-message-list/chat-message-list.component'; +export { MessageTemplateDirective } from './lib/primitives/chat-message-list/message-template.directive'; +export { getMessageType } from './lib/primitives/chat-message-list/chat-message-list.component'; export { ChatInputComponent, submitMessage } from './lib/primitives/chat-input/chat-input.component'; export { ChatTypingIndicatorComponent, isTyping } from './lib/primitives/chat-typing-indicator/chat-typing-indicator.component'; export { ChatErrorComponent, extractErrorMessage } from './lib/primitives/chat-error/chat-error.component'; From 6cab3eab94d64ed033e72242649d4656c758c926 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Fri, 1 May 2026 13:44:02 -0700 Subject: [PATCH 13/38] refactor(chat): rewrite chat-input with new pill design --- .../chat-input/chat-input.component.ts | 101 ++++++++++-------- libs/chat/src/lib/styles/chat-input.styles.ts | 67 ++++++++++++ 2 files changed, 124 insertions(+), 44 deletions(-) create mode 100644 libs/chat/src/lib/styles/chat-input.styles.ts diff --git a/libs/chat/src/lib/primitives/chat-input/chat-input.component.ts b/libs/chat/src/lib/primitives/chat-input/chat-input.component.ts index cc32cc72e..c2bbcd753 100644 --- a/libs/chat/src/lib/primitives/chat-input/chat-input.component.ts +++ b/libs/chat/src/lib/primitives/chat-input/chat-input.component.ts @@ -1,3 +1,4 @@ +// libs/chat/src/lib/primitives/chat-input/chat-input.component.ts // SPDX-License-Identifier: MIT import { Component, @@ -11,7 +12,13 @@ import { } from '@angular/core'; import { FormsModule } from '@angular/forms'; import type { Agent } from '../../agent'; +import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens'; +import { CHAT_INPUT_STYLES } from '../../styles/chat-input.styles'; +/** + * Submits a trimmed message to the agent. + * Returns the trimmed string on success, or `null` if the input was empty. + */ export function submitMessage( agent: Agent, text: string, @@ -27,50 +34,44 @@ export function submitMessage( standalone: true, imports: [FormsModule], changeDetection: ChangeDetectionStrategy.OnPush, - styles: [` - textarea { - field-sizing: content; - } - `], + styles: [CHAT_HOST_TOKENS, CHAT_INPUT_STYLES], template: ` - +
+ + +
+ + +
+ + +
+
+ +
`, }) export class ChatInputComponent { @@ -82,14 +83,26 @@ export class ChatInputComponent { readonly isDisabled = computed(() => this.agent().isLoading()); readonly focused = signal(false); + /** Two-way binding helper for ngModel */ + get messageTextProxy(): string { return this.messageText(); } + set messageTextProxy(v: string) { this.messageText.set(v); } + + readonly canSubmit = computed(() => { + if (this.isDisabled()) return false; + return this.messageText().trim().length > 0; + }); + private readonly textareaEl = viewChild>('textareaEl'); + focusTextarea(): void { + this.textareaEl()?.nativeElement.focus(); + } + onSubmit(): void { const submitted = submitMessage(this.agent(), this.messageText()); if (submitted !== null) { this.submitted.emit(submitted); this.messageText.set(''); - // Re-focus the textarea after submit requestAnimationFrame(() => this.textareaEl()?.nativeElement.focus()); } } diff --git a/libs/chat/src/lib/styles/chat-input.styles.ts b/libs/chat/src/lib/styles/chat-input.styles.ts new file mode 100644 index 000000000..d3fb17276 --- /dev/null +++ b/libs/chat/src/lib/styles/chat-input.styles.ts @@ -0,0 +1,67 @@ +// libs/chat/src/lib/styles/chat-input.styles.ts +// SPDX-License-Identifier: MIT +export const CHAT_INPUT_STYLES = ` + :host { display: block; background: var(--ngaf-chat-bg); } + .chat-input__container { padding: 0 0 15px 0; background: var(--ngaf-chat-bg); } + .chat-input__pill { + cursor: text; + position: relative; + background: var(--ngaf-chat-surface-alt); + border: 1px solid var(--ngaf-chat-separator); + border-radius: var(--ngaf-chat-radius-input); + padding: 12px 14px; + padding-right: 56px; + min-height: 75px; + margin: 0 auto; + width: 95%; + box-sizing: border-box; + display: flex; + align-items: flex-start; + transition: border-color 200ms ease; + } + .chat-input__pill:focus-within { border-color: var(--ngaf-chat-text-muted); } + .chat-input__textarea { + flex: 1; + outline: 0; + border: 0; + resize: none; + background: transparent; + color: var(--ngaf-chat-text); + font-family: inherit; + font-size: var(--ngaf-chat-font-size-sm); + line-height: 1.5rem; + width: 100%; + margin: 0; + padding: 0; + field-sizing: content; + min-height: 1.5rem; + max-height: 9rem; + overflow-y: auto; + } + .chat-input__textarea::placeholder { color: var(--ngaf-chat-text-muted); opacity: 1; } + .chat-input__textarea::-webkit-scrollbar { width: 6px; } + .chat-input__textarea::-webkit-scrollbar-thumb { background: var(--ngaf-chat-separator); border-radius: 10px; } + .chat-input__controls { + position: absolute; + right: 14px; + bottom: 12px; + display: flex; + gap: 3px; + } + .chat-input__send { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border: 0; + background: var(--ngaf-chat-primary); + color: var(--ngaf-chat-on-primary); + border-radius: var(--ngaf-chat-radius-button); + cursor: pointer; + transition: transform 200ms ease, background 200ms ease; + } + .chat-input__send:hover:not(:disabled) { transform: scale(1.05); } + .chat-input__send:disabled { background: var(--ngaf-chat-muted); color: var(--ngaf-chat-on-primary); cursor: not-allowed; } + .chat-input__send svg { width: 16px; height: 16px; } +`; From dcd30131ea307080af213aa39260f2bbe954aaa8 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Fri, 1 May 2026 13:45:58 -0700 Subject: [PATCH 14/38] refactor(chat): restyle chat-typing-indicator as 3-dot animation --- .../chat-typing-indicator.component.ts | 42 ++++--------------- .../styles/chat-typing-indicator.styles.ts | 14 +++++++ 2 files changed, 23 insertions(+), 33 deletions(-) create mode 100644 libs/chat/src/lib/styles/chat-typing-indicator.styles.ts diff --git a/libs/chat/src/lib/primitives/chat-typing-indicator/chat-typing-indicator.component.ts b/libs/chat/src/lib/primitives/chat-typing-indicator/chat-typing-indicator.component.ts index f4262603c..bc43ffe04 100644 --- a/libs/chat/src/lib/primitives/chat-typing-indicator/chat-typing-indicator.component.ts +++ b/libs/chat/src/lib/primitives/chat-typing-indicator/chat-typing-indicator.component.ts @@ -1,11 +1,9 @@ +// libs/chat/src/lib/primitives/chat-typing-indicator/chat-typing-indicator.component.ts // SPDX-License-Identifier: MIT -import { - Component, - computed, - input, - ChangeDetectionStrategy, -} from '@angular/core'; +import { Component, ChangeDetectionStrategy, input, computed } from '@angular/core'; import type { Agent } from '../../agent'; +import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens'; +import { CHAT_TYPING_INDICATOR_STYLES } from '../../styles/chat-typing-indicator.styles'; export function isTyping(agent: Agent): boolean { if (!agent.isLoading()) return false; @@ -14,7 +12,6 @@ export function isTyping(agent: Agent): boolean { const last = msgs[msgs.length - 1]; if (last.role === 'user') return true; if (last.role === 'assistant') { - // Empty assistant message: string is empty or content block array is empty return typeof last.content === 'string' ? !last.content : last.content.length === 0; @@ -26,34 +23,13 @@ export function isTyping(agent: Agent): boolean { selector: 'chat-typing-indicator', standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, - styles: [` - .chat-dot { - display: inline-block; - width: 5px; - height: 5px; - border-radius: 50%; - background: var(--chat-text-muted); - animation: chat-dot-pulse 1.4s ease-in-out infinite; - } - .chat-dot:nth-child(2) { animation-delay: 0.2s; } - .chat-dot:nth-child(3) { animation-delay: 0.4s; } - @keyframes chat-dot-pulse { - 0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); } - 40% { opacity: 1; transform: scale(1); } - } - `], + styles: [CHAT_HOST_TOKENS, CHAT_TYPING_INDICATOR_STYLES], template: ` @if (visible()) { -
-
A
-
- - - -
+
+ + +
} `, diff --git a/libs/chat/src/lib/styles/chat-typing-indicator.styles.ts b/libs/chat/src/lib/styles/chat-typing-indicator.styles.ts new file mode 100644 index 000000000..f5d5ee407 --- /dev/null +++ b/libs/chat/src/lib/styles/chat-typing-indicator.styles.ts @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: MIT +export const CHAT_TYPING_INDICATOR_STYLES = ` + :host { display: block; padding: 0 var(--ngaf-chat-space-6) var(--ngaf-chat-space-3); } + .chat-typing__dots { display: inline-flex; gap: 4px; align-items: center; } + .chat-typing__dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--ngaf-chat-text-muted); + animation: ngaf-chat-typing-dot 1.4s ease-in-out infinite both; + } + .chat-typing__dot:nth-child(2) { animation-delay: 0.2s; } + .chat-typing__dot:nth-child(3) { animation-delay: 0.4s; } +`; From 2ea7ee27e1bbcd20e18c2699f5dc465e1ee56fcd Mon Sep 17 00:00:00 2001 From: Brian Love Date: Fri, 1 May 2026 13:46:27 -0700 Subject: [PATCH 15/38] refactor(chat): restyle chat-error --- .../chat-error/chat-error.component.ts | 22 +++++++++---------- libs/chat/src/lib/styles/chat-error.styles.ts | 18 +++++++++++++++ 2 files changed, 29 insertions(+), 11 deletions(-) create mode 100644 libs/chat/src/lib/styles/chat-error.styles.ts diff --git a/libs/chat/src/lib/primitives/chat-error/chat-error.component.ts b/libs/chat/src/lib/primitives/chat-error/chat-error.component.ts index 1d2700593..69a377ec6 100644 --- a/libs/chat/src/lib/primitives/chat-error/chat-error.component.ts +++ b/libs/chat/src/lib/primitives/chat-error/chat-error.component.ts @@ -1,11 +1,9 @@ +// libs/chat/src/lib/primitives/chat-error/chat-error.component.ts // SPDX-License-Identifier: MIT -import { - Component, - computed, - input, - ChangeDetectionStrategy, -} from '@angular/core'; +import { Component, ChangeDetectionStrategy, input, computed } from '@angular/core'; import type { Agent } from '../../agent'; +import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens'; +import { CHAT_ERROR_STYLES } from '../../styles/chat-error.styles'; export function extractErrorMessage(error: unknown): string | null { if (!error) return null; @@ -18,13 +16,15 @@ export function extractErrorMessage(error: unknown): string | null { selector: 'chat-error', standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, + styles: [CHAT_HOST_TOKENS, CHAT_ERROR_STYLES], template: ` @if (errorMessage(); as msg) { - + } `, }) diff --git a/libs/chat/src/lib/styles/chat-error.styles.ts b/libs/chat/src/lib/styles/chat-error.styles.ts new file mode 100644 index 000000000..40b8e7a87 --- /dev/null +++ b/libs/chat/src/lib/styles/chat-error.styles.ts @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: MIT +export const CHAT_ERROR_STYLES = ` + :host { display: block; } + .chat-error { + display: flex; + align-items: flex-start; + gap: 0.5rem; + background: var(--ngaf-chat-error-bg); + border: 1px solid var(--ngaf-chat-error-border); + color: var(--ngaf-chat-error-text); + border-radius: var(--ngaf-chat-radius-card); + padding: 8px 12px; + font-size: var(--ngaf-chat-font-size-sm); + margin: 0 var(--ngaf-chat-space-6) var(--ngaf-chat-space-2); + } + .chat-error__icon { flex-shrink: 0; width: 16px; height: 16px; margin-top: 2px; } + .chat-error__msg { flex: 1; min-width: 0; word-break: break-word; } +`; From 3924af536f858676e5bb456bdee6fb64a548350d Mon Sep 17 00:00:00 2001 From: Brian Love Date: Fri, 1 May 2026 13:47:06 -0700 Subject: [PATCH 16/38] refactor(chat): restyle chat-interrupt --- .../chat-interrupt.component.ts | 38 ++++++++++++------- .../src/lib/styles/chat-interrupt.styles.ts | 16 ++++++++ 2 files changed, 41 insertions(+), 13 deletions(-) create mode 100644 libs/chat/src/lib/styles/chat-interrupt.styles.ts diff --git a/libs/chat/src/lib/primitives/chat-interrupt/chat-interrupt.component.ts b/libs/chat/src/lib/primitives/chat-interrupt/chat-interrupt.component.ts index 60c999536..f080f5733 100644 --- a/libs/chat/src/lib/primitives/chat-interrupt/chat-interrupt.component.ts +++ b/libs/chat/src/lib/primitives/chat-interrupt/chat-interrupt.component.ts @@ -1,3 +1,4 @@ +// libs/chat/src/lib/primitives/chat-interrupt/chat-interrupt.component.ts // SPDX-License-Identifier: MIT import { Component, @@ -10,12 +11,9 @@ import { import { NgTemplateOutlet } from '@angular/common'; import type { Agent } from '../../agent'; import type { AgentInterrupt } from '../../agent/agent-interrupt'; +import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens'; +import { CHAT_INTERRUPT_STYLES } from '../../styles/chat-interrupt.styles'; -/** - * Retrieves the current interrupt value from an Agent, or undefined when - * the runtime does not expose interrupts. - * Exported for unit testing without DOM rendering. - */ export function getInterrupt(agent: Agent): AgentInterrupt | undefined { return agent.interrupt?.(); } @@ -25,21 +23,35 @@ export function getInterrupt(agent: Agent): AgentInterrupt | undefined { standalone: true, imports: [NgTemplateOutlet], changeDetection: ChangeDetectionStrategy.OnPush, + styles: [CHAT_HOST_TOKENS, CHAT_INTERRUPT_STYLES], template: ` @if (interrupt(); as currentInterrupt) { - @if (templateRef()) { - - } +
+
+ + Agent paused +
+ @if (templateRef()) { + + } @else { +

{{ defaultText(currentInterrupt) }}

+ } +
} `, }) export class ChatInterruptComponent { readonly agent = input.required(); - readonly templateRef = contentChild(TemplateRef); - readonly interrupt = computed(() => getInterrupt(this.agent())); + + defaultText(i: AgentInterrupt): string { + const v = (i as { value?: unknown }).value; + return typeof v === 'string' ? v : JSON.stringify(v); + } } diff --git a/libs/chat/src/lib/styles/chat-interrupt.styles.ts b/libs/chat/src/lib/styles/chat-interrupt.styles.ts new file mode 100644 index 000000000..a253ad2d2 --- /dev/null +++ b/libs/chat/src/lib/styles/chat-interrupt.styles.ts @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: MIT +export const CHAT_INTERRUPT_STYLES = ` + :host { display: block; } + .chat-interrupt { + background: var(--ngaf-chat-warning-bg); + color: var(--ngaf-chat-warning-text); + border-left: 3px solid var(--ngaf-chat-warning-text); + border-radius: var(--ngaf-chat-radius-card); + padding: 12px 16px; + margin: 0 var(--ngaf-chat-space-6) var(--ngaf-chat-space-2); + font-size: var(--ngaf-chat-font-size-sm); + } + .chat-interrupt__title { font-weight: 600; margin: 0 0 4px; display: flex; align-items: center; gap: 6px; } + .chat-interrupt__body { margin: 0 0 8px; opacity: 0.95; } + .chat-interrupt__actions { display: flex; gap: 8px; flex-wrap: wrap; } +`; From 88b2ec6ad116f028979da0fbbe5cea9e640ed3b4 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Fri, 1 May 2026 13:48:56 -0700 Subject: [PATCH 17/38] refactor(chat): restyle chat-thread-list, add optional new-thread button --- .../chat-thread-list.component.ts | 41 +++++++++++++++---- .../src/lib/styles/chat-thread-list.styles.ts | 40 ++++++++++++++++++ 2 files changed, 74 insertions(+), 7 deletions(-) create mode 100644 libs/chat/src/lib/styles/chat-thread-list.styles.ts diff --git a/libs/chat/src/lib/primitives/chat-thread-list/chat-thread-list.component.ts b/libs/chat/src/lib/primitives/chat-thread-list/chat-thread-list.component.ts index 35b1ef1ca..bd781bea8 100644 --- a/libs/chat/src/lib/primitives/chat-thread-list/chat-thread-list.component.ts +++ b/libs/chat/src/lib/primitives/chat-thread-list/chat-thread-list.component.ts @@ -1,3 +1,4 @@ +// libs/chat/src/lib/primitives/chat-thread-list/chat-thread-list.component.ts // SPDX-License-Identifier: MIT import { Component, @@ -8,6 +9,8 @@ import { ChangeDetectionStrategy, } from '@angular/core'; import { NgTemplateOutlet } from '@angular/common'; +import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens'; +import { CHAT_THREAD_LIST_STYLES } from '../../styles/chat-thread-list.styles'; export type Thread = { id: string; [key: string]: unknown }; @@ -16,26 +19,50 @@ export type Thread = { id: string; [key: string]: unknown }; standalone: true, imports: [NgTemplateOutlet], changeDetection: ChangeDetectionStrategy.OnPush, + styles: [CHAT_HOST_TOKENS, CHAT_THREAD_LIST_STYLES], template: ` - @for (thread of threads(); track thread.id) { - @if (templateRef()) { - - } + @if (showNewThreadButton()) { + } +
    + @for (thread of threads(); track thread.id) { +
  • + @if (templateRef()) { + + } @else { + + } +
  • + } +
`, }) export class ChatThreadListComponent { readonly threads = input.required(); readonly activeThreadId = input(''); + readonly showNewThreadButton = input(false); readonly threadSelected = output(); + readonly newThreadRequested = output(); readonly templateRef = contentChild(TemplateRef); selectThread(threadId: string): void { this.threadSelected.emit(threadId); } + + protected threadLabel(thread: Thread): string { + const title = thread['title']; + if (typeof title === 'string' && title.length > 0) return title; + return thread.id; + } } diff --git a/libs/chat/src/lib/styles/chat-thread-list.styles.ts b/libs/chat/src/lib/styles/chat-thread-list.styles.ts new file mode 100644 index 000000000..080186b47 --- /dev/null +++ b/libs/chat/src/lib/styles/chat-thread-list.styles.ts @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MIT +export const CHAT_THREAD_LIST_STYLES = ` + :host { display: block; padding: var(--ngaf-chat-space-2); } + .chat-thread-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 2px; } + .chat-thread-list__item { + display: block; + height: 36px; + padding: 8px 12px; + border-radius: var(--ngaf-chat-radius-button); + cursor: pointer; + color: var(--ngaf-chat-text); + font-size: var(--ngaf-chat-font-size-sm); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + background: transparent; + border: 0; + text-align: left; + width: 100%; + box-sizing: border-box; + transition: background-color 150ms ease; + } + .chat-thread-list__item:hover { background: color-mix(in srgb, var(--ngaf-chat-text) 5%, transparent); } + .chat-thread-list__item[data-active="true"] { background: var(--ngaf-chat-surface-alt); font-weight: 500; } + .chat-thread-list__new { + display: block; + width: 100%; + height: 36px; + margin-bottom: var(--ngaf-chat-space-2); + border: 1px dashed var(--ngaf-chat-separator); + border-radius: var(--ngaf-chat-radius-button); + background: transparent; + color: var(--ngaf-chat-primary); + cursor: pointer; + font-size: var(--ngaf-chat-font-size-sm); + box-sizing: border-box; + transition: background 150ms ease; + } + .chat-thread-list__new:hover { background: var(--ngaf-chat-surface-alt); } +`; From fd274be1bcacf047a832636730d6e6f5b5caf604 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Fri, 1 May 2026 13:49:34 -0700 Subject: [PATCH 18/38] refactor(chat): restyle chat-generative-ui wrapper --- .../chat-generative-ui.component.ts | 4 ++++ .../src/lib/styles/chat-generative-ui.styles.ts | 17 +++++++++++++++++ 2 files changed, 21 insertions(+) create mode 100644 libs/chat/src/lib/styles/chat-generative-ui.styles.ts diff --git a/libs/chat/src/lib/primitives/chat-generative-ui/chat-generative-ui.component.ts b/libs/chat/src/lib/primitives/chat-generative-ui/chat-generative-ui.component.ts index b98f90f37..c938825b0 100644 --- a/libs/chat/src/lib/primitives/chat-generative-ui/chat-generative-ui.component.ts +++ b/libs/chat/src/lib/primitives/chat-generative-ui/chat-generative-ui.component.ts @@ -1,3 +1,4 @@ +// libs/chat/src/lib/primitives/chat-generative-ui/chat-generative-ui.component.ts // SPDX-License-Identifier: MIT import { Component, @@ -8,12 +9,15 @@ import { import type { Spec, StateStore } from '@json-render/core'; import type { AngularRegistry, RenderEvent } from '@ngaf/render'; import { RenderSpecComponent } from '@ngaf/render'; +import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens'; +import { CHAT_GENERATIVE_UI_STYLES } from '../../styles/chat-generative-ui.styles'; @Component({ selector: 'chat-generative-ui', standalone: true, imports: [RenderSpecComponent], changeDetection: ChangeDetectionStrategy.OnPush, + styles: [CHAT_HOST_TOKENS, CHAT_GENERATIVE_UI_STYLES], template: ` @if (spec()) { Date: Fri, 1 May 2026 13:52:17 -0700 Subject: [PATCH 19/38] refactor(chat): rewrite chat-tool-call-card on chat-trace --- .../chat-tool-call-card.component.ts | 100 ++++++++++-------- 1 file changed, 54 insertions(+), 46 deletions(-) diff --git a/libs/chat/src/lib/compositions/chat-tool-call-card/chat-tool-call-card.component.ts b/libs/chat/src/lib/compositions/chat-tool-call-card/chat-tool-call-card.component.ts index e4c074612..13d0ad6cd 100644 --- a/libs/chat/src/lib/compositions/chat-tool-call-card/chat-tool-call-card.component.ts +++ b/libs/chat/src/lib/compositions/chat-tool-call-card/chat-tool-call-card.component.ts @@ -1,10 +1,8 @@ +// libs/chat/src/lib/compositions/chat-tool-call-card/chat-tool-call-card.component.ts // SPDX-License-Identifier: MIT -import { - Component, - input, - signal, - ChangeDetectionStrategy, -} from '@angular/core'; +import { Component, ChangeDetectionStrategy, input, computed } from '@angular/core'; +import { ChatTraceComponent, type TraceState } from '../../primitives/chat-trace/chat-trace.component'; +import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens'; export interface ToolCallInfo { id: string; @@ -16,57 +14,67 @@ export interface ToolCallInfo { @Component({ selector: 'chat-tool-call-card', standalone: true, + imports: [ChatTraceComponent], changeDetection: ChangeDetectionStrategy.OnPush, + styles: [CHAT_HOST_TOKENS, ` + :host { display: block; } + .tcc__name { font-family: var(--ngaf-chat-font-mono); color: var(--ngaf-chat-text); } + .tcc__status { font-size: var(--ngaf-chat-font-size-xs); margin-left: 4px; } + .tcc__status[data-state="done"] { color: var(--ngaf-chat-success); } + .tcc__status[data-state="error"] { color: var(--ngaf-chat-error-text); } + .tcc__section { padding: 8px 0; } + .tcc__section + .tcc__section { border-top: 1px solid var(--ngaf-chat-separator); } + .tcc__section-label { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--ngaf-chat-text-muted); + margin: 0 0 4px; + } + .tcc__section-body { + font-family: var(--ngaf-chat-font-mono); + font-size: var(--ngaf-chat-font-size-xs); + color: var(--ngaf-chat-text); + white-space: pre-wrap; + overflow-x: auto; + margin: 0; + } + `], template: ` -
- - - - - @if (expanded()) { -
-
-

Inputs

-
{{ formatJson(toolCall().args) }}
-
- @if (toolCall().result !== undefined) { -
-

Output

-
{{ formatJson(toolCall().result) }}
-
- } + + + {{ toolCall().name }} + @switch (state()) { + @case ('done') { done } + @case ('error') { error } + @case ('running') { running… } + } + +
+ +
{{ formatJson(toolCall().args) }}
+
+ @if (toolCall().result !== undefined) { +
+ +
{{ formatJson(toolCall().result) }}
} -
+ `, }) export class ChatToolCallCardComponent { readonly toolCall = input.required(); - readonly expanded = signal(false); - + readonly state = computed(() => { + const tc = this.toolCall(); + if (tc.result !== undefined) return 'done'; + return 'running'; + }); formatJson(value: unknown): string { if (typeof value === 'string') return value; - try { - return JSON.stringify(value, null, 2); - } catch { - return String(value); - } + try { return JSON.stringify(value, null, 2); } catch { return String(value); } } } From 83ea6ba043f3589439e8efe0cb49c5939ddba761 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Fri, 1 May 2026 13:52:20 -0700 Subject: [PATCH 20/38] refactor(chat): rewrite chat-subagent-card on chat-trace --- .../chat-subagent-card.component.spec.ts | 8 +- .../chat-subagent-card.component.ts | 126 +++++++++--------- 2 files changed, 68 insertions(+), 66 deletions(-) diff --git a/libs/chat/src/lib/compositions/chat-subagent-card/chat-subagent-card.component.spec.ts b/libs/chat/src/lib/compositions/chat-subagent-card/chat-subagent-card.component.spec.ts index c23fa67e2..f18058b90 100644 --- a/libs/chat/src/lib/compositions/chat-subagent-card/chat-subagent-card.component.spec.ts +++ b/libs/chat/src/lib/compositions/chat-subagent-card/chat-subagent-card.component.spec.ts @@ -11,18 +11,18 @@ describe('ChatSubagentCardComponent', () => { describe('statusColor', () => { it('returns muted style for pending', () => { - expect(statusColor('pending')).toContain('var(--chat-text-muted)'); + expect(statusColor('pending')).toContain('var(--ngaf-chat-text-muted)'); }); it('returns warning style for running', () => { - expect(statusColor('running')).toContain('var(--chat-warning-text)'); + expect(statusColor('running')).toContain('var(--ngaf-chat-warning-text)'); }); it('returns success style for complete', () => { - expect(statusColor('complete')).toContain('var(--chat-success)'); + expect(statusColor('complete')).toContain('var(--ngaf-chat-success)'); }); it('returns error style for error', () => { - expect(statusColor('error')).toContain('var(--chat-error-text)'); + expect(statusColor('error')).toContain('var(--ngaf-chat-error-text)'); }); }); diff --git a/libs/chat/src/lib/compositions/chat-subagent-card/chat-subagent-card.component.ts b/libs/chat/src/lib/compositions/chat-subagent-card/chat-subagent-card.component.ts index 05b81a313..8bc41887f 100644 --- a/libs/chat/src/lib/compositions/chat-subagent-card/chat-subagent-card.component.ts +++ b/libs/chat/src/lib/compositions/chat-subagent-card/chat-subagent-card.component.ts @@ -1,87 +1,89 @@ +// libs/chat/src/lib/compositions/chat-subagent-card/chat-subagent-card.component.ts // SPDX-License-Identifier: MIT -import { - Component, - computed, - input, - signal, - ChangeDetectionStrategy, -} from '@angular/core'; +import { Component, ChangeDetectionStrategy, input, computed } from '@angular/core'; +import { ChatTraceComponent, type TraceState } from '../../primitives/chat-trace/chat-trace.component'; +import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens'; import type { Subagent, SubagentStatus } from '../../agent/subagent'; -function statusColor(status: SubagentStatus): string { +/** + * Returns a CSS style string for a subagent's status badge. + * Kept exported for backward compatibility with existing consumers; the + * preferred way to style status visually is via the `data-status` attribute + * + CSS selectors (see component styles below). + */ +export function statusColor(status: SubagentStatus): string { switch (status) { - case 'pending': return 'background: var(--chat-bg-alt); color: var(--chat-text-muted);'; - case 'running': return 'background: var(--chat-warning-bg); color: var(--chat-warning-text);'; - case 'complete': return 'color: var(--chat-success);'; - case 'error': return 'background: var(--chat-error-bg); color: var(--chat-error-text);'; + case 'pending': return 'background: var(--ngaf-chat-surface-alt); color: var(--ngaf-chat-text-muted);'; + case 'running': return 'background: var(--ngaf-chat-warning-bg); color: var(--ngaf-chat-warning-text);'; + case 'complete': return 'color: var(--ngaf-chat-success);'; + case 'error': return 'background: var(--ngaf-chat-error-bg); color: var(--ngaf-chat-error-text);'; } } -export { statusColor }; +function statusToTraceState(s: SubagentStatus): TraceState { + switch (s) { + case 'pending': return 'pending'; + case 'running': return 'running'; + case 'complete': return 'done'; + case 'error': return 'error'; + } +} @Component({ selector: 'chat-subagent-card', standalone: true, + imports: [ChatTraceComponent], changeDetection: ChangeDetectionStrategy.OnPush, + styles: [CHAT_HOST_TOKENS, ` + :host { display: block; } + .sac__name { color: var(--ngaf-chat-text); font-weight: 500; font-size: var(--ngaf-chat-font-size-sm); } + .sac__id { font-family: var(--ngaf-chat-font-mono); font-size: var(--ngaf-chat-font-size-xs); color: var(--ngaf-chat-text-muted); margin-left: 4px; } + .sac__pill { + padding: 1px 8px; + border-radius: 9999px; + font-size: 11px; + font-weight: 500; + margin-left: 4px; + } + .sac__pill[data-status="pending"] { background: var(--ngaf-chat-surface-alt); color: var(--ngaf-chat-text-muted); } + .sac__pill[data-status="running"] { background: var(--ngaf-chat-warning-bg); color: var(--ngaf-chat-warning-text); } + .sac__pill[data-status="complete"] { color: var(--ngaf-chat-success); } + .sac__pill[data-status="error"] { background: var(--ngaf-chat-error-bg); color: var(--ngaf-chat-error-text); } + .sac__count { font-size: var(--ngaf-chat-font-size-xs); color: var(--ngaf-chat-text-muted); } + .sac__latest-label { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: var(--ngaf-chat-text-muted); margin: 8px 0 4px; } + .sac__latest { + font-family: var(--ngaf-chat-font-mono); + font-size: var(--ngaf-chat-font-size-xs); + color: var(--ngaf-chat-text); + white-space: pre-wrap; + overflow-x: auto; + margin: 0; + } + `], template: ` -
- - - - - @if (expanded()) { -
- -
- {{ subagent().messages().length }} message(s) -
- - - @if (subagent().messages().length > 0) { -
-

Latest Message

-
- {{ latestMessageContent() }} -
-
- } -
+ + + Subagent + {{ subagent().toolCallId }} + {{ subagent().status() }} + +
{{ subagent().messages().length }} message(s)
+ @if (subagent().messages().length > 0) { +

Latest message

+
{{ latestMessageContent() }}
} -
+ `, }) export class ChatSubagentCardComponent { readonly subagent = input.required(); - - readonly expanded = signal(false); - - - readonly statusColor = computed(() => statusColor(this.subagent().status())); + readonly state = computed(() => statusToTraceState(this.subagent().status())); readonly latestMessageContent = computed(() => { const messages = this.subagent().messages(); if (messages.length === 0) return ''; const last = messages[messages.length - 1]; - const content = last.content; - if (typeof content === 'string') return content; - return JSON.stringify(content); + const c = last.content; + return typeof c === 'string' ? c : JSON.stringify(c); }); } From 27f8d0f96b5f69556a3d6ad60189377fa908b31f Mon Sep 17 00:00:00 2001 From: Brian Love Date: Fri, 1 May 2026 13:55:19 -0700 Subject: [PATCH 21/38] refactor(chat): rewrite chat-timeline-slider as vertical history walk --- .../chat-timeline-slider.component.ts | 165 +++++++++++++----- 1 file changed, 125 insertions(+), 40 deletions(-) diff --git a/libs/chat/src/lib/compositions/chat-timeline-slider/chat-timeline-slider.component.ts b/libs/chat/src/lib/compositions/chat-timeline-slider/chat-timeline-slider.component.ts index c932bccb6..f0a1a45b0 100644 --- a/libs/chat/src/lib/compositions/chat-timeline-slider/chat-timeline-slider.component.ts +++ b/libs/chat/src/lib/compositions/chat-timeline-slider/chat-timeline-slider.component.ts @@ -1,63 +1,148 @@ +// libs/chat/src/lib/compositions/chat-timeline-slider/chat-timeline-slider.component.ts // SPDX-License-Identifier: MIT import { Component, computed, input, output, signal, ChangeDetectionStrategy, } from '@angular/core'; import type { AgentWithHistory, AgentCheckpoint } from '../../agent'; +import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens'; @Component({ selector: 'chat-timeline-slider', standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, + styles: [CHAT_HOST_TOKENS, ` + :host { display: block; padding: var(--ngaf-chat-space-2); } + .timeline-slider__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 var(--ngaf-chat-space-1) var(--ngaf-chat-space-2); + } + .timeline-slider__title { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--ngaf-chat-text-muted); + margin: 0; + } + .timeline-slider__count { + font-size: var(--ngaf-chat-font-size-xs); + color: var(--ngaf-chat-text-muted); + } + .timeline-slider__empty { + text-align: center; + padding: var(--ngaf-chat-space-4); + color: var(--ngaf-chat-text-muted); + font-size: var(--ngaf-chat-font-size-xs); + } + .timeline-slider__list { + list-style: none; + padding-left: 12px; + margin: 0; + border-left: 1px solid var(--ngaf-chat-separator); + display: flex; + flex-direction: column; + gap: 2px; + } + .timeline-slider__entry { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 8px; + margin-left: -1px; + border-left: 2px solid transparent; + border-radius: var(--ngaf-chat-radius-button); + cursor: default; + color: var(--ngaf-chat-text-muted); + font-size: var(--ngaf-chat-font-size-sm); + transition: background 150ms ease; + } + .timeline-slider__entry:hover { background: color-mix(in srgb, var(--ngaf-chat-text) 5%, transparent); } + .timeline-slider__entry[data-active="true"] { + border-left-color: var(--ngaf-chat-primary); + color: var(--ngaf-chat-text); + } + .timeline-slider__index { + width: 22px; + height: 22px; + border-radius: 9999px; + background: var(--ngaf-chat-surface-alt); + color: var(--ngaf-chat-text-muted); + font-size: 11px; + font-weight: 600; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + } + .timeline-slider__entry[data-active="true"] .timeline-slider__index { + background: var(--ngaf-chat-primary); + color: var(--ngaf-chat-on-primary); + } + .timeline-slider__body { flex: 1; min-width: 0; } + .timeline-slider__label { + font-weight: 500; + color: var(--ngaf-chat-text); + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: var(--ngaf-chat-font-size-sm); + } + .timeline-slider__id { + font-family: var(--ngaf-chat-font-mono); + font-size: 11px; + color: var(--ngaf-chat-text-muted); + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .timeline-slider__actions { display: flex; gap: 4px; flex-shrink: 0; } + .timeline-slider__btn { + padding: 2px 8px; + font-size: var(--ngaf-chat-font-size-xs); + border-radius: var(--ngaf-chat-radius-button); + background: var(--ngaf-chat-surface-alt); + color: var(--ngaf-chat-text); + border: 0; + cursor: pointer; + transition: background 150ms ease; + } + .timeline-slider__btn:hover { background: color-mix(in srgb, var(--ngaf-chat-text) 8%, transparent); } + `], template: ` -
-
-

Timeline

- {{ history().length }} checkpoint(s) -
- - @if (history().length === 0) { -

No checkpoints yet.

- } +
+

Timeline

+ {{ history().length }} checkpoint(s) +
-
+ @if (history().length === 0) { +

No checkpoints yet.

+ } @else { +
    @for (cp of history(); track $index; let i = $index) { -
    - - {{ i + 1 }} - - -
    -

    - {{ cp.label ?? 'Step ' + (i + 1) }} -

    + {{ i + 1 }} +
    +

    {{ cp.label ?? 'Step ' + (i + 1) }}

    @if (cp.id) { -

    {{ cp.id }}

    +

    {{ cp.id }}

    }
    - -
    - - +
    + +
    -
    + } -
    -
    +
+ } `, }) export class ChatTimelineSliderComponent { From 0258555489a859536ce666663ec49e568d55ebf3 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Fri, 1 May 2026 13:56:51 -0700 Subject: [PATCH 22/38] feat(chat): default-render trace-based cards for tool-calls and subagents --- .../chat-subagents.component.ts | 19 +++++--------- .../chat-tool-calls.component.ts | 25 ++++++++++++------- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/libs/chat/src/lib/primitives/chat-subagents/chat-subagents.component.ts b/libs/chat/src/lib/primitives/chat-subagents/chat-subagents.component.ts index f29ef49bf..3105c756e 100644 --- a/libs/chat/src/lib/primitives/chat-subagents/chat-subagents.component.ts +++ b/libs/chat/src/lib/primitives/chat-subagents/chat-subagents.component.ts @@ -1,3 +1,4 @@ +// libs/chat/src/lib/primitives/chat-subagents/chat-subagents.component.ts // SPDX-License-Identifier: MIT import { Component, @@ -10,13 +11,8 @@ import { import { NgTemplateOutlet } from '@angular/common'; import type { Agent } from '../../agent'; import type { Subagent } from '../../agent/subagent'; +import { ChatSubagentCardComponent } from '../../compositions/chat-subagent-card/chat-subagent-card.component'; -/** - * Returns the list of currently-active subagents on the agent. "Active" means - * the subagent status is neither `complete` nor `error`. Returns an empty list - * when the runtime does not expose a subagents surface. - * Exported for unit testing without DOM rendering. - */ export function activeSubagentsFromAgent(agent: Agent): Subagent[] { const map = agent.subagents?.(); if (!map) return []; @@ -31,23 +27,20 @@ export function activeSubagentsFromAgent(agent: Agent): Subagent[] { @Component({ selector: 'chat-subagents', standalone: true, - imports: [NgTemplateOutlet], + imports: [NgTemplateOutlet, ChatSubagentCardComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: ` @for (subagent of activeSubagents(); track subagent.toolCallId) { @if (templateRef()) { - + + } @else { + } } `, }) export class ChatSubagentsComponent { readonly agent = input.required(); - readonly templateRef = contentChild(TemplateRef); - readonly activeSubagents = computed(() => activeSubagentsFromAgent(this.agent())); } diff --git a/libs/chat/src/lib/primitives/chat-tool-calls/chat-tool-calls.component.ts b/libs/chat/src/lib/primitives/chat-tool-calls/chat-tool-calls.component.ts index 9df6e9e70..0fe5f1b43 100644 --- a/libs/chat/src/lib/primitives/chat-tool-calls/chat-tool-calls.component.ts +++ b/libs/chat/src/lib/primitives/chat-tool-calls/chat-tool-calls.component.ts @@ -1,3 +1,4 @@ +// libs/chat/src/lib/primitives/chat-tool-calls/chat-tool-calls.component.ts // SPDX-License-Identifier: MIT import { Component, @@ -9,19 +10,19 @@ import { } from '@angular/core'; import { NgTemplateOutlet } from '@angular/common'; import type { Agent, Message, ToolCall } from '../../agent'; +import { ChatToolCallCardComponent, type ToolCallInfo } from '../../compositions/chat-tool-call-card/chat-tool-call-card.component'; @Component({ selector: 'chat-tool-calls', standalone: true, - imports: [NgTemplateOutlet], + imports: [NgTemplateOutlet, ChatToolCallCardComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: ` @for (toolCall of toolCalls(); track toolCall.id) { @if (templateRef()) { - + + } @else { + } } `, @@ -29,7 +30,6 @@ import type { Agent, Message, ToolCall } from '../../agent'; export class ChatToolCallsComponent { readonly agent = input.required(); readonly message = input(undefined); - readonly templateRef = contentChild(TemplateRef); readonly toolCalls = computed((): ToolCall[] => { @@ -37,10 +37,17 @@ export class ChatToolCallsComponent { if (msg && msg.role === 'assistant' && Array.isArray(msg.content)) { const blocks = msg.content.filter((b) => b.type === 'tool_use'); const all = this.agent().toolCalls(); - return blocks - .map(b => all.find(tc => tc.id === b.id)) - .filter((x): x is ToolCall => !!x); + return blocks.map(b => all.find(tc => tc.id === b.id)).filter((x): x is ToolCall => !!x); } return this.agent().toolCalls(); }); + + protected toToolCallInfo(tc: ToolCall): ToolCallInfo { + return { + id: tc.id, + name: tc.name, + args: tc.args, + result: tc.result, + }; + } } From b9a7115f5e0855fe5f4a8df7d39bd67da3942fab Mon Sep 17 00:00:00 2001 From: Brian Love Date: Fri, 1 May 2026 14:01:41 -0700 Subject: [PATCH 23/38] refactor(chat): rewrite composition for new asymmetric design Replaces Tailwind-based layout + inline avatar markup with CSS-custom-property shell layout; wraps each message role in inside the per-role templates; adds and inside the assistant wrapper; introduces prevRole() helper for spacing context. Co-Authored-By: Claude Sonnet 4.6 --- .../compositions/chat/chat.component.spec.ts | 24 ++ .../lib/compositions/chat/chat.component.ts | 334 +++++++----------- 2 files changed, 143 insertions(+), 215 deletions(-) diff --git a/libs/chat/src/lib/compositions/chat/chat.component.spec.ts b/libs/chat/src/lib/compositions/chat/chat.component.spec.ts index 0b04e5d70..b661f3445 100644 --- a/libs/chat/src/lib/compositions/chat/chat.component.spec.ts +++ b/libs/chat/src/lib/compositions/chat/chat.component.spec.ts @@ -120,6 +120,30 @@ describe('ChatComponent — content classification', () => { }); }); +describe('ChatComponent — prevRole', () => { + it('prevRole(0) returns undefined for the first message', () => { + TestBed.configureTestingModule({}); + TestBed.runInInjectionContext(() => { + // prevRole at index 0 should always return undefined (no previous message) + // We test the logic directly, mirroring the implementation. + function prevRole(index: number, messages: Array<{ role?: string }>): string | undefined { + if (index === 0) return undefined; + const prev = messages[index - 1]; + if (!prev) return undefined; + const role = (prev as unknown as { role?: string }).role; + if (role === 'user') return 'user'; + if (role === 'assistant') return 'assistant'; + if (role === 'system') return 'system'; + if (role === 'tool') return 'tool'; + return undefined; + } + expect(prevRole(0, [{ role: 'user' }])).toBeUndefined(); + expect(prevRole(1, [{ role: 'user' }, { role: 'assistant' }])).toBe('user'); + expect(prevRole(2, [{ role: 'user' }, { role: 'assistant' }, { role: 'user' }])).toBe('assistant'); + }); + }); +}); + describe('ChatComponent — events$ routing', () => { // Angular 21 zoneless mode (ZONELESS_ENABLED defaults to true) means // ComponentFixture.autoDetect cannot be disabled, making createComponent diff --git a/libs/chat/src/lib/compositions/chat/chat.component.ts b/libs/chat/src/lib/compositions/chat/chat.component.ts index 98f9e51d8..de94d7372 100644 --- a/libs/chat/src/lib/compositions/chat/chat.component.ts +++ b/libs/chat/src/lib/compositions/chat/chat.component.ts @@ -1,247 +1,171 @@ // libs/chat/src/lib/compositions/chat/chat.component.ts // SPDX-License-Identifier: MIT import { - Component, - input, - output, - signal, - computed, - effect, - viewChild, - ElementRef, - ChangeDetectionStrategy, - DestroyRef, - inject, + Component, ChangeDetectionStrategy, input, output, computed, effect, viewChild, ElementRef, + DestroyRef, inject, } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { KeyValuePipe } from '@angular/common'; import type { Agent } from '../../agent'; import type { ViewRegistry, RenderEvent } from '@ngaf/render'; import type { A2uiActionMessage } from '@ngaf/a2ui'; import type { StateStore } from '@json-render/core'; +import { toRenderRegistry, signalStateStore } from '@ngaf/render'; +import { ChatWindowComponent } from '../../primitives/chat-window/chat-window.component'; import { ChatMessageListComponent } from '../../primitives/chat-message-list/chat-message-list.component'; import { MessageTemplateDirective } from '../../primitives/chat-message-list/message-template.directive'; +import { ChatMessageComponent, type ChatMessageRole } from '../../primitives/chat-message/chat-message.component'; import { ChatInputComponent } from '../../primitives/chat-input/chat-input.component'; import { ChatTypingIndicatorComponent } from '../../primitives/chat-typing-indicator/chat-typing-indicator.component'; import { ChatErrorComponent } from '../../primitives/chat-error/chat-error.component'; import { ChatInterruptComponent } from '../../primitives/chat-interrupt/chat-interrupt.component'; -import { ChatThreadListComponent, Thread } from '../../primitives/chat-thread-list/chat-thread-list.component'; +import { ChatThreadListComponent, type Thread } from '../../primitives/chat-thread-list/chat-thread-list.component'; import { ChatGenerativeUiComponent } from '../../primitives/chat-generative-ui/chat-generative-ui.component'; -import { toRenderRegistry, signalStateStore } from '@ngaf/render'; -import { createContentClassifier, type ContentClassifier } from '../../streaming/content-classifier'; -import { messageContent } from '../shared/message-utils'; -import { CHAT_THEME_STYLES } from '../../styles/chat-theme'; -import { CHAT_MARKDOWN_STYLES } from '../../styles/chat-markdown'; import { ChatStreamingMdComponent } from '../../streaming/streaming-markdown.component'; +import { ChatToolCallsComponent } from '../../primitives/chat-tool-calls/chat-tool-calls.component'; +import { ChatSubagentsComponent } from '../../primitives/chat-subagents/chat-subagents.component'; import { A2uiSurfaceComponent } from '../../a2ui/surface.component'; +import { createContentClassifier, type ContentClassifier } from '../../streaming/content-classifier'; +import { messageContent } from '../shared/message-utils'; +import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens'; import type { ChatRenderEvent } from './chat-render-event'; -import { KeyValuePipe } from '@angular/common'; @Component({ selector: 'chat', standalone: true, imports: [ - ChatMessageListComponent, - MessageTemplateDirective, - ChatInputComponent, - ChatTypingIndicatorComponent, - ChatErrorComponent, - ChatInterruptComponent, - ChatThreadListComponent, - ChatGenerativeUiComponent, - ChatStreamingMdComponent, - A2uiSurfaceComponent, KeyValuePipe, + ChatWindowComponent, ChatMessageListComponent, MessageTemplateDirective, ChatMessageComponent, + ChatInputComponent, ChatTypingIndicatorComponent, ChatErrorComponent, ChatInterruptComponent, + ChatThreadListComponent, ChatGenerativeUiComponent, + ChatStreamingMdComponent, ChatToolCallsComponent, ChatSubagentsComponent, A2uiSurfaceComponent, ], changeDetection: ChangeDetectionStrategy.OnPush, - styles: [CHAT_THEME_STYLES, CHAT_MARKDOWN_STYLES], + styles: [CHAT_HOST_TOKENS, ` + :host { display: flex; flex-direction: column; height: 100%; min-height: 0; background: var(--ngaf-chat-bg); } + .chat-shell { display: flex; flex: 1; min-height: 0; } + .chat-shell__sidebar { + width: 240px; + flex-shrink: 0; + border-right: 1px solid var(--ngaf-chat-separator); + background: var(--ngaf-chat-surface-alt); + overflow-y: auto; + display: none; + } + @media (min-width: 768px) { .chat-shell__sidebar { display: block; } } + .chat-shell__main { flex: 1; min-width: 0; display: flex; flex-direction: column; min-height: 0; } + .chat-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + padding: 60px 20px; + color: var(--ngaf-chat-text-muted); + text-align: center; + } + .chat-empty__title { font-size: 1.125rem; font-weight: 500; color: var(--ngaf-chat-text); margin: 0; } + .chat-empty__sub { margin: 0; font-size: var(--ngaf-chat-font-size-sm); } + .chat-scroll { flex: 1; min-height: 0; overflow-y: auto; } + .chat-scroll::-webkit-scrollbar { width: 6px; } + .chat-scroll::-webkit-scrollbar-thumb { background: var(--ngaf-chat-separator); border-radius: 10px; } + `], template: ` -
- +
@if (threads().length > 0) { - + /> + } - - -
- -
-
+
+ + +
@if (agent().messages().length === 0 && !agent().isLoading()) { - -
-
A
-

Send a message to start a conversation.

+
+
} - - -
-
{{ messageContent(message) }}
-
+ + {{ messageContent(message) }} - - + @let content = messageContent(message); - @let classified = classifyMessage(content, index); -
-
A
-
- @if (classified.markdown(); as md) { - - } - - @if (classified.spec(); as spec) { - + + + @if (classified.markdown(); as md) { + + } + @if (classified.spec(); as spec) { + + } + @if (classified.type() === 'a2ui' && views(); as catalog) { + @for (entry of classified.a2uiSurfaces() | keyvalue; track entry.key) { + } - - @if (classified.type() === 'a2ui') { - @if (views(); as catalog) { - @for (entry of classified.a2uiSurfaces() | keyvalue; track entry.key) { - - } - } - } -
-
+ } +
- -
{{ messageContent(message) }}
+
- -
- - {{ messageContent(message) }} - -
+ {{ messageContent(message) }}
-
- - - @if (agent().interrupt) { - - -
-

Agent paused: {{ interrupt.value }}

-
-
-
- } - - -
- -
- - -
-
- +
+ + +
-
+
`, }) export class ChatComponent { - readonly agent = input.required(); - readonly views = input(undefined); readonly store = input(undefined); readonly handlers = input) => unknown | Promise>>({}); readonly threads = input([]); readonly activeThreadId = input(''); readonly threadSelected = output(); - readonly sidebarOpen = signal(false); - readonly renderEvent = output(); - private readonly _internalStore = signalStateStore({}); - - /** - * Resolved store: use the explicitly provided store input, or fall back to - * an internal store when `views` are provided (generative-ui use case). - */ readonly resolvedStore = computed(() => { const explicit = this.store(); if (explicit) return explicit; @@ -249,38 +173,25 @@ export class ChatComponent { return undefined; }); - private readonly destroyRef = inject(DestroyRef); - private eventsSubscribed = false; - - private readonly classifiers = new Map(); - - /** Convert ViewRegistry → AngularRegistry for ChatGenerativeUiComponent. */ readonly renderRegistry = computed(() => { const v = this.views(); return v ? toRenderRegistry(v) : undefined; }); readonly messageContent = messageContent; + private readonly classifiers = new Map(); + private readonly destroyRef = inject(DestroyRef); + private eventsSubscribed = false; private readonly scrollContainer = viewChild>('scrollContainer'); - - /** Track message count to trigger auto-scroll */ private readonly messageCount = computed(() => this.agent().messages().length); - private prevMessageCount = 0; constructor() { - // Route state_update events from the agent to the render state store - // so components bound to $state paths reactively update. effect(() => { if (this.eventsSubscribed) return; let agent: ReturnType; - try { - agent = this.agent(); - } catch { - // Required input not yet available — skip; effect will retry. - return; - } + try { agent = this.agent(); } catch { return; } this.eventsSubscribed = true; agent.events$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((event) => { if (event.type !== 'state_update') return; @@ -290,32 +201,16 @@ export class ChatComponent { }); }); - // Auto-scroll to bottom: - // - Always scroll when message count increases (new message sent/received) - // - During streaming partials, only scroll if user is near bottom effect(() => { - // Guard against required `agent` input not yet being set (can fire - // during initial change detection before input signals are populated). let count: number; let msgs: ReturnType['messages']>; - try { - count = this.messageCount(); - msgs = this.agent().messages(); - } catch { - return; - } - // Track last message content to trigger scroll during streaming partials - const lastContent = msgs.length > 0 - ? (msgs[msgs.length - 1] as unknown as Record)['content'] - : undefined; - void lastContent; // consume the tracked value - + try { count = this.messageCount(); msgs = this.agent().messages(); } catch { return; } + const lastContent = msgs.length > 0 ? (msgs[msgs.length - 1] as unknown as Record)['content'] : undefined; + void lastContent; const el = this.scrollContainer()?.nativeElement; if (!el) return; - const isNewMessage = count !== this.prevMessageCount; this.prevMessageCount = count; - const isNearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 150; if (isNewMessage || isNearBottom) { requestAnimationFrame(() => { @@ -325,14 +220,23 @@ export class ChatComponent { }); } + prevRole(index: number): ChatMessageRole | undefined { + if (index === 0) return undefined; + const prev = this.agent().messages()[index - 1]; + if (!prev) return undefined; + const role = (prev as unknown as { role?: string }).role; + if (role === 'user') return 'user'; + if (role === 'assistant') return 'assistant'; + if (role === 'system') return 'system'; + if (role === 'tool') return 'tool'; + return undefined; + } + classifyMessage(content: string, index: number): ContentClassifier { - let classifier = this.classifiers.get(index); - if (!classifier) { - classifier = createContentClassifier(); - this.classifiers.set(index, classifier); - } - classifier.update(content); - return classifier; + let c = this.classifiers.get(index); + if (!c) { c = createContentClassifier(); this.classifiers.set(index, c); } + c.update(content); + return c; } clearClassifiers(): void { From 207f157f2ec0cdf09435652bc0beb656e9f2ae30 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Fri, 1 May 2026 14:04:08 -0700 Subject: [PATCH 24/38] feat(chat): add chat-popup floating composition --- .../chat-popup/chat-popup.component.spec.ts | 28 +++++++ .../chat-popup/chat-popup.component.ts | 78 +++++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 libs/chat/src/lib/compositions/chat-popup/chat-popup.component.spec.ts create mode 100644 libs/chat/src/lib/compositions/chat-popup/chat-popup.component.ts diff --git a/libs/chat/src/lib/compositions/chat-popup/chat-popup.component.spec.ts b/libs/chat/src/lib/compositions/chat-popup/chat-popup.component.spec.ts new file mode 100644 index 000000000..6aa1cf5e2 --- /dev/null +++ b/libs/chat/src/lib/compositions/chat-popup/chat-popup.component.spec.ts @@ -0,0 +1,28 @@ +// libs/chat/src/lib/compositions/chat-popup/chat-popup.component.spec.ts +// SPDX-License-Identifier: MIT +import { describe, it, expect } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { ChatPopupComponent } from './chat-popup.component'; + +describe('ChatPopupComponent', () => { + it('class is defined and imports resolve', () => { + expect(ChatPopupComponent).toBeDefined(); + expect(typeof ChatPopupComponent).toBe('function'); + }); + + it('toggle/openWindow/closeWindow flip the open model', () => { + TestBed.configureTestingModule({}); + TestBed.runInInjectionContext(() => { + const popup = new ChatPopupComponent(); + expect(popup.open()).toBe(false); + popup.toggle(); + expect(popup.open()).toBe(true); + popup.toggle(); + expect(popup.open()).toBe(false); + popup.openWindow(); + expect(popup.open()).toBe(true); + popup.closeWindow(); + expect(popup.open()).toBe(false); + }); + }); +}); diff --git a/libs/chat/src/lib/compositions/chat-popup/chat-popup.component.ts b/libs/chat/src/lib/compositions/chat-popup/chat-popup.component.ts new file mode 100644 index 000000000..9fd794c0a --- /dev/null +++ b/libs/chat/src/lib/compositions/chat-popup/chat-popup.component.ts @@ -0,0 +1,78 @@ +// libs/chat/src/lib/compositions/chat-popup/chat-popup.component.ts +// SPDX-License-Identifier: MIT +import { Component, ChangeDetectionStrategy, input, model } from '@angular/core'; +import type { Agent } from '../../agent'; +import { ChatComponent } from '../chat/chat.component'; +import { ChatLauncherButtonComponent } from '../../primitives/chat-launcher-button/chat-launcher-button.component'; +import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens'; + +@Component({ + selector: 'chat-popup', + standalone: true, + imports: [ChatComponent, ChatLauncherButtonComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + styles: [CHAT_HOST_TOKENS, ` + :host { position: fixed; bottom: 1rem; right: 1rem; z-index: 30; } + .chat-popup__launcher { position: relative; } + .chat-popup__window { + position: fixed; + bottom: 5rem; + right: 1rem; + width: 24rem; + height: 600px; + max-height: calc(100vh - 6rem); + background: var(--ngaf-chat-bg); + border-radius: 0.75rem; + box-shadow: 0 5px 40px rgba(0,0,0,.16); + transform-origin: bottom right; + transform: scale(0.95) translateY(20px); + opacity: 0; + pointer-events: none; + transition: transform 200ms ease-out, opacity 100ms ease-out; + overflow: hidden; + display: flex; + flex-direction: column; + } + .chat-popup__window[data-open="true"] { + transform: scale(1) translateY(0); + opacity: 1; + pointer-events: auto; + } + @media (max-width: 640px) { + .chat-popup__window { inset: 0; width: 100vw; height: 100vh; max-height: 100vh; border-radius: 0; bottom: auto; right: auto; } + } + .chat-popup__close { + position: absolute; top: 8px; right: 8px; + width: 32px; height: 32px; + background: transparent; border: 0; cursor: pointer; + color: var(--ngaf-chat-text-muted); + border-radius: 50%; + z-index: 1; + display: flex; + align-items: center; + justify-content: center; + } + .chat-popup__close:hover { background: var(--ngaf-chat-surface-alt); color: var(--ngaf-chat-text); } + `], + template: ` +
+ +
+ + `, +}) +export class ChatPopupComponent { + readonly agent = input.required(); + readonly open = model(false); + + toggle(): void { this.open.update((v) => !v); } + openWindow(): void { this.open.set(true); } + closeWindow(): void { this.open.set(false); } +} From 968c9fd9d59f423f5c1bf767625aa0ba3636aa6a Mon Sep 17 00:00:00 2001 From: Brian Love Date: Fri, 1 May 2026 14:05:42 -0700 Subject: [PATCH 25/38] feat(chat): add chat-sidebar slide-in composition --- .../chat-sidebar.component.spec.ts | 28 ++++++++ .../chat-sidebar/chat-sidebar.component.ts | 72 +++++++++++++++++++ 2 files changed, 100 insertions(+) create mode 100644 libs/chat/src/lib/compositions/chat-sidebar/chat-sidebar.component.spec.ts create mode 100644 libs/chat/src/lib/compositions/chat-sidebar/chat-sidebar.component.ts diff --git a/libs/chat/src/lib/compositions/chat-sidebar/chat-sidebar.component.spec.ts b/libs/chat/src/lib/compositions/chat-sidebar/chat-sidebar.component.spec.ts new file mode 100644 index 000000000..e9759e145 --- /dev/null +++ b/libs/chat/src/lib/compositions/chat-sidebar/chat-sidebar.component.spec.ts @@ -0,0 +1,28 @@ +// libs/chat/src/lib/compositions/chat-sidebar/chat-sidebar.component.spec.ts +// SPDX-License-Identifier: MIT +import { describe, it, expect } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { ChatSidebarComponent } from './chat-sidebar.component'; + +describe('ChatSidebarComponent', () => { + it('class is defined and imports resolve', () => { + expect(ChatSidebarComponent).toBeDefined(); + expect(typeof ChatSidebarComponent).toBe('function'); + }); + + it('toggle/openWindow/closeWindow flip the open model', () => { + TestBed.configureTestingModule({}); + TestBed.runInInjectionContext(() => { + const sidebar = new ChatSidebarComponent(); + expect(sidebar.open()).toBe(false); + sidebar.toggle(); + expect(sidebar.open()).toBe(true); + sidebar.toggle(); + expect(sidebar.open()).toBe(false); + sidebar.openWindow(); + expect(sidebar.open()).toBe(true); + sidebar.closeWindow(); + expect(sidebar.open()).toBe(false); + }); + }); +}); 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 new file mode 100644 index 000000000..4cff95c7a --- /dev/null +++ b/libs/chat/src/lib/compositions/chat-sidebar/chat-sidebar.component.ts @@ -0,0 +1,72 @@ +// libs/chat/src/lib/compositions/chat-sidebar/chat-sidebar.component.ts +// SPDX-License-Identifier: MIT +import { Component, ChangeDetectionStrategy, input, model } from '@angular/core'; +import type { Agent } from '../../agent'; +import { ChatComponent } from '../chat/chat.component'; +import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens'; + +@Component({ + selector: 'chat-sidebar', + standalone: true, + imports: [ChatComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + '[attr.data-push]': 'pushContent() ? "true" : "false"', + '[attr.data-open]': 'open() ? "true" : "false"', + }, + styles: [CHAT_HOST_TOKENS, ` + :host { display: block; } + .chat-sidebar__content { transition: margin-right 300ms ease; min-height: 100vh; } + :host([data-push="true"][data-open="true"]) .chat-sidebar__content { margin-right: 28rem; } + @media (max-width: 640px) { + :host([data-push="true"][data-open="true"]) .chat-sidebar__content { margin-right: 0; } + } + .chat-sidebar__panel { + position: fixed; + top: 0; right: 0; bottom: 0; + width: 28rem; + background: var(--ngaf-chat-bg); + box-shadow: -8px 0 32px rgba(0,0,0,.12); + transform: translateX(100%); + transition: transform 200ms ease-out; + z-index: 30; + display: flex; + flex-direction: column; + } + .chat-sidebar__panel[data-open="true"] { transform: translateX(0); } + @media (max-width: 640px) { + .chat-sidebar__panel { width: 100vw; } + } + .chat-sidebar__close { + position: absolute; top: 8px; right: 8px; + width: 32px; height: 32px; + background: transparent; border: 0; cursor: pointer; + color: var(--ngaf-chat-text-muted); + border-radius: 50%; z-index: 1; + display: flex; + align-items: center; + justify-content: center; + } + .chat-sidebar__close:hover { background: var(--ngaf-chat-surface-alt); color: var(--ngaf-chat-text); } + `], + template: ` +
+ + `, +}) +export class ChatSidebarComponent { + readonly agent = input.required(); + readonly open = model(false); + readonly pushContent = input(false); + + toggle(): void { this.open.update((v) => !v); } + openWindow(): void { this.open.set(true); } + closeWindow(): void { this.open.set(false); } +} From e8cde561680cd2f9054c4f7ad77d28bbd2fe7616 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Fri, 1 May 2026 14:08:03 -0700 Subject: [PATCH 26/38] refactor(chat): restyle chat-interrupt-panel Replace Tailwind utility classes with hand-written BEM CSS, rename all --chat-* tokens to --ngaf-chat-*, and add CHAT_HOST_TOKENS to styles array. Co-Authored-By: Claude Sonnet 4.6 --- .../chat-interrupt-panel.component.ts | 107 ++++++++++-------- 1 file changed, 59 insertions(+), 48 deletions(-) diff --git a/libs/chat/src/lib/compositions/chat-interrupt-panel/chat-interrupt-panel.component.ts b/libs/chat/src/lib/compositions/chat-interrupt-panel/chat-interrupt-panel.component.ts index d7a0d12f6..8ba3334ec 100644 --- a/libs/chat/src/lib/compositions/chat-interrupt-panel/chat-interrupt-panel.component.ts +++ b/libs/chat/src/lib/compositions/chat-interrupt-panel/chat-interrupt-panel.component.ts @@ -8,6 +8,7 @@ import { } from '@angular/core'; import type { Agent } from '../../agent'; import type { AgentInterrupt } from '../../agent/agent-interrupt'; +import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens'; export type InterruptAction = 'accept' | 'edit' | 'respond' | 'ignore'; @@ -24,61 +25,71 @@ export function getInterruptFromAgent(agent: Agent): AgentInterrupt | undefined selector: 'chat-interrupt-panel', standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, + styles: [ + CHAT_HOST_TOKENS, + ` + .chat-interrupt-panel { + background: var(--ngaf-chat-warning-bg); + color: var(--ngaf-chat-warning-text); + border-left: 3px solid var(--ngaf-chat-warning-text); + border-radius: var(--ngaf-chat-radius-card); + padding: 12px 16px; + margin: 0 var(--ngaf-chat-space-6) var(--ngaf-chat-space-2); + font-size: var(--ngaf-chat-font-size-sm); + } + .chat-interrupt-panel__title { + font-weight: 600; + margin: 0 0 4px; + display: flex; + align-items: center; + gap: 6px; + } + .chat-interrupt-panel__body { + margin: 0 0 8px; + opacity: 0.95; + } + .chat-interrupt-panel__actions { + display: flex; + gap: 8px; + flex-wrap: wrap; + } + .chat-interrupt-panel__btn { + padding: 4px 12px; + font-size: var(--ngaf-chat-font-size-sm); + border-radius: var(--ngaf-chat-radius-button); + border: 0; + cursor: pointer; + background: var(--ngaf-chat-primary); + color: var(--ngaf-chat-on-primary); + transition: transform 200ms ease; + } + .chat-interrupt-panel__btn:hover { transform: scale(1.03); } + .chat-interrupt-panel__btn--secondary { + background: transparent; + color: var(--ngaf-chat-warning-text); + border: 1px solid var(--ngaf-chat-warning-text); + } + `, + ], template: ` @if (interrupt()) { -