Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions apps/website/content/docs/chat/api/api-docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -3073,6 +3073,12 @@
"description": "",
"optional": false
},
{
"name": "modelOptions",
"type": "InputSignal<readonly ChatSelectOption[]>",
"description": "Forwarded to the inner <chat>. When non-empty, a model picker pill\nrenders in the chat-input chrome.",
"optional": false
},
{
"name": "open",
"type": "ModelSignal<boolean>",
Expand All @@ -3085,6 +3091,12 @@
"description": "",
"optional": false
},
{
"name": "selectedModel",
"type": "ModelSignal<string>",
"description": "Two-way bound current model value.",
"optional": false
},
{
"name": "shortcut",
"type": "InputSignal<string | null>",
Expand Down Expand Up @@ -3556,6 +3568,12 @@
"description": "",
"optional": false
},
{
"name": "modelOptions",
"type": "InputSignal<readonly ChatSelectOption[]>",
"description": "Forwarded to the inner <chat>. When non-empty, a model picker pill\nrenders in the chat-input chrome.",
"optional": false
},
{
"name": "open",
"type": "ModelSignal<boolean>",
Expand All @@ -3574,6 +3592,12 @@
"description": "",
"optional": false
},
{
"name": "selectedModel",
"type": "ModelSignal<string>",
"description": "Two-way bound current model value.",
"optional": false
},
{
"name": "views",
"type": "InputSignal<Readonly<Record<string, Type<unknown> | RenderViewEntry>> | undefined>",
Expand Down
181 changes: 181 additions & 0 deletions docs/superpowers/plans/2026-05-13-chat-model-picker-wiring.md
Original file line number Diff line number Diff line change
@@ -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 `<chat>` 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 `<chat>` composition (in `libs/chat`) already exposes `[modelOptions]` and `[(selectedModel)]` inputs. Each smoke-app mode component (`embed-mode`, `popup-mode`, `sidebar-mode`) currently uses `<chat>` 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 `<chat>` 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<string>`).

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<readonly { value: string; label: string }[]>([
```

to:

```ts
readonly modelOptions = signal<readonly { value: string; label: string }[]>([
```

(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 `<chat>` opening tag (currently line 14-19):

```html
<chat
[agent]="agent"
[views]="catalog"
(replayRequested)="shell.onTimelineReplay($event)"
(forkRequested)="shell.onTimelineFork($event)"
>
```

Replace with:

```html
<chat
[agent]="agent"
[views]="catalog"
[modelOptions]="shell.modelOptions()"
[selectedModel]="shell.model()"
(selectedModelChange)="shell.onModelChange($event)"
(replayRequested)="shell.onTimelineReplay($event)"
(forkRequested)="shell.onTimelineFork($event)"
>
```

- [ ] **Step 3: Wire `popup-mode.component.ts`**

In `examples/chat/angular/src/app/modes/popup-mode.component.ts`, find the `<chat>` 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 `<chat>` 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 <chat> 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 `<chat>` 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.
3 changes: 3 additions & 0 deletions examples/chat/angular/src/app/modes/embed-mode.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ import { WELCOME_SUGGESTIONS } from './welcome-suggestions';
<chat
[agent]="agent"
[views]="catalog"
[modelOptions]="shell.modelOptions()"
[selectedModel]="shell.model()"
(selectedModelChange)="shell.onModelChange($event)"
(replayRequested)="shell.onTimelineReplay($event)"
(forkRequested)="shell.onTimelineFork($event)"
>
Expand Down
3 changes: 3 additions & 0 deletions examples/chat/angular/src/app/modes/popup-mode.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ import { WELCOME_SUGGESTIONS } from './welcome-suggestions';
<chat-popup
[agent]="agent"
[views]="catalog"
[modelOptions]="shell.modelOptions()"
[selectedModel]="shell.model()"
(selectedModelChange)="shell.onModelChange($event)"
(replayRequested)="shell.onTimelineReplay($event)"
(forkRequested)="shell.onTimelineFork($event)"
>
Expand Down
3 changes: 3 additions & 0 deletions examples/chat/angular/src/app/modes/sidebar-mode.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ import { WELCOME_SUGGESTIONS } from './welcome-suggestions';
<chat-sidebar
[agent]="agent"
[views]="catalog"
[modelOptions]="shell.modelOptions()"
[selectedModel]="shell.model()"
(selectedModelChange)="shell.onModelChange($event)"
(replayRequested)="shell.onTimelineReplay($event)"
(forkRequested)="shell.onTimelineFork($event)"
>
Expand Down
4 changes: 2 additions & 2 deletions examples/chat/angular/src/app/shell/demo-shell.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,7 @@ export class DemoShell {
{ value: 'sidebar', label: 'Sidebar' },
] as const;

protected readonly modelOptions = signal<readonly { value: string; label: string }[]>([
readonly modelOptions = signal<readonly { value: string; label: string }[]>([
{ value: 'gpt-5', label: 'gpt-5' },
{ value: 'gpt-5-mini', label: 'gpt-5-mini' },
{ value: 'gpt-5-nano', label: 'gpt-5-nano' },
Expand Down Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -67,6 +68,9 @@ import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens';
<chat
[agent]="agent()"
[views]="views()"
[modelOptions]="modelOptions()"
[selectedModel]="selectedModel()"
(selectedModelChange)="selectedModel.set($event)"
(replayRequested)="replayRequested.emit($event)"
(forkRequested)="forkRequested.emit($event)"
>
Expand All @@ -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<ViewRegistry | undefined>(undefined);
/** Forwarded to the inner <chat>. When non-empty, a model picker pill
* renders in the chat-input chrome. */
readonly modelOptions = input<readonly ChatSelectOption[]>([]);
/** Two-way bound current model value. */
readonly selectedModel = model<string>('');
readonly open = model(false);
readonly replayRequested = output<string>();
readonly forkRequested = output<string>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -60,6 +61,9 @@ import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens';
<chat
[agent]="agent()"
[views]="views()"
[modelOptions]="modelOptions()"
[selectedModel]="selectedModel()"
(selectedModelChange)="selectedModel.set($event)"
(replayRequested)="replayRequested.emit($event)"
(forkRequested)="forkRequested.emit($event)"
>
Expand All @@ -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<ViewRegistry | undefined>(undefined);
/** Forwarded to the inner <chat>. When non-empty, a model picker pill
* renders in the chat-input chrome. */
readonly modelOptions = input<readonly ChatSelectOption[]>([]);
/** Two-way bound current model value. */
readonly selectedModel = model<string>('');
readonly open = model(false);
readonly pushContent = input<boolean>(false);
readonly replayRequested = output<string>();
Expand Down
Loading