Skip to content

Commit 3697120

Browse files
bloveclaude
andauthored
feat(ag-ui): app mode + Google Map cockpit (phase 2) (#732)
* build(ag-ui): wire Google Maps API key via gitignored generated-keys.local.ts Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(ag-ui): add lat/lng to ItineraryStop; seed Paris coords Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(ag-ui): GeocodingService wrapping google.maps.Geocoder Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(ag-ui): geocode at the add_stop client-tool seam * feat(ag-ui): App-mode signal with persistence and sidebar-mode lock Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(ag-ui): toolbar App-mode toggle with key-gating + sidebar lock Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(ag-ui): MapCanvasComponent with dark style, day-colored markers, per-day polylines Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(ag-ui): App-mode layout — full map + floating overlay + chat sidebar * feat(ag-ui): info window with Remove on marker click + pan-to-focus * feat(ag-ui): row click focuses the map; focused row highlights Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(ag-ui): collapsible itinerary panel (overlay minimize) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(ag-ui): collapse overlay actually hides content (:host selector) Live map smoke caught that the collapse toggled the host class but the day content stayed visible: `.itin--collapsed [cdkDropListGroup]` can't match a host-level class under Angular emulated encapsulation (the selector gets _ngcontent appended; the host carries _nghost). Use :host(.itin--collapsed) so the descendant hide-rules apply. The P2.11 unit test only asserted the host class toggles, not that content hides, so it passed while the visual collapse was broken. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent dbc6643 commit 3697120

22 files changed

Lines changed: 645 additions & 24 deletions

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,3 +62,6 @@ libs/licensing/src/lib/license-public-key.generated.ts
6262
.superpowers/
6363
# TypeScript incremental build caches (any project)
6464
*.tsbuildinfo
65+
66+
# AG-UI example generated API keys (injected from .env at build time)
67+
examples/ag-ui/angular/src/environments/generated-keys.local.ts

examples/ag-ui/angular/project.json

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,18 @@
55
"projectType": "application",
66
"prefix": "app",
77
"targets": {
8+
"inject-env": {
9+
"executor": "nx:run-commands",
10+
"options": {
11+
"command": "node examples/ag-ui/angular/scripts/inject-env.mjs",
12+
"cwd": "{workspaceRoot}"
13+
}
14+
},
815
"build": {
916
"executor": "@angular/build:application",
17+
"dependsOn": [
18+
"inject-env"
19+
],
1020
"outputs": [
1121
"{options.outputPath.base}"
1222
],
@@ -53,6 +63,10 @@
5363
{
5464
"replace": "examples/ag-ui/angular/src/environments/environment.ts",
5565
"with": "examples/ag-ui/angular/src/environments/environment.development.ts"
66+
},
67+
{
68+
"replace": "examples/ag-ui/angular/src/environments/generated-keys.ts",
69+
"with": "examples/ag-ui/angular/src/environments/generated-keys.local.ts"
5670
}
5771
]
5872
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// SPDX-License-Identifier: MIT
2+
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
3+
import { resolve, dirname } from 'node:path';
4+
import { fileURLToPath } from 'node:url';
5+
6+
const __dirname = dirname(fileURLToPath(import.meta.url));
7+
const repoRoot = resolve(__dirname, '../../../..');
8+
9+
function readDotEnv() {
10+
const envPath = resolve(repoRoot, '.env');
11+
if (!existsSync(envPath)) return {};
12+
const raw = readFileSync(envPath, 'utf8');
13+
const out = {};
14+
for (const line of raw.split('\n')) {
15+
const m = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/);
16+
if (m) out[m[1]] = m[2].replace(/^"|"$/g, '');
17+
}
18+
return out;
19+
}
20+
21+
const env = { ...readDotEnv(), ...process.env };
22+
const key = env.GOOGLE_MAPS_API_KEY ?? '';
23+
24+
const targetPath = resolve(__dirname, '../src/environments/generated-keys.local.ts');
25+
const contents = `// SPDX-License-Identifier: MIT
26+
// AUTO-GENERATED by scripts/inject-env.mjs. Do not edit by hand.
27+
export const GENERATED_KEYS = {
28+
googleMaps: ${JSON.stringify(key)},
29+
} as const;
30+
`;
31+
writeFileSync(targetPath, contents);
32+
console.log(`[inject-env] wrote generated-keys.local.ts (key length: ${key.length})`);

examples/ag-ui/angular/src/app/app.config.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import {
33
ApplicationConfig,
44
provideBrowserGlobalErrorListeners,
5+
provideEnvironmentInitializer,
56
provideZonelessChangeDetection,
67
} from '@angular/core';
78
import { provideRouter } from '@angular/router';
@@ -34,6 +35,21 @@ export const appConfig: ApplicationConfig = {
3435
// Typed agent provider: flows ItineraryState through DI so every
3536
// injectAgent(ITINERARY_AGENT) call returns AgUiAgent<ItineraryState>.
3637
provideAgent(ITINERARY_AGENT, { url: environment.agentUrl }),
38+
// Load the Google Maps JS API once at bootstrap so the map canvas and the
39+
// GeocodingService both run against the same loaded script. Skips cleanly
40+
// when no key is configured (the googleMapsApiKey is '' in that case).
41+
provideEnvironmentInitializer(() => {
42+
const key = (environment.googleMapsApiKey as string) ?? '';
43+
if (!key) return;
44+
const g = globalThis as { google?: unknown };
45+
if (g.google) return;
46+
if (document.querySelector('script[data-google-maps]')) return;
47+
const script = document.createElement('script');
48+
script.src = `https://maps.googleapis.com/maps/api/js?key=${encodeURIComponent(key)}&libraries=geocoding`;
49+
script.async = true;
50+
script.setAttribute('data-google-maps', '');
51+
document.head.appendChild(script);
52+
}),
3753
provideChat({ license: environment.license }),
3854
// The frontend-owned itinerary is a single shared instance: the panel,
3955
// the App component, and the client-tool ask component all inject it, so

examples/ag-ui/angular/src/app/client-tools.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { inject } from '@angular/core';
33
import { tools, action, view, ask, type ClientToolRegistry, createAgentRef } from '@threadplane/chat';
44
import { z } from 'zod/v4';
55
import { ItineraryStore } from './itinerary-store';
6+
import { GeocodingService } from './geocoding.service';
67
import { DayCardComponent, DAY_CARD_SCHEMA } from './day-card.component';
78
import { ClearDayConfirmComponent } from './clear-day-confirm.component';
89

@@ -35,6 +36,7 @@ export const CLEAR_DAY_SCHEMA = z.object({ day: z.number().int().min(1) });
3536
* steering the model gets — no system-prompt coaching (by design). */
3637
export function itineraryClientTools(): ClientToolRegistry {
3738
const store = inject(ItineraryStore);
39+
const geocoding = inject(GeocodingService);
3840
return tools({
3941
get_itinerary: action(
4042
"Read the user's trip itinerary: every planned stop grouped by day (with ids).",
@@ -44,7 +46,10 @@ export function itineraryClientTools(): ClientToolRegistry {
4446
add_stop: action(
4547
'Add a stop to a day of the trip itinerary. Afterwards, show the updated day with day_card.',
4648
z.object({ day: z.number().int().min(1), place: z.string(), note: z.string().optional() }),
47-
async ({ day, place, note }) => ({ added: store.add(day, place, note) }),
49+
async ({ day, place, note }) => {
50+
const coords = (await geocoding.geocode(place)) ?? undefined;
51+
return { added: store.add(day, place, note, coords ? { coords } : undefined) };
52+
},
4853
),
4954
move_stop: action(
5055
'Move an existing stop (matched by place name) to another day. Afterwards, show the updated day with day_card.',
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
// SPDX-License-Identifier: MIT
2+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
3+
import { TestBed } from '@angular/core/testing';
4+
import { GeocodingService } from './geocoding.service';
5+
6+
beforeEach(() => {
7+
(globalThis as any).google = {
8+
maps: {
9+
Geocoder: class {
10+
async geocode({ address }: { address: string }) {
11+
if (address === 'fail') throw new Error('boom');
12+
if (address === 'empty') return { results: [] };
13+
return {
14+
results: [
15+
{ geometry: { location: { lat: () => 48.85, lng: () => 2.35 } } },
16+
],
17+
};
18+
}
19+
},
20+
},
21+
};
22+
});
23+
24+
afterEach(() => {
25+
delete (globalThis as any).google;
26+
});
27+
28+
describe('GeocodingService', () => {
29+
it('resolves an address to { lat, lng }', async () => {
30+
const svc = TestBed.inject(GeocodingService);
31+
expect(await svc.geocode('Louvre')).toEqual({ lat: 48.85, lng: 2.35 });
32+
});
33+
34+
it('returns null when geocoding throws', async () => {
35+
const svc = TestBed.inject(GeocodingService);
36+
expect(await svc.geocode('fail')).toBeNull();
37+
});
38+
39+
it('returns null when there are no results', async () => {
40+
const svc = TestBed.inject(GeocodingService);
41+
expect(await svc.geocode('empty')).toBeNull();
42+
});
43+
44+
it('returns null when google.maps is not loaded', async () => {
45+
delete (globalThis as any).google;
46+
const svc = TestBed.inject(GeocodingService);
47+
expect(await svc.geocode('Louvre')).toBeNull();
48+
});
49+
});
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
// SPDX-License-Identifier: MIT
2+
/// <reference types="google.maps" />
3+
import { Injectable } from '@angular/core';
4+
5+
/**
6+
* Wraps the Google Maps Geocoder to resolve a place string to coordinates.
7+
* Returns null on any failure — Maps not loaded, no results, or a thrown
8+
* error — so callers can add a stop with no pin rather than break.
9+
*/
10+
@Injectable({ providedIn: 'root' })
11+
export class GeocodingService {
12+
private geocoder: google.maps.Geocoder | null = null;
13+
14+
async geocode(address: string): Promise<{ lat: number; lng: number } | null> {
15+
const g = (globalThis as { google?: typeof google }).google;
16+
if (!g?.maps?.Geocoder) return null;
17+
try {
18+
this.geocoder ??= new g.maps.Geocoder();
19+
const { results } = await this.geocoder.geocode({ address });
20+
const first = results?.[0];
21+
if (!first) return null;
22+
return { lat: first.geometry.location.lat(), lng: first.geometry.location.lng() };
23+
} catch {
24+
return null;
25+
}
26+
}
27+
}

examples/ag-ui/angular/src/app/itinerary-panel.component.spec.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,4 +31,51 @@ describe('ItineraryPanelComponent — agent-edit pulse', () => {
3131
// satisfy lint
3232
expect(added.id).toBeDefined();
3333
});
34+
35+
it('toggles the itin--collapsed host class via the collapse button', () => {
36+
TestBed.configureTestingModule({
37+
providers: [
38+
ItineraryStore,
39+
provideAgent(ITINERARY_AGENT, { url: '/__test__' }),
40+
],
41+
});
42+
43+
const fixture = TestBed.createComponent(ItineraryPanelComponent);
44+
fixture.detectChanges();
45+
46+
const host = fixture.nativeElement as HTMLElement;
47+
expect(host.classList.contains('itin--collapsed')).toBe(false);
48+
49+
const toggle = host.querySelector('.itin__collapse') as HTMLButtonElement;
50+
expect(toggle).toBeTruthy();
51+
52+
toggle.click();
53+
fixture.detectChanges();
54+
expect(host.classList.contains('itin--collapsed')).toBe(true);
55+
56+
toggle.click();
57+
fixture.detectChanges();
58+
expect(host.classList.contains('itin--collapsed')).toBe(false);
59+
});
60+
61+
it('highlights the focused row', () => {
62+
TestBed.configureTestingModule({
63+
providers: [
64+
ItineraryStore,
65+
provideAgent(ITINERARY_AGENT, { url: '/__test__' }),
66+
],
67+
});
68+
const store = TestBed.inject(ItineraryStore);
69+
const stop = store.stops()[0];
70+
store.focus(stop.id);
71+
72+
const fixture = TestBed.createComponent(ItineraryPanelComponent);
73+
fixture.detectChanges();
74+
75+
const rows = fixture.nativeElement.querySelectorAll('.itin__stop');
76+
const pulsing = Array.from(rows).filter((el: any) =>
77+
el.classList.contains('itin__stop--pulse'),
78+
);
79+
expect(pulsing.length).toBe(1);
80+
});
3481
});

examples/ag-ui/angular/src/app/itinerary-panel.component.ts

Lines changed: 62 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,20 +10,34 @@ import { ItineraryStop, ItineraryStore } from './itinerary-store';
1010
standalone: true,
1111
imports: [DragDropModule],
1212
changeDetection: ChangeDetectionStrategy.OnPush,
13-
host: { class: 'itin', role: 'region', 'aria-label': 'Trip itinerary' },
13+
host: {
14+
class: 'itin',
15+
role: 'region',
16+
'aria-label': 'Trip itinerary',
17+
'[class.itin--collapsed]': 'collapsed()',
18+
},
1419
template: `
1520
<div class="itin__head">
1621
<h2 class="itin__title">
1722
Trip itinerary
1823
<span class="itin__total">· {{ totalLabel() }}</span>
1924
</h2>
20-
<button
21-
type="button"
22-
class="itin__overflow"
23-
[attr.aria-expanded]="menuOpen()"
24-
aria-label="Itinerary actions"
25-
(click)="toggleMenu()"
26-
>more_vert</button>
25+
<div class="itin__head-actions">
26+
<button
27+
type="button"
28+
class="itin__collapse"
29+
[attr.aria-expanded]="!collapsed()"
30+
aria-label="Toggle itinerary details"
31+
(click)="toggleCollapsed()"
32+
>{{ collapsed() ? 'expand_more' : 'expand_less' }}</button>
33+
<button
34+
type="button"
35+
class="itin__overflow"
36+
[attr.aria-expanded]="menuOpen()"
37+
aria-label="Itinerary actions"
38+
(click)="toggleMenu()"
39+
>more_vert</button>
40+
</div>
2741
@if (menuOpen()) {
2842
<div class="itin__menu" role="menu">
2943
<button type="button" class="itin__menu-item" role="menuitem" (click)="reset()">
@@ -59,7 +73,8 @@ import { ItineraryStop, ItineraryStore } from './itinerary-store';
5973
@for (s of g.stops; track s.id; let i = $index) {
6074
<li
6175
class="itin__stop"
62-
[class.itin__stop--pulse]="store.recentlyChangedId() === s.id"
76+
[class.itin__stop--pulse]="store.recentlyChangedId() === s.id || store.focusedStopId() === s.id"
77+
(click)="onRowClick(s.id, $event)"
6378
cdkDrag
6479
[cdkDragData]="s"
6580
>
@@ -421,12 +436,40 @@ import { ItineraryStop, ItineraryStore } from './itinerary-store';
421436
outline-offset: 2px;
422437
border-radius: 4px;
423438
}
439+
.itin__head-actions {
440+
display: flex;
441+
align-items: center;
442+
gap: 2px;
443+
}
444+
.itin__collapse {
445+
font-family: 'Material Symbols Outlined', sans-serif;
446+
font-size: 18px;
447+
background: transparent;
448+
border: none;
449+
color: var(--ngaf-chat-text-muted);
450+
cursor: pointer;
451+
padding: 4px;
452+
line-height: 1;
453+
border-radius: var(--ngaf-chat-radius-card);
454+
}
455+
.itin__collapse:hover {
456+
background: var(--ngaf-chat-surface-alt);
457+
color: var(--ngaf-chat-text);
458+
}
459+
:host(.itin--collapsed) [cdkDropListGroup],
460+
:host(.itin--collapsed) .itin__add-day-btn {
461+
display: none;
462+
}
463+
:host(.itin--collapsed) .itin__head {
464+
margin-bottom: 0;
465+
}
424466
`,
425467
],
426468
})
427469
export class ItineraryPanelComponent {
428470
protected readonly store = inject(ItineraryStore);
429471
protected readonly menuOpen = signal(false);
472+
protected readonly collapsed = signal(false);
430473
protected readonly composer = signal<number | null>(null);
431474
protected readonly composerText = signal('');
432475
protected readonly totalLabel = computed(() => {
@@ -444,6 +487,10 @@ export class ItineraryPanelComponent {
444487
this.menuOpen.update((v) => !v);
445488
}
446489

490+
protected toggleCollapsed(): void {
491+
this.collapsed.update((v) => !v);
492+
}
493+
447494
protected reset(): void {
448495
this.store.reset({ source: 'user' });
449496
this.menuOpen.set(false);
@@ -473,6 +520,12 @@ export class ItineraryPanelComponent {
473520
this.store.remove(id, { source: 'user' });
474521
}
475522

523+
protected onRowClick(id: string, event: Event): void {
524+
const target = event.target as HTMLElement;
525+
if (target.closest('.itin__handle') || target.closest('.itin__remove')) return;
526+
this.store.focus(id);
527+
}
528+
476529
protected onDrop(event: CdkDragDrop<ItineraryStop[]>, toDay: number): void {
477530
const stop = event.item.data as ItineraryStop;
478531
this.store.reorder(stop.id, toDay, event.currentIndex, { source: 'user' });

0 commit comments

Comments
 (0)