Skip to content

feat: persist AI analysis with Vercel Workflow DevKit#24

Merged
sergical merged 19 commits intomainfrom
sergical/workflow-durable-analysis
Feb 17, 2026
Merged

feat: persist AI analysis with Vercel Workflow DevKit#24
sergical merged 19 commits intomainfrom
sergical/workflow-durable-analysis

Conversation

@sergical
Copy link
Member

@sergical sergical commented Feb 13, 2026

Summary

  • Extract analysis orchestration into a durable workflow (workflows/analysis.ts) using DurableAgent from @workflow/ai — survives navigation, hard refreshes, and server restarts
  • Replace DefaultChatTransport with WorkflowChatTransport for automatic stream reconnection via localStorage run ID persistence
  • Convert internal <a> tags to <Link> for client-side navigation (CoreWebVitalsSection, VitalsReport, DemoHeader)
  • Fix "Analyze another" reset to clear workflow state and use router.push instead of window.location.reload()

Regression fixes

  • Upgrade web-vitals to 5.1.0-soft-navs-2 with reportSoftNavs: true so metrics re-fire on soft navigations
  • Remove onChatEnd callback that cleared the run ID — workflow now persists across soft navigation round-trips
  • Widen loading UI condition to include "submitted" status so loading facts appear immediately when starting a scan

Test plan

  • Start analysis → loading facts should appear immediately (not blank)
  • After analysis completes → click vital metric link → soft nav, web-vitals report updates
  • From vital page → click "Home" → workflow results still visible
  • "Analyze another" → clean state, ready for new scan
  • Hard refresh during streaming → should reconnect to in-flight workflow
  • Follow-up suggestions still work after initial analysis
  • npx workflow web — inspect workflow runs in the WDK dashboard
  • Verify Sentry spans/metrics still fire (domain, outcome, duration)

🤖 Generated with Claude Code

@vercel
Copy link

vercel bot commented Feb 13, 2026

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

Project Deployment Actions Updated (UTC)
webvitals-com Ready Ready Preview, Comment Feb 17, 2026 5:03pm
webvitals.com Ready Ready Preview, Comment Feb 17, 2026 5:03pm

Request Review

sergical and others added 2 commits February 17, 2026 10:30
- Extract analysis logic into durable workflow (workflows/analysis.ts)
  using DurableAgent from @workflow/ai with prepareStep + stopWhen
- Rewrite /api/chat to use workflow start(), return x-workflow-run-id header
- Add /api/chat/[id]/stream reconnection endpoint using getRun()
- Replace DefaultChatTransport with WorkflowChatTransport in HeroSection
  for automatic stream reconnection via localStorage run ID persistence
- Convert <a> to <Link> in CoreWebVitalsSection, VitalsReport, DemoHeader
  for client-side navigation that preserves analysis state
- Fix handleReset in ChatInterface and FollowUpSuggestions to clear
  workflow run ID and use router.push instead of window.location.reload
- Wrap next.config with withWorkflow() for directive compilation

Co-Authored-By: Claude <noreply@anthropic.com>
GHSA-vw5p-8cq8-m7mv and GHSA-g2pg-6438-jwpf — memory/CPU
exhaustion in devalue.parse, patched in 5.6.2.

Co-Authored-By: Claude <noreply@anthropic.com>
sergical and others added 8 commits February 17, 2026 10:30
…way-and-back

Move localStorage run ID cleanup from onFinish (which fires even after
unmount via zombie fetch) to a mounted useEffect that only triggers when
status transitions to "ready". Abort client-side stream on unmount so no
background callbacks mutate stale state.

Co-Authored-By: Claude <noreply@anthropic.com>
stop() returns a promise that rejects with AbortError when killing
the fetch — need .catch() not try/catch in a sync cleanup.

Co-Authored-By: Claude <noreply@anthropic.com>
The stop() call throws AbortError through SDK internals that can't be
cleanly caught. It's also unnecessary: the only reason to abort on
unmount was to prevent onFinish from clearing localStorage, which we
already moved to a mounted useEffect. A zombie stream completing in
the background now only triggers Sentry logging — harmless.

Co-Authored-By: Claude <noreply@anthropic.com>
- Share web-vitals subscriptions via Zustand store (useVitalsStore)
  so LiveWebVitals and VitalsReport read from a single source
- Restore document.visibilityState after triggerVisibilityChange so
  the web-vitals library doesn't think the page is backgrounded on
  subsequent soft navigations
- Use try/catch for defineProperty to survive HMR reloads
- Force hard navigation for FCP and TTFB demos (same as TTFB was
  already doing) since these metrics require a fresh page load

Co-Authored-By: Claude <noreply@anthropic.com>
The per-component isInitialMount ref didn't survive React Strict Mode
(double-fired effects consumed the ref, then second fire wiped metrics).
Soft nav between pages also left stale values in the shared Zustand store.

Move reset logic to a single VitalsNavigationReset component in root
layout that compares pathname via useRef — Strict Mode safe because
both fires see the same pathname and skip. On actual soft nav, pathname
differs → reset fires once.

Co-Authored-By: Claude <noreply@anthropic.com>
Reset useLoadState to loading:true alongside vitals store reset so demo
pages (FCP 2s delay, CLS block additions, etc.) start from a clean state
after soft navigation instead of rendering instantly.

Co-Authored-By: Claude <noreply@anthropic.com>
The FCP demo's client-side setTimeout returning null didn't actually
affect the browser's FCP metric — the server-rendered shell painted
immediately. The TTFB demo defined a delay constant but never used it.

Move the 2s delay to the server components so HTML delivery is genuinely
delayed, which is what both metrics actually measure.

Co-Authored-By: Claude <noreply@anthropic.com>
Server component `await` doesn't delay TTFB (Next.js streams layout
immediately) and executes twice per request (~4s instead of ~2s).
Move the 2s delay to proxy.ts so the entire HTTP response is delayed,
correctly affecting both TTFB and FCP.

Homepage FCP/TTFB cards now use `<a>` instead of `<Link>` so
navigation creates real timing entries for metrics to register.

Co-Authored-By: Claude <noreply@anthropic.com>
Named `proxy` export works locally but Vercel's build adapter may not
pick it up reliably (vercel/next.js#85711). Default export is the
canonical pattern in Vercel's routing middleware docs.

Co-Authored-By: Claude <noreply@anthropic.com>
Default export breaks edge function deployment. The proxy.ts convention
requires a named `proxy` export.

Co-Authored-By: Claude <noreply@anthropic.com>
proxy.ts delay works locally but Vercel's build pipeline has bugs with
the new convention (vercel/next.js#85711, #86122). middleware.ts produces
the identical edge function bundle and is battle-tested on Vercel.

Co-Authored-By: Claude <noreply@anthropic.com>
Adds X-Demo-Delay response header to verify whether the proxy function
actually executes on Vercel preview deployments.

Co-Authored-By: Claude <noreply@anthropic.com>
Soft-nav TTFB/FCP values were overwriting the real hard-navigation
values in the Zustand store, causing the demo to show ~13ms instead
of the actual ~2000ms delay.

Co-Authored-By: Claude <noreply@anthropic.com>
Comment on lines +47 to +48
try { localStorage.removeItem("webvitals-run-id"); } catch {}
window.location.href = "/";
Copy link

Choose a reason for hiding this comment

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

Bug: In Safari private mode, localStorage.removeItem() fails silently. The subsequent hard navigation with window.location.href causes the old run ID to persist, resuming the previous workflow.
Severity: HIGH

Suggested Fix

Replace the hard navigation window.location.href = "/" with a soft navigation, for example using Next.js's router.push("/"). This would allow the in-memory state to be reset correctly without relying on localStorage clearing, which is unreliable in private browsing modes. Alternatively, implement a more robust reset mechanism that doesn't depend on localStorage.removeItem() succeeding before navigation.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: components/ChatInterface.tsx#L47-L48

Potential issue: When a user in Safari's private browsing mode clicks "Analyze another",
the `handleReset` function attempts to clear the session by calling
`localStorage.removeItem("webvitals-run-id")`. This operation fails silently in Safari's
private mode. The code then performs a hard page navigation via `window.location.href =
"/"`. Upon reload, the application reads the stale `activeRunId` from `localStorage`,
which was never removed, and incorrectly calls `resumeStream()` to continue the previous
analysis workflow instead of initiating a new one. This prevents affected users from
starting a new analysis.

Copy link
Member Author

Choose a reason for hiding this comment

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

Not a real issue. Safari's private browsing mode has supported localStorage since Safari 11 (2017). The old behavior where localStorage.setItem() threw in private mode was removed years ago. In modern Safari private browsing, localStorage
works normally — it's just scoped to the session and cleared when the window closes.

The try/catch is already defensive enough for any truly exotic edge case. The removeItem call won't fail silently — it'll work fine or get caught.

@sergical sergical merged commit bc7c1d7 into main Feb 17, 2026
7 of 8 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.

1 participant