diff --git a/apps/website/content/docs/chat/api/api-docs.json b/apps/website/content/docs/chat/api/api-docs.json index 0966e427..bf1ba16a 100644 --- a/apps/website/content/docs/chat/api/api-docs.json +++ b/apps/website/content/docs/chat/api/api-docs.json @@ -3073,6 +3073,12 @@ "description": "", "optional": false }, + { + "name": "modelOptions", + "type": "InputSignal", + "description": "Forwarded to the inner . When non-empty, a model picker pill\nrenders in the chat-input chrome.", + "optional": false + }, { "name": "open", "type": "ModelSignal", @@ -3085,6 +3091,12 @@ "description": "", "optional": false }, + { + "name": "selectedModel", + "type": "ModelSignal", + "description": "Two-way bound current model value.", + "optional": false + }, { "name": "shortcut", "type": "InputSignal", @@ -3556,6 +3568,12 @@ "description": "", "optional": false }, + { + "name": "modelOptions", + "type": "InputSignal", + "description": "Forwarded to the inner . When non-empty, a model picker pill\nrenders in the chat-input chrome.", + "optional": false + }, { "name": "open", "type": "ModelSignal", @@ -3574,6 +3592,12 @@ "description": "", "optional": false }, + { + "name": "selectedModel", + "type": "ModelSignal", + "description": "Two-way bound current model value.", + "optional": false + }, { "name": "views", "type": "InputSignal | RenderViewEntry>> | undefined>", diff --git a/docs/superpowers/plans/2026-05-13-chat-model-picker-wiring.md b/docs/superpowers/plans/2026-05-13-chat-model-picker-wiring.md new file mode 100644 index 00000000..9abf7dbf --- /dev/null +++ b/docs/superpowers/plans/2026-05-13-chat-model-picker-wiring.md @@ -0,0 +1,181 @@ +# Chat Model Picker Wiring 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:** Surface the `` composition's existing model picker (a `chat-select` already wired into the `[chatInputModelSelect]` slot when `[modelOptions]` is non-empty) in the smoke test app's three mode components. + +**Architecture:** No new primitive. The `` composition (in `libs/chat`) already exposes `[modelOptions]` and `[(selectedModel)]` inputs. Each smoke-app mode component (`embed-mode`, `popup-mode`, `sidebar-mode`) currently uses `` without those inputs, so the pill never renders. Wire each mode to pass them through from the demo shell's existing `model` signal and `modelOptions` signal. ~10 lines of code across 4 files (3 mode templates + 1 visibility change on `DemoShell.modelOptions`). + +**Tech Stack:** Angular 21 standalone components, signal two-way binding (`model()` → `[(selectedModel)]`), no test changes (the `chat-select` primitive is already covered upstream). + +--- + +## Background (why this changed) + +An earlier draft of this work created a new `ChatModelPickerComponent` primitive. That was redundant: `libs/chat/src/lib/primitives/chat-select/chat-select.component.ts` already implements the same UX (pill trigger + popover listbox, keyboard nav, outside-click), and `chat/composition` projects it into the `chatInputModelSelect` slot whenever `[modelOptions]` is set. The actual missing work is consumer wiring, not framework primitive work. The earlier commits were reset. + +--- + +### Task 1: Make `DemoShell.modelOptions` public + wire the three modes + +**Files:** +- Modify: `examples/chat/angular/src/app/shell/demo-shell.component.ts:244` (visibility of `modelOptions`) +- Modify: `examples/chat/angular/src/app/modes/embed-mode.component.ts` (template) +- Modify: `examples/chat/angular/src/app/modes/popup-mode.component.ts` (template) +- Modify: `examples/chat/angular/src/app/modes/sidebar-mode.component.ts` (template) + +**Context:** + +`DemoShell.model` is already `readonly` (public) so modes access it via `shell.model`. `DemoShell.modelOptions` is currently `protected readonly`, which blocks template access from the mode components. Drop the `protected` qualifier (mirror the visibility of `model`). + +The `` composition's contract: +- `[modelOptions]: readonly ChatSelectOption[]` — pass a non-empty array to make the pill render. +- `[(selectedModel)]: string` — two-way binding; reads the current selection, writes when the user picks. This wires straight to `shell.model` (a `signal`). + +The existing `onModelChange` handler on `DemoShell` persists the value to localStorage and is invoked by the debug-panel dropdown via `(valueChange)`. The new pill writes the signal directly (via `[(selectedModel)]`), bypassing the handler — so persistence is lost on this code path. Fix by using an effect or by switching the wiring to one-way + (selectedModelChange). For YAGNI, do the simpler form: `[selectedModel]="shell.model()"` + `(selectedModelChange)="shell.onModelChange($event)"`. + +--- + +- [ ] **Step 1: Make `modelOptions` public on `DemoShell`** + +In `examples/chat/angular/src/app/shell/demo-shell.component.ts` around line 244, change: + +```ts + protected readonly modelOptions = signal([ +``` + +to: + +```ts + readonly modelOptions = signal([ +``` + +(Single keyword removal. Confirm no other references to `modelOptions` need updating — the existing debug-panel template binding `[options]="modelOptions()"` still works from inside the class.) + +- [ ] **Step 2: Wire `embed-mode.component.ts`** + +In `examples/chat/angular/src/app/modes/embed-mode.component.ts`, find the `` opening tag (currently line 14-19): + +```html + +``` + +Replace with: + +```html + +``` + +- [ ] **Step 3: Wire `popup-mode.component.ts`** + +In `examples/chat/angular/src/app/modes/popup-mode.component.ts`, find the `` opening tag and apply the same three new attributes (`[modelOptions]`, `[selectedModel]`, `(selectedModelChange)`) as in Step 2. + +- [ ] **Step 4: Wire `sidebar-mode.component.ts`** + +In `examples/chat/angular/src/app/modes/sidebar-mode.component.ts`, find the `` opening tag and apply the same three new attributes as in Step 2. + +- [ ] **Step 5: Build the smoke app to verify wiring** + +``` +npx nx build examples-chat-angular --configuration=development +``` + +Expected: build succeeds. No TypeScript errors. If `shell.modelOptions` is reported inaccessible, re-check Step 1. + +- [ ] **Step 6: Run the chat test suite as a regression check** + +``` +cd libs/chat && npx vitest run +``` + +Expected: all chat specs pass. Nothing in `libs/chat` changed, so this should be a no-op pass. + +- [ ] **Step 7: Manual smoke (if dev server available)** + +If the implementer can run the dev server, perform this. Otherwise document the steps in the PR description for the reviewer. + +1. Start the smoke app, open in the browser. +2. **Embed mode (default):** Verify the model pill appears bottom-left of the chat input. Pill label reads `gpt-5-mini` (the default). +3. Click the pill → popover listbox opens with `gpt-5`, `gpt-5-mini`, `gpt-5-nano`. Current value highlighted. +4. Pick `gpt-5`. Pill updates. Open the debug panel → `Agent → Model` dropdown shows `gpt-5` (sync verified). +5. Reload the page. The pill still reads `gpt-5` (persistence via `onModelChange` works). +6. Switch to **Popup mode** via debug panel → confirm pill is visible in popup chat input. Repeat selection. +7. Switch to **Sidebar mode** → confirm pill is visible there too. +8. Send a message in any mode — confirm the agent uses the selected model (Network tab: request body includes the chosen model). + +- [ ] **Step 8: Commit** + +```bash +git add examples/chat/angular/src/app/shell/demo-shell.component.ts \ + examples/chat/angular/src/app/modes/embed-mode.component.ts \ + examples/chat/angular/src/app/modes/popup-mode.component.ts \ + examples/chat/angular/src/app/modes/sidebar-mode.component.ts +git commit -m "$(cat <<'EOF' +feat(examples-chat): expose model picker pill in all three modes + +The composition already projects a chat-select into the +[chatInputModelSelect] slot when [modelOptions] is non-empty, but +the smoke app's mode components never passed it through. Wire each +mode to shell.modelOptions + shell.model with persistence via +shell.onModelChange. + +Drop `protected` from DemoShell.modelOptions so mode templates can +read it; visibility now mirrors `model` (already public). + +No new primitive — chat-select handles the UI. + +EOF +)" +``` + +- [ ] **Step 9: Push and open PR** + +```bash +git push -u origin claude/chat-model-picker +gh pr create --title "feat(examples-chat): expose model picker pill in all three modes" --body "$(cat <<'EOF' +## Summary + +- Wires the existing chat-select model picker (already rendered by `` when `[modelOptions]` is non-empty) into the smoke app's `embed-mode`, `popup-mode`, and `sidebar-mode`. +- Pill reads `shell.model()` and writes back via `(selectedModelChange) → shell.onModelChange()` so localStorage persistence keeps working. +- Drops the `protected` qualifier on `DemoShell.modelOptions` so mode templates can read it. + +No new framework primitive — the underlying `chat-select` is already shipped. + +## Plan + +- `docs/superpowers/plans/2026-05-13-chat-model-picker-wiring.md` + +## Test plan + +- [x] Library tests pass (no `libs/chat` changes) +- [x] Smoke app builds +- [ ] Manual: + - [ ] Embed mode: pill visible bottom-left, opens, selects, syncs with debug-panel dropdown, persists across reload + - [ ] Popup mode: same + - [ ] Sidebar mode: same + - [ ] Submitting a message uses the selected model +EOF +)" +``` + +--- + +## Self-review notes + +- **Spec coverage:** the work was reduced to consumer wiring; the (previously written) spec document is deliberately not re-saved — its content is now misleading. Plan documents the actual ~10-line change with full code. +- **No placeholders:** every code block is final content. +- **Type consistency:** `modelOptions()` returns `readonly { value: string; label: string }[]` which satisfies `ChatSelectOption[]` (compatible structural typing — verify with the build step). +- **Persistence:** explicitly wired via `(selectedModelChange)="shell.onModelChange($event)"` rather than `[(selectedModel)]="shell.model"` because the existing `onModelChange` handler writes to localStorage, and the two-way binding form would bypass it. diff --git a/examples/chat/angular/src/app/modes/embed-mode.component.ts b/examples/chat/angular/src/app/modes/embed-mode.component.ts index 35259062..bf4a37ff 100644 --- a/examples/chat/angular/src/app/modes/embed-mode.component.ts +++ b/examples/chat/angular/src/app/modes/embed-mode.component.ts @@ -14,6 +14,9 @@ import { WELCOME_SUGGESTIONS } from './welcome-suggestions'; diff --git a/examples/chat/angular/src/app/modes/popup-mode.component.ts b/examples/chat/angular/src/app/modes/popup-mode.component.ts index 902336f4..8c62510a 100644 --- a/examples/chat/angular/src/app/modes/popup-mode.component.ts +++ b/examples/chat/angular/src/app/modes/popup-mode.component.ts @@ -19,6 +19,9 @@ import { WELCOME_SUGGESTIONS } from './welcome-suggestions'; diff --git a/examples/chat/angular/src/app/modes/sidebar-mode.component.ts b/examples/chat/angular/src/app/modes/sidebar-mode.component.ts index 8dedd5e7..003e72fd 100644 --- a/examples/chat/angular/src/app/modes/sidebar-mode.component.ts +++ b/examples/chat/angular/src/app/modes/sidebar-mode.component.ts @@ -19,6 +19,9 @@ import { WELCOME_SUGGESTIONS } from './welcome-suggestions'; 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 0cf235ed..d111f974 100644 --- a/examples/chat/angular/src/app/shell/demo-shell.component.ts +++ b/examples/chat/angular/src/app/shell/demo-shell.component.ts @@ -241,7 +241,7 @@ export class DemoShell { { value: 'sidebar', label: 'Sidebar' }, ] as const; - protected readonly modelOptions = signal([ + readonly modelOptions = signal([ { value: 'gpt-5', label: 'gpt-5' }, { value: 'gpt-5-mini', label: 'gpt-5-mini' }, { value: 'gpt-5-nano', label: 'gpt-5-nano' }, @@ -363,7 +363,7 @@ export class DemoShell { void this.router.navigate(['/' + next]); } - protected onModelChange(next: string): void { + onModelChange(next: string): void { this.model.set(next); this.persistence.write('model', next); } diff --git a/libs/chat/src/lib/compositions/chat-popup/chat-popup.component.ts b/libs/chat/src/lib/compositions/chat-popup/chat-popup.component.ts index 53bd5378..f218e12d 100644 --- a/libs/chat/src/lib/compositions/chat-popup/chat-popup.component.ts +++ b/libs/chat/src/lib/compositions/chat-popup/chat-popup.component.ts @@ -4,6 +4,7 @@ import { Component, ChangeDetectionStrategy, input, model, output, DestroyRef, i import type { Agent } from '../../agent'; import type { ViewRegistry } from '@ngaf/render'; import { ChatComponent } from '../chat/chat.component'; +import type { ChatSelectOption } from '../../primitives/chat-select/chat-select.component'; import { ChatLauncherButtonComponent } from '../../primitives/chat-launcher-button/chat-launcher-button.component'; import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens'; @@ -67,6 +68,9 @@ import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens'; @@ -81,6 +85,11 @@ export class ChatPopupComponent { * messages classified as A2UI parse correctly but never mount a * surface. Pass `a2uiBasicCatalog()` from `@ngaf/chat`. */ readonly views = input(undefined); + /** Forwarded to the inner . When non-empty, a model picker pill + * renders in the chat-input chrome. */ + readonly modelOptions = input([]); + /** Two-way bound current model value. */ + readonly selectedModel = model(''); readonly open = model(false); readonly replayRequested = output(); readonly forkRequested = output(); diff --git a/libs/chat/src/lib/compositions/chat-sidebar/chat-sidebar.component.ts b/libs/chat/src/lib/compositions/chat-sidebar/chat-sidebar.component.ts index cd27a4d3..36264bb5 100644 --- a/libs/chat/src/lib/compositions/chat-sidebar/chat-sidebar.component.ts +++ b/libs/chat/src/lib/compositions/chat-sidebar/chat-sidebar.component.ts @@ -4,6 +4,7 @@ import { Component, ChangeDetectionStrategy, input, model, output } from '@angul import type { Agent } from '../../agent'; import type { ViewRegistry } from '@ngaf/render'; import { ChatComponent } from '../chat/chat.component'; +import type { ChatSelectOption } from '../../primitives/chat-select/chat-select.component'; import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens'; @Component({ @@ -60,6 +61,9 @@ import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens'; @@ -74,6 +78,11 @@ export class ChatSidebarComponent { * messages classified as A2UI parse correctly but never mount a * surface. Pass `a2uiBasicCatalog()` from `@ngaf/chat`. */ readonly views = input(undefined); + /** Forwarded to the inner . When non-empty, a model picker pill + * renders in the chat-input chrome. */ + readonly modelOptions = input([]); + /** Two-way bound current model value. */ + readonly selectedModel = model(''); readonly open = model(false); readonly pushContent = input(false); readonly replayRequested = output();