Skip to content

Commit 62b456f

Browse files
bloveclaude
andcommitted
feat(examples-chat): App-mode toggle + map-compatible routing (embed↔sidebar coercion)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 2ab792e commit 62b456f

5 files changed

Lines changed: 216 additions & 0 deletions

File tree

examples/chat/angular/src/app/shell/demo-shell.component.css

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,3 +177,44 @@
177177
.demo-shell ::ng-deep .chat-sidebar__panel {
178178
top: 0;
179179
}
180+
181+
/* App-mode toggle: pill button in the toolbar. Enabled only when a Google
182+
* Maps key is configured (App mode renders the map cockpit). */
183+
.demo-shell__app-toggle {
184+
display: inline-flex;
185+
align-items: center;
186+
gap: 6px;
187+
padding: 4px 10px 4px 8px;
188+
border-radius: 999px;
189+
border: 1px solid var(--tplane-chat-separator);
190+
background: transparent;
191+
color: var(--tplane-chat-text-muted);
192+
cursor: pointer;
193+
font: inherit;
194+
font-size: var(--tplane-chat-font-size-sm);
195+
flex: 0 0 auto;
196+
}
197+
.demo-shell__app-toggle:disabled {
198+
opacity: 0.4;
199+
cursor: not-allowed;
200+
}
201+
.demo-shell__app-toggle.is-on {
202+
color: var(--tplane-chat-on-primary);
203+
background: var(--tplane-chat-primary);
204+
border-color: var(--tplane-chat-primary);
205+
}
206+
.demo-shell__app-toggle-icon {
207+
font-family: 'Material Symbols Outlined', sans-serif;
208+
font-size: 16px;
209+
line-height: 1;
210+
}
211+
.demo-shell__app-toggle-thumb {
212+
display: inline-block;
213+
width: 8px;
214+
height: 8px;
215+
border-radius: 50%;
216+
background: var(--tplane-chat-text-muted);
217+
}
218+
.demo-shell__app-toggle.is-on .demo-shell__app-toggle-thumb {
219+
background: var(--tplane-chat-on-primary);
220+
}

examples/chat/angular/src/app/shell/demo-shell.component.html

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,20 @@
2121
}
2222
</div>
2323

24+
<button
25+
type="button"
26+
class="demo-shell__app-toggle"
27+
[class.is-on]="appMode() === 'on'"
28+
[attr.aria-pressed]="appMode() === 'on'"
29+
[disabled]="!hasMapsKey"
30+
[attr.title]="hasMapsKey ? null : 'Set GOOGLE_MAPS_API_KEY to enable'"
31+
(click)="onAppModeChange(appMode() === 'on' ? 'off' : 'on')"
32+
>
33+
<span class="demo-shell__app-toggle-icon" aria-hidden="true">map</span>
34+
<span class="demo-shell__app-toggle-label">App mode</span>
35+
<span class="demo-shell__app-toggle-thumb" aria-hidden="true"></span>
36+
</button>
37+
2438
<div class="demo-shell__field demo-shell__field--first" data-field="model">
2539
<chat-select
2640
[options]="modelOptions()"

examples/chat/angular/src/app/shell/demo-shell.component.spec.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -387,6 +387,87 @@ describe('DemoShell — URL knob hydration', () => {
387387
});
388388
});
389389

390+
describe('DemoShell — App mode routing', () => {
391+
beforeEach(() => {
392+
localStorage.clear();
393+
TestBed.configureTestingModule({
394+
providers: [
395+
threadsAdapterProvider,
396+
ItineraryStore,
397+
provideRouter([
398+
{ path: 'embed', component: DemoShell },
399+
{ path: 'popup', component: DemoShell },
400+
{ path: 'sidebar', component: DemoShell },
401+
{ path: '', pathMatch: 'full', redirectTo: 'embed' },
402+
{ path: '**', redirectTo: 'embed' },
403+
]),
404+
],
405+
});
406+
});
407+
408+
it('coerces embed → sidebar when App mode turns on', async () => {
409+
const router = TestBed.inject(Router);
410+
await router.navigateByUrl('/embed');
411+
const fx = TestBed.createComponent(DemoShell);
412+
fx.detectChanges();
413+
414+
const cmp = fx.componentInstance as unknown as {
415+
appMode: () => 'on' | 'off';
416+
onAppModeChange(v: 'on' | 'off'): void;
417+
};
418+
expect(cmp.appMode()).toBe('off');
419+
420+
cmp.onAppModeChange('on');
421+
fx.detectChanges();
422+
await new Promise<void>((resolve) => setTimeout(resolve, 0));
423+
424+
expect(cmp.appMode()).toBe('on');
425+
expect(router.url).toContain('/sidebar');
426+
});
427+
428+
it('turning App mode off keeps the current route', async () => {
429+
const router = TestBed.inject(Router);
430+
await router.navigateByUrl('/sidebar');
431+
const fx = TestBed.createComponent(DemoShell);
432+
fx.detectChanges();
433+
434+
const cmp = fx.componentInstance as unknown as {
435+
appMode: { (): 'on' | 'off'; set(v: 'on' | 'off'): void };
436+
onAppModeChange(v: 'on' | 'off'): void;
437+
};
438+
cmp.appMode.set('on');
439+
fx.detectChanges();
440+
441+
cmp.onAppModeChange('off');
442+
fx.detectChanges();
443+
await new Promise<void>((resolve) => setTimeout(resolve, 0));
444+
445+
expect(cmp.appMode()).toBe('off');
446+
expect(router.url).toContain('/sidebar');
447+
expect(router.url).not.toContain('/embed');
448+
});
449+
450+
it('selecting embed while App mode on turns App mode off', async () => {
451+
const router = TestBed.inject(Router);
452+
await router.navigateByUrl('/sidebar');
453+
const fx = TestBed.createComponent(DemoShell);
454+
fx.detectChanges();
455+
456+
const cmp = fx.componentInstance as unknown as {
457+
appMode: { (): 'on' | 'off'; set(v: 'on' | 'off'): void };
458+
onModeChange(next: string): void;
459+
};
460+
cmp.appMode.set('on');
461+
fx.detectChanges();
462+
463+
cmp.onModeChange('embed');
464+
fx.detectChanges();
465+
await new Promise<void>((resolve) => setTimeout(resolve, 0));
466+
467+
expect(cmp.appMode()).toBe('off');
468+
});
469+
});
470+
390471
// ── Itinerary ↔ checkpoint sync (Task 9) ────────────────────────────────────
391472

392473
/** A FakeStreamTransport that records the payload of the most recent

examples/chat/angular/src/app/shell/demo-shell.component.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,38 @@ export class DemoShell {
310310

311311
protected readonly mode = computed<DemoMode>(() => this.urlState().mode);
312312

313+
/** Whether the Google Maps key is configured — App mode needs the map,
314+
* so the toggle is disabled without it. */
315+
readonly hasMapsKey = (environment.googleMapsApiKey as string).length > 0;
316+
317+
/** App mode: a presentational layer valid only in popup/sidebar (embed's
318+
* full-bleed chat would cover the map). Persisted across reloads and
319+
* mirrored to the `appmode` query param so shared links restore it. */
320+
readonly appMode = signal<'on' | 'off'>(this.initialAppMode());
321+
322+
/** Mode parsed from the REAL browser path. Available immediately at
323+
* bootstrap (unlike router.url, which reads '/' before the initial
324+
* navigation settles), so App mode restores against the right route. */
325+
private locationMode(): DemoMode {
326+
const path = this.document.defaultView?.location.pathname ?? '';
327+
const seg = path.split('/').filter(Boolean)[0];
328+
return (MODES as readonly string[]).includes(seg) ? (seg as DemoMode) : 'embed';
329+
}
330+
331+
/** App mode persists across reloads, but it can only run in popup/sidebar —
332+
* embed is full-chat with no background for the map. Reads the `appmode`
333+
* query param from the real browser URL (available at bootstrap, unlike
334+
* ActivatedRoute), falling back to persistence. Starts off when the value
335+
* isn't 'on' OR the current route is embed (e.g. a hand-typed
336+
* /embed?appmode=on). */
337+
private initialAppMode(): 'on' | 'off' {
338+
const search = this.document.defaultView?.location.search ?? '';
339+
const raw = (new URLSearchParams(search).get('appmode') ??
340+
this.persistence.read('appMode')) as 'on' | 'off' | null;
341+
if (raw !== 'on') return 'off';
342+
return this.locationMode() === 'embed' ? 'off' : 'on';
343+
}
344+
313345
/**
314346
* Source of truth for the model picker. The shell owns it; the
315347
* patched submit injects it into state on every send.
@@ -528,6 +560,13 @@ export class DemoShell {
528560
protected readonly _demoState: DemoState = this.agent.value();
529561

530562
protected onModeChange(next: DemoMode | string): void {
563+
// Embed can't coexist with App mode (its full-bleed chat covers the
564+
// map), so selecting Embed while App mode is on turns App mode off.
565+
// Popup and Sidebar layer over the map, so they leave App mode alone.
566+
if (next === 'embed' && this.appMode() === 'on') {
567+
this.appMode.set('off');
568+
this.persistence.write('appMode', 'off');
569+
}
531570
// Preserve the active thread across mode switches: /embed/abc →
532571
// /popup/abc keeps the conversation visible in the new chrome.
533572
// Preserve query params so knob state survives the mode hop.
@@ -538,6 +577,45 @@ export class DemoShell {
538577
);
539578
}
540579

580+
/**
581+
* Toggle App mode. Enabling it needs a background area for the map —
582+
* embed has none, so it coerces to the sidebar cockpit; popup/sidebar
583+
* keep their current presentation. Unlike ag-ui-shell (which has a
584+
* knob→URL effect that resolves routing), demo-shell is URL-as-truth,
585+
* so this handler both navigates AND writes `appmode` to the query
586+
* itself (merging so knob params survive).
587+
*/
588+
onAppModeChange(v: 'on' | 'off'): void {
589+
this.persistence.write('appMode', v);
590+
if (v === 'on' && this.mode() === 'embed') {
591+
// Navigate to the sidebar cockpit first (preserving query so the
592+
// active thread + knobs survive), then flip the signal + stamp
593+
// appmode=on into the URL.
594+
const id = this.threadIdSignal();
595+
void this.router
596+
.navigate(id ? ['/', 'sidebar', id] : ['/', 'sidebar'], {
597+
queryParamsHandling: 'preserve',
598+
})
599+
.then(() => {
600+
this.appMode.set('on');
601+
void this.router.navigate([], {
602+
queryParams: { appmode: 'on' },
603+
queryParamsHandling: 'merge',
604+
replaceUrl: true,
605+
});
606+
});
607+
return;
608+
}
609+
// popup/sidebar (or turning off): keep the current route, just update
610+
// the appmode query param. 'off' → null drops it from the URL.
611+
this.appMode.set(v);
612+
void this.router.navigate([], {
613+
queryParams: { appmode: v === 'off' ? null : 'on' },
614+
queryParamsHandling: 'merge',
615+
replaceUrl: true,
616+
});
617+
}
618+
541619
/** Build the full knob → URL-value mapping. Default values become
542620
* null so Angular's router drops them from the resulting URL when
543621
* used with queryParamsHandling: 'merge'. */

examples/chat/angular/src/app/shell/palette-persistence.service.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ interface PaletteState {
1212
sidenavMode?: 'expanded' | 'collapsed' | null;
1313
selectedProjectId?: string | null;
1414
colorScheme?: 'light' | 'dark' | null;
15+
appMode?: 'on' | 'off' | null;
1516
}
1617

1718
type PaletteKey = keyof PaletteState;
@@ -30,6 +31,7 @@ const ALLOWED = {
3031
theme: new Set(['default-dark', 'default-light', 'material-dark', 'material-light']),
3132
colorScheme: new Set<string>(['light', 'dark']),
3233
sidenavMode: new Set<string>(['expanded', 'collapsed']),
34+
appMode: new Set<string>(['on', 'off']),
3335
} as const satisfies Partial<Record<PaletteKey, ReadonlySet<string>>>;
3436

3537
type EnumKey = keyof typeof ALLOWED;

0 commit comments

Comments
 (0)