Skip to content

Commit 662c94a

Browse files
bloveclaude
andcommitted
feat(examples-chat): port itinerary panel/map/day-card/clear-day UI (langgraph agent)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 0f37460 commit 662c94a

5 files changed

Lines changed: 1049 additions & 0 deletions

File tree

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
// SPDX-License-Identifier: MIT
2+
import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core';
3+
import { injectRenderHost } from '@threadplane/render';
4+
import { ItineraryStore } from './itinerary-store';
5+
6+
/**
7+
* The interactive component for the `clear_day` client tool (an `ask`). The
8+
* model fills `day`; the user confirms or cancels. Because an ask emits the
9+
* tool result and the handler layer cannot intercept it, the mutation happens
10+
* HERE: Clear writes the shared `ItineraryStore` (so the panel updates live)
11+
* and then announces the outcome via `injectRenderHost().result(...)`, which
12+
* becomes the tool result that resumes the run. Cancel never touches the store.
13+
*
14+
* Once the ask resolves, the adapter writes the emitted value back onto the
15+
* local tool call, so this component re-renders with `cleared`/`removed` as
16+
* props (chat-tool-views spreads `{...args, ...result, status}` into it). When
17+
* `cleared()` is defined we render a FROZEN line with no buttons; the live
18+
* interactive card only shows while `cleared()` is still undefined.
19+
*/
20+
@Component({
21+
selector: 'app-clear-day-confirm',
22+
standalone: true,
23+
changeDetection: ChangeDetectionStrategy.OnPush,
24+
template: `
25+
@if (cleared() === undefined) {
26+
<div class="cdc">
27+
<p class="cdc__summary">Clear all {{ count() }} stops on day {{ day() }}?</p>
28+
<div class="cdc__actions">
29+
<button type="button" class="cdc__btn cdc__btn--primary" (click)="clear()">Clear</button>
30+
<button type="button" class="cdc__btn" (click)="cancel()">Cancel</button>
31+
</div>
32+
</div>
33+
} @else if (cleared() === true) {
34+
<div class="cdc cdc--resolved">
35+
<p class="cdc__summary">Day {{ day() }} cleared — {{ removed() }} removed ✓</p>
36+
</div>
37+
} @else {
38+
<div class="cdc cdc--resolved">
39+
<p class="cdc__summary">Kept day {{ day() }} — clear cancelled</p>
40+
</div>
41+
}
42+
`,
43+
styles: [
44+
`
45+
.cdc {
46+
border: 1px solid var(--tplane-chat-separator, #e5e7eb);
47+
border-radius: 12px;
48+
padding: 16px;
49+
max-width: 360px;
50+
}
51+
.cdc--resolved .cdc__summary {
52+
margin: 0;
53+
opacity: 0.85;
54+
}
55+
.cdc__summary {
56+
margin: 0 0 12px;
57+
}
58+
.cdc__actions {
59+
display: flex;
60+
gap: 8px;
61+
}
62+
.cdc__btn {
63+
padding: 6px 14px;
64+
border-radius: 8px;
65+
border: 1px solid var(--tplane-chat-separator, #e5e7eb);
66+
background: transparent;
67+
color: inherit;
68+
cursor: pointer;
69+
}
70+
.cdc__btn--primary {
71+
background: var(--tplane-chat-accent, #2563eb);
72+
color: #fff;
73+
border-color: transparent;
74+
}
75+
`,
76+
],
77+
})
78+
export class ClearDayConfirmComponent {
79+
readonly day = input.required<number>();
80+
/** Spread back onto props after the ask resolves (undefined while interactive). */
81+
readonly cleared = input<boolean | undefined>(undefined);
82+
readonly removed = input<number | undefined>(undefined);
83+
private readonly store = inject(ItineraryStore);
84+
private readonly host = injectRenderHost();
85+
86+
protected readonly count = computed(
87+
() => this.store.stops().filter((s) => s.day === this.day()).length,
88+
);
89+
90+
protected clear(): void {
91+
const day = this.day();
92+
const removed = this.store.clearDay(day);
93+
this.host.result({ cleared: true, day, removed });
94+
}
95+
96+
protected cancel(): void {
97+
this.host.result({ cleared: false, day: this.day() });
98+
}
99+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
// SPDX-License-Identifier: MIT
2+
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
3+
import type { ViewProps } from '@threadplane/chat';
4+
import { z } from 'zod/v4';
5+
6+
/**
7+
* Schema for the `day_card` view tool — co-located with the component so the
8+
* inputs and the schema shape can be kept in sync at a glance.
9+
* `client-tools.ts` imports this schema to pass to `view(…, DAY_CARD_SCHEMA, …)`.
10+
*/
11+
export const DAY_CARD_SCHEMA = z.object({
12+
day: z.number().int().min(1),
13+
places: z.array(z.string()),
14+
});
15+
16+
/** Input types derived directly from the `day_card` schema — guarantees this
17+
* component stays compatible with the view() check at compile time. */
18+
type Inputs = ViewProps<typeof DAY_CARD_SCHEMA>;
19+
20+
/**
21+
* A frontend-owned view rendered for the `day_card` client tool. The model
22+
* fills `day` and `places`; this card recaps one itinerary day after an edit.
23+
*/
24+
@Component({
25+
selector: 'app-day-card',
26+
standalone: true,
27+
changeDetection: ChangeDetectionStrategy.OnPush,
28+
template: `
29+
<div class="dc">
30+
<div class="dc__head">Day {{ day() }}</div>
31+
<ul class="dc__list">
32+
@for (p of places(); track p) {
33+
<li class="dc__item">{{ p }}</li>
34+
} @empty {
35+
<li class="dc__item dc__item--empty">No stops</li>
36+
}
37+
</ul>
38+
</div>
39+
`,
40+
styles: [
41+
`
42+
.dc {
43+
border: 1px solid var(--tplane-chat-separator, #e5e7eb);
44+
border-radius: 12px;
45+
padding: 16px;
46+
max-width: 280px;
47+
}
48+
.dc__head {
49+
font-weight: 600;
50+
margin-bottom: 8px;
51+
}
52+
.dc__list {
53+
list-style: none;
54+
margin: 0;
55+
padding: 0;
56+
display: flex;
57+
flex-direction: column;
58+
gap: 4px;
59+
}
60+
.dc__item {
61+
opacity: 0.9;
62+
}
63+
.dc__item--empty {
64+
opacity: 0.5;
65+
}
66+
`,
67+
],
68+
})
69+
export class DayCardComponent {
70+
readonly day = input.required<Inputs['day']>();
71+
readonly places = input<Inputs['places']>([]);
72+
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
// SPDX-License-Identifier: MIT
2+
import { describe, it, expect, beforeEach } from 'vitest';
3+
import { TestBed } from '@angular/core/testing';
4+
import { provideAgent, FakeStreamTransport } from '@threadplane/langgraph';
5+
import { DEMO_AGENT_REF } from './shell/agent-ref';
6+
import { ItineraryPanelComponent } from './itinerary-panel.component';
7+
import { ItineraryStore } from './itinerary-store';
8+
9+
// Bind the DEMO_AGENT_REF token to an in-process fake LangGraph agent so
10+
// injectAgent(DEMO_AGENT_REF) in the panel resolves without a backend.
11+
const fakeAgentProvider = provideAgent(DEMO_AGENT_REF, {
12+
assistantId: 'fake',
13+
transport: new FakeStreamTransport(),
14+
});
15+
16+
describe('ItineraryPanelComponent — agent-edit pulse', () => {
17+
beforeEach(() => localStorage.clear());
18+
19+
it('applies .itin__stop--pulse to the row matching recentlyChangedId', async () => {
20+
TestBed.configureTestingModule({
21+
providers: [
22+
ItineraryStore,
23+
...fakeAgentProvider,
24+
],
25+
});
26+
const store = TestBed.inject(ItineraryStore);
27+
const added = store.add(3, 'Sacré-Cœur'); // agent source by default
28+
29+
const fixture = TestBed.createComponent(ItineraryPanelComponent);
30+
fixture.detectChanges();
31+
32+
const rows = fixture.nativeElement.querySelectorAll('.itin__stop');
33+
const pulsing = Array.from(rows).filter((el: any) =>
34+
el.classList.contains('itin__stop--pulse'),
35+
);
36+
expect(pulsing.length).toBe(1);
37+
expect((pulsing[0] as HTMLElement).textContent).toContain('Sacré-Cœur');
38+
// satisfy lint
39+
expect(added.id).toBeDefined();
40+
});
41+
42+
it('toggles the itin--collapsed host class via the collapse button', () => {
43+
TestBed.configureTestingModule({
44+
providers: [
45+
ItineraryStore,
46+
...fakeAgentProvider,
47+
],
48+
});
49+
50+
const fixture = TestBed.createComponent(ItineraryPanelComponent);
51+
fixture.detectChanges();
52+
53+
const host = fixture.nativeElement as HTMLElement;
54+
expect(host.classList.contains('itin--collapsed')).toBe(false);
55+
56+
const toggle = host.querySelector('.itin__collapse') as HTMLButtonElement;
57+
expect(toggle).toBeTruthy();
58+
59+
toggle.click();
60+
fixture.detectChanges();
61+
expect(host.classList.contains('itin--collapsed')).toBe(true);
62+
63+
toggle.click();
64+
fixture.detectChanges();
65+
expect(host.classList.contains('itin--collapsed')).toBe(false);
66+
});
67+
68+
it('highlights the focused row', () => {
69+
TestBed.configureTestingModule({
70+
providers: [
71+
ItineraryStore,
72+
...fakeAgentProvider,
73+
],
74+
});
75+
const store = TestBed.inject(ItineraryStore);
76+
// The chat-demo store starts empty, so seed a stop before focusing it.
77+
const stop = store.add(1, 'Louvre');
78+
store.focus(stop.id);
79+
80+
const fixture = TestBed.createComponent(ItineraryPanelComponent);
81+
fixture.detectChanges();
82+
83+
const rows = fixture.nativeElement.querySelectorAll('.itin__stop');
84+
const pulsing = Array.from(rows).filter((el: any) =>
85+
el.classList.contains('itin__stop--pulse'),
86+
);
87+
expect(pulsing.length).toBe(1);
88+
});
89+
});

0 commit comments

Comments
 (0)