feat(examples-chat): App mode — itinerary map cockpit on the langgraph canonical demo#772
Conversation
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>
…client tools present
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.
…on, no localStorage
…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>
…+ SDK checkpoint push
… 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.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Claude finished @blove's task in 3m 49s —— View job PR Review — App mode itinerary cockpit
SummarySolid implementation — the LangGraph checkpoint-as-source-of-truth model is well-executed and the retry-on-409 fix for mid-run Bugs
Minor / resource issues
No security issues, no public |
| } 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'); |
There was a problem hiding this comment.
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');| // 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; |
There was a problem hiding this comment.
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);
});| 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; |
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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).
|
Claude finished @blove's task in 2m 15s —— View job PR Review — App mode itinerary cockpit (follow-up)
All five fixes were committed locally. The push was blocked by a repository permission error ( Fix 1 — Wrong overlay selector (
|
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.
|
Claude finished @blove's task in 2m 24s —— View job PR Review — App mode itinerary cockpit (follow-up sync)
All five fixes are committed locally as What changedFix 1 — Wrong overlay selector ( // Before
const overlay = document.querySelector('.ag-ui-shell__itinerary-overlay');
// After
const overlay = document.querySelector('.demo-shell__itinerary-overlay');
Fix 2 — Thread switch clears the map ( Added 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 ( 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 — import { Injectable, computed, signal } from '@angular/core';
// ...
@Injectable()
export class ItineraryStore {Without this Angular can't emit Fix 5 — CodeQL clear-text logging ( // Before
console.log(`... (key length: ${key.length}, ...)`);
// After
console.log(`... (key: ${key ? 'set' : 'unset'}, ...)`);Replaces the numeric key length (CodeQL data-flow path to |
|
Claude finished @blove's task in 2m 15s —— View job PR Review — App mode itinerary cockpit (fixes applied locally)
All five fixes are committed locally as To unblock: Go to Settings → Actions → General → Workflow permissions and set it to "Read and write permissions", then re-trigger Alternatively, cherry-pick the commit manually: What's in the commitFix 1 — Wrong overlay selector ( // Before
const overlay = document.querySelector('.ag-ui-shell__itinerary-overlay');
// After
const overlay = document.querySelector('.demo-shell__itinerary-overlay');
Fix 2 — Thread switch clears the map ( Added 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 ( 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 — import { Injectable, computed, signal } from '@angular/core';
// ...
@Injectable()
export class ItineraryStore {Without this Angular can't emit Fix 5 — CodeQL clear-text logging ( // Before
console.log(`... (key length: ${key.length}, ...)`);
// After
console.log(`... (key: ${key ? 'set' : 'unset'}, ...)`);Replaces the numeric key length (CodeQL data-flow path to |
App Mode on the LangChain canonical demo — itinerary cockpit
Ports the App-mode map/itinerary cockpit from
examples/ag-uito 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) alongsidemessages— 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.add_stop/day_cardclient tools.submitinjectsstate.itinerary;DemoShellpushes the store to the checkpoint via the SDKclient.threads.updateState(...); hydration readsagent.value().itineraryon reload/thread-switch. No localStorage.chatgraph):bind_client_toolswhen the frontend sends the catalog, client-tool-aware routing (all-client-tool turns skip the server ToolNode), planner framing + current-itinerary context injection.get_itinerarydropped (the model sees the itinerary in context).Verification
examples/chate2e: App-mode cockpit (empty state + embed coercion), keyless.updateStatenever fired — the push now retries the mid-run 409 (capped) until the run settles.Non-goals (deferred)
GOOGLE_MAPS_API_KEY(+ map id) is set in its env.examples/chat).🤖 Generated with Claude Code