Skip to content

feat(examples-chat): App mode — itinerary map cockpit on the langgraph canonical demo#772

Merged
blove merged 23 commits into
mainfrom
feat/langgraph-app-mode-itinerary
Jul 7, 2026
Merged

feat(examples-chat): App mode — itinerary map cockpit on the langgraph canonical demo#772
blove merged 23 commits into
mainfrom
feat/langgraph-app-mode-itinerary

Conversation

@blove

@blove blove commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

App Mode on the LangChain canonical demo — itinerary cockpit

Ports the App-mode map/itinerary cockpit from examples/ag-ui to the langchain canonical demo (examples/chat, @threadplane/langgraph), using a single shared agent. Spec: docs/superpowers/specs/2026-07-06-langgraph-canonical-app-mode-itinerary-design.md; plan: docs/superpowers/plans/2026-07-06-langgraph-canonical-app-mode-itinerary.md.

Key design divergence from ag-ui

The itinerary lives in the langgraph graph state (itinerary: list[Stop], per-thread checkpoint) alongside messages — not a frontend-only store. This showcases langgraph's durable per-thread state: switching threads swaps the map, and a mid-plan reload restores the trip from the checkpoint.

  • No seed data — the demo starts empty; the user prompts the agent (via context-aware welcome suggestions) to generate a plan. The agent both recommends places and populates app state by calling add_stop/day_card client tools.
  • Client-authoritative sync: submit injects state.itinerary; DemoShell pushes the store to the checkpoint via the SDK client.threads.updateState(...); hydration reads agent.value().itinerary on reload/thread-switch. No localStorage.
  • Backend (additive to the existing chat graph): bind_client_tools when the frontend sends the catalog, client-tool-aware routing (all-client-tool turns skip the server ToolNode), planner framing + current-itinerary context injection. get_itinerary dropped (the model sees the itinerary in context).
  • Layout ①: App mode collapses the thread sidenav to its drawer; map backdrop + floating itinerary overlay + chat rail.

Verification

  • Unit 96 green, backend pytest 52 green, lint 0 errors, build green.
  • examples/chat e2e: App-mode cockpit (empty state + embed coercion), keyless.
  • Live Chrome-MCP gate with a real LLM: empty → "plan Osaka/Tokyo food tour" → agent populates the map/panel live with geocoded stops → reload restores from the checkpoint. This caught + fixed a durability bug: the client-tool resume loop keeps the agent loading the whole plan, so a run-gated updateState never fired — the push now retries the mid-run 409 (capped) until the run settles.

Non-goals (deferred)

  • Deployment — local-first. No Vercel Maps key, no deployed-graph update, no prod smoke. The deployed demo shows no itinerary until GOOGLE_MAPS_API_KEY (+ map id) is set in its env.
  • No shared-lib extraction (standalone-examples convention — duplicated into examples/chat).

🤖 Generated with Claude Code

blove and others added 20 commits July 6, 2026 17:24
Design for porting the ag-ui App-mode map/itinerary cockpit to
examples/chat (langgraph), single shared agent. Key divergence:
itinerary lives in graph state (per-thread checkpoint) alongside
messages, synced client-authoritatively via input.state + updateState;
no localStorage. Local-first; deploy deferred.
No seed/seeding: a fresh thread starts empty; users prompt the agent
(via welcome suggestions) to generate a trip plan. Adds a first-class
Planner behavior & prompt-tuning section (recommend + populate app state
via client tools), empty-state CTA, and empty-thread live gate.
… demo

16 tasks across 5 phases (spike → backend state/planner → frontend port
→ shell/wiring → verify). TDD, subagent-executable, local-first.
…ng (#itinerary)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Mirrors examples/ag-ui/angular: inject-env.mjs reads GOOGLE_MAPS_API_KEY/
GOOGLE_MAPS_MAP_ID from the repo-root .env and writes a gitignored
generated-keys.local.ts, swapped in via project.json fileReplacements.
Ships empty in CI; local/preview builds get the real value.
…langgraph agent)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… use demo agent)

Copies examples/ag-ui/angular/src/app/client-tools.ts into examples/chat/angular,
removing the get_itinerary tool, ITINERARY_AGENT ref, and ItineraryState interface
since the chat demo's DEMO_AGENT_REF/DemoState already covers agent typing and the
model now sees itinerary state via injected graph-state context instead of a
read round-trip.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… compiles specs)

tsconfig.app.json includes src/**/*.ts, so spec files are type-checked in
the AOT build; the T8 client-tools.spec relied on ambient describe/it/expect
and broke the build. Import them explicitly, matching sibling specs.
…sidebar coercion)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…lay + sidenav→drawer)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…aware welcome suggestions

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ool error strings

get_itinerary was removed (the model sees the itinerary via injected graph
context); the move_stop/reorder_stop 'not found' errors no longer point at
a nonexistent tool.
…id-run 409)

The live gate caught that reload lost the trip: the client-tool resume loop
keeps the agent loading for the whole plan, so the run-gated updateState push
never fired with the final itinerary and the checkpoint kept the empty list
sent at first submit. Drop the isLoading gate and retry the updateState on the
409 a mid-run write returns until the run settles, so the final itinerary
always lands. Also guard hydration so a behind/empty server snapshot can't wipe
a populated local working copy mid-plan. Verified live: plan → reload restores.
Address final-review findings: the retry now discriminates — only a mid-run
409 (conflict) is retried, and only up to MAX_PUSH_RETRIES, so a persistently
failing thread (404/auth/500) can't spin an unbounded background loop. Track
the retry timer, and correct the now-stale run-gated/500ms comment. Adds
isConflict unit tests.
@vercel

vercel Bot commented Jul 7, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
threadplane Ready Ready Preview, Comment Jul 7, 2026 7:22pm

Request Review

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated approval: this PR received an intelligent (AI) code review. See the review comments on this PR.

@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Claude finished @blove's task in 3m 49s —— View job


PR Review — App mode itinerary cockpit

  • Read CLAUDE.md and repo conventions
  • Read PR diff and changed files
  • Review Python backend changes (graph.py)
  • Review Angular frontend — core services/store
  • Review Angular frontend — components
  • Review tests
  • Post inline comments and summary

Summary

Solid implementation — the LangGraph checkpoint-as-source-of-truth model is well-executed and the retry-on-409 fix for mid-run updateState is correct. Backend is clean: bind_client_tools, the client-tool-aware should_continue, and build_system_prompt are all minimal and testable. Four issues found:

Bugs

  1. Wrong overlay selector in fitPadding() (map-canvas.component.ts:208) — The code queries .ag-ui-shell__itinerary-overlay (copied from the ag-ui example), but the demo shell assigns the class demo-shell__itinerary-overlay. The query always returns null, so pad.left is stuck at 48px regardless of the panel width — markers can frame behind the overlay.

  2. Thread switch to empty thread leaves previous itinerary visible (demo-shell.component.ts:278–281) — The guard that prevents a behind checkpoint from wiping a mid-run local store (incoming.length === 0 && localHasStops → skip) can't distinguish "same thread, checkpoint lagging" from "different thread, genuinely empty". Switching to a new thread leaves the previous trip on the map, contradicting the stated "switching threads swaps the map" design.

Minor / resource issues

  1. setInterval poll has no escape hatch (google-maps-loader.ts:41–49) — If a prior <script data-google-maps> is in-flight but the Maps API never populates window.google.maps (bad key, quota, CSP), the interval runs indefinitely. A setTimeout(() => clearInterval(poll), 30_000) backstop would close this.

  2. ItineraryStore missing @Injectable decorator (itinerary-store.ts:24) — Works via explicit provision in app.config.ts today, but without the decorator the compiler can't emit ɵfac, IDE injection inference breaks, and any future inject(ItineraryStore) outside the explicit scope fails at runtime instead of compile time.

No security issues, no public @threadplane/* API regressions, no RxJS subscription leaks.

} as const;
`;
writeFileSync(targetPath, contents);
console.log(`[inject-env] wrote generated-keys.local.ts (key length: ${key.length}, mapId: ${mapId ? 'set' : 'unset'})`);
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');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: wrong overlay selector — left padding never adapts.

The query uses .ag-ui-shell__itinerary-overlay (copied from the ag-ui example), but this demo's shell assigns the class demo-shell__itinerary-overlay to the panel host:

<app-itinerary-panel class="demo-shell__itinerary-overlay" />

document.querySelector('.ag-ui-shell__itinerary-overlay') always returns null, so pad.left is stuck at the 48px BASE. When the itinerary overlay is open, the rightmost visible portion of a newly-added stop can land beneath it.

Fix:

const overlay = document.querySelector('.demo-shell__itinerary-overlay');

Fix this →

Comment on lines +278 to +281
// 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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Behavioral bug: switching to a new/empty thread leaves the previous itinerary on the map.

This guard is correct for the mid-run case (client tools fill the store faster than the checkpoint saves), but it fires equally on a genuine thread switch. When the user selects a brand-new thread with no itinerary, incoming is [] and the local store still has the previous trip's stops, so the guard returns early without clearing — contradicting the PR's stated "switching threads swaps the map" behaviour.

The root issue is that the same effect can't distinguish "mid-run checkpoint lag on the same thread" from "new thread with genuinely no stops."

One approach: track the thread id separately and adopt [] unconditionally when the thread id changes:

effect(() => {
  const incoming = extractItinerary(this.agent.value());
  const tid = threadIdState();
  if (incoming === null) return;
  const isNewThread = tid !== this.lastHydratedThreadId;
  // Only skip the empty-incoming guard mid-run on the SAME thread.
  if (!isNewThread && incoming.length === 0 && untracked(() => this.itinerary.stops()).length > 0) return;
  const json = JSON.stringify(incoming);
  if (json === this.lastSyncedItinerary && !isNewThread) return;
  this.lastHydratedThreadId = tid;
  this.lastSyncedItinerary = json;
  this.itinerary.hydrate(incoming);
});

Comment on lines +41 to +49
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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resource leak: interval never clears if google.maps fails to initialize.

If a prior <script data-google-maps> tag is in-flight but the Maps API ultimately fails to populate window.google.maps (wrong key, quota, CSP block), this setInterval runs forever with no escape hatch. The loaded signal never flips and the interval accumulates.

Add a max-wait timeout or a script.addEventListener('error', ...) path that clears the interval:

const poll = setInterval(() => {
  if ((globalThis as { google?: { maps?: unknown } }).google?.maps) {
    clearInterval(poll);
    this.loaded.set(true);
  }
}, 100);
// Stop polling after 30 s regardless (key error, quota, CSP).
setTimeout(() => clearInterval(poll), 30_000);

* 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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: missing @Injectable decorator.

ItineraryStore is a plain class provided explicitly in app.config.ts, so DI works today. But without @Injectable(), Angular's compiler can't emit the ɵfac factory, IDE tooling may miss it, and any future call to inject(ItineraryStore) outside the explicit-provider scope fails silently at runtime rather than at compile time.

import { Injectable, computed, signal } from '@angular/core';

@Injectable()
export class ItineraryStore {

…dency

The map's dark theme was a cloud-based map style bound to a specific GCP
Map ID — brittle (lives in the Console, tied to one project/billing acct,
invisible to git). Replace it with an in-code colorScheme: DARK on the
vector map, which keeps AdvancedMarkers, works with any mapId (incl. the
DEMO_MAP_ID fallback), and is version-controlled + project-independent.
Kept as a plain string so the field carries no runtime google.maps
reference (map-canvas builds under jsdom in the shell specs).
@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Claude finished @blove's task in 2m 15s —— View job


PR Review — App mode itinerary cockpit (follow-up)

  • Read CLAUDE.md and repo conventions
  • Read PR diff and key changed files
  • Fix Bug 1: Wrong overlay selector in fitPadding()
  • Fix Bug 2: Thread switch to empty thread leaves previous itinerary visible
  • Fix Bug 3: setInterval poll has no escape hatch in google-maps-loader.ts
  • Fix Bug 4: ItineraryStore missing @Injectable decorator
  • Fix CodeQL finding: clear-text logging of sensitive info in inject-env.mjs
  • Push fixes (blocked: 403 from GitHub Actions bot — write permission needed on this repo)

All five fixes were committed locally. The push was blocked by a repository permission error (403 Permission denied). Here's what changed:

Fix 1 — Wrong overlay selector (map-canvas.component.ts:216)

// Before
const overlay = document.querySelector('.ag-ui-shell__itinerary-overlay');
// After
const overlay = document.querySelector('.demo-shell__itinerary-overlay');

The ag-ui class was copy-pasted from the other example but this shell uses .demo-shell__itinerary-overlay, so pad.left was always stuck at the 48 px base.

Fix 2 — Thread switch clears the map (demo-shell.component.ts)

Added private lastHydratedThreadId: string | null = null and wired threadIdState() into the hydration effect. When the thread id changes the "skip empty" guard is bypassed, so switching to a new/empty thread correctly wipes the previous trip:

const isNewThread = tid !== this.lastHydratedThreadId;
if (!isNewThread && incoming.length === 0 && untracked(() => this.itinerary.stops()).length > 0) return;
if (json === this.lastSyncedItinerary && !isNewThread) return;
this.lastHydratedThreadId = tid;

Fix 3 — Interval leak backstop (google-maps-loader.ts:49)

setTimeout(() => clearInterval(poll), 30_000);

Prevents the poll from running forever when a Maps script tag is already in-flight but the API never resolves (bad key, quota, CSP).

Fix 4 — @Injectable() decorator (itinerary-store.ts)

import { Injectable, computed, signal } from '@angular/core';
// ...
@Injectable()
export class ItineraryStore {

Without the decorator Angular can't emit ɵfac; IDE inference breaks and any future inject(ItineraryStore) outside the explicit provider scope fails at runtime.

Fix 5 — CodeQL clear-text logging (inject-env.mjs:34)

// Before
console.log(`... (key length: ${key.length}, ...)`);
// After
console.log(`... (key: ${key ? 'set' : 'unset'}, ...)`);

Replaces the numeric key length (which CodeQL treats as sensitive data flow) with a plain boolean presence check.

The map now tracks the app's light/dark toggle (the <html data-color-scheme>
the shell reflects), not the gen-UI mode. mapColorScheme is derived from a
MutationObserver-backed signal; since Google's colorScheme is init-only, the
template remounts <google-map> via a scheme-keyed @for when it flips.
@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Claude finished @blove's task in 2m 24s —— View job


PR Review — App mode itinerary cockpit (follow-up sync)

  • Read CLAUDE.md and repo conventions
  • Verified all 5 prior fixes are still pending (prior agent was blocked; same 403 here)
  • Fix 1: Wrong overlay selector in map-canvas.component.ts
  • Fix 2: Thread switch leaves previous itinerary in demo-shell.component.ts
  • Fix 3: setInterval no escape hatch in google-maps-loader.ts
  • Fix 4: Missing @Injectable decorator in itinerary-store.ts
  • Fix 5: CodeQL clear-text logging in inject-env.mjs
  • Push — blocked: 403 Permission denied (GitHub Actions bot lacks write on this repo)

All five fixes are committed locally as e966b6e but can't be pushed. The github-actions[bot] account needs write permission on cacheplane/angular-agent-framework for the bot to push. You can grant it under Settings → Actions → General → Workflow permissions (set to "Read and write") or cherry-pick the commit manually.

What changed

Fix 1 — Wrong overlay selector (map-canvas.component.ts:241)

// Before
const overlay = document.querySelector('.ag-ui-shell__itinerary-overlay');
// After
const overlay = document.querySelector('.demo-shell__itinerary-overlay');

fitPadding() was always returning the 48 px base; the panel width was never measured.

Fix 2 — Thread switch clears the map (demo-shell.component.ts)

Added private lastHydratedThreadId: string | null = null and wired threadIdState() into the hydration effect. When the thread id changes the "skip empty" guard is bypassed, so switching to a new/empty thread correctly wipes the previous trip:

const isNewThread = tid !== this.lastHydratedThreadId;
if (!isNewThread && incoming.length === 0 && untracked(() => this.itinerary.stops()).length > 0) return;
const json = JSON.stringify(incoming);
if (json === this.lastSyncedItinerary && !isNewThread) return;
this.lastHydratedThreadId = tid;

Fix 3 — Interval leak backstop (google-maps-loader.ts:49)

setTimeout(() => clearInterval(poll), 30_000);

Prevents the poll from running forever when a Maps script tag is in-flight but the API never resolves (bad key, quota, CSP).

Fix 4 — @Injectable() decorator (itinerary-store.ts)

import { Injectable, computed, signal } from '@angular/core';
// ...
@Injectable()
export class ItineraryStore {

Without this Angular can't emit ɵfac; IDE inference breaks and any future inject(ItineraryStore) outside the explicit provider scope fails at runtime.

Fix 5 — CodeQL clear-text logging (inject-env.mjs:34)

// Before
console.log(`... (key length: ${key.length}, ...)`);
// After
console.log(`... (key: ${key ? 'set' : 'unset'}, ...)`);

Replaces the numeric key length (CodeQL data-flow path to GOOGLE_MAPS_API_KEY) with a boolean presence check.

@blove blove enabled auto-merge (squash) July 7, 2026 19:19
@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Claude finished @blove's task in 2m 15s —— View job


PR Review — App mode itinerary cockpit (fixes applied locally)

  • Read CLAUDE.md and repo conventions
  • Check current state of files (all 5 fixes still pending from prior agents)
  • Fix Bug 1: Wrong overlay selector in map-canvas.component.ts
  • Fix Bug 2: Thread switch to empty thread leaves previous itinerary visible
  • Fix Bug 3: setInterval poll has no escape hatch in google-maps-loader.ts
  • Fix Bug 4: ItineraryStore missing @Injectable decorator
  • Fix Bug 5: CodeQL clear-text logging in inject-env.mjs
  • Push fixes — blocked: 403 Permission denied (same as prior two agents)

All five fixes are committed locally as 9339611 but the push is blocked by the same 403 Permission denied that stopped the previous two agents. The github-actions[bot] account needs write permission on this repository to push.

To unblock: Go to Settings → Actions → General → Workflow permissions and set it to "Read and write permissions", then re-trigger @claude on this PR.

Alternatively, cherry-pick the commit manually:

git fetch origin feat/langgraph-app-mode-itinerary
git cherry-pick 9339611
git push

What's in the commit

Fix 1 — Wrong overlay selector (map-canvas.component.ts:241)

// Before
const overlay = document.querySelector('.ag-ui-shell__itinerary-overlay');
// After
const overlay = document.querySelector('.demo-shell__itinerary-overlay');

fitPadding() was always returning the 48 px base; the panel width was never measured.

Fix 2 — Thread switch clears the map (demo-shell.component.ts)

Added private lastHydratedThreadId: string | null = null and wired threadIdState() into the hydration effect. When the thread id changes the "skip empty" guard is bypassed:

const isNewThread = tid !== this.lastHydratedThreadId;
if (!isNewThread && incoming.length === 0 && untracked(() => this.itinerary.stops()).length > 0) return;
if (json === this.lastSyncedItinerary && !isNewThread) return;
this.lastHydratedThreadId = tid;

Fix 3 — Interval leak backstop (google-maps-loader.ts)

setTimeout(() => clearInterval(poll), 30_000);

Prevents the poll from running forever when a Maps script tag is in-flight but the API never resolves (bad key, quota, CSP).

Fix 4 — @Injectable() decorator (itinerary-store.ts)

import { Injectable, computed, signal } from '@angular/core';
// ...
@Injectable()
export class ItineraryStore {

Without this Angular can't emit ɵfac; IDE inference breaks and any future inject(ItineraryStore) outside the explicit provider scope fails at runtime.

Fix 5 — CodeQL clear-text logging (inject-env.mjs:34)

// Before
console.log(`... (key length: ${key.length}, ...)`);
// After
console.log(`... (key: ${key ? 'set' : 'unset'}, ...)`);

Replaces the numeric key length (CodeQL data-flow path to GOOGLE_MAPS_API_KEY) with a boolean presence check.

@blove blove merged commit ecd2947 into main Jul 7, 2026
24 of 25 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants