Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
3e9a991
docs(spec): App mode on the langchain canonical demo — itinerary cockpit
blove Jul 7, 2026
dcacffb
docs(spec): drop seed data — empty start, agent builds the plan
blove Jul 7, 2026
3c24c09
docs(plan): implementation plan — App mode on the langchain canonical…
blove Jul 7, 2026
74d45e2
feat(chat-graph): spike client-tool binding + client-tool-aware routi…
blove Jul 7, 2026
e23fb54
feat(chat-graph): add itinerary Stop state channel
blove Jul 7, 2026
cdaeeea
feat(chat-graph): planner framing + itinerary context injection when …
blove Jul 7, 2026
de8f706
feat(examples-chat): wire Google Maps key via inject-env (local only)
blove Jul 7, 2026
e55515c
feat(examples-chat): port map-bounds, geocoding, google-maps-loader
blove Jul 7, 2026
0f37460
feat(examples-chat): port ItineraryStore — empty start, value hydrati…
blove Jul 7, 2026
662c94a
feat(examples-chat): port itinerary panel/map/day-card/clear-day UI (…
blove Jul 7, 2026
cc95310
feat(examples-chat): port itinerary client tools (drop get_itinerary,…
blove Jul 7, 2026
d5bae8a
feat(examples-chat): sync itinerary — submit state + value hydration …
blove Jul 7, 2026
2ab792e
fix(examples-chat): import vitest globals in client-tools.spec (build…
blove Jul 7, 2026
62b456f
feat(examples-chat): App-mode toggle + map-compatible routing (embed↔…
blove Jul 7, 2026
7d941ad
feat(examples-chat): App-mode cockpit layout (map bg + itinerary over…
blove Jul 7, 2026
77ababf
feat(examples-chat): wire client tools + cockpit into modes; context-…
blove Jul 7, 2026
c81c7b8
fix(examples-chat): drop dangling get_itinerary reference in client-t…
blove Jul 7, 2026
6318ac3
test(examples-chat): e2e App-mode cockpit (empty state + embed coercion)
blove Jul 7, 2026
f3f29ed
fix(examples-chat): reliably persist itinerary to checkpoint (retry m…
blove Jul 7, 2026
3fb99b4
fix(examples-chat): retry checkpoint push only on 409, capped (review)
blove Jul 7, 2026
f8e1383
feat(examples-chat): dark map via colorScheme, drop cloud-style depen…
blove Jul 7, 2026
eee067b
feat(examples-chat): map light/dark follows the app color scheme
blove Jul 7, 2026
5a98f31
Merge branch 'main' into feat/langgraph-app-mode-itinerary
blove Jul 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
1,049 changes: 1,049 additions & 0 deletions docs/superpowers/plans/2026-07-06-langgraph-canonical-app-mode-itinerary.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -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<T>` — 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 `<mode>/: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 `<mode>/: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.
38 changes: 38 additions & 0 deletions examples/chat/angular/e2e/app-mode-itinerary.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
25 changes: 24 additions & 1 deletion examples/chat/angular/project.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
],
Expand Down Expand Up @@ -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": {
Expand Down Expand Up @@ -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"
}
]
}
Expand All @@ -89,6 +109,9 @@
"serve": {
"continuous": true,
"executor": "@angular/build:dev-server",
"dependsOn": [
"inject-env"
],
"options": {
"port": 4200
},
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
34 changes: 34 additions & 0 deletions examples/chat/angular/scripts/inject-env.mjs
Original file line number Diff line number Diff line change
@@ -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'})`);

Check failure

Code scanning / CodeQL

Clear-text logging of sensitive information High

This logs sensitive data returned by
an access to GOOGLE_MAPS_API_KEY
as clear text.
Loading
Loading