diff --git a/cockpit/chat/generative-ui/angular/src/app/generative-ui.component.ts b/cockpit/chat/generative-ui/angular/src/app/generative-ui.component.ts index ff7485f59..bce0c778b 100644 --- a/cockpit/chat/generative-ui/angular/src/app/generative-ui.component.ts +++ b/cockpit/chat/generative-ui/angular/src/app/generative-ui.component.ts @@ -1,6 +1,6 @@ // SPDX-License-Identifier: MIT import { Component } from '@angular/core'; -import { ChatComponent, views } from '@ngaf/chat'; +import { ChatComponent, ChatWelcomeSuggestionComponent, views } from '@ngaf/chat'; import { agent } from '@ngaf/langgraph'; import { ExampleChatLayoutComponent } from '@ngaf/example-layouts'; import { environment } from '../environments/environment'; @@ -21,13 +21,28 @@ const dashboardViews = views({ data_grid: DataGridComponent, }); +const WELCOME_SUGGESTIONS = [ + { label: 'Render a dashboard', value: 'Show me a Q3 sales dashboard with three metrics.' }, + { label: 'Render a form', value: 'Create a contact form with name, email, and message.' }, +] as const; + @Component({ selector: 'app-generative-ui', standalone: true, - imports: [ChatComponent, ExampleChatLayoutComponent], + imports: [ChatComponent, ChatWelcomeSuggestionComponent, ExampleChatLayoutComponent], template: ` - + +
+ @for (s of suggestions; track s.value) { + + } +
+
`, }) @@ -37,4 +52,9 @@ export class GenerativeUiComponent { assistantId: environment.generativeUiAssistantId, }); protected readonly dashboardViews = dashboardViews; + protected readonly suggestions = WELCOME_SUGGESTIONS; + + protected send(text: string): void { + void this.agent.submit({ message: text }); + } } diff --git a/cockpit/langgraph/interrupts/angular/src/app/interrupts.component.ts b/cockpit/langgraph/interrupts/angular/src/app/interrupts.component.ts index 0067a4759..224ae1923 100644 --- a/cockpit/langgraph/interrupts/angular/src/app/interrupts.component.ts +++ b/cockpit/langgraph/interrupts/angular/src/app/interrupts.component.ts @@ -1,12 +1,16 @@ // SPDX-License-Identifier: MIT import { Component } from '@angular/core'; -import { ChatComponent, ChatInterruptPanelComponent, views, type InterruptAction } from '@ngaf/chat'; +import { ChatComponent, ChatInterruptPanelComponent, ChatWelcomeSuggestionComponent, views, type InterruptAction } from '@ngaf/chat'; import { agent } from '@ngaf/langgraph'; import { ExampleChatLayoutComponent } from '@ngaf/example-layouts'; import { signalStateStore } from '@ngaf/render'; import { environment } from '../environments/environment'; import { ApprovalCardComponent } from './views/approval-card.component'; +const WELCOME_SUGGESTIONS = [ + { label: 'Approve a tool call', value: 'Book a flight to Paris for next Tuesday.' }, +] as const; + /** * InterruptsComponent demonstrates human-in-the-loop with `agent()`. * @@ -22,11 +26,21 @@ import { ApprovalCardComponent } from './views/approval-card.component'; @Component({ selector: 'app-interrupts', standalone: true, - imports: [ChatComponent, ChatInterruptPanelComponent, ExampleChatLayoutComponent], + imports: [ChatComponent, ChatInterruptPanelComponent, ChatWelcomeSuggestionComponent, ExampleChatLayoutComponent], template: `
- + +
+ @for (s of suggestions; track s.value) { + + } +
+
@if (agent.interrupt()) {
@@ -39,6 +53,11 @@ import { ApprovalCardComponent } from './views/approval-card.component'; export class InterruptsComponent { readonly ui = views({ 'approval-card': ApprovalCardComponent }); readonly uiStore = signalStateStore({}); + protected readonly suggestions = WELCOME_SUGGESTIONS; + + protected send(text: string): void { + void this.agent.submit({ message: text }); + } /** * The streaming resource with interrupt support. diff --git a/cockpit/langgraph/persistence/angular/src/app/persistence.component.ts b/cockpit/langgraph/persistence/angular/src/app/persistence.component.ts index e3d97e0fb..900e60dff 100644 --- a/cockpit/langgraph/persistence/angular/src/app/persistence.component.ts +++ b/cockpit/langgraph/persistence/angular/src/app/persistence.component.ts @@ -1,9 +1,14 @@ +// SPDX-License-Identifier: MIT import { Component, signal } from '@angular/core'; -import { ChatComponent } from '@ngaf/chat'; +import { ChatComponent, ChatWelcomeSuggestionComponent } from '@ngaf/chat'; import { agent } from '@ngaf/langgraph'; import { ExampleChatLayoutComponent } from '@ngaf/example-layouts'; import { environment } from '../environments/environment'; +const WELCOME_SUGGESTIONS = [ + { label: 'Save this thread for later', value: 'Help me draft a project brief I can revisit.' }, +] as const; + interface Thread { id: string; label: string; @@ -23,10 +28,20 @@ interface Thread { @Component({ selector: 'app-persistence', standalone: true, - imports: [ChatComponent, ExampleChatLayoutComponent], + imports: [ChatComponent, ChatWelcomeSuggestionComponent, ExampleChatLayoutComponent], template: ` - + +
+ @for (s of suggestions; track s.value) { + + } +
+
([]); protected readonly activeThreadId = signal(null); + protected readonly suggestions = WELCOME_SUGGESTIONS; private threadCounter = 0; + protected send(text: string): void { + void this.agent.submit({ message: text }); + } + /** * The streaming resource with thread persistence. * diff --git a/cockpit/langgraph/streaming/angular/src/app/streaming.component.ts b/cockpit/langgraph/streaming/angular/src/app/streaming.component.ts index 0141a9e80..c5e800297 100644 --- a/cockpit/langgraph/streaming/angular/src/app/streaming.component.ts +++ b/cockpit/langgraph/streaming/angular/src/app/streaming.component.ts @@ -1,10 +1,15 @@ // SPDX-License-Identifier: MIT import { Component } from '@angular/core'; -import { ChatComponent } from '@ngaf/chat'; +import { ChatComponent, ChatWelcomeSuggestionComponent } from '@ngaf/chat'; import { agent } from '@ngaf/langgraph'; import { ExampleChatLayoutComponent } from '@ngaf/example-layouts'; import { environment } from '../environments/environment'; +const WELCOME_SUGGESTIONS = [ + { label: 'Stream a long answer', value: 'Explain LangGraph checkpointing in 200 words.' }, + { label: 'Walk me through agent tool calls', value: 'Show me how an agent decides which tool to use.' }, +] as const; + /** * Streaming demo — simplest possible @ngaf/chat integration. * @@ -15,10 +20,20 @@ import { environment } from '../environments/environment'; @Component({ selector: 'app-streaming', standalone: true, - imports: [ChatComponent, ExampleChatLayoutComponent], + imports: [ChatComponent, ChatWelcomeSuggestionComponent, ExampleChatLayoutComponent], template: ` - + +
+ @for (s of suggestions; track s.value) { + + } +
+
`, }) @@ -27,4 +42,9 @@ export class StreamingComponent { apiUrl: environment.langGraphApiUrl, assistantId: environment.streamingAssistantId, }); + protected readonly suggestions = WELCOME_SUGGESTIONS; + + protected send(text: string): void { + void this.agent.submit({ message: text }); + } } diff --git a/docs/superpowers/plans/gtm/2026-05-16-cockpit-activation-recipes.md b/docs/superpowers/plans/gtm/2026-05-16-cockpit-activation-recipes.md new file mode 100644 index 000000000..fa77c6ef0 --- /dev/null +++ b/docs/superpowers/plans/gtm/2026-05-16-cockpit-activation-recipes.md @@ -0,0 +1,670 @@ +# Spec 4 — Cockpit Activation Recipes 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:** Tighten `ChatComponent` so `CHAT_LIFECYCLE.firstMessageSent` flips on any submit path, and drop pre-baked `` rows on the four cockpit capability examples that map to activation signals. + +**Architecture:** One small effect added inside `ChatComponent` watches `agent.lifecycle.streamStartedAt`; on its first transition to non-null, it flips the sticky `firstMessageSent` flag. Four cockpit example components (streaming, persistence, interrupts, generative-ui) gain a `WELCOME_SUGGESTIONS` const and project `` rows into the `` welcome slot using the canonical pattern from `examples/chat/angular/src/app/modes/embed-mode.component.ts`. No new analytics events. + +**Tech Stack:** Angular 20+ (Signals + `effect()`); `@ngaf/chat` (`ChatComponent`, `ChatWelcomeSuggestionComponent`); `@ngaf/langgraph` (`agent()`); Vitest + jsdom for unit tests (`npx nx run chat:test`). + +--- + +## Context for the implementer + +- **Spec:** `docs/superpowers/specs/gtm/2026-05-16-cockpit-activation-recipes-design.md` — read §§3–6 before starting. +- **Canonical suggestion pattern:** `examples/chat/angular/src/app/modes/embed-mode.component.ts`. Mirror it. +- **Lifecycle background:** Spec 1C (`docs/superpowers/specs/gtm/2026-05-15-analytics-foundation-1c-cockpit-instrumentation-design.md` §5) defines the three `*_LIFECYCLE` tokens. `firstMessageSent` is documented as sticky for the life of the chat instance. +- **`agent.lifecycle.streamStartedAt`:** comes from `AGENT_LIFECYCLE` in `@ngaf/langgraph` (Spec 1C Task 0.4). Available on every `agent()` return as `agentRef.lifecycle.streamStartedAt`. It's a `Signal` that flips to `Date.now()` on the first stream chunk and resets to `null` on `switchThread(null)`. +- **No backend dependency for the new tests.** The chat lib test uses `mockAgent()` from `libs/chat/src/lib/testing/mock-agent.ts`, which already exposes a writable lifecycle. +- **TDD discipline:** spec → run-see-fail → implement → run-see-pass → commit. +- **Commit format:** conventional commits, one task = one commit. +- **Worktree branch:** `gtm-spec-4-cockpit-activation-recipes` (already created from `origin/main`, currently at `a2bbcbf9`). + +## File structure (locked) + +``` +MODIFIED +├── libs/chat/src/lib/compositions/chat/chat.component.ts # Phase 0 — new effect +├── libs/chat/src/lib/lifecycle.spec.ts # Phase 0 — new test case +├── cockpit/langgraph/streaming/angular/src/app/streaming.component.ts # Phase 1 +├── cockpit/langgraph/persistence/angular/src/app/persistence.component.ts # Phase 2 +├── cockpit/langgraph/interrupts/angular/src/app/interrupts.component.ts # Phase 3 +├── cockpit/chat/generative-ui/angular/src/app/generative-ui.component.ts # Phase 4 +``` + +No new files. No deletions. + +--- + +## Phase 0 — Tighten `ChatComponent.firstMessageSent` + +### Task 0.1: TDD — add lifecycle test for agent-driven submit + +**Files:** +- Modify: `libs/chat/src/lib/lifecycle.spec.ts` + +- [ ] **Step 1: Read the existing spec** + +Open `libs/chat/src/lib/lifecycle.spec.ts`. Note the fixture setup pattern: `TestBed.createComponent(ChatComponent)` + `setInput('agent', mockAgent())` + `fixture.detectChanges()` + `injector.get(CHAT_LIFECYCLE)`. + +- [ ] **Step 2: Inspect `mockAgent` so you know how to drive its lifecycle** + +```bash +cat libs/chat/src/lib/testing/mock-agent.ts +``` + +You need to know: +- Does `mockAgent()` return an object whose `lifecycle.streamStartedAt` is a writable signal? Or is the lifecycle stubbed read-only? +- If writable: the test can do `agentRef.lifecycle._internal.streamStartedAt.set(Date.now())` or similar. +- If stubbed: you may need to call `agentRef.submit({...})` to drive the signal, and rely on the mock's internal submit handler. + +Match the test approach to the actual shape of `mockAgent`. If the mock doesn't expose a way to drive `streamStartedAt`, ask before guessing — the mock may need a small enhancement (out of scope or in scope, your call). + +- [ ] **Step 3: Write the failing test** + +Append to `libs/chat/src/lib/lifecycle.spec.ts` inside the `describe('ChatLifecycle integration', ...)` block (after the existing tests): + +```typescript + test('firstMessageSent flips when agent.lifecycle.streamStartedAt transitions to non-null', () => { + // Capture the agent we passed in so we can drive its lifecycle directly. + const agent = mockAgent(); + const f = TestBed.createComponent(ChatComponent); + f.componentRef.setInput('agent', agent); + f.detectChanges(); + const lc = f.componentRef.injector.get(CHAT_LIFECYCLE); + expect(lc.firstMessageSent()).toBe(false); + + // Drive the agent's streamStartedAt signal. Exact API depends on mockAgent's + // shape — use whichever helper the mock exposes. Common patterns: + // agent._internal.streamStartedAt.set(Date.now()) // direct setter + // agent.simulateStreamStart() // helper method + // await agent.submit({ message: 'hi' }) // real submit path + // Use the path that exists; if none does, enhance mockAgent (small). + agent._internal.streamStartedAt.set(Date.now()); + f.detectChanges(); + expect(lc.firstMessageSent()).toBe(true); + }); + + test('firstMessageSent stays sticky across multiple agent-driven transitions', () => { + const agent = mockAgent(); + const f = TestBed.createComponent(ChatComponent); + f.componentRef.setInput('agent', agent); + f.detectChanges(); + const lc = f.componentRef.injector.get(CHAT_LIFECYCLE); + + agent._internal.streamStartedAt.set(Date.now()); + f.detectChanges(); + expect(lc.firstMessageSent()).toBe(true); + + // A second stream-start (e.g., second submit) must not unset. + agent._internal.streamStartedAt.set(null); + f.detectChanges(); + expect(lc.firstMessageSent()).toBe(true); + + agent._internal.streamStartedAt.set(Date.now()); + f.detectChanges(); + expect(lc.firstMessageSent()).toBe(true); + }); +``` + +- [ ] **Step 4: Run, see fail** + +```bash +npx nx run chat:test -- --testPathPattern=lifecycle.spec +``` + +Expected: the two new tests fail (firstMessageSent stays false even after streamStartedAt is set), because the effect doesn't exist yet. + +- [ ] **Step 5: NO COMMIT** — Task 0.2 implements the effect. + +### Task 0.2: Implement the agent-stream-start effect + +**Files:** +- Modify: `libs/chat/src/lib/compositions/chat/chat.component.ts` + +- [ ] **Step 1: Read the existing constructor / init effect** + +In `libs/chat/src/lib/compositions/chat/chat.component.ts`, find the existing constructor and the effect that flips `lifecycle._internal.componentReady.set(true)`. That's where the new effect lives (alongside it, not inside it). + +- [ ] **Step 2: Add the new effect** + +Inside the constructor (after the existing init effect, or anywhere in the constructor body that runs in the injection context — `effect()` requires it), add: + +```typescript +// Spec 4: flip CHAT_LIFECYCLE.firstMessageSent when the agent's stream +// starts, regardless of submit path (input-bound, programmatic, suggestion- +// click). Sticky — guarded so we never re-set a flag that's already true. +effect(() => { + const agentRef = this.agent(); + if (!agentRef) return; + const streamStartedAt = agentRef.lifecycle.streamStartedAt(); + if (streamStartedAt !== null && !this.lifecycle._internal.firstMessageSent()) { + this.lifecycle._internal.firstMessageSent.set(true); + } +}); +``` + +Confirm the imports already include `effect` from `@angular/core`. If not, add it. + +- [ ] **Step 3: Run, see pass** + +```bash +npx nx run chat:test -- --testPathPattern=lifecycle.spec +``` + +Expected: all lifecycle tests pass (existing + 2 new = ~8 tests). + +- [ ] **Step 4: Run the full chat test suite** + +```bash +npx nx run chat:test +``` + +Expected: green. The effect is idempotent and only writes when `firstMessageSent` is false, so existing tests stay correct. + +- [ ] **Step 5: Build** + +```bash +npx nx run chat:build +``` + +Expected: green. + +- [ ] **Step 6: Commit (Task 0.1 + Task 0.2 land together)** + +Two failing tests + implementation that makes them pass land as one commit so the history reads as a clean TDD slice. + +```bash +git add libs/chat/src/lib/compositions/chat/chat.component.ts libs/chat/src/lib/lifecycle.spec.ts +git commit -m "$(cat <<'EOF' +feat(chat): flip firstMessageSent on agent stream-start (any submit path) + +ChatComponent now subscribes to agent.lifecycle.streamStartedAt and +flips CHAT_LIFECYCLE.firstMessageSent on its first transition to a +non-null value. This makes the lifecycle robust to programmatic +agent.submit() calls, including the click +handler pattern in cockpit examples. + +messageCount and inputSubmittedAt remain input-bound by design — they +measure typing engagement, not stream initiation. + +Two new lifecycle tests cover the agent-driven flip + stickiness across +multiple stream-starts. + +Co-Authored-By: Claude Opus 4.7 +EOF +)" +``` + +--- + +## Phase 1 — Streaming capability suggestions + +### Task 1.1: Add `WELCOME_SUGGESTIONS` to `streaming.component.ts` + +**File:** `cockpit/langgraph/streaming/angular/src/app/streaming.component.ts` + +- [ ] **Step 1: Replace the component** + +Open the file. Current shape (verbatim from main): + +```typescript +import { Component } from '@angular/core'; +import { ChatComponent } from '@ngaf/chat'; +import { agent } from '@ngaf/langgraph'; +import { ExampleChatLayoutComponent } from '@ngaf/example-layouts'; +import { environment } from '../environments/environment'; + +@Component({ + selector: 'app-streaming', + standalone: true, + imports: [ChatComponent, ExampleChatLayoutComponent], + template: ` + + + + `, +}) +export class StreamingComponent { + protected readonly agent = agent({ + apiUrl: environment.langGraphApiUrl, + assistantId: environment.streamingAssistantId, + }); +} +``` + +Replace with: + +```typescript +// SPDX-License-Identifier: MIT +import { Component } from '@angular/core'; +import { ChatComponent, ChatWelcomeSuggestionComponent } from '@ngaf/chat'; +import { agent } from '@ngaf/langgraph'; +import { ExampleChatLayoutComponent } from '@ngaf/example-layouts'; +import { environment } from '../environments/environment'; + +const WELCOME_SUGGESTIONS = [ + { label: 'Stream a long answer', value: 'Explain LangGraph checkpointing in 200 words.' }, + { label: 'Walk me through agent tool calls', value: 'Show me how an agent decides which tool to use.' }, +] as const; + +/** + * Streaming demo — simplest possible @ngaf/chat integration. + * + * Creates an agent ref and passes it to the prebuilt composition. + * The welcome state projects pre-baked suggestion rows that drive + * cockpit:transport_connected + cockpit:chat_first_message activation + * signals on first click. + */ +@Component({ + selector: 'app-streaming', + standalone: true, + imports: [ChatComponent, ChatWelcomeSuggestionComponent, ExampleChatLayoutComponent], + template: ` + + +
+ @for (s of suggestions; track s.value) { + + } +
+
+
+ `, +}) +export class StreamingComponent { + protected readonly agent = agent({ + apiUrl: environment.langGraphApiUrl, + assistantId: environment.streamingAssistantId, + }); + + protected readonly suggestions = WELCOME_SUGGESTIONS; + + protected send(text: string): void { + void this.agent.submit({ message: text }); + } +} +``` + +- [ ] **Step 2: Verify build** + +```bash +npx nx run cockpit-langgraph-streaming-angular:build +npx nx run cockpit-langgraph-streaming-angular:build:cockpit +``` + +Expected: both green. + +- [ ] **Step 3: Commit** + +```bash +git add cockpit/langgraph/streaming/angular/src/app/streaming.component.ts +git commit -m "$(cat <<'EOF' +feat(cockpit-streaming): pre-baked welcome suggestions + +Two rows in the empty-state — "Stream a long +answer" and "Walk me through agent tool calls". Clicking either calls +agent.submit({ message: ... }) which fires AGENT_LIFECYCLE.streamStartedAt +on first chunk arrival → cockpit:transport_connected. ChatComponent's +new effect (Phase 0) then flips firstMessageSent → cockpit:chat_first_message. + +Co-Authored-By: Claude Opus 4.7 +EOF +)" +``` + +--- + +## Phase 2 — Persistence capability suggestions + +### Task 2.1: Add `WELCOME_SUGGESTIONS` to `persistence.component.ts` + +**File:** `cockpit/langgraph/persistence/angular/src/app/persistence.component.ts` + +- [ ] **Step 1: Open the file and locate the `` element** + +The current template has `` as a self-closing tag inside ``. The suggestion rows go inside the `` element, so the self-close needs to become a paired open/close. + +- [ ] **Step 2: Add imports** + +Add `ChatWelcomeSuggestionComponent` to the import from `@ngaf/chat`: + +```typescript +import { ChatComponent, ChatWelcomeSuggestionComponent } from '@ngaf/chat'; +``` + +Update the `imports:` array in the `@Component` decorator to include `ChatWelcomeSuggestionComponent`. + +- [ ] **Step 3: Add the `WELCOME_SUGGESTIONS` const above the class** + +```typescript +const WELCOME_SUGGESTIONS = [ + { label: 'Save this thread for later', value: 'Help me draft a project brief I can revisit.' }, +] as const; +``` + +- [ ] **Step 4: Update the template — change self-close to paired, project suggestions** + +Find `` and replace with: + +```html + +
+ @for (s of suggestions; track s.value) { + + } +
+
+``` + +- [ ] **Step 5: Add `suggestions` field + `send` method** + +In the `PersistenceComponent` class body, add: + +```typescript + protected readonly suggestions = WELCOME_SUGGESTIONS; + + protected send(text: string): void { + void this.agent.submit({ message: text }); + } +``` + +(Position them after the existing `agent` declaration. Mind the existing thread-list signal — don't displace it.) + +- [ ] **Step 6: Verify builds** + +```bash +npx nx run cockpit-langgraph-persistence-angular:build +npx nx run cockpit-langgraph-persistence-angular:build:cockpit +``` + +Expected: both green. + +- [ ] **Step 7: Commit** + +```bash +git add cockpit/langgraph/persistence/angular/src/app/persistence.component.ts +git commit -m "$(cat <<'EOF' +feat(cockpit-persistence): pre-baked welcome suggestion + +One row — "Save this thread for later" — +that prompts a project-brief conversation. After the user reloads the +page, AGENT_LIFECYCLE.threadPersistedAt fires → cockpit:thread_persisted +activation signal. + +Co-Authored-By: Claude Opus 4.7 +EOF +)" +``` + +--- + +## Phase 3 — Interrupts capability suggestions + +### Task 3.1: Add `WELCOME_SUGGESTIONS` to `interrupts.component.ts` + +**File:** `cockpit/langgraph/interrupts/angular/src/app/interrupts.component.ts` + +- [ ] **Step 1: Open the file and locate the `` element** + +The template has `` inside the layout `main` slot. Same self-close-to-paired transformation. + +- [ ] **Step 2: Add imports** + +```typescript +import { ChatComponent, ChatInterruptPanelComponent, ChatWelcomeSuggestionComponent, views, type InterruptAction } from '@ngaf/chat'; +``` + +Add `ChatWelcomeSuggestionComponent` to the `imports:` array. + +- [ ] **Step 3: Add `WELCOME_SUGGESTIONS` above the class** + +```typescript +const WELCOME_SUGGESTIONS = [ + { label: 'Approve a tool call', value: 'Book a flight to Paris for next Tuesday.' }, +] as const; +``` + +- [ ] **Step 4: Update the template — paired ``, project suggestions** + +Replace `` with: + +```html + +
+ @for (s of suggestions; track s.value) { + + } +
+
+``` + +The interrupt panel `@if (agent.interrupt()) { ... }` block stays where it is — outside ``. + +- [ ] **Step 5: Add `suggestions` + `send` to the class** + +```typescript + protected readonly suggestions = WELCOME_SUGGESTIONS; + + protected send(text: string): void { + void this.agent.submit({ message: text }); + } +``` + +- [ ] **Step 6: Verify builds** + +```bash +npx nx run cockpit-langgraph-interrupts-angular:build +npx nx run cockpit-langgraph-interrupts-angular:build:cockpit +``` + +Expected: both green. + +- [ ] **Step 7: Commit** + +```bash +git add cockpit/langgraph/interrupts/angular/src/app/interrupts.component.ts +git commit -m "$(cat <<'EOF' +feat(cockpit-interrupts): pre-baked welcome suggestion + +One row — "Approve a tool call" — that +prompts a flight-booking conversation. The graph pauses at an interrupt; +when the user approves via the ChatInterruptPanelComponent, +AGENT_LIFECYCLE.interruptResolvedAt fires → cockpit:interrupt_handled +activation signal. + +Co-Authored-By: Claude Opus 4.7 +EOF +)" +``` + +--- + +## Phase 4 — Generative-UI capability suggestions + +### Task 4.1: Add `WELCOME_SUGGESTIONS` to `generative-ui.component.ts` + +**File:** `cockpit/chat/generative-ui/angular/src/app/generative-ui.component.ts` + +- [ ] **Step 1: Update imports + class** + +Replace the current file with: + +```typescript +// SPDX-License-Identifier: MIT +import { Component } from '@angular/core'; +import { ChatComponent, ChatWelcomeSuggestionComponent, views } from '@ngaf/chat'; +import { agent } from '@ngaf/langgraph'; +import { ExampleChatLayoutComponent } from '@ngaf/example-layouts'; +import { environment } from '../environments/environment'; + +import { StatCardComponent } from './views/stat-card.component'; +import { ContainerComponent } from './views/container.component'; +import { DashboardGridComponent } from './views/dashboard-grid.component'; +import { LineChartComponent } from './views/line-chart.component'; +import { BarChartComponent } from './views/bar-chart.component'; +import { DataGridComponent } from './views/data-grid.component'; + +const dashboardViews = views({ + stat_card: StatCardComponent, + container: ContainerComponent, + dashboard_grid: DashboardGridComponent, + line_chart: LineChartComponent, + bar_chart: BarChartComponent, + data_grid: DataGridComponent, +}); + +const WELCOME_SUGGESTIONS = [ + { label: 'Render a dashboard', value: 'Show me a Q3 sales dashboard with three metrics.' }, + { label: 'Render a form', value: 'Create a contact form with name, email, and message.' }, +] as const; + +@Component({ + selector: 'app-generative-ui', + standalone: true, + imports: [ChatComponent, ChatWelcomeSuggestionComponent, ExampleChatLayoutComponent], + template: ` + + +
+ @for (s of suggestions; track s.value) { + + } +
+
+
+ `, +}) +export class GenerativeUiComponent { + protected readonly agent = agent({ + apiUrl: environment.langGraphApiUrl, + assistantId: environment.generativeUiAssistantId, + }); + protected readonly dashboardViews = dashboardViews; + protected readonly suggestions = WELCOME_SUGGESTIONS; + + protected send(text: string): void { + void this.agent.submit({ message: text }); + } +} +``` + +- [ ] **Step 2: Verify builds** + +```bash +npx nx run cockpit-chat-generative-ui-angular:build +npx nx run cockpit-chat-generative-ui-angular:build:cockpit +``` + +Expected: both green. + +- [ ] **Step 3: Commit** + +```bash +git add cockpit/chat/generative-ui/angular/src/app/generative-ui.component.ts +git commit -m "$(cat <<'EOF' +feat(cockpit-generative-ui): pre-baked welcome suggestions + +Two rows — "Render a dashboard" and +"Render a form". Clicking either prompts the agent to emit a +generative-UI payload that RenderSpecComponent mounts, firing +RENDER_LIFECYCLE.firstMountAt → cockpit:generative_component_rendered +activation signal. + +Co-Authored-By: Claude Opus 4.7 +EOF +)" +``` + +--- + +## Phase 5 — Verification (no commit) + +### Task 5.1: Full test sweep across affected projects + +- [ ] **Step 1: Run lib tests** + +```bash +npx nx run-many -t test -p chat,cockpit-telemetry +``` + +Expected: green. Chat suite now ~712-714 tests passing (existing + 2 new lifecycle tests). + +- [ ] **Step 2: Run the 4 example builds in both modes** + +```bash +npx nx run-many -t build -p cockpit-langgraph-streaming-angular,cockpit-langgraph-persistence-angular,cockpit-langgraph-interrupts-angular,cockpit-chat-generative-ui-angular +``` + +Expected: green. + +```bash +npx nx run cockpit-langgraph-streaming-angular:build:cockpit +npx nx run cockpit-langgraph-persistence-angular:build:cockpit +npx nx run cockpit-langgraph-interrupts-angular:build:cockpit +npx nx run cockpit-chat-generative-ui-angular:build:cockpit +``` + +Expected: all four green. + +- [ ] **Step 3: Sanity-check the suggestions render in the cockpit dev server** + +Optional smoke (requires `OPENAI_API_KEY` for the agent itself to respond, but the suggestion UI renders without it): + +```bash +nx run cockpit:serve-streaming +``` + +Open `http://localhost:4201/langgraph/core-capabilities/streaming/...` in a browser. Confirm two pill-style suggestion rows appear below the welcome input ("Stream a long answer" and "Walk me through agent tool calls"). Click one — the input populates and the agent starts streaming (if backend is available). + +Repeat for the three other capabilities if desired. + +- [ ] **Step 4: Done** + +If Steps 1 and 2 pass, Spec 4 is implementation-complete. Proceed to PR. + +--- + +## Self-Review + +**1. Spec coverage:** + +| Spec deliverable | Task | +|---|---| +| `libs/chat/src/lib/compositions/chat/chat.component.ts` — agent.lifecycle.streamStartedAt watcher | 0.2 | +| `libs/chat/src/lib/lifecycle.spec.ts` — new test asserts agent-driven flip + stickiness | 0.1 (lands in 0.2's commit per TDD) | +| `cockpit/langgraph/streaming/angular/src/app/streaming.component.ts` — suggestions | 1.1 | +| `cockpit/langgraph/persistence/angular/src/app/persistence.component.ts` — suggestions | 2.1 | +| `cockpit/langgraph/interrupts/angular/src/app/interrupts.component.ts` — suggestions | 3.1 | +| `cockpit/chat/generative-ui/angular/src/app/generative-ui.component.ts` — suggestions | 4.1 | +| Affected projects' tests + builds green | 5.1 | + +All deliverables covered. + +**2. Placeholder scan:** No "TBD", no "implement later", no "similar to Task N" without showing the code. Every step has the actual code or command. ✓ + +**3. Type consistency:** + +- `WELCOME_SUGGESTIONS` shape `{label: string, value: string}[]` — same across all 4 example files. ✓ +- `send(text: string): void` signature — same across all 4 example files. ✓ +- `protected readonly suggestions = WELCOME_SUGGESTIONS` — same field name everywhere. ✓ +- `` `[label]` + `[value]` + `(selected)` — matches the component API in `libs/chat/src/lib/primitives/chat-welcome/chat-welcome-suggestion.component.ts`. ✓ +- `agent.lifecycle.streamStartedAt` — matches the `AGENT_LIFECYCLE` shape in `libs/langgraph/src/lib/lifecycle.ts` (Spec 1C Task 0.3). ✓ +- `this.lifecycle._internal.firstMessageSent` — matches the existing internal access pattern in `chat.component.ts` (Spec 1C Task 0.2). ✓ diff --git a/docs/superpowers/specs/gtm/2026-05-16-cockpit-activation-recipes-design.md b/docs/superpowers/specs/gtm/2026-05-16-cockpit-activation-recipes-design.md new file mode 100644 index 000000000..353b55354 --- /dev/null +++ b/docs/superpowers/specs/gtm/2026-05-16-cockpit-activation-recipes-design.md @@ -0,0 +1,260 @@ +--- +workstream: cockpit-activation-recipes +status: approved +owner: brian +phase: 1 +spec: docs/superpowers/specs/gtm/2026-05-16-cockpit-activation-recipes-design.md +plan: docs/superpowers/plans/gtm/2026-05-16-cockpit-activation-recipes.md +parent: docs/superpowers/specs/gtm/2026-05-13-gtm-meta-design.md +--- + +# Spec 4 — Cockpit Activation Recipes (Design) + +> Drop pre-baked "Try this prompt" suggestions onto each of the four capability examples that map to an activation signal, and tighten `ChatComponent` so `firstMessageSent` flips on any submit path. A developer landing in the cockpit now gets a 1-click path to firing each of the five activation signals. + +## 1. Goal + +Phase 1's activation deliverable: the developer-funnel dashboard from Spec 1A populates with real cohort data because evaluators have a frictionless way to walk through the activation funnel. Two cohesive changes: + +1. **Tighten `ChatComponent`** in `@ngaf/chat` so `CHAT_LIFECYCLE.firstMessageSent` flips on the first stream-start regardless of submit path. Today the flag only flips on input-bound submits; suggestion-click handlers that call `agent.submit({message})` directly bypass the lifecycle. The fix watches `AGENT_LIFECYCLE.streamStartedAt` and flips the sticky `firstMessageSent` flag on its first transition to non-null. +2. **Add `` rows to four cockpit capability examples** (streaming, persistence, interrupts, generative-ui) using the canonical pattern from `examples/chat/angular/src/app/modes/embed-mode.component.ts`. Each suggestion is pre-baked to drive its capability's lifecycle signal. + +## 2. Context + +- Parent: `docs/superpowers/specs/gtm/2026-05-13-gtm-meta-design.md` §6 (Phase 1 critical path). The meta-spec's original exit criterion for Spec 4 referenced "six activation signals" and "one comparison page successfully drives an activation." Both are revised: + - Five signals (post-Spec 1C rename `cockpit:six_signals_complete` → `cockpit:activation_complete`). + - Spec 3 (comparison pages) was deliberately deferred; the "driver" for activations is now the cockpit shell sidebar itself. +- The lifecycle wiring lives in `@ngaf/cockpit-telemetry` (Spec 1C) and fires on tokens from `@ngaf/chat`, `@ngaf/langgraph`, `@ngaf/render`. Spec 4 doesn't touch the telemetry library; it touches the *trigger surface* (the capability example components) and the chat composition that gates `firstMessageSent`. +- The `` primitive already exists in `@ngaf/chat`: + - Declaration: `libs/chat/src/lib/primitives/chat-welcome/chat-welcome-suggestion.component.ts` + - Public export: `libs/chat/src/public-api.ts` line ~75 + - Canonical usage in `examples/chat/angular/src/app/modes/embed-mode.component.ts` projects suggestions into the `[chatWelcomeSuggestions]` content slot of ``. +- The `submitMessage()` public method added to `ChatComponent` in Spec 1C Task 0.2 already routes input submits through the lifecycle. But `agent.submit({message})` — the pattern the canonical `EmbedMode` uses for suggestion clicks — bypasses it. The cleanest fix is at the lib level (see §4.1) so every consumer benefits without per-example boilerplate. + +## 3. Scope + +**In scope:** + +- `libs/chat/src/lib/compositions/chat/chat.component.ts` — extend the existing init effect (or add a new effect alongside it) that watches `agent.lifecycle.streamStartedAt`. When it transitions from `null` to a non-null number, flip `lifecycle._internal.firstMessageSent.set(true)` — but only if it isn't already true (idempotent, sticky semantic preserved). `messageCount` and `inputSubmittedAt` are NOT touched by this path; they stay input-bound by design. +- A new unit test in `libs/chat/src/lib/lifecycle.spec.ts` (or a sibling) covering the "agent-driven submit fires firstMessageSent" case. Use the existing `mockAgent` helper from `./testing/mock-agent`. +- Per-example WELCOME_SUGGESTIONS for the four activation-signal capability examples: + - `cockpit/langgraph/streaming/angular/src/app/streaming.component.ts` + - `cockpit/langgraph/persistence/angular/src/app/persistence.component.ts` + - `cockpit/langgraph/interrupts/angular/src/app/interrupts.component.ts` + - `cockpit/chat/generative-ui/angular/src/app/generative-ui.component.ts` +- Each example mirrors the `EmbedMode` pattern: imports `ChatWelcomeSuggestionComponent`, projects suggestions through `
`, click handler calls `agent.submit({message: text})`. +- Each example gets a small Vitest test (or component-level Angular test) asserting that suggestion rows render with the expected labels. Tests use the existing test infrastructure under each example's `angular/` directory. +- A short README pointer (`cockpit/README.md` or a per-example doc) describing how to add more suggestions if a future capability adds an activation path. Optional polish — skip if it inflates scope. + +**Out of scope:** + +- aimock harness as the default backend for `cockpit:serve-`. Today the python backends require `OPENAI_API_KEY`; without it, suggestion clicks fail at the stream layer. We accept this v1: evaluators with an API key get the full path; evaluators without one still see the suggestion UI but the agent errors. A follow-up spec can wire aimock as the default. +- An "activation progress" indicator (e.g., "3/5 signals fired") in the cockpit shell. Worth doing — a follow-up spec can add it. Spec 4 stays focused on the trigger surface + the lib fix. +- A Playwright end-to-end activation suite that walks all five signals and asserts `cockpit:activation_complete` lands in PostHog. Worth doing — a follow-up spec can add it. +- New analytics events. The existing `cockpit:*` event names are unchanged. +- Updating the three existing `examples/chat/angular/src/app/modes/{embed,sidebar,popup}-mode.component.ts` files. They already use the canonical pattern; the lib fix in §4.1 makes their suggestion paths fire `firstMessageSent` automatically. No edits needed. +- The fourth capability page IS `cockpit/chat/generative-ui/angular/src/app/generative-ui.component.ts`. If the path turns out to differ (verify in implementation), the implementer adapts; spec intent is "the example that fires `cockpit:generative_component_rendered`." + +**Success criteria:** + +- Clicking any pre-baked suggestion in any of the four capability examples fires its corresponding activation signal (assuming a working backend). +- `CHAT_LIFECYCLE.firstMessageSent` is true after the first stream chunk, regardless of whether the submit came from the input or from `agent.submit({message})`. +- `npx nx run-many -t test -p chat,cockpit-telemetry` is green. +- The four cockpit example builds (`npx nx run cockpit-langgraph-streaming-angular:build:cockpit` etc.) succeed. +- Manual smoke: a user opening a capability in the cockpit, clicking a suggestion, and waiting for the stream sees the corresponding signal land in PostHog Live Events. + +## 4. Architecture + +``` +User clicks + │ (selected) → send(text) + ▼ +example component: + agent.submit({ message: text }) + │ + ├──▶ AGENT_LIFECYCLE.streamStartedAt flips on first chunk arrival + │ │ + │ ├──▶ CockpitTelemetryService observes → fires cockpit:transport_connected + │ │ + │ └──▶ ChatComponent observes (NEW in Spec 4) → flips CHAT_LIFECYCLE.firstMessageSent + │ │ + │ └──▶ CockpitTelemetryService observes → fires cockpit:chat_first_message + │ + ├──▶ AGENT_LIFECYCLE.threadCreatedAt (first submit) → cockpit:thread_persisted later, on reload + ├──▶ AGENT_LIFECYCLE.interruptResolvedAt (when user resolves) → cockpit:interrupt_handled + └──▶ RENDER_LIFECYCLE.firstMountAt (when generative UI mounts) → cockpit:generative_component_rendered +``` + +## 5. Components + +### 5.1 `ChatComponent` enhancement + +In `libs/chat/src/lib/compositions/chat/chat.component.ts`, the constructor already runs an effect that flips `componentReady` once `this.agent()` resolves. Add a parallel effect that watches the agent's `streamStartedAt`: + +```typescript +// Inside the constructor (or alongside the existing init effect): +effect(() => { + const agentRef = this.agent(); + if (!agentRef) return; + const streamStartedAt = agentRef.lifecycle.streamStartedAt(); + if (streamStartedAt !== null && !this.lifecycle._internal.firstMessageSent()) { + this.lifecycle._internal.firstMessageSent.set(true); + } +}); +``` + +Semantics: +- The signal is sticky. The if-guard prevents double-set. +- `messageCount` and `inputSubmittedAt` stay input-bound — agent-bound submits don't increment them. Rationale: those signals exist to measure UX engagement (typing, time-stamped interaction), not stream initiation. The downstream cockpit telemetry only depends on `firstMessageSent` for `cockpit:chat_first_message`, so the more selective scope is acceptable. +- The effect's auto-tracking re-runs on every signal change, but the work is constant-time and idempotent. + +### 5.2 New test in `libs/chat/src/lib/lifecycle.spec.ts` + +```typescript +test('firstMessageSent flips on agent-driven submit (no input call)', async () => { + // ...standard fixture setup with mockAgent + TestBed... + expect(lifecycle.firstMessageSent()).toBe(false); + // Programmatic submit (bypasses chat-input) + await chatRef.agent().submit({ message: 'hello from agent.submit' }); + // After the stream starts, firstMessageSent must be true. + await waitForSignal(lifecycle.firstMessageSent, true); + expect(lifecycle.firstMessageSent()).toBe(true); +}); +``` + +The exact API (`mockAgent`, `waitForSignal` helper if it exists) follows the patterns already in `lifecycle.spec.ts`. The implementer adapts to match. + +### 5.3 Per-example suggestion arrays + +Each of the four example components adds a `WELCOME_SUGGESTIONS` const, imports `ChatWelcomeSuggestionComponent`, and projects suggestions into ``. Full template + content per file: + +**`cockpit/langgraph/streaming/angular/src/app/streaming.component.ts`:** + +```typescript +const WELCOME_SUGGESTIONS = [ + { label: 'Stream a long answer', value: 'Explain LangGraph checkpointing in 200 words.' }, + { label: 'Walk me through agent tool calls', value: 'Show me how an agent decides which tool to use.' }, +]; +``` + +Template: +```html + + +
+ @for (s of suggestions; track s.value) { + + } +
+
+
+``` + +`send(text)`: +```typescript +protected send(text: string): void { + void this.agent.submit({ message: text }); +} +``` + +**`cockpit/langgraph/persistence/angular/src/app/persistence.component.ts`:** + +```typescript +const WELCOME_SUGGESTIONS = [ + { label: 'Save this thread for later', value: 'Help me draft a project brief I can revisit.' }, +]; +``` + +**`cockpit/langgraph/interrupts/angular/src/app/interrupts.component.ts`:** + +```typescript +const WELCOME_SUGGESTIONS = [ + { label: 'Approve a tool call', value: 'Book a flight to Paris for next Tuesday.' }, +]; +``` + +**`cockpit/chat/generative-ui/angular/src/app/generative-ui.component.ts`:** + +```typescript +const WELCOME_SUGGESTIONS = [ + { label: 'Render a dashboard', value: 'Show me a Q3 sales dashboard with three metrics.' }, + { label: 'Render a form', value: 'Create a contact form with name, email, and message.' }, +]; +``` + +Each component imports `ChatWelcomeSuggestionComponent` from `@ngaf/chat`, adds it to the `imports` array, and exposes `suggestions = WELCOME_SUGGESTIONS` as a protected readonly field. + +### 5.4 Per-example tests + +Each capability example adds a small Vitest test: + +```typescript +// e.g., cockpit/langgraph/streaming/angular/src/app/streaming.component.spec.ts +test('renders welcome suggestion buttons', async () => { + const fixture = TestBed.createComponent(StreamingComponent); + fixture.detectChanges(); + await fixture.whenStable(); + const buttons = fixture.nativeElement.querySelectorAll('.chat-welcome-suggestion'); + expect(buttons.length).toBeGreaterThan(0); + expect(buttons[0].textContent).toMatch(/Stream a long answer/); +}); +``` + +(Spec intent — exact selector + helper may need adjusting to match existing test patterns in the same example directory.) + +## 6. Data flow + +For a Streaming-capability user landing fresh: + +1. User opens `/langgraph/core-capabilities/streaming/...` in the cockpit. +2. `` renders the welcome state (no messages); `` slot shows the input plus two `` rows. +3. User clicks "Stream a long answer". +4. `(selected)="send($event)"` fires with `'Explain LangGraph checkpointing in 200 words.'`. +5. `send()` calls `agent.submit({ message: text })`. +6. The agent stream starts → `AGENT_LIFECYCLE.streamStartedAt` flips non-null. +7. `CockpitTelemetryService.subscribeAgent()` effect fires `cockpit:transport_connected` and `aggregator.markSignal('transport_connected')`. +8. `ChatComponent`'s new Spec-4 effect observes the same `streamStartedAt` transition and flips `CHAT_LIFECYCLE.firstMessageSent` to true. +9. `CockpitTelemetryService.subscribeChat()` effect fires `cockpit:chat_first_message` and `aggregator.markSignal('chat_first_message')`. +10. Both signals join the 5-signal aggregator window. The user explores other capabilities; when all five fire within 30 min, `cockpit:activation_complete` fires. + +## 7. Error handling + +- **Suggestion click with no backend:** the agent's `submit` throws or stalls; the chat surface shows its existing error state. No special handling needed. +- **`agent.submit` errors:** captured by the existing chat error pipeline. `firstMessageSent` does not flip because `streamStartedAt` doesn't transition to non-null. +- **Empty `WELCOME_SUGGESTIONS`:** the `@for` loop renders nothing; the welcome slot is empty. Each example's array is hard-coded so this is a code-review concern, not a runtime risk. + +## 8. Testing + +- **Unit (jsdom, `libs/chat`):** one new lifecycle spec assertion for agent-driven submit → firstMessageSent. +- **Per-example tests:** four small assertions that suggestion buttons render. +- **Build smoke:** `nx run cockpit-langgraph-streaming-angular:build:cockpit` and the three siblings build green. +- **No new dashboard or PostHog work** — the existing developer-funnel dashboard and the activation-funnel insight already consume the same events. +- **Manual smoke** (post-merge, in production): open the cockpit on a real PostHog token; click a suggestion in each capability; verify both `cockpit:transport_connected` and `cockpit:chat_first_message` show up in Live Events with the same `cockpit_did`. + +## 9. Risks + +- **Backend dependency.** Capability examples require `OPENAI_API_KEY` to actually fire signals beyond `transport_connected`. Without one, the user sees the suggestion UI but the agent errors. Acceptable for v1; a follow-up spec can wire aimock as the default. +- **`firstMessageSent` flipping on stream-start, not on submit.** If an agent errors before the first chunk arrives, the signal never flips even though the user did submit. Acceptable: `cockpit:chat_first_message` is meant to mean "the user got a streaming response", which is the threshold for "they engaged with the cockpit" in PostHog terms. +- **Prompt content may not drive every signal cleanly.** "Book a flight to Paris" only triggers an interrupt if the example graph is wired for human-in-the-loop on that intent. The implementer verifies during smoke; if a prompt doesn't drive the signal, the implementer adjusts the prompt (not the graph) until it does. +- **Substring-overlap rule** (from earlier specs): no blind `replace_all`. Each example file is edited individually. + +## 10. Phases + +1. **Phase 0 — `ChatComponent` enhancement.** Add the agent.lifecycle.streamStartedAt watcher + new lifecycle test. (~2 commits, TDD.) +2. **Phase 1 — Streaming example.** WELCOME_SUGGESTIONS + template + test. (~1 commit.) +3. **Phase 2 — Persistence example.** Same pattern. (~1 commit.) +4. **Phase 3 — Interrupts example.** Same pattern. (~1 commit.) +5. **Phase 4 — Generative-UI example.** Same pattern. (~1 commit.) +6. **Phase 5 — Verification** (no commit). Tests + builds + brief manual sanity in the cockpit dev server. + +## 11. Deliverables + +- ☐ `libs/chat/src/lib/compositions/chat/chat.component.ts` — agent.lifecycle.streamStartedAt watcher added +- ☐ `libs/chat/src/lib/lifecycle.spec.ts` — new test asserts agent-driven submit flips firstMessageSent +- ☐ `cockpit/langgraph/streaming/angular/src/app/streaming.component.ts` — WELCOME_SUGGESTIONS + `` rows + `send()` +- ☐ `cockpit/langgraph/persistence/angular/src/app/persistence.component.ts` — same +- ☐ `cockpit/langgraph/interrupts/angular/src/app/interrupts.component.ts` — same +- ☐ `cockpit/chat/generative-ui/angular/src/app/generative-ui.component.ts` — same +- ☐ Per-example component test asserts suggestion buttons render +- ☐ `nx run-many -t test -p chat,cockpit-telemetry` green +- ☐ Per-example `:build:cockpit` targets green diff --git a/libs/chat/src/lib/compositions/chat/chat.component.ts b/libs/chat/src/lib/compositions/chat/chat.component.ts index 5c4c1bcc2..e8ffc76cd 100644 --- a/libs/chat/src/lib/compositions/chat/chat.component.ts +++ b/libs/chat/src/lib/compositions/chat/chat.component.ts @@ -422,6 +422,21 @@ export class ChatComponent { }); }); + // Spec 4: flip CHAT_LIFECYCLE.firstMessageSent when the agent's stream + // starts, regardless of submit path (input-bound, programmatic, suggestion- + // click). Sticky — guarded so we never re-set a flag that's already true. + // `lifecycle` is not on the base Agent contract; adapters like @ngaf/langgraph + // attach it. Duck-type the read so non-lifecycle agents are a no-op. + effect(() => { + let agentRef: ReturnType; + try { agentRef = this.agent(); } catch { return; } + const lc = (agentRef as unknown as { lifecycle?: { streamStartedAt?: () => number | null } }).lifecycle; + const streamStartedAt = lc?.streamStartedAt?.(); + if (streamStartedAt != null && !this.lifecycle._internal.firstMessageSent()) { + this.lifecycle._internal.firstMessageSent.set(true); + } + }); + // Auto-scroll-to-bottom. Fires on every signal update during streaming // (each token mutates the last message's content), so this MUST be cheap // and idempotent. Earlier this used scrollTo({ behavior: 'smooth' }) per diff --git a/libs/chat/src/lib/lifecycle.spec.ts b/libs/chat/src/lib/lifecycle.spec.ts index cd3b62d98..68c4025be 100644 --- a/libs/chat/src/lib/lifecycle.spec.ts +++ b/libs/chat/src/lib/lifecycle.spec.ts @@ -70,4 +70,37 @@ describe('ChatLifecycle integration', () => { expect(lifecycle.messageCount()).toBe(1); expect(lifecycle.firstMessageSent()).toBe(true); }); + + test('firstMessageSent flips when agent.lifecycle.streamStartedAt transitions to non-null', () => { + const agent = mockAgent(); + const f = TestBed.createComponent(ChatComponent); + f.componentRef.setInput('agent', agent); + f.detectChanges(); + const lc = f.componentRef.injector.get(CHAT_LIFECYCLE); + expect(lc.firstMessageSent()).toBe(false); + + agent._internal.streamStartedAt.set(Date.now()); + f.detectChanges(); + expect(lc.firstMessageSent()).toBe(true); + }); + + test('firstMessageSent stays sticky across multiple agent-driven transitions', () => { + const agent = mockAgent(); + const f = TestBed.createComponent(ChatComponent); + f.componentRef.setInput('agent', agent); + f.detectChanges(); + const lc = f.componentRef.injector.get(CHAT_LIFECYCLE); + + agent._internal.streamStartedAt.set(Date.now()); + f.detectChanges(); + expect(lc.firstMessageSent()).toBe(true); + + agent._internal.streamStartedAt.set(null); + f.detectChanges(); + expect(lc.firstMessageSent()).toBe(true); + + agent._internal.streamStartedAt.set(Date.now()); + f.detectChanges(); + expect(lc.firstMessageSent()).toBe(true); + }); }); diff --git a/libs/chat/src/lib/testing/mock-agent.ts b/libs/chat/src/lib/testing/mock-agent.ts index 9a26f5062..d8dd083eb 100644 --- a/libs/chat/src/lib/testing/mock-agent.ts +++ b/libs/chat/src/lib/testing/mock-agent.ts @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -import { signal, WritableSignal } from '@angular/core'; +import { signal, Signal, WritableSignal } from '@angular/core'; import { EMPTY, type Observable } from 'rxjs'; import type { Agent, @@ -25,6 +25,24 @@ export interface MockAgent extends Agent { subagents?: WritableSignal>; history?: WritableSignal; events$: Observable; + /** + * Minimal lifecycle stub the chat lib's effects subscribe to. We only + * model the signals the chat composition currently reads; richer adapter + * lifecycles (langgraph, ag-ui) extend this contract on their own mocks. + * The public `lifecycle` view is a readonly signal; tests drive the + * value via `_internal.streamStartedAt.set(...)` below. + */ + lifecycle: { + streamStartedAt: Signal; + }; + /** + * Test-only escape hatch for driving lifecycle signals from a spec. Mirrors + * the `_internal` pattern used by CHAT_LIFECYCLE so tests can flip the + * underlying writable without going through a full submit/stream cycle. + */ + _internal: { + streamStartedAt: WritableSignal; + }; /** Captured calls to submit() in order. */ submitCalls: Array<{ input: AgentSubmitInput; opts?: AgentSubmitOptions }>; /** Count of stop() invocations. */ @@ -65,11 +83,15 @@ export function mockAgent(opts: MockAgentOptions = {}): MockAgent { const submitCalls: MockAgent['submitCalls'] = []; let stopCount = 0; + const streamStartedAt = signal(null); + const agent: MockAgent = { messages, status, isLoading, error, toolCalls, state, ...(interrupt ? { interrupt } : {}), ...(subagents ? { subagents } : {}), ...(history ? { history } : {}), + lifecycle: { streamStartedAt: streamStartedAt.asReadonly() }, + _internal: { streamStartedAt }, events$: opts.events$ ?? EMPTY, submit: async (input, submitOpts) => { submitCalls.push({ input, opts: submitOpts }); }, stop: async () => { stopCount++; },