Skip to content

Commit 77ababf

Browse files
bloveclaude
andcommitted
feat(examples-chat): wire client tools + cockpit into modes; context-aware welcome suggestions
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 7d941ad commit 77ababf

11 files changed

Lines changed: 304 additions & 32 deletions
358 KB
Loading

examples/chat/angular/src/app/app.config.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,18 @@
11
// SPDX-License-Identifier: MIT
2-
import { ApplicationConfig, provideBrowserGlobalErrorListeners, provideZonelessChangeDetection } from '@angular/core';
2+
import {
3+
ApplicationConfig,
4+
inject,
5+
provideBrowserGlobalErrorListeners,
6+
provideEnvironmentInitializer,
7+
provideZonelessChangeDetection,
8+
} from '@angular/core';
39
import { provideRouter, withComponentInputBinding } from '@angular/router';
410
import { provideThreadplaneTelemetry } from '@threadplane/telemetry/browser';
511
import { LANGGRAPH_THREADS_CONFIG, LANGGRAPH_CLIENT_OPTIONS } from '@threadplane/langgraph';
612
import { provideChat } from '@threadplane/chat';
713
import { e2eClientOptions } from './shell/e2e-overrides';
814
import { ItineraryStore } from './itinerary-store';
15+
import { GoogleMapsLoader } from './google-maps-loader';
916
import { routes } from './app.routes';
1017
import { environment } from '../environments/environment';
1118

@@ -37,5 +44,8 @@ export const appConfig: ApplicationConfig = {
3744
// all read/write ONE working copy of the itinerary. Provided at root (not at
3845
// the component) so routed children share the same instance.
3946
ItineraryStore,
47+
// Eagerly kick off the Google Maps JS API load at bootstrap so the map
48+
// canvas can mount as soon as App mode turns on (no per-component wait).
49+
provideEnvironmentInitializer(() => inject(GoogleMapsLoader).ensureLoaded()),
4050
],
4151
};
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
// SPDX-License-Identifier: MIT
2+
import { describe, it, expect } from 'vitest';
3+
import { TestBed } from '@angular/core/testing';
4+
import { AppModePromoComponent } from './app-mode-promo.component';
5+
6+
function setup(hasMapsKey: boolean) {
7+
TestBed.configureTestingModule({ imports: [AppModePromoComponent] });
8+
const fixture = TestBed.createComponent(AppModePromoComponent);
9+
fixture.componentRef.setInput('hasMapsKey', hasMapsKey);
10+
fixture.detectChanges();
11+
return fixture;
12+
}
13+
14+
describe('AppModePromoComponent', () => {
15+
it('renders the headline, four capability pills, and the CTA', () => {
16+
const el: HTMLElement = setup(true).nativeElement;
17+
expect(el.textContent).toContain('See your trip come alive on a live map');
18+
expect(el.querySelectorAll('.promo__pill').length).toBe(4);
19+
const cta = el.querySelector('.promo__cta') as HTMLButtonElement | null;
20+
expect(cta).toBeTruthy();
21+
expect(cta!.disabled).toBe(false);
22+
});
23+
24+
it('emits enable when the CTA is clicked', () => {
25+
const fixture = setup(true);
26+
let emitted = 0;
27+
fixture.componentInstance.enable.subscribe(() => (emitted += 1));
28+
(fixture.nativeElement.querySelector('.promo__cta') as HTMLButtonElement).click();
29+
expect(emitted).toBe(1);
30+
});
31+
32+
it('disables the CTA and shows the key note when hasMapsKey is false', () => {
33+
const el: HTMLElement = setup(false).nativeElement;
34+
const cta = el.querySelector('.promo__cta') as HTMLButtonElement | null;
35+
expect(cta!.disabled).toBe(true);
36+
expect(el.textContent).toContain('GOOGLE_MAPS_API_KEY');
37+
expect(cta!.title).toBe('Set GOOGLE_MAPS_API_KEY to enable');
38+
});
39+
});
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
// SPDX-License-Identifier: MIT
2+
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
3+
4+
/**
5+
* Marketing hero shown in sidebar mode while App mode is off. Sells the
6+
* App-mode map cockpit and the Threadplane primitives behind it, with a CTA
7+
* that enables App mode.
8+
*
9+
* Isolated contract — no shell coupling:
10+
* - `hasMapsKey`: whether GOOGLE_MAPS_API_KEY is configured (gates the CTA).
11+
* - `enable`: emitted when the user clicks the CTA.
12+
*/
13+
@Component({
14+
selector: 'app-mode-promo',
15+
standalone: true,
16+
changeDetection: ChangeDetectionStrategy.OnPush,
17+
template: `
18+
<div class="promo">
19+
<img
20+
#promoImg
21+
class="promo__img"
22+
src="/app-mode-preview.jpg"
23+
alt="Preview of the App-mode map cockpit"
24+
loading="lazy"
25+
(error)="promoImg.style.display = 'none'"
26+
/>
27+
<div class="promo__caption">
28+
<div class="promo__copy">
29+
<span class="promo__eyebrow">
30+
<span class="promo__icon promo__icon--sm" aria-hidden="true">layers</span>
31+
Built with Threadplane
32+
</span>
33+
<h2 class="promo__title">See your trip come alive on a live map</h2>
34+
<p class="promo__subtitle">A map cockpit where the agent edits your itinerary in real time.</p>
35+
<ul class="promo__pills">
36+
<li class="promo__pill"><span class="promo__icon promo__icon--sm" aria-hidden="true">build</span>Client tools</li>
37+
<li class="promo__pill"><span class="promo__icon promo__icon--sm" aria-hidden="true">widgets</span>Generative UI</li>
38+
<li class="promo__pill"><span class="promo__icon promo__icon--sm" aria-hidden="true">how_to_reg</span>Human-in-the-loop</li>
39+
<li class="promo__pill"><span class="promo__icon promo__icon--sm" aria-hidden="true">database</span>Shared state</li>
40+
</ul>
41+
</div>
42+
<div class="promo__action">
43+
<button
44+
type="button"
45+
class="promo__cta"
46+
[disabled]="!hasMapsKey()"
47+
[attr.title]="hasMapsKey() ? null : 'Set GOOGLE_MAPS_API_KEY to enable'"
48+
(click)="enable.emit()"
49+
>
50+
<span class="promo__icon" aria-hidden="true">map</span>
51+
Enable app mode
52+
<span class="promo__icon" aria-hidden="true">arrow_forward</span>
53+
</button>
54+
@if (!hasMapsKey()) {
55+
<p class="promo__note">Set <code>GOOGLE_MAPS_API_KEY</code> to enable</p>
56+
}
57+
</div>
58+
</div>
59+
</div>
60+
`,
61+
styles: [`
62+
:host { display: block; width: 100%; }
63+
.promo {
64+
position: relative;
65+
width: min(780px, 100%);
66+
margin: 0 auto;
67+
aspect-ratio: 16 / 10;
68+
border-radius: var(--tplane-chat-radius-card, 12px);
69+
overflow: hidden;
70+
background: #0e1626;
71+
border: 1px solid var(--tplane-chat-separator, rgba(255, 255, 255, 0.12));
72+
animation: promo-rise 320ms ease both;
73+
}
74+
.promo__img {
75+
position: absolute; inset: 0;
76+
width: 100%; height: 100%;
77+
object-fit: cover; display: block;
78+
}
79+
.promo__caption {
80+
position: absolute; left: 0; right: 0; bottom: 0;
81+
display: flex; flex-wrap: wrap; align-items: center; gap: 16px;
82+
padding: 16px 20px;
83+
background: rgba(8, 15, 28, 0.96);
84+
border-top: 1px solid rgba(255, 255, 255, 0.12);
85+
}
86+
.promo__copy { flex: 1 1 320px; min-width: 0; }
87+
.promo__eyebrow {
88+
display: inline-flex; align-items: center; gap: 6px;
89+
background: rgba(37, 99, 235, 0.18); color: #93b4f5;
90+
font-size: 12px; padding: 3px 10px; border-radius: 8px; margin-bottom: 9px;
91+
}
92+
.promo__title { font-size: 20px; font-weight: 600; color: #f2f5fb; line-height: 1.3; margin: 0 0 4px; }
93+
.promo__subtitle { font-size: 13px; color: #9aa6bd; line-height: 1.5; margin: 0 0 12px; }
94+
.promo__pills { display: flex; flex-wrap: wrap; gap: 8px; list-style: none; margin: 0; padding: 0; }
95+
.promo__pill {
96+
display: inline-flex; align-items: center; gap: 6px;
97+
background: rgba(255, 255, 255, 0.06); border: 1px solid rgba(255, 255, 255, 0.1);
98+
color: #c2cbdc; font-size: 12px; padding: 5px 10px; border-radius: 8px;
99+
}
100+
.promo__action { flex: 0 0 auto; display: flex; flex-direction: column; align-items: flex-start; gap: 6px; }
101+
.promo__cta {
102+
display: inline-flex; align-items: center; gap: 8px;
103+
background: var(--tplane-chat-primary, #2563eb); color: var(--tplane-chat-on-primary, #fff);
104+
border: none; font: inherit; font-size: 14px; font-weight: 600;
105+
padding: 11px 18px; border-radius: 8px; cursor: pointer;
106+
}
107+
.promo__cta:disabled { opacity: 0.5; cursor: not-allowed; }
108+
.promo__cta:focus-visible { outline: 2px solid var(--tplane-chat-text); outline-offset: 2px; }
109+
.promo__cta:hover:not(:disabled) { filter: brightness(1.08); }
110+
.promo__note { font-size: 12px; color: #9aa6bd; margin: 0; }
111+
.promo__note code { font-family: var(--tplane-chat-font-mono, monospace); }
112+
.promo__icon { font-family: 'Material Symbols Outlined', sans-serif; font-size: 18px; line-height: 1; }
113+
.promo__icon--sm { font-size: 15px; }
114+
@keyframes promo-rise { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
115+
@media (prefers-reduced-motion: reduce) { .promo { animation: none; } }
116+
`],
117+
})
118+
export class AppModePromoComponent {
119+
/** Whether GOOGLE_MAPS_API_KEY is configured; gates the CTA. */
120+
readonly hasMapsKey = input<boolean>(false);
121+
/** Emitted when the user clicks the "Enable app mode" CTA. */
122+
readonly enable = output<void>();
123+
}

examples/chat/angular/src/app/modes/embed-mode.component.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,13 @@ import { WelcomeSuggestionsComponent } from './welcome-suggestions.component';
1313
template: `
1414
<chat
1515
[agent]="agent"
16+
[clientTools]="shell.clientTools"
1617
[views]="catalog"
1718
[modelOptions]="shell.modelOptions()"
1819
[selectedModel]="shell.model()"
1920
(selectedModelChange)="shell.onModelChange($event)"
2021
>
21-
<welcome-suggestions chatWelcomeSuggestions (selected)="send($event)" />
22+
<welcome-suggestions chatWelcomeSuggestions [appModeOn]="shell.appMode() === 'on'" (selected)="send($event)" />
2223
</chat>
2324
`,
2425
styles: [`

examples/chat/angular/src/app/modes/popup-mode.component.ts

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,27 +4,36 @@ import { ChatPopupComponent, a2uiBasicCatalog } from '@threadplane/chat';
44
import { DemoShell } from '../shell/demo-shell.component';
55
import { DEMO_AGENT } from '../shell/shell-tokens';
66
import { WelcomeSuggestionsComponent } from './welcome-suggestions.component';
7+
import { AppModePromoComponent } from './app-mode-promo.component';
78

89
@Component({
910
selector: 'popup-mode',
1011
standalone: true,
11-
imports: [ChatPopupComponent, WelcomeSuggestionsComponent],
12+
imports: [ChatPopupComponent, WelcomeSuggestionsComponent, AppModePromoComponent],
1213
changeDetection: ChangeDetectionStrategy.OnPush,
1314
template: `
1415
<div class="popup-mode__background">
15-
<p class="popup-mode__hint">
16-
Click the launcher button (bottom-right) to open the chat.
17-
</p>
16+
<!-- The chat is a floating launcher here, so the area behind it markets
17+
App mode (same hero as sidebar mode). App mode forces sidebar, so in
18+
popup mode it is effectively always off — gate it the same way for
19+
symmetry with sidebar mode. -->
20+
@if (shell.appMode() !== 'on') {
21+
<app-mode-promo
22+
[hasMapsKey]="shell.hasMapsKey"
23+
(enable)="shell.onAppModeChange('on')"
24+
/>
25+
}
1826
</div>
1927
<chat-popup
2028
[agent]="agent"
29+
[clientTools]="shell.clientTools"
2130
[views]="catalog"
2231
[modelOptions]="shell.modelOptions()"
2332
[selectedModel]="shell.model()"
2433
[showModelPicker]="false"
2534
(selectedModelChange)="shell.onModelChange($event)"
2635
>
27-
<welcome-suggestions chatWelcomeSuggestions (selected)="send($event)" />
36+
<welcome-suggestions chatWelcomeSuggestions [appModeOn]="shell.appMode() === 'on'" (selected)="send($event)" />
2837
</chat-popup>
2938
`,
3039
styles: [`
@@ -33,8 +42,6 @@ import { WelcomeSuggestionsComponent } from './welcome-suggestions.component';
3342
display: grid;
3443
place-items: center;
3544
height: 100%;
36-
color: #8a92a3;
37-
font-size: 14px;
3845
}
3946
`],
4047
})

examples/chat/angular/src/app/modes/sidebar-mode.component.ts

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,19 @@ import { Component, ChangeDetectionStrategy, inject } from '@angular/core';
33
import { ChatSidebarComponent, a2uiBasicCatalog } from '@threadplane/chat';
44
import { DemoShell } from '../shell/demo-shell.component';
55
import { DEMO_AGENT } from '../shell/shell-tokens';
6+
import { MapCanvasComponent } from '../map-canvas.component';
7+
import { AppModePromoComponent } from './app-mode-promo.component';
68
import { WelcomeSuggestionsComponent } from './welcome-suggestions.component';
79

810
@Component({
911
selector: 'sidebar-mode',
1012
standalone: true,
11-
imports: [ChatSidebarComponent, WelcomeSuggestionsComponent],
13+
imports: [ChatSidebarComponent, MapCanvasComponent, AppModePromoComponent, WelcomeSuggestionsComponent],
1214
changeDetection: ChangeDetectionStrategy.OnPush,
1315
template: `
1416
<chat-sidebar
1517
[agent]="agent"
18+
[clientTools]="shell.clientTools"
1619
[views]="catalog"
1720
[modelOptions]="shell.modelOptions()"
1821
[selectedModel]="shell.model()"
@@ -22,25 +25,35 @@ import { WelcomeSuggestionsComponent } from './welcome-suggestions.component';
2225
(selectedModelChange)="shell.onModelChange($event)"
2326
>
2427
<span chatSidebarPanelTitle>{{ shell.currentThreadTitle() }}</span>
25-
<div class="sidebar-mode__background">
26-
<p class="sidebar-mode__hint">
27-
Use the launcher (right edge) to dismiss or re-open the chat panel.
28-
</p>
29-
</div>
30-
<welcome-suggestions chatWelcomeSuggestions (selected)="send($event)" />
28+
<!-- In App mode the map IS the main content — projected into the sidebar's
29+
flex content slot so it fills the area left of the drawer (no absolute
30+
positioning / occupy-var inset in the shell). Plain sidebar mode
31+
markets the cockpit instead, with a CTA to turn it on. -->
32+
@if (shell.appMode() === 'on') {
33+
<app-map-canvas class="sidebar-mode__map" />
34+
} @else {
35+
<div class="sidebar-mode__background">
36+
<app-mode-promo
37+
[hasMapsKey]="shell.hasMapsKey"
38+
(enable)="shell.onAppModeChange('on')"
39+
/>
40+
</div>
41+
}
42+
<welcome-suggestions chatWelcomeSuggestions [appModeOn]="shell.appMode() === 'on'" (selected)="send($event)" />
3143
</chat-sidebar>
3244
`,
3345
styles: [`
3446
:host { display: block; flex: 1; min-height: 0; position: relative; }
47+
/* The map fills the chat-sidebar content slot (which is flex:1 at threaded
48+
* height); [pushContent] applies the right-margin push for the open drawer. */
49+
.sidebar-mode__map { display: block; height: 100%; }
3550
/* Projected into chat-sidebar's default content slot so [pushContent]
3651
* applies its right-margin push to this background when the panel
3752
* opens. Sized to fill the visible area below the toolbar. */
3853
.sidebar-mode__background {
3954
display: grid;
4055
place-items: center;
4156
height: 100%;
42-
color: #8a92a3;
43-
font-size: 14px;
4457
}
4558
`],
4659
})

examples/chat/angular/src/app/modes/welcome-suggestions.component.spec.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import { describe, it, expect, beforeEach } from 'vitest';
33
import { ComponentFixture, TestBed } from '@angular/core/testing';
44
import { WelcomeSuggestionsComponent } from './welcome-suggestions.component';
5-
import { FEATURED_SUGGESTIONS, MORE_SUGGESTIONS } from './welcome-suggestions';
5+
import { FEATURED_SUGGESTIONS, MORE_SUGGESTIONS, ITINERARY_SUGGESTIONS } from './welcome-suggestions';
66

77
describe('WelcomeSuggestionsComponent', () => {
88
let fx: ComponentFixture<WelcomeSuggestionsComponent>;
@@ -32,8 +32,8 @@ describe('WelcomeSuggestionsComponent', () => {
3232
expect(trigger.textContent).toContain('More prompts');
3333
});
3434

35-
it('merges FEATURED_SUGGESTIONS[1..] + MORE_SUGGESTIONS into dropdown options', () => {
36-
const opts = fx.componentInstance['moreOptions'] as { value: string; label: string }[];
35+
it('merges FEATURED_SUGGESTIONS[1..] + MORE_SUGGESTIONS into dropdown options (appModeOn=false)', () => {
36+
const opts = fx.componentInstance['moreOptions']() as { value: string; label: string }[];
3737
const expectedLen = FEATURED_SUGGESTIONS.length - 1 + MORE_SUGGESTIONS.length;
3838
expect(opts.length).toBe(expectedLen);
3939
expect(opts[0].label).toBe(FEATURED_SUGGESTIONS[1].label);
@@ -42,6 +42,23 @@ describe('WelcomeSuggestionsComponent', () => {
4242
expect(opts[moreStart].label).toBe(MORE_SUGGESTIONS[0].label);
4343
});
4444

45+
it('features the trip-planning starters when appModeOn is true', () => {
46+
fx.componentRef.setInput('appModeOn', true);
47+
fx.detectChanges();
48+
// Featured chip becomes the first itinerary prompt.
49+
const label = fx.nativeElement.querySelector(
50+
'chat-welcome-suggestion .chat-welcome-suggestion__label',
51+
) as HTMLElement;
52+
expect(label.textContent?.trim()).toBe(ITINERARY_SUGGESTIONS[0].label);
53+
// The remaining itinerary prompts lead the "More prompts" set, followed by
54+
// this demo's existing capability prompts (preserved, not replaced).
55+
const opts = fx.componentInstance['moreOptions']() as { value: string; label: string }[];
56+
expect(opts[0].label).toBe(ITINERARY_SUGGESTIONS[1].label);
57+
const labels = opts.map((o) => o.label);
58+
expect(labels).toContain(FEATURED_SUGGESTIONS[0].label);
59+
expect(labels).toContain(MORE_SUGGESTIONS[0].label);
60+
});
61+
4562
it('emits (selected) with the featured value when the chip is clicked', () => {
4663
let captured: string | null = null;
4764
fx.componentInstance.selected.subscribe((v) => (captured = v));

0 commit comments

Comments
 (0)