diff --git a/.gitignore b/.gitignore index 5d3296a11..856009b4a 100644 --- a/.gitignore +++ b/.gitignore @@ -65,3 +65,5 @@ libs/licensing/src/lib/license-public-key.generated.ts # AG-UI example generated API keys (injected from .env at build time) examples/ag-ui/angular/src/environments/generated-keys.local.ts +# Chat example generated API keys (injected from .env at build time) +examples/chat/angular/src/environments/generated-keys.local.ts diff --git a/docs/superpowers/plans/2026-07-06-langgraph-canonical-app-mode-itinerary.md b/docs/superpowers/plans/2026-07-06-langgraph-canonical-app-mode-itinerary.md new file mode 100644 index 000000000..22e0b589e --- /dev/null +++ b/docs/superpowers/plans/2026-07-06-langgraph-canonical-app-mode-itinerary.md @@ -0,0 +1,1049 @@ +# App Mode on the LangChain Canonical Demo — Itinerary Cockpit — 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:** Port the App-mode map/itinerary cockpit from `examples/ag-ui` to the langchain canonical demo (`examples/chat`, `@threadplane/langgraph`), storing the itinerary in the langgraph graph state alongside `messages`, with a single shared agent and no seed data. + +**Architecture:** App mode is a presentational layer over the existing `chat` graph. The graph gains an `itinerary` state channel (per-thread checkpoint) plus frontend-client-tool binding. The Angular `ItineraryStore` is the live working copy (signals → map/panel); the checkpoint is the durable record, synced client-authoritatively via `state.itinerary` on `submit` + `agent.updateState({ itinerary })` after a run and on user edits. The agent recommends places AND populates app state via client tools; the demo starts empty and the user prompts a plan. + +**Tech Stack:** Angular (zoneless, signals), `@threadplane/langgraph` + `@threadplane/chat`, `@angular/google-maps`, LangGraph (Python) + `threadplane-middleware`, Vitest, Playwright, pytest. + +**Design spec:** `docs/superpowers/specs/2026-07-06-langgraph-canonical-app-mode-itinerary-design.md` + +**Execution context:** Work on branch `feat/langgraph-app-mode-itinerary` in the **main checkout** (not a worktree) — the running dev servers (`:4200` examples-chat-angular, `:2024` langgraph `chat` graph) already serve it, which the Chrome-MCP live gate (Task 16) depends on. Leave the pre-existing unrelated dirty files (`libs/chat/.../markdown-table*`) untouched; never `git add` them. + +**Source of truth to copy from:** `examples/ag-ui/angular/src/app/` and `examples/ag-ui/python/`. **Target:** `examples/chat/angular/src/app/` and `examples/chat/python/`. + +**Conventions:** SPDX header `// SPDX-License-Identifier: MIT` on new TS files (match neighbors). Commit after each task. Run unit tests from the repo root. + +--- + +## Phase 0 — De-risk the client-tool round trip + +### Task 1: Spike — prove the langgraph client-tool payload reaches the graph + +**Goal:** Determine which state key the langgraph run payload lands the frontend client-tool catalog in, wire the backend minimally, and prove the model can call a frontend tool that routes away from the server `ToolNode`. + +**Files:** +- Read: `packages/threadplane-middleware/src/threadplane/middleware/langgraph/` (all `.py`) +- Modify: `examples/chat/python/pyproject.toml` +- Modify: `examples/chat/python/src/graph.py` +- Test: `examples/chat/python/tests/test_client_tools_spike.py` (create) + +- [ ] **Step 1: Read the middleware to find the state key + names contract** + +Read every `.py` under `packages/threadplane-middleware/src/threadplane/middleware/langgraph/`. Determine: +- the exact signature of `bind_client_tools(llm, server_tools, state)` and **which `state[...]` key** it reads the client catalog from (candidates: `state["client_tools"]` or `state["tools"]`); +- the exact signature and return of `client_tool_names(state)`. + +Record the key name — call it `` below. (ag-ui populates `state["tools"]` via `ag-ui-langgraph`; the langgraph adapter's TS `mergeClientTools` sets `client_tools` in the run payload, so the langgraph server merges it into state under whatever key matches the graph's `State` channel. The channel name in `State` MUST match the payload key the SDK sends: `client_tools`.) + +- [ ] **Step 2: Add the middleware dependency** + +In `examples/chat/python/pyproject.toml`, add to `[project].dependencies`: + +```toml + "threadplane-middleware>=0.0.1", +``` + +Then install: `cd examples/chat/python && uv sync` — Expected: resolves `threadplane-middleware` from the workspace. + +- [ ] **Step 3: Write a failing test for client-tool routing** + +Create `examples/chat/python/tests/test_client_tools_spike.py`: + +```python +import pytest + + +@pytest.mark.smoke +def test_state_has_client_tools_channel(): + from src.graph import State + ann = State.__annotations__ + assert "client_tools" in ann, "State must carry the frontend client-tool catalog channel" + + +@pytest.mark.smoke +def test_all_client_tool_turn_routes_away_from_server_tools(): + # A turn whose only tool_calls are client tools must NOT route to the + # server ToolNode (which has no implementation for them); the browser + # executes them, so the graph terminates the server loop. + from langchain_core.messages import AIMessage + from src.graph import should_continue + + state = { + "messages": [AIMessage(content="", tool_calls=[ + {"name": "add_stop", "args": {"day": 1, "place": "Louvre"}, "id": "t1"}, + ])], + "client_tools": [{"name": "add_stop"}], + } + assert should_continue(state) != "tools" +``` + +- [ ] **Step 4: Run the test to verify it fails** + +Run: `cd examples/chat/python && uv run pytest tests/test_client_tools_spike.py -v` +Expected: FAIL — `State` has no `client_tools` and/or `should_continue` routes to `tools`. + +- [ ] **Step 5: Add the `client_tools` channel to `State` and bind client tools** + +In `examples/chat/python/src/graph.py`, extend `State` (currently lines 365-369) so it retains the catalog across the graph: + +```python +class State(TypedDict): + messages: Annotated[list, add_messages] + model: Optional[str] + reasoning_effort: Optional[str] + gen_ui_mode: Optional[str] + client_tools: Optional[list] # frontend client-tool catalog (langgraph run payload) + itinerary: list # NEW — see Task 2 (declared here so the channel exists) +``` + +Add the import near the other `threadplane`/langgraph imports at the top of the file: + +```python +from threadplane.middleware.langgraph import bind_client_tools, client_tool_names +``` + +Replace the `bind_tools` call (lines 405-407) so the frontend catalog is appended when present: + +```python + llm = bind_client_tools( + ChatOpenAI(**kwargs), + [search_documents, request_approval, research, gen_ui_tool], + state, + ) +``` + +> If Step 1 found the middleware reads a key other than `client_tools`, name the `State` channel to match what the middleware reads AND what the SDK sends. If they differ, add a tiny normalization at the top of `generate`: `state = {**state, "": state.get("client_tools") or state.get("") or []}` and document it in a comment. Prefer a single key end-to-end. + +- [ ] **Step 6: Update `should_continue` to route all-client-tool turns away from the server ToolNode** + +Find `should_continue` in `examples/chat/python/src/graph.py` (the router after `generate`). Mirror the ag-ui pattern: + +```python +def should_continue(state: State) -> Literal["tools", "attach_citations"]: + last = state["messages"][-1] + if not (isinstance(last, AIMessage) and last.tool_calls): + return "attach_citations" + client = client_tool_names(state) + if all(tc["name"] in client for tc in last.tool_calls): + # Every call is a frontend client tool → the browser executes them; + # the server tool loop terminates. + return "attach_citations" + return "tools" +``` + +Keep the existing terminal target name if the chat graph uses something other than `attach_citations` (e.g. the node that precedes `generate_title`/`END`). Match the existing return literals exactly. + +- [ ] **Step 7: Run the test to verify it passes** + +Run: `cd examples/chat/python && uv run pytest tests/test_client_tools_spike.py -v` +Expected: PASS (both tests). + +- [ ] **Step 8: Live round-trip proof against the running graph** + +With `langgraph dev` on `:2024`, send a run that includes a client-tool catalog and a planning prompt, and confirm the model emits an `add_stop` tool call: + +```bash +cd examples/chat/python +uv run python - <<'PY' +import asyncio, json +from langgraph_sdk import get_client + +async def main(): + client = get_client(url="http://localhost:2024") + thread = await client.threads.create() + catalog = [{"name": "add_stop", + "description": "Add a stop to a day of the trip itinerary.", + "parameters": {"type": "object", + "properties": {"day": {"type": "integer"}, "place": {"type": "string"}}, + "required": ["day", "place"]}}] + saw_add_stop = False + async for chunk in client.runs.stream( + thread["thread_id"], "chat", + input={"messages": [{"role": "user", "content": "Plan 2 days in Paris. Add stops."}], + "client_tools": catalog, "itinerary": []}, + stream_mode=["messages-tuple", "values"], + ): + if "add_stop" in json.dumps(chunk.data): + saw_add_stop = True + print("SAW add_stop tool call:", saw_add_stop) + assert saw_add_stop, "model did not call the frontend client tool — payload key mismatch" + +asyncio.run(main()) +PY +``` + +Expected: `SAW add_stop tool call: True`. If False, the catalog key is not reaching the model — revisit Step 1/Step 5 (key alignment) before proceeding. This is the gate for the whole approach. + +- [ ] **Step 9: Commit** + +```bash +git add examples/chat/python/pyproject.toml examples/chat/python/uv.lock examples/chat/python/src/graph.py examples/chat/python/tests/test_client_tools_spike.py +git commit -m "feat(chat-graph): spike client-tool binding + client-tool-aware routing (#itinerary)" +``` + +--- + +## Phase 1 — Backend: itinerary state, context injection, planner framing + +### Task 2: Itinerary state channel + `Stop` shape + +**Files:** +- Modify: `examples/chat/python/src/graph.py` +- Test: `examples/chat/python/tests/test_itinerary_state.py` (create) + +- [ ] **Step 1: Write the failing test** + +Create `examples/chat/python/tests/test_itinerary_state.py`: + +```python +import pytest + + +@pytest.mark.smoke +def test_state_declares_itinerary_channel(): + from src.graph import State + assert "itinerary" in State.__annotations__ + + +@pytest.mark.smoke +def test_stop_shape(): + from src.graph import Stop + ann = Stop.__annotations__ + for key in ("id", "day", "place"): + assert key in ann, f"Stop must declare {key}" +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `cd examples/chat/python && uv run pytest tests/test_itinerary_state.py -v` +Expected: FAIL — `Stop` not defined. + +- [ ] **Step 3: Define `Stop` and confirm the `itinerary` channel** + +In `examples/chat/python/src/graph.py`, ensure `NotRequired` is imported (`from typing_extensions import TypedDict, NotRequired`) and add above `State`: + +```python +class Stop(TypedDict): + id: str + day: int + place: str + note: NotRequired[str] + lat: NotRequired[float] + lng: NotRequired[float] +``` + +Change the `itinerary` channel added in Task 1 to the typed shape: + +```python + itinerary: list[Stop] # per-thread checkpoint; last-write-wins (plain key) +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `cd examples/chat/python && uv run pytest tests/test_itinerary_state.py -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add examples/chat/python/src/graph.py examples/chat/python/tests/test_itinerary_state.py +git commit -m "feat(chat-graph): add itinerary Stop state channel" +``` + +### Task 3: Inject itinerary context + planner framing into `generate` + +**Files:** +- Modify: `examples/chat/python/src/graph.py` +- Test: `examples/chat/python/tests/test_planner_framing.py` (create) + +- [ ] **Step 1: Write the failing test** + +Create `examples/chat/python/tests/test_planner_framing.py`: + +```python +import pytest +from src.graph import build_system_prompt # helper introduced in Step 3 + + +@pytest.mark.smoke +def test_planner_framing_only_when_client_tools_present(): + plain = build_system_prompt(gen_ui_mode="json-render", client_tools=[], itinerary=[]) + assert "trip-planning" not in plain.lower() + + app = build_system_prompt( + gen_ui_mode="json-render", + client_tools=[{"name": "add_stop"}], + itinerary=[], + ) + assert "trip-planning" in app.lower() + assert "add_stop" in app # instructs the model to populate state via the tool + + +@pytest.mark.smoke +def test_current_itinerary_is_injected_when_present(): + app = build_system_prompt( + gen_ui_mode="json-render", + client_tools=[{"name": "add_stop"}], + itinerary=[{"id": "a", "day": 1, "place": "Louvre"}], + ) + assert "Louvre" in app +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `cd examples/chat/python && uv run pytest tests/test_planner_framing.py -v` +Expected: FAIL — `build_system_prompt` not defined. + +- [ ] **Step 3: Extract `build_system_prompt` and call it from `generate`** + +In `examples/chat/python/src/graph.py`, add a module-level constant and helper (place the constant near `SYSTEM_PROMPT`, the helper just above `generate`): + +```python +_PLANNER_FRAMING = ( + "\n\n--- APP MODE: TRIP PLANNER ---\n" + "You are a trip-planning assistant driving a live map cockpit. When the user " + "asks for a plan, RECOMMEND concrete places (a short note each) grouped into " + "days, and POPULATE the itinerary by calling `add_stop` for each recommendation " + "(then `day_card` to recap a day). Revise with `move_stop`/`reorder_stop`/`clear_day`. " + "Do NOT just describe the plan in prose — call the tools so the map and panel update. " + "Only add stops that are not already present." +) + + +def build_system_prompt(gen_ui_mode: str, client_tools: list, itinerary: list) -> str: + system = SYSTEM_PROMPT + if gen_ui_mode == "a2ui": + system = system + "\n\n--- A2UI v1 SCHEMA ---\n" + A2UI_V1_SCHEMA_PROMPT + ( + "\n\nWhen rendering UI in a2ui mode, emit envelopes in this order: " + "surfaceUpdate FIRST, then beginRendering, then any dataModelUpdate entries." + ) + if client_tools: + system = system + _PLANNER_FRAMING + if itinerary: + import json as _json + system = system + ( + "\n\nCURRENT ITINERARY (do not re-add existing stops):\n" + + _json.dumps(itinerary, ensure_ascii=False) + ) + return system +``` + +In `generate`, replace the inline system assembly (lines ~408-417) with: + +```python + system = build_system_prompt( + gen_ui_mode=gen_ui_mode, + client_tools=state.get("client_tools") or [], + itinerary=state.get("itinerary") or [], + ) + messages = [SystemMessage(content=system)] + state["messages"] +``` + +Preserve the existing a2ui partial-handler wiring that follows. + +- [ ] **Step 4: Run to verify it passes** + +Run: `cd examples/chat/python && uv run pytest tests/test_planner_framing.py -v` +Expected: PASS. + +- [ ] **Step 5: Full backend smoke + graph import** + +Run: `cd examples/chat/python && uv run pytest tests/ -m smoke -v` +Expected: PASS (existing smokes + the new ones; `src.graph.graph` still imports). + +- [ ] **Step 6: Commit** + +```bash +git add examples/chat/python/src/graph.py examples/chat/python/tests/test_planner_framing.py +git commit -m "feat(chat-graph): planner framing + itinerary context injection when client tools present" +``` + +--- + +## Phase 2 — Frontend: Maps key plumbing + ported services/store + +### Task 4: Google Maps key env + inject-env for examples/chat + +**Files:** +- Create: `examples/chat/angular/scripts/inject-env.mjs` +- Create: `examples/chat/angular/src/environments/generated-keys.ts` +- Modify: `examples/chat/angular/src/environments/environment.ts` +- Modify: `examples/chat/angular/src/environments/environment.development.ts` +- Modify: `examples/chat/angular/project.json` +- Modify: `examples/chat/angular/.gitignore` (or repo root `.gitignore`) + +- [ ] **Step 1: Copy the inject-env script** + +```bash +mkdir -p examples/chat/angular/scripts +cp examples/ag-ui/angular/scripts/inject-env.mjs examples/chat/angular/scripts/inject-env.mjs +``` + +Open the copy and confirm `repoRoot = resolve(__dirname, '../../../..')` still resolves to the repo root from `examples/chat/angular/scripts/` (same depth as ag-ui — it does). No edit needed. + +- [ ] **Step 2: Add the committed stub** + +Create `examples/chat/angular/src/environments/generated-keys.ts`: + +```typescript +// SPDX-License-Identifier: MIT +// Committed stub. Regenerated as generated-keys.local.ts at build time by +// scripts/inject-env.mjs and swapped in via project.json fileReplacements. +// Ships empty (CI has no key); local/preview builds get the real value. +export const GENERATED_KEYS = { + googleMaps: '', + googleMapsMapId: '', +} as const; +``` + +- [ ] **Step 3: Add the Maps fields to both environments** + +In `examples/chat/angular/src/environments/environment.ts` add the import at top and the two fields to the object: + +```typescript +import { GENERATED_KEYS } from './generated-keys'; +``` +```typescript + googleMapsApiKey: GENERATED_KEYS.googleMaps, + googleMapsMapId: GENERATED_KEYS.googleMapsMapId, +``` + +Repeat the identical import + two fields in `environment.development.ts`. + +- [ ] **Step 4: Wire the project.json target + fileReplacements** + +In `examples/chat/angular/project.json`, mirror ag-ui: +- Add an `inject-env` target that runs `node examples/chat/angular/scripts/inject-env.mjs`. +- Add `"inject-env"` to `dependsOn` of both `build` and `serve`. +- In build `configurations.production.fileReplacements` and `configurations.development.fileReplacements`, add: + +```json +{ "replace": "examples/chat/angular/src/environments/generated-keys.ts", + "with": "examples/chat/angular/src/environments/generated-keys.local.ts" } +``` + +Copy the exact target/option shapes from `examples/ag-ui/angular/project.json` (targets `inject-env`, `build`, `serve`) so option names match this repo's Nx executors. + +- [ ] **Step 5: Gitignore the generated file** + +Ensure `examples/chat/angular/src/environments/generated-keys.local.ts` is gitignored (add the path to the repo-root `.gitignore` if a glob doesn't already cover `generated-keys.local.ts`). Verify: `git check-ignore examples/chat/angular/src/environments/generated-keys.local.ts` prints the path. + +- [ ] **Step 6: Generate + typecheck** + +```bash +GOOGLE_MAPS_API_KEY=$(grep -E '^GOOGLE_MAPS_API_KEY=' .env | head -1 | cut -d= -f2- | tr -d '"') +GOOGLE_MAPS_MAP_ID=86d464ea7d5306034fe2a254 \ + node examples/chat/angular/scripts/inject-env.mjs +grep -oE 'googleMaps:\s*"[^"]*"' examples/chat/angular/src/environments/generated-keys.local.ts +``` +Expected: prints `googleMaps: ""` with non-empty length. + +- [ ] **Step 7: Commit** + +```bash +git add examples/chat/angular/scripts/inject-env.mjs examples/chat/angular/src/environments/generated-keys.ts examples/chat/angular/src/environments/environment.ts examples/chat/angular/src/environments/environment.development.ts examples/chat/angular/project.json .gitignore +git commit -m "feat(examples-chat): wire Google Maps key via inject-env (local only)" +``` + +### Task 5: Port map utilities + services (no behavior change) + +**Files:** +- Create (copy): `examples/chat/angular/src/app/{map-bounds.ts,map-bounds.spec.ts,geocoding.service.ts,geocoding.service.spec.ts,google-maps-loader.ts}` +- Test: the copied specs + +- [ ] **Step 1: Copy the standalone files verbatim** + +```bash +cd examples/chat/angular/src/app +cp ../../../../ag-ui/angular/src/app/map-bounds.ts . +cp ../../../../ag-ui/angular/src/app/map-bounds.spec.ts . +cp ../../../../ag-ui/angular/src/app/geocoding.service.ts . +cp ../../../../ag-ui/angular/src/app/geocoding.service.spec.ts . +cp ../../../../ag-ui/angular/src/app/google-maps-loader.ts . +``` + +These have no `@threadplane/ag-ui` imports (only `@angular/core`, `../environments/environment`, `google.maps` types) — no edits needed. `google-maps-loader.ts` imports `../environments/environment`, which now carries `googleMapsApiKey` (Task 4). + +- [ ] **Step 2: Run the copied unit tests** + +Run: `npx nx test examples-chat-angular -- map-bounds geocoding` +(If the project uses a direct vitest invocation, run from `examples/chat/angular`: `npx vitest run src/app/map-bounds.spec.ts src/app/geocoding.service.spec.ts`.) +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add examples/chat/angular/src/app/map-bounds.ts examples/chat/angular/src/app/map-bounds.spec.ts examples/chat/angular/src/app/geocoding.service.ts examples/chat/angular/src/app/geocoding.service.spec.ts examples/chat/angular/src/app/google-maps-loader.ts +git commit -m "feat(examples-chat): port map-bounds, geocoding, google-maps-loader" +``` + +### Task 6: Port `ItineraryStore` — drop SEED + localStorage, add value hydration + +**Files:** +- Create (copy + edit): `examples/chat/angular/src/app/itinerary-store.ts` +- Create (copy + edit): `examples/chat/angular/src/app/itinerary-store.spec.ts` + +- [ ] **Step 1: Copy the store + spec** + +```bash +cd examples/chat/angular/src/app +cp ../../../../ag-ui/angular/src/app/itinerary-store.ts . +cp ../../../../ag-ui/angular/src/app/itinerary-store.spec.ts . +``` + +- [ ] **Step 2: Write failing tests for empty-start + hydration** + +Replace any seed/localStorage assertions in `itinerary-store.spec.ts` and add: + +```typescript +import { ItineraryStore } from './itinerary-store'; + +describe('ItineraryStore — server-state model', () => { + it('starts empty (no seed)', () => { + const store = new ItineraryStore(); + expect(store.stops()).toEqual([]); + expect(store.days()).toEqual([]); + }); + + it('hydrates from a server itinerary snapshot', () => { + const store = new ItineraryStore(); + store.hydrate([{ id: 'x', day: 1, place: 'Louvre' }]); + expect(store.stops().map((s) => s.place)).toEqual(['Louvre']); + }); + + it('does not touch localStorage on update', () => { + const spy = vi.spyOn(Storage.prototype, 'setItem'); + const store = new ItineraryStore(); + store.add(1, 'Eiffel Tower'); + expect(spy).not.toHaveBeenCalled(); + spy.mockRestore(); + }); +}); +``` + +(Keep the existing add/move/reorder/clearDay behavior tests — those still pass unchanged.) + +- [ ] **Step 3: Run to verify it fails** + +Run from `examples/chat/angular`: `npx vitest run src/app/itinerary-store.spec.ts` +Expected: FAIL — store seeds from SEED / calls localStorage / no `hydrate`. + +- [ ] **Step 4: Edit the store** + +In `examples/chat/angular/src/app/itinerary-store.ts`: +- Delete the `SEED` constant (lines ~21-25) and the `ITINERARY_STORAGE_KEY` constant. +- Change the `stops` signal initializer from `signal(this.hydrate())` to `signal([])`. +- Replace the private `hydrate()` (localStorage read) and `update()` (localStorage write) with: + +```typescript + /** Replace the working copy from a server state snapshot (thread switch / + * values stream). Public — the shell calls it when agent.values() changes. */ + hydrate(stops: ItineraryStop[]): void { + this.stops.set(stops ?? []); + } + + private update(next: ItineraryStop[]): void { + this.stops.set(next); + } +``` +- In `reset()`, replace `this.update([...SEED])` with `this.update([])`. +- Remove the now-unused `try/catch`/`localStorage` code. + +- [ ] **Step 5: Run to verify it passes** + +Run: `npx vitest run src/app/itinerary-store.spec.ts` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add examples/chat/angular/src/app/itinerary-store.ts examples/chat/angular/src/app/itinerary-store.spec.ts +git commit -m "feat(examples-chat): port ItineraryStore — empty start, value hydration, no localStorage" +``` + +### Task 7: Port itinerary UI components (day-card, clear-day-confirm, itinerary-panel, map-canvas) + +**Files:** +- Create (copy verbatim): `day-card.component.ts`, `clear-day-confirm.component.ts` +- Create (copy + edit): `itinerary-panel.component.ts` (+ `.spec.ts`), `map-canvas.component.ts` + +- [ ] **Step 1: Copy all four (+ panel spec)** + +```bash +cd examples/chat/angular/src/app +cp ../../../../ag-ui/angular/src/app/day-card.component.ts . +cp ../../../../ag-ui/angular/src/app/clear-day-confirm.component.ts . +cp ../../../../ag-ui/angular/src/app/itinerary-panel.component.ts . +cp ../../../../ag-ui/angular/src/app/itinerary-panel.component.spec.ts . +cp ../../../../ag-ui/angular/src/app/map-canvas.component.ts . +``` + +`day-card.component.ts` (uses `@threadplane/chat` `ViewProps`) and `clear-day-confirm.component.ts` (uses `@threadplane/render`) need **no edits**. `map-canvas.component.ts` has no `@threadplane` imports — no edits. + +- [ ] **Step 2: Swap the agent import in `itinerary-panel.component.ts`** + +Change the import (line ~3): + +```typescript +import { injectAgent } from '@threadplane/langgraph'; +import { DEMO_AGENT_REF } from './shell/agent-ref'; +``` +(Remove the `import { ITINERARY_AGENT } from './client-tools';` line.) Then replace `injectAgent(ITINERARY_AGENT)` with `injectAgent(DEMO_AGENT_REF)`. If the panel used any `ItineraryState`-typed field off the agent, `DemoState` from `./shell/agent-ref` is the langgraph analogue — retype accordingly. Keep everything else (drag-drop, store binding) identical. + +- [ ] **Step 3: Point the empty-state at "ask to plan"** + +In `itinerary-panel.component.ts` template, the empty-state (rendered when `store.days().length === 0`) must read as a prompt-to-plan CTA. Ensure a block like: + +```html +@if (store.days().length === 0) { +
+

No trip yet.

+

Ask the assistant to plan one — try a suggestion in the chat.

+
+} +``` +If the ag-ui panel already has an empty state with different copy, update the copy to the above (no seed implies this is the default view users see first). + +- [ ] **Step 4: Run the panel spec** + +Run from `examples/chat/angular`: `npx vitest run src/app/itinerary-panel.component.spec.ts` +Expected: PASS (fix any `ITINERARY_AGENT`/provider references in the spec to `DEMO_AGENT_REF`; provide the agent via the same TestBed pattern the chat specs use — see `shell/demo-shell.component.spec.ts` for the langgraph agent test harness). + +- [ ] **Step 5: Typecheck the app** + +Run: `npx nx build examples-chat-angular --configuration development` (or `npx tsc -p examples/chat/angular/tsconfig.app.json --noEmit`). +Expected: no type errors from the ported components. (Components not yet referenced by routes/shell are fine; this just proves they compile.) + +- [ ] **Step 6: Commit** + +```bash +git add examples/chat/angular/src/app/day-card.component.ts examples/chat/angular/src/app/clear-day-confirm.component.ts examples/chat/angular/src/app/itinerary-panel.component.ts examples/chat/angular/src/app/itinerary-panel.component.spec.ts examples/chat/angular/src/app/map-canvas.component.ts +git commit -m "feat(examples-chat): port itinerary panel/map/day-card/clear-day UI (langgraph agent)" +``` + +### Task 8: Port client tools — drop `get_itinerary`, target the demo agent + +**Files:** +- Create (copy + edit): `examples/chat/angular/src/app/client-tools.ts` +- Test: `examples/chat/angular/src/app/client-tools.spec.ts` (create) + +- [ ] **Step 1: Copy client-tools** + +```bash +cp examples/ag-ui/angular/src/app/client-tools.ts examples/chat/angular/src/app/client-tools.ts +``` + +- [ ] **Step 2: Edit for the chat/langgraph demo** + +In `examples/chat/angular/src/app/client-tools.ts`: +- **Remove** the `ITINERARY_AGENT` export and the `ItineraryState` interface and the `createAgentRef` import — the chat demo already owns `DEMO_AGENT_REF`/`DemoState` in `./shell/agent-ref`. Keep imports: `tools, action, view, ask, type ClientToolRegistry` from `@threadplane/chat`. +- **Remove** the `get_itinerary` tool entirely (the model sees the itinerary via injected context now; no read round-trip). +- Keep `add_stop`, `move_stop`, `reorder_stop`, `clear_day`, `day_card` unchanged. +- Keep `CLEAR_DAY_SCHEMA` and `itineraryClientTools()`. + +- [ ] **Step 3: Write a failing test** + +Create `examples/chat/angular/src/app/client-tools.spec.ts`: + +```typescript +import { TestBed } from '@angular/core/testing'; +import { ItineraryStore } from './itinerary-store'; +import { GeocodingService } from './geocoding.service'; +import { itineraryClientTools } from './client-tools'; + +describe('itineraryClientTools (langgraph demo)', () => { + beforeEach(() => TestBed.configureTestingModule({ providers: [ItineraryStore, GeocodingService] })); + + it('exposes mutation tools but NOT get_itinerary', () => { + const registry = TestBed.runInInjectionContext(() => itineraryClientTools()); + const names = Object.keys(registry as Record); + expect(names).toContain('add_stop'); + expect(names).toContain('clear_day'); + expect(names).not.toContain('get_itinerary'); + }); +}); +``` + +- [ ] **Step 4: Run — fail then pass** + +Run: `npx vitest run src/app/client-tools.spec.ts` (from `examples/chat/angular`). +Expected: FAIL before the `get_itinerary` removal, PASS after. (If registry key introspection differs, adapt the assertion to the `ClientToolRegistry` shape observed in `libs/chat` client-tools tests.) + +- [ ] **Step 5: Commit** + +```bash +git add examples/chat/angular/src/app/client-tools.ts examples/chat/angular/src/app/client-tools.spec.ts +git commit -m "feat(examples-chat): port itinerary client tools (drop get_itinerary, use demo agent)" +``` + +--- + +## Phase 3 — Shell + agent wiring + +### Task 9: App state sync — submit injects `state.itinerary`, `updateState` persists + +**Files:** +- Modify: `examples/chat/angular/src/app/shell/demo-shell.component.ts` +- Test: `examples/chat/angular/src/app/shell/demo-shell.component.spec.ts` + +- [ ] **Step 1: Write failing tests for the sync** + +Add to `demo-shell.component.spec.ts` (follow the file's existing TestBed + agent-stub pattern): + +```typescript +it('injects the current itinerary into submit state', async () => { + // Arrange: store has one stop; spy on the underlying agent.submit. + // Act: call shell.agent.submit({ messages: [...] }) + // Assert: the orig submit received input.state.itinerary === store.stops() +}); + +it('pushes the itinerary to the checkpoint after a run settles', () => { + // Arrange: spy on agent.updateState. + // Act: simulate run-end (the refreshOnRunEnd / running() → false transition). + // Assert: updateState called with { itinerary: store.stops() }. +}); +``` + +Fill these in against the concrete stub the spec already uses for `injectAgent(DEMO_AGENT_REF)`. If the spec stubs the agent via a provider, extend that stub with `submit` + `updateState` spies and a `running`/`values` signal. + +- [ ] **Step 2: Run to verify it fails** + +Run: `npx vitest run src/app/shell/demo-shell.component.spec.ts` +Expected: FAIL. + +- [ ] **Step 3: Extend the submit wrapper + add the sync effects** + +In `demo-shell.component.ts`: +- Inject the store: add `protected readonly itinerary = inject(ItineraryStore);` (and provide `ItineraryStore` in the component `providers` array, or app-level — Task 12 sets app-level; component-level is fine here for the shell that owns it). Import `ItineraryStore` from `../itinerary-store`. +- In the `submit` wrapper (lines 393-424), add `itinerary: this.itinerary.stops(),` into the `state: { ... }` object alongside `model/reasoning_effort/gen_ui_mode`. +- After the wrapper, add sync effects: + +```typescript +constructor() { + // ...existing constructor body... + + // Hydrate the working copy from server state on thread switch / values stream. + effect(() => { + const v = this.agent.values() as { itinerary?: ItineraryStop[] } | null | undefined; + if (v && Array.isArray(v.itinerary)) this.itinerary.hydrate(v.itinerary); + }); + + // Persist the working copy to the checkpoint when a run settles. + let wasRunning = false; + effect(() => { + const running = this.agent.running(); + if (wasRunning && !running) { + void this.agent.updateState?.({ itinerary: this.itinerary.stops() }); + } + wasRunning = running; + }); +} +``` + +Import `ItineraryStop` from `../itinerary-store`. If `agent.running` isn't the exact signal name, use the run-state signal the chat agent exposes (check `libs/langgraph` agent type — `refreshOnRunEnd` is already imported by this shell, so an equivalent run-end hook exists; prefer wiring the `updateState` into that hook if cleaner). + +- [ ] **Step 4: Add the user-edit sync** + +Direct user edits (drag-reorder/add/clear in the panel) must also persist. In `itinerary-panel.component.ts`, after each user-initiated store mutation, call `this.agent.updateState?.({ itinerary: this.store.stops() })` (the panel already injects the agent from Task 7). Guard so it only fires for `source: 'user'` mutations (agent edits are captured by the run-settle effect). Add a focused panel spec asserting `updateState` is called on a user drag-drop. + +- [ ] **Step 5: Run to verify it passes** + +Run: `npx vitest run src/app/shell/demo-shell.component.spec.ts src/app/itinerary-panel.component.spec.ts` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add examples/chat/angular/src/app/shell/demo-shell.component.ts examples/chat/angular/src/app/shell/demo-shell.component.spec.ts examples/chat/angular/src/app/itinerary-panel.component.ts examples/chat/angular/src/app/itinerary-panel.component.spec.ts +git commit -m "feat(examples-chat): sync itinerary — submit state + updateState on run-settle and user edit" +``` + +### Task 10: App-mode signal + toggle + map-compatible routing + +**Files:** +- Modify: `examples/chat/angular/src/app/shell/demo-shell.component.ts` (+ `.html`/template, `.css`) +- Modify: `examples/chat/angular/src/app/shell/palette-persistence.service.ts` (reuse for appMode persistence — or the existing persistence service the shell uses) +- Test: `demo-shell.component.spec.ts` + +- [ ] **Step 1: Write failing tests for appMode routing** + +Add to `demo-shell.component.spec.ts` (port the ag-ui-shell semantics): + +```typescript +it('coerces embed → sidebar when App mode turns on', () => { + // set mode embed, call onAppModeChange('on'); expect navigation to /sidebar and appMode()==='on' +}); +it('turning App mode off keeps the current route', () => { + // mode sidebar + appMode on; onAppModeChange('off'); route stays /sidebar, appMode()==='off' +}); +it('selecting embed while App mode is on turns App mode off', () => { + // appMode on; onModeChange('embed'); expect appMode()==='off' +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `npx vitest run src/app/shell/demo-shell.component.spec.ts` +Expected: FAIL — no `appMode`. + +- [ ] **Step 3: Port the appMode state + handlers from ag-ui-shell** + +Into `demo-shell.component.ts`, port from `examples/ag-ui/angular/src/app/shell/ag-ui-shell.component.ts`: +- `readonly appMode = signal<'on' | 'off'>(this.initialAppMode());` +- `readonly hasMapsKey = (environment.googleMapsApiKey as string).length > 0;` +- `initialAppMode()`, `onAppModeChange(v)`, and the embed-coercion in `onModeChange` (see ag-ui-shell lines 100-104, 207-235). +- The knob param-sync effect's `appmode` entry (ag-ui-shell lines 191, 201-203): include `appmode` in the query object and coerce `embed → sidebar` when App mode is on. Adapt to demo-shell's existing router/effect (demo-shell parses URL via `urlState()`/`parseUrl()` and navigates via `onModeChange` — keep those; add the appMode coercion in the same navigate path). +- Persist appMode via the shell's persistence service (`write('appMode', ...)`, read in `initialAppMode`). + +Import `environment` from `../../environments/environment`. + +- [ ] **Step 4: Add the toggle to the toolbar template** + +In the demo-shell template, add the App-mode toggle button between the segmented mode buttons and the model field (port markup from `ag-ui-shell.component.html` lines ~18-28), bound to `appMode()`/`hasMapsKey`/`onAppModeChange`. Add the toggle's CSS from `ag-ui-shell.component.css` (`.ag-ui-shell__app-toggle*`), renamed to the `demo-shell__` prefix. + +- [ ] **Step 5: Run to verify it passes** + +Run: `npx vitest run src/app/shell/demo-shell.component.spec.ts` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add examples/chat/angular/src/app/shell/ +git commit -m "feat(examples-chat): App-mode toggle + map-compatible routing (embed↔sidebar coercion)" +``` + +### Task 11: App-mode layout — map background + itinerary overlay + sidenav→drawer (layout ①) + +**Files:** +- Modify: `examples/chat/angular/src/app/shell/demo-shell.component.html` +- Modify: `examples/chat/angular/src/app/shell/demo-shell.component.css` +- Modify: `examples/chat/angular/src/app/shell/demo-shell.component.ts` + +- [ ] **Step 1: Force the thread sidenav to drawer in App mode** + +In `demo-shell.component.ts`, make `sidenavMode` (computed, line ~274) return `'drawer'` when `this.appMode() === 'on'` (so the thread sidenav collapses to the hamburger drawer and the map owns the background): + +```typescript +readonly sidenavMode = computed(() => { + if (this.appMode() === 'on') return 'drawer'; + if (this.viewportIsNarrow()) return 'drawer'; // keep existing narrow-viewport rule + return this.storedDesktopMode(); +}); +``` +(Preserve the existing viewport/preference logic; add the appMode branch first.) + +- [ ] **Step 2: Add the app-body layout branch to the template** + +In `demo-shell.component.html`, wrap the `.demo-shell__main` region with an appMode branch, porting `ag-ui-shell.component.html`'s `@if (appMode() === 'on')` block: + +```html +@if (appMode() === 'on') { +
+ + +
+ + @if (agent.interrupt && agent.interrupt()) { +
+ +
+ } +
+
+} @else { + +} +``` +Add `MapCanvasComponent` and `ItineraryPanelComponent` to the component `imports` array. Keep the `` / palette markup outside this branch (the sidenav is still present but in drawer mode). + +- [ ] **Step 3: Port the app-body CSS** + +Into `demo-shell.component.css`, port `.ag-ui-shell__app-body`, `.ag-ui-shell__map` (full-bleed background), `.ag-ui-shell__itinerary-overlay` (floating left overlay), renamed to `demo-shell__`. Ensure `.demo-shell__map` is `position:absolute; inset:0;` behind the overlay + chat rail, and the itinerary overlay floats above it (left, with a max-width). Chat renders as the right rail via the existing sidebar/popup mode components. + +- [ ] **Step 4: Verify build + a rendering unit check** + +Run: `npx nx build examples-chat-angular --configuration development` +Expected: builds. Add/extend a demo-shell spec asserting that with `appMode() === 'on'` the template renders `app-map-canvas` and forces `sidenavMode() === 'drawer'`. +Run: `npx vitest run src/app/shell/demo-shell.component.spec.ts` → PASS. + +- [ ] **Step 5: Commit** + +```bash +git add examples/chat/angular/src/app/shell/ +git commit -m "feat(examples-chat): App-mode cockpit layout (map bg + itinerary overlay + sidenav→drawer)" +``` + +### Task 12: Wire client tools + store into the app; mode components render the cockpit + +**Files:** +- Modify: `examples/chat/angular/src/app/app.config.ts` +- Modify: `examples/chat/angular/src/app/shell/demo-shell.component.ts` +- Modify: `examples/chat/angular/src/app/modes/sidebar-mode.component.ts`, `popup-mode.component.ts`, `embed-mode.component.ts` +- Create (copy + edit): `examples/chat/angular/src/app/modes/app-mode-promo.component.ts` (+ spec), retune `welcome-suggestions*` + +- [ ] **Step 1: Provide the store + maps loader in app.config** + +In `examples/chat/angular/src/app/app.config.ts` add to `providers`: + +```typescript +import { inject, provideEnvironmentInitializer } from '@angular/core'; +import { ItineraryStore } from './itinerary-store'; +import { GoogleMapsLoader } from './google-maps-loader'; +// ... + ItineraryStore, + provideEnvironmentInitializer(() => inject(GoogleMapsLoader).ensureLoaded()), +``` +Do **not** add a seed / `initialValues` — the demo starts empty. Keep the existing `LANGGRAPH_THREADS_CONFIG`/`provideChat` providers. + +- [ ] **Step 2: Expose the client-tools registry on the shell** + +In `demo-shell.component.ts` add: + +```typescript +import { itineraryClientTools } from '../client-tools'; +// field: +readonly clientTools = itineraryClientTools(); // built in injection context +``` + +- [ ] **Step 3: Bind the map/itinerary + clientTools in the mode components** + +Port the ag-ui mode composition into the chat modes: +- `sidebar-mode.component.ts`: bind `[clientTools]="shell.clientTools"` on the `` (inject the shell via `inject(DemoShell)`), and when `shell.appMode() === 'on'` render `` in the sidebar content slot (see `examples/ag-ui/angular/src/app/modes/sidebar-mode.component.ts` for the exact slot/`@if` structure). Import `MapCanvasComponent`, `AppModePromoComponent`. +- `popup-mode.component.ts`: bind `[clientTools]` on ``; the map renders as the shell background (Task 11), the popup floats over it. +- `embed-mode.component.ts`: bind `[clientTools]` (embed is not an App-mode layout, but the tools remain available so a user in embed can still ask for a plan — harmless; the itinerary just isn't visualized). + +- [ ] **Step 4: Port the app-mode promo + retune welcome suggestions** + +```bash +cp examples/ag-ui/angular/src/app/modes/app-mode-promo.component.ts examples/chat/angular/src/app/modes/app-mode-promo.component.ts +cp examples/ag-ui/angular/src/app/modes/app-mode-promo.component.spec.ts examples/chat/angular/src/app/modes/app-mode-promo.component.spec.ts 2>/dev/null || true +``` +No `@threadplane/ag-ui` import there — no edit beyond confirming the promo image asset path resolves (copy any referenced asset from `examples/ag-ui/angular/src/assets` into `examples/chat/angular/src/assets` if missing). + +Retune `examples/chat/angular/src/app/modes/welcome-suggestions.ts` to trip-planning starters: + +```typescript +export const WELCOME_SUGGESTIONS = [ + 'Plan a long weekend in Paris', + '3 days in Tokyo with great food', + 'A week on the California coast', +] as const; +``` +(Match the existing export name/shape in that file; only change the content to planning prompts. Update `welcome-suggestions.component.spec.ts` expectations accordingly.) + +- [ ] **Step 5: Build + unit tests** + +Run: `npx nx build examples-chat-angular --configuration development` +Run: `npx vitest run src/app/modes` (from `examples/chat/angular`) +Expected: builds; mode + promo + welcome specs PASS. + +- [ ] **Step 6: Commit** + +```bash +git add examples/chat/angular/src/app/app.config.ts examples/chat/angular/src/app/shell/demo-shell.component.ts examples/chat/angular/src/app/modes/ examples/chat/angular/src/assets +git commit -m "feat(examples-chat): wire client tools + cockpit into modes; planner welcome suggestions" +``` + +--- + +## Phase 4 — Verify + +### Task 13: Cross-cutting gates (lint / typecheck / unit / build) + +- [ ] **Step 1: Frontend gates** + +```bash +npx nx lint examples-chat-angular +npx nx test examples-chat-angular +npx nx build examples-chat-angular --configuration development +``` +Expected: lint clean (fix any aliased-input eslint-disable needs), all unit specs green, build succeeds. + +- [ ] **Step 2: Backend gates** + +```bash +cd examples/chat/python && uv run pytest tests/ -v +``` +Expected: all pass (spike + itinerary-state + planner-framing + pre-existing). + +- [ ] **Step 3: Commit any fixups** + +```bash +git add -A -- examples/chat +git commit -m "chore(examples-chat): gate fixups (lint/typecheck/test)" --allow-empty +``` + +### Task 14: e2e — App-mode cockpit on examples/chat + +**Files:** +- Create: `examples/chat/angular/e2e/app-mode-itinerary.spec.ts` + +- [ ] **Step 1: Write the e2e spec** + +Using the existing `examples/chat/angular/e2e/test-helpers.ts` (`openDemo`, `messageInput`, `sendButton`, `selectToolbarDropdown`), create `app-mode-itinerary.spec.ts` asserting (no reliance on map tiles — DOM/layout only, per the Maps-canvas harness lesson): + +```typescript +import { test, expect } from '@playwright/test'; +import { openDemo } from './test-helpers'; + +test('App mode shows the cockpit with an empty prompt-to-plan state', async ({ page }) => { + await openDemo(page, '/sidebar?appmode=on'); + await expect(page.locator('app-map-canvas')).toBeVisible(); + await expect(page.locator('app-itinerary-panel')).toContainText(/plan/i); + // sidenav collapsed to drawer in App mode → hamburger present, panel not pinned + await expect(page.locator('.demo-shell__hamburger')).toBeVisible(); +}); + +test('embed turns App mode off (coercion)', async ({ page }) => { + await openDemo(page, '/sidebar?appmode=on'); + await page.getByRole('button', { name: 'Embed' }).click(); + await expect(page).toHaveURL(/\/embed/); + await expect(page.locator('app-map-canvas')).toHaveCount(0); +}); +``` +(If `openDemo` can't pass query params, extend it to accept `/sidebar?appmode=on`, mirroring the ag-ui `openDemo(page, '/?appmode=on')` helper.) + +- [ ] **Step 2: Run the e2e (kill stale servers first)** + +Per the examples-chat e2e runbook, free `:4200`/`:2024` of stale listeners, then: +```bash +npx nx e2e examples-chat-angular -- --grep "App mode" +``` +Expected: the two specs pass. (These run keyless-capable — the panel + layout render regardless of Maps tiles.) + +- [ ] **Step 3: Commit** + +```bash +git add examples/chat/angular/e2e/app-mode-itinerary.spec.ts examples/chat/angular/e2e/test-helpers.ts +git commit -m "test(examples-chat): e2e App-mode cockpit (empty state + embed coercion)" +``` + +### Task 15: Live Chrome-MCP gate — empty → planning prompt → populated → reload restores + +**Manual/controller task (no file changes). Uses the running `:4200` + `:2024` stack + a real LLM (OPENAI_API_KEY in `.env`).** + +- [ ] **Step 1:** Ensure the chat stack serves the fresh bundle with a Maps key: restart `examples-chat-angular` serve with `GOOGLE_MAPS_API_KEY` exported (and `GOOGLE_MAPS_MAP_ID=86d464ea7d5306034fe2a254`); confirm `generated-keys.local.ts` has a non-empty key. Symlink the root `.env` into the checkout if serving from a worktree. +- [ ] **Step 2:** In Chrome MCP, open `http://localhost:4200/sidebar?appmode=on`. Probe DOM (not screenshots) to confirm `app-map-canvas` + `app-itinerary-panel` mount and the panel shows the empty prompt-to-plan state. +- [ ] **Step 3:** Send "Plan 3 days in Tokyo with great food". Confirm via `ng.getComponent` / DOM that the agent issues multiple `add_stop` calls and the itinerary panel + map pins populate live across days. +- [ ] **Step 4:** Reload the page mid-thread; confirm the itinerary restores from the checkpoint (`agent.values().itinerary` hydrates the panel/map) — proving per-thread durable state. +- [ ] **Step 5:** Start a new thread; confirm it opens empty (no seed). Record findings; if any layer fails, root-cause before merge (systematic-debugging). + +### Task 16: Final review + branch finish + +- [ ] **Step 1:** Dispatch a final code-review subagent over the whole diff (spec compliance + code quality). +- [ ] **Step 2:** Confirm the standing no-persist constraint: scan `git diff origin/main...HEAD` for the excluded competitor product name (per the session rule) — it must return nothing. +- [ ] **Step 3:** Use superpowers:finishing-a-development-branch to open the PR (local parity; deploy is a follow-up noted in the PR body). + +--- + +## Notes for the executor + +- **DRY/YAGNI:** the ported components are near-verbatim; do not refactor them. Only the documented edits (agent import, empty-state, store hydration, client-tool trimming) are in scope. +- **Standalone-examples convention:** everything lives in `examples/chat`; never import across examples or extract to a lib. +- **No brand-name churn:** do not rename existing `@threadplane/*` imports beyond the `ag-ui → langgraph` swap in `itinerary-panel`. +- **Maps footgun:** a keyless bundle silently disables the map (no error). Verify `generated-keys.local.ts` before every live check. +- **If the spike (Task 1) fails the live proof:** stop and reconcile the client-tool payload key before building the frontend — the whole cockpit depends on it. diff --git a/docs/superpowers/specs/2026-07-06-langgraph-canonical-app-mode-itinerary-design.md b/docs/superpowers/specs/2026-07-06-langgraph-canonical-app-mode-itinerary-design.md new file mode 100644 index 000000000..a82260531 --- /dev/null +++ b/docs/superpowers/specs/2026-07-06-langgraph-canonical-app-mode-itinerary-design.md @@ -0,0 +1,156 @@ +# App Mode on the LangChain Canonical Demo — Itinerary Cockpit (Design) + +**Date:** 2026-07-06 +**Status:** Approved design, pending spec review → implementation plan +**Owner:** Brian Love +**Branch:** `feat/langgraph-app-mode-itinerary` + +## Goal + +Bring the App-mode map cockpit — the travel-itinerary trip planner currently exclusive to `examples/ag-ui` — to the **langchain canonical demo** (`examples/chat`, backed by `@threadplane/langgraph`). This proves the langgraph adapter reaches App-mode feature parity with ag-ui, using a single shared agent. + +The one deliberate architectural divergence from ag-ui: **the itinerary lives in the langgraph graph state (checkpointed per thread) alongside `messages`**, rather than in a frontend-only store. This showcases langgraph's durable per-thread state. + +The demo starts **empty** — there is no seed data. Users explore and prompt the agent (typically via a welcome suggestion) to generate a trip plan; the agent both **recommends** places and **populates the app state** as it goes, building the itinerary/map live. + +## Scope + +**In scope (this effort — local parity):** +- Full App-mode cockpit in `examples/chat`: map background, floating itinerary overlay, `appMode` toggle, map-compatible routing (sidebar/popup), welcome suggestions, app-mode promo. +- Itinerary as first-class langgraph graph state, synced client-authoritatively (see State Design). +- Backend: extend the existing `chat` graph (bind client tools + inject itinerary context) — **no second agent/graph**. +- Google Maps key wired into `examples/chat` locally via the `inject-env` `GENERATED_KEYS` mechanism. +- Verification: unit tests at each seam + `examples/chat` e2e for the App-mode cockpit + a live Chrome-MCP smoke against the running langgraph stack (real LLM). + +**Explicit non-goals (deferred / rejected):** +- **Deployment** — no Vercel Maps key, no update to the deployed langgraph graph, no prod smoke. Local-first; deploy is a follow-up. +- **Shared library extraction** — the itinerary/map surface is **duplicated** into `examples/chat`, not promoted to a lib. This follows the standalone-examples convention (examples own their code; never share across examples). +- **Server-side geocoding** — geocoding stays in the browser (Maps JS geocoder). No server Google Geocoding key. +- **Second agent / dedicated itinerary graph** — rejected; App mode is a pure layout over the single shared `chat` agent. + +## Architecture Overview + +App mode is a **presentational layer** over the same agent that powers plain chat. The single `chat` graph gains: +1. an `itinerary` key in its `State` (checkpointed per thread), and +2. the ability to bind frontend-declared client tools (`threadplane-middleware`'s `bind_client_tools`) and to see the current itinerary in the `generate` node's context. + +The frontend keeps a live `ItineraryStore` (Angular signals) as the **working copy** driving the map/panel; the graph checkpoint is the **durable record**, synced from the client. + +``` +User / Agent edits ──▶ ItineraryStore (signals, live) + │ map + panel render from this instantly + ▼ + submit(): input.state.itinerary = store.stops() ─┐ (turn start: model sees the trip) + updateState({ itinerary }) after run / on edit ─┴─▶ langgraph checkpoint (durable, per thread) + ▲ + thread switch ────┘ hydrate store from agent.values().itinerary + new thread ───────── empty itinerary → prompt-to-plan (agent populates live) +``` + +## State Design (the core decision) + +### Graph state shape (`examples/chat/python/src/graph.py`) + +The current `State` is extended with a flat itinerary list that mirrors the frontend `ItineraryStop` shape exactly (so the panel/map render logic ports 1:1): + +```python +from typing_extensions import TypedDict, NotRequired +from typing import Annotated, Optional + +class Stop(TypedDict): + id: str + day: int + place: str + note: NotRequired[str] + lat: NotRequired[float] + lng: NotRequired[float] + +class State(TypedDict): + messages: Annotated[list, add_messages] + model: Optional[str] + reasoning_effort: Optional[str] + gen_ui_mode: Optional[str] + itinerary: list[Stop] # NEW — plain key = last-write-wins overwrite +``` + +- **Flat list, day-as-field.** Grouping-by-day stays a pure frontend view (`days()` computed). Matches the store's canonical shape. +- **Plain (non-`Annotated`) key → last-write-wins.** The client always sends the full list (via `input.state` and `updateState`), so an append-style reducer would only duplicate entries. Overwrite is correct for a single agent. +- **No seed data, no seeding concept.** A fresh thread starts with an empty `itinerary` (`[]`). The user drives plan creation by prompting the agent; the agent populates the itinerary via the client tools. The graph tolerates an absent/empty `itinerary` everywhere. + +### Ownership & mutation model: client tools + state sync + +Mutations run in the **browser** (reusing the ag-ui client tools almost verbatim — browser geocoding, compute-next-stops), and the result is synced into the durable checkpoint: + +1. **Turn start** — the shell's `submit` wrapper injects `state.itinerary = store.stops()` (the identical mechanism already used for `model`/`reasoning_effort`/`gen_ui_mode`). The `generate` node folds a compact summary into context, so the model always sees the current trip. **`get_itinerary` is removed** (no read round-trip). +2. **During a turn** — the model calls `add_stop` / `move_stop` / `clear_day` (client tools). The browser executes them: geocodes, updates the `ItineraryStore` (live map/panel), returns the result as the tool message. +3. **Sync to checkpoint** — after a run settles, the shell calls `agent.updateState({ itinerary: store.stops() })` to capture the agent's edits. Direct user panel edits (drag-reorder, add, clear) between runs call the same `updateState` immediately (no active run to conflict with). +4. **Hydration** — switching threads loads the store from `agent.values().itinerary` (server truth). A brand-new thread starts **empty** (no seed) and shows the prompt-to-plan empty state until the user asks the agent to build a plan. + +**localStorage is dropped** — the checkpoint is now the durable store. Consequence: a brand-new thread opens on an **empty** map + itinerary with a prompt-to-plan empty state, and the itinerary is **per thread** — switching threads swaps the map to that thread's plan. + +### Ephemeral UI state stays frontend + +`recentlyChangedId` (agent-edit pulse) and `focusedStopId` (map focus) are transient view concerns — they remain frontend signals and are **not** persisted to graph state. + +### Verified enabling APIs (`@threadplane/langgraph`) + +- `agent.values(): Signal` — current graph state values (the `values` stream mode is already enabled). Frontend renders the itinerary from `values().itinerary`. +- `agent.updateState(values, signal, { asNode })` — writes a checkpoint as if a node produced the values. Used for the client → checkpoint sync. +- `provideAgent(..., { initialValues })` — available for first-paint defaults; here it is omitted or `{ itinerary: [] }` (no seed data). +- `mergeClientTools` / `createClientToolsCapability` (TS) + `threadplane.middleware.langgraph.bind_client_tools` (Python) — the frontend-client-tool binding path, already used by ag-ui. + +## Backend Changes (`examples/chat/python`) + +Additive to the existing `chat` graph — plain chat is unaffected when no client-tool catalog is sent. + +- Add `threadplane-middleware>=0.0.1` to `pyproject.toml`. +- `State` gains the `itinerary: list[Stop]` key (above). +- `generate` node: when `state["itinerary"]` is non-empty, inject a compact JSON summary into the system context so the model reasons over the current trip. +- `generate` node: bind the frontend client-tool catalog via `bind_client_tools` (only affects runs where the App-mode frontend sends the catalog), composing with the existing server tools (`search_documents`, `request_approval`, `research`, `gen_ui_tool`). +- No new graph, no new `graph_id` in `langgraph.json`. + +### Planner behavior & prompt tuning + +With no seed data, the whole experience depends on the agent turning a request into a populated plan. The prompt/tool tuning is a first-class deliverable, not an afterthought: + +- **System prompt (App-mode / planner framing):** when the itinerary client tools are bound, the `generate` node's system context casts the agent as a trip-planning assistant that, given a request, (1) **recommends** concrete places (with a one-line note each) grouped into days, and (2) **populates the app state** by calling `add_stop` for each recommendation and `day_card` to surface a day — rather than only describing the plan in prose. It revises via `move_stop` / `clear_day` when the user asks. The current `state["itinerary"]` (possibly empty) is injected so the agent knows what already exists and only adds what's missing. +- **Tool descriptions:** tuned so the model reliably *acts* (calls `add_stop` as it recommends) instead of narrating. Descriptions are the primary steering; the system framing above is light supplemental coaching, warranted here because there is no seed to imply the pattern. +- **Welcome suggestions** are concrete trip-planning starters that both invite exploration and trigger plan generation — e.g. "Plan a long weekend in Paris", "3 days in Tokyo with great food", "A week on the California coast". Selecting one sends the prompt and the agent builds the plan live. +- **Definition of done for the prompt:** a cold thread + one welcome suggestion yields multiple `add_stop` calls that populate the map + panel with recommended, geocoded stops across days — verified in the live Chrome-MCP gate. + +## Frontend Changes (`examples/chat/angular`) + +### Duplicated surface (ported from `examples/ag-ui`, `@threadplane/ag-ui` → `@threadplane/langgraph`) + +~1,300 LOC copied and re-wired (agent import, `submit` state field, `values`/`updateState` sync): +`map-canvas.component` (neutral default view when empty; fit-to-bounds once stops exist), `itinerary-panel.component` (+spec; **empty-state CTA** — "Ask the assistant to plan a trip" — shown when there are no stops), `itinerary-store` (localStorage **and** the `SEED` constant removed; empty initial state; hydrate-from-`values` added), `map-bounds` (+spec), `geocoding.service` (+spec), `google-maps-loader`, `client-tools` (get_itinerary dropped; results sync to checkpoint), `day-card.component`, `clear-day-confirm.component`, plus `modes/app-mode-promo.component` and `modes/welcome-suggestions` retuned to trip-planning starters. + +### Shell reconciliation (`shell/demo-shell.component`) — layout ① + +- Add an `appMode` toggle to the toolbar + `hasMapsKey` gate (from `environment.googleMapsApiKey`). +- **In App mode:** the thread sidenav auto-collapses to the hamburger drawer (demo-shell already has drawer mode + hamburger). The map renders full-bleed background, the itinerary floats as a left overlay, chat is the right rail (sidebar) / bubble (popup). Faithful to ag-ui's cockpit; reuses demo-shell's existing drawer machinery. +- Port ag-ui-shell's `appMode` param-sync effect (persist to URL; App mode valid in sidebar/popup; coerce `embed → sidebar` when App mode is on), coexisting with demo-shell's `/:threadId` route matcher. +- `submit` wrapper extended to inject `state.itinerary`; post-run hook calls `updateState`. + +### Config + +- `environment.ts` / `environment.development.ts`: add `googleMapsApiKey` + `googleMapsMapId` from `GENERATED_KEYS` (port the `inject-env` wiring from ag-ui). Local `.env` only. +- `app.config.ts`: `provideAgent(...)` wires the itinerary client-tools registry; **no seed** (omit `initialValues`, or pass `{ itinerary: [] }`). + +## Testing Strategy + +- **Unit (vitest):** `itinerary-store` starts empty (no `SEED`) + hydrate-from-`values` + no-localStorage; `map-bounds`; `geocoding.service`; the `submit`-wrapper injects `state.itinerary`; `updateState` called on run-settle and on user edit; empty-state CTA renders with zero stops; App-mode routing coercion (`embed → sidebar`). +- **Backend (pytest):** `generate` binds client tools when catalog present and not otherwise; itinerary context injected when `state["itinerary"]` non-empty and tolerated when empty/absent; the planner framing is applied only when the catalog is bound; plain-chat path unchanged. +- **e2e (`examples/chat` Playwright):** App-mode cockpit renders (map + overlay + right-rail chat); a fresh thread shows the empty-state prompt-to-plan; sidenav collapses to drawer in App mode; per-thread itinerary swaps on thread switch. (Map tiles gated on a local Maps key — assert DOM/layout, not tile pixels, per the Maps-canvas harness lesson.) +- **Live gate (Chrome MCP):** against the running `:4200` + `:2024` stack with a real LLM — start from an **empty** thread, send a planning suggestion ("plan 3 days in Tokyo"), confirm the agent recommends stops and populates the map/panel live via multiple `add_stop` calls, then reload mid-thread and confirm the plan restores from the checkpoint. + +## Risks & Mitigations + +- **Client-tool payload key alignment** (TS `client_tools` vs Python `bind_client_tools` reading `state["tools"]`): de-risk with a tiny spike early — send the itinerary catalog from `examples/chat` and confirm the model can call `add_stop`. The mechanism is proven in ag-ui; only the langgraph-adapter payload shape needs confirming. +- **`updateState` vs active run**: only call post-run (run settled) for agent edits; user edits occur between runs. Never call during an active run. +- **Shell coexistence**: the appMode param-sync effect and the `/:threadId` matcher both navigate — port ag-ui's `untracked(mode)` + absolute-`/sidebar` discipline to avoid the bootstrap `→ /embed` bounce. +- **Maps key footgun** (worktree has no local `.env`): symlink the main-checkout `.env` into the worktree root before serving (per the established runbook). + +## Open Questions + +None blocking. Deploy wiring, lib extraction, and server geocoding are explicit non-goals for this effort. diff --git a/examples/chat/angular/e2e/app-mode-itinerary.spec.ts b/examples/chat/angular/e2e/app-mode-itinerary.spec.ts new file mode 100644 index 000000000..c6c5c3660 --- /dev/null +++ b/examples/chat/angular/e2e/app-mode-itinerary.spec.ts @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: MIT +// App-mode itinerary cockpit — structural e2e. These assert layout/DOM, NOT +// map tiles (the WebGL/vector map does not render reliably in the automated +// browser, and the bundle is keyless in CI). App mode is reached via the +// URL (`?appmode=on`), which is honored regardless of the Maps key, so these +// run in keyless CI. +import { test, expect } from '@playwright/test'; +import { openDemo } from './test-helpers'; + +test.describe('App mode — itinerary cockpit', () => { + test('shows the cockpit with an empty prompt-to-plan state', async ({ page }) => { + await openDemo(page, '/sidebar?appmode=on'); + + // The map canvas (in the sidebar content slot) and the floating itinerary + // overlay both mount in App mode. + await expect(page.locator('app-map-canvas')).toBeAttached(); + await expect(page.locator('app-itinerary-panel')).toBeVisible(); + + // Empty start (no seed) → the panel invites the user to ask for a plan. + await expect(page.locator('app-itinerary-panel')).toContainText(/plan/i); + + // Layout ①: the thread sidenav collapses to the hamburger drawer in App mode. + await expect(page.locator('.demo-shell__hamburger')).toBeVisible(); + }); + + test('selecting Embed turns App mode off (coercion)', async ({ page }) => { + await openDemo(page, '/sidebar?appmode=on'); + await expect(page.locator('app-map-canvas')).toBeAttached(); + + await page.getByRole('button', { name: 'Embed', exact: true }).click(); + + // Embed can't coexist with the map → route drops to /embed and the cockpit + // tears down. + await expect(page).toHaveURL(/\/embed/); + await expect(page.locator('app-map-canvas')).toHaveCount(0); + await expect(page.locator('app-itinerary-panel')).toHaveCount(0); + }); +}); diff --git a/examples/chat/angular/project.json b/examples/chat/angular/project.json index 3f4dd626b..140dc0e75 100644 --- a/examples/chat/angular/project.json +++ b/examples/chat/angular/project.json @@ -5,8 +5,18 @@ "projectType": "application", "prefix": "app", "targets": { + "inject-env": { + "executor": "nx:run-commands", + "options": { + "command": "node examples/chat/angular/scripts/inject-env.mjs", + "cwd": "{workspaceRoot}" + } + }, "build": { "executor": "@angular/build:application", + "dependsOn": [ + "inject-env" + ], "outputs": [ "{options.outputPath.base}" ], @@ -49,7 +59,13 @@ "maximumError": "16kb" } ], - "outputHashing": "all" + "outputHashing": "all", + "fileReplacements": [ + { + "replace": "examples/chat/angular/src/environments/generated-keys.ts", + "with": "examples/chat/angular/src/environments/generated-keys.local.ts" + } + ] }, "production-debug": { "define": { @@ -80,6 +96,10 @@ { "replace": "examples/chat/angular/src/environments/environment.ts", "with": "examples/chat/angular/src/environments/environment.development.ts" + }, + { + "replace": "examples/chat/angular/src/environments/generated-keys.ts", + "with": "examples/chat/angular/src/environments/generated-keys.local.ts" } ] } @@ -89,6 +109,9 @@ "serve": { "continuous": true, "executor": "@angular/build:dev-server", + "dependsOn": [ + "inject-env" + ], "options": { "port": 4200 }, diff --git a/examples/chat/angular/public/app-mode-preview.jpg b/examples/chat/angular/public/app-mode-preview.jpg new file mode 100644 index 000000000..b2f000ff0 Binary files /dev/null and b/examples/chat/angular/public/app-mode-preview.jpg differ diff --git a/examples/chat/angular/scripts/inject-env.mjs b/examples/chat/angular/scripts/inject-env.mjs new file mode 100644 index 000000000..93500c617 --- /dev/null +++ b/examples/chat/angular/scripts/inject-env.mjs @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: MIT +import { readFileSync, writeFileSync, existsSync } from 'node:fs'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(__dirname, '../../../..'); + +function readDotEnv() { + const envPath = resolve(repoRoot, '.env'); + if (!existsSync(envPath)) return {}; + const raw = readFileSync(envPath, 'utf8'); + const out = {}; + for (const line of raw.split('\n')) { + const m = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/); + if (m) out[m[1]] = m[2].replace(/^"|"$/g, ''); + } + return out; +} + +const env = { ...readDotEnv(), ...process.env }; +const key = env.GOOGLE_MAPS_API_KEY ?? ''; +const mapId = env.GOOGLE_MAPS_MAP_ID ?? ''; + +const targetPath = resolve(__dirname, '../src/environments/generated-keys.local.ts'); +const contents = `// SPDX-License-Identifier: MIT +// AUTO-GENERATED by scripts/inject-env.mjs. Do not edit by hand. +export const GENERATED_KEYS = { + googleMaps: ${JSON.stringify(key)}, + googleMapsMapId: ${JSON.stringify(mapId)}, +} as const; +`; +writeFileSync(targetPath, contents); +console.log(`[inject-env] wrote generated-keys.local.ts (key length: ${key.length}, mapId: ${mapId ? 'set' : 'unset'})`); diff --git a/examples/chat/angular/src/app/app.config.ts b/examples/chat/angular/src/app/app.config.ts index d75ac3c38..b1103d0c6 100644 --- a/examples/chat/angular/src/app/app.config.ts +++ b/examples/chat/angular/src/app/app.config.ts @@ -1,10 +1,18 @@ // SPDX-License-Identifier: MIT -import { ApplicationConfig, provideBrowserGlobalErrorListeners, provideZonelessChangeDetection } from '@angular/core'; +import { + ApplicationConfig, + inject, + provideBrowserGlobalErrorListeners, + provideEnvironmentInitializer, + provideZonelessChangeDetection, +} from '@angular/core'; import { provideRouter, withComponentInputBinding } from '@angular/router'; import { provideThreadplaneTelemetry } from '@threadplane/telemetry/browser'; import { LANGGRAPH_THREADS_CONFIG, LANGGRAPH_CLIENT_OPTIONS } from '@threadplane/langgraph'; import { provideChat } from '@threadplane/chat'; import { e2eClientOptions } from './shell/e2e-overrides'; +import { ItineraryStore } from './itinerary-store'; +import { GoogleMapsLoader } from './google-maps-loader'; import { routes } from './app.routes'; import { environment } from '../environments/environment'; @@ -32,5 +40,12 @@ export const appConfig: ApplicationConfig = { provideChat({ license: environment.license, }), + // App-wide singleton so DemoShell, the itinerary panel, and the map cockpit + // all read/write ONE working copy of the itinerary. Provided at root (not at + // the component) so routed children share the same instance. + ItineraryStore, + // Eagerly kick off the Google Maps JS API load at bootstrap so the map + // canvas can mount as soon as App mode turns on (no per-component wait). + provideEnvironmentInitializer(() => inject(GoogleMapsLoader).ensureLoaded()), ], }; diff --git a/examples/chat/angular/src/app/clear-day-confirm.component.ts b/examples/chat/angular/src/app/clear-day-confirm.component.ts new file mode 100644 index 000000000..2500da8e8 --- /dev/null +++ b/examples/chat/angular/src/app/clear-day-confirm.component.ts @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: MIT +import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core'; +import { injectRenderHost } from '@threadplane/render'; +import { ItineraryStore } from './itinerary-store'; + +/** + * The interactive component for the `clear_day` client tool (an `ask`). The + * model fills `day`; the user confirms or cancels. Because an ask emits the + * tool result and the handler layer cannot intercept it, the mutation happens + * HERE: Clear writes the shared `ItineraryStore` (so the panel updates live) + * and then announces the outcome via `injectRenderHost().result(...)`, which + * becomes the tool result that resumes the run. Cancel never touches the store. + * + * Once the ask resolves, the adapter writes the emitted value back onto the + * local tool call, so this component re-renders with `cleared`/`removed` as + * props (chat-tool-views spreads `{...args, ...result, status}` into it). When + * `cleared()` is defined we render a FROZEN line with no buttons; the live + * interactive card only shows while `cleared()` is still undefined. + */ +@Component({ + selector: 'app-clear-day-confirm', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + @if (cleared() === undefined) { +
+

Clear all {{ count() }} stops on day {{ day() }}?

+
+ + +
+
+ } @else if (cleared() === true) { +
+

Day {{ day() }} cleared — {{ removed() }} removed ✓

+
+ } @else { +
+

Kept day {{ day() }} — clear cancelled

+
+ } + `, + styles: [ + ` + .cdc { + border: 1px solid var(--tplane-chat-separator, #e5e7eb); + border-radius: 12px; + padding: 16px; + max-width: 360px; + } + .cdc--resolved .cdc__summary { + margin: 0; + opacity: 0.85; + } + .cdc__summary { + margin: 0 0 12px; + } + .cdc__actions { + display: flex; + gap: 8px; + } + .cdc__btn { + padding: 6px 14px; + border-radius: 8px; + border: 1px solid var(--tplane-chat-separator, #e5e7eb); + background: transparent; + color: inherit; + cursor: pointer; + } + .cdc__btn--primary { + background: var(--tplane-chat-accent, #2563eb); + color: #fff; + border-color: transparent; + } + `, + ], +}) +export class ClearDayConfirmComponent { + readonly day = input.required(); + /** Spread back onto props after the ask resolves (undefined while interactive). */ + readonly cleared = input(undefined); + readonly removed = input(undefined); + private readonly store = inject(ItineraryStore); + private readonly host = injectRenderHost(); + + protected readonly count = computed( + () => this.store.stops().filter((s) => s.day === this.day()).length, + ); + + protected clear(): void { + const day = this.day(); + const removed = this.store.clearDay(day); + this.host.result({ cleared: true, day, removed }); + } + + protected cancel(): void { + this.host.result({ cleared: false, day: this.day() }); + } +} diff --git a/examples/chat/angular/src/app/client-tools.spec.ts b/examples/chat/angular/src/app/client-tools.spec.ts new file mode 100644 index 000000000..1d12359d7 --- /dev/null +++ b/examples/chat/angular/src/app/client-tools.spec.ts @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: MIT +import { describe, it, expect, beforeEach } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { ItineraryStore } from './itinerary-store'; +import { GeocodingService } from './geocoding.service'; +import { itineraryClientTools } from './client-tools'; + +describe('itineraryClientTools (langgraph demo)', () => { + beforeEach(() => TestBed.configureTestingModule({ providers: [ItineraryStore, GeocodingService] })); + + it('exposes mutation tools but NOT get_itinerary', () => { + const registry = TestBed.runInInjectionContext(() => itineraryClientTools()); + const names = Object.keys(registry as Record); + expect(names).toContain('add_stop'); + expect(names).toContain('clear_day'); + expect(names).not.toContain('get_itinerary'); + }); +}); diff --git a/examples/chat/angular/src/app/client-tools.ts b/examples/chat/angular/src/app/client-tools.ts new file mode 100644 index 000000000..3c8a0f065 --- /dev/null +++ b/examples/chat/angular/src/app/client-tools.ts @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: MIT +import { inject } from '@angular/core'; +import { tools, action, view, ask, type ClientToolRegistry } from '@threadplane/chat'; +import { z } from 'zod/v4'; +import { ItineraryStore } from './itinerary-store'; +import { GeocodingService } from './geocoding.service'; +import { DayCardComponent, DAY_CARD_SCHEMA } from './day-card.component'; +import { ClearDayConfirmComponent } from './clear-day-confirm.component'; + +/** Schema for the `clear_day` ask tool — exported in case consumers want to + * derive types from it (e.g. `ViewProps`). */ +export const CLEAR_DAY_SCHEMA = z.object({ day: z.number().int().min(1) }); + +/** Client tools over the frontend-owned itinerary. Call inside an injection + * context (e.g. a field initializer in App). The descriptions are the ONLY + * steering the model gets — no system-prompt coaching (by design). */ +export function itineraryClientTools(): ClientToolRegistry { + const store = inject(ItineraryStore); + const geocoding = inject(GeocodingService); + return tools({ + add_stop: action( + 'Add a stop to a day of the trip itinerary. Afterwards, show the updated day with day_card.', + z.object({ day: z.number().int().min(1), place: z.string(), note: z.string().optional() }), + async ({ day, place, note }) => { + const coords = (await geocoding.geocode(place)) ?? undefined; + return { added: store.add(day, place, note, coords ? { coords } : undefined) }; + }, + ), + move_stop: action( + 'Move an existing stop (matched by place name) to another day. Afterwards, show the updated day with day_card.', + z.object({ place: z.string(), toDay: z.number().int().min(1) }), + async ({ place, toDay }) => { + const target = store.stops().find((s) => s.place.toLowerCase() === place.toLowerCase()); + if (!target) { + return { error: `No stop named "${place}" — check the current itinerary.` }; + } + const toDayStops = store.stops().filter((s) => s.day === toDay && s.id !== target.id); + store.reorder(target.id, toDay, toDayStops.length); + return { moved: { ...target, day: toDay } }; + }, + ), + reorder_stop: action( + 'Reorder a stop within or across days. Use after the user describes a sequence change (e.g., "put Louvre first", "move Eiffel to day 2 second"). `toIndex` is zero-based within the target day.', + z.object({ + place: z.string(), + toDay: z.number().int().min(1), + toIndex: z.number().int().min(0), + }), + async ({ place, toDay, toIndex }) => { + const target = store.stops().find((s) => s.place.toLowerCase() === place.toLowerCase()); + if (!target) { + return { error: `No stop named "${place}" — check the current itinerary.` }; + } + store.reorder(target.id, toDay, toIndex); + return { reordered: { ...target, day: toDay, toIndex } }; + }, + ), + clear_day: ask( + 'Ask the user to confirm clearing every stop on a day, then clear it if they accept.', + CLEAR_DAY_SCHEMA, + ClearDayConfirmComponent, + ), + day_card: view( + "Show the user a visual recap card for one itinerary day. Call it after add_stop or move_stop with the day's full updated place list.", + DAY_CARD_SCHEMA, + DayCardComponent, + ), + }); +} diff --git a/examples/chat/angular/src/app/day-card.component.ts b/examples/chat/angular/src/app/day-card.component.ts new file mode 100644 index 000000000..7a536164c --- /dev/null +++ b/examples/chat/angular/src/app/day-card.component.ts @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: MIT +import { ChangeDetectionStrategy, Component, input } from '@angular/core'; +import type { ViewProps } from '@threadplane/chat'; +import { z } from 'zod/v4'; + +/** + * Schema for the `day_card` view tool — co-located with the component so the + * inputs and the schema shape can be kept in sync at a glance. + * `client-tools.ts` imports this schema to pass to `view(…, DAY_CARD_SCHEMA, …)`. + */ +export const DAY_CARD_SCHEMA = z.object({ + day: z.number().int().min(1), + places: z.array(z.string()), +}); + +/** Input types derived directly from the `day_card` schema — guarantees this + * component stays compatible with the view() check at compile time. */ +type Inputs = ViewProps; + +/** + * A frontend-owned view rendered for the `day_card` client tool. The model + * fills `day` and `places`; this card recaps one itinerary day after an edit. + */ +@Component({ + selector: 'app-day-card', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+
Day {{ day() }}
+
    + @for (p of places(); track p) { +
  • {{ p }}
  • + } @empty { +
  • No stops
  • + } +
+
+ `, + styles: [ + ` + .dc { + border: 1px solid var(--tplane-chat-separator, #e5e7eb); + border-radius: 12px; + padding: 16px; + max-width: 280px; + } + .dc__head { + font-weight: 600; + margin-bottom: 8px; + } + .dc__list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 4px; + } + .dc__item { + opacity: 0.9; + } + .dc__item--empty { + opacity: 0.5; + } + `, + ], +}) +export class DayCardComponent { + readonly day = input.required(); + readonly places = input([]); +} diff --git a/examples/chat/angular/src/app/geocoding.service.spec.ts b/examples/chat/angular/src/app/geocoding.service.spec.ts new file mode 100644 index 000000000..d8501107b --- /dev/null +++ b/examples/chat/angular/src/app/geocoding.service.spec.ts @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: MIT +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { GeocodingService } from './geocoding.service'; + +beforeEach(() => { + (globalThis as any).google = { + maps: { + Geocoder: class { + async geocode({ address }: { address: string }) { + if (address === 'fail') throw new Error('boom'); + if (address === 'empty') return { results: [] }; + return { + results: [ + { geometry: { location: { lat: () => 48.85, lng: () => 2.35 } } }, + ], + }; + } + }, + }, + }; +}); + +afterEach(() => { + delete (globalThis as any).google; +}); + +describe('GeocodingService', () => { + it('resolves an address to { lat, lng }', async () => { + const svc = TestBed.inject(GeocodingService); + expect(await svc.geocode('Louvre')).toEqual({ lat: 48.85, lng: 2.35 }); + }); + + it('returns null when geocoding throws', async () => { + const svc = TestBed.inject(GeocodingService); + expect(await svc.geocode('fail')).toBeNull(); + }); + + it('returns null when there are no results', async () => { + const svc = TestBed.inject(GeocodingService); + expect(await svc.geocode('empty')).toBeNull(); + }); + + it('returns null when google.maps is not loaded', async () => { + delete (globalThis as any).google; + const svc = TestBed.inject(GeocodingService); + expect(await svc.geocode('Louvre')).toBeNull(); + }); +}); diff --git a/examples/chat/angular/src/app/geocoding.service.ts b/examples/chat/angular/src/app/geocoding.service.ts new file mode 100644 index 000000000..a3c20069f --- /dev/null +++ b/examples/chat/angular/src/app/geocoding.service.ts @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: MIT +/// +import { Injectable } from '@angular/core'; + +/** + * Wraps the Google Maps Geocoder to resolve a place string to coordinates. + * Returns null on any failure — Maps not loaded, no results, or a thrown + * error — so callers can add a stop with no pin rather than break. + */ +@Injectable({ providedIn: 'root' }) +export class GeocodingService { + private geocoder: google.maps.Geocoder | null = null; + + async geocode(address: string): Promise<{ lat: number; lng: number } | null> { + const g = (globalThis as { google?: typeof google }).google; + if (!g?.maps?.Geocoder) return null; + try { + this.geocoder ??= new g.maps.Geocoder(); + const { results } = await this.geocoder.geocode({ address }); + const first = results?.[0]; + if (!first) return null; + return { lat: first.geometry.location.lat(), lng: first.geometry.location.lng() }; + } catch { + return null; + } + } +} diff --git a/examples/chat/angular/src/app/google-maps-loader.ts b/examples/chat/angular/src/app/google-maps-loader.ts new file mode 100644 index 000000000..297899eef --- /dev/null +++ b/examples/chat/angular/src/app/google-maps-loader.ts @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: MIT +import { Injectable, signal } from '@angular/core'; +import { environment } from '../environments/environment'; + +/** + * Loads the Google Maps JS API on demand and exposes a `loaded` signal. + * + * Why this exists: `` (from `@angular/google-maps`) THROWS in its + * constructor when `window.google` is absent ("Namespace google not found…", + * dev-mode only). The Maps script loads asynchronously, so rendering the map + * before it resolves aborts the host component's change-detection pass — which + * (on a fresh load with App mode persisted on) blanks the whole shell. The fix + * is the documented `@angular/google-maps` contract: only render `` + * once the API is present. Consumers gate their template on `loaded()` and call + * `ensureLoaded()` once. + */ +@Injectable({ providedIn: 'root' }) +export class GoogleMapsLoader { + /** Becomes true once `window.google.maps` is available. */ + readonly loaded = signal(false); + private started = false; + + /** Idempotent: injects the Maps script once (if a key is present) and flips + * `loaded` when ready. Safe to call from multiple components. */ + ensureLoaded(): void { + if (this.loaded() || this.started) return; + const w = globalThis as { google?: { maps?: unknown }; document?: Document }; + if (w.google?.maps) { + this.loaded.set(true); + return; + } + this.started = true; + + const key = (environment.googleMapsApiKey as string) ?? ''; + if (!key) return; // No key → map stays gated off (the toolbar toggle is also disabled). + + const doc = w.document; + if (!doc) return; + + const existing = doc.querySelector('script[data-google-maps]'); + if (existing) { + // A load is already in flight (e.g. a prior instance). Poll for readiness. + const poll = setInterval(() => { + if ((globalThis as { google?: { maps?: unknown } }).google?.maps) { + clearInterval(poll); + this.loaded.set(true); + } + }, 100); + return; + } + + const script = doc.createElement('script'); + script.src = `https://maps.googleapis.com/maps/api/js?key=${encodeURIComponent(key)}&libraries=geocoding,marker`; + script.async = true; + script.setAttribute('data-google-maps', ''); + script.addEventListener('load', () => this.loaded.set(true)); + doc.head.appendChild(script); + } +} diff --git a/examples/chat/angular/src/app/itinerary-panel.component.spec.ts b/examples/chat/angular/src/app/itinerary-panel.component.spec.ts new file mode 100644 index 000000000..663e03c55 --- /dev/null +++ b/examples/chat/angular/src/app/itinerary-panel.component.spec.ts @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: MIT +import { describe, it, expect, beforeEach } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { provideAgent, FakeStreamTransport } from '@threadplane/langgraph'; +import { DEMO_AGENT_REF } from './shell/agent-ref'; +import { ItineraryPanelComponent } from './itinerary-panel.component'; +import { ItineraryStore } from './itinerary-store'; + +// Bind the DEMO_AGENT_REF token to an in-process fake LangGraph agent so +// injectAgent(DEMO_AGENT_REF) in the panel resolves without a backend. +const fakeAgentProvider = provideAgent(DEMO_AGENT_REF, { + assistantId: 'fake', + transport: new FakeStreamTransport(), +}); + +describe('ItineraryPanelComponent — agent-edit pulse', () => { + beforeEach(() => localStorage.clear()); + + it('applies .itin__stop--pulse to the row matching recentlyChangedId', async () => { + TestBed.configureTestingModule({ + providers: [ + ItineraryStore, + ...fakeAgentProvider, + ], + }); + const store = TestBed.inject(ItineraryStore); + const added = store.add(3, 'Sacré-Cœur'); // agent source by default + + const fixture = TestBed.createComponent(ItineraryPanelComponent); + fixture.detectChanges(); + + const rows = fixture.nativeElement.querySelectorAll('.itin__stop'); + const pulsing = Array.from(rows).filter((el: any) => + el.classList.contains('itin__stop--pulse'), + ); + expect(pulsing.length).toBe(1); + expect((pulsing[0] as HTMLElement).textContent).toContain('Sacré-Cœur'); + // satisfy lint + expect(added.id).toBeDefined(); + }); + + it('toggles the itin--collapsed host class via the collapse button', () => { + TestBed.configureTestingModule({ + providers: [ + ItineraryStore, + ...fakeAgentProvider, + ], + }); + + const fixture = TestBed.createComponent(ItineraryPanelComponent); + fixture.detectChanges(); + + const host = fixture.nativeElement as HTMLElement; + expect(host.classList.contains('itin--collapsed')).toBe(false); + + const toggle = host.querySelector('.itin__collapse') as HTMLButtonElement; + expect(toggle).toBeTruthy(); + + toggle.click(); + fixture.detectChanges(); + expect(host.classList.contains('itin--collapsed')).toBe(true); + + toggle.click(); + fixture.detectChanges(); + expect(host.classList.contains('itin--collapsed')).toBe(false); + }); + + it('highlights the focused row', () => { + TestBed.configureTestingModule({ + providers: [ + ItineraryStore, + ...fakeAgentProvider, + ], + }); + const store = TestBed.inject(ItineraryStore); + // The chat-demo store starts empty, so seed a stop before focusing it. + const stop = store.add(1, 'Louvre'); + store.focus(stop.id); + + const fixture = TestBed.createComponent(ItineraryPanelComponent); + fixture.detectChanges(); + + const rows = fixture.nativeElement.querySelectorAll('.itin__stop'); + const pulsing = Array.from(rows).filter((el: any) => + el.classList.contains('itin__stop--pulse'), + ); + expect(pulsing.length).toBe(1); + }); +}); diff --git a/examples/chat/angular/src/app/itinerary-panel.component.ts b/examples/chat/angular/src/app/itinerary-panel.component.ts new file mode 100644 index 000000000..ef3a4ec29 --- /dev/null +++ b/examples/chat/angular/src/app/itinerary-panel.component.ts @@ -0,0 +1,533 @@ +// SPDX-License-Identifier: MIT +import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core'; +import { CdkDragDrop, DragDropModule } from '@angular/cdk/drag-drop'; +import { injectAgent } from '@threadplane/langgraph'; +import { DEMO_AGENT_REF } from './shell/agent-ref'; +import { ItineraryStop, ItineraryStore } from './itinerary-store'; + +@Component({ + selector: 'app-itinerary-panel', + standalone: true, + imports: [DragDropModule], + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + class: 'itin', + role: 'region', + 'aria-label': 'Trip itinerary', + '[class.itin--collapsed]': 'collapsed()', + }, + template: ` +
+

+ Trip itinerary + · {{ totalLabel() }} +

+
+ + +
+ @if (menuOpen()) { + + } +
+ +
+ @for (g of store.days(); track g.day) { +
+
+

Day {{ g.day }}

+ {{ g.stops.length }} stop{{ g.stops.length === 1 ? '' : 's' }} + +
+
    + @for (s of g.stops; track s.id; let i = $index) { +
  • + drag_indicator + {{ i + 1 }} + + {{ s.place }} + @if (s.note) { {{ s.note }} } + + +
  • + } +
+ @if (composer() === g.day) { +
+ +
+ } +
+ } @empty { +
+ +

No trip yet

+

Ask the assistant to plan one — try a suggestion in the chat.

+
+ + +
+
+ } +
+ + @if (showFooterAdd()) { + + } + `, + styles: [ + ` + :host { + display: block; + padding: 16px; + font-size: var(--tplane-chat-font-size-sm); + color: var(--tplane-chat-text); + font-family: var(--tplane-chat-font-family); + position: relative; + } + .itin__head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 16px; + position: relative; + } + .itin__title { + margin: 0; + font-size: 1rem; + color: var(--tplane-chat-text); + display: flex; + align-items: baseline; + gap: 6px; + } + .itin__total { + font-size: 0.8rem; + color: var(--tplane-chat-text-muted); + font-weight: normal; + } + .itin__overflow { + font-family: 'Material Symbols Outlined', sans-serif; + font-size: 18px; + background: transparent; + border: none; + color: var(--tplane-chat-text-muted); + cursor: pointer; + padding: 4px; + border-radius: var(--tplane-chat-radius-card); + line-height: 1; + } + .itin__overflow:hover { + background: var(--tplane-chat-surface-alt); + color: var(--tplane-chat-text); + } + .itin__menu { + position: absolute; + top: 100%; + right: 0; + background: var(--tplane-chat-bg); + border: 1px solid var(--tplane-chat-separator); + border-radius: var(--tplane-chat-radius-card); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); + z-index: 10; + min-width: 160px; + } + .itin__menu-item { + display: block; + width: 100%; + text-align: left; + background: transparent; + border: none; + color: var(--tplane-chat-text); + padding: 8px 12px; + cursor: pointer; + font: inherit; + } + .itin__menu-item:hover { + background: var(--tplane-chat-surface-alt); + } + .itin__day { + margin-bottom: 14px; + } + .itin__day-head { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 6px; + } + .itin__day-title { + margin: 0; + font-size: 0.85rem; + font-weight: 600; + color: var(--tplane-chat-text); + } + .itin__day-count { + font-size: 0.75rem; + color: var(--tplane-chat-text-muted); + } + .itin__day-add { + font-family: inherit; + font-size: 0.75rem; + background: transparent; + border: 1px dashed var(--tplane-chat-separator); + color: var(--tplane-chat-text-muted); + border-radius: var(--tplane-chat-radius-card); + padding: 2px 8px; + cursor: pointer; + margin-left: auto; + display: inline-flex; + align-items: center; + gap: 4px; + } + .itin__day-add:hover, .itin__day-add.is-active { + color: var(--tplane-chat-text); + border-color: var(--tplane-chat-text); + } + .itin__icon { + font-family: 'Material Symbols Outlined', sans-serif; + font-size: 16px; + line-height: 1; + vertical-align: -3px; + } + .itin__stops { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 4px; + } + .itin__stop { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 10px; + border: 1px solid var(--tplane-chat-separator); + border-radius: var(--tplane-chat-radius-card); + background: var(--tplane-chat-bg); + transition: box-shadow 200ms ease, transform 200ms ease; + } + .itin__handle { + font-family: 'Material Symbols Outlined', sans-serif; + font-size: 16px; + color: var(--tplane-chat-text-muted); + cursor: grab; + opacity: 0; + transition: opacity 100ms ease; + line-height: 1; + flex: none; + } + .itin__stop:hover .itin__handle { opacity: 1; } + .itin__index { + flex: none; + width: 22px; + height: 22px; + border-radius: 6px; + background: var(--tplane-chat-text); + color: var(--tplane-chat-bg); + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 0.7rem; + font-weight: 600; + } + .itin__place { + flex: 1 1 auto; + min-width: 0; + display: flex; + flex-direction: column; + gap: 1px; + } + .itin__place-name { + color: var(--tplane-chat-text); + font-weight: 500; + } + .itin__note { + color: var(--tplane-chat-text-muted); + font-size: 0.8rem; + } + .itin__remove { + flex: none; + font-family: 'Material Symbols Outlined', sans-serif; + font-size: 16px; + background: transparent; + border: none; + color: var(--tplane-chat-text-muted); + cursor: pointer; + padding: 4px; + line-height: 1; + opacity: 0; + transition: opacity 100ms ease; + border-radius: 4px; + } + .itin__stop:hover .itin__remove { opacity: 0.7; } + .itin__remove:hover { opacity: 1 !important; color: var(--tplane-chat-text); } + .itin__composer { + margin-top: 6px; + } + .itin__composer-input { + width: 100%; + padding: 8px 10px; + border: 1px solid var(--tplane-chat-text); + border-radius: var(--tplane-chat-radius-card); + background: var(--tplane-chat-bg); + color: var(--tplane-chat-text); + font-family: inherit; + font-size: inherit; + box-sizing: border-box; + } + .itin__add-day-btn { + margin-top: 12px; + font-family: inherit; + font-size: 0.8rem; + background: transparent; + border: 1px dashed var(--tplane-chat-separator); + color: var(--tplane-chat-text-muted); + border-radius: var(--tplane-chat-radius-card); + padding: 6px 12px; + cursor: pointer; + width: 100%; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 4px; + } + .itin__add-day-btn:hover { + color: var(--tplane-chat-text); + border-color: var(--tplane-chat-text); + } + .itin__empty { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + gap: 8px; + padding: 24px 8px; + color: var(--tplane-chat-text-muted); + } + .itin__empty-icon { + font-family: 'Material Symbols Outlined', sans-serif; + font-size: 48px; + color: var(--tplane-chat-text-muted); + line-height: 1; + } + .itin__empty-title { + margin: 0; + font-size: 0.95rem; + color: var(--tplane-chat-text); + font-weight: 500; + } + .itin__empty-sub { + margin: 0; + font-size: 0.8rem; + } + .itin__empty-chips { + display: flex; + flex-wrap: wrap; + gap: 6px; + justify-content: center; + margin-top: 4px; + } + .itin__empty-chip { + font-family: inherit; + font-size: 0.8rem; + background: transparent; + border: 1px solid var(--tplane-chat-separator); + color: var(--tplane-chat-text); + border-radius: 999px; + padding: 4px 12px; + cursor: pointer; + } + .itin__empty-chip:hover { + background: var(--tplane-chat-surface-alt); + border-color: var(--tplane-chat-text); + } + .itin__stop.cdk-drag-preview { + box-shadow: + 0 5px 5px -3px rgba(0, 0, 0, 0.2), + 0 8px 10px 1px rgba(0, 0, 0, 0.14), + 0 3px 14px 2px rgba(0, 0, 0, 0.12); + background: var(--tplane-chat-bg); + } + .itin__stop.cdk-drag-placeholder { + opacity: 0.3; + } + .itin__stops.cdk-drop-list-dragging .itin__stop:not(.cdk-drag-placeholder) { + transition: transform 200ms cubic-bezier(0, 0, 0.2, 1); + } + @keyframes itinPulse { + 0% { box-shadow: 0 0 0 0 var(--tplane-chat-primary); transform: scale(1); } + 20% { box-shadow: 0 0 0 3px color-mix(in srgb, var(--tplane-chat-primary) 50%, transparent); transform: scale(1.015); } + 100% { box-shadow: 0 0 0 0 transparent; transform: scale(1); } + } + .itin__stop--pulse { + animation: itinPulse 1600ms ease-out; + } + @media (prefers-reduced-motion: reduce) { + .itin__stop--pulse { + animation: none; + } + } + .itin__handle.cdk-keyboard-focused, + .itin__handle:focus-visible { + opacity: 1; + outline: 2px solid var(--tplane-chat-text); + outline-offset: 2px; + border-radius: 4px; + } + .itin__head-actions { + display: flex; + align-items: center; + gap: 2px; + } + .itin__collapse { + font-family: 'Material Symbols Outlined', sans-serif; + font-size: 18px; + background: transparent; + border: none; + color: var(--tplane-chat-text-muted); + cursor: pointer; + padding: 4px; + line-height: 1; + border-radius: var(--tplane-chat-radius-card); + } + .itin__collapse:hover { + background: var(--tplane-chat-surface-alt); + color: var(--tplane-chat-text); + } + :host(.itin--collapsed) [cdkDropListGroup], + :host(.itin--collapsed) .itin__add-day-btn { + display: none; + } + :host(.itin--collapsed) .itin__head { + margin-bottom: 0; + } + `, + ], +}) +export class ItineraryPanelComponent { + protected readonly store = inject(ItineraryStore); + protected readonly menuOpen = signal(false); + protected readonly collapsed = signal(false); + protected readonly composer = signal(null); + protected readonly composerText = signal(''); + protected readonly totalLabel = computed(() => { + const n = this.store.stops().length; + return `${n} stop${n === 1 ? '' : 's'}`; + }); + protected readonly showFooterAdd = computed(() => this.store.days().length > 0); + private readonly agent = injectAgent(DEMO_AGENT_REF); + + protected suggestion(prompt: string): void { + void this.agent.submit({ message: prompt }); + } + + protected toggleMenu(): void { + this.menuOpen.update((v) => !v); + } + + protected toggleCollapsed(): void { + this.collapsed.update((v) => !v); + } + + protected reset(): void { + this.store.reset({ source: 'user' }); + this.menuOpen.set(false); + } + + protected openComposer(day: number): void { + this.composer.set(day); + this.composerText.set(''); + } + + protected commitComposer(event: Event, day: number): void { + event.preventDefault(); + const text = this.composerText().trim(); + if (text) { + this.store.add(day, text, undefined, { source: 'user' }); + } + this.composer.set(null); + this.composerText.set(''); + } + + protected addNewDay(): void { + const maxDay = Math.max(0, ...this.store.days().map((g) => g.day)); + this.openComposer(maxDay + 1); + } + + protected remove(id: string): void { + this.store.remove(id, { source: 'user' }); + } + + protected onRowClick(id: string, event: Event): void { + const target = event.target as HTMLElement; + if (target.closest('.itin__handle') || target.closest('.itin__remove')) return; + this.store.focus(id); + } + + protected onDrop(event: CdkDragDrop, toDay: number): void { + const stop = event.item.data as ItineraryStop; + this.store.reorder(stop.id, toDay, event.currentIndex, { source: 'user' }); + } +} diff --git a/examples/chat/angular/src/app/itinerary-store.spec.ts b/examples/chat/angular/src/app/itinerary-store.spec.ts new file mode 100644 index 000000000..1fab775af --- /dev/null +++ b/examples/chat/angular/src/app/itinerary-store.spec.ts @@ -0,0 +1,162 @@ +// SPDX-License-Identifier: MIT +import { describe, it, expect, vi } from 'vitest'; +import { ItineraryStore } from './itinerary-store'; + +describe('ItineraryStore', () => { + it('add appends a stop', () => { + const s = new ItineraryStore(); + s.add(2, 'Sainte-Chapelle', 'morning'); + expect(s.stops().some((x) => x.place === 'Sainte-Chapelle')).toBe(true); + }); + + it('move matches place case-insensitively and returns the stop', () => { + const s = new ItineraryStore(); + s.add(1, 'Louvre'); + const moved = s.move('louvre', 2); + expect(moved?.day).toBe(2); + expect(s.move('atlantis', 1)).toBeUndefined(); + }); + + it('clearDay removes only stops for that day', () => { + const s = new ItineraryStore(); + s.add(1, 'Louvre'); + s.add(1, 'Eiffel Tower'); + s.add(2, "Musée d'Orsay"); + const removed = s.clearDay(1); + expect(removed).toBe(2); + expect(s.stops().every((x) => x.day !== 1)).toBe(true); + // day 2 stop still exists + expect(s.stops().some((x) => x.day === 2)).toBe(true); + }); + + it('remove deletes the stop with the given id', () => { + const s = new ItineraryStore(); + s.add(1, 'Louvre'); + s.add(1, 'Eiffel Tower'); + const first = s.stops()[0]; + s.remove(first.id); + expect(s.stops().find((x) => x.id === first.id)).toBeUndefined(); + expect(s.stops().length).toBe(1); + }); + + it('reset clears all stops', () => { + const s = new ItineraryStore(); + s.add(3, 'Versailles'); + s.reset(); + expect(s.stops().length).toBe(0); + }); +}); + +describe('ItineraryStore — server-state model', () => { + it('starts empty (no seed)', () => { + const store = new ItineraryStore(); + expect(store.stops()).toEqual([]); + expect(store.days()).toEqual([]); + }); + + it('hydrates from a server itinerary snapshot', () => { + const store = new ItineraryStore(); + store.hydrate([{ id: 'x', day: 1, place: 'Louvre' }]); + expect(store.stops().map((s) => s.place)).toEqual(['Louvre']); + }); + + it('does not touch localStorage on update', () => { + const spy = vi.spyOn(Storage.prototype, 'setItem'); + const store = new ItineraryStore(); + store.add(1, 'Eiffel Tower'); + expect(spy).not.toHaveBeenCalled(); + spy.mockRestore(); + }); +}); + +describe('reorder', () => { + it('moves a stop to a new index within the same day', () => { + const s = new ItineraryStore(); + s.add(1, 'Louvre'); + s.add(1, 'Eiffel Tower'); + const eiffel = s.stops().find((x) => x.place === 'Eiffel Tower')!; + s.reorder(eiffel.id, 1, 0); + const day1 = s.days().find((g) => g.day === 1)!; + expect(day1.stops.map((x) => x.place)).toEqual(['Eiffel Tower', 'Louvre']); + }); + + it('moves a stop across days at a specific index', () => { + const s = new ItineraryStore(); + s.add(1, 'Louvre'); + s.add(1, 'Eiffel Tower'); + s.add(2, "Musée d'Orsay"); + const orsay = s.stops().find((x) => x.place === "Musée d'Orsay")!; + s.reorder(orsay.id, 1, 0); + const day1 = s.days().find((g) => g.day === 1)!; + expect(day1.stops[0].place).toBe("Musée d'Orsay"); + expect(day1.stops[0].day).toBe(1); + }); + + it('reorder by unknown id is a no-op', () => { + const s = new ItineraryStore(); + s.add(1, 'Louvre'); + const before = s.stops(); + s.reorder('does-not-exist', 1, 0); + expect(s.stops()).toEqual(before); + }); +}); + +describe('recentlyChangedId', () => { + it('is null initially', () => { + const s = new ItineraryStore(); + expect(s.recentlyChangedId()).toBeNull(); + }); + + it('is set after an agent-source add', () => { + const s = new ItineraryStore(); + const added = s.add(3, 'Sacré-Cœur'); + expect(s.recentlyChangedId()).toBe(added.id); + }); + + it('is NOT set after a user-source add', () => { + const s = new ItineraryStore(); + s.add(3, 'Sacré-Cœur', undefined, { source: 'user' }); + expect(s.recentlyChangedId()).toBeNull(); + }); + + it('clears 1600ms after the change', async () => { + vi.useFakeTimers(); + const s = new ItineraryStore(); + s.add(3, 'Sacré-Cœur'); + expect(s.recentlyChangedId()).not.toBeNull(); + vi.advanceTimersByTime(1600); + expect(s.recentlyChangedId()).toBeNull(); + vi.useRealTimers(); + }); +}); + +describe('focus', () => { + it('focus sets and clears focusedStopId', () => { + const s = new ItineraryStore(); + s.add(1, 'Louvre'); + const id = s.stops()[0].id; + expect(s.focusedStopId()).toBeNull(); + s.focus(id); + expect(s.focusedStopId()).toBe(id); + s.focus(null); + expect(s.focusedStopId()).toBeNull(); + }); +}); + +describe('coordinates', () => { + it('add accepts optional lat/lng via opts.coords', () => { + const s = new ItineraryStore(); + const added = s.add(2, 'Sacré-Cœur', undefined, { + coords: { lat: 48.8867, lng: 2.3431 }, + }); + expect(added.lat).toBeCloseTo(48.8867, 3); + expect(added.lng).toBeCloseTo(2.3431, 3); + }); + + it('add without coords leaves lat/lng undefined', () => { + const s = new ItineraryStore(); + const added = s.add(2, 'Somewhere'); + expect(added.lat).toBeUndefined(); + expect(added.lng).toBeUndefined(); + }); +}); diff --git a/examples/chat/angular/src/app/itinerary-store.ts b/examples/chat/angular/src/app/itinerary-store.ts new file mode 100644 index 000000000..33b809738 --- /dev/null +++ b/examples/chat/angular/src/app/itinerary-store.ts @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: MIT +import { computed, signal } from '@angular/core'; + +export interface ItineraryStop { + id: string; + day: number; + place: string; + note?: string; + lat?: number; + lng?: number; +} + +export interface MutationOptions { + source?: 'user' | 'agent'; + coords?: { lat: number; lng: number }; +} + +const PULSE_MS = 1600; + +/** Working copy of the itinerary: the user edits it in the panel, the agent + * edits it through client tools. Both write the same signals, so either's + * changes render immediately. The graph checkpoint is the durable record — + * the shell calls `hydrate()` from a server state snapshot on thread switch. */ +export class ItineraryStore { + readonly stops = signal([]); + readonly days = computed(() => { + const byDay = new Map(); + for (const s of this.stops()) byDay.set(s.day, [...(byDay.get(s.day) ?? []), s]); + return [...byDay.entries()] + .sort(([a], [b]) => a - b) + .map(([day, stops]) => ({ day, stops })); + }); + readonly recentlyChangedId = signal(null); + readonly focusedStopId = signal(null); + private pulseTimer: ReturnType | null = null; + + focus(id: string | null): void { + this.focusedStopId.set(id); + } + + add(day: number, place: string, note?: string, opts?: MutationOptions): ItineraryStop { + const stop: ItineraryStop = { + id: crypto.randomUUID(), + day, + place, + ...(note ? { note } : {}), + ...(opts?.coords ? { lat: opts.coords.lat, lng: opts.coords.lng } : {}), + }; + this.update([...this.stops(), stop]); + this.flagChanged(stop.id, opts); + return stop; + } + + move(place: string, toDay: number, opts?: MutationOptions): ItineraryStop | undefined { + const target = this.stops().find((s) => s.place.toLowerCase() === place.toLowerCase()); + if (!target) return undefined; + const moved = { ...target, day: toDay }; + this.update(this.stops().map((s) => (s.id === target.id ? moved : s))); + this.flagChanged(moved.id, opts); + return moved; + } + + reorder(stopId: string, toDay: number, toIndex: number, opts?: MutationOptions): void { + const current = this.stops(); + const target = current.find((s) => s.id === stopId); + if (!target) return; + const without = current.filter((s) => s.id !== stopId); + const dayStops = without.filter((s) => s.day === toDay); + const others = without.filter((s) => s.day !== toDay); + const clampedIndex = Math.max(0, Math.min(toIndex, dayStops.length)); + const newDayStops = [ + ...dayStops.slice(0, clampedIndex), + { ...target, day: toDay }, + ...dayStops.slice(clampedIndex), + ]; + this.update([...others, ...newDayStops]); + this.flagChanged(stopId, opts); + } + + remove(id: string, opts?: MutationOptions): void { + this.update(this.stops().filter((s) => s.id !== id)); + this.flagChanged(id, opts); + } + + clearDay(day: number, opts?: MutationOptions): number { + const removed = this.stops().filter((s) => s.day === day).length; + this.update(this.stops().filter((s) => s.day !== day)); + if (removed > 0) this.flagChanged(null, opts); + return removed; + } + + reset(opts?: MutationOptions): void { + this.update([]); + this.flagChanged(null, opts); + } + + private flagChanged(id: string | null, opts?: MutationOptions): void { + if (opts?.source === 'user') return; + if (this.pulseTimer) clearTimeout(this.pulseTimer); + this.recentlyChangedId.set(id); + if (id !== null) { + this.pulseTimer = setTimeout(() => { + this.recentlyChangedId.set(null); + this.pulseTimer = null; + }, PULSE_MS); + } + } + + /** Replace the working copy from a server state snapshot (thread switch / + * values stream). Public — the shell calls it when agent.values() changes. */ + hydrate(stops: ItineraryStop[]): void { + this.stops.set(stops ?? []); + } + + private update(next: ItineraryStop[]): void { + this.stops.set(next); + } +} diff --git a/examples/chat/angular/src/app/map-bounds.spec.ts b/examples/chat/angular/src/app/map-bounds.spec.ts new file mode 100644 index 000000000..7b33800b5 --- /dev/null +++ b/examples/chat/angular/src/app/map-bounds.spec.ts @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MIT +import { describe, it, expect } from 'vitest'; +import { computeBounds } from './map-bounds'; + +describe('computeBounds', () => { + it('returns null for an empty list', () => { + expect(computeBounds([])).toBeNull(); + }); + + it('returns null when no stop has coordinates', () => { + expect(computeBounds([{ lat: null, lng: null }, { lat: undefined, lng: undefined }])).toBeNull(); + }); + + it('returns a degenerate box for a single stop', () => { + expect(computeBounds([{ lat: 48.86, lng: 2.35 }])).toEqual({ + north: 48.86, south: 48.86, east: 2.35, west: 2.35, + }); + }); + + it('returns min/max extents for multiple stops', () => { + const b = computeBounds([ + { lat: 48.86, lng: 2.35 }, + { lat: 48.80, lng: 2.40 }, + { lat: 48.90, lng: 2.30 }, + ]); + expect(b).toEqual({ north: 48.90, south: 48.80, east: 2.40, west: 2.30 }); + }); + + it('ignores stops missing coordinates', () => { + const b = computeBounds([ + { lat: 48.86, lng: 2.35 }, + { lat: null, lng: null }, + { lat: 48.90, lng: 2.40 }, + ]); + expect(b).toEqual({ north: 48.90, south: 48.86, east: 2.40, west: 2.35 }); + }); +}); diff --git a/examples/chat/angular/src/app/map-bounds.ts b/examples/chat/angular/src/app/map-bounds.ts new file mode 100644 index 000000000..d0a091fed --- /dev/null +++ b/examples/chat/angular/src/app/map-bounds.ts @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MIT + +/** A geographic bounding box (LatLngBoundsLiteral-compatible). */ +export interface Bounds { + north: number; + south: number; + east: number; + west: number; +} + +/** Smallest box containing every stop that has coordinates, or null if none do. */ +export function computeBounds( + stops: ReadonlyArray<{ lat?: number | null; lng?: number | null }>, +): Bounds | null { + let north = -Infinity, south = Infinity, east = -Infinity, west = Infinity; + let found = false; + for (const s of stops) { + if (s.lat == null || s.lng == null) continue; + found = true; + north = Math.max(north, s.lat); + south = Math.min(south, s.lat); + east = Math.max(east, s.lng); + west = Math.min(west, s.lng); + } + return found ? { north, south, east, west } : null; +} diff --git a/examples/chat/angular/src/app/map-canvas.component.spec.ts b/examples/chat/angular/src/app/map-canvas.component.spec.ts new file mode 100644 index 000000000..34b686270 --- /dev/null +++ b/examples/chat/angular/src/app/map-canvas.component.spec.ts @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: MIT +import { describe, it, expect, afterEach } from 'vitest'; +import { readAppColorScheme } from './map-canvas.component'; + +describe('readAppColorScheme — map follows the app light/dark toggle', () => { + afterEach(() => document.documentElement.removeAttribute('data-color-scheme')); + + it("reads 'light' from ", () => { + document.documentElement.setAttribute('data-color-scheme', 'light'); + expect(readAppColorScheme()).toBe('light'); + }); + + it("reads 'dark' from ", () => { + document.documentElement.setAttribute('data-color-scheme', 'dark'); + expect(readAppColorScheme()).toBe('dark'); + }); + + it("defaults to 'dark' when the attribute is absent or unknown", () => { + expect(readAppColorScheme()).toBe('dark'); + document.documentElement.setAttribute('data-color-scheme', 'sepia'); + expect(readAppColorScheme()).toBe('dark'); + }); +}); diff --git a/examples/chat/angular/src/app/map-canvas.component.ts b/examples/chat/angular/src/app/map-canvas.component.ts new file mode 100644 index 000000000..bc622df3c --- /dev/null +++ b/examples/chat/angular/src/app/map-canvas.component.ts @@ -0,0 +1,289 @@ +// SPDX-License-Identifier: MIT +/// +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + ElementRef, + afterNextRender, + computed, + effect, + inject, + signal, + viewChild, + viewChildren, +} from '@angular/core'; +import { GoogleMap, MapInfoWindow, MapAdvancedMarker, MapPolyline } from '@angular/google-maps'; +import { ItineraryStop, ItineraryStore } from './itinerary-store'; +import { computeBounds } from './map-bounds'; +import { GoogleMapsLoader } from './google-maps-loader'; +import { environment } from '../environments/environment'; + +const DAY_COLORS = ['#ef4444', '#3b82f6', '#10b981', '#f59e0b', '#8b5cf6', '#ec4899', '#06b6d4', '#84cc16']; +const PARIS_CENTER: google.maps.LatLngLiteral = { lat: 48.8566, lng: 2.3522 }; + +/** The app's active light/dark scheme, read from the `` + * attribute the demo shell reflects (NOT the gen-UI mode). Defaults to 'dark' + * (the app default) when the attribute is absent. */ +export function readAppColorScheme(): 'light' | 'dark' { + return document.documentElement.getAttribute('data-color-scheme') === 'light' ? 'light' : 'dark'; +} + +@Component({ + selector: 'app-map-canvas', + standalone: true, + imports: [GoogleMap, MapInfoWindow, MapAdvancedMarker, MapPolyline], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + + @if (loader.loaded()) { + + @for (scheme of [mapColorScheme()]; track scheme) { + + @for (m of markerViews(); track m.id) { + + } + @for (line of polylines(); track line.day) { + + } + + @if (focused(); as f) { +
+ {{ f.place }} + @if (f.note) { +
{{ f.note }}
+ } + +
+ } +
+
+ } + } + `, + styles: [ + ` + :host { display: block; width: 100%; height: 100%; } + .info { font-family: var(--tplane-chat-font-family, sans-serif); font-size: 0.85rem; color: #111; } + .info__note { color: #555; margin: 4px 0 8px; } + .info__remove { + font: inherit; cursor: pointer; padding: 4px 10px; + border: 1px solid #ddd; border-radius: 6px; background: #fff; color: #111; + } + .info__remove:hover { background: #f4f4f4; } + `, + ], +}) +export class MapCanvasComponent { + protected readonly store = inject(ItineraryStore); + protected readonly loader = inject(GoogleMapsLoader); + private readonly hostRef = inject>(ElementRef); + private readonly destroyRef = inject(DestroyRef); + protected readonly center = signal(PARIS_CENTER); + protected readonly zoom = signal(12); + // A mapId is REQUIRED for advanced markers. The map theme is applied IN CODE + // via `colorScheme` — NOT a cloud-based map style — so there is no Google Cloud + // Console style to maintain and ANY mapId works. DEMO_MAP_ID lets a fresh clone + // run with no Console setup at all. + protected readonly mapId = environment.googleMapsMapId || 'DEMO_MAP_ID'; + + // Follow the app's light/dark toggle (the the shell + // reflects) — NOT the gen-UI mode. Seeded synchronously so the first paint + // themes correctly; a MutationObserver (afterNextRender) keeps it live. + private readonly appColorScheme = signal<'light' | 'dark'>(readAppColorScheme()); + protected readonly mapColorScheme = computed<'LIGHT' | 'DARK'>(() => + this.appColorScheme() === 'light' ? 'LIGHT' : 'DARK', + ); + + protected readonly mapOptions = computed(() => ({ + mapId: this.mapId, + // colorScheme is INIT-ONLY on a Google map, so the template remounts + // when mapColorScheme() flips (see the keyed @for). Plain string + // = the google.maps.ColorScheme enum's runtime value, so this holds no + // runtime `google.maps` reference (map-canvas builds under jsdom). Cast for + // @types/google.maps version independence; the live Maps JS honors it. + colorScheme: this.mapColorScheme(), + disableDefaultUI: true, + zoomControl: true, + clickableIcons: false, + } as google.maps.MapOptions)); + // AdvancedMarkerElement is NOT clickable by default (unlike the legacy + // Marker) — without gmpClickable it never fires click/gmp-click, so the + // wrapper's (mapClick) output stays silent and the info window can't open. + protected readonly advancedMarkerOptions: google.maps.marker.AdvancedMarkerElementOptions = { + gmpClickable: true, + }; + + private readonly googleMap = viewChild(GoogleMap); + private readonly infoWindow = viewChild(MapInfoWindow); + private readonly markers = viewChildren(MapAdvancedMarker); + + protected readonly stopsWithCoords = computed(() => + this.store.stops().filter((s) => s.lat != null && s.lng != null), + ); + + /** One marker view per coord'd stop. The pin
is built here (not in a + * template method) so it is recreated only when stops change — not on every + * change-detection pass (e.g. focus pans), which would cause flicker. */ + protected readonly markerViews = computed(() => + this.stopsWithCoords().map((s) => ({ + id: s.id, + stop: s, + position: { lat: s.lat!, lng: s.lng! }, + content: this.makePin(s.day), + })), + ); + + protected readonly focused = computed( + () => this.store.stops().find((s) => s.id === this.store.focusedStopId()) ?? null, + ); + + constructor() { + this.loader.ensureLoaded(); + + effect(() => { + const f = this.focused(); + if (!f || f.lat == null || f.lng == null) return; + const idx = this.stopsWithCoords().findIndex((s) => s.id === f.id); + const marker = this.markers()[idx]; + const win = this.infoWindow(); + if (idx >= 0 && marker && win) { + this.center.set({ lat: f.lat, lng: f.lng }); + win.open(marker); + } + }); + + // Frame the map to all stops on structural change (add/remove/geocode). + // Reads googleMap() so it re-runs once the map mounts behind the loader gate. + // Keyed on stopsWithCoords() only — NOT focus — so panning to a focused stop + // and reframing to all stops never fight (they fire on different signals). + effect(() => { + const map = this.googleMap(); + this.stopsWithCoords(); // re-frame on structural change (add/remove/geocode) + if (map) this.frameToBounds(map); // null while is behind the loader gate + }); + + // The map lives in the chat-sidebar's flex content slot, whose width changes + // when the drawer pushes it (and on mode toggles). Google Maps caches its + // viewport size at construction, so without a resize event the tiles render + // grey and fitBounds frames the stale size. Re-sync on every container resize. + afterNextRender(() => { + const ro = new ResizeObserver(() => { + const map = this.googleMap(); + if (!map?.googleMap) return; + google.maps.event.trigger(map.googleMap, 'resize'); + this.frameToBounds(map); + }); + ro.observe(this.hostRef.nativeElement); + this.destroyRef.onDestroy(() => ro.disconnect()); + + // Live-follow the app's light/dark toggle. colorScheme is init-only, so the + // template @for remounts when this signal flips. + const mo = new MutationObserver(() => this.appColorScheme.set(readAppColorScheme())); + mo.observe(document.documentElement, { attributes: true, attributeFilter: ['data-color-scheme'] }); + this.destroyRef.onDestroy(() => mo.disconnect()); + }); + } + + /** Frame the map to all coord'd stops (>=2: fitBounds; 1: center+zoom; 0: Paris). */ + private frameToBounds(map: GoogleMap): void { + const stops = this.stopsWithCoords(); + if (stops.length === 0) { + this.center.set(PARIS_CENTER); + this.zoom.set(12); + return; + } + if (stops.length === 1) { + this.center.set({ lat: stops[0].lat!, lng: stops[0].lng! }); + this.zoom.set(13); + return; + } + const b = computeBounds(stops); + if (b) map.fitBounds(b, this.fitPadding()); + } + + /** + * fitBounds padding (px per side) that reserves the App-mode panels floating + * over the full-bleed map, so a stop never frames *underneath* them — the + * reported bug was the rightmost marker hiding under the open chat rail. + * + * Measured live (not hardcoded) so it adapts to the chat drawer's open/closed + * state and the responsive panel widths: + * - left = our floating itinerary overlay card's footprint + * - right = the chat sidebar drawer's footprint (the gap its push leaves + * between `.chat-sidebar__content` and the map's right edge) + * Falls back to a uniform base when the panels aren't present (unit tests, + * non-App-mode layouts), preserving the prior behavior. + */ + private fitPadding(): google.maps.Padding { + const BASE = 48; + const GAP = 24; + const pad: google.maps.Padding = { top: BASE, right: BASE, bottom: BASE, left: BASE }; + const mapRect = this.hostRef.nativeElement.getBoundingClientRect(); + if (mapRect.width === 0) return pad; // not laid out (e.g. jsdom) — uniform + + const overlay = document.querySelector('.ag-ui-shell__itinerary-overlay'); + if (overlay) { + const r = overlay.getBoundingClientRect(); + if (r.width > 0) pad.left = Math.max(BASE, Math.round(r.right - mapRect.left + GAP)); + } + const content = document.querySelector('.chat-sidebar__content'); + if (content) { + const occupied = mapRect.right - content.getBoundingClientRect().right; + if (occupied > 1) pad.right = Math.max(BASE, Math.round(occupied + GAP)); + } + return pad; + } + + protected onMarkerClick(s: ItineraryStop): void { + this.store.focus(s.id); + } + + protected removeFocused(): void { + const f = this.focused(); + if (!f) return; + this.store.remove(f.id, { source: 'user' }); + this.store.focus(null); + this.infoWindow()?.close(); + } + + protected readonly polylines = computed(() => { + const byDay = new Map(); + for (const s of this.stopsWithCoords()) byDay.set(s.day, [...(byDay.get(s.day) ?? []), s]); + return [...byDay.entries()] + .filter(([, stops]) => stops.length >= 2) + .map(([day, stops]) => ({ day, path: stops.map((s) => ({ lat: s.lat!, lng: s.lng! })) })); + }); + + private makePin(day: number): HTMLElement { + const el = document.createElement('div'); + el.style.cssText = + 'width:16px;height:16px;border-radius:50%;border:2px solid #fff;' + + `box-shadow:0 1px 3px rgba(0,0,0,.4);background:${DAY_COLORS[(day - 1) % DAY_COLORS.length]};`; + return el; + } + + protected polylineOptions(day: number): google.maps.PolylineOptions { + return { + strokeColor: DAY_COLORS[(day - 1) % DAY_COLORS.length], + strokeOpacity: 0.7, + strokeWeight: 3, + }; + } +} diff --git a/examples/chat/angular/src/app/modes/app-mode-promo.component.spec.ts b/examples/chat/angular/src/app/modes/app-mode-promo.component.spec.ts new file mode 100644 index 000000000..860a73c34 --- /dev/null +++ b/examples/chat/angular/src/app/modes/app-mode-promo.component.spec.ts @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MIT +import { describe, it, expect } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { AppModePromoComponent } from './app-mode-promo.component'; + +function setup(hasMapsKey: boolean) { + TestBed.configureTestingModule({ imports: [AppModePromoComponent] }); + const fixture = TestBed.createComponent(AppModePromoComponent); + fixture.componentRef.setInput('hasMapsKey', hasMapsKey); + fixture.detectChanges(); + return fixture; +} + +describe('AppModePromoComponent', () => { + it('renders the headline, four capability pills, and the CTA', () => { + const el: HTMLElement = setup(true).nativeElement; + expect(el.textContent).toContain('See your trip come alive on a live map'); + expect(el.querySelectorAll('.promo__pill').length).toBe(4); + const cta = el.querySelector('.promo__cta') as HTMLButtonElement | null; + expect(cta).toBeTruthy(); + expect(cta!.disabled).toBe(false); + }); + + it('emits enable when the CTA is clicked', () => { + const fixture = setup(true); + let emitted = 0; + fixture.componentInstance.enable.subscribe(() => (emitted += 1)); + (fixture.nativeElement.querySelector('.promo__cta') as HTMLButtonElement).click(); + expect(emitted).toBe(1); + }); + + it('disables the CTA and shows the key note when hasMapsKey is false', () => { + const el: HTMLElement = setup(false).nativeElement; + const cta = el.querySelector('.promo__cta') as HTMLButtonElement | null; + expect(cta!.disabled).toBe(true); + expect(el.textContent).toContain('GOOGLE_MAPS_API_KEY'); + expect(cta!.title).toBe('Set GOOGLE_MAPS_API_KEY to enable'); + }); +}); diff --git a/examples/chat/angular/src/app/modes/app-mode-promo.component.ts b/examples/chat/angular/src/app/modes/app-mode-promo.component.ts new file mode 100644 index 000000000..b4f6a57d1 --- /dev/null +++ b/examples/chat/angular/src/app/modes/app-mode-promo.component.ts @@ -0,0 +1,123 @@ +// SPDX-License-Identifier: MIT +import { ChangeDetectionStrategy, Component, input, output } from '@angular/core'; + +/** + * Marketing hero shown in sidebar mode while App mode is off. Sells the + * App-mode map cockpit and the Threadplane primitives behind it, with a CTA + * that enables App mode. + * + * Isolated contract — no shell coupling: + * - `hasMapsKey`: whether GOOGLE_MAPS_API_KEY is configured (gates the CTA). + * - `enable`: emitted when the user clicks the CTA. + */ +@Component({ + selector: 'app-mode-promo', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+ Preview of the App-mode map cockpit +
+
+ + + Built with Threadplane + +

See your trip come alive on a live map

+

A map cockpit where the agent edits your itinerary in real time.

+
    +
  • Client tools
  • +
  • Generative UI
  • +
  • Human-in-the-loop
  • +
  • Shared state
  • +
+
+
+ + @if (!hasMapsKey()) { +

Set GOOGLE_MAPS_API_KEY to enable

+ } +
+
+
+ `, + styles: [` + :host { display: block; width: 100%; } + .promo { + position: relative; + width: min(780px, 100%); + margin: 0 auto; + aspect-ratio: 16 / 10; + border-radius: var(--tplane-chat-radius-card, 12px); + overflow: hidden; + background: #0e1626; + border: 1px solid var(--tplane-chat-separator, rgba(255, 255, 255, 0.12)); + animation: promo-rise 320ms ease both; + } + .promo__img { + position: absolute; inset: 0; + width: 100%; height: 100%; + object-fit: cover; display: block; + } + .promo__caption { + position: absolute; left: 0; right: 0; bottom: 0; + display: flex; flex-wrap: wrap; align-items: center; gap: 16px; + padding: 16px 20px; + background: rgba(8, 15, 28, 0.96); + border-top: 1px solid rgba(255, 255, 255, 0.12); + } + .promo__copy { flex: 1 1 320px; min-width: 0; } + .promo__eyebrow { + display: inline-flex; align-items: center; gap: 6px; + background: rgba(37, 99, 235, 0.18); color: #93b4f5; + font-size: 12px; padding: 3px 10px; border-radius: 8px; margin-bottom: 9px; + } + .promo__title { font-size: 20px; font-weight: 600; color: #f2f5fb; line-height: 1.3; margin: 0 0 4px; } + .promo__subtitle { font-size: 13px; color: #9aa6bd; line-height: 1.5; margin: 0 0 12px; } + .promo__pills { display: flex; flex-wrap: wrap; gap: 8px; list-style: none; margin: 0; padding: 0; } + .promo__pill { + display: inline-flex; align-items: center; gap: 6px; + background: rgba(255, 255, 255, 0.06); border: 1px solid rgba(255, 255, 255, 0.1); + color: #c2cbdc; font-size: 12px; padding: 5px 10px; border-radius: 8px; + } + .promo__action { flex: 0 0 auto; display: flex; flex-direction: column; align-items: flex-start; gap: 6px; } + .promo__cta { + display: inline-flex; align-items: center; gap: 8px; + background: var(--tplane-chat-primary, #2563eb); color: var(--tplane-chat-on-primary, #fff); + border: none; font: inherit; font-size: 14px; font-weight: 600; + padding: 11px 18px; border-radius: 8px; cursor: pointer; + } + .promo__cta:disabled { opacity: 0.5; cursor: not-allowed; } + .promo__cta:focus-visible { outline: 2px solid var(--tplane-chat-text); outline-offset: 2px; } + .promo__cta:hover:not(:disabled) { filter: brightness(1.08); } + .promo__note { font-size: 12px; color: #9aa6bd; margin: 0; } + .promo__note code { font-family: var(--tplane-chat-font-mono, monospace); } + .promo__icon { font-family: 'Material Symbols Outlined', sans-serif; font-size: 18px; line-height: 1; } + .promo__icon--sm { font-size: 15px; } + @keyframes promo-rise { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } } + @media (prefers-reduced-motion: reduce) { .promo { animation: none; } } + `], +}) +export class AppModePromoComponent { + /** Whether GOOGLE_MAPS_API_KEY is configured; gates the CTA. */ + readonly hasMapsKey = input(false); + /** Emitted when the user clicks the "Enable app mode" CTA. */ + readonly enable = output(); +} diff --git a/examples/chat/angular/src/app/modes/embed-mode.component.ts b/examples/chat/angular/src/app/modes/embed-mode.component.ts index 3d10fb88d..6c43525c0 100644 --- a/examples/chat/angular/src/app/modes/embed-mode.component.ts +++ b/examples/chat/angular/src/app/modes/embed-mode.component.ts @@ -13,12 +13,13 @@ import { WelcomeSuggestionsComponent } from './welcome-suggestions.component'; template: ` - + `, styles: [` diff --git a/examples/chat/angular/src/app/modes/popup-mode.component.ts b/examples/chat/angular/src/app/modes/popup-mode.component.ts index 273b0f8f7..4eeb8e1e7 100644 --- a/examples/chat/angular/src/app/modes/popup-mode.component.ts +++ b/examples/chat/angular/src/app/modes/popup-mode.component.ts @@ -4,27 +4,36 @@ import { ChatPopupComponent, a2uiBasicCatalog } from '@threadplane/chat'; import { DemoShell } from '../shell/demo-shell.component'; import { DEMO_AGENT } from '../shell/shell-tokens'; import { WelcomeSuggestionsComponent } from './welcome-suggestions.component'; +import { AppModePromoComponent } from './app-mode-promo.component'; @Component({ selector: 'popup-mode', standalone: true, - imports: [ChatPopupComponent, WelcomeSuggestionsComponent], + imports: [ChatPopupComponent, WelcomeSuggestionsComponent, AppModePromoComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: ` - + `, styles: [` @@ -33,8 +42,6 @@ import { WelcomeSuggestionsComponent } from './welcome-suggestions.component'; display: grid; place-items: center; height: 100%; - color: #8a92a3; - font-size: 14px; } `], }) diff --git a/examples/chat/angular/src/app/modes/sidebar-mode.component.ts b/examples/chat/angular/src/app/modes/sidebar-mode.component.ts index a58c3481c..233ba1ae7 100644 --- a/examples/chat/angular/src/app/modes/sidebar-mode.component.ts +++ b/examples/chat/angular/src/app/modes/sidebar-mode.component.ts @@ -3,16 +3,19 @@ import { Component, ChangeDetectionStrategy, inject } from '@angular/core'; import { ChatSidebarComponent, a2uiBasicCatalog } from '@threadplane/chat'; import { DemoShell } from '../shell/demo-shell.component'; import { DEMO_AGENT } from '../shell/shell-tokens'; +import { MapCanvasComponent } from '../map-canvas.component'; +import { AppModePromoComponent } from './app-mode-promo.component'; import { WelcomeSuggestionsComponent } from './welcome-suggestions.component'; @Component({ selector: 'sidebar-mode', standalone: true, - imports: [ChatSidebarComponent, WelcomeSuggestionsComponent], + imports: [ChatSidebarComponent, MapCanvasComponent, AppModePromoComponent, WelcomeSuggestionsComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: ` {{ shell.currentThreadTitle() }} - - + + @if (shell.appMode() === 'on') { + + } @else { + + } + `, styles: [` :host { display: block; flex: 1; min-height: 0; position: relative; } + /* The map fills the chat-sidebar content slot (which is flex:1 at threaded + * height); [pushContent] applies the right-margin push for the open drawer. */ + .sidebar-mode__map { display: block; height: 100%; } /* Projected into chat-sidebar's default content slot so [pushContent] * applies its right-margin push to this background when the panel * opens. Sized to fill the visible area below the toolbar. */ @@ -39,8 +54,6 @@ import { WelcomeSuggestionsComponent } from './welcome-suggestions.component'; display: grid; place-items: center; height: 100%; - color: #8a92a3; - font-size: 14px; } `], }) diff --git a/examples/chat/angular/src/app/modes/welcome-suggestions.component.spec.ts b/examples/chat/angular/src/app/modes/welcome-suggestions.component.spec.ts index 8437191e2..22670c63c 100644 --- a/examples/chat/angular/src/app/modes/welcome-suggestions.component.spec.ts +++ b/examples/chat/angular/src/app/modes/welcome-suggestions.component.spec.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { WelcomeSuggestionsComponent } from './welcome-suggestions.component'; -import { FEATURED_SUGGESTIONS, MORE_SUGGESTIONS } from './welcome-suggestions'; +import { FEATURED_SUGGESTIONS, MORE_SUGGESTIONS, ITINERARY_SUGGESTIONS } from './welcome-suggestions'; describe('WelcomeSuggestionsComponent', () => { let fx: ComponentFixture; @@ -32,8 +32,8 @@ describe('WelcomeSuggestionsComponent', () => { expect(trigger.textContent).toContain('More prompts'); }); - it('merges FEATURED_SUGGESTIONS[1..] + MORE_SUGGESTIONS into dropdown options', () => { - const opts = fx.componentInstance['moreOptions'] as { value: string; label: string }[]; + it('merges FEATURED_SUGGESTIONS[1..] + MORE_SUGGESTIONS into dropdown options (appModeOn=false)', () => { + const opts = fx.componentInstance['moreOptions']() as { value: string; label: string }[]; const expectedLen = FEATURED_SUGGESTIONS.length - 1 + MORE_SUGGESTIONS.length; expect(opts.length).toBe(expectedLen); expect(opts[0].label).toBe(FEATURED_SUGGESTIONS[1].label); @@ -42,6 +42,23 @@ describe('WelcomeSuggestionsComponent', () => { expect(opts[moreStart].label).toBe(MORE_SUGGESTIONS[0].label); }); + it('features the trip-planning starters when appModeOn is true', () => { + fx.componentRef.setInput('appModeOn', true); + fx.detectChanges(); + // Featured chip becomes the first itinerary prompt. + const label = fx.nativeElement.querySelector( + 'chat-welcome-suggestion .chat-welcome-suggestion__label', + ) as HTMLElement; + expect(label.textContent?.trim()).toBe(ITINERARY_SUGGESTIONS[0].label); + // The remaining itinerary prompts lead the "More prompts" set, followed by + // this demo's existing capability prompts (preserved, not replaced). + const opts = fx.componentInstance['moreOptions']() as { value: string; label: string }[]; + expect(opts[0].label).toBe(ITINERARY_SUGGESTIONS[1].label); + const labels = opts.map((o) => o.label); + expect(labels).toContain(FEATURED_SUGGESTIONS[0].label); + expect(labels).toContain(MORE_SUGGESTIONS[0].label); + }); + it('emits (selected) with the featured value when the chip is clicked', () => { let captured: string | null = null; fx.componentInstance.selected.subscribe((v) => (captured = v)); diff --git a/examples/chat/angular/src/app/modes/welcome-suggestions.component.ts b/examples/chat/angular/src/app/modes/welcome-suggestions.component.ts index b6c867e36..d5b508786 100644 --- a/examples/chat/angular/src/app/modes/welcome-suggestions.component.ts +++ b/examples/chat/angular/src/app/modes/welcome-suggestions.component.ts @@ -1,11 +1,11 @@ // SPDX-License-Identifier: MIT -import { ChangeDetectionStrategy, Component, output } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core'; import { ChatWelcomeSuggestionComponent, ChatSelectComponent, type ChatSelectOption, } from '@threadplane/chat'; -import { FEATURED_SUGGESTIONS, MORE_SUGGESTIONS } from './welcome-suggestions'; +import { suggestionsForAppMode } from './welcome-suggestions'; /** * Demo-side composition that renders the welcome-state suggestion surface @@ -26,13 +26,13 @@ import { FEATURED_SUGGESTIONS, MORE_SUGGESTIONS } from './welcome-suggestions';
(); - protected readonly featuredOne = FEATURED_SUGGESTIONS[0]; - protected readonly moreOptions: readonly ChatSelectOption[] = [ - ...FEATURED_SUGGESTIONS.slice(1), - ...MORE_SUGGESTIONS, - ].map((s) => ({ value: s.value, label: s.label, description: s.description })); + /** + * When true, lead with the trip-planning starters (App mode / map cockpit on + * screen). When false, keep this demo's broad capability prompts (plain chat). + * Passed by each mode from `shell.appMode()`. + */ + readonly appModeOn = input(false); + + private readonly suggestions = computed(() => suggestionsForAppMode(this.appModeOn())); + protected readonly featuredOne = computed(() => this.suggestions().featured); + protected readonly moreOptions = computed(() => + this.suggestions().more.map((s) => ({ value: s.value, label: s.label, description: s.description })), + ); } diff --git a/examples/chat/angular/src/app/modes/welcome-suggestions.ts b/examples/chat/angular/src/app/modes/welcome-suggestions.ts index 0900df128..8af0cf2e4 100644 --- a/examples/chat/angular/src/app/modes/welcome-suggestions.ts +++ b/examples/chat/angular/src/app/modes/welcome-suggestions.ts @@ -141,3 +141,52 @@ export const WELCOME_SUGGESTIONS: readonly WelcomeSuggestion[] = [ ...FEATURED_SUGGESTIONS, ...MORE_SUGGESTIONS, ]; + +/** + * Trip-planning starters featured when App mode is on (the map cockpit is on + * screen) — they seed an itinerary the agent then builds out via the frontend + * client tools, so they match what the user is looking at. Plain chat keeps the + * broad capability prompts above (see `suggestionsForAppMode`). + */ +export const ITINERARY_SUGGESTIONS: readonly WelcomeSuggestion[] = [ + { + label: 'Plan a long weekend in Paris', + value: 'Plan a long weekend in Paris', + description: 'Agent builds a day-by-day itinerary you watch appear on the map.', + }, + { + label: '3 days in Tokyo with great food', + value: '3 days in Tokyo with great food', + description: 'Streams a food-forward trip and plots each stop live.', + }, + { + label: 'A week on the California coast', + value: 'A week on the California coast', + description: 'Lays out a multi-day route the agent edits on the cockpit map.', + }, +]; + +/** + * Pick the featured chip + "More prompts" set for the current context. + * + * - App mode ON (map cockpit visible): feature the trip-planning starters — + * they drive the itinerary panel + map. The broad capability prompts trail. + * - App mode OFF (plain chat): keep this demo's EXISTING capability surface + * (GenUI / streaming / tool-use + citations, then the "More prompts" set). + */ +export function suggestionsForAppMode(appModeOn: boolean): { + readonly featured: WelcomeSuggestion; + readonly more: readonly WelcomeSuggestion[]; +} { + if (appModeOn) { + const [featured, ...restItinerary] = ITINERARY_SUGGESTIONS; + return { + featured, + more: [...restItinerary, ...FEATURED_SUGGESTIONS, ...MORE_SUGGESTIONS], + }; + } + return { + featured: FEATURED_SUGGESTIONS[0], + more: [...FEATURED_SUGGESTIONS.slice(1), ...MORE_SUGGESTIONS], + }; +} diff --git a/examples/chat/angular/src/app/shell/demo-shell.component.css b/examples/chat/angular/src/app/shell/demo-shell.component.css index 36b56921a..21ddf0766 100644 --- a/examples/chat/angular/src/app/shell/demo-shell.component.css +++ b/examples/chat/angular/src/app/shell/demo-shell.component.css @@ -177,3 +177,80 @@ .demo-shell ::ng-deep .chat-sidebar__panel { top: 0; } + +/* App-mode toggle: pill button in the toolbar. Enabled only when a Google + * Maps key is configured (App mode renders the map cockpit). */ +.demo-shell__app-toggle { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px 4px 8px; + border-radius: 999px; + border: 1px solid var(--tplane-chat-separator); + background: transparent; + color: var(--tplane-chat-text-muted); + cursor: pointer; + font: inherit; + font-size: var(--tplane-chat-font-size-sm); + flex: 0 0 auto; +} +.demo-shell__app-toggle:disabled { + opacity: 0.4; + cursor: not-allowed; +} +.demo-shell__app-toggle.is-on { + color: var(--tplane-chat-on-primary); + background: var(--tplane-chat-primary); + border-color: var(--tplane-chat-primary); +} +.demo-shell__app-toggle-icon { + font-family: 'Material Symbols Outlined', sans-serif; + font-size: 16px; + line-height: 1; +} +.demo-shell__app-toggle-thumb { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--tplane-chat-text-muted); +} +.demo-shell__app-toggle.is-on .demo-shell__app-toggle-thumb { + background: var(--tplane-chat-on-primary); +} + +/* App-mode layout: full-bleed map with the itinerary panel floating as an + * overlay card (top-left) and the chat sidebar alongside via router-outlet. */ +.demo-shell__app-body { + flex: 1 1 auto; + min-height: 0; + position: relative; + display: flex; +} +/* Popup mode only: the map is the full-bleed backdrop with the chat bubble + floating over it. (Sidebar mode renders the map inside the chat-sidebar's + flex content slot instead — see SidebarMode, Task 12 — so it gets its + column there.) */ +.demo-shell__map { + position: absolute; + inset: 0; +} +.demo-shell__itinerary-overlay { + position: absolute; + top: 16px; + left: 16px; + width: min(360px, 36vw); + max-height: calc(100vh - 120px); + overflow-y: auto; + background: var(--tplane-chat-bg); + border: 1px solid var(--tplane-chat-separator); + border-radius: var(--tplane-chat-radius-card); + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.25); + z-index: 10; +} +.demo-shell__app-body .demo-shell__main { + position: relative; + z-index: 5; + flex: 1 1 auto; + min-width: 0; +} diff --git a/examples/chat/angular/src/app/shell/demo-shell.component.html b/examples/chat/angular/src/app/shell/demo-shell.component.html index 721b3dc45..a44f6e31f 100644 --- a/examples/chat/angular/src/app/shell/demo-shell.component.html +++ b/examples/chat/angular/src/app/shell/demo-shell.component.html @@ -21,6 +21,20 @@ }
+ +
-
- - @if (agent.interrupt && agent.interrupt()) { -
- + @if (appMode() === 'on') { +
+ + @if (mode() === 'popup') { + + } + +
+ + @if (agent.interrupt && agent.interrupt()) { +
+ +
+ }
- } -
+
+ } @else { +
+ + @if (agent.interrupt && agent.interrupt()) { +
+ +
+ } +
+ } { TestBed.configureTestingModule({ providers: [ threadsAdapterProvider, + ItineraryStore, provideRouter([ { path: 'embed', component: DemoShell }, { path: 'popup', component: DemoShell }, @@ -77,6 +86,7 @@ describe('DemoShell — toolbar layout', () => { TestBed.configureTestingModule({ providers: [ threadsAdapterProvider, + ItineraryStore, provideRouter([ { path: 'embed', component: DemoShell }, { path: '', pathMatch: 'full', redirectTo: 'embed' }, @@ -93,6 +103,7 @@ describe('DemoShell — toolbar layout', () => { TestBed.configureTestingModule({ providers: [ threadsAdapterProvider, + ItineraryStore, provideRouter([ { path: 'embed', component: DemoShell }, { path: '', pathMatch: 'full', redirectTo: 'embed' }, @@ -116,6 +127,7 @@ describe('DemoShell — toolbar dropdowns use chat-select', () => { TestBed.configureTestingModule({ providers: [ threadsAdapterProvider, + ItineraryStore, provideRouter([ { path: 'embed', component: DemoShell }, { path: '', pathMatch: 'full', redirectTo: 'embed' }, @@ -143,6 +155,7 @@ describe('DemoShell — URL thread sync', () => { TestBed.configureTestingModule({ providers: [ threadsAdapterProvider, + ItineraryStore, provideRouter([ { path: 'embed', component: DemoShell }, { path: 'embed/:threadId', component: DemoShell }, @@ -255,6 +268,7 @@ describe('DemoShell — URL knob hydration', () => { TestBed.configureTestingModule({ providers: [ threadsAdapterProvider, + ItineraryStore, provideRouter([ { path: 'embed', component: DemoShell }, { path: 'embed/:threadId', component: DemoShell }, @@ -372,3 +386,237 @@ describe('DemoShell — URL knob hydration', () => { expect(router.url).toContain('model=gpt-5-nano'); }); }); + +describe('DemoShell — App mode routing', () => { + beforeEach(() => { + localStorage.clear(); + TestBed.configureTestingModule({ + providers: [ + threadsAdapterProvider, + ItineraryStore, + provideRouter([ + { path: 'embed', component: DemoShell }, + { path: 'popup', component: DemoShell }, + { path: 'sidebar', component: DemoShell }, + { path: '', pathMatch: 'full', redirectTo: 'embed' }, + { path: '**', redirectTo: 'embed' }, + ]), + ], + }); + }); + + it('coerces embed → sidebar when App mode turns on', async () => { + const router = TestBed.inject(Router); + await router.navigateByUrl('/embed'); + const fx = TestBed.createComponent(DemoShell); + fx.detectChanges(); + + const cmp = fx.componentInstance as unknown as { + appMode: () => 'on' | 'off'; + onAppModeChange(v: 'on' | 'off'): void; + }; + expect(cmp.appMode()).toBe('off'); + + cmp.onAppModeChange('on'); + fx.detectChanges(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(cmp.appMode()).toBe('on'); + expect(router.url).toContain('/sidebar'); + }); + + it('turning App mode off keeps the current route', async () => { + const router = TestBed.inject(Router); + await router.navigateByUrl('/sidebar'); + const fx = TestBed.createComponent(DemoShell); + fx.detectChanges(); + + const cmp = fx.componentInstance as unknown as { + appMode: { (): 'on' | 'off'; set(v: 'on' | 'off'): void }; + onAppModeChange(v: 'on' | 'off'): void; + }; + cmp.appMode.set('on'); + fx.detectChanges(); + + cmp.onAppModeChange('off'); + fx.detectChanges(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(cmp.appMode()).toBe('off'); + expect(router.url).toContain('/sidebar'); + expect(router.url).not.toContain('/embed'); + }); + + it('selecting embed while App mode on turns App mode off', async () => { + const router = TestBed.inject(Router); + await router.navigateByUrl('/sidebar'); + const fx = TestBed.createComponent(DemoShell); + fx.detectChanges(); + + const cmp = fx.componentInstance as unknown as { + appMode: { (): 'on' | 'off'; set(v: 'on' | 'off'): void }; + onModeChange(next: string): void; + }; + cmp.appMode.set('on'); + fx.detectChanges(); + + cmp.onModeChange('embed'); + fx.detectChanges(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(cmp.appMode()).toBe('off'); + }); +}); + +describe('DemoShell — App-mode cockpit layout', () => { + beforeEach(() => { + localStorage.clear(); + TestBed.configureTestingModule({ + providers: [ + threadsAdapterProvider, + ItineraryStore, + provideRouter([ + { path: 'embed', component: DemoShell }, + { path: 'popup', component: DemoShell }, + { path: 'sidebar', component: DemoShell }, + { path: '', pathMatch: 'full', redirectTo: 'embed' }, + { path: '**', redirectTo: 'embed' }, + ]), + ], + }); + }); + + it('renders the map backdrop + itinerary overlay and forces drawer sidenav in popup App mode', async () => { + const router = TestBed.inject(Router); + await router.navigateByUrl('/popup'); + const fx = TestBed.createComponent(DemoShell); + fx.detectChanges(); + + const cmp = fx.componentInstance as unknown as { + appMode: { (): 'on' | 'off'; set(v: 'on' | 'off'): void }; + sidenavMode: () => string; + }; + + cmp.appMode.set('on'); + fx.detectChanges(); + + // App mode collapses the sidenav to its hamburger drawer. + expect(cmp.sidenavMode()).toBe('drawer'); + + // Popup App mode renders the full-bleed map backdrop + floating itinerary. + const el = fx.nativeElement as HTMLElement; + expect(el.querySelector('.demo-shell__app-body')).toBeTruthy(); + expect(el.querySelector('app-map-canvas')).toBeTruthy(); + expect(el.querySelector('app-itinerary-panel')).toBeTruthy(); + }); +}); + +// ── Itinerary ↔ checkpoint sync (Task 9) ──────────────────────────────────── + +/** A FakeStreamTransport that records the payload of the most recent + * submit so a spec can assert the shell's state injection. */ +class CapturingTransport extends FakeStreamTransport { + lastPayload: unknown = undefined; + override async *stream( + assistantId: string, + threadId: string | null, + payload: unknown, + signal: AbortSignal, + options?: Parameters[4], + ) { + this.lastPayload = payload; + yield* super.stream(assistantId, threadId, payload, signal, options); + } +} + +describe('shouldSyncCheckpoint — push-gate predicate', () => { + it('pushes when a thread exists and content changed', () => { + expect(shouldSyncCheckpoint('thread-1', '[1]', '[0]')).toBe(true); + }); + + it('never pushes without a thread id', () => { + expect(shouldSyncCheckpoint(null, '[1]', '[0]')).toBe(false); + }); + + it('skips when the content already matches lastSynced (echo-loop guard)', () => { + expect(shouldSyncCheckpoint('thread-1', '[1]', '[1]')).toBe(false); + }); +}); + +describe('isConflict — retry only the mid-run 409', () => { + it('is true for a 409 status', () => { + expect(isConflict({ status: 409 })).toBe(true); + }); + + it('is true when the message mentions 409/conflict', () => { + expect(isConflict(new Error('Request failed: 409 Conflict'))).toBe(true); + }); + + it('is false for other errors (terminal — do not retry)', () => { + expect(isConflict({ status: 404 })).toBe(false); + expect(isConflict(new Error('Unauthorized'))).toBe(false); + expect(isConflict(null)).toBe(false); + }); +}); + +describe('extractItinerary — value → stops', () => { + it('returns the itinerary array from a state value', () => { + const stops: ItineraryStop[] = [{ id: 'x', day: 1, place: 'Louvre' }]; + expect(extractItinerary({ itinerary: stops })).toEqual(stops); + }); + + it('returns null when no itinerary present', () => { + expect(extractItinerary({ messages: [] })).toBeNull(); + expect(extractItinerary(undefined)).toBeNull(); + expect(extractItinerary({ itinerary: 'not-an-array' })).toBeNull(); + }); +}); + +describe('DemoShell — submit injects itinerary into state', () => { + beforeEach(() => { + localStorage.clear(); + TestBed.configureTestingModule({ + providers: [ + threadsAdapterProvider, + ItineraryStore, + { provide: LANGGRAPH_THREADS_CONFIG, useValue: { apiUrl: 'http://localhost:2024' } }, + provideRouter([ + { path: 'embed', component: DemoShell }, + { path: '', pathMatch: 'full', redirectTo: 'embed' }, + { path: '**', redirectTo: 'embed' }, + ]), + ], + }); + }); + + it('forwards store.stops() as state.itinerary on submit', async () => { + const capturing = new CapturingTransport(); + const store = new ItineraryStore(); + // Override the component-scoped agent so its transport is capturable, + // and share the SAME store instance the shell injects. + TestBed.overrideComponent(DemoShell, { + set: { + providers: [ + { provide: ItineraryStore, useValue: store }, + ...provideAgent(DEMO_AGENT_REF, { + assistantId: 'fake', + transport: capturing, + }), + ], + }, + }); + + const fx = TestBed.createComponent(DemoShell); + fx.detectChanges(); + const shell = fx.componentInstance as unknown as { + agent: { submit: (i: unknown) => Promise }; + }; + + store.add(1, 'Louvre'); + await shell.agent.submit({ messages: [{ role: 'user', content: 'hi' }] }); + + const payload = capturing.lastPayload as { itinerary?: ItineraryStop[] }; + expect(payload.itinerary).toEqual(store.stops()); + expect(payload.itinerary?.[0].place).toBe('Louvre'); + }); +}); diff --git a/examples/chat/angular/src/app/shell/demo-shell.component.ts b/examples/chat/angular/src/app/shell/demo-shell.component.ts index 5ae9debdd..48d8bfb61 100644 --- a/examples/chat/angular/src/app/shell/demo-shell.component.ts +++ b/examples/chat/angular/src/app/shell/demo-shell.component.ts @@ -13,8 +13,17 @@ import { import { Router, RouterOutlet, NavigationEnd } from '@angular/router'; import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import { filter, map, startWith } from 'rxjs/operators'; -import { injectAgent, provideAgent, LangGraphThreadsAdapter, refreshOnRunEnd } from '@threadplane/langgraph'; +import { + injectAgent, + provideAgent, + LangGraphThreadsAdapter, + LANGGRAPH_THREADS_CONFIG, + createLangGraphClient, + refreshOnRunEnd, +} from '@threadplane/langgraph'; import { DEMO_AGENT_REF, type DemoState } from './agent-ref'; +import { ItineraryStore, type ItineraryStop } from '../itinerary-store'; +import { itineraryClientTools } from '../client-tools'; import { ThreadplaneTelemetryService } from '@threadplane/telemetry/browser'; import { ChatInterruptPanelComponent, @@ -33,6 +42,8 @@ import { } from '@threadplane/chat'; import { PalettePersistence } from './palette-persistence.service'; import { ProjectsService } from './projects.service'; +import { MapCanvasComponent } from '../map-canvas.component'; +import { ItineraryPanelComponent } from '../itinerary-panel.component'; import { DEMO_AGENT } from './shell-tokens'; import { createCanonicalDemoRuntimeTelemetrySink } from './runtime-telemetry'; import { environment } from '../../environments/environment'; @@ -65,6 +76,55 @@ function parseUrl(url: string): { mode: DemoMode; threadId: string | null } { return { mode, threadId }; } +// ── Itinerary ↔ checkpoint sync helpers (Task 9) ──────────────────────────── +// Extracted as pure functions so the sync decisions are unit-testable without +// driving the agent's derived `value()`/`isLoading()` signals (which the fake +// transport harness can't emit). The effects below stay thin wrappers. + +/** + * Decide whether to push the working itinerary to the durable checkpoint. + * Returns true when a thread exists and the itinerary changed since the last + * sync. The `json === lastJson` check is the echo-loop guard: hydration stamps + * `lastSyncedItinerary` with the incoming server JSON, so the re-fired push + * effect sees "no change" and skips — making hydrate→push→hydrate converge. + * + * Run-state is intentionally NOT gated. The client-tool resume loop keeps the + * agent loading for the whole plan, so a run-gated push never fires with the + * final itinerary; instead the push attempts anyway and retries on the 409 a + * mid-run `updateState` returns, until the run settles (see the push effect). + */ +export function shouldSyncCheckpoint( + threadId: string | null, + json: string, + lastJson: string, +): boolean { + if (!threadId) return false; + return json !== lastJson; +} + +/** Pull the itinerary array out of a graph-state `value()` snapshot, or null + * when the state has no (well-formed) itinerary to hydrate from. */ +export function extractItinerary(value: unknown): ItineraryStop[] | null { + if (value && typeof value === 'object') { + const itin = (value as { itinerary?: unknown }).itinerary; + if (Array.isArray(itin)) return itin as ItineraryStop[]; + } + return null; +} + +/** Cap on checkpoint-push retries — a backstop so a persistently-failing thread + * can't spin an unbounded background retry loop. */ +export const MAX_PUSH_RETRIES = 20; + +/** A mid-run `updateState` returns HTTP 409 (the client-tool resume loop is + * streaming); that is the ONLY error worth retrying. Any other failure + * (404/auth/500) is terminal for this best-effort sync. */ +export function isConflict(err: unknown): boolean { + const status = (err as { status?: number })?.status; + if (status === 409) return true; + return /\b409\b|conflict/i.test(String((err as { message?: string })?.message ?? err)); +} + @Component({ selector: 'demo-shell', standalone: true, @@ -75,6 +135,8 @@ function parseUrl(url: string): { mode: DemoMode; threadId: string | null } { ChatSidenavScrimComponent, ChatHistorySearchPaletteComponent, ChatSelectComponent, + MapCanvasComponent, + ItineraryPanelComponent, ], changeDetection: ChangeDetectionStrategy.OnPush, templateUrl: './demo-shell.component.html', @@ -115,6 +177,26 @@ export class DemoShell { protected readonly projectsSvc = inject(ProjectsService); private readonly telemetry = inject(ThreadplaneTelemetryService); + /** Shared working copy of the itinerary — an app-wide singleton (provided in + * app.config.ts) so this shell, the panel, and the map read/write ONE store. */ + protected readonly itinerary = inject(ItineraryStore); + + /** Frontend-owned itinerary client tools, forwarded to the chat mode + * components (`[clientTools]`) so the agent can read/mutate the panel. + * Built here (an injection context) since the registry injects the store. */ + readonly clientTools = itineraryClientTools(); + + /** Out-of-band SDK client used to push the working itinerary to the durable + * checkpoint (`threads.updateState`) between runs. `LANGGRAPH_THREADS_CONFIG` + * is optional so knob/routing unit tests that don't provide it still run. */ + private readonly lgClient = createLangGraphClient( + inject(LANGGRAPH_THREADS_CONFIG, { optional: true })?.apiUrl ?? environment.langGraphApiUrl, + ); + + /** JSON of the last itinerary we synced (either pushed OR hydrated). Breaks + * the hydrate→push→hydrate echo loop — see `shouldSyncCheckpoint`. */ + private lastSyncedItinerary = ''; + constructor() { // Reflect the chosen theme onto so the // global stylesheet's scoped --a2ui-* overrides activate. Runs on @@ -180,6 +262,63 @@ export class DemoShell { // needing a manual thread switch or reload. refreshOnRunEnd(this.agent, () => this.threadsSvc.refresh()); + // ── Itinerary ↔ checkpoint sync (Task 9) ──────────────────────────────── + // The checkpoint (per-thread graph state) is the durable record; the + // ItineraryStore is the live working copy. We sync client-authoritatively. + + // (2) Hydrate the store from the checkpoint on reload / thread switch. + // Reads the agent's graph-state value(); when it carries an itinerary + // array, replaces the working copy. Stamps lastSyncedItinerary with the + // incoming JSON so the push effect below sees "no change" and skips — + // this is the other half of the echo-loop guard. + effect(() => { + const incoming = extractItinerary(this.agent.value()); + if (incoming === null) return; + // Don't let a behind/empty server snapshot wipe a populated local working + // copy: during a plan the client tools fill the store before the checkpoint + // catches up via the push below. Adopt an empty server itinerary only when + // the store is itself empty (a genuine thread switch / fresh load). + if (incoming.length === 0 && untracked(() => this.itinerary.stops()).length > 0) return; + const json = JSON.stringify(incoming); + if (json === this.lastSyncedItinerary) return; + this.lastSyncedItinerary = json; + this.itinerary.hydrate(incoming); + }); + + // (3) Push the working copy to the durable checkpoint on every change. + // NOT run-gated: the client-tool resume loop keeps the agent loading for + // the whole plan, so a mid-run write returns 409 — retry ONLY that (bounded + // by MAX_PUSH_RETRIES) until the run settles so the final itinerary always + // lands. Debounced ~1200ms; onCleanup cancels a superseded attempt. Any + // non-conflict error is terminal — this is a best-effort sync, not a queue. + effect((onCleanup) => { + const stops = this.itinerary.stops(); + const tid = threadIdState(); + const json = JSON.stringify(stops); + if (!shouldSyncCheckpoint(tid, json, this.lastSyncedItinerary)) return; + let cancelled = false; + let retries = 0; + let timer: ReturnType; + const attempt = async (): Promise => { + if (cancelled) return; + try { + await this.lgClient.threads.updateState(tid as string, { + values: { itinerary: stops }, + }); + this.lastSyncedItinerary = json; + } catch (err) { + if (!cancelled && isConflict(err) && retries++ < MAX_PUSH_RETRIES) { + timer = setTimeout(() => void attempt(), 1500); + } + } + }; + timer = setTimeout(() => void attempt(), 1200); + onCleanup(() => { + cancelled = true; + clearTimeout(timer); + }); + }); + if (typeof window !== 'undefined') { const onResize = () => this.viewportWidth.set(window.innerWidth); window.addEventListener('resize', onResize); @@ -210,6 +349,38 @@ export class DemoShell { protected readonly mode = computed(() => this.urlState().mode); + /** Whether the Google Maps key is configured — App mode needs the map, + * so the toggle is disabled without it. */ + readonly hasMapsKey = (environment.googleMapsApiKey as string).length > 0; + + /** App mode: a presentational layer valid only in popup/sidebar (embed's + * full-bleed chat would cover the map). Persisted across reloads and + * mirrored to the `appmode` query param so shared links restore it. */ + readonly appMode = signal<'on' | 'off'>(this.initialAppMode()); + + /** Mode parsed from the REAL browser path. Available immediately at + * bootstrap (unlike router.url, which reads '/' before the initial + * navigation settles), so App mode restores against the right route. */ + private locationMode(): DemoMode { + const path = this.document.defaultView?.location.pathname ?? ''; + const seg = path.split('/').filter(Boolean)[0]; + return (MODES as readonly string[]).includes(seg) ? (seg as DemoMode) : 'embed'; + } + + /** App mode persists across reloads, but it can only run in popup/sidebar — + * embed is full-chat with no background for the map. Reads the `appmode` + * query param from the real browser URL (available at bootstrap, unlike + * ActivatedRoute), falling back to persistence. Starts off when the value + * isn't 'on' OR the current route is embed (e.g. a hand-typed + * /embed?appmode=on). */ + private initialAppMode(): 'on' | 'off' { + const search = this.document.defaultView?.location.search ?? ''; + const raw = (new URLSearchParams(search).get('appmode') ?? + this.persistence.read('appMode')) as 'on' | 'off' | null; + if (raw !== 'on') return 'off'; + return this.locationMode() === 'embed' ? 'off' : 'on'; + } + /** * Source of truth for the model picker. The shell owns it; the * patched submit injects it into state on every send. @@ -270,10 +441,13 @@ export class DemoShell { (this.persistence.read('sidenavMode') as 'expanded' | 'collapsed' | null) ?? 'expanded', ); - /** Computed sidenav mode: viewport forces drawer below 768px, else user preference. */ - protected readonly sidenavMode = computed(() => - this.viewportWidth() >= 768 ? this.storedDesktopMode() : 'drawer', - ); + /** Computed sidenav mode: App mode forces drawer (the cockpit reclaims the + * full width for the map + overlay), then viewport forces drawer below + * 768px, else user preference. */ + protected readonly sidenavMode = computed(() => { + if (this.appMode() === 'on') return 'drawer'; + return this.viewportWidth() >= 768 ? this.storedDesktopMode() : 'drawer'; + }); /** Active threads filtered by the selected project (or all when none selected). */ protected readonly visibleThreads = computed(() => { @@ -415,6 +589,7 @@ export class DemoShell { model: this.model(), reasoning_effort: this.effort(), gen_ui_mode: this.genUiMode(), + itinerary: this.itinerary.stops(), }, }, opts, @@ -427,6 +602,13 @@ export class DemoShell { protected readonly _demoState: DemoState = this.agent.value(); protected onModeChange(next: DemoMode | string): void { + // Embed can't coexist with App mode (its full-bleed chat covers the + // map), so selecting Embed while App mode is on turns App mode off. + // Popup and Sidebar layer over the map, so they leave App mode alone. + if (next === 'embed' && this.appMode() === 'on') { + this.appMode.set('off'); + this.persistence.write('appMode', 'off'); + } // Preserve the active thread across mode switches: /embed/abc → // /popup/abc keeps the conversation visible in the new chrome. // Preserve query params so knob state survives the mode hop. @@ -437,6 +619,45 @@ export class DemoShell { ); } + /** + * Toggle App mode. Enabling it needs a background area for the map — + * embed has none, so it coerces to the sidebar cockpit; popup/sidebar + * keep their current presentation. Unlike ag-ui-shell (which has a + * knob→URL effect that resolves routing), demo-shell is URL-as-truth, + * so this handler both navigates AND writes `appmode` to the query + * itself (merging so knob params survive). + */ + onAppModeChange(v: 'on' | 'off'): void { + this.persistence.write('appMode', v); + if (v === 'on' && this.mode() === 'embed') { + // Navigate to the sidebar cockpit first (preserving query so the + // active thread + knobs survive), then flip the signal + stamp + // appmode=on into the URL. + const id = this.threadIdSignal(); + void this.router + .navigate(id ? ['/', 'sidebar', id] : ['/', 'sidebar'], { + queryParamsHandling: 'preserve', + }) + .then(() => { + this.appMode.set('on'); + void this.router.navigate([], { + queryParams: { appmode: 'on' }, + queryParamsHandling: 'merge', + replaceUrl: true, + }); + }); + return; + } + // popup/sidebar (or turning off): keep the current route, just update + // the appmode query param. 'off' → null drops it from the URL. + this.appMode.set(v); + void this.router.navigate([], { + queryParams: { appmode: v === 'off' ? null : 'on' }, + queryParamsHandling: 'merge', + replaceUrl: true, + }); + } + /** Build the full knob → URL-value mapping. Default values become * null so Angular's router drops them from the resulting URL when * used with queryParamsHandling: 'merge'. */ diff --git a/examples/chat/angular/src/app/shell/palette-persistence.service.ts b/examples/chat/angular/src/app/shell/palette-persistence.service.ts index 3abc1553c..b233be266 100644 --- a/examples/chat/angular/src/app/shell/palette-persistence.service.ts +++ b/examples/chat/angular/src/app/shell/palette-persistence.service.ts @@ -12,6 +12,7 @@ interface PaletteState { sidenavMode?: 'expanded' | 'collapsed' | null; selectedProjectId?: string | null; colorScheme?: 'light' | 'dark' | null; + appMode?: 'on' | 'off' | null; } type PaletteKey = keyof PaletteState; @@ -30,6 +31,7 @@ const ALLOWED = { theme: new Set(['default-dark', 'default-light', 'material-dark', 'material-light']), colorScheme: new Set(['light', 'dark']), sidenavMode: new Set(['expanded', 'collapsed']), + appMode: new Set(['on', 'off']), } as const satisfies Partial>>; type EnumKey = keyof typeof ALLOWED; diff --git a/examples/chat/angular/src/environments/environment.development.ts b/examples/chat/angular/src/environments/environment.development.ts index bd0292040..ace45c0ac 100644 --- a/examples/chat/angular/src/environments/environment.development.ts +++ b/examples/chat/angular/src/environments/environment.development.ts @@ -5,6 +5,8 @@ * Points to a local LangGraph server started with: * cd examples/chat/python && langgraph dev */ +import { GENERATED_KEYS } from './generated-keys'; + export const environment = { production: false, langGraphApiUrl: 'http://localhost:2024', @@ -14,4 +16,6 @@ export const environment = { sampleRate: 1, }, license: undefined as string | undefined, + googleMapsApiKey: GENERATED_KEYS.googleMaps, + googleMapsMapId: GENERATED_KEYS.googleMapsMapId, }; diff --git a/examples/chat/angular/src/environments/environment.ts b/examples/chat/angular/src/environments/environment.ts index 8fd11d49c..f955cb942 100644 --- a/examples/chat/angular/src/environments/environment.ts +++ b/examples/chat/angular/src/environments/environment.ts @@ -7,6 +7,8 @@ * which injects x-api-key server-side and proxies to the shared * cockpit-dev LangGraph Cloud assistant. */ +import { GENERATED_KEYS } from './generated-keys'; + export const environment = { production: true, langGraphApiUrl: '/api', @@ -17,4 +19,6 @@ export const environment = { sampleRate: 1, }, license: undefined as string | undefined, + googleMapsApiKey: GENERATED_KEYS.googleMaps, + googleMapsMapId: GENERATED_KEYS.googleMapsMapId, }; diff --git a/examples/chat/angular/src/environments/generated-keys.ts b/examples/chat/angular/src/environments/generated-keys.ts new file mode 100644 index 000000000..7da7b6554 --- /dev/null +++ b/examples/chat/angular/src/environments/generated-keys.ts @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: MIT +// Committed stub. Regenerated as generated-keys.local.ts at build time by +// scripts/inject-env.mjs and swapped in via project.json fileReplacements. +// Ships empty (CI has no key); local/preview builds get the real value. +export const GENERATED_KEYS = { + googleMaps: '', + googleMapsMapId: '', +} as const; diff --git a/examples/chat/python/pyproject.toml b/examples/chat/python/pyproject.toml index 961c196f1..a1c9056b3 100644 --- a/examples/chat/python/pyproject.toml +++ b/examples/chat/python/pyproject.toml @@ -7,6 +7,7 @@ dependencies = [ "langchain-openai>=0.3", "langgraph-api>=0.8.7", "python-dotenv>=1.0", + "threadplane-middleware>=0.0.1", ] [tool.uv] diff --git a/examples/chat/python/src/graph.py b/examples/chat/python/src/graph.py index 48e0c4c6d..7fe553f1f 100644 --- a/examples/chat/python/src/graph.py +++ b/examples/chat/python/src/graph.py @@ -23,7 +23,7 @@ import json import os from typing import Annotated, Literal, Optional -from typing_extensions import TypedDict +from typing_extensions import TypedDict, NotRequired from langgraph.graph import StateGraph, END from langgraph.graph.message import add_messages @@ -42,6 +42,8 @@ from langgraph_sdk import get_client from langsmith import traceable +from threadplane.middleware.langgraph import bind_client_tools, client_tool_names + from src.streaming.a2ui_partial_handler import A2uiPartialHandler from src.streaming.envelope_tool import render_a2ui_surface from src.streaming.envelope_normalizer import normalize_envelope_args @@ -180,6 +182,16 @@ async def generate_title(state: "State", config: RunnableConfig) -> dict: "ask for a UI / form / card / interactive surface." ) +_PLANNER_FRAMING = ( + "\n\n--- APP MODE: TRIP PLANNER ---\n" + "You are a trip-planning assistant driving a live map cockpit. When the user " + "asks for a plan, RECOMMEND concrete places (a short note each) grouped into " + "days, and POPULATE the itinerary by calling `add_stop` for each recommendation " + "(then `day_card` to recap a day). Revise with `move_stop`/`reorder_stop`/`clear_day`. " + "Do NOT just describe the plan in prose — call the tools so the map and panel update. " + "Only add stops that are not already present." +) + # Reasoning-capable model prefixes. We only attach the ``reasoning`` # parameter when the model name suggests reasoning support; setting it # on a non-reasoning model would be ignored anyway. @@ -362,11 +374,50 @@ def _as_text(content) -> str: return "" +class Stop(TypedDict): + id: str + day: int + place: str + note: NotRequired[str] + lat: NotRequired[float] + lng: NotRequired[float] + + class State(TypedDict): messages: Annotated[list, add_messages] model: Optional[str] reasoning_effort: Optional[str] gen_ui_mode: Optional[str] + # Frontend client-tool catalog. The @threadplane/langgraph SDK path + # (mergeClientTools) sends the browser-declared tool stubs under + # ``client_tools`` in the run payload; the threadplane-middleware + # ``_catalog`` reads ``state["tools"]`` first and falls back to + # ``state["client_tools"]`` — so this channel name feeds the middleware + # directly with no normalization needed. The channel must exist here so + # the catalog survives the generate → should_continue path. + client_tools: Optional[list[dict]] + # Trip itinerary the frontend client tools (add_stop/move_stop/...) + # mutate. per-thread checkpoint; last-write-wins (plain key) + itinerary: list[Stop] + + +def build_system_prompt(gen_ui_mode: str, client_tools: list, itinerary: list) -> str: + system = SYSTEM_PROMPT + if gen_ui_mode == "a2ui": + system = system + "\n\n--- A2UI v1 SCHEMA ---\n" + A2UI_V1_SCHEMA_PROMPT + ( + "\n\nWhen rendering UI in a2ui mode, emit envelopes in this order: " + "surfaceUpdate FIRST, then beginRendering, then any dataModelUpdate " + "entries. This lets the client mount the surface as early as possible." + ) + if client_tools: + system = system + _PLANNER_FRAMING + if itinerary: + import json as _json + system = system + ( + "\n\nCURRENT ITINERARY (do not re-add existing stops):\n" + + _json.dumps(itinerary, ensure_ascii=False) + ) + return system async def generate(state: State, config: RunnableConfig) -> dict: @@ -402,18 +453,24 @@ async def generate(state: State, config: RunnableConfig) -> dict: # libs/chat/src/lib/a2ui/envelope-normalizer.ts) to canonicalize the # four observed argument shapes (envelopes / envelope / positional / # flat). The spike showed 80-93% canonical even without strict. - llm = ChatOpenAI(**kwargs).bind_tools( + # bind_client_tools appends the frontend client-tool catalog stubs (read + # from state["client_tools"], sent by the @threadplane/langgraph SDK + # mergeClientTools path) to the server tool list so the model can call any + # browser-declared tool by name (add_stop/move_stop/clear_day/day_card). + llm = bind_client_tools( + ChatOpenAI(**kwargs), [search_documents, request_approval, research, gen_ui_tool], + state, ) # Append A2UI v1 schema to system prompt when in a2ui mode, so the parent - # LLM knows how to construct the envelopes directly. - system = SYSTEM_PROMPT - if gen_ui_mode == "a2ui": - system = SYSTEM_PROMPT + "\n\n--- A2UI v1 SCHEMA ---\n" + A2UI_V1_SCHEMA_PROMPT + ( - "\n\nWhen rendering UI in a2ui mode, emit envelopes in this order: " - "surfaceUpdate FIRST, then beginRendering, then any dataModelUpdate " - "entries. This lets the client mount the surface as early as possible." - ) + # LLM knows how to construct the envelopes directly. When the frontend + # client-tool catalog is present (App mode), also inject the trip-planner + # framing + the current itinerary so the model populates state via the tools. + system = build_system_prompt( + gen_ui_mode=gen_ui_mode, + client_tools=state.get("client_tools") or [], + itinerary=state.get("itinerary") or [], + ) messages = [SystemMessage(content=system)] + state["messages"] # When in a2ui mode, attach the partial-envelope sideband handler so # the parent LLM's tool_call_chunks for render_a2ui_surface are @@ -427,13 +484,23 @@ async def generate(state: State, config: RunnableConfig) -> dict: def should_continue(state: State) -> Literal["tools", "attach_citations"]: - """Conditional edge from generate: route to tools node when any - tool_call is present (GenUI tools, search, approval, research), - otherwise route to attach_citations terminal post-process.""" + """Conditional edge from generate: route to the server ToolNode when any + SERVER tool_call is present (GenUI tools, search, approval, research). + + A turn whose calls are ALL frontend client tools must END (route to the + terminal attach_citations post-process) so the browser can execute them + and re-run with the resulting ToolMessage — the server ToolNode has no + implementation for client tools and would otherwise error. Turns with no + tool calls also terminate at attach_citations (a no-op without search + results). Mixed server+client turns keep the 'tools' route. + """ last = state["messages"][-1] - if isinstance(last, AIMessage) and last.tool_calls: - return "tools" - return "attach_citations" + if not (isinstance(last, AIMessage) and last.tool_calls): + return "attach_citations" + client = client_tool_names(state) + if all(tc["name"] in client for tc in last.tool_calls): + return "attach_citations" + return "tools" def after_tools(state: State) -> Literal["emit_generated_surface", "generate"]: diff --git a/examples/chat/python/tests/test_client_tools_spike.py b/examples/chat/python/tests/test_client_tools_spike.py new file mode 100644 index 000000000..1885ccf8f --- /dev/null +++ b/examples/chat/python/tests/test_client_tools_spike.py @@ -0,0 +1,21 @@ +import pytest + + +@pytest.mark.smoke +def test_state_has_client_tools_channel(): + from src.graph import State + ann = State.__annotations__ + assert "client_tools" in ann, "State must carry the frontend client-tool catalog channel" + + +@pytest.mark.smoke +def test_all_client_tool_turn_routes_away_from_server_tools(): + from langchain_core.messages import AIMessage + from src.graph import should_continue + state = { + "messages": [AIMessage(content="", tool_calls=[ + {"name": "add_stop", "args": {"day": 1, "place": "Louvre"}, "id": "t1"}, + ])], + "client_tools": [{"name": "add_stop"}], + } + assert should_continue(state) != "tools" diff --git a/examples/chat/python/tests/test_itinerary_state.py b/examples/chat/python/tests/test_itinerary_state.py new file mode 100644 index 000000000..76f26ebad --- /dev/null +++ b/examples/chat/python/tests/test_itinerary_state.py @@ -0,0 +1,15 @@ +import pytest + + +@pytest.mark.smoke +def test_state_declares_itinerary_channel(): + from src.graph import State + assert "itinerary" in State.__annotations__ + + +@pytest.mark.smoke +def test_stop_shape(): + from src.graph import Stop + ann = Stop.__annotations__ + for key in ("id", "day", "place"): + assert key in ann, f"Stop must declare {key}" diff --git a/examples/chat/python/tests/test_planner_framing.py b/examples/chat/python/tests/test_planner_framing.py new file mode 100644 index 000000000..a80427208 --- /dev/null +++ b/examples/chat/python/tests/test_planner_framing.py @@ -0,0 +1,26 @@ +import pytest +from src.graph import build_system_prompt # helper introduced in Step 3 + + +@pytest.mark.smoke +def test_planner_framing_only_when_client_tools_present(): + plain = build_system_prompt(gen_ui_mode="json-render", client_tools=[], itinerary=[]) + assert "trip-planning" not in plain.lower() + + app = build_system_prompt( + gen_ui_mode="json-render", + client_tools=[{"name": "add_stop"}], + itinerary=[], + ) + assert "trip-planning" in app.lower() + assert "add_stop" in app # instructs the model to populate state via the tool + + +@pytest.mark.smoke +def test_current_itinerary_is_injected_when_present(): + app = build_system_prompt( + gen_ui_mode="json-render", + client_tools=[{"name": "add_stop"}], + itinerary=[{"id": "a", "day": 1, "place": "Louvre"}], + ) + assert "Louvre" in app diff --git a/examples/chat/python/uv.lock b/examples/chat/python/uv.lock index 060f09728..758c13d2e 100644 --- a/examples/chat/python/uv.lock +++ b/examples/chat/python/uv.lock @@ -288,6 +288,7 @@ dependencies = [ { name = "langgraph" }, { name = "langgraph-api" }, { name = "python-dotenv" }, + { name = "threadplane-middleware" }, ] [package.dev-dependencies] @@ -303,6 +304,7 @@ requires-dist = [ { name = "langgraph", specifier = ">=0.3" }, { name = "langgraph-api", specifier = ">=0.8.7" }, { name = "python-dotenv", specifier = ">=1.0" }, + { name = "threadplane-middleware", specifier = ">=0.0.1" }, ] [package.metadata.requires-dev] @@ -1488,6 +1490,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, ] +[[package]] +name = "threadplane-middleware" +version = "0.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "langchain-core" }, + { name = "langgraph" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/01/5f/fee360e3547bede9388bb72a9ad20ca811935582b989c78dafeaae57558a/threadplane_middleware-0.0.1.tar.gz", hash = "sha256:60c931d0248ed2e701a3aa1330c6fd647e611a949500269c5ba82f3607102cef", size = 95505, upload-time = "2026-06-16T20:55:18.335Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/55/28e6b752f3f53f2c5d2c2fa3ef27ac62a255a3b29376384f2dfc96d8acb0/threadplane_middleware-0.0.1-py3-none-any.whl", hash = "sha256:c14e49d506e8bd3078f54df381c150d390f2d129f844694d8f5154a2cd8710ea", size = 4315, upload-time = "2026-06-16T20:55:19.044Z" }, +] + [[package]] name = "tiktoken" version = "0.12.0"