From d94d0bc167f3584beb3eec91063ca1a6ea339c9d Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 11 May 2026 12:58:01 -0700 Subject: [PATCH 01/18] =?UTF-8?q?docs(specs):=20chat-debug=20devtools=20?= =?UTF-8?q?=E2=80=94=20floating=20launcher=20+=20extension=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Repurpose from a chat-bundled composition into a floating devtools launcher: built-in timeline + state inspector, plus slot API (chatDebugControls, chatDebugInspector) and blessed primitives for host apps to inject controls and tabs. Smoke app's bespoke ControlPalette migrates into chat-debug. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-05-11-chat-debug-devtools-design.md | 291 ++++++++++++++++++ 1 file changed, 291 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-11-chat-debug-devtools-design.md diff --git a/docs/superpowers/specs/2026-05-11-chat-debug-devtools-design.md b/docs/superpowers/specs/2026-05-11-chat-debug-devtools-design.md new file mode 100644 index 000000000..3522a3285 --- /dev/null +++ b/docs/superpowers/specs/2026-05-11-chat-debug-devtools-design.md @@ -0,0 +1,291 @@ +# Chat Debug Devtools — Design + +**Date:** 2026-05-11 +**Status:** Approved — ready for implementation plan +**Surface:** `libs/chat` (`@ngaf/chat`), with smoke-app migration in `examples/chat/angular` + +## Summary + +Repurpose `` from a chat-bundled composition into a **floating +devtools launcher** for any `AgentWithHistory`. A developer drops one tag into +their app, gets observability (timeline + state inspector) plus a slot API for +host-extensible controls and inspector tabs. No chat surface is rendered; the +panel pairs with whatever chat the host already has. + +The smoke app's bespoke `ControlPalette` and its bottom-overlay debug pane are +deleted; their controls move into a `chatDebugControls` template inside the +new ``. + +## Goals + +- **Drop-in:** `` is enough to get a working + launcher. +- **Themed by default:** consumes existing `--ngaf-chat-*` tokens, so it + inherits the host's chat theme. +- **Extensible:** host apps inject their own pinned controls and inspector + tabs through a slot API built on `` structural directives. +- **Decoupled:** never renders a chat surface, never assumes a specific host + layout. `position: fixed` panel; host's chat is unaffected. + +## Non-goals (v1) + +- Multiple agents per launcher. One agent, one launcher. +- Detach-to-window. Three dock positions only: `right` (default), `bottom`, + `left`. +- Tabs in the controls zone. Controls are always a single pinned stack. +- A separate token namespace. We reuse `--ngaf-chat-*` directly. + +## Component API + +```html + + + + + + + + + + + + + + + + + + +``` + +### Inputs + +| Input | Type | Default | Notes | +|---|---|---|---| +| `agent` | `AgentWithHistory` (required) | — | Source for `history()` and `state()`. | +| `dock` | `'right' \| 'bottom' \| 'left'` | `'right'` | Initial dock; persisted thereafter. | +| `defaultOpen` | `boolean` | `false` | Used only when no persisted state exists. | +| `storageKey` | `string` | `'chat-debug'` | Prefix for all localStorage keys (`{storageKey}:dock`, `{storageKey}:open`, `{storageKey}:size`, `{storageKey}:tab`). | + +### Outputs + +| Output | Payload | Notes | +|---|---|---| +| `replayRequested` | `string` (checkpoint id) | Forwarded from timeline row action. | +| `forkRequested` | `string` (checkpoint id) | Forwarded from timeline row action. | +| `dockChange` | `'right' \| 'bottom' \| 'left'` | After user dock toggle. | +| `openChange` | `boolean` | After user open/close (including ESC). | + +### Slots + +Structural directives projected onto ``: + +- `chatDebugControls` — **single** template. Rendered in the pinned top zone. + If absent, the controls zone collapses to zero height. +- `chatDebugInspector` — **repeatable**, requires `label` input. Each becomes + a tab appended after the built-in `Timeline` and `State` tabs. + +### Public primitives + +Each is a standalone component exported from `@ngaf/chat` for use inside +`chatDebugControls` (and reusable elsewhere if a host wants to): + +| Component | Inputs | Outputs | +|---|---|---| +| `` | `label?: string` | — | +| `` | `options: {value, label}[]`, `value: string` | `valueChange` | +| `` | `label: string`, `options: {value, label}[]`, `value: string` | `valueChange` | +| `` | `label: string`, `value: boolean` | `valueChange` | +| `` | `label: string` | `clicked` | + +## Layout + +### Launcher (closed state) + +40×40px floating button, fixed at `bottom: 16px; right: 16px` regardless of +dock setting. Icon from existing `chat-icons`. Hover reveals "Open chat debug" +tooltip. z-index above chat content, below the existing interrupt overlay +(z-index 998). + +### Docked panel (open state) + +- `dock: 'right'` — `position: fixed; top: 0; right: 0; bottom: 0; width: 420px`. Left edge is a 4px drag handle for resize. +- `dock: 'bottom'` — `position: fixed; left: 0; right: 0; bottom: 0; height: 40vh`. Top edge is the drag handle. +- `dock: 'left'` — mirror of `right`. + +Min size 320px / 25vh; max 60vw / 70vh. + +### Panel chrome (top → bottom) + +1. **Header.** Title "Chat Debug", dock-position toggle (three icons: ◧ ▭ ◨), + close button. ~40px tall. +2. **Controls zone.** Renders the `chatDebugControls` template, if any. + Scrolls internally on overflow. Hidden when no template is provided. +3. **Tab strip.** Built-in tabs: `Timeline`, `State`. Host `chatDebugInspector` + tabs append after. The strip is hidden only in the degenerate case where + exactly one tab is registered (i.e., never in v1, since both built-ins + ship). +4. **Active inspector.** Fills remaining height; scrolls independently. + +### Behavior + +- Dock position, open state, panel size, and selected inspector tab all + persist to `localStorage` under `{storageKey}:*`. +- `Esc` while focus is inside the panel closes it. +- The panel is `position: fixed` and does not reflow the host's layout. + Hosts that want the chat to reflow next to a docked panel must do so + themselves (with `padding-right`/`margin-right` on their layout container, + reacting to `dockChange` if needed). + +## Built-in inspectors + +### Timeline tab (default) + +Vertical list of checkpoints from `agent.history()`, oldest → newest. + +Each row: +- Index badge. +- Short label derived from the last message's role + truncated content. +- Timestamp. +- Expand chevron. Click expands the row inline to show a state diff vs the + previous checkpoint (current `state-diff.ts` rendering). +- On hover: **Replay** and **Fork** actions that emit `replayRequested` / + `forkRequested` with the checkpoint id. + +Header above the list: "{n} checkpoints" + a "clear selection" link. + +Keyboard: +- `↑`/`↓` — move selection. +- `Enter` — expand/collapse the selected row. +- `Home`/`End` — jump to first/last. + +### State tab + +- Current `agent.state()` rendered as a collapsible JSON tree (reuse + `debug-state-inspector`). +- Search input at top filters keys. +- Copy button copies the JSON payload to clipboard. + +## File structure + +Under `libs/chat/src/lib/compositions/chat-debug/`: + +**New / restructured** + +- `chat-debug.component.ts` — launcher + docked panel chrome (replaces + current implementation). +- `chat-debug-controls.directive.ts` — `[chatDebugControls]` structural + directive marker. +- `chat-debug-inspector.directive.ts` — `[chatDebugInspector]` structural + directive with `label` input. +- `primitives/chat-debug-section.component.ts` +- `primitives/chat-debug-segmented.component.ts` +- `primitives/chat-debug-select.component.ts` +- `primitives/chat-debug-toggle.component.ts` +- `primitives/chat-debug-action.component.ts` +- `inspectors/timeline-inspector.component.ts` — vertical list + inline diff + + keyboard nav; wraps existing checkpoint card and state-diff rendering. +- `inspectors/state-inspector.component.ts` — wraps existing + `debug-state-inspector` for the State tab. +- `persistence.ts` — typed `localStorage` wrapper with `{storageKey}:*` + namespacing. + +**Survives, re-wired under new chrome** + +- `debug-checkpoint-card.component.ts` +- `debug-state-diff.component.ts` +- `debug-state-inspector.component.ts` +- `debug-utils.ts` +- `state-diff.ts` + +**Removed** + +- `debug-controls.component.ts` — step buttons replaced by keyboard + + click selection. +- `debug-summary.component.ts` — count moves into the timeline header. +- `debug-detail.component.ts` — detail moves inline into the expanded + timeline row. + +## Public exports from `@ngaf/chat` + +- `ChatDebugComponent` +- `ChatDebugControlsDirective` +- `ChatDebugInspectorDirective` +- `ChatDebugSectionComponent` +- `ChatDebugSegmentedComponent` +- `ChatDebugSelectComponent` +- `ChatDebugToggleComponent` +- `ChatDebugActionComponent` + +## Styling and tokens + +The panel consumes existing `--ngaf-chat-*` tokens directly +(`--ngaf-chat-bg`, `--ngaf-chat-text`, `--ngaf-chat-text-muted`, +`--ngaf-chat-separator`, `--ngaf-chat-surface-alt`, `--ngaf-chat-radius-card`, +spacing tokens). No new token namespace is introduced. The launcher button +uses `--ngaf-chat-primary` for its background. + +A host with the A2UI theme system installed gets the panel themed +automatically; a host without it gets the chat library's default token +values. The tokens themselves are independent of A2UI surface implementation. + +## Smoke app migration + +In `examples/chat/angular`: + +**Replace** the `` element and the +`.demo-shell__debug` overlay block in +[demo-shell.component.html](examples/chat/angular/src/app/shell/demo-shell.component.html) +with a single `` instance whose `chatDebugControls` template +houses the existing mode/model/effort/genUI/theme/new-conversation controls +using the new primitives. + +**Delete** + +- `examples/chat/angular/src/app/shell/control-palette.component.{ts,html,css}` and its spec. +- `examples/chat/angular/src/app/shell/palette-persistence.service.{ts,spec.ts}`. +- The "Debug on/off" toggle (chat-debug owns its open state). +- The `.demo-shell__debug` fixed-bottom overlay style block. + +**Survives** + +- Threads drawer (left side, orthogonal). +- Interrupt panel + subagents overlays (orthogonal). +- Mode routing (`embed` / `popup` / `sidebar`) — still drives the + ``. Mode picker now lives in chat-debug controls. + +## Testing + +- Unit specs per primitive covering input/output contract and a11y + attributes (`aria-pressed`, `role="tab"`, label association on selects). +- `chat-debug.component.spec.ts`: open/close, dock cycling, persistence + round-trip, slot projection (controls present / absent), tab strip + rendering rules (hidden with one tab, visible with two+), ESC close. +- `timeline-inspector.spec.ts`: keyboard navigation, replay/fork emission, + selection state, inline diff rendering on expand. +- Smoke pass: rerun `examples-chat-smoke:run` against a local-published + `@ngaf/chat` and verify the migrated demo-shell renders correctly with no + duplicate chat surfaces. + +## Out of scope (future work) + +- Bundled "chat playground" composition (was the old `` shape). + If a future use case justifies it, it can be reintroduced as a separate + composition that internally uses `` + `` together. +- Detach-to-popup-window (Chrome devtools-style undock). +- Multi-agent comparison view. +- Network / LLM-call / tool-call inspector tabs (the slot API is the + forward path for these). From 88382f2d30f1c7044b2bdb4ca7bfa5d7f7a628bc Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 11 May 2026 13:18:34 -0700 Subject: [PATCH 02/18] docs(plans): chat-debug devtools implementation plan 13 bite-sized tasks covering persistence wrapper, slot directives, blessed primitives (section/segmented/select/toggle/action), built-in Timeline + State inspectors, full ChatDebugComponent rewrite, public-api wiring, and smoke app migration with end-to-end verification. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plans/2026-05-11-chat-debug-devtools.md | 1659 +++++++++++++++++ 1 file changed, 1659 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-11-chat-debug-devtools.md diff --git a/docs/superpowers/plans/2026-05-11-chat-debug-devtools.md b/docs/superpowers/plans/2026-05-11-chat-debug-devtools.md new file mode 100644 index 000000000..f24d5776a --- /dev/null +++ b/docs/superpowers/plans/2026-05-11-chat-debug-devtools.md @@ -0,0 +1,1659 @@ +# Chat Debug Devtools 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:** Repurpose `` into a floating devtools launcher with a built-in Timeline + State inspector and a slot-based extension API (`chatDebugControls`, `chatDebugInspector`). Migrate the smoke app's `ControlPalette` to live inside `chat-debug` via the new API. + +**Architecture:** Single launcher component owning open/close + dock state, persisted via a typed localStorage wrapper. Built-in inspectors are repositories that re-use existing checkpoint-card / state-diff / state-inspector pieces. Public slots are structural directives on `` queried via `contentChild` / `contentChildren`. Blessed primitive components (``, ``, ``, ``, ``) give host apps a styled vocabulary for the controls slot. + +**Tech Stack:** Angular 20 (signals, standalone, `input.required`, `contentChild(ren)`), vitest + jsdom for tests, Nx monorepo, `@ngaf/chat` package surface, `--ngaf-chat-*` design tokens. + +**Spec:** [docs/superpowers/specs/2026-05-11-chat-debug-devtools-design.md](../specs/2026-05-11-chat-debug-devtools-design.md) + +--- + +## File map + +**New files** (all under `libs/chat/src/lib/compositions/chat-debug/`): + +- `persistence.ts` — typed localStorage wrapper. +- `persistence.spec.ts` — unit tests for persistence. +- `chat-debug-controls.directive.ts` — `[chatDebugControls]` structural marker. +- `chat-debug-inspector.directive.ts` — `[chatDebugInspector]` with `label` input. +- `primitives/chat-debug-section.component.ts` +- `primitives/chat-debug-segmented.component.ts` +- `primitives/chat-debug-select.component.ts` +- `primitives/chat-debug-toggle.component.ts` +- `primitives/chat-debug-action.component.ts` +- `primitives/primitives.spec.ts` — defined-as-class smoke tests for all five primitives. +- `inspectors/timeline-inspector.component.ts` — vertical list, keyboard nav, inline expand. +- `inspectors/timeline-inspector.spec.ts` — keyboard nav pure-function tests. +- `inspectors/state-inspector.component.ts` — wraps existing `debug-state-inspector` for the tab. + +**Files modified:** + +- `libs/chat/src/lib/compositions/chat-debug/chat-debug.component.ts` — full rewrite (launcher + dock chrome). +- `libs/chat/src/lib/compositions/chat-debug/chat-debug.component.spec.ts` — replace stale entries, add new defined-as-class checks. +- `libs/chat/src/public-api.ts:75` — replace single export with new public surface. +- `examples/chat/angular/src/app/shell/demo-shell.component.html` — replace `` block + `.demo-shell__debug` block with `` + projected `chatDebugControls`. +- `examples/chat/angular/src/app/shell/demo-shell.component.ts` — drop palette imports/wiring, import chat-debug primitives. +- `examples/chat/angular/src/app/shell/demo-shell.component.css` — delete `.demo-shell__debug` rule and any palette-only CSS. + +**Files deleted:** + +- `libs/chat/src/lib/compositions/chat-debug/debug-controls.component.ts` +- `libs/chat/src/lib/compositions/chat-debug/debug-summary.component.ts` +- `libs/chat/src/lib/compositions/chat-debug/debug-detail.component.ts` +- `libs/chat/src/lib/compositions/chat-debug/debug-timeline.component.ts` (replaced by `inspectors/timeline-inspector.component.ts`) +- `examples/chat/angular/src/app/shell/control-palette.component.ts` +- `examples/chat/angular/src/app/shell/control-palette.component.html` +- `examples/chat/angular/src/app/shell/control-palette.component.css` +- `examples/chat/angular/src/app/shell/control-palette.component.spec.ts` +- `examples/chat/angular/src/app/shell/palette-persistence.service.ts` +- `examples/chat/angular/src/app/shell/palette-persistence.service.spec.ts` + +--- + +## Task 1: Persistence wrapper + +**Files:** +- Create: `libs/chat/src/lib/compositions/chat-debug/persistence.ts` +- Test: `libs/chat/src/lib/compositions/chat-debug/persistence.spec.ts` + +- [ ] **Step 1: Write the failing test** + +`libs/chat/src/lib/compositions/chat-debug/persistence.spec.ts`: + +```ts +// SPDX-License-Identifier: MIT +import { describe, it, expect, beforeEach } from 'vitest'; +import { createPersistence } from './persistence'; + +describe('createPersistence', () => { + beforeEach(() => { + localStorage.clear(); + }); + + it('reads undefined when no key set', () => { + const p = createPersistence('test'); + expect(p.read('dock')).toBeUndefined(); + }); + + it('round-trips a string value under the namespaced key', () => { + const p = createPersistence('test'); + p.write('dock', 'bottom'); + expect(p.read('dock')).toBe('bottom'); + expect(localStorage.getItem('test:dock')).toBe('"bottom"'); + }); + + it('round-trips a number value', () => { + const p = createPersistence('test'); + p.write('size', 480); + expect(p.read('size')).toBe(480); + }); + + it('round-trips a boolean value', () => { + const p = createPersistence('test'); + p.write('open', true); + expect(p.read('open')).toBe(true); + }); + + it('returns undefined when stored JSON is malformed', () => { + localStorage.setItem('test:dock', '{not-json'); + const p = createPersistence('test'); + expect(p.read('dock')).toBeUndefined(); + }); + + it('isolates by prefix', () => { + const a = createPersistence('a'); + const b = createPersistence('b'); + a.write('open', true); + expect(b.read('open')).toBeUndefined(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx nx test chat -- persistence.spec` +Expected: FAIL with module-not-found / `createPersistence is not a function`. + +- [ ] **Step 3: Write the implementation** + +`libs/chat/src/lib/compositions/chat-debug/persistence.ts`: + +```ts +// SPDX-License-Identifier: MIT +export interface Persistence { + read(key: string): T | undefined; + write(key: string, value: T): void; +} + +/** + * Tiny typed localStorage wrapper namespaced under `{prefix}:`. SSR-safe: + * when `localStorage` is undefined (server build), read returns undefined + * and write is a no-op. + */ +export function createPersistence(prefix: string): Persistence { + const fullKey = (k: string) => `${prefix}:${k}`; + return { + read(key: string): T | undefined { + if (typeof localStorage === 'undefined') return undefined; + const raw = localStorage.getItem(fullKey(key)); + if (raw === null) return undefined; + try { + return JSON.parse(raw) as T; + } catch { + return undefined; + } + }, + write(key: string, value: T): void { + if (typeof localStorage === 'undefined') return; + localStorage.setItem(fullKey(key), JSON.stringify(value)); + }, + }; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx nx test chat -- persistence.spec` +Expected: PASS, 6 tests. + +- [ ] **Step 5: Commit** + +```bash +git add libs/chat/src/lib/compositions/chat-debug/persistence.ts libs/chat/src/lib/compositions/chat-debug/persistence.spec.ts +git commit -m "feat(chat): chat-debug persistence wrapper" +``` + +--- + +## Task 2: Slot directives + +**Files:** +- Create: `libs/chat/src/lib/compositions/chat-debug/chat-debug-controls.directive.ts` +- Create: `libs/chat/src/lib/compositions/chat-debug/chat-debug-inspector.directive.ts` + +These are pure marker directives queried by `contentChild` / `contentChildren`. No behavior on their own — they expose `TemplateRef` + a `label` input (inspector only). + +- [ ] **Step 1: Create controls directive** + +`libs/chat/src/lib/compositions/chat-debug/chat-debug-controls.directive.ts`: + +```ts +// SPDX-License-Identifier: MIT +import { Directive, TemplateRef, inject } from '@angular/core'; + +/** + * Marks an `` as the controls slot of ``. Rendered + * pinned at the top of the docked panel. Host apps put their app-specific + * controls (mode picker, model select, etc.) inside this template. + */ +@Directive({ + selector: 'ng-template[chatDebugControls]', + standalone: true, +}) +export class ChatDebugControlsDirective { + readonly templateRef = inject(TemplateRef); +} +``` + +- [ ] **Step 2: Create inspector directive** + +`libs/chat/src/lib/compositions/chat-debug/chat-debug-inspector.directive.ts`: + +```ts +// SPDX-License-Identifier: MIT +import { Directive, TemplateRef, inject, input } from '@angular/core'; + +/** + * Marks an `` as a host-registered inspector tab. Each instance + * adds a tab in the docked panel's tab strip, appended after the built-in + * Timeline and State tabs. + */ +@Directive({ + selector: 'ng-template[chatDebugInspector]', + standalone: true, +}) +export class ChatDebugInspectorDirective { + readonly label = input.required({ alias: 'chatDebugInspectorLabel' }); + readonly templateRef = inject(TemplateRef); +} +``` + +Note: the alias makes the usage read `` to avoid binding both `chatDebugInspector` and `label` separately. If a simpler `label` binding is preferred, drop the alias. + +- [ ] **Step 3: Commit** + +```bash +git add libs/chat/src/lib/compositions/chat-debug/chat-debug-controls.directive.ts libs/chat/src/lib/compositions/chat-debug/chat-debug-inspector.directive.ts +git commit -m "feat(chat): chat-debug slot directives (controls + inspector)" +``` + +--- + +## Task 3: `` primitive + +**Files:** +- Create: `libs/chat/src/lib/compositions/chat-debug/primitives/chat-debug-section.component.ts` + +Visual container with an optional label and consistent vertical spacing for stacked controls. + +- [ ] **Step 1: Write the implementation** + +```ts +// SPDX-License-Identifier: MIT +import { Component, ChangeDetectionStrategy, input } from '@angular/core'; +import { CHAT_HOST_TOKENS } from '../../../styles/chat-tokens'; + +@Component({ + selector: 'chat-debug-section', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + styles: [ + CHAT_HOST_TOKENS, + ` + :host { + display: block; + padding: var(--ngaf-chat-space-3) var(--ngaf-chat-space-4); + border-bottom: 1px solid var(--ngaf-chat-separator); + } + :host:last-child { border-bottom: 0; } + .section__label { + font-size: var(--ngaf-chat-font-size-xs); + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--ngaf-chat-text-muted); + margin: 0 0 var(--ngaf-chat-space-2); + } + .section__body { display: flex; flex-direction: column; gap: var(--ngaf-chat-space-2); } + `, + ], + template: ` + @if (label()) { + + } +
+ `, +}) +export class ChatDebugSectionComponent { + readonly label = input(''); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add libs/chat/src/lib/compositions/chat-debug/primitives/chat-debug-section.component.ts +git commit -m "feat(chat): chat-debug-section primitive" +``` + +--- + +## Task 4: `` primitive + +**Files:** +- Create: `libs/chat/src/lib/compositions/chat-debug/primitives/chat-debug-segmented.component.ts` + +Tab-style segmented choice — visual equivalent of the smoke app's mode tabs (Embed / Popup / Sidebar). + +- [ ] **Step 1: Write the implementation** + +```ts +// SPDX-License-Identifier: MIT +import { Component, ChangeDetectionStrategy, input, output } from '@angular/core'; +import { CHAT_HOST_TOKENS } from '../../../styles/chat-tokens'; + +export interface SegmentedOption { + readonly value: string; + readonly label: string; +} + +@Component({ + selector: 'chat-debug-segmented', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + styles: [ + CHAT_HOST_TOKENS, + ` + :host { display: block; } + .segmented { + display: inline-flex; + gap: 0; + padding: 2px; + background: var(--ngaf-chat-surface-alt); + border: 1px solid var(--ngaf-chat-separator); + border-radius: var(--ngaf-chat-radius-button); + } + .segmented__btn { + appearance: none; + border: 0; + background: transparent; + padding: 4px 10px; + font-size: var(--ngaf-chat-font-size-sm); + color: var(--ngaf-chat-text-muted); + cursor: pointer; + border-radius: calc(var(--ngaf-chat-radius-button) - 2px); + } + .segmented__btn.is-active { + background: var(--ngaf-chat-bg); + color: var(--ngaf-chat-text); + box-shadow: var(--ngaf-chat-shadow-sm); + } + `, + ], + template: ` +
+ @for (opt of options(); track opt.value) { + + } +
+ `, +}) +export class ChatDebugSegmentedComponent { + readonly options = input.required(); + readonly value = input.required(); + readonly valueChange = output(); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add libs/chat/src/lib/compositions/chat-debug/primitives/chat-debug-segmented.component.ts +git commit -m "feat(chat): chat-debug-segmented primitive" +``` + +--- + +## Task 5: `` primitive + +**Files:** +- Create: `libs/chat/src/lib/compositions/chat-debug/primitives/chat-debug-select.component.ts` + +Labeled dropdown for Model / Effort / GenUI / Theme. + +- [ ] **Step 1: Write the implementation** + +```ts +// SPDX-License-Identifier: MIT +import { Component, ChangeDetectionStrategy, input, output } from '@angular/core'; +import { CHAT_HOST_TOKENS } from '../../../styles/chat-tokens'; + +export interface SelectOption { + readonly value: string; + readonly label: string; +} + +@Component({ + selector: 'chat-debug-select', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + styles: [ + CHAT_HOST_TOKENS, + ` + :host { display: block; } + label { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--ngaf-chat-space-3); + font-size: var(--ngaf-chat-font-size-sm); + color: var(--ngaf-chat-text); + } + select { + appearance: none; + background: var(--ngaf-chat-bg); + color: var(--ngaf-chat-text); + border: 1px solid var(--ngaf-chat-separator); + border-radius: var(--ngaf-chat-radius-button); + padding: 4px 8px; + font: inherit; + font-size: var(--ngaf-chat-font-size-sm); + } + `, + ], + template: ` + + `, +}) +export class ChatDebugSelectComponent { + readonly label = input.required(); + readonly options = input.required(); + readonly value = input.required(); + readonly valueChange = output(); + + protected onChange(event: Event): void { + this.valueChange.emit((event.target as HTMLSelectElement).value); + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add libs/chat/src/lib/compositions/chat-debug/primitives/chat-debug-select.component.ts +git commit -m "feat(chat): chat-debug-select primitive" +``` + +--- + +## Task 6: `` primitive + +**Files:** +- Create: `libs/chat/src/lib/compositions/chat-debug/primitives/chat-debug-toggle.component.ts` + +On/off switch, future-proofing the API. + +- [ ] **Step 1: Write the implementation** + +```ts +// SPDX-License-Identifier: MIT +import { Component, ChangeDetectionStrategy, input, output } from '@angular/core'; +import { CHAT_HOST_TOKENS } from '../../../styles/chat-tokens'; + +@Component({ + selector: 'chat-debug-toggle', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + styles: [ + CHAT_HOST_TOKENS, + ` + :host { display: block; } + button { + display: inline-flex; + align-items: center; + gap: var(--ngaf-chat-space-2); + appearance: none; + background: transparent; + border: 1px solid var(--ngaf-chat-separator); + border-radius: var(--ngaf-chat-radius-button); + padding: 4px 10px; + font: inherit; + font-size: var(--ngaf-chat-font-size-sm); + color: var(--ngaf-chat-text); + cursor: pointer; + } + .dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--ngaf-chat-muted); + } + button.is-on .dot { background: var(--ngaf-chat-success); } + `, + ], + template: ` + + `, +}) +export class ChatDebugToggleComponent { + readonly label = input.required(); + readonly value = input.required(); + readonly valueChange = output(); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add libs/chat/src/lib/compositions/chat-debug/primitives/chat-debug-toggle.component.ts +git commit -m "feat(chat): chat-debug-toggle primitive" +``` + +--- + +## Task 7: `` primitive + primitives spec + +**Files:** +- Create: `libs/chat/src/lib/compositions/chat-debug/primitives/chat-debug-action.component.ts` +- Create: `libs/chat/src/lib/compositions/chat-debug/primitives/primitives.spec.ts` + +Simple button (e.g., "New conversation"). + +- [ ] **Step 1: Write the action component** + +```ts +// SPDX-License-Identifier: MIT +import { Component, ChangeDetectionStrategy, input, output } from '@angular/core'; +import { CHAT_HOST_TOKENS } from '../../../styles/chat-tokens'; + +@Component({ + selector: 'chat-debug-action', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + styles: [ + CHAT_HOST_TOKENS, + ` + :host { display: block; } + button { + appearance: none; + background: var(--ngaf-chat-surface-alt); + color: var(--ngaf-chat-text); + border: 1px solid var(--ngaf-chat-separator); + border-radius: var(--ngaf-chat-radius-button); + padding: 6px 12px; + font: inherit; + font-size: var(--ngaf-chat-font-size-sm); + cursor: pointer; + width: 100%; + text-align: left; + } + button:hover { background: color-mix(in srgb, var(--ngaf-chat-text) 5%, var(--ngaf-chat-surface-alt)); } + `, + ], + template: ` + + `, +}) +export class ChatDebugActionComponent { + readonly label = input.required(); + readonly clicked = output(); +} +``` + +- [ ] **Step 2: Write the primitives smoke spec** + +`libs/chat/src/lib/compositions/chat-debug/primitives/primitives.spec.ts`: + +```ts +// SPDX-License-Identifier: MIT +import { describe, it, expect } from 'vitest'; +import { ChatDebugSectionComponent } from './chat-debug-section.component'; +import { ChatDebugSegmentedComponent } from './chat-debug-segmented.component'; +import { ChatDebugSelectComponent } from './chat-debug-select.component'; +import { ChatDebugToggleComponent } from './chat-debug-toggle.component'; +import { ChatDebugActionComponent } from './chat-debug-action.component'; + +describe('chat-debug primitives are defined', () => { + it('section', () => { expect(typeof ChatDebugSectionComponent).toBe('function'); }); + it('segmented', () => { expect(typeof ChatDebugSegmentedComponent).toBe('function'); }); + it('select', () => { expect(typeof ChatDebugSelectComponent).toBe('function'); }); + it('toggle', () => { expect(typeof ChatDebugToggleComponent).toBe('function'); }); + it('action', () => { expect(typeof ChatDebugActionComponent).toBe('function'); }); +}); +``` + +- [ ] **Step 3: Run primitives spec** + +Run: `npx nx test chat -- primitives.spec` +Expected: PASS, 5 tests. + +- [ ] **Step 4: Commit** + +```bash +git add libs/chat/src/lib/compositions/chat-debug/primitives/chat-debug-action.component.ts libs/chat/src/lib/compositions/chat-debug/primitives/primitives.spec.ts +git commit -m "feat(chat): chat-debug-action primitive + primitives smoke spec" +``` + +--- + +## Task 8: Timeline inspector + +**Files:** +- Create: `libs/chat/src/lib/compositions/chat-debug/inspectors/timeline-inspector.component.ts` +- Create: `libs/chat/src/lib/compositions/chat-debug/inspectors/timeline-inspector.spec.ts` + +Vertical list of checkpoints with inline expansion. Re-uses existing `DebugCheckpointCardComponent` for each row and `chat-debug-state-diff` inside the expanded row. + +- [ ] **Step 1: Write the failing nav-logic spec** + +`libs/chat/src/lib/compositions/chat-debug/inspectors/timeline-inspector.spec.ts`: + +```ts +// SPDX-License-Identifier: MIT +import { describe, it, expect } from 'vitest'; +import { stepSelection, type Direction } from './timeline-inspector.component'; + +describe('stepSelection', () => { + it('moves down when not at end', () => { + expect(stepSelection('down', 0, 3)).toBe(1); + }); + + it('does not move past last index', () => { + expect(stepSelection('down', 2, 3)).toBe(2); + }); + + it('moves up when not at start', () => { + expect(stepSelection('up', 2, 3)).toBe(1); + }); + + it('does not move below 0', () => { + expect(stepSelection('up', 0, 3)).toBe(0); + }); + + it('jumps to start', () => { + expect(stepSelection('home', 5, 10)).toBe(0); + }); + + it('jumps to end', () => { + expect(stepSelection('end', 0, 4)).toBe(3); + }); + + it('returns -1 when count is 0', () => { + expect(stepSelection('down', -1, 0)).toBe(-1); + expect(stepSelection('end', -1, 0)).toBe(-1); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx nx test chat -- timeline-inspector.spec` +Expected: FAIL with `stepSelection is not a function`. + +- [ ] **Step 3: Write the component file** + +`libs/chat/src/lib/compositions/chat-debug/inspectors/timeline-inspector.component.ts`: + +```ts +// SPDX-License-Identifier: MIT +import { + Component, + ChangeDetectionStrategy, + computed, + input, + output, + signal, + HostListener, +} from '@angular/core'; +import type { AgentWithHistory } from '../../../agent'; +import { CHAT_HOST_TOKENS } from '../../../styles/chat-tokens'; +import { DebugCheckpointCardComponent, type DebugCheckpoint } from '../debug-checkpoint-card.component'; +import { DebugStateDiffComponent } from '../debug-state-diff.component'; +import { toDebugCheckpoint, extractStateValues } from '../debug-utils'; + +export type Direction = 'up' | 'down' | 'home' | 'end'; + +/** + * Pure selection-step function. Exported for unit testing — the component's + * keyboard handler delegates to it. + */ +export function stepSelection(dir: Direction, current: number, count: number): number { + if (count === 0) return -1; + switch (dir) { + case 'down': return Math.min(current + 1, count - 1); + case 'up': return Math.max(current - 1, 0); + case 'home': return 0; + case 'end': return count - 1; + } +} + +@Component({ + selector: 'chat-debug-timeline-inspector', + standalone: true, + imports: [DebugCheckpointCardComponent, DebugStateDiffComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + styles: [ + CHAT_HOST_TOKENS, + ` + :host { display: flex; flex-direction: column; height: 100%; outline: none; } + .timeline__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--ngaf-chat-space-2) var(--ngaf-chat-space-4); + border-bottom: 1px solid var(--ngaf-chat-separator); + font-size: var(--ngaf-chat-font-size-xs); + color: var(--ngaf-chat-text-muted); + } + .timeline__clear { + background: transparent; + border: 0; + cursor: pointer; + color: var(--ngaf-chat-text-muted); + font-size: var(--ngaf-chat-font-size-xs); + } + .timeline__clear:disabled { opacity: 0.5; cursor: default; } + .timeline__list { + flex: 1; + overflow-y: auto; + padding: var(--ngaf-chat-space-2) var(--ngaf-chat-space-4); + display: flex; + flex-direction: column; + gap: var(--ngaf-chat-space-2); + } + .timeline__row { display: flex; flex-direction: column; gap: var(--ngaf-chat-space-2); } + .timeline__row-actions { + display: none; + gap: var(--ngaf-chat-space-2); + padding-left: var(--ngaf-chat-space-3); + } + .timeline__row:hover .timeline__row-actions { display: flex; } + .timeline__row button.row-action { + background: transparent; + border: 1px solid var(--ngaf-chat-separator); + border-radius: var(--ngaf-chat-radius-button); + padding: 2px 8px; + font-size: var(--ngaf-chat-font-size-xs); + color: var(--ngaf-chat-text-muted); + cursor: pointer; + } + .timeline__row button.row-action:hover { color: var(--ngaf-chat-text); } + .timeline__diff { + padding: var(--ngaf-chat-space-2); + background: var(--ngaf-chat-surface-alt); + border-radius: var(--ngaf-chat-radius-card); + } + `, + ], + template: ` +
+ {{ checkpoints().length }} checkpoints + +
+
+ @for (cp of checkpoints(); let i = $index; track cp.checkpointId ?? i) { +
+ + @if (i === selectedIndex() && cp.checkpointId) { +
+ + +
+ } + @if (i === selectedIndex()) { +
+ +
+ } +
+ } +
+ `, +}) +export class TimelineInspectorComponent { + readonly agent = input.required(); + readonly replayRequested = output(); + readonly forkRequested = output(); + + readonly selectedIndex = signal(-1); + + readonly checkpoints = computed((): DebugCheckpoint[] => + this.agent().history().map((cp, i) => toDebugCheckpoint(cp, i)), + ); + + currentStateAt(i: number): Record { + return extractStateValues(this.agent().history()[i]); + } + + previousStateAt(i: number): Record { + if (i <= 0) return {}; + return extractStateValues(this.agent().history()[i - 1]); + } + + @HostListener('keydown', ['$event']) + protected onKey(ev: KeyboardEvent): void { + const map: Record = { + ArrowDown: 'down', + ArrowUp: 'up', + Home: 'home', + End: 'end', + }; + const dir = map[ev.key]; + if (!dir) return; + ev.preventDefault(); + this.selectedIndex.set(stepSelection(dir, this.selectedIndex(), this.checkpoints().length)); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx nx test chat -- timeline-inspector.spec` +Expected: PASS, 7 tests. + +- [ ] **Step 5: Commit** + +```bash +git add libs/chat/src/lib/compositions/chat-debug/inspectors/ +git commit -m "feat(chat): timeline inspector with keyboard nav + inline diff" +``` + +--- + +## Task 9: State inspector wrapper + +**Files:** +- Create: `libs/chat/src/lib/compositions/chat-debug/inspectors/state-inspector.component.ts` + +Thin wrapper around the existing `DebugStateInspectorComponent` for the State tab. Existing component already renders the JSON tree; this component just feeds it `agent.state()` and adds a copy button. + +- [ ] **Step 1: Write the implementation** + +```ts +// SPDX-License-Identifier: MIT +import { Component, ChangeDetectionStrategy, input, computed } from '@angular/core'; +import type { AgentWithHistory } from '../../../agent'; +import { CHAT_HOST_TOKENS } from '../../../styles/chat-tokens'; +import { DebugStateInspectorComponent } from '../debug-state-inspector.component'; +import { extractStateValues } from '../debug-utils'; + +@Component({ + selector: 'chat-debug-state-tab', + standalone: true, + imports: [DebugStateInspectorComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + styles: [ + CHAT_HOST_TOKENS, + ` + :host { display: flex; flex-direction: column; height: 100%; } + .state__header { + display: flex; + justify-content: flex-end; + padding: var(--ngaf-chat-space-2) var(--ngaf-chat-space-4); + border-bottom: 1px solid var(--ngaf-chat-separator); + } + .state__copy { + background: transparent; + border: 1px solid var(--ngaf-chat-separator); + border-radius: var(--ngaf-chat-radius-button); + padding: 2px 8px; + font: inherit; + font-size: var(--ngaf-chat-font-size-xs); + color: var(--ngaf-chat-text-muted); + cursor: pointer; + } + .state__body { + flex: 1; + overflow-y: auto; + padding: var(--ngaf-chat-space-2) var(--ngaf-chat-space-4); + } + `, + ], + template: ` +
+ +
+
+ +
+ `, +}) +export class StateInspectorComponent { + readonly agent = input.required(); + + readonly state = computed((): Record => { + const history = this.agent().history(); + const last = history[history.length - 1]; + return extractStateValues(last); + }); + + copy(): void { + if (typeof navigator === 'undefined' || !navigator.clipboard) return; + void navigator.clipboard.writeText(JSON.stringify(this.state(), null, 2)); + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add libs/chat/src/lib/compositions/chat-debug/inspectors/state-inspector.component.ts +git commit -m "feat(chat): state inspector tab wrapper" +``` + +--- + +## Task 10: Rewrite `` — launcher + dock chrome + +**Files:** +- Modify: `libs/chat/src/lib/compositions/chat-debug/chat-debug.component.ts` (full rewrite) + +The new `ChatDebugComponent` is a launcher that: +- shows a 40px floating button when closed, +- when open, renders a fixed docked panel with header (dock toggle + close), optional projected controls, tab strip, and active inspector, +- persists `open`, `dock`, `size`, `tab` via the persistence wrapper, +- queries `ChatDebugControlsDirective` (single) via `contentChild`, +- queries `ChatDebugInspectorDirective[]` (repeatable) via `contentChildren`, +- forwards `replayRequested` / `forkRequested` from the timeline. + +- [ ] **Step 1: Replace `chat-debug.component.ts` entirely** + +```ts +// SPDX-License-Identifier: MIT +import { + Component, + ChangeDetectionStrategy, + computed, + contentChild, + contentChildren, + effect, + HostListener, + input, + output, + signal, +} from '@angular/core'; +import { NgTemplateOutlet } from '@angular/common'; +import type { AgentWithHistory } from '../../agent'; +import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens'; +import { ChatDebugControlsDirective } from './chat-debug-controls.directive'; +import { ChatDebugInspectorDirective } from './chat-debug-inspector.directive'; +import { TimelineInspectorComponent } from './inspectors/timeline-inspector.component'; +import { StateInspectorComponent } from './inspectors/state-inspector.component'; +import { createPersistence } from './persistence'; + +export type DockPosition = 'right' | 'bottom' | 'left'; + +interface TabEntry { + readonly id: string; + readonly label: string; + readonly kind: 'builtin-timeline' | 'builtin-state' | 'host'; + readonly hostIndex?: number; +} + +@Component({ + selector: 'chat-debug', + standalone: true, + imports: [ + NgTemplateOutlet, + TimelineInspectorComponent, + StateInspectorComponent, + ], + changeDetection: ChangeDetectionStrategy.OnPush, + styles: [ + CHAT_HOST_TOKENS, + ` + :host { display: contents; } + + /* Floating launcher */ + .launcher { + position: fixed; + bottom: 16px; + right: 16px; + width: 40px; + height: 40px; + border-radius: var(--ngaf-chat-radius-launcher); + background: var(--ngaf-chat-primary); + color: var(--ngaf-chat-on-primary); + border: 0; + cursor: pointer; + z-index: 990; + box-shadow: var(--ngaf-chat-shadow-md); + font-size: 18px; + display: flex; + align-items: center; + justify-content: center; + } + + /* Docked panel */ + .panel { + position: fixed; + background: var(--ngaf-chat-bg); + color: var(--ngaf-chat-text); + border: 1px solid var(--ngaf-chat-separator); + z-index: 991; + display: flex; + flex-direction: column; + box-shadow: var(--ngaf-chat-shadow-lg); + } + .panel--right { top: 0; right: 0; bottom: 0; width: var(--panel-size, 420px); border-right: 0; } + .panel--left { top: 0; left: 0; bottom: 0; width: var(--panel-size, 420px); border-left: 0; } + .panel--bottom { left: 0; right: 0; bottom: 0; height: var(--panel-size, 40vh); border-bottom: 0; } + + .panel__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--ngaf-chat-space-2) var(--ngaf-chat-space-4); + border-bottom: 1px solid var(--ngaf-chat-separator); + } + .panel__title { + margin: 0; + font-size: var(--ngaf-chat-font-size-sm); + font-weight: 600; + } + .panel__actions { display: flex; align-items: center; gap: var(--ngaf-chat-space-1); } + .panel__actions button { + background: transparent; + border: 1px solid transparent; + border-radius: var(--ngaf-chat-radius-button); + padding: 2px 6px; + color: var(--ngaf-chat-text-muted); + font: inherit; + font-size: var(--ngaf-chat-font-size-sm); + cursor: pointer; + } + .panel__actions button:hover { color: var(--ngaf-chat-text); border-color: var(--ngaf-chat-separator); } + .panel__actions button.is-active { color: var(--ngaf-chat-text); } + + .panel__controls { + border-bottom: 1px solid var(--ngaf-chat-separator); + overflow-y: auto; + max-height: 40%; + } + .panel__controls:empty { display: none; } + + .panel__tabs { + display: flex; + gap: 0; + border-bottom: 1px solid var(--ngaf-chat-separator); + padding: 0 var(--ngaf-chat-space-2); + } + .panel__tab { + appearance: none; + background: transparent; + border: 0; + border-bottom: 2px solid transparent; + padding: var(--ngaf-chat-space-2) var(--ngaf-chat-space-3); + font: inherit; + font-size: var(--ngaf-chat-font-size-sm); + color: var(--ngaf-chat-text-muted); + cursor: pointer; + } + .panel__tab.is-active { + color: var(--ngaf-chat-text); + border-bottom-color: var(--ngaf-chat-primary); + } + + .panel__body { flex: 1; min-height: 0; overflow: hidden; display: flex; flex-direction: column; } + `, + ], + template: ` + @if (!open()) { + + } @else { +
+
+

Chat Debug

+
+ + + + +
+
+ + @if (controls()) { +
+ +
+ } + + @if (tabs().length > 1) { +
+ @for (tab of tabs(); track tab.id) { + + } +
+ } + +
+ @switch (activeTab()?.kind) { + @case ('builtin-timeline') { + + } + @case ('builtin-state') { + + } + @case ('host') { + @if (activeHostInspector(); as host) { + + } + } + } +
+
+ } + `, +}) +export class ChatDebugComponent { + readonly agent = input.required(); + readonly dock = input('right'); + readonly defaultOpen = input(false); + readonly storageKey = input('chat-debug'); + + readonly replayRequested = output(); + readonly forkRequested = output(); + readonly openChange = output(); + readonly dockChange = output(); + + protected readonly controls = contentChild(ChatDebugControlsDirective); + protected readonly hostInspectors = contentChildren(ChatDebugInspectorDirective); + + protected readonly open = signal(false); + // Internal dock state — initialised in the persistence effect below from + // persisted value or the `dock` input. + protected readonly dockState = signal('right'); + protected readonly activeTabId = signal('timeline'); + + protected readonly tabs = computed((): TabEntry[] => { + const host = this.hostInspectors().map((d, i): TabEntry => ({ + id: `host-${i}`, + label: d.label(), + kind: 'host', + hostIndex: i, + })); + return [ + { id: 'timeline', label: 'Timeline', kind: 'builtin-timeline' }, + { id: 'state', label: 'State', kind: 'builtin-state' }, + ...host, + ]; + }); + + protected readonly activeTab = computed(() => + this.tabs().find((t) => t.id === this.activeTabId()), + ); + + protected readonly activeHostInspector = computed(() => { + const t = this.activeTab(); + if (!t || t.kind !== 'host' || t.hostIndex === undefined) return undefined; + return this.hostInspectors()[t.hostIndex]; + }); + + private restored = false; + + constructor() { + // First read of inputs/storage on view init seeds the writable signals, + // then write-through on every change. Untracked reads of the writable + // signals avoid feedback loops. + effect(() => { + const p = createPersistence(this.storageKey()); + if (!this.restored) { + const persistedOpen = p.read('open'); + this.open.set(persistedOpen ?? this.defaultOpen()); + const persistedDock = p.read('dock'); + this.dockState.set(persistedDock ?? this.dock()); + const persistedTab = p.read('tab'); + if (persistedTab) this.activeTabId.set(persistedTab); + this.restored = true; + return; + } + p.write('open', this.open()); + p.write('dock', this.dockState()); + p.write('tab', this.activeTabId()); + }); + } + + setOpen(value: boolean): void { + this.open.set(value); + this.openChange.emit(value); + } + + setDock(next: DockPosition): void { + this.dockState.set(next); + this.dockChange.emit(next); + } + + setActiveTab(id: string): void { + this.activeTabId.set(id); + } + + @HostListener('document:keydown.escape', ['$event']) + protected onEsc(_ev: KeyboardEvent): void { + if (this.open()) { + this.setOpen(false); + } + } +} +``` + +- [ ] **Step 2: Replace the obsolete chat-debug spec body** + +Replace `libs/chat/src/lib/compositions/chat-debug/chat-debug.component.spec.ts` with: + +```ts +// SPDX-License-Identifier: MIT +import { describe, it, expect } from 'vitest'; +import { computeStateDiff } from './state-diff'; +import type { DiffEntry } from './state-diff'; +import { toDebugCheckpoint, extractStateValues } from './debug-utils'; +import type { AgentCheckpoint } from '../../agent'; +import { DebugCheckpointCardComponent } from './debug-checkpoint-card.component'; +import { ChatDebugComponent } from './chat-debug.component'; + +// ── computeStateDiff (unchanged from previous spec) ──────────────────────── + +describe('computeStateDiff', () => { + it('detects added keys', () => { + const result = computeStateDiff({}, { name: 'Alice' }); + expect(result).toEqual([ + { path: 'name', type: 'added', after: 'Alice' }, + ]); + }); + it('detects removed keys', () => { + const result = computeStateDiff({ name: 'Alice' }, {}); + expect(result).toEqual([ + { path: 'name', type: 'removed', before: 'Alice' }, + ]); + }); + it('detects changed keys', () => { + const result = computeStateDiff({ count: 1 }, { count: 2 }); + expect(result).toEqual([ + { path: 'count', type: 'changed', before: 1, after: 2 }, + ]); + }); + it('returns empty array when states are identical', () => { + expect(computeStateDiff({ a: 1 }, { a: 1 })).toEqual([]); + }); + it('recurses into nested objects', () => { + const result = computeStateDiff( + { config: { theme: 'light' } }, + { config: { theme: 'dark' } }, + ); + expect(result).toEqual([ + { path: 'config.theme', type: 'changed', before: 'light', after: 'dark' }, + ]); + }); + it('treats array changes as a single changed entry', () => { + const result = computeStateDiff({ items: [1] }, { items: [1, 2] }); + expect(result).toEqual([ + { path: 'items', type: 'changed', before: [1], after: [1, 2] }, + ]); + }); +}); + +// ── toDebugCheckpoint ────────────────────────────────────────────────────── + +describe('toDebugCheckpoint', () => { + it('uses label as node name when available', () => { + const cp: AgentCheckpoint = { id: 'cp1', label: 'agent', values: {} }; + const result = toDebugCheckpoint(cp, 0); + expect(result.node).toBe('agent'); + expect(result.checkpointId).toBe('cp1'); + }); + it('falls back to Step N when label is absent', () => { + const cp: AgentCheckpoint = { values: {} }; + expect(toDebugCheckpoint(cp, 2).node).toBe('Step 3'); + }); +}); + +// ── extractStateValues ───────────────────────────────────────────────────── + +describe('extractStateValues', () => { + it('returns empty object for undefined checkpoint', () => { + expect(extractStateValues(undefined)).toEqual({}); + }); + it('extracts values from a AgentCheckpoint', () => { + const cp: AgentCheckpoint = { values: { messages: [], count: 5 } }; + expect(extractStateValues(cp)).toEqual({ messages: [], count: 5 }); + }); +}); + +// ── Defined-as-class smoke tests ────────────────────────────────────────── + +describe('ChatDebugComponent', () => { + it('is defined as a class', () => { + expect(typeof ChatDebugComponent).toBe('function'); + }); +}); + +describe('DebugCheckpointCardComponent', () => { + it('is defined as a class', () => { + expect(typeof DebugCheckpointCardComponent).toBe('function'); + }); +}); +``` + +- [ ] **Step 3: Run the lib's tests** + +Run: `npx nx test chat` +Expected: All tests pass (existing + new). + +- [ ] **Step 4: Commit** + +```bash +git add libs/chat/src/lib/compositions/chat-debug/chat-debug.component.ts libs/chat/src/lib/compositions/chat-debug/chat-debug.component.spec.ts +git commit -m "feat(chat): rewrite chat-debug as floating devtools launcher" +``` + +--- + +## Task 11: Delete obsolete files + wire public-api exports + +**Files:** +- Delete: `libs/chat/src/lib/compositions/chat-debug/debug-controls.component.ts` +- Delete: `libs/chat/src/lib/compositions/chat-debug/debug-summary.component.ts` +- Delete: `libs/chat/src/lib/compositions/chat-debug/debug-detail.component.ts` +- Delete: `libs/chat/src/lib/compositions/chat-debug/debug-timeline.component.ts` +- Modify: `libs/chat/src/public-api.ts:75` + +- [ ] **Step 1: Delete obsolete components** + +```bash +git rm libs/chat/src/lib/compositions/chat-debug/debug-controls.component.ts \ + libs/chat/src/lib/compositions/chat-debug/debug-summary.component.ts \ + libs/chat/src/lib/compositions/chat-debug/debug-detail.component.ts \ + libs/chat/src/lib/compositions/chat-debug/debug-timeline.component.ts +``` + +- [ ] **Step 2: Update public-api.ts** + +Locate line 75 (`export { ChatDebugComponent } ...`) and replace with: + +```ts +// chat-debug devtools +export { ChatDebugComponent } from './lib/compositions/chat-debug/chat-debug.component'; +export type { DockPosition } from './lib/compositions/chat-debug/chat-debug.component'; +export { ChatDebugControlsDirective } from './lib/compositions/chat-debug/chat-debug-controls.directive'; +export { ChatDebugInspectorDirective } from './lib/compositions/chat-debug/chat-debug-inspector.directive'; +export { ChatDebugSectionComponent } from './lib/compositions/chat-debug/primitives/chat-debug-section.component'; +export { ChatDebugSegmentedComponent } from './lib/compositions/chat-debug/primitives/chat-debug-segmented.component'; +export type { SegmentedOption } from './lib/compositions/chat-debug/primitives/chat-debug-segmented.component'; +export { ChatDebugSelectComponent } from './lib/compositions/chat-debug/primitives/chat-debug-select.component'; +export type { SelectOption } from './lib/compositions/chat-debug/primitives/chat-debug-select.component'; +export { ChatDebugToggleComponent } from './lib/compositions/chat-debug/primitives/chat-debug-toggle.component'; +export { ChatDebugActionComponent } from './lib/compositions/chat-debug/primitives/chat-debug-action.component'; +``` + +- [ ] **Step 3: Build the lib to catch any reference leaks** + +Run: `npx nx build chat` +Expected: build succeeds. + +If anything still imports the deleted files (e.g., `chat-timeline-slider`), grep and decide: if the importer is also part of the rewrite scope, remove that import. If unrelated, restore the file. Today's grep should only show the now-deleted `chat-debug.component.ts` consumers, which we just rewrote. + +```bash +git grep -n 'debug-controls\|debug-summary\|debug-detail\|debug-timeline\.component' libs/chat/src +``` + +Expected: no matches. + +- [ ] **Step 4: Run all chat lib tests** + +Run: `npx nx test chat` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add libs/chat/src/public-api.ts +git commit -m "feat(chat): public-api exports for chat-debug devtools surface" +``` + +--- + +## Task 12: Smoke app migration — wire chat-debug, delete control-palette + +**Files:** +- Modify: `examples/chat/angular/src/app/shell/demo-shell.component.ts` +- Modify: `examples/chat/angular/src/app/shell/demo-shell.component.html` +- Modify: `examples/chat/angular/src/app/shell/demo-shell.component.css` +- Delete: `examples/chat/angular/src/app/shell/control-palette.component.{ts,html,css,spec.ts}` +- Delete: `examples/chat/angular/src/app/shell/palette-persistence.service.{ts,spec.ts}` + +- [ ] **Step 1: Update `demo-shell.component.ts` imports** + +Find the imports array of `DemoShell`. Replace `ControlPalette` import with the chat-debug surface: + +```ts +import { + ChatDebugComponent, + ChatDebugControlsDirective, + ChatDebugSectionComponent, + ChatDebugSegmentedComponent, + ChatDebugSelectComponent, + ChatDebugActionComponent, +} from '@ngaf/chat'; +``` + +In the component's `imports: [...]` array, **remove** `ControlPalette` and **add**: + +```ts +ChatDebugComponent, +ChatDebugControlsDirective, +ChatDebugSectionComponent, +ChatDebugSegmentedComponent, +ChatDebugSelectComponent, +ChatDebugActionComponent, +``` + +Also remove the `PalettePersistence` provider/usage if any exists in `DemoShell` (search for `PalettePersistence`). The component no longer needs `debugOpen` signal/state — `chat-debug` owns its own open state. Remove `debugOpen`, `onDebugChange`, and any related plumbing. + +- [ ] **Step 2: Replace `` and `.demo-shell__debug` block in `demo-shell.component.html`** + +Open `examples/chat/angular/src/app/shell/demo-shell.component.html`. Replace the entire `` block with the chat-debug usage. Also remove the `@if (debugOpen()) {
...
}` block at the bottom. + +Final desired structure: + +```html +
+ + + + + + +
+ + @if (agent.interrupt && agent.interrupt()) { +
+ +
+ } + @if (agent.subagents && agent.subagents().size > 0) { +
+ +
+ } +
+ + + + + + + + + + + + + + + + + + + +
+``` + +`modeOptions` is the array of `{value, label}` used by the segmented control. It currently lives in the control palette's HTML as inline `Embed/Popup/Sidebar` buttons. Move it to `DemoShell`: + +```ts +protected readonly modeOptions = [ + { value: 'embed', label: 'Embed' }, + { value: 'popup', label: 'Popup' }, + { value: 'sidebar', label: 'Sidebar' }, +] as const; +``` + +- [ ] **Step 3: Update `demo-shell.component.css`** + +Remove the `.demo-shell__debug` rule block (lines that style the old bottom overlay). Leave the rest of the file unchanged. + +- [ ] **Step 4: Delete obsolete control-palette + palette-persistence files** + +```bash +git rm examples/chat/angular/src/app/shell/control-palette.component.ts \ + examples/chat/angular/src/app/shell/control-palette.component.html \ + examples/chat/angular/src/app/shell/control-palette.component.css \ + examples/chat/angular/src/app/shell/control-palette.component.spec.ts \ + examples/chat/angular/src/app/shell/palette-persistence.service.ts \ + examples/chat/angular/src/app/shell/palette-persistence.service.spec.ts +``` + +- [ ] **Step 5: Build the example app** + +Run: `npx nx build examples-chat` +Expected: build succeeds. If TS complains about unused imports or missing `modeOptions`, fix the references in `demo-shell.component.ts`. + +- [ ] **Step 6: Run example app tests** + +Run: `npx nx test examples-chat` +Expected: PASS. If a spec referenced `ControlPalette` or `PalettePersistence`, remove the reference and let the spec exercise the new wiring (or drop the spec if it only existed to test the deleted unit). + +- [ ] **Step 7: Commit** + +```bash +git add examples/chat/angular/src/app/shell/ +git commit -m "feat(examples-chat): migrate control palette into chat-debug devtools" +``` + +--- + +## Task 13: End-to-end smoke verification + +**Files:** none (verification only) + +- [ ] **Step 1: Run the example app locally** + +Run: `npx nx serve examples-chat` +Expected: dev server starts on port 4200. + +- [ ] **Step 2: Manually verify in the browser** + +Open `http://localhost:4200/`. Confirm: + +1. No legacy "Control Palette" floating widget in the top-right. +2. A small gear-icon launcher in the bottom-right corner. +3. Clicking the launcher opens a right-docked panel labelled "Chat Debug". +4. Inside, top to bottom: header with dock toggles + close, controls section (Mode / Agent / Appearance / New conversation), tab strip (Timeline / State), Timeline tab content. +5. Toggling Mode tabs (Embed / Popup / Sidebar) routes correctly. +6. Changing Model / Effort / Gen UI / Theme behaves as before. +7. "New conversation" starts a fresh thread. +8. Clicking the bottom dock icon (▭) moves the panel to a bottom dock. Refresh the page — it stays bottom. Click right dock (◨) to restore. +9. Press Escape inside the panel — it closes. Click the launcher again — it reopens, same tab, same dock. +10. Send a message, wait for streaming to complete. Switch to the **Timeline** tab — checkpoints appear. Click one — state diff appears inline. Press ↓/↑ — selection moves. Hover a selected row — Replay / Fork buttons appear. +11. Switch to **State** tab — JSON tree of the current agent state. Click Copy — clipboard receives the JSON. +12. The embed/popup/sidebar chat itself renders normally. No duplicate chat surface anywhere. + +- [ ] **Step 3: Rebuild the smoke generator and run it** + +In a separate terminal: + +```bash +npx nx run examples-chat-smoke:run +``` + +Follow the prompts to create a fresh consumer in `~/tmp/ngaf-debug-smoke`, accept the latest `@ngaf/chat` version (or the locally linked version per the smoke flow), let it `npm install`, and start it. Confirm the same in-browser checks pass against the published-package consumer path. + +- [ ] **Step 4: Done — no commit needed** + +If everything passes, the implementation is complete. + +--- + +## Out of scope (deferred) + +- Drag-to-resize handles on the panel edges. Initial size is fixed (420px right/left, 40vh bottom); persistence wrapper already supports a future `size` key. +- Search filter inside the State tab. +- Detach-to-popup-window. +- Tests that mount Angular components against `TestBed` — the repo's existing chat-debug specs use vitest + pure-function tests because the library tests don't have a JIT compiler configured. New specs follow the same convention. From ddd2595a2e7d8f49dead9a5b1782001a05b15b57 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 11 May 2026 13:23:03 -0700 Subject: [PATCH 03/18] feat(chat): chat-debug persistence wrapper Co-Authored-By: Claude Sonnet 4.6 --- .../chat-debug/persistence.spec.ts | 46 +++++++++++++++++++ .../compositions/chat-debug/persistence.ts | 30 ++++++++++++ 2 files changed, 76 insertions(+) create mode 100644 libs/chat/src/lib/compositions/chat-debug/persistence.spec.ts create mode 100644 libs/chat/src/lib/compositions/chat-debug/persistence.ts diff --git a/libs/chat/src/lib/compositions/chat-debug/persistence.spec.ts b/libs/chat/src/lib/compositions/chat-debug/persistence.spec.ts new file mode 100644 index 000000000..16e75a2bc --- /dev/null +++ b/libs/chat/src/lib/compositions/chat-debug/persistence.spec.ts @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: MIT +import { describe, it, expect, beforeEach } from 'vitest'; +import { createPersistence } from './persistence'; + +describe('createPersistence', () => { + beforeEach(() => { + localStorage.clear(); + }); + + it('reads undefined when no key set', () => { + const p = createPersistence('test'); + expect(p.read('dock')).toBeUndefined(); + }); + + it('round-trips a string value under the namespaced key', () => { + const p = createPersistence('test'); + p.write('dock', 'bottom'); + expect(p.read('dock')).toBe('bottom'); + expect(localStorage.getItem('test:dock')).toBe('"bottom"'); + }); + + it('round-trips a number value', () => { + const p = createPersistence('test'); + p.write('size', 480); + expect(p.read('size')).toBe(480); + }); + + it('round-trips a boolean value', () => { + const p = createPersistence('test'); + p.write('open', true); + expect(p.read('open')).toBe(true); + }); + + it('returns undefined when stored JSON is malformed', () => { + localStorage.setItem('test:dock', '{not-json'); + const p = createPersistence('test'); + expect(p.read('dock')).toBeUndefined(); + }); + + it('isolates by prefix', () => { + const a = createPersistence('a'); + const b = createPersistence('b'); + a.write('open', true); + expect(b.read('open')).toBeUndefined(); + }); +}); diff --git a/libs/chat/src/lib/compositions/chat-debug/persistence.ts b/libs/chat/src/lib/compositions/chat-debug/persistence.ts new file mode 100644 index 000000000..280c379a0 --- /dev/null +++ b/libs/chat/src/lib/compositions/chat-debug/persistence.ts @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: MIT +export interface Persistence { + read(key: string): T | undefined; + write(key: string, value: T): void; +} + +/** + * Tiny typed localStorage wrapper namespaced under `{prefix}:`. SSR-safe: + * when `localStorage` is undefined (server build), read returns undefined + * and write is a no-op. + */ +export function createPersistence(prefix: string): Persistence { + const fullKey = (k: string) => `${prefix}:${k}`; + return { + read(key: string): T | undefined { + if (typeof localStorage === 'undefined') return undefined; + const raw = localStorage.getItem(fullKey(key)); + if (raw === null) return undefined; + try { + return JSON.parse(raw) as T; + } catch { + return undefined; + } + }, + write(key: string, value: T): void { + if (typeof localStorage === 'undefined') return; + localStorage.setItem(fullKey(key), JSON.stringify(value)); + }, + }; +} From ebf0e0449c8863134870a61cd1f42796a55f692e Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 11 May 2026 13:29:37 -0700 Subject: [PATCH 04/18] feat(chat): chat-debug slot directives (controls + inspector) --- .../chat-debug/chat-debug-controls.directive.ts | 15 +++++++++++++++ .../chat-debug/chat-debug-inspector.directive.ts | 16 ++++++++++++++++ 2 files changed, 31 insertions(+) create mode 100644 libs/chat/src/lib/compositions/chat-debug/chat-debug-controls.directive.ts create mode 100644 libs/chat/src/lib/compositions/chat-debug/chat-debug-inspector.directive.ts diff --git a/libs/chat/src/lib/compositions/chat-debug/chat-debug-controls.directive.ts b/libs/chat/src/lib/compositions/chat-debug/chat-debug-controls.directive.ts new file mode 100644 index 000000000..a652c7977 --- /dev/null +++ b/libs/chat/src/lib/compositions/chat-debug/chat-debug-controls.directive.ts @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: MIT +import { Directive, TemplateRef, inject } from '@angular/core'; + +/** + * Marks an `` as the controls slot of ``. Rendered + * pinned at the top of the docked panel. Host apps put their app-specific + * controls (mode picker, model select, etc.) inside this template. + */ +@Directive({ + selector: 'ng-template[chatDebugControls]', + standalone: true, +}) +export class ChatDebugControlsDirective { + readonly templateRef = inject(TemplateRef); +} diff --git a/libs/chat/src/lib/compositions/chat-debug/chat-debug-inspector.directive.ts b/libs/chat/src/lib/compositions/chat-debug/chat-debug-inspector.directive.ts new file mode 100644 index 000000000..d6f7ec598 --- /dev/null +++ b/libs/chat/src/lib/compositions/chat-debug/chat-debug-inspector.directive.ts @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: MIT +import { Directive, TemplateRef, inject, input } from '@angular/core'; + +/** + * Marks an `` as a host-registered inspector tab. Each instance + * adds a tab in the docked panel's tab strip, appended after the built-in + * Timeline and State tabs. + */ +@Directive({ + selector: 'ng-template[chatDebugInspector]', + standalone: true, +}) +export class ChatDebugInspectorDirective { + readonly label = input.required({ alias: 'chatDebugInspectorLabel' }); + readonly templateRef = inject(TemplateRef); +} From 43190eed7ac0d84c33a13aa83db4edad7212ab85 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 11 May 2026 13:29:51 -0700 Subject: [PATCH 05/18] feat(chat): chat-debug-section primitive --- .../chat-debug-section.component.ts | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 libs/chat/src/lib/compositions/chat-debug/primitives/chat-debug-section.component.ts diff --git a/libs/chat/src/lib/compositions/chat-debug/primitives/chat-debug-section.component.ts b/libs/chat/src/lib/compositions/chat-debug/primitives/chat-debug-section.component.ts new file mode 100644 index 000000000..b55672f4e --- /dev/null +++ b/libs/chat/src/lib/compositions/chat-debug/primitives/chat-debug-section.component.ts @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MIT +import { Component, ChangeDetectionStrategy, input } from '@angular/core'; +import { CHAT_HOST_TOKENS } from '../../../styles/chat-tokens'; + +@Component({ + selector: 'chat-debug-section', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + styles: [ + CHAT_HOST_TOKENS, + ` + :host { + display: block; + padding: var(--ngaf-chat-space-3) var(--ngaf-chat-space-4); + border-bottom: 1px solid var(--ngaf-chat-separator); + } + :host:last-child { border-bottom: 0; } + .section__label { + font-size: var(--ngaf-chat-font-size-xs); + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--ngaf-chat-text-muted); + margin: 0 0 var(--ngaf-chat-space-2); + } + .section__body { display: flex; flex-direction: column; gap: var(--ngaf-chat-space-2); } + `, + ], + template: ` + @if (label()) { + + } +
+ `, +}) +export class ChatDebugSectionComponent { + readonly label = input(''); +} From 0eef14899a8e9bd8226dca9f086aeb5690b02227 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 11 May 2026 13:30:01 -0700 Subject: [PATCH 06/18] feat(chat): chat-debug-segmented primitive --- .../chat-debug-segmented.component.ts | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 libs/chat/src/lib/compositions/chat-debug/primitives/chat-debug-segmented.component.ts diff --git a/libs/chat/src/lib/compositions/chat-debug/primitives/chat-debug-segmented.component.ts b/libs/chat/src/lib/compositions/chat-debug/primitives/chat-debug-segmented.component.ts new file mode 100644 index 000000000..7b9777c74 --- /dev/null +++ b/libs/chat/src/lib/compositions/chat-debug/primitives/chat-debug-segmented.component.ts @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: MIT +import { Component, ChangeDetectionStrategy, input, output } from '@angular/core'; +import { CHAT_HOST_TOKENS } from '../../../styles/chat-tokens'; + +export interface SegmentedOption { + readonly value: string; + readonly label: string; +} + +@Component({ + selector: 'chat-debug-segmented', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + styles: [ + CHAT_HOST_TOKENS, + ` + :host { display: block; } + .segmented { + display: inline-flex; + gap: 0; + padding: 2px; + background: var(--ngaf-chat-surface-alt); + border: 1px solid var(--ngaf-chat-separator); + border-radius: var(--ngaf-chat-radius-button); + } + .segmented__btn { + appearance: none; + border: 0; + background: transparent; + padding: 4px 10px; + font-size: var(--ngaf-chat-font-size-sm); + color: var(--ngaf-chat-text-muted); + cursor: pointer; + border-radius: calc(var(--ngaf-chat-radius-button) - 2px); + } + .segmented__btn.is-active { + background: var(--ngaf-chat-bg); + color: var(--ngaf-chat-text); + box-shadow: var(--ngaf-chat-shadow-sm); + } + `, + ], + template: ` +
+ @for (opt of options(); track opt.value) { + + } +
+ `, +}) +export class ChatDebugSegmentedComponent { + readonly options = input.required(); + readonly value = input.required(); + readonly valueChange = output(); +} From 8b609ef1d7d9366d18320cc2c444f56c58aec373 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 11 May 2026 13:30:11 -0700 Subject: [PATCH 07/18] feat(chat): chat-debug-select primitive --- .../primitives/chat-debug-select.component.ts | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 libs/chat/src/lib/compositions/chat-debug/primitives/chat-debug-select.component.ts diff --git a/libs/chat/src/lib/compositions/chat-debug/primitives/chat-debug-select.component.ts b/libs/chat/src/lib/compositions/chat-debug/primitives/chat-debug-select.component.ts new file mode 100644 index 000000000..7244d2b2f --- /dev/null +++ b/libs/chat/src/lib/compositions/chat-debug/primitives/chat-debug-select.component.ts @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: MIT +import { Component, ChangeDetectionStrategy, input, output } from '@angular/core'; +import { CHAT_HOST_TOKENS } from '../../../styles/chat-tokens'; + +export interface SelectOption { + readonly value: string; + readonly label: string; +} + +@Component({ + selector: 'chat-debug-select', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + styles: [ + CHAT_HOST_TOKENS, + ` + :host { display: block; } + label { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--ngaf-chat-space-3); + font-size: var(--ngaf-chat-font-size-sm); + color: var(--ngaf-chat-text); + } + select { + appearance: none; + background: var(--ngaf-chat-bg); + color: var(--ngaf-chat-text); + border: 1px solid var(--ngaf-chat-separator); + border-radius: var(--ngaf-chat-radius-button); + padding: 4px 8px; + font: inherit; + font-size: var(--ngaf-chat-font-size-sm); + } + `, + ], + template: ` + + `, +}) +export class ChatDebugSelectComponent { + readonly label = input.required(); + readonly options = input.required(); + readonly value = input.required(); + readonly valueChange = output(); + + protected onChange(event: Event): void { + this.valueChange.emit((event.target as HTMLSelectElement).value); + } +} From 91ae8d6db8576bfa23601dce0c5edd366b0538fa Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 11 May 2026 13:30:19 -0700 Subject: [PATCH 08/18] feat(chat): chat-debug-toggle primitive --- .../primitives/chat-debug-toggle.component.ts | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 libs/chat/src/lib/compositions/chat-debug/primitives/chat-debug-toggle.component.ts diff --git a/libs/chat/src/lib/compositions/chat-debug/primitives/chat-debug-toggle.component.ts b/libs/chat/src/lib/compositions/chat-debug/primitives/chat-debug-toggle.component.ts new file mode 100644 index 000000000..0b9227f32 --- /dev/null +++ b/libs/chat/src/lib/compositions/chat-debug/primitives/chat-debug-toggle.component.ts @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: MIT +import { Component, ChangeDetectionStrategy, input, output } from '@angular/core'; +import { CHAT_HOST_TOKENS } from '../../../styles/chat-tokens'; + +@Component({ + selector: 'chat-debug-toggle', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + styles: [ + CHAT_HOST_TOKENS, + ` + :host { display: block; } + button { + display: inline-flex; + align-items: center; + gap: var(--ngaf-chat-space-2); + appearance: none; + background: transparent; + border: 1px solid var(--ngaf-chat-separator); + border-radius: var(--ngaf-chat-radius-button); + padding: 4px 10px; + font: inherit; + font-size: var(--ngaf-chat-font-size-sm); + color: var(--ngaf-chat-text); + cursor: pointer; + } + .dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--ngaf-chat-muted); + } + button.is-on .dot { background: var(--ngaf-chat-success); } + `, + ], + template: ` + + `, +}) +export class ChatDebugToggleComponent { + readonly label = input.required(); + readonly value = input.required(); + readonly valueChange = output(); +} From 3b517ea7197de1bc7246d71c14f6e2a58a11bbcb Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 11 May 2026 13:30:39 -0700 Subject: [PATCH 09/18] feat(chat): chat-debug-action primitive + primitives smoke spec --- .../primitives/chat-debug-action.component.ts | 36 +++++++++++++++++++ .../chat-debug/primitives/primitives.spec.ts | 15 ++++++++ 2 files changed, 51 insertions(+) create mode 100644 libs/chat/src/lib/compositions/chat-debug/primitives/chat-debug-action.component.ts create mode 100644 libs/chat/src/lib/compositions/chat-debug/primitives/primitives.spec.ts diff --git a/libs/chat/src/lib/compositions/chat-debug/primitives/chat-debug-action.component.ts b/libs/chat/src/lib/compositions/chat-debug/primitives/chat-debug-action.component.ts new file mode 100644 index 000000000..2c0587168 --- /dev/null +++ b/libs/chat/src/lib/compositions/chat-debug/primitives/chat-debug-action.component.ts @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MIT +import { Component, ChangeDetectionStrategy, input, output } from '@angular/core'; +import { CHAT_HOST_TOKENS } from '../../../styles/chat-tokens'; + +@Component({ + selector: 'chat-debug-action', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + styles: [ + CHAT_HOST_TOKENS, + ` + :host { display: block; } + button { + appearance: none; + background: var(--ngaf-chat-surface-alt); + color: var(--ngaf-chat-text); + border: 1px solid var(--ngaf-chat-separator); + border-radius: var(--ngaf-chat-radius-button); + padding: 6px 12px; + font: inherit; + font-size: var(--ngaf-chat-font-size-sm); + cursor: pointer; + width: 100%; + text-align: left; + } + button:hover { background: color-mix(in srgb, var(--ngaf-chat-text) 5%, var(--ngaf-chat-surface-alt)); } + `, + ], + template: ` + + `, +}) +export class ChatDebugActionComponent { + readonly label = input.required(); + readonly clicked = output(); +} diff --git a/libs/chat/src/lib/compositions/chat-debug/primitives/primitives.spec.ts b/libs/chat/src/lib/compositions/chat-debug/primitives/primitives.spec.ts new file mode 100644 index 000000000..d440260bb --- /dev/null +++ b/libs/chat/src/lib/compositions/chat-debug/primitives/primitives.spec.ts @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: MIT +import { describe, it, expect } from 'vitest'; +import { ChatDebugSectionComponent } from './chat-debug-section.component'; +import { ChatDebugSegmentedComponent } from './chat-debug-segmented.component'; +import { ChatDebugSelectComponent } from './chat-debug-select.component'; +import { ChatDebugToggleComponent } from './chat-debug-toggle.component'; +import { ChatDebugActionComponent } from './chat-debug-action.component'; + +describe('chat-debug primitives are defined', () => { + it('section', () => { expect(typeof ChatDebugSectionComponent).toBe('function'); }); + it('segmented', () => { expect(typeof ChatDebugSegmentedComponent).toBe('function'); }); + it('select', () => { expect(typeof ChatDebugSelectComponent).toBe('function'); }); + it('toggle', () => { expect(typeof ChatDebugToggleComponent).toBe('function'); }); + it('action', () => { expect(typeof ChatDebugActionComponent).toBe('function'); }); +}); From af77cd4603a78fbbcdfd2c86ccbcd6746508681e Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 11 May 2026 13:32:44 -0700 Subject: [PATCH 10/18] feat(chat): timeline inspector with keyboard nav + inline diff Co-Authored-By: Claude Sonnet 4.6 --- .../timeline-inspector.component.ts | 166 ++++++++++++++++++ .../inspectors/timeline-inspector.spec.ts | 34 ++++ 2 files changed, 200 insertions(+) create mode 100644 libs/chat/src/lib/compositions/chat-debug/inspectors/timeline-inspector.component.ts create mode 100644 libs/chat/src/lib/compositions/chat-debug/inspectors/timeline-inspector.spec.ts diff --git a/libs/chat/src/lib/compositions/chat-debug/inspectors/timeline-inspector.component.ts b/libs/chat/src/lib/compositions/chat-debug/inspectors/timeline-inspector.component.ts new file mode 100644 index 000000000..5783c7230 --- /dev/null +++ b/libs/chat/src/lib/compositions/chat-debug/inspectors/timeline-inspector.component.ts @@ -0,0 +1,166 @@ +// SPDX-License-Identifier: MIT +import { + Component, + ChangeDetectionStrategy, + computed, + input, + output, + signal, + HostListener, +} from '@angular/core'; +import type { AgentWithHistory } from '../../../agent'; +import { CHAT_HOST_TOKENS } from '../../../styles/chat-tokens'; +import { DebugCheckpointCardComponent, type DebugCheckpoint } from '../debug-checkpoint-card.component'; +import { DebugStateDiffComponent } from '../debug-state-diff.component'; +import { toDebugCheckpoint, extractStateValues } from '../debug-utils'; + +export type Direction = 'up' | 'down' | 'home' | 'end'; + +/** + * Pure selection-step function. Exported for unit testing — the component's + * keyboard handler delegates to it. + */ +export function stepSelection(dir: Direction, current: number, count: number): number { + if (count === 0) return -1; + switch (dir) { + case 'down': return Math.min(current + 1, count - 1); + case 'up': return Math.max(current - 1, 0); + case 'home': return 0; + case 'end': return count - 1; + } +} + +@Component({ + selector: 'chat-debug-timeline-inspector', + standalone: true, + imports: [DebugCheckpointCardComponent, DebugStateDiffComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + styles: [ + CHAT_HOST_TOKENS, + ` + :host { display: flex; flex-direction: column; height: 100%; outline: none; } + .timeline__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--ngaf-chat-space-2) var(--ngaf-chat-space-4); + border-bottom: 1px solid var(--ngaf-chat-separator); + font-size: var(--ngaf-chat-font-size-xs); + color: var(--ngaf-chat-text-muted); + } + .timeline__clear { + background: transparent; + border: 0; + cursor: pointer; + color: var(--ngaf-chat-text-muted); + font-size: var(--ngaf-chat-font-size-xs); + } + .timeline__clear:disabled { opacity: 0.5; cursor: default; } + .timeline__list { + flex: 1; + overflow-y: auto; + padding: var(--ngaf-chat-space-2) var(--ngaf-chat-space-4); + display: flex; + flex-direction: column; + gap: var(--ngaf-chat-space-2); + } + .timeline__row { display: flex; flex-direction: column; gap: var(--ngaf-chat-space-2); } + .timeline__row-actions { + display: none; + gap: var(--ngaf-chat-space-2); + padding-left: var(--ngaf-chat-space-3); + } + .timeline__row:hover .timeline__row-actions { display: flex; } + .timeline__row button.row-action { + background: transparent; + border: 1px solid var(--ngaf-chat-separator); + border-radius: var(--ngaf-chat-radius-button); + padding: 2px 8px; + font-size: var(--ngaf-chat-font-size-xs); + color: var(--ngaf-chat-text-muted); + cursor: pointer; + } + .timeline__row button.row-action:hover { color: var(--ngaf-chat-text); } + .timeline__diff { + padding: var(--ngaf-chat-space-2); + background: var(--ngaf-chat-surface-alt); + border-radius: var(--ngaf-chat-radius-card); + } + `, + ], + template: ` +
+ {{ checkpoints().length }} checkpoints + +
+
+ @for (cp of checkpoints(); let i = $index; track cp.checkpointId ?? i) { +
+ + @if (i === selectedIndex() && cp.checkpointId) { +
+ + +
+ } + @if (i === selectedIndex()) { +
+ +
+ } +
+ } +
+ `, +}) +export class TimelineInspectorComponent { + readonly agent = input.required(); + readonly replayRequested = output(); + readonly forkRequested = output(); + + readonly selectedIndex = signal(-1); + + readonly checkpoints = computed((): DebugCheckpoint[] => + this.agent().history().map((cp, i) => toDebugCheckpoint(cp, i)), + ); + + currentStateAt(i: number): Record { + return extractStateValues(this.agent().history()[i]); + } + + previousStateAt(i: number): Record { + if (i <= 0) return {}; + return extractStateValues(this.agent().history()[i - 1]); + } + + @HostListener('keydown', ['$event']) + protected onKey(ev: KeyboardEvent): void { + const map: Record = { + ArrowDown: 'down', + ArrowUp: 'up', + Home: 'home', + End: 'end', + }; + const dir = map[ev.key]; + if (!dir) return; + ev.preventDefault(); + this.selectedIndex.set(stepSelection(dir, this.selectedIndex(), this.checkpoints().length)); + } +} diff --git a/libs/chat/src/lib/compositions/chat-debug/inspectors/timeline-inspector.spec.ts b/libs/chat/src/lib/compositions/chat-debug/inspectors/timeline-inspector.spec.ts new file mode 100644 index 000000000..e3c2480c3 --- /dev/null +++ b/libs/chat/src/lib/compositions/chat-debug/inspectors/timeline-inspector.spec.ts @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: MIT +import { describe, it, expect } from 'vitest'; +import { stepSelection, type Direction } from './timeline-inspector.component'; + +describe('stepSelection', () => { + it('moves down when not at end', () => { + expect(stepSelection('down', 0, 3)).toBe(1); + }); + + it('does not move past last index', () => { + expect(stepSelection('down', 2, 3)).toBe(2); + }); + + it('moves up when not at start', () => { + expect(stepSelection('up', 2, 3)).toBe(1); + }); + + it('does not move below 0', () => { + expect(stepSelection('up', 0, 3)).toBe(0); + }); + + it('jumps to start', () => { + expect(stepSelection('home', 5, 10)).toBe(0); + }); + + it('jumps to end', () => { + expect(stepSelection('end', 0, 4)).toBe(3); + }); + + it('returns -1 when count is 0', () => { + expect(stepSelection('down', -1, 0)).toBe(-1); + expect(stepSelection('end', -1, 0)).toBe(-1); + }); +}); From a391bcfc8a63cbfa6bb19f932f1940070d1b522b Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 11 May 2026 13:33:34 -0700 Subject: [PATCH 11/18] feat(chat): state inspector tab wrapper --- .../inspectors/state-inspector.component.ts | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 libs/chat/src/lib/compositions/chat-debug/inspectors/state-inspector.component.ts diff --git a/libs/chat/src/lib/compositions/chat-debug/inspectors/state-inspector.component.ts b/libs/chat/src/lib/compositions/chat-debug/inspectors/state-inspector.component.ts new file mode 100644 index 000000000..f553a5645 --- /dev/null +++ b/libs/chat/src/lib/compositions/chat-debug/inspectors/state-inspector.component.ts @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: MIT +import { Component, ChangeDetectionStrategy, input, computed } from '@angular/core'; +import type { AgentWithHistory } from '../../../agent'; +import { CHAT_HOST_TOKENS } from '../../../styles/chat-tokens'; +import { DebugStateInspectorComponent } from '../debug-state-inspector.component'; +import { extractStateValues } from '../debug-utils'; + +@Component({ + selector: 'chat-debug-state-tab', + standalone: true, + imports: [DebugStateInspectorComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + styles: [ + CHAT_HOST_TOKENS, + ` + :host { display: flex; flex-direction: column; height: 100%; } + .state__header { + display: flex; + justify-content: flex-end; + padding: var(--ngaf-chat-space-2) var(--ngaf-chat-space-4); + border-bottom: 1px solid var(--ngaf-chat-separator); + } + .state__copy { + background: transparent; + border: 1px solid var(--ngaf-chat-separator); + border-radius: var(--ngaf-chat-radius-button); + padding: 2px 8px; + font: inherit; + font-size: var(--ngaf-chat-font-size-xs); + color: var(--ngaf-chat-text-muted); + cursor: pointer; + } + .state__body { + flex: 1; + overflow-y: auto; + padding: var(--ngaf-chat-space-2) var(--ngaf-chat-space-4); + } + `, + ], + template: ` +
+ +
+
+ +
+ `, +}) +export class StateInspectorComponent { + readonly agent = input.required(); + + readonly state = computed((): Record => { + const history = this.agent().history(); + const last = history[history.length - 1]; + return extractStateValues(last); + }); + + copy(): void { + if (typeof navigator === 'undefined' || !navigator.clipboard) return; + void navigator.clipboard.writeText(JSON.stringify(this.state(), null, 2)); + } +} From d189f7d0af431c1299a29e29cbc0731fb4557c32 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 11 May 2026 13:35:46 -0700 Subject: [PATCH 12/18] feat(chat): rewrite chat-debug as floating devtools launcher Co-Authored-By: Claude Sonnet 4.6 --- .../chat-debug/chat-debug.component.spec.ts | 147 +---- .../chat-debug/chat-debug.component.ts | 600 ++++++------------ 2 files changed, 224 insertions(+), 523 deletions(-) diff --git a/libs/chat/src/lib/compositions/chat-debug/chat-debug.component.spec.ts b/libs/chat/src/lib/compositions/chat-debug/chat-debug.component.spec.ts index 4aacb63cc..96283589c 100644 --- a/libs/chat/src/lib/compositions/chat-debug/chat-debug.component.spec.ts +++ b/libs/chat/src/lib/compositions/chat-debug/chat-debug.component.spec.ts @@ -5,10 +5,9 @@ import type { DiffEntry } from './state-diff'; import { toDebugCheckpoint, extractStateValues } from './debug-utils'; import type { AgentCheckpoint } from '../../agent'; import { DebugCheckpointCardComponent } from './debug-checkpoint-card.component'; -import { DebugControlsComponent } from './debug-controls.component'; -import { DebugSummaryComponent } from './debug-summary.component'; +import { ChatDebugComponent } from './chat-debug.component'; -// ── computeStateDiff ──────────────────────────────────────────────────────── +// ── computeStateDiff (unchanged from previous spec) ──────────────────────── describe('computeStateDiff', () => { it('detects added keys', () => { @@ -17,70 +16,36 @@ describe('computeStateDiff', () => { { path: 'name', type: 'added', after: 'Alice' }, ]); }); - it('detects removed keys', () => { const result = computeStateDiff({ name: 'Alice' }, {}); expect(result).toEqual([ { path: 'name', type: 'removed', before: 'Alice' }, ]); }); - it('detects changed keys', () => { const result = computeStateDiff({ count: 1 }, { count: 2 }); expect(result).toEqual([ { path: 'count', type: 'changed', before: 1, after: 2 }, ]); }); - it('returns empty array when states are identical', () => { - const result = computeStateDiff( - { a: 1, b: 'x' }, - { a: 1, b: 'x' }, - ); - expect(result).toEqual([]); + expect(computeStateDiff({ a: 1 }, { a: 1 })).toEqual([]); }); - it('recurses into nested objects', () => { const result = computeStateDiff( - { config: { theme: 'light', debug: false } }, - { config: { theme: 'dark', debug: false } }, + { config: { theme: 'light' } }, + { config: { theme: 'dark' } }, ); expect(result).toEqual([ { path: 'config.theme', type: 'changed', before: 'light', after: 'dark' }, ]); }); - - it('handles nested additions and removals', () => { - const result = computeStateDiff( - { config: { a: 1 } }, - { config: { b: 2 } }, - ); - expect(result).toHaveLength(2); - expect(result).toContainEqual({ path: 'config.a', type: 'removed', before: 1 }); - expect(result).toContainEqual({ path: 'config.b', type: 'added', after: 2 }); - }); - it('treats array changes as a single changed entry', () => { - const result = computeStateDiff( - { items: [1, 2] }, - { items: [1, 2, 3] }, - ); + const result = computeStateDiff({ items: [1] }, { items: [1, 2] }); expect(result).toEqual([ - { path: 'items', type: 'changed', before: [1, 2], after: [1, 2, 3] }, + { path: 'items', type: 'changed', before: [1], after: [1, 2] }, ]); }); - - it('handles mixed additions, removals, and changes', () => { - const result = computeStateDiff( - { a: 1, b: 2, c: 3 }, - { a: 1, c: 99, d: 4 }, - ); - expect(result).toContainEqual({ path: 'b', type: 'removed', before: 2 }); - expect(result).toContainEqual({ path: 'c', type: 'changed', before: 3, after: 99 }); - expect(result).toContainEqual({ path: 'd', type: 'added', after: 4 }); - // 'a' is unchanged, no entry for it - expect(result.find(e => e.path === 'a')).toBeUndefined(); - }); }); // ── toDebugCheckpoint ────────────────────────────────────────────────────── @@ -92,17 +57,9 @@ describe('toDebugCheckpoint', () => { expect(result.node).toBe('agent'); expect(result.checkpointId).toBe('cp1'); }); - it('falls back to Step N when label is absent', () => { const cp: AgentCheckpoint = { values: {} }; - const result = toDebugCheckpoint(cp, 2); - expect(result.node).toBe('Step 3'); - }); - - it('returns undefined checkpointId when id is not present', () => { - const cp: AgentCheckpoint = { label: 'tool', values: {} }; - const result = toDebugCheckpoint(cp, 0); - expect(result.checkpointId).toBeUndefined(); + expect(toDebugCheckpoint(cp, 2).node).toBe('Step 3'); }); }); @@ -112,100 +69,22 @@ describe('extractStateValues', () => { it('returns empty object for undefined checkpoint', () => { expect(extractStateValues(undefined)).toEqual({}); }); - it('extracts values from a AgentCheckpoint', () => { const cp: AgentCheckpoint = { values: { messages: [], count: 5 } }; expect(extractStateValues(cp)).toEqual({ messages: [], count: 5 }); }); - - it('returns empty object for a checkpoint with empty values', () => { - const cp: AgentCheckpoint = { values: {} }; - expect(extractStateValues(cp)).toEqual({}); - }); }); -// ── DebugCheckpointCardComponent ─────────────────────────────────────────── +// ── Defined-as-class smoke tests ────────────────────────────────────────── -describe('DebugCheckpointCardComponent', () => { +describe('ChatDebugComponent', () => { it('is defined as a class', () => { - expect(typeof DebugCheckpointCardComponent).toBe('function'); + expect(typeof ChatDebugComponent).toBe('function'); }); }); -// ── DebugControlsComponent ───────────────────────────────────────────────── - -describe('DebugControlsComponent', () => { - it('is defined as a class', () => { - expect(typeof DebugControlsComponent).toBe('function'); - }); -}); - -// ── DebugSummaryComponent ────────────────────────────────────────────────── - -describe('DebugSummaryComponent', () => { +describe('DebugCheckpointCardComponent', () => { it('is defined as a class', () => { - expect(typeof DebugSummaryComponent).toBe('function'); - }); -}); - -// ── ChatDebug navigation logic (tested via pure functions) ───────────────── - -describe('ChatDebug navigation logic', () => { - // Test the step/jump logic as pure functions since the component - // can't be imported without Angular JIT compiler - - function createNavigation(initialIdx: number, count: number) { - let idx = initialIdx; - return { - get idx() { return idx; }, - stepForward() { - if (idx < count - 1) idx = idx + 1; - }, - stepBack() { - if (idx > 0) idx = idx - 1; - }, - jumpToStart() { - idx = 0; - }, - jumpToEnd() { - idx = count - 1; - }, - }; - } - - it('stepForward increments index when not at end', () => { - const nav = createNavigation(0, 3); - nav.stepForward(); - expect(nav.idx).toBe(1); - }); - - it('stepForward does not exceed checkpoint length', () => { - const nav = createNavigation(2, 3); - nav.stepForward(); - expect(nav.idx).toBe(2); - }); - - it('stepBack decrements index when above 0', () => { - const nav = createNavigation(2, 3); - nav.stepBack(); - expect(nav.idx).toBe(1); - }); - - it('stepBack does not go below 0', () => { - const nav = createNavigation(0, 3); - nav.stepBack(); - expect(nav.idx).toBe(0); - }); - - it('jumpToStart sets index to 0', () => { - const nav = createNavigation(5, 10); - nav.jumpToStart(); - expect(nav.idx).toBe(0); - }); - - it('jumpToEnd sets index to last checkpoint', () => { - const nav = createNavigation(0, 4); - nav.jumpToEnd(); - expect(nav.idx).toBe(3); + expect(typeof DebugCheckpointCardComponent).toBe('function'); }); }); 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 ee8771e89..30b37d496 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 @@ -1,475 +1,297 @@ // SPDX-License-Identifier: MIT import { Component, + ChangeDetectionStrategy, computed, + contentChild, + contentChildren, effect, + HostListener, input, - inject, output, signal, - viewChild, - ElementRef, - ChangeDetectionStrategy, } from '@angular/core'; -import { DomSanitizer } from '@angular/platform-browser'; +import { NgTemplateOutlet } from '@angular/common'; import type { AgentWithHistory } from '../../agent'; -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'; -import { messageContent } from '../shared/message-utils'; import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens'; -import { renderMarkdown } from '../../streaming/markdown-render'; -import { DebugTimelineComponent } from './debug-timeline.component'; -import { DebugDetailComponent } from './debug-detail.component'; -import { DebugControlsComponent } from './debug-controls.component'; -import { DebugSummaryComponent } from './debug-summary.component'; -import type { DebugCheckpoint } from './debug-checkpoint-card.component'; -import { toDebugCheckpoint, extractStateValues } from './debug-utils'; -import { ChatTimelineSliderComponent } from '../chat-timeline-slider/chat-timeline-slider.component'; +import { ChatDebugControlsDirective } from './chat-debug-controls.directive'; +import { ChatDebugInspectorDirective } from './chat-debug-inspector.directive'; +import { TimelineInspectorComponent } from './inspectors/timeline-inspector.component'; +import { StateInspectorComponent } from './inspectors/state-inspector.component'; +import { createPersistence } from './persistence'; + +export type DockPosition = 'right' | 'bottom' | 'left'; + +interface TabEntry { + readonly id: string; + readonly label: string; + readonly kind: 'builtin-timeline' | 'builtin-state' | 'host'; + readonly hostIndex?: number; +} @Component({ selector: 'chat-debug', standalone: true, imports: [ - ChatMessageListComponent, - MessageTemplateDirective, - ChatInputComponent, - ChatTypingIndicatorComponent, - ChatErrorComponent, - DebugTimelineComponent, - DebugDetailComponent, - DebugControlsComponent, - DebugSummaryComponent, - ChatTimelineSliderComponent, + NgTemplateOutlet, + TimelineInspectorComponent, + StateInspectorComponent, ], changeDetection: ChangeDetectionStrategy.OnPush, styles: [ CHAT_HOST_TOKENS, ` - :host { - display: flex; - flex-direction: column; - height: 100%; - overflow: hidden; - background: var(--ngaf-chat-bg); - } - - /* Layout */ - .chat-debug__layout { display: flex; height: 100%; } - - /* Chat column */ - .chat-debug__chat { display: flex; flex-direction: column; flex: 1; min-width: 0; } - .chat-debug__messages { - flex: 1; - overflow-y: auto; - padding: var(--ngaf-chat-space-6) var(--ngaf-chat-space-5); - } - .chat-debug__messages-inner { - max-width: var(--ngaf-chat-max-width); - margin: 0 auto; - display: flex; - flex-direction: column; - gap: var(--ngaf-chat-space-5); - } - - /* Message templates */ - .chat-debug__msg-human { display: flex; justify-content: flex-end; } - .chat-debug__msg-human__bubble { - max-width: 75%; - padding: 10px 16px; - font-size: var(--ngaf-chat-font-size); - line-height: var(--ngaf-chat-line-height); - word-break: break-words; - border-radius: var(--ngaf-chat-radius-bubble) var(--ngaf-chat-radius-bubble) 6px var(--ngaf-chat-radius-bubble); - background: var(--ngaf-chat-surface-alt); - color: var(--ngaf-chat-text); - border: 1px solid var(--ngaf-chat-separator); - } - - .chat-debug__msg-ai { display: flex; gap: 12px; } - .chat-debug__msg-ai__avatar { - width: 28px; - height: 28px; + :host { display: contents; } + + /* Floating launcher */ + .launcher { + position: fixed; + bottom: 16px; + right: 16px; + width: 40px; + height: 40px; + border-radius: var(--ngaf-chat-radius-launcher); + background: var(--ngaf-chat-primary); + color: var(--ngaf-chat-on-primary); + border: 0; + cursor: pointer; + z-index: 990; + box-shadow: var(--ngaf-chat-shadow-md); + font-size: 18px; display: flex; align-items: center; justify-content: center; - font-size: var(--ngaf-chat-font-size-xs); - font-weight: 600; - flex-shrink: 0; - margin-top: 2px; - border-radius: 8px; - background: var(--ngaf-chat-surface-alt); - color: var(--ngaf-chat-text-muted); - } - .chat-debug__msg-ai__content { - flex: 1; - min-width: 0; - word-break: break-words; - font-size: var(--ngaf-chat-font-size); - line-height: var(--ngaf-chat-line-height); - color: var(--ngaf-chat-text); } - .chat-debug__msg-tool { - padding: 10px 14px; - font-family: var(--ngaf-chat-font-mono); - font-size: 13px; - word-break: break-words; - white-space: pre-wrap; - border-radius: var(--ngaf-chat-radius-card); - border: 1px solid var(--ngaf-chat-separator); - background: var(--ngaf-chat-surface-alt); + /* Docked panel */ + .panel { + position: fixed; + background: var(--ngaf-chat-bg); color: var(--ngaf-chat-text); - } - - .chat-debug__msg-system { display: flex; justify-content: center; } - .chat-debug__msg-system__text { - font-size: var(--ngaf-chat-font-size-xs); - font-style: italic; - color: var(--ngaf-chat-text-muted); - } - - /* Input bar */ - .chat-debug__input-bar { - border-top: 1px solid var(--ngaf-chat-separator); - padding: var(--ngaf-chat-space-4) var(--ngaf-chat-space-5); - } - .chat-debug__input-inner { - max-width: var(--ngaf-chat-max-width); - margin: 0 auto; - } - - /* Debug panel toggle */ - .chat-debug__toggle-btn { - width: 32px; - display: flex; - align-items: center; - justify-content: center; - border: none; - border-left: 1px solid var(--ngaf-chat-separator); - cursor: pointer; - transition: background 150ms ease; - background: var(--ngaf-chat-surface-alt); - color: var(--ngaf-chat-text-muted); - } - .chat-debug__toggle-btn:hover { - background: color-mix(in srgb, var(--ngaf-chat-text) 5%, transparent); - } - - /* Debug panel */ - .chat-debug__panel { - width: 320px; - border-left: 1px solid var(--ngaf-chat-separator); + border: 1px solid var(--ngaf-chat-separator); + z-index: 991; display: flex; flex-direction: column; - overflow: hidden; - flex-shrink: 0; - background: var(--ngaf-chat-bg); + box-shadow: var(--ngaf-chat-shadow-lg); } - .chat-debug__panel-header { + .panel--right { top: 0; right: 0; bottom: 0; width: var(--panel-size, 420px); border-right: 0; } + .panel--left { top: 0; left: 0; bottom: 0; width: var(--panel-size, 420px); border-left: 0; } + .panel--bottom { left: 0; right: 0; bottom: 0; height: var(--panel-size, 40vh); border-bottom: 0; } + + .panel__header { display: flex; align-items: center; justify-content: space-between; - padding: 8px 12px; + padding: var(--ngaf-chat-space-2) var(--ngaf-chat-space-4); border-bottom: 1px solid var(--ngaf-chat-separator); } - .chat-debug__panel-title { - font-size: 11px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.05em; + .panel__title { margin: 0; - color: var(--ngaf-chat-text-muted); + font-size: var(--ngaf-chat-font-size-sm); + font-weight: 600; } - .chat-debug__panel-close { - font-size: var(--ngaf-chat-font-size-xs); + .panel__actions { display: flex; align-items: center; gap: var(--ngaf-chat-space-1); } + .panel__actions button { background: transparent; - border: 0; - cursor: pointer; + border: 1px solid transparent; + border-radius: var(--ngaf-chat-radius-button); + padding: 2px 6px; color: var(--ngaf-chat-text-muted); - transition: color 150ms ease; + font: inherit; + font-size: var(--ngaf-chat-font-size-sm); + cursor: pointer; } - .chat-debug__panel-close:hover { color: var(--ngaf-chat-text); } + .panel__actions button:hover { color: var(--ngaf-chat-text); border-color: var(--ngaf-chat-separator); } + .panel__actions button.is-active { color: var(--ngaf-chat-text); } - .chat-debug__panel-section { - padding: 8px 12px; + .panel__controls { border-bottom: 1px solid var(--ngaf-chat-separator); - } - .chat-debug__panel-timeline { - flex: 1; - overflow-y: auto; - padding: 8px 12px; - } - .chat-debug__panel-detail { - border-top: 1px solid var(--ngaf-chat-separator); - padding: 8px 12px; - max-height: 256px; overflow-y: auto; + max-height: 40%; } + .panel__controls:empty { display: none; } - .chat-debug__section { padding: 8px 12px; border-top: 1px solid var(--ngaf-chat-separator); } - .chat-debug__section-title { - font-size: 11px; - text-transform: uppercase; - letter-spacing: 0.05em; - color: var(--ngaf-chat-text-muted); - margin: 0 0 6px; - } - - /* Markdown rendering */ - :host ::ng-deep .chat-md p { margin: 0 0 0.75em; } - :host ::ng-deep .chat-md p:last-child { margin-bottom: 0; } - :host ::ng-deep .chat-md code { - background: var(--ngaf-chat-surface-alt); - padding: 2px 6px; - border-radius: 4px; - font-size: 0.875em; - font-family: var(--ngaf-chat-font-mono); - } - :host ::ng-deep .chat-md pre { - background: var(--ngaf-chat-surface-alt); - padding: 12px 16px; - border-radius: var(--ngaf-chat-radius-card); - overflow-x: auto; - margin: 0.75em 0; + .panel__tabs { + display: flex; + gap: 0; + border-bottom: 1px solid var(--ngaf-chat-separator); + padding: 0 var(--ngaf-chat-space-2); } - :host ::ng-deep .chat-md pre code { background: none; padding: 0; } - :host ::ng-deep .chat-md ul, :host ::ng-deep .chat-md ol { margin: 0.5em 0; padding-left: 1.5em; } - :host ::ng-deep .chat-md li { margin: 0.25em 0; } - :host ::ng-deep .chat-md a { color: var(--ngaf-chat-text); text-decoration: underline; } - :host ::ng-deep .chat-md strong { font-weight: 600; } - :host ::ng-deep .chat-md blockquote { - border-left: 3px solid var(--ngaf-chat-separator); - padding-left: 12px; - margin: 0.75em 0; + .panel__tab { + appearance: none; + background: transparent; + border: 0; + border-bottom: 2px solid transparent; + padding: var(--ngaf-chat-space-2) var(--ngaf-chat-space-3); + font: inherit; + font-size: var(--ngaf-chat-font-size-sm); color: var(--ngaf-chat-text-muted); + cursor: pointer; } - :host ::ng-deep .chat-md h1, :host ::ng-deep .chat-md h2, :host ::ng-deep .chat-md h3, :host ::ng-deep .chat-md h4 { margin: 1em 0 0.5em; font-weight: 600; } - :host ::ng-deep .chat-md h1 { font-size: 1.25em; } - :host ::ng-deep .chat-md h2 { font-size: 1.125em; } - :host ::ng-deep .chat-md h3 { font-size: 1em; } - :host ::ng-deep .chat-md table { border-collapse: collapse; width: 100%; margin: 0.75em 0; } - :host ::ng-deep .chat-md th, :host ::ng-deep .chat-md td { border: 1px solid var(--ngaf-chat-separator); padding: 6px 12px; text-align: left; } - :host ::ng-deep .chat-md th { background: var(--ngaf-chat-surface-alt); font-weight: 600; font-size: 0.875em; } + .panel__tab.is-active { + color: var(--ngaf-chat-text); + border-bottom-color: var(--ngaf-chat-primary); + } + + .panel__body { flex: 1; min-height: 0; overflow: hidden; display: flex; flex-direction: column; } `, ], template: ` -
- -
-
-
- - - -
-
{{ messageContent(message) }}
-
-
- - - -
-
A
-
-
-
- - - -
{{ messageContent(message) }}
-
- - - -
- {{ messageContent(message) }} -
-
-
- - + @if (!open()) { + + } @else { +
+
+

Chat Debug

+
+ + + +
- - - -
-
- -
-
-
- - - @if (!debugOpen()) { - - } - - - @if (debugOpen()) { -
- -
-

Debug

- -
- - -
- + @if (controls()) { +
+
- - -
- + } + + @if (tabs().length > 1) { +
+ @for (tab of tabs(); track tab.id) { + + }
- - -
- -
- - - @if (selectedCheckpointIndex() >= 0) { -
- + @switch (activeTab()?.kind) { + @case ('builtin-timeline') { + -
+ } + @case ('builtin-state') { + + } + @case ('host') { + @if (activeHostInspector(); as host) { + + } + } } - - -
-

Legacy timeline slider

- -
- } -
+
+ } `, }) export class ChatDebugComponent { - private readonly sanitizer = inject(DomSanitizer); - readonly agent = input.required(); + readonly dock = input('right'); + readonly defaultOpen = input(false); + readonly storageKey = input('chat-debug'); readonly replayRequested = output(); readonly forkRequested = output(); + readonly openChange = output(); + readonly dockChange = output(); + + protected readonly controls = contentChild(ChatDebugControlsDirective); + protected readonly hostInspectors = contentChildren(ChatDebugInspectorDirective); + + protected readonly open = signal(false); + protected readonly dockState = signal('right'); + protected readonly activeTabId = signal('timeline'); + + protected readonly tabs = computed((): TabEntry[] => { + const host = this.hostInspectors().map((d, i): TabEntry => ({ + id: `host-${i}`, + label: d.label(), + kind: 'host', + hostIndex: i, + })); + return [ + { id: 'timeline', label: 'Timeline', kind: 'builtin-timeline' }, + { id: 'state', label: 'State', kind: 'builtin-state' }, + ...host, + ]; + }); - readonly debugOpen = signal(true); - readonly selectedCheckpointIndex = signal(-1); - - readonly checkpoints = computed((): DebugCheckpoint[] => - this.agent().history().map((cp, i) => toDebugCheckpoint(cp, i)), + protected readonly activeTab = computed(() => + this.tabs().find((t) => t.id === this.activeTabId()), ); - readonly selectedState = computed((): Record => { - const idx = this.selectedCheckpointIndex(); - const history = this.agent().history(); - return extractStateValues(history[idx]); - }); - - readonly previousState = computed((): Record => { - const idx = this.selectedCheckpointIndex(); - const history = this.agent().history(); - if (idx <= 0) return {}; - return extractStateValues(history[idx - 1]); + protected readonly activeHostInspector = computed(() => { + const t = this.activeTab(); + if (!t || t.kind !== 'host' || t.hostIndex === undefined) return undefined; + return this.hostInspectors()[t.hostIndex]; }); - // Message templates are intentionally co-located (shadcn copy-paste model) - readonly messageContent = messageContent; - - private readonly scrollContainer = viewChild>('scrollContainer'); - - /** Track message count to trigger auto-scroll */ - private readonly messageCount = computed(() => this.agent().messages().length); - - private prevMessageCount = 0; + private restored = false; constructor() { + // First read of inputs/storage on view init seeds the writable signals, + // then write-through on every change. Untracked reads of the writable + // signals avoid feedback loops. effect(() => { - const count = this.messageCount(); - this.agent().isLoading(); // track - 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' }); - }); + const p = createPersistence(this.storageKey()); + if (!this.restored) { + const persistedOpen = p.read('open'); + this.open.set(persistedOpen ?? this.defaultOpen()); + const persistedDock = p.read('dock'); + this.dockState.set(persistedDock ?? this.dock()); + const persistedTab = p.read('tab'); + if (persistedTab) this.activeTabId.set(persistedTab); + this.restored = true; + return; } + p.write('open', this.open()); + p.write('dock', this.dockState()); + p.write('tab', this.activeTabId()); }); } - renderMd(content: string) { - return renderMarkdown(content, this.sanitizer); - } - - stepForward(): void { - const idx = this.selectedCheckpointIndex(); - if (idx < this.checkpoints().length - 1) { - this.selectedCheckpointIndex.set(idx + 1); - } + setOpen(value: boolean): void { + this.open.set(value); + this.openChange.emit(value); } - stepBack(): void { - const idx = this.selectedCheckpointIndex(); - if (idx > 0) { - this.selectedCheckpointIndex.set(idx - 1); - } + setDock(next: DockPosition): void { + this.dockState.set(next); + this.dockChange.emit(next); } - jumpToStart(): void { - this.selectedCheckpointIndex.set(0); + setActiveTab(id: string): void { + this.activeTabId.set(id); } - jumpToEnd(): void { - this.selectedCheckpointIndex.set(this.checkpoints().length - 1); + @HostListener('document:keydown.escape', ['$event']) + protected onEsc(_ev: KeyboardEvent): void { + if (this.open()) { + this.setOpen(false); + } } } From dba4898abe0aacc0f0e2a6f1211baee1281b2027 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 11 May 2026 13:38:03 -0700 Subject: [PATCH 13/18] feat(chat): delete obsolete debug components + wire public-api exports Co-Authored-By: Claude Sonnet 4.6 --- .../chat-debug/chat-debug.component.ts | 2 +- .../chat-debug/debug-controls.component.ts | 77 ------------------ .../chat-debug/debug-detail.component.ts | 50 ------------ .../chat-debug/debug-summary.component.ts | 40 --------- .../chat-debug/debug-timeline.component.ts | 81 ------------------- libs/chat/src/public-api.ts | 11 +++ 6 files changed, 12 insertions(+), 249 deletions(-) delete mode 100644 libs/chat/src/lib/compositions/chat-debug/debug-controls.component.ts delete mode 100644 libs/chat/src/lib/compositions/chat-debug/debug-detail.component.ts delete mode 100644 libs/chat/src/lib/compositions/chat-debug/debug-summary.component.ts delete mode 100644 libs/chat/src/lib/compositions/chat-debug/debug-timeline.component.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 30b37d496..7a1915f7b 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 @@ -289,7 +289,7 @@ export class ChatDebugComponent { } @HostListener('document:keydown.escape', ['$event']) - protected onEsc(_ev: KeyboardEvent): void { + protected onEsc(_ev: Event): void { if (this.open()) { this.setOpen(false); } diff --git a/libs/chat/src/lib/compositions/chat-debug/debug-controls.component.ts b/libs/chat/src/lib/compositions/chat-debug/debug-controls.component.ts deleted file mode 100644 index 7f6ff01f0..000000000 --- a/libs/chat/src/lib/compositions/chat-debug/debug-controls.component.ts +++ /dev/null @@ -1,77 +0,0 @@ -// SPDX-License-Identifier: MIT -import { - Component, - input, - output, - ChangeDetectionStrategy, -} from '@angular/core'; -import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens'; - -@Component({ - selector: 'chat-debug-controls', - standalone: true, - changeDetection: ChangeDetectionStrategy.OnPush, - styles: [ - CHAT_HOST_TOKENS, - ` - .debug-controls { - display: flex; - align-items: center; - gap: 4px; - } - .debug-controls__btn { - padding: 4px 8px; - font-size: var(--ngaf-chat-font-size-xs); - border-radius: 4px; - border: 1px solid var(--ngaf-chat-separator); - background: var(--ngaf-chat-surface-alt); - color: var(--ngaf-chat-text); - cursor: pointer; - transition: background 150ms ease; - } - .debug-controls__btn:hover:not(:disabled) { - background: color-mix(in srgb, var(--ngaf-chat-text) 5%, transparent); - } - .debug-controls__btn:disabled { - opacity: 0.4; - cursor: not-allowed; - } - `, - ], - template: ` -
- - - - -
- `, -}) -export class DebugControlsComponent { - readonly checkpointCount = input(0); - readonly selectedIndex = input(-1); - readonly stepForward = output(); - readonly stepBack = output(); - readonly jumpToStart = output(); - readonly jumpToEnd = output(); -} diff --git a/libs/chat/src/lib/compositions/chat-debug/debug-detail.component.ts b/libs/chat/src/lib/compositions/chat-debug/debug-detail.component.ts deleted file mode 100644 index 4277db746..000000000 --- a/libs/chat/src/lib/compositions/chat-debug/debug-detail.component.ts +++ /dev/null @@ -1,50 +0,0 @@ -// SPDX-License-Identifier: MIT -import { - Component, - input, - ChangeDetectionStrategy, -} from '@angular/core'; -import { DebugStateDiffComponent } from './debug-state-diff.component'; -import { DebugStateInspectorComponent } from './debug-state-inspector.component'; -import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens'; - -@Component({ - selector: 'chat-debug-detail', - standalone: true, - imports: [DebugStateDiffComponent, DebugStateInspectorComponent], - changeDetection: ChangeDetectionStrategy.OnPush, - styles: [ - CHAT_HOST_TOKENS, - ` - .debug-detail { - display: flex; - flex-direction: column; - gap: 12px; - } - .debug-detail__section-title { - font-size: var(--ngaf-chat-font-size-xs); - font-weight: 600; - color: var(--ngaf-chat-text-muted); - text-transform: uppercase; - letter-spacing: 0.05em; - margin: 0 0 4px; - } - `, - ], - template: ` -
-
-

State Diff

- -
-
-

Current State

- -
-
- `, -}) -export class DebugDetailComponent { - readonly currentState = input>({}); - readonly previousState = input>({}); -} diff --git a/libs/chat/src/lib/compositions/chat-debug/debug-summary.component.ts b/libs/chat/src/lib/compositions/chat-debug/debug-summary.component.ts deleted file mode 100644 index 6978e5b43..000000000 --- a/libs/chat/src/lib/compositions/chat-debug/debug-summary.component.ts +++ /dev/null @@ -1,40 +0,0 @@ -// SPDX-License-Identifier: MIT -import { - Component, - computed, - input, - ChangeDetectionStrategy, -} from '@angular/core'; -import type { DebugCheckpoint } from './debug-checkpoint-card.component'; -import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens'; - -@Component({ - selector: 'chat-debug-summary', - standalone: true, - changeDetection: ChangeDetectionStrategy.OnPush, - styles: [ - CHAT_HOST_TOKENS, - ` - .debug-summary { - display: flex; - align-items: center; - gap: 12px; - font-size: var(--ngaf-chat-font-size-xs); - color: var(--ngaf-chat-text-muted); - } - `, - ], - template: ` -
- {{ checkpoints().length }} step(s) - {{ totalDuration() }}ms total -
- `, -}) -export class DebugSummaryComponent { - readonly checkpoints = input([]); - - readonly totalDuration = computed(() => - this.checkpoints().reduce((sum, cp) => sum + (cp.duration ?? 0), 0), - ); -} diff --git a/libs/chat/src/lib/compositions/chat-debug/debug-timeline.component.ts b/libs/chat/src/lib/compositions/chat-debug/debug-timeline.component.ts deleted file mode 100644 index c69681254..000000000 --- a/libs/chat/src/lib/compositions/chat-debug/debug-timeline.component.ts +++ /dev/null @@ -1,81 +0,0 @@ -// SPDX-License-Identifier: MIT -import { - Component, - input, - output, - ChangeDetectionStrategy, -} from '@angular/core'; -import { DebugCheckpointCardComponent } from './debug-checkpoint-card.component'; -import type { DebugCheckpoint } from './debug-checkpoint-card.component'; -import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens'; - -@Component({ - selector: 'chat-debug-timeline', - standalone: true, - imports: [DebugCheckpointCardComponent], - changeDetection: ChangeDetectionStrategy.OnPush, - styles: [ - CHAT_HOST_TOKENS, - ` - .debug-timeline { - position: relative; - display: flex; - flex-direction: column; - gap: 4px; - } - .debug-timeline__rail { - position: absolute; - left: 16px; - top: 0; - bottom: 0; - width: 2px; - background: var(--ngaf-chat-separator); - } - .debug-timeline__item { - position: relative; - padding-left: 32px; - } - .debug-timeline__dot { - position: absolute; - left: 12px; - top: 12px; - width: 10px; - height: 10px; - border-radius: 50%; - border: 2px solid var(--ngaf-chat-separator); - background: var(--ngaf-chat-bg); - } - .debug-timeline__dot--selected { - background: var(--ngaf-chat-primary); - border-color: var(--ngaf-chat-text-muted); - } - `, - ], - template: ` -
- -
- - @for (cp of checkpoints(); track $index; let i = $index) { -
- -
- - -
- } -
- `, -}) -export class DebugTimelineComponent { - readonly checkpoints = input([]); - readonly selectedIndex = input(-1); - readonly checkpointSelected = output(); -} diff --git a/libs/chat/src/public-api.ts b/libs/chat/src/public-api.ts index e1dafc065..1809e750c 100644 --- a/libs/chat/src/public-api.ts +++ b/libs/chat/src/public-api.ts @@ -72,7 +72,18 @@ export { ChatComponent } from './lib/compositions/chat/chat.component'; export type { ChatRenderEvent } from './lib/compositions/chat/chat-render-event'; export { ChatPopupComponent } from './lib/compositions/chat-popup/chat-popup.component'; export { ChatSidebarComponent } from './lib/compositions/chat-sidebar/chat-sidebar.component'; +// chat-debug devtools export { ChatDebugComponent } from './lib/compositions/chat-debug/chat-debug.component'; +export type { DockPosition } from './lib/compositions/chat-debug/chat-debug.component'; +export { ChatDebugControlsDirective } from './lib/compositions/chat-debug/chat-debug-controls.directive'; +export { ChatDebugInspectorDirective } from './lib/compositions/chat-debug/chat-debug-inspector.directive'; +export { ChatDebugSectionComponent } from './lib/compositions/chat-debug/primitives/chat-debug-section.component'; +export { ChatDebugSegmentedComponent } from './lib/compositions/chat-debug/primitives/chat-debug-segmented.component'; +export type { SegmentedOption } from './lib/compositions/chat-debug/primitives/chat-debug-segmented.component'; +export { ChatDebugSelectComponent } from './lib/compositions/chat-debug/primitives/chat-debug-select.component'; +export type { SelectOption } from './lib/compositions/chat-debug/primitives/chat-debug-select.component'; +export { ChatDebugToggleComponent } from './lib/compositions/chat-debug/primitives/chat-debug-toggle.component'; +export { ChatDebugActionComponent } from './lib/compositions/chat-debug/primitives/chat-debug-action.component'; export { ChatTimelineSliderComponent } from './lib/compositions/chat-timeline-slider/chat-timeline-slider.component'; export { ChatThreadDrawerComponent } from './lib/compositions/chat-thread-drawer/chat-thread-drawer.component'; export type { ChatThreadDrawerMode } from './lib/compositions/chat-thread-drawer/chat-thread-drawer.component'; From e1ef79e2e0151c381c99cdcd5e6f16c88cf79506 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 11 May 2026 13:41:05 -0700 Subject: [PATCH 14/18] feat(examples-chat): migrate control palette into chat-debug devtools Removes the standalone ControlPalette component and projects all mode/model/effort/genUi/theme controls into ChatDebugComponent via the new chatDebugControls slot API. Deletes the .demo-shell__debug overlay CSS rule; debug panel open/close is now owned by ChatDebugComponent. Co-Authored-By: Claude Sonnet 4.6 --- .../app/shell/control-palette.component.css | 114 ------------------ .../app/shell/control-palette.component.html | 96 --------------- .../app/shell/control-palette.component.ts | 86 ------------- .../src/app/shell/demo-shell.component.css | 12 -- .../src/app/shell/demo-shell.component.html | 73 +++++++---- .../src/app/shell/demo-shell.component.ts | 27 +++-- 6 files changed, 65 insertions(+), 343 deletions(-) delete mode 100644 examples/chat/angular/src/app/shell/control-palette.component.css delete mode 100644 examples/chat/angular/src/app/shell/control-palette.component.html delete mode 100644 examples/chat/angular/src/app/shell/control-palette.component.ts diff --git a/examples/chat/angular/src/app/shell/control-palette.component.css b/examples/chat/angular/src/app/shell/control-palette.component.css deleted file mode 100644 index 472c55dd9..000000000 --- a/examples/chat/angular/src/app/shell/control-palette.component.css +++ /dev/null @@ -1,114 +0,0 @@ -:host { - position: fixed; - top: 12px; - right: 12px; - z-index: 1000; -} - -.palette { - display: flex; - flex-direction: column; - gap: 8px; - background: #1a1d23; - color: #e6e9ef; - border: 1px solid #303540; - border-radius: 10px; - padding: 10px; - font-size: 12px; - box-shadow: 0 6px 24px rgba(0, 0, 0, 0.3); - min-width: 220px; -} - -.palette--collapsed { - width: 36px; - height: 36px; - border-radius: 50%; - background: #1a1d23; - color: #e6e9ef; - border: 1px solid #303540; - cursor: pointer; - font-size: 16px; -} - -.palette__group { - display: flex; - align-items: center; - gap: 6px; -} - -.palette__group--mode { - background: #0f1116; - border-radius: 6px; - padding: 3px; -} -.palette__group--mode button { - flex: 1; - background: transparent; - border: 0; - color: inherit; - padding: 5px 8px; - border-radius: 4px; - font-size: 12px; - cursor: pointer; -} -.palette__group--mode button.is-active { - background: #2c313c; -} - -.palette__group--model { - display: grid; - grid-template-columns: auto 1fr; - align-items: center; -} -.palette__label { - opacity: 0.7; - margin-right: 8px; -} -.palette__group--model select { - background: #0f1116; - color: inherit; - border: 1px solid #303540; - border-radius: 4px; - padding: 4px 6px; -} - -.palette__toggle { - display: flex; - align-items: center; - gap: 8px; - background: transparent; - border: 1px solid #303540; - color: inherit; - padding: 6px 8px; - border-radius: 6px; - cursor: pointer; - text-align: left; -} -.palette__toggle.is-on { - border-color: #4f8df5; -} -.palette__toggle-dot { - width: 10px; height: 10px; border-radius: 50%; - background: #303540; -} -.palette__toggle.is-on .palette__toggle-dot { - background: #4f8df5; -} - -.palette__action { - background: transparent; - border: 1px solid #303540; - color: inherit; - padding: 6px 8px; - border-radius: 6px; - cursor: pointer; -} - -.palette__collapse { - background: transparent; - border: 0; - color: #8a92a3; - cursor: pointer; - align-self: flex-end; - font-size: 14px; -} diff --git a/examples/chat/angular/src/app/shell/control-palette.component.html b/examples/chat/angular/src/app/shell/control-palette.component.html deleted file mode 100644 index c8dbd0ef3..000000000 --- a/examples/chat/angular/src/app/shell/control-palette.component.html +++ /dev/null @@ -1,96 +0,0 @@ -@if (collapsed()) { - -} @else { -
-
- - - -
- - - - - - - - - - - - - - -
-} diff --git a/examples/chat/angular/src/app/shell/control-palette.component.ts b/examples/chat/angular/src/app/shell/control-palette.component.ts deleted file mode 100644 index ffdc18382..000000000 --- a/examples/chat/angular/src/app/shell/control-palette.component.ts +++ /dev/null @@ -1,86 +0,0 @@ -// SPDX-License-Identifier: MIT -import { - Component, - ChangeDetectionStrategy, - input, - output, - signal, - inject, - effect, -} from '@angular/core'; -import { PalettePersistence } from './palette-persistence.service'; -import type { DemoMode } from './demo-shell.component'; - -@Component({ - selector: 'app-control-palette', - standalone: true, - changeDetection: ChangeDetectionStrategy.OnPush, - templateUrl: './control-palette.component.html', - styleUrl: './control-palette.component.css', -}) -export class ControlPalette { - private readonly persistence = inject(PalettePersistence); - - readonly mode = input.required(); - readonly model = input.required(); - readonly modelOptions = input.required(); - readonly effort = input.required(); - readonly effortOptions = input.required(); - readonly genUiMode = input.required(); - readonly genUiOptions = input.required(); - readonly theme = input.required(); - readonly themeOptions = input.required(); - readonly debugOpen = input.required(); - - readonly modeChange = output(); - readonly modelChange = output(); - readonly effortChange = output(); - readonly genUiModeChange = output(); - readonly themeChange = output(); - readonly debugOpenChange = output(); - readonly newConversation = output(); - - protected readonly collapsed = signal(this.persistence.read('collapsed') ?? false); - - constructor() { - effect(() => { - this.persistence.write('collapsed', this.collapsed()); - }); - } - - protected toggleCollapsed(): void { - this.collapsed.update((c) => !c); - } - - protected pickMode(next: DemoMode): void { - this.modeChange.emit(next); - } - - protected pickModel(event: Event): void { - const value = (event.target as HTMLSelectElement).value; - this.modelChange.emit(value); - } - - protected pickEffort(event: Event): void { - const value = (event.target as HTMLSelectElement).value; - this.effortChange.emit(value); - } - - protected pickGenUiMode(event: Event): void { - const value = (event.target as HTMLSelectElement).value; - this.genUiModeChange.emit(value); - } - - protected pickTheme(event: Event): void { - const value = (event.target as HTMLSelectElement).value; - this.themeChange.emit(value); - } - - protected toggleDebug(): void { - this.debugOpenChange.emit(!this.debugOpen()); - } - - protected emitNewConversation(): void { - this.newConversation.emit(); - } -} diff --git a/examples/chat/angular/src/app/shell/demo-shell.component.css b/examples/chat/angular/src/app/shell/demo-shell.component.css index 54181c06e..843606d85 100644 --- a/examples/chat/angular/src/app/shell/demo-shell.component.css +++ b/examples/chat/angular/src/app/shell/demo-shell.component.css @@ -45,18 +45,6 @@ .demo-shell__main--push { padding-left: 0; } } -.demo-shell__debug { - position: fixed; - left: 0; - right: 0; - bottom: 0; - height: 30vh; - background: #0f1116; - border-top: 1px solid #303540; - overflow: auto; - z-index: 999; -} - .demo-shell__interrupt-panel { position: fixed; left: 50%; diff --git a/examples/chat/angular/src/app/shell/demo-shell.component.html b/examples/chat/angular/src/app/shell/demo-shell.component.html index 0cb6be6e5..d486f065b 100644 --- a/examples/chat/angular/src/app/shell/demo-shell.component.html +++ b/examples/chat/angular/src/app/shell/demo-shell.component.html @@ -7,26 +7,6 @@ (click)="toggleDrawer()" >☰ - -
} - @if (debugOpen()) { -
- -
- }
+ + + + + + + + + + + + + + + + + + +
diff --git a/examples/chat/angular/src/app/shell/demo-shell.component.ts b/examples/chat/angular/src/app/shell/demo-shell.component.ts index b2ec5bafe..315f857e9 100644 --- a/examples/chat/angular/src/app/shell/demo-shell.component.ts +++ b/examples/chat/angular/src/app/shell/demo-shell.component.ts @@ -15,13 +15,17 @@ import { filter, map, startWith } from 'rxjs/operators'; import { agent } from '@ngaf/langgraph'; import { ChatDebugComponent, + ChatDebugControlsDirective, + ChatDebugSectionComponent, + ChatDebugSegmentedComponent, + ChatDebugSelectComponent, + ChatDebugActionComponent, ChatInterruptPanelComponent, ChatSubagentsComponent, ChatThreadDrawerComponent, ChatThreadListComponent, type InterruptAction, } from '@ngaf/chat'; -import { ControlPalette } from './control-palette.component'; import { PalettePersistence } from './palette-persistence.service'; import { ThreadsService } from './threads.service'; import { DEMO_AGENT } from './shell-tokens'; @@ -40,8 +44,12 @@ function modeFromUrl(url: string): DemoMode { standalone: true, imports: [ RouterOutlet, - ControlPalette, ChatDebugComponent, + ChatDebugControlsDirective, + ChatDebugSectionComponent, + ChatDebugSegmentedComponent, + ChatDebugSelectComponent, + ChatDebugActionComponent, ChatInterruptPanelComponent, ChatSubagentsComponent, ChatThreadDrawerComponent, @@ -129,8 +137,6 @@ export class DemoShell { */ readonly theme = signal(this.persistence.read('theme') ?? 'default-dark'); - protected readonly debugOpen = signal(this.persistence.read('debug') ?? false); - /** Whether the threads drawer is open. Persisted across reloads. */ protected readonly drawerOpen = signal(this.persistence.read('drawerOpen') ?? false); @@ -144,6 +150,12 @@ export class DemoShell { this.viewportWidth() >= 1024 ? 'push' : 'overlay', ); + protected readonly modeOptions = [ + { value: 'embed', label: 'Embed' }, + { value: 'popup', label: 'Popup' }, + { value: 'sidebar', label: 'Sidebar' }, + ] as const; + protected readonly modelOptions = signal([ { value: 'gpt-5', label: 'gpt-5' }, { value: 'gpt-5-mini', label: 'gpt-5-mini' }, @@ -211,7 +223,7 @@ export class DemoShell { return a; })(); - protected onModeChange(next: DemoMode): void { + protected onModeChange(next: DemoMode | string): void { void this.router.navigate(['/' + next]); } @@ -235,11 +247,6 @@ export class DemoShell { this.persistence.write('theme', next); } - protected onDebugChange(next: boolean): void { - this.debugOpen.set(next); - this.persistence.write('debug', next); - } - protected onDrawerOpenChange(next: boolean): void { this.drawerOpen.set(next); this.persistence.write('drawerOpen', next); From 7d0d5ed1af65529b4362a2985aecc134b988e4e6 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 11 May 2026 13:46:46 -0700 Subject: [PATCH 15/18] fix(chat): chat-debug persistence write-through MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The combined restore-and-write effect tracked only inputs (storageKey, defaultOpen, dock) on its first run via the restore branch, then bailed out via the `restored` guard before reading the writable signals. Result: the effect never re-ran when open/dock/tab changed, so localStorage was never updated. Split into a synchronous restore in the constructor body (inputs are stable for the instance lifetime, so reading them once is correct) and a pure write-through effect that reads open()/dockState()/activeTabId(), which registers them as dependencies and writes on every change. Caught by end-to-end browser verification — pre-fix, no chat-debug:* keys appeared in localStorage after dock or tab interaction. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../chat-debug/chat-debug.component.ts | 29 +++++++++---------- 1 file changed, 14 insertions(+), 15 deletions(-) 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 7a1915f7b..9e5ef75eb 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 @@ -250,24 +250,23 @@ export class ChatDebugComponent { return this.hostInspectors()[t.hostIndex]; }); - private restored = false; - constructor() { - // First read of inputs/storage on view init seeds the writable signals, - // then write-through on every change. Untracked reads of the writable - // signals avoid feedback loops. + // Restore once from storage on construction, seeded by inputs as the + // fallback. Reads of `storageKey`, `defaultOpen`, `dock` are intentional + // — the input signals are stable for the lifetime of an instance, so this + // runs effectively once. + const restore = createPersistence(this.storageKey()); + const persistedOpen = restore.read('open'); + this.open.set(persistedOpen ?? this.defaultOpen()); + const persistedDock = restore.read('dock'); + this.dockState.set(persistedDock ?? this.dock()); + const persistedTab = restore.read('tab'); + if (persistedTab) this.activeTabId.set(persistedTab); + + // Write-through effect — reads each writable signal so subsequent + // changes trigger a fresh run that writes them all to storage. effect(() => { const p = createPersistence(this.storageKey()); - if (!this.restored) { - const persistedOpen = p.read('open'); - this.open.set(persistedOpen ?? this.defaultOpen()); - const persistedDock = p.read('dock'); - this.dockState.set(persistedDock ?? this.dock()); - const persistedTab = p.read('tab'); - if (persistedTab) this.activeTabId.set(persistedTab); - this.restored = true; - return; - } p.write('open', this.open()); p.write('dock', this.dockState()); p.write('tab', this.activeTabId()); From e319a80614d7a80d2e324cf034107116e157dcee Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 11 May 2026 13:55:29 -0700 Subject: [PATCH 16/18] chore(chat,examples-chat): post-review cleanups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - chat-debug: simplify outdated constructor comment about the restore flow (no longer happens inside an effect). - palette-persistence: drop dead `debug` and `collapsed` keys — both moved to chat-debug's own persistence when the control palette was migrated. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../angular/src/app/shell/palette-persistence.service.ts | 2 -- .../src/lib/compositions/chat-debug/chat-debug.component.ts | 6 ++---- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/examples/chat/angular/src/app/shell/palette-persistence.service.ts b/examples/chat/angular/src/app/shell/palette-persistence.service.ts index 0a356df1d..c40ed330e 100644 --- a/examples/chat/angular/src/app/shell/palette-persistence.service.ts +++ b/examples/chat/angular/src/app/shell/palette-persistence.service.ts @@ -8,9 +8,7 @@ interface PaletteState { effort?: string | null; genUiMode?: string | null; theme?: string | null; - debug?: boolean | null; threadId?: string | null; - collapsed?: boolean | null; drawerOpen?: boolean | null; } 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 9e5ef75eb..6d7c31dd9 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 @@ -251,10 +251,8 @@ export class ChatDebugComponent { }); constructor() { - // Restore once from storage on construction, seeded by inputs as the - // fallback. Reads of `storageKey`, `defaultOpen`, `dock` are intentional - // — the input signals are stable for the lifetime of an instance, so this - // runs effectively once. + // Restore once from storage on construction; inputs seed the fallback. + // `storageKey` is read-once: rebinding it at runtime is not supported. const restore = createPersistence(this.storageKey()); const persistedOpen = restore.read('open'); this.open.set(persistedOpen ?? this.defaultOpen()); From ea9f4916bdb3a7dc3a98fe774a7abd01898bcbb2 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 11 May 2026 14:09:55 -0700 Subject: [PATCH 17/18] =?UTF-8?q?feat(chat):=20chat-debug=20visual=20polis?= =?UTF-8?q?h=20=E2=80=94=20icons,=20depth,=20hover=20states?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace ASCII glyphs with proper inline SVG icons throughout: - Launcher uses the ICON_TOOL gear icon (was ⚙ character) - Dock toggle group: three mini panel-position icons (was ◧ ▭ ◨) - Close button: line × (was × character) - Select chevron (was native appearance arrow) - State Copy button gets a copy-icon + 1.5s "Copied" check-mark feedback Add visible weight and depth: - Launcher 40 → 44px with directional shadow + hover lift - Panel gets dock-direction-aware drop shadow - Header gets subtle vertical gradient + a green status dot - Dock toggles styled as a true segmented group (matches segmented primitive) - Controls zone takes a faint surface-alt tint to separate it from inspectors - Tab strip: hover state, taller padding, primary-colored underline on active - Section labels: 10px tracked uppercase, more breathing room Primitive refinements: - chat-debug-segmented: flex-grow so options share width, focus/hover transitions - chat-debug-select: custom chevron, focus ring via primary border, hover border - chat-debug-action: centered button, focus/active transitions - timeline-inspector: pill-shaped count badge, hover row actions with bg, diff card gets shadow + border, empty-state copy when no checkpoints Also fix palette-persistence spec to use `drawerOpen` after `debug`/`collapsed` were removed in the post-review cleanup commit. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../shell/palette-persistence.service.spec.ts | 11 +- .../chat-debug/chat-debug.component.ts | 153 ++++++++++++++---- .../inspectors/state-inspector.component.ts | 42 ++++- .../timeline-inspector.component.ts | 60 ++++++- .../primitives/chat-debug-action.component.ts | 14 +- .../chat-debug-section.component.ts | 11 +- .../chat-debug-segmented.component.ts | 15 +- .../primitives/chat-debug-select.component.ts | 45 ++++-- 8 files changed, 279 insertions(+), 72 deletions(-) diff --git a/examples/chat/angular/src/app/shell/palette-persistence.service.spec.ts b/examples/chat/angular/src/app/shell/palette-persistence.service.spec.ts index 940925ee0..750717654 100644 --- a/examples/chat/angular/src/app/shell/palette-persistence.service.spec.ts +++ b/examples/chat/angular/src/app/shell/palette-persistence.service.spec.ts @@ -13,9 +13,8 @@ describe('PalettePersistence', () => { const svc = TestBed.runInInjectionContext(() => new PalettePersistence()); expect(svc.read('model')).toBeNull(); expect(svc.read('effort')).toBeNull(); - expect(svc.read('debug')).toBeNull(); expect(svc.read('threadId')).toBeNull(); - expect(svc.read('collapsed')).toBeNull(); + expect(svc.read('drawerOpen')).toBeNull(); }); it('round-trips a string value', () => { @@ -32,10 +31,10 @@ describe('PalettePersistence', () => { it('round-trips a boolean value', () => { const svc = TestBed.runInInjectionContext(() => new PalettePersistence()); - svc.write('debug', true); - expect(svc.read('debug')).toBe(true); - svc.write('debug', false); - expect(svc.read('debug')).toBe(false); + svc.write('drawerOpen', true); + expect(svc.read('drawerOpen')).toBe(true); + svc.write('drawerOpen', false); + expect(svc.read('drawerOpen')).toBe(false); }); it('clearing a key with null removes it from storage', () => { 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 6d7c31dd9..bb3ee54fe 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 @@ -7,13 +7,16 @@ import { contentChildren, effect, HostListener, + inject, input, output, signal, } from '@angular/core'; import { NgTemplateOutlet } from '@angular/common'; +import { DomSanitizer, type SafeHtml } from '@angular/platform-browser'; import type { AgentWithHistory } from '../../agent'; import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens'; +import { ICON_TOOL } from '../../styles/chat-icons'; import { ChatDebugControlsDirective } from './chat-debug-controls.directive'; import { ChatDebugInspectorDirective } from './chat-debug-inspector.directive'; import { TimelineInspectorComponent } from './inspectors/timeline-inspector.component'; @@ -46,22 +49,28 @@ interface TabEntry { /* Floating launcher */ .launcher { position: fixed; - bottom: 16px; - right: 16px; - width: 40px; - height: 40px; + bottom: 20px; + right: 20px; + width: 44px; + height: 44px; border-radius: var(--ngaf-chat-radius-launcher); background: var(--ngaf-chat-primary); color: var(--ngaf-chat-on-primary); border: 0; cursor: pointer; z-index: 990; - box-shadow: var(--ngaf-chat-shadow-md); - font-size: 18px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.18), 0 2px 4px rgba(0, 0, 0, 0.10); display: flex; align-items: center; justify-content: center; + transition: transform 150ms ease, box-shadow 150ms ease; } + .launcher:hover { + transform: translateY(-1px); + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.22), 0 3px 6px rgba(0, 0, 0, 0.12); + } + .launcher:active { transform: translateY(0); } + .launcher svg { display: block; } /* Docked panel */ .panel { @@ -72,62 +81,124 @@ interface TabEntry { z-index: 991; display: flex; flex-direction: column; - box-shadow: var(--ngaf-chat-shadow-lg); + box-shadow: -8px 0 24px rgba(0, 0, 0, 0.08), -2px 0 8px rgba(0, 0, 0, 0.04); } .panel--right { top: 0; right: 0; bottom: 0; width: var(--panel-size, 420px); border-right: 0; } - .panel--left { top: 0; left: 0; bottom: 0; width: var(--panel-size, 420px); border-left: 0; } - .panel--bottom { left: 0; right: 0; bottom: 0; height: var(--panel-size, 40vh); border-bottom: 0; } + .panel--left { top: 0; left: 0; bottom: 0; width: var(--panel-size, 420px); border-left: 0; + box-shadow: 8px 0 24px rgba(0, 0, 0, 0.08), 2px 0 8px rgba(0, 0, 0, 0.04); } + .panel--bottom { left: 0; right: 0; bottom: 0; height: var(--panel-size, 40vh); border-bottom: 0; + box-shadow: 0 -8px 24px rgba(0, 0, 0, 0.08), 0 -2px 8px rgba(0, 0, 0, 0.04); } .panel__header { display: flex; align-items: center; justify-content: space-between; - padding: var(--ngaf-chat-space-2) var(--ngaf-chat-space-4); + padding: 10px var(--ngaf-chat-space-4); border-bottom: 1px solid var(--ngaf-chat-separator); + background: linear-gradient(to bottom, var(--ngaf-chat-bg), var(--ngaf-chat-surface)); + min-height: 44px; + box-sizing: border-box; } .panel__title { margin: 0; font-size: var(--ngaf-chat-font-size-sm); font-weight: 600; + letter-spacing: -0.01em; + display: flex; + align-items: center; + gap: var(--ngaf-chat-space-2); + } + .panel__title-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--ngaf-chat-success); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--ngaf-chat-success) 22%, transparent); + } + + .panel__dock-group { + display: inline-flex; + gap: 0; + padding: 2px; + background: var(--ngaf-chat-surface-alt); + border: 1px solid var(--ngaf-chat-separator); + border-radius: var(--ngaf-chat-radius-button); } - .panel__actions { display: flex; align-items: center; gap: var(--ngaf-chat-space-1); } - .panel__actions button { + .panel__dock-btn { + appearance: none; background: transparent; - border: 1px solid transparent; + border: 0; + border-radius: calc(var(--ngaf-chat-radius-button) - 2px); + width: 24px; + height: 22px; + padding: 0; + color: var(--ngaf-chat-text-muted); + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + transition: background 120ms ease, color 120ms ease; + } + .panel__dock-btn:hover { color: var(--ngaf-chat-text); } + .panel__dock-btn.is-active { + background: var(--ngaf-chat-bg); + color: var(--ngaf-chat-text); + box-shadow: var(--ngaf-chat-shadow-sm); + } + .panel__dock-btn svg { display: block; } + + .panel__close { + appearance: none; + background: transparent; + border: 0; border-radius: var(--ngaf-chat-radius-button); - padding: 2px 6px; + width: 26px; + height: 26px; + margin-left: var(--ngaf-chat-space-1); color: var(--ngaf-chat-text-muted); - font: inherit; - font-size: var(--ngaf-chat-font-size-sm); cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + transition: background 120ms ease, color 120ms ease; + } + .panel__close:hover { + background: var(--ngaf-chat-surface-alt); + color: var(--ngaf-chat-text); } - .panel__actions button:hover { color: var(--ngaf-chat-text); border-color: var(--ngaf-chat-separator); } - .panel__actions button.is-active { color: var(--ngaf-chat-text); } + + .panel__actions { display: flex; align-items: center; gap: 0; } .panel__controls { border-bottom: 1px solid var(--ngaf-chat-separator); overflow-y: auto; - max-height: 40%; + max-height: 50%; + background: color-mix(in srgb, var(--ngaf-chat-surface-alt) 50%, var(--ngaf-chat-bg)); } .panel__controls:empty { display: none; } .panel__tabs { display: flex; - gap: 0; + gap: var(--ngaf-chat-space-1); border-bottom: 1px solid var(--ngaf-chat-separator); - padding: 0 var(--ngaf-chat-space-2); + padding: 0 var(--ngaf-chat-space-3); + background: var(--ngaf-chat-bg); } .panel__tab { appearance: none; background: transparent; border: 0; border-bottom: 2px solid transparent; - padding: var(--ngaf-chat-space-2) var(--ngaf-chat-space-3); + padding: 10px var(--ngaf-chat-space-2); font: inherit; font-size: var(--ngaf-chat-font-size-sm); + font-weight: 500; color: var(--ngaf-chat-text-muted); cursor: pointer; + transition: color 120ms ease, border-color 120ms ease; + margin-bottom: -1px; } + .panel__tab:hover { color: var(--ngaf-chat-text); } .panel__tab.is-active { color: var(--ngaf-chat-text); border-bottom-color: var(--ngaf-chat-primary); @@ -144,7 +215,8 @@ interface TabEntry { title="Open chat debug" aria-label="Open chat debug" (click)="setOpen(true)" - >⚙ + [innerHTML]="launcherIcon" + > } @else {
-

Chat Debug

+

+ + Chat Debug +

- - - - +
+ + + +
+
@@ -209,6 +303,9 @@ interface TabEntry { `, }) export class ChatDebugComponent { + private readonly sanitizer = inject(DomSanitizer); + protected readonly launcherIcon: SafeHtml = this.sanitizer.bypassSecurityTrustHtml(ICON_TOOL); + readonly agent = input.required(); readonly dock = input('right'); readonly defaultOpen = input(false); diff --git a/libs/chat/src/lib/compositions/chat-debug/inspectors/state-inspector.component.ts b/libs/chat/src/lib/compositions/chat-debug/inspectors/state-inspector.component.ts index f553a5645..b69397b11 100644 --- a/libs/chat/src/lib/compositions/chat-debug/inspectors/state-inspector.component.ts +++ b/libs/chat/src/lib/compositions/chat-debug/inspectors/state-inspector.component.ts @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -import { Component, ChangeDetectionStrategy, input, computed } from '@angular/core'; +import { Component, ChangeDetectionStrategy, input, computed, signal } from '@angular/core'; import type { AgentWithHistory } from '../../../agent'; import { CHAT_HOST_TOKENS } from '../../../styles/chat-tokens'; import { DebugStateInspectorComponent } from '../debug-state-inspector.component'; @@ -16,30 +16,54 @@ import { extractStateValues } from '../debug-utils'; :host { display: flex; flex-direction: column; height: 100%; } .state__header { display: flex; - justify-content: flex-end; - padding: var(--ngaf-chat-space-2) var(--ngaf-chat-space-4); + align-items: center; + justify-content: space-between; + padding: 10px var(--ngaf-chat-space-4); border-bottom: 1px solid var(--ngaf-chat-separator); + background: color-mix(in srgb, var(--ngaf-chat-surface-alt) 40%, var(--ngaf-chat-bg)); + font-size: var(--ngaf-chat-font-size-xs); + font-weight: 500; + color: var(--ngaf-chat-text-muted); } .state__copy { - background: transparent; + display: inline-flex; + align-items: center; + gap: 6px; + background: var(--ngaf-chat-bg); border: 1px solid var(--ngaf-chat-separator); border-radius: var(--ngaf-chat-radius-button); - padding: 2px 8px; + padding: 3px var(--ngaf-chat-space-2); font: inherit; font-size: var(--ngaf-chat-font-size-xs); color: var(--ngaf-chat-text-muted); cursor: pointer; + transition: color 120ms ease, border-color 120ms ease; } + .state__copy:hover { + color: var(--ngaf-chat-text); + border-color: var(--ngaf-chat-text-muted); + } + .state__copy.is-copied { color: var(--ngaf-chat-success); border-color: var(--ngaf-chat-success); } + .state__copy svg { display: block; } .state__body { flex: 1; overflow-y: auto; - padding: var(--ngaf-chat-space-2) var(--ngaf-chat-space-4); + padding: var(--ngaf-chat-space-3) var(--ngaf-chat-space-4); } `, ], template: `
- + Current state +
@@ -55,8 +79,12 @@ export class StateInspectorComponent { return extractStateValues(last); }); + protected readonly justCopied = signal(false); + copy(): void { if (typeof navigator === 'undefined' || !navigator.clipboard) return; void navigator.clipboard.writeText(JSON.stringify(this.state(), null, 2)); + this.justCopied.set(true); + setTimeout(() => this.justCopied.set(false), 1500); } } diff --git a/libs/chat/src/lib/compositions/chat-debug/inspectors/timeline-inspector.component.ts b/libs/chat/src/lib/compositions/chat-debug/inspectors/timeline-inspector.component.ts index 5783c7230..761780afd 100644 --- a/libs/chat/src/lib/compositions/chat-debug/inspectors/timeline-inspector.component.ts +++ b/libs/chat/src/lib/compositions/chat-debug/inspectors/timeline-inspector.component.ts @@ -43,27 +43,58 @@ export function stepSelection(dir: Direction, current: number, count: number): n display: flex; align-items: center; justify-content: space-between; - padding: var(--ngaf-chat-space-2) var(--ngaf-chat-space-4); + padding: 10px var(--ngaf-chat-space-4); border-bottom: 1px solid var(--ngaf-chat-separator); font-size: var(--ngaf-chat-font-size-xs); + font-weight: 500; color: var(--ngaf-chat-text-muted); + background: color-mix(in srgb, var(--ngaf-chat-surface-alt) 40%, var(--ngaf-chat-bg)); + } + .timeline__count { display: inline-flex; align-items: center; gap: 6px; } + .timeline__count-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 22px; + height: 18px; + padding: 0 6px; + border-radius: 9px; + background: var(--ngaf-chat-surface-alt); + border: 1px solid var(--ngaf-chat-separator); + color: var(--ngaf-chat-text); + font-size: 11px; + font-variant-numeric: tabular-nums; } .timeline__clear { background: transparent; border: 0; cursor: pointer; color: var(--ngaf-chat-text-muted); + font: inherit; font-size: var(--ngaf-chat-font-size-xs); + padding: 2px 6px; + border-radius: var(--ngaf-chat-radius-button); + transition: color 120ms ease, background 120ms ease; + } + .timeline__clear:hover:not(:disabled) { + color: var(--ngaf-chat-text); + background: var(--ngaf-chat-surface-alt); } - .timeline__clear:disabled { opacity: 0.5; cursor: default; } + .timeline__clear:disabled { opacity: 0.4; cursor: default; } .timeline__list { flex: 1; overflow-y: auto; - padding: var(--ngaf-chat-space-2) var(--ngaf-chat-space-4); + padding: var(--ngaf-chat-space-3) var(--ngaf-chat-space-4); display: flex; flex-direction: column; gap: var(--ngaf-chat-space-2); } + .timeline__empty { + padding: var(--ngaf-chat-space-6) var(--ngaf-chat-space-4); + text-align: center; + color: var(--ngaf-chat-text-muted); + font-size: var(--ngaf-chat-font-size-sm); + } .timeline__row { display: flex; flex-direction: column; gap: var(--ngaf-chat-space-2); } .timeline__row-actions { display: none; @@ -72,25 +103,35 @@ export function stepSelection(dir: Direction, current: number, count: number): n } .timeline__row:hover .timeline__row-actions { display: flex; } .timeline__row button.row-action { - background: transparent; + background: var(--ngaf-chat-bg); border: 1px solid var(--ngaf-chat-separator); border-radius: var(--ngaf-chat-radius-button); - padding: 2px 8px; + padding: 3px var(--ngaf-chat-space-2); + font: inherit; font-size: var(--ngaf-chat-font-size-xs); color: var(--ngaf-chat-text-muted); cursor: pointer; + transition: color 120ms ease, border-color 120ms ease; + } + .timeline__row button.row-action:hover { + color: var(--ngaf-chat-text); + border-color: var(--ngaf-chat-text-muted); } - .timeline__row button.row-action:hover { color: var(--ngaf-chat-text); } .timeline__diff { - padding: var(--ngaf-chat-space-2); + padding: var(--ngaf-chat-space-3); background: var(--ngaf-chat-surface-alt); + border: 1px solid var(--ngaf-chat-separator); border-radius: var(--ngaf-chat-radius-card); + box-shadow: var(--ngaf-chat-shadow-sm); } `, ], template: `
- {{ checkpoints().length }} checkpoints + + {{ checkpoints().length }} + checkpoint{{ checkpoints().length === 1 ? '' : 's' }} +