diff --git a/apps/website/content/docs/chat/api/api-docs.json b/apps/website/content/docs/chat/api/api-docs.json index fa970d6ab..539b83f03 100644 --- a/apps/website/content/docs/chat/api/api-docs.json +++ b/apps/website/content/docs/chat/api/api-docs.json @@ -1897,6 +1897,28 @@ } ] }, + { + "name": "ChatDebugActionComponent", + "kind": "class", + "description": "", + "params": [], + "examples": [], + "properties": [ + { + "name": "clicked", + "type": "OutputEmitterRef", + "description": "", + "optional": false + }, + { + "name": "label", + "type": "InputSignal", + "description": "", + "optional": false + } + ], + "methods": [] + }, { "name": "ChatDebugComponent", "kind": "class", @@ -1904,6 +1926,24 @@ "params": [], "examples": [], "properties": [ + { + "name": "activeHostInspector", + "type": "Signal | undefined>", + "description": "", + "optional": false + }, + { + "name": "activeTab", + "type": "Signal", + "description": "", + "optional": false + }, + { + "name": "activeTabId", + "type": "WritableSignal", + "description": "", + "optional": false + }, { "name": "agent", "type": "InputSignal", @@ -1911,14 +1951,32 @@ "optional": false }, { - "name": "checkpoints", - "type": "Signal", + "name": "controls", + "type": "Signal | undefined>", "description": "", "optional": false }, { - "name": "debugOpen", - "type": "WritableSignal", + "name": "defaultOpen", + "type": "InputSignal", + "description": "", + "optional": false + }, + { + "name": "dock", + "type": "InputSignal", + "description": "", + "optional": false + }, + { + "name": "dockChange", + "type": "OutputEmitterRef", + "description": "", + "optional": false + }, + { + "name": "dockState", + "type": "WritableSignal", "description": "", "optional": false }, @@ -1929,14 +1987,26 @@ "optional": false }, { - "name": "messageContent", - "type": "object", + "name": "hostInspectors", + "type": "Signal", "description": "", "optional": false }, { - "name": "previousState", - "type": "Signal>", + "name": "launcherIcon", + "type": "SafeHtml", + "description": "", + "optional": false + }, + { + "name": "open", + "type": "WritableSignal", + "description": "", + "optional": false + }, + { + "name": "openChange", + "type": "OutputEmitterRef", "description": "", "optional": false }, @@ -1947,58 +2017,231 @@ "optional": false }, { - "name": "selectedCheckpointIndex", - "type": "WritableSignal", + "name": "storageKey", + "type": "InputSignal", "description": "", "optional": false }, { - "name": "selectedState", - "type": "Signal>", + "name": "tabs", + "type": "Signal", "description": "", "optional": false } ], "methods": [ { - "name": "jumpToEnd", - "signature": "jumpToEnd()", + "name": "onEsc", + "signature": "onEsc(_ev: Event)", "description": "", - "params": [] + "params": [ + { + "name": "_ev", + "type": "Event", + "description": "", + "optional": false + } + ] }, { - "name": "jumpToStart", - "signature": "jumpToStart()", + "name": "setActiveTab", + "signature": "setActiveTab(id: string)", "description": "", - "params": [] + "params": [ + { + "name": "id", + "type": "string", + "description": "", + "optional": false + } + ] }, { - "name": "renderMd", - "signature": "renderMd(content: string)", + "name": "setDock", + "signature": "setDock(next: DockPosition)", "description": "", "params": [ { - "name": "content", - "type": "string", + "name": "next", + "type": "DockPosition", "description": "", "optional": false } ] }, { - "name": "stepBack", - "signature": "stepBack()", + "name": "setOpen", + "signature": "setOpen(value: boolean)", "description": "", - "params": [] + "params": [ + { + "name": "value", + "type": "boolean", + "description": "", + "optional": false + } + ] + } + ] + }, + { + "name": "ChatDebugControlsDirective", + "kind": "class", + "description": "Marks an `` as the controls slot of ``. Rendered\npinned at the top of the docked panel. Host apps put their app-specific\ncontrols (mode picker, model select, etc.) inside this template.", + "params": [], + "examples": [], + "properties": [ + { + "name": "templateRef", + "type": "TemplateRef", + "description": "", + "optional": false + } + ], + "methods": [] + }, + { + "name": "ChatDebugInspectorDirective", + "kind": "class", + "description": "Marks an `` as a host-registered inspector tab. Each instance\nadds a tab in the docked panel's tab strip, appended after the built-in\nTimeline and State tabs.", + "params": [], + "examples": [], + "properties": [ + { + "name": "label", + "type": "InputSignal", + "description": "", + "optional": false }, { - "name": "stepForward", - "signature": "stepForward()", + "name": "templateRef", + "type": "TemplateRef", "description": "", - "params": [] + "optional": false + } + ], + "methods": [] + }, + { + "name": "ChatDebugSectionComponent", + "kind": "class", + "description": "", + "params": [], + "examples": [], + "properties": [ + { + "name": "label", + "type": "InputSignal", + "description": "", + "optional": false + } + ], + "methods": [] + }, + { + "name": "ChatDebugSegmentedComponent", + "kind": "class", + "description": "", + "params": [], + "examples": [], + "properties": [ + { + "name": "options", + "type": "InputSignal", + "description": "", + "optional": false + }, + { + "name": "value", + "type": "InputSignal", + "description": "", + "optional": false + }, + { + "name": "valueChange", + "type": "OutputEmitterRef", + "description": "", + "optional": false + } + ], + "methods": [] + }, + { + "name": "ChatDebugSelectComponent", + "kind": "class", + "description": "", + "params": [], + "examples": [], + "properties": [ + { + "name": "label", + "type": "InputSignal", + "description": "", + "optional": false + }, + { + "name": "options", + "type": "InputSignal", + "description": "", + "optional": false + }, + { + "name": "value", + "type": "InputSignal", + "description": "", + "optional": false + }, + { + "name": "valueChange", + "type": "OutputEmitterRef", + "description": "", + "optional": false + } + ], + "methods": [ + { + "name": "onChange", + "signature": "onChange(event: Event)", + "description": "", + "params": [ + { + "name": "event", + "type": "Event", + "description": "", + "optional": false + } + ] } ] }, + { + "name": "ChatDebugToggleComponent", + "kind": "class", + "description": "", + "params": [], + "examples": [], + "properties": [ + { + "name": "label", + "type": "InputSignal", + "description": "", + "optional": false + }, + { + "name": "value", + "type": "InputSignal", + "description": "", + "optional": false + }, + { + "name": "valueChange", + "type": "OutputEmitterRef", + "description": "", + "optional": false + } + ], + "methods": [] + }, { "name": "ChatErrorComponent", "kind": "class", @@ -4976,6 +5219,46 @@ ], "examples": [] }, + { + "name": "SegmentedOption", + "kind": "interface", + "description": "", + "properties": [ + { + "name": "label", + "type": "string", + "description": "", + "optional": false + }, + { + "name": "value", + "type": "string", + "description": "", + "optional": false + } + ], + "examples": [] + }, + { + "name": "SelectOption", + "kind": "interface", + "description": "", + "properties": [ + { + "name": "label", + "type": "string", + "description": "", + "optional": false + }, + { + "name": "value", + "type": "string", + "description": "", + "optional": false + } + ], + "examples": [] + }, { "name": "Subagent", "kind": "interface", @@ -5152,6 +5435,13 @@ "signature": "\"pending\" | \"markdown\" | \"json-render\" | \"a2ui\" | \"mixed\"", "examples": [] }, + { + "name": "DockPosition", + "kind": "type", + "description": "", + "signature": "\"right\" | \"bottom\" | \"left\"", + "examples": [] + }, { "name": "DynamicBoolean", "kind": "type", 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. 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). 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 91cc00bd1..000000000 --- a/examples/chat/angular/src/app/shell/control-palette.component.css +++ /dev/null @@ -1,262 +0,0 @@ -:host { - position: fixed; - top: 12px; - right: 12px; - z-index: 1000; -} - -/* ── Status pill (collapsed) ─────────────────────────────────────────── */ - -.palette-pill { - display: inline-flex; - align-items: center; - gap: 8px; - background: #18181b; - color: #fafafa; - border: 1px solid #27272a; - border-radius: 999px; - padding: 6px 12px; - font-family: ui-monospace, 'SF Mono', Menlo, Consolas, monospace; - font-size: 12px; - cursor: pointer; - box-shadow: 0 6px 18px rgba(0, 0, 0, 0.4); - transition: background 120ms ease, border-color 120ms ease; -} -.palette-pill:hover { - background: #1f1f23; - border-color: #3f3f46; -} - -.palette-pill__dot { - width: 8px; - height: 8px; - border-radius: 50%; - background: #4ade80; - box-shadow: 0 0 8px rgba(74, 222, 128, 0.6); -} -.palette-pill__dot--streaming { - background: #4f8df5; - box-shadow: 0 0 8px rgba(79, 141, 245, 0.7); - animation: palette-pill-pulse 1.2s ease-in-out infinite; -} -@keyframes palette-pill-pulse { - 0%, 100% { opacity: 1; transform: scale(1); } - 50% { opacity: 0.6; transform: scale(0.85); } -} - -.palette-pill__sep, -.palette-pill__mode { - color: #a1a1aa; -} -.palette-pill__model { - font-weight: 600; -} - -/* ── Panel (expanded) ────────────────────────────────────────────────── */ - -.palette-panel { - width: 320px; - background: #18181b; - border: 1px solid #27272a; - border-radius: 12px; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); - color: #fafafa; - font-family: ui-sans-serif, system-ui, -apple-system, sans-serif; - font-size: 13px; - overflow: hidden; - transform-origin: top right; - animation: palette-panel-enter 120ms ease; -} -@keyframes palette-panel-enter { - from { opacity: 0; transform: scale(0.96); } - to { opacity: 1; transform: scale(1); } -} - -.palette-panel__header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 14px 16px; - border-bottom: 1px solid #27272a; -} -.palette-panel__title { - font-size: 13px; - font-weight: 600; - letter-spacing: -0.01em; -} -.palette-panel__close { - background: transparent; - border: 0; - color: #71717a; - cursor: pointer; - width: 24px; - height: 24px; - border-radius: 6px; - font-size: 16px; - line-height: 1; - display: inline-flex; - align-items: center; - justify-content: center; -} -.palette-panel__close:hover { background: #27272a; color: #fafafa; } - -.palette-panel__divider { - height: 1px; - background: #27272a; -} - -.palette-panel__section { - padding: 14px 16px; -} -.palette-panel__section--action { - padding-top: 14px; - padding-bottom: 16px; -} -.palette-panel__section-title { - font-size: 11px; - font-weight: 600; - letter-spacing: 0.04em; - text-transform: uppercase; - color: #71717a; - margin: 0 0 10px 0; -} - -/* ── Segmented (Mode) ────────────────────────────────────────────────── */ - -.palette-segmented { - display: flex; - background: #09090b; - border: 1px solid #27272a; - border-radius: 8px; - padding: 3px; - gap: 0; -} -.palette-segmented button { - flex: 1; - background: transparent; - border: 0; - color: #a1a1aa; - padding: 6px 8px; - border-radius: 5px; - font-size: 12px; - cursor: pointer; - font-family: inherit; - transition: background 120ms ease, color 120ms ease; -} -.palette-segmented button:hover { background: #18181b; color: #fafafa; } -.palette-segmented button.is-active { - background: #27272a; - color: #fafafa; - font-weight: 500; -} - -/* ── Field rows (label left, control right) ──────────────────────────── */ - -.palette-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; -} -.palette-row + .palette-row { margin-top: 10px; } -.palette-row__label { - font-size: 13px; - color: #d4d4d8; -} - -/* ── Styled select (native - @for (opt of modelOptions(); track opt.value) { - - } - - - -
- -
- {{ labelFor(effort(), effortOptions()) }} - - -
-
-
- -
- {{ labelFor(genUiMode(), genUiOptions()) }} - - -
-
- - -
- -
-

Appearance

-
- -
- {{ labelFor(theme(), themeOptions()) }} - - -
-
-
- Debug overlay - -
-
- -
- -
- -
- -} diff --git a/examples/chat/angular/src/app/shell/control-palette.component.spec.ts b/examples/chat/angular/src/app/shell/control-palette.component.spec.ts deleted file mode 100644 index 5c907fdc9..000000000 --- a/examples/chat/angular/src/app/shell/control-palette.component.spec.ts +++ /dev/null @@ -1,129 +0,0 @@ -// SPDX-License-Identifier: MIT -import { describe, it, expect, beforeEach } from 'vitest'; -import { TestBed } from '@angular/core/testing'; -import { Component, signal } from '@angular/core'; -import { ControlPalette } from './control-palette.component'; -import { PalettePersistence } from './palette-persistence.service'; - -@Component({ - standalone: true, - imports: [ControlPalette], - template: ``, -}) -class HostComponent { - debug = signal(false); - streaming = signal(false); -} - -class StubPersistence { - private store: Record = {}; - read(key: string): T | undefined { return this.store[key] as T | undefined; } - write(key: string, value: T): void { this.store[key] = value; } -} - -describe('ControlPalette — pill / panel toggle', () => { - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [HostComponent], - providers: [{ provide: PalettePersistence, useClass: StubPersistence }], - }); - }); - - it('starts in the pill state (collapsed) on first run', () => { - const fx = TestBed.createComponent(HostComponent); - fx.detectChanges(); - expect(fx.nativeElement.querySelector('.palette-pill')).toBeTruthy(); - expect(fx.nativeElement.querySelector('.palette-panel')).toBeNull(); - }); - - it('clicking the pill opens the panel', () => { - const fx = TestBed.createComponent(HostComponent); - fx.detectChanges(); - (fx.nativeElement.querySelector('.palette-pill') as HTMLButtonElement).click(); - fx.detectChanges(); - expect(fx.nativeElement.querySelector('.palette-panel')).toBeTruthy(); - expect(fx.nativeElement.querySelector('.palette-pill')).toBeNull(); - }); - - it('clicking the close button collapses the panel back to a pill', () => { - const fx = TestBed.createComponent(HostComponent); - fx.detectChanges(); - (fx.nativeElement.querySelector('.palette-pill') as HTMLButtonElement).click(); - fx.detectChanges(); - (fx.nativeElement.querySelector('.palette-panel__close') as HTMLButtonElement).click(); - fx.detectChanges(); - expect(fx.nativeElement.querySelector('.palette-pill')).toBeTruthy(); - }); - - it('Escape keydown closes the panel', () => { - const fx = TestBed.createComponent(HostComponent); - fx.detectChanges(); - (fx.nativeElement.querySelector('.palette-pill') as HTMLButtonElement).click(); - fx.detectChanges(); - document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); - fx.detectChanges(); - expect(fx.nativeElement.querySelector('.palette-pill')).toBeTruthy(); - }); - - it('document click outside the palette closes it', () => { - const fx = TestBed.createComponent(HostComponent); - fx.detectChanges(); - (fx.nativeElement.querySelector('.palette-pill') as HTMLButtonElement).click(); - fx.detectChanges(); - - const outsideTarget = document.createElement('div'); - document.body.appendChild(outsideTarget); - outsideTarget.dispatchEvent(new MouseEvent('click', { bubbles: true })); - fx.detectChanges(); - document.body.removeChild(outsideTarget); - - expect(fx.nativeElement.querySelector('.palette-pill')).toBeTruthy(); - }); - - it('document click INSIDE the panel does not close it', () => { - const fx = TestBed.createComponent(HostComponent); - fx.detectChanges(); - (fx.nativeElement.querySelector('.palette-pill') as HTMLButtonElement).click(); - fx.detectChanges(); - - const title = fx.nativeElement.querySelector('.palette-panel__title') as HTMLElement; - title.dispatchEvent(new MouseEvent('click', { bubbles: true })); - fx.detectChanges(); - - expect(fx.nativeElement.querySelector('.palette-panel')).toBeTruthy(); - }); - - it('streaming=true adds the streaming class on the dot when pill is visible', () => { - const fx = TestBed.createComponent(HostComponent); - fx.componentInstance.streaming.set(true); - fx.detectChanges(); - expect( - fx.nativeElement.querySelector('.palette-pill__dot--streaming'), - ).toBeTruthy(); - }); - - it('debug switch toggles aria-checked via output', () => { - const fx = TestBed.createComponent(HostComponent); - fx.detectChanges(); - (fx.nativeElement.querySelector('.palette-pill') as HTMLButtonElement).click(); - fx.detectChanges(); - const sw = fx.nativeElement.querySelector('.palette-switch') as HTMLButtonElement; - expect(sw.getAttribute('aria-checked')).toBe('false'); - sw.click(); - fx.detectChanges(); - expect(sw.getAttribute('aria-checked')).toBe('true'); - }); -}); 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 60537dd1c..000000000 --- a/examples/chat/angular/src/app/shell/control-palette.component.ts +++ /dev/null @@ -1,132 +0,0 @@ -// SPDX-License-Identifier: MIT -import { - Component, - ChangeDetectionStrategy, - input, - output, - signal, - inject, - effect, - ElementRef, - HostListener, -} 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); - private readonly elementRef = inject>(ElementRef); - - 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(); - /** True while the agent is streaming. Drives the status-dot pulse. */ - readonly streaming = input(false); - - readonly modeChange = output(); - readonly modelChange = output(); - readonly effortChange = output(); - readonly genUiModeChange = output(); - readonly themeChange = output(); - readonly debugOpenChange = output(); - readonly newConversation = output(); - - /** - * Whether the palette is collapsed to its status-pill state. Defaults - * to true (pill = resting state, matching Next.js dev tools). Persisted - * across reloads via PalettePersistence. - */ - protected readonly collapsed = signal(this.persistence.read('collapsed') ?? true); - - constructor() { - effect(() => { - this.persistence.write('collapsed', this.collapsed()); - }); - } - - protected expand(): void { - this.collapsed.set(false); - } - - protected close(): void { - this.collapsed.set(true); - } - - 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(); - } - - /** - * Close the panel on document-level clicks outside the palette. - * No-ops when already collapsed; checks event.target containment so - * inside-panel clicks don't close. - */ - @HostListener('document:click', ['$event']) - protected onDocumentClick(event: MouseEvent): void { - if (this.collapsed()) return; - const target = event.target as Node | null; - if (target && this.elementRef.nativeElement.contains(target)) return; - this.close(); - } - - /** Close on Escape anywhere in the document while the panel is open. */ - @HostListener('document:keydown.escape') - protected onEscape(): void { - if (!this.collapsed()) this.close(); - } - - /** - * Selected-option label for a value across an options list. Used by the - * styled select trigger to show the human-friendly label rather than - * the raw value. - */ - protected labelFor( - value: string, - options: readonly { value: string; label: string }[], - ): string { - const match = options.find(o => o.value === value); - return match?.label ?? value; - } -} 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 a33c8ef95..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,27 +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); 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/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-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..319fe5290 --- /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(); + readonly templateRef = inject(TemplateRef); +} 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..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 @@ -1,475 +1,391 @@ // SPDX-License-Identifier: MIT import { Component, + ChangeDetectionStrategy, computed, + contentChild, + contentChildren, effect, - input, + HostListener, inject, + input, output, signal, - viewChild, - ElementRef, - ChangeDetectionStrategy, } from '@angular/core'; -import { DomSanitizer } from '@angular/platform-browser'; +import { NgTemplateOutlet } from '@angular/common'; +import { DomSanitizer, type SafeHtml } from '@angular/platform-browser'; 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 { 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'; +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 { + :host { display: contents; } + + /* Floating launcher */ + .launcher { + position: fixed; + 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: 0 4px 12px rgba(0, 0, 0, 0.18), 0 2px 4px rgba(0, 0, 0, 0.10); display: flex; - flex-direction: column; - height: 100%; - overflow: hidden; - background: var(--ngaf-chat-bg); + align-items: center; + justify-content: center; + transition: transform 150ms ease, box-shadow 150ms ease; } - - /* 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); + .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; } - /* 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); + /* 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: -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; + 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); } - .chat-debug__msg-ai { display: flex; gap: 12px; } - .chat-debug__msg-ai__avatar { - width: 28px; - height: 28px; + .panel__header { display: flex; align-items: center; - justify-content: center; - font-size: var(--ngaf-chat-font-size-xs); + justify-content: space-between; + 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; - flex-shrink: 0; - margin-top: 2px; - border-radius: 8px; - background: var(--ngaf-chat-surface-alt); - color: var(--ngaf-chat-text-muted); + letter-spacing: -0.01em; + display: flex; + align-items: center; + gap: var(--ngaf-chat-space-2); } - .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); + .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); } - .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); + .panel__dock-group { + display: inline-flex; + gap: 0; + padding: 2px; 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); } - - .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; + .panel__dock-btn { + appearance: none; + background: 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; } - - /* 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; + .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; } - /* Debug panel toggle */ - .chat-debug__toggle-btn { - width: 32px; - display: flex; + .panel__close { + appearance: none; + background: transparent; + border: 0; + border-radius: var(--ngaf-chat-radius-button); + width: 26px; + height: 26px; + margin-left: var(--ngaf-chat-space-1); + color: var(--ngaf-chat-text-muted); + cursor: pointer; + display: inline-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); + transition: background 120ms ease, color 120ms ease; } - .chat-debug__toggle-btn:hover { - background: color-mix(in srgb, var(--ngaf-chat-text) 5%, transparent); + .panel__close:hover { + background: var(--ngaf-chat-surface-alt); + color: var(--ngaf-chat-text); } - /* Debug panel */ - .chat-debug__panel { - width: 320px; - border-left: 1px solid var(--ngaf-chat-separator); - display: flex; - flex-direction: column; - overflow: hidden; - flex-shrink: 0; - background: var(--ngaf-chat-bg); + .panel__actions { display: flex; align-items: center; gap: 0; } + + .panel__controls { + border-bottom: 1px solid var(--ngaf-chat-separator); + overflow-y: auto; + max-height: 50%; + background: color-mix(in srgb, var(--ngaf-chat-surface-alt) 50%, var(--ngaf-chat-bg)); } - .chat-debug__panel-header { + .panel__controls:empty { display: none; } + + .panel__tabs { display: flex; - align-items: center; - justify-content: space-between; - padding: 8px 12px; + gap: var(--ngaf-chat-space-1); border-bottom: 1px solid var(--ngaf-chat-separator); + padding: 0 var(--ngaf-chat-space-3); + background: var(--ngaf-chat-bg); } - .chat-debug__panel-title { - font-size: 11px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.05em; - margin: 0; - color: var(--ngaf-chat-text-muted); - } - .chat-debug__panel-close { - font-size: var(--ngaf-chat-font-size-xs); + .panel__tab { + appearance: none; background: transparent; border: 0; - cursor: pointer; + border-bottom: 2px solid transparent; + 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); - transition: color 150ms ease; - } - .chat-debug__panel-close:hover { color: var(--ngaf-chat-text); } - - .chat-debug__panel-section { - padding: 8px 12px; - 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; + cursor: pointer; + transition: color 120ms ease, border-color 120ms ease; + margin-bottom: -1px; } - - .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; + .panel__tab:hover { color: var(--ngaf-chat-text); } + .panel__tab.is-active { + color: var(--ngaf-chat-text); + border-bottom-color: var(--ngaf-chat-primary); } - /* 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; - } - :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; - color: var(--ngaf-chat-text-muted); - } - :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__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); + protected readonly launcherIcon: SafeHtml = this.sanitizer.bypassSecurityTrustHtml(ICON_TOOL); 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; - constructor() { + // 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()); + 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 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()); + p.write('open', this.open()); + p.write('dock', this.dockState()); + p.write('tab', this.activeTabId()); }); } - renderMd(content: string) { - return renderMarkdown(content, this.sanitizer); + setOpen(value: boolean): void { + this.open.set(value); + this.openChange.emit(value); } - stepForward(): void { - const idx = this.selectedCheckpointIndex(); - if (idx < this.checkpoints().length - 1) { - this.selectedCheckpointIndex.set(idx + 1); - } + setDock(next: DockPosition): void { + this.dockState.set(next); + this.dockChange.emit(next); } - stepBack(): void { - const idx = this.selectedCheckpointIndex(); - if (idx > 0) { - this.selectedCheckpointIndex.set(idx - 1); - } + setActiveTab(id: string): void { + this.activeTabId.set(id); } - jumpToStart(): void { - this.selectedCheckpointIndex.set(0); - } - - jumpToEnd(): void { - this.selectedCheckpointIndex.set(this.checkpoints().length - 1); + @HostListener('document:keydown.escape', ['$event']) + 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/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..b69397b11 --- /dev/null +++ b/libs/chat/src/lib/compositions/chat-debug/inspectors/state-inspector.component.ts @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: MIT +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'; +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; + 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 { + 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: 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-3) var(--ngaf-chat-space-4); + } + `, + ], + template: ` +
+ Current state + +
+
+ +
+ `, +}) +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); + }); + + 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 new file mode 100644 index 000000000..761780afd --- /dev/null +++ b/libs/chat/src/lib/compositions/chat-debug/inspectors/timeline-inspector.component.ts @@ -0,0 +1,210 @@ +// 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: 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.4; cursor: default; } + .timeline__list { + flex: 1; + overflow-y: auto; + 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; + 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: var(--ngaf-chat-bg); + border: 1px solid var(--ngaf-chat-separator); + border-radius: var(--ngaf-chat-radius-button); + 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__diff { + 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 }} + checkpoint{{ checkpoints().length === 1 ? '' : 's' }} + + +
+
+ @if (checkpoints().length === 0) { +
No checkpoints yet. Send a message to populate the timeline.
+ } + @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); + }); +}); 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)); + }, + }; +} 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..b6d8ca83d --- /dev/null +++ b/libs/chat/src/lib/compositions/chat-debug/primitives/chat-debug-action.component.ts @@ -0,0 +1,42 @@ +// 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-bg); + color: var(--ngaf-chat-text); + border: 1px solid var(--ngaf-chat-separator); + border-radius: var(--ngaf-chat-radius-button); + padding: 8px var(--ngaf-chat-space-3); + font: inherit; + font-size: var(--ngaf-chat-font-size-sm); + font-weight: 500; + cursor: pointer; + width: 100%; + text-align: center; + transition: background 120ms ease, border-color 120ms ease, transform 80ms ease; + } + button:hover { + background: var(--ngaf-chat-surface-alt); + border-color: var(--ngaf-chat-text-muted); + } + button:active { transform: translateY(1px); } + `, + ], + template: ` + + `, +}) +export class ChatDebugActionComponent { + readonly label = input.required(); + readonly clicked = output(); +} 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..4926a41d6 --- /dev/null +++ b/libs/chat/src/lib/compositions/chat-debug/primitives/chat-debug-section.component.ts @@ -0,0 +1,38 @@ +// 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-4); + border-bottom: 1px solid color-mix(in srgb, var(--ngaf-chat-separator) 60%, transparent); + } + :host:last-child { border-bottom: 0; } + .section__label { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--ngaf-chat-text-muted); + margin: 0 0 var(--ngaf-chat-space-3); + } + .section__body { display: flex; flex-direction: column; gap: var(--ngaf-chat-space-2); } + `, + ], + template: ` + @if (label()) { + + } +
+ `, +}) +export class ChatDebugSectionComponent { + readonly label = input(''); +} 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..73c89273a --- /dev/null +++ b/libs/chat/src/lib/compositions/chat-debug/primitives/chat-debug-segmented.component.ts @@ -0,0 +1,69 @@ +// 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: 3px; + background: var(--ngaf-chat-surface-alt); + border: 1px solid var(--ngaf-chat-separator); + border-radius: var(--ngaf-chat-radius-button); + width: 100%; + box-sizing: border-box; + } + .segmented__btn { + appearance: none; + border: 0; + background: transparent; + padding: 6px var(--ngaf-chat-space-3); + font: inherit; + font-size: var(--ngaf-chat-font-size-sm); + font-weight: 500; + color: var(--ngaf-chat-text-muted); + cursor: pointer; + border-radius: calc(var(--ngaf-chat-radius-button) - 3px); + flex: 1; + transition: color 120ms ease, background 120ms ease; + } + .segmented__btn:hover:not(.is-active) { color: var(--ngaf-chat-text); } + .segmented__btn.is-active { + background: var(--ngaf-chat-bg); + color: var(--ngaf-chat-text); + box-shadow: var(--ngaf-chat-shadow-sm), 0 0 0 1px var(--ngaf-chat-separator); + } + `, + ], + template: ` +
+ @for (opt of options(); track opt.value) { + + } +
+ `, +}) +export class ChatDebugSegmentedComponent { + readonly options = input.required(); + readonly value = input.required(); + readonly valueChange = output(); +} 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..9056dccf5 --- /dev/null +++ b/libs/chat/src/lib/compositions/chat-debug/primitives/chat-debug-select.component.ts @@ -0,0 +1,87 @@ +// 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-wrap { + position: relative; + flex: 1; + max-width: 60%; + } + select { + appearance: none; + -webkit-appearance: none; + width: 100%; + 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: 5px 26px 5px 10px; + font: inherit; + font-size: var(--ngaf-chat-font-size-sm); + cursor: pointer; + transition: border-color 120ms ease, background 120ms ease; + } + select:hover { border-color: var(--ngaf-chat-text-muted); } + select:focus { outline: none; border-color: var(--ngaf-chat-primary); } + .chevron { + position: absolute; + right: 8px; + top: 50%; + transform: translateY(-50%); + pointer-events: none; + color: var(--ngaf-chat-text-muted); + display: flex; + } + `, + ], + 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); + } +} 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(); +} 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'); }); +}); diff --git a/libs/chat/src/public-api.ts b/libs/chat/src/public-api.ts index 7031cd910..a6faf77ca 100644 --- a/libs/chat/src/public-api.ts +++ b/libs/chat/src/public-api.ts @@ -73,7 +73,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';