Skip to content

Add authenticated hosted saved UI route - #50

Merged
kentcdodds merged 9 commits into
mainfrom
cursor/ui-display-url-5b4a
Mar 25, 2026
Merged

kentcdodds merged 9 commits into
mainfrom
cursor/ui-display-url-5b4a

Conversation

@kentcdodds

@kentcdodds kentcdodds commented Mar 25, 2026 •

Copy link
Copy Markdown
Owner

Summary

  • add an authenticated /ui/:id browser route plus owner-scoped saved-ui data/execute endpoints
  • allow hosted saved UI pages to render artifacts saved from either browser-session or OAuth/MCP ownership ids
  • include the hosted fallback URL in saved UI capability output and open_generated_ui messaging
  • clarify that tool calls do not work from the hosted fallback page
  • fix hosted shell timing so nested iframe content renders reliably and the focused e2e route spec passes

Testing

  • bun run typecheck
  • bun test ./packages/worker/src/user-id.test.ts ./packages/worker/src/app/handlers/session-handler.test.ts ./packages/worker/client/mcp-apps/generated-ui-shell.test.ts
  • bun run test:mcp
  • bun run test:e2e e2e/saved-ui.spec.ts
  • manual browser walkthrough of /ui/manual-demo-saved-ui
Open in Web Open in Cursor 

Summary by CodeRabbit

  • New Features

    • Hosted Saved UI pages (/ui/{id}) with iframe preview, data API (/ui-api/{id}) and execute endpoint (/ui-api/{id}/execute); hosted fallback URLs surfaced in tool outputs.
    • App layout widened for /ui routes for full-width rendering.
  • Authentication

    • Artifact ownership now recognizes stable email-derived IDs; owners can view/run saved UIs; unauthenticated users are redirected to login with return URL.
  • Tests

    • Added E2E and unit tests for Saved UI flows and stable user-id behavior.

Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
@coderabbitai

coderabbitai Bot commented Mar 25, 2026 •

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Adds a Saved UI feature: client route and iframe bridge, server page/data/execute handlers, DB lookup by multiple owner IDs, stable email→user-id utility, hosted-URL helpers, MCP/tool updates to surface hosted URLs, and accompanying tests and E2E spec.

Changes

Cohort / File(s) Summary
E2E Tests
e2e/saved-ui.spec.ts
New Playwright spec that seeds a saved UI artifact into the local DB and verifies authenticated rendering and unauthenticated redirect-to-login behavior.
Client Route & Layout
packages/worker/client/routes/saved-ui.tsx, packages/worker/client/routes/index.tsx, packages/worker/client/app.tsx
Added SavedUiRoute (iframe-based JSON‑RPC bridge, artifact fetch, render/execution tooling) and registered /ui/:id; switched layout gating from isChatLayout to isWideLayout to include /ui/ paths.
Server Handlers & Routes
packages/worker/src/app/handlers/saved-ui.ts, packages/worker/src/app/routes.ts, packages/worker/src/app/router.ts
New handler factories for page, data, and execute endpoints; added /ui/:id, /ui-api/:id, and POST /ui-api/:id/execute; shared auth + artifact lookup by owner IDs.
Authentication & User ID
packages/worker/src/app/authenticated-user.ts, packages/worker/src/user-id.ts, packages/worker/src/user-id.test.ts, packages/worker/src/oauth-handlers.ts
Extended AuthenticatedAppUser with artifactOwnerIds; added createStableUserIdFromEmail and test; replaced local OAuth user-id generation with the stable-id utility.
Database & Repository
packages/worker/src/mcp/ui-artifacts-repo.ts
Added getUiArtifactByOwnerIds to query ui_artifacts using parameterized SQL with multiple owner IDs.
URL Utilities & MCP/Tools
packages/worker/src/ui-artifact-urls.ts, packages/worker/src/mcp/capabilities/apps/ui-save-app.ts, packages/worker/src/mcp/tools/open-generated-ui.ts, packages/worker/src/chat-agent.ts
New getHostedUiUrl/buildSavedUiUrl helpers; MCP capability/tool outputs extended to include hosted URLs; chat-agent mock outputs include hosted fallback URL.
MCP E2E Tests & Client Bridge
packages/worker/src/mcp/mcp-server-e2e.test.ts, packages/worker/client/mcp-apps/generated-ui-shell.ts, packages/worker/client/mcp-apps/widget-host-bridge.ts
E2E assertions updated to expect hosted URL in structured outputs/text; generated-ui shell adds execution timeout handling; widget host bridge accepts per-call timeoutMs.

Sequence Diagram

sequenceDiagram
    participant User
    participant Browser
    participant ClientRoute as Client\nSavedUiRoute
    participant Server as Server\nSavedUi Handlers
    participant DB as Database\nui_artifacts
    participant Iframe as Iframe\ngenerated-ui

    User->>Browser: Navigate to /ui/{appId}
    Browser->>ClientRoute: Mount SavedUiRoute
    ClientRoute->>Server: GET /ui-api/{appId} (credentials included)
    Server->>DB: Query artifact by ownerIds + appId
    DB-->>Server: Return artifact row
    Server-->>ClientRoute: Return artifact metadata & source
    ClientRoute->>Browser: Render shell + `<iframe data-saved-ui-shell>`
    Browser->>Iframe: Load /dev/generated-ui
    Iframe->>ClientRoute: postMessage (initialize/ready)
    ClientRoute->>Iframe: postMessage (ui-lifecycle-iframe-render-data)
    Iframe->>ClientRoute: postMessage (tools/call: execute { code })
    ClientRoute->>Server: POST /ui-api/{appId}/execute { code }
    Server->>Server: Validate + runCodemodeWithRegistry
    Server-->>ClientRoute: Execution result + logs
    ClientRoute->>Iframe: postMessage (result)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Poem

🐰 I seeded a UI in a snug little lair,
I framed tiny iframes and posted with care,
Stable IDs hop, hosted links gleam,
Messages bounce in a JSON‑RPC stream,
Run the code — watch the burrow light up fair!

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Add authenticated hosted saved UI route' accurately reflects the main change: introducing a new authenticated /ui/:id route and associated handlers for hosting saved UI artifacts.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch cursor/ui-display-url-5b4a

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@kentcdodds
kentcdodds marked this pull request as ready for review March 25, 2026 17:45
@github-actions

github-actions Bot commented Mar 25, 2026 •

Copy link
Copy Markdown
Contributor

🔎 Preview deployed: https://kody-pr-50.kentcdodds.workers.dev

Worker: kody-pr-50
D1: kody-pr-50-db
KV: kody-pr-50-oauth-kv

Mocks:

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@e2e/saved-ui.spec.ts`:
- Around line 3-6: Remove the unused Shell import and replace the CommonJS
require call with an ES module import: import { execFile } from
'node:child_process' and then promisify that execFile using promisify from
'node:util' (e.g., const execFileAsync = promisify(execFile)); update usages of
execFile to use the new promisified name; ensure there are no other require(...)
calls in this module so it is ESM-compatible and the unused Shell symbol is
removed.

In `@packages/worker/client/routes/saved-ui.tsx`:
- Around line 105-129: The postMessage calls in postRenderDataIfReady (and the
same handler's other postMessage invocations) currently use '*' which allows any
origin to receive/authenticate messages; replace the wildcard targetOrigin with
the savedUiShellOrigin variable when calling latestShellWindow.postMessage so
messages are only delivered to the trusted shell origin, and apply the same
savedUiShellOrigin replacement to every other postMessage in this handler (e.g.,
the calls that send renderDataType, ui_load_app_source, execute responses) to
ensure all bridge messages use the pinned origin.
- Around line 22-29: getSavedUiIdFromLocation can throw when decodeURIComponent
encounters malformed % escapes; update getSavedUiIdFromLocation to wrap the
decodeURIComponent(trimmed) call in a try/catch and return null on any decode
error so callers like refreshArtifact receive null instead of letting an
exception escape; ensure the catch is local to decoding and does not rethrow so
the existing "Saved UI not found" flow remains intact.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 19495217-5c6c-4e1a-be7d-1d6f879ae243

📥 Commits

Reviewing files that changed from the base of the PR and between 3c3bfeb and 285157a.

📒 Files selected for processing (17)
  • e2e/saved-ui.spec.ts
  • packages/worker/client/app.tsx
  • packages/worker/client/routes/index.tsx
  • packages/worker/client/routes/saved-ui.tsx
  • packages/worker/src/app/authenticated-user.ts
  • packages/worker/src/app/handlers/saved-ui.ts
  • packages/worker/src/app/router.ts
  • packages/worker/src/app/routes.ts
  • packages/worker/src/chat-agent.ts
  • packages/worker/src/mcp/capabilities/apps/ui-save-app.ts
  • packages/worker/src/mcp/mcp-server-e2e.test.ts
  • packages/worker/src/mcp/tools/open-generated-ui.ts
  • packages/worker/src/mcp/ui-artifacts-repo.ts
  • packages/worker/src/oauth-handlers.ts
  • packages/worker/src/ui-artifact-urls.ts
  • packages/worker/src/user-id.test.ts
  • packages/worker/src/user-id.ts

Comment thread e2e/saved-ui.spec.ts Outdated
Comment thread packages/worker/client/routes/saved-ui.tsx Outdated
Comment thread packages/worker/client/routes/saved-ui.tsx
Comment thread packages/worker/client/routes/saved-ui.tsx Outdated
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>

@cursor cursor 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.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Saved UI doesn’t refresh on SPA navigation
    • Added a router navigation listener to re-queue saved UI fetches when the /ui/:id changes, preventing stale artifacts.

Comment thread packages/worker/client/routes/saved-ui.tsx
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
@kentcdodds

Copy link
Copy Markdown
Owner Author

@cursoragent could you please address valid feedback on this PR and check out the failing CI E2E tests and fix those? Thanks!

@cursor

This comment has been minimized.

cursoragent and others added 2 commits March 25, 2026 18:27
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
- Post tools/call execute responses via async IIFE instead of handle.queueTask
  so JSON-RPC replies are not delayed past the widget bridge default timeout.
- Raise generated-ui-shell bridge requestTimeoutMs to 90s to match codemode.
- E2E: assert on the inner [data-generated-ui-frame] document; the shell loads
  the saved app in a nested iframe. Use unambiguous page heading and static
  fixture body text for the hosted document.

Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

♻️ Duplicate comments (1)
packages/worker/client/routes/saved-ui.tsx (1)

103-127: ⚠️ Potential issue | 🔴 Critical

Pin the iframe bridge to the shell origin.

event.source === frameElement.contentWindow is not a trust boundary once the iframe navigates. A page that takes over that browsing context can still issue tools/call / ui_load_app_source, and every reply/render-data post here uses '*', so the authenticated responses go back to that page. Require event.origin to match the expected /dev/generated-ui origin and use that origin for every postMessage in this bridge.

Also applies to: 181-305

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/worker/client/routes/saved-ui.tsx` around lines 103 - 127, The
bridge currently posts messages with '*' and trusts event.source alone; update
it to validate and pin the shell origin and use that origin for postMessage
calls: derive or define an expected shell origin (e.g., compute new
URL(generatedUiResourceUri).origin or a constant expectedShellOrigin), ensure
the message handler only accepts events where event.origin ===
expectedShellOrigin (in addition to event.source === frameElement.contentWindow)
and store that origin (e.g., latestShellOrigin), then replace all
latestShellWindow.postMessage(..., '*') calls (including inside
postRenderDataIfReady and the other postMessage sites between lines ~181-305) to
use latestShellOrigin instead of '*'. Ensure you update any initialization logic
that sets latestShellWindow to also set latestShellOrigin after origin
validation.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/worker/client/mcp-apps/generated-ui-shell.ts`:
- Around line 749-750: The bridge-wide requestTimeoutMs in
createWidgetHostBridge is set to 90_000 which makes all bridge requests (not
just long-running tools) silently return null after 90s; update the
implementation so the long timeout is scoped to the execute path or make timeout
failures explicit: either remove or shorten requestTimeoutMs in
createWidgetHostBridge and apply a 90_000 timeout only around the execute/tool
call, or modify callTool() in widget-host-bridge.ts to return a descriptive
timeout error (not null) so the execute-code path does not translate a timeout
into { result: null, errorMessage: null }; locate createWidgetHostBridge,
requestTimeoutMs, callTool(), and the execute/code handling to implement one of
these fixes.

In `@packages/worker/client/routes/saved-ui.tsx`:
- Around line 96-97: The bug is that execute remains bound to the previously
loaded artifact because refreshArtifact flips activeAppId before loadSavedUi
completes; fix by pinning execute and UI state to the specific
loadRequestId/loadingAppId for each load cycle: in
refreshArtifact()/loadSavedUi() capture a local const requestId =
++loadRequestId and set loadingAppId = artifact?.appId only after the fetch
resolves, and before awaiting the new artifact clear or hide the old shell and
reject incoming execute calls unless requestId === loadRequestId and
artifact?.appId && status === 'ready'; update the execute handler to check the
pinned requestId/loadingAppId (not global activeAppId) before posting to
`/ui-api/{appId}/execute` so stale iframes cannot send commands to the newly
active app.

---

Duplicate comments:
In `@packages/worker/client/routes/saved-ui.tsx`:
- Around line 103-127: The bridge currently posts messages with '*' and trusts
event.source alone; update it to validate and pin the shell origin and use that
origin for postMessage calls: derive or define an expected shell origin (e.g.,
compute new URL(generatedUiResourceUri).origin or a constant
expectedShellOrigin), ensure the message handler only accepts events where
event.origin === expectedShellOrigin (in addition to event.source ===
frameElement.contentWindow) and store that origin (e.g., latestShellOrigin),
then replace all latestShellWindow.postMessage(..., '*') calls (including inside
postRenderDataIfReady and the other postMessage sites between lines ~181-305) to
use latestShellOrigin instead of '*'. Ensure you update any initialization logic
that sets latestShellWindow to also set latestShellOrigin after origin
validation.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 89938139-c9de-4323-976c-2c1737e52b5a

📥 Commits

Reviewing files that changed from the base of the PR and between 2aaefc8 and c42bf23.

📒 Files selected for processing (3)
  • e2e/saved-ui.spec.ts
  • packages/worker/client/mcp-apps/generated-ui-shell.ts
  • packages/worker/client/routes/saved-ui.tsx
🚧 Files skipped from review as they are similar to previous changes (1)
  • e2e/saved-ui.spec.ts

Comment thread packages/worker/client/mcp-apps/generated-ui-shell.ts Outdated
Comment thread packages/worker/client/routes/saved-ui.tsx Outdated

@cursor cursor 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.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Loading state not rendered on UI switch
    • Added an update call before awaiting the load so the loading state renders immediately on navigation.

Comment thread packages/worker/client/routes/saved-ui.tsx
cursoragent and others added 3 commits March 25, 2026 18:48
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
…dodds/kody into cursor/ui-display-url-5b4a

Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
Comment thread packages/worker/client/routes/saved-ui.tsx
@kentcdodds

Copy link
Copy Markdown
Owner Author

@cursoragent, please address the valid feedback in this PR.

@cursor

cursor Bot commented Mar 25, 2026 •

Copy link
Copy Markdown
Contributor

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Loading state not rendered on UI switch
    • Added an update call before awaiting the load so the loading state renders immediately on navigation.

Create PR

Preview (95bac39fc4)
diff --git a/e2e/saved-ui.spec.ts b/e2e/saved-ui.spec.ts
new file mode 100644
--- /dev/null
+++ b/e2e/saved-ui.spec.ts
@@ -1,0 +1,118 @@
+import { expect, test } from './playwright-utils.ts'
+import { createStableUserIdFromEmail } from '#worker/user-id.ts'
+import { execFile as execFileCb } from 'node:child_process'
+import { promisify } from 'node:util'
+
+const execFile = promisify(execFileCb)
+
+test.describe.configure({ mode: 'serial' })
+
+async function seedSavedUi(options: {
+	appId: string
+	userId: string
+	title: string
+	description: string
+	code: string
+}) {
+	const sql = [
+		'INSERT OR REPLACE INTO ui_artifacts (',
+		'id, user_id, title, description, keywords, source_type, source_code, search_text, created_at, updated_at',
+		') VALUES (',
+		`'${options.appId}',`,
+		`'${options.userId}',`,
+		`'${options.title.replaceAll("'", "''")}',`,
+		`'${options.description.replaceAll("'", "''")}',`,
+		`'["saved-ui"]',`,
+		"'html',",
+		`'${options.code.replaceAll("'", "''")}',`,
+		"'saved ui route e2e',",
+		'CURRENT_TIMESTAMP,',
+		'CURRENT_TIMESTAMP',
+		');',
+	].join(' ')
+
+	let lastError: unknown = null
+	for (const delayMs of [0, 150, 300, 600]) {
+		if (delayMs > 0) {
+			await new Promise((resolve) => setTimeout(resolve, delayMs))
+		}
+		try {
+			await execFile(
+				'bun',
+				[
+					'--no-env-file',
+					'--env-file=packages/worker/.env',
+					'./wrangler-env.ts',
+					'd1',
+					'execute',
+					'APP_DB',
+					'--local',
+					'--persist-to',
+					'.wrangler/state/e2e',
+					'--command',
+					sql,
+				],
+				{
+					cwd: process.cwd(),
+				},
+			)
+			return
+		} catch (error) {
+			lastError = error
+			const message = error instanceof Error ? error.message : String(error)
+			if (!message.includes('database is locked')) {
+				throw error
+			}
+		}
+	}
+	throw lastError instanceof Error
+		? lastError
+		: new Error('Unable to seed saved UI into the e2e database.')
+}
+
+test('authenticated user can open their hosted saved ui route', async ({
+	page,
+	login,
+}) => {
+	const appId = `saved-ui-${Date.now()}`
+	await seedSavedUi({
+		appId,
+		userId: '1',
+		title: 'Hosted saved ui',
+		description: 'Saved ui route test artifact',
+		code: '<main><h1>Hosted saved ui</h1><p>Saved UI route e2e body</p></main>',
+	})
+	await login()
+
+	await page.goto(`/ui/${appId}`)
+
+	await expect(
+		page.getByRole('heading', { name: 'Hosted saved ui' }),
+	).toBeVisible()
+	// Shell page is one iframe; the saved app document is nested inside
+	// `[data-generated-ui-frame]` (see generated-ui-shell entry HTML).
+	const shellFrame = page.frameLocator('[data-saved-ui-shell]')
+	const appFrame = shellFrame.frameLocator('[data-generated-ui-frame]')
+	await expect(
+		appFrame.getByRole('heading', { name: 'Hosted saved ui' }),
+	).toBeVisible()
+	await expect(appFrame.getByText('Saved UI route e2e body')).toBeVisible()
+})
+
+test('saved ui route redirects unauthenticated users to login', async ({
+	page,
+}) => {
+	const appId = `saved-ui-redirect-${Date.now()}`
+	const emailOwnerId = await createStableUserIdFromEmail('me@kentcdodds.com')
+	await seedSavedUi({
+		appId,
+		userId: emailOwnerId,
+		title: 'Redirect saved ui',
+		description: 'Saved ui auth redirect test',
+		code: '<main><h1>Redirect saved ui</h1></main>',
+	})
+
+	await page.context().clearCookies()
+	await page.goto(`/ui/${appId}`)
+	await expect(page).toHaveURL(/\/login\?redirectTo=%2Fui%2F/)
+})

diff --git a/packages/worker/client/app.tsx b/packages/worker/client/app.tsx
--- a/packages/worker/client/app.tsx
+++ b/packages/worker/client/app.tsx
@@ -88,7 +88,8 @@
 	}
 
 	return () => {
-		const isChatLayout = currentPathname.startsWith('/chat')
+		const isWideLayout =
+			currentPathname.startsWith('/chat') || currentPathname.startsWith('/ui/')
 		const sessionEmail = session?.email ?? ''
 		const isSessionReady = sessionStatus === 'ready'
 		const isLoggedIn = isSessionReady && Boolean(sessionEmail)
@@ -103,23 +104,23 @@
 		return (
 			<main
 				css={{
-					maxWidth: isChatLayout ? 'none' : '52rem',
+					maxWidth: isWideLayout ? 'none' : '52rem',
 					width: '100%',
-					margin: isChatLayout ? 0 : '0 auto',
-					padding: isChatLayout
+					margin: isWideLayout ? 0 : '0 auto',
+					padding: isWideLayout
 						? `${spacing.lg} ${spacing.xl} ${spacing.sm}`
 						: spacing['2xl'],
-					minHeight: isChatLayout ? '100vh' : undefined,
+					minHeight: isWideLayout ? '100vh' : undefined,
 					fontFamily: typography.fontFamily,
 					boxSizing: 'border-box',
-					[mq.tablet]: isChatLayout
+					[mq.tablet]: isWideLayout
 						? {
 								padding: `${spacing.sm} ${spacing.sm} 0`,
 							}
 						: {
 								padding: spacing.xl,
 							},
-					[mq.mobile]: isChatLayout
+					[mq.mobile]: isWideLayout
 						? {
 								padding: `${spacing.md} ${spacing.md} ${spacing.sm}`,
 							}
@@ -134,8 +135,8 @@
 						alignItems: 'center',
 						gap: spacing.md,
 						flexWrap: 'wrap',
-						marginBottom: isChatLayout ? spacing.lg : spacing.xl,
-						[mq.tablet]: isChatLayout
+						marginBottom: isWideLayout ? spacing.lg : spacing.xl,
+						[mq.tablet]: isWideLayout
 							? {
 									gap: spacing.sm,
 									marginBottom: spacing.sm,
@@ -143,7 +144,7 @@
 							: {},
 						[mq.mobile]: {
 							gap: spacing.sm,
-							marginBottom: isChatLayout ? spacing.md : spacing.lg,
+							marginBottom: isWideLayout ? spacing.md : spacing.lg,
 						},
 					}}
 				>

diff --git a/packages/worker/client/mcp-apps/generated-ui-shell.ts b/packages/worker/client/mcp-apps/generated-ui-shell.ts
--- a/packages/worker/client/mcp-apps/generated-ui-shell.ts
+++ b/packages/worker/client/mcp-apps/generated-ui-shell.ts
@@ -675,6 +675,7 @@
 		const result = (await hostBridge.callTool({
 			name: 'ui_load_app_source',
 			arguments: { app_id: appId },
+			timeoutMs: 15_000,
 		})) as HostToolResult | null
 		if (!result || result.isError) {
 			throw new Error('Failed to load saved app source.')
@@ -801,17 +802,36 @@
 					typeof payload.requestId === 'string' ? payload.requestId : null
 				const code = typeof payload.code === 'string' ? payload.code : null
 				if (requestId && code && event.source) {
+					let didTimeout = false
+					const targetWindow = event.source as WindowProxy
+					const timeoutId = window.setTimeout(() => {
+						didTimeout = true
+						targetWindow.postMessage(
+							{
+								type: `${childMessagePrefix}execute-result`,
+								payload: {
+									requestId,
+									result: null,
+									errorMessage: 'Code execution timed out.',
+								},
+							},
+							'*',
+						)
+					}, 90_000)
 					void hostBridge
 						.callTool({
 							name: 'execute',
 							arguments: { code },
+							timeoutMs: 90_000,
 						})
 						.then((result) => {
+							if (didTimeout) return
+							window.clearTimeout(timeoutId)
 							const errorMessage = getHostToolErrorMessage(result)
 							const structuredContent = isRecord(result?.structuredContent)
 								? result.structuredContent
 								: null
-							;(event.source as WindowProxy).postMessage(
+							targetWindow.postMessage(
 								{
 									type: `${childMessagePrefix}execute-result`,
 									payload: {
@@ -826,6 +846,21 @@
 								'*',
 							)
 						})
+						.catch(() => {
+							if (didTimeout) return
+							window.clearTimeout(timeoutId)
+							targetWindow.postMessage(
+								{
+									type: `${childMessagePrefix}execute-result`,
+									payload: {
+										requestId,
+										result: null,
+										errorMessage: 'Code execution failed.',
+									},
+								},
+								'*',
+							)
+						})
 				}
 				return
 			}

diff --git a/packages/worker/client/mcp-apps/widget-host-bridge.ts b/packages/worker/client/mcp-apps/widget-host-bridge.ts
--- a/packages/worker/client/mcp-apps/widget-host-bridge.ts
+++ b/packages/worker/client/mcp-apps/widget-host-bridge.ts
@@ -29,6 +29,7 @@
 	callTool(input: {
 		name: string
 		arguments?: Record<string, unknown>
+		timeoutMs?: number
 	}): Promise<ServerToolResult | null>
 	openLink(url: string): Promise<boolean>
 	requestDisplayMode(mode: DisplayMode): Promise<DisplayMode | null>
@@ -205,6 +206,7 @@
 	function sendBridgeRequest(
 		method: string,
 		params: Record<string, unknown>,
+		timeoutMs = requestTimeoutMs,
 	): Promise<BridgeResponseMessage> {
 		return new Promise<BridgeResponseMessage>((resolve, reject) => {
 			requestCounter += 1
@@ -212,7 +214,7 @@
 			const timeoutId = globalThis.setTimeout(() => {
 				pendingRequests.delete(requestId)
 				reject(new Error('Bridge request timed out'))
-			}, requestTimeoutMs)
+			}, timeoutMs)
 
 			pendingRequests.set(requestId, {
 				resolve,
@@ -305,6 +307,7 @@
 	async function callTool(input: {
 		name: string
 		arguments?: Record<string, unknown>
+		timeoutMs?: number
 	}) {
 		const bridgeReady = await initialize()
 		if (!bridgeReady) return null
@@ -313,7 +316,7 @@
 			const response = await sendBridgeRequest('tools/call', {
 				name: input.name,
 				...(input.arguments ? { arguments: input.arguments } : {}),
-			})
+			}, input.timeoutMs)
 			const result = response.result
 			if (!result) return null
 			return {

diff --git a/packages/worker/client/routes/index.tsx b/packages/worker/client/routes/index.tsx
--- a/packages/worker/client/routes/index.tsx
+++ b/packages/worker/client/routes/index.tsx
@@ -5,11 +5,13 @@
 import { OAuthAuthorizeRoute } from './oauth-authorize.tsx'
 import { OAuthCallbackRoute } from './oauth-callback.tsx'
 import { ResetPasswordRoute } from './reset-password.tsx'
+import { SavedUiRoute } from './saved-ui.tsx'
 
 export const clientRoutes = {
 	'/': <HomeRoute />,
 	'/chat': <ChatRoute />,
 	'/chat/:threadId': <ChatRoute />,
+	'/ui/:id': <SavedUiRoute />,
 	'/account': <AccountRoute />,
 	'/login': <LoginRoute />,
 	'/signup': <LoginRoute />,

diff --git a/packages/worker/client/routes/saved-ui.tsx b/packages/worker/client/routes/saved-ui.tsx
new file mode 100644
--- /dev/null
+++ b/packages/worker/client/routes/saved-ui.tsx
@@ -1,0 +1,460 @@
+import { type Handle } from 'remix/component'
+import { listenToRouterNavigation } from '#client/client-router.tsx'
+import { colors, mq, spacing, typography } from '#client/styles/tokens.ts'
+
+type SavedUiArtifact = {
+	appId: string
+	title: string
+	description: string
+	keywords: Array<string>
+	runtime: 'html' | 'javascript'
+	code: string
+	createdAt: string
+	updatedAt: string
+}
+
+type SavedUiStatus = 'loading' | 'ready' | 'error'
+
+const latestProtocolVersion = '2026-01-26'
+const initializedNotificationMethod = 'ui/notifications/initialized'
+const renderDataType = 'ui-lifecycle-iframe-render-data'
+const generatedUiResourceUri = 'ui://generated-ui-shell/entry-point.html'
+
+function getSavedUiIdFromLocation() {
+	if (typeof window === 'undefined') return null
+	const prefix = '/ui/'
+	if (!window.location.pathname.startsWith(prefix)) return null
+	const path = window.location.pathname.slice(prefix.length)
+	const [id] = path.split('/')
+	const trimmed = id?.trim()
+	if (!trimmed) return null
+	try {
+		return decodeURIComponent(trimmed)
+	} catch {
+		return null
+	}
+}
+
+function getSavedUiApiPath(appId: string) {
+	return `/ui-api/${encodeURIComponent(appId)}`
+}
+
+async function loadSavedUi(appId: string) {
+	const response = await fetch(getSavedUiApiPath(appId), {
+		credentials: 'include',
+		headers: { Accept: 'application/json' },
+	})
+	const payload = (await response.json().catch(() => null)) as {
+		ok?: boolean
+		error?: string
+		artifact?: SavedUiArtifact
+	} | null
+	if (!response.ok || !payload?.ok || !payload.artifact) {
+		throw new Error(payload?.error || 'Unable to load saved UI.')
+	}
+	return payload.artifact
+}
+
+async function executeSavedUiCode(appId: string, code: string) {
+	const response = await fetch(`${getSavedUiApiPath(appId)}/execute`, {
+		method: 'POST',
+		credentials: 'include',
+		headers: {
+			'Content-Type': 'application/json',
+			Accept: 'application/json',
+		},
+		body: JSON.stringify({ code }),
+	})
+	const payload = (await response.json().catch(() => null)) as {
+		ok?: boolean
+		error?: string
+		result?: unknown
+	} | null
+	if (!response.ok || !payload?.ok) {
+		throw new Error(payload?.error || 'Code execution failed.')
+	}
+	return payload.result ?? null
+}
+
+function getTextContent(content: unknown) {
+	if (!Array.isArray(content)) return null
+	const entry = content[0]
+	if (!entry || typeof entry !== 'object') return null
+	return (entry as { type?: unknown }).type === 'text' &&
+		typeof (entry as { text?: unknown }).text === 'string'
+		? (entry as { text: string }).text
+		: null
+}
+
+export function SavedUiRoute(handle: Handle) {
+	const savedUiShellOrigin =
+		typeof window === 'undefined'
+			? null
+			: new URL('/dev/generated-ui', window.location.href).origin
+	let status: SavedUiStatus = 'loading'
+	let errorMessage: string | null = null
+	let artifact: SavedUiArtifact | null = null
+	let shellInitialized = false
+	let latestShellWindow: Window | null = null
+	let activeAppId: string | null = null
+	let loadRequestId = 0
+
+	function update() {
+		handle.update()
+	}
+
+	function postRenderDataIfReady() {
+		if (
+			!shellInitialized ||
+			!latestShellWindow ||
+			!artifact ||
+			!savedUiShellOrigin
+		) {
+			return
+		}
+		latestShellWindow.postMessage(
+			{
+				type: renderDataType,
+				payload: {
+					renderData: {
+						theme: 'light',
+						displayMode: 'inline',
+						availableDisplayModes: ['inline'],
+						toolOutput: {
+							widget: 'generated_ui',
+							resourceUri: generatedUiResourceUri,
+							renderSource: 'inline_code',
+							appId: artifact.appId,
+							title: artifact.title,
+							description: artifact.description,
+							runtime: artifact.runtime,
+							sourceCode: artifact.code,
+						},
+					},
+				},
+			},
+			savedUiShellOrigin,
+		)
+	}
+
+	async function refreshArtifact() {
+		const appId = getSavedUiIdFromLocation()
+		if (!appId) {
+			status = 'error'
+			errorMessage = 'Saved UI not found.'
+			artifact = null
+			update()
+			return
+		}
+		activeAppId = appId
+		status = 'loading'
+		errorMessage = null
+		artifact = null
+		shellInitialized = false
+		latestShellWindow = null
+		update()
+		const requestId = ++loadRequestId
+		try {
+			const nextArtifact = await loadSavedUi(appId)
+			if (requestId !== loadRequestId || getSavedUiIdFromLocation() !== appId) {
+				return
+			}
+			artifact = nextArtifact
+			status = 'ready'
+			errorMessage = null
+			update()
+			postRenderDataIfReady()
+		} catch (error) {
+			if (requestId !== loadRequestId || getSavedUiIdFromLocation() !== appId) {
+				return
+			}
+			status = 'error'
+			errorMessage =
+				error instanceof Error ? error.message : 'Unable to load saved UI.'
+			artifact = null
+			shellInitialized = false
+			latestShellWindow = null
+			update()
+		}
+	}
+
+	handle.queueTask(refreshArtifact)
+
+	listenToRouterNavigation(handle, () => {
+		const nextAppId = getSavedUiIdFromLocation()
+		if (!nextAppId) {
+			activeAppId = null
+			return
+		}
+		if (nextAppId === activeAppId) return
+		handle.queueTask(refreshArtifact)
+	})
+
+	handle.on(window, {
+		message: (event: MessageEvent) => {
+			const frameElement = document.querySelector<HTMLIFrameElement>(
+				'[data-saved-ui-shell]',
+			)
+			if (
+				!frameElement ||
+				!savedUiShellOrigin ||
+				event.source !== frameElement.contentWindow ||
+				event.origin !== savedUiShellOrigin
+			) {
+				return
+			}
+			latestShellWindow = frameElement.contentWindow
+			const message = event.data
+			if (!message || typeof message !== 'object') return
+
+			if ((message as { type?: unknown }).type === 'ui-request-render-data') {
+				postRenderDataIfReady()
+				return
+			}
+
+			if ((message as { jsonrpc?: unknown }).jsonrpc !== '2.0') return
+
+			if (
+				(message as { method?: unknown }).method === 'ui/initialize' &&
+				latestShellWindow
+			) {
+				shellInitialized = true
+				latestShellWindow.postMessage(
+					{
+						jsonrpc: '2.0',
+						id: (message as { id?: unknown }).id,
+						result: {
+							protocolVersion: latestProtocolVersion,
+							hostInfo: { name: 'kody-web-host', version: '1.0.0' },
+							hostCapabilities: {
+								message: { text: {} },
+								openLinks: {},
+								serverTools: { listChanged: false },
+							},
+							hostContext: {
+								theme: 'light',
+								displayMode: 'inline',
+								availableDisplayModes: ['inline'],
+							},
+						},
+					},
+					savedUiShellOrigin,
+				)
+				latestShellWindow.postMessage(
+					{
+						jsonrpc: '2.0',
+						method: initializedNotificationMethod,
+						params: {},
+					},
+					savedUiShellOrigin,
+				)
+				postRenderDataIfReady()
+				return
+			}
+
+			if (
+				(message as { method?: unknown }).method === 'ui/message' &&
+				latestShellWindow
+			) {
+				const text = getTextContent(
+					(message as { params?: { content?: unknown } }).params?.content,
+				)
+				latestShellWindow.postMessage(
+					{
+						jsonrpc: '2.0',
+						id: (message as { id?: unknown }).id,
+						result: text ? {} : { isError: true },
+					},
+					savedUiShellOrigin,
+				)
+				return
+			}
+
+			if (
+				(message as { method?: unknown }).method === 'ui/open-link' &&
+				latestShellWindow
+			) {
+				const url = (message as { params?: { url?: unknown } }).params?.url
+				if (typeof url === 'string' && url) {
+					window.open(url, '_blank', 'noopener,noreferrer')
+				}
+				latestShellWindow.postMessage(
+					{
+						jsonrpc: '2.0',
+						id: (message as { id?: unknown }).id,
+						result: {},
+					},
+					savedUiShellOrigin,
+				)
+				return
+			}
+
+			if (
+				(message as { method?: unknown }).method ===
+					'ui/request-display-mode' &&
+				latestShellWindow
+			) {
+				latestShellWindow.postMessage(
+					{
+						jsonrpc: '2.0',
+						id: (message as { id?: unknown }).id,
+						result: { mode: 'inline' },
+					},
+					savedUiShellOrigin,
+				)
+				return
+			}
+
+			if (
+				(message as { method?: unknown }).method !== 'tools/call' ||
+				!latestShellWindow
+			) {
+				return
+			}
+
+			const toolName = (message as { params?: { name?: unknown } }).params?.name
+			const respond = (result: Record<string, unknown>) => {
+				latestShellWindow?.postMessage(
+					{
+						jsonrpc: '2.0',
+						id: (message as { id?: unknown }).id,
+						result,
+					},
+					savedUiShellOrigin,
+				)
+			}
+
+			if (toolName === 'ui_load_app_source') {
+				if (status !== 'ready' || !artifact) {
+					respond({
+						isError: true,
+						structuredContent: {
+							error: { message: 'Saved UI is not ready yet.' },
+						},
+					})
+					return
+				}
+				respond({
+					structuredContent: {
+						app_id: artifact.appId,
+						title: artifact.title,
+						description: artifact.description,
+						runtime: artifact.runtime,
+						code: artifact.code,
+					},
+				})
+				return
+			}
+
+			if (toolName === 'execute') {
+				const code = (
+					message as {
+						params?: { arguments?: { code?: unknown } }
+					}
+				).params?.arguments?.code
+				const targetAppId = status === 'ready' ? artifact?.appId : null
+				if (typeof code !== 'string' || !code.trim() || !targetAppId) {
+					respond({
+						isError: true,
+						structuredContent: { error: { message: 'Code is required.' } },
+					})
+					return
+				}
+				// Respond via microtask/async continuation — not handle.queueTask. Deferring
+				// through the Remix scheduler can land after the widget bridge request timeout
+				// (default 1500ms), so tools/call would fail with a null result in the shell.
+				void (async () => {
+					try {
+						const result = await executeSavedUiCode(targetAppId, code)
+						respond({ structuredContent: { result } })
+					} catch (error) {
+						respond({
+							isError: true,
+							structuredContent: {
+								error: {
+									message:
+										error instanceof Error
+											? error.message
+											: 'Tool call failed.',
+								},
+							},
+						})
+					}
+				})()
+				return
+			}
+
+			respond({
+				isError: true,
+				structuredContent: {
+					error: { message: 'Unsupported tool call.' },
+				},
+			})
+		},
+	})
+
+	return () => {
+		return (
+			<section
+				css={{
+					display: 'grid',
+					gap: spacing.lg,
+					minHeight: 'calc(100vh - 7rem)',
+				}}
+			>
+				<header css={{ display: 'grid', gap: spacing.xs }}>
+					<h1
+						css={{
+							margin: 0,
+							color: colors.text,
+							fontSize: typography.fontSize.xl,
+							fontWeight: typography.fontWeight.semibold,
+						}}
+					>
+						{status === 'ready' ? (artifact?.title ?? 'Saved UI') : 'Saved UI'}
+					</h1>
+					<p css={{ margin: 0, color: colors.textMuted }}>
+						Hosted fallback for a saved generated UI.
+					</p>
+				</header>
+				{status === 'loading' ? (
+					<p css={{ margin: 0, color: colors.textMuted }}>
+						Loading saved UI...
+					</p>
+				) : null}
+				{errorMessage ? (
+					<p
+						role="alert"
+						css={{
+							margin: 0,
+							padding: `${spacing.sm} ${spacing.md}`,
+							borderRadius: '0.75rem',
+							border: `1px solid ${colors.error}`,
+							color: colors.error,
+							backgroundColor: colors.surface,
+						}}
+					>
+						{errorMessage}
+					</p>
+				) : null}
+				{status === 'ready' ? (
+					<iframe
+						data-saved-ui-shell
+						title="Saved UI"
+						src="/dev/generated-ui"
+						css={{
+							width: '100%',
+							minHeight: '70vh',
+							border: `1px solid ${colors.border}`,
+							borderRadius: '1rem',
+							backgroundColor: 'transparent',
+							[mq.mobile]: {
+								minHeight: '60vh',
+							},
+						}}
+					/>
+				) : null}
+			</section>
+		)
+	}
+}

diff --git a/packages/worker/src/app/authenticated-user.ts b/packages/worker/src/app/authenticated-user.ts
--- a/packages/worker/src/app/authenticated-user.ts
+++ b/packages/worker/src/app/authenticated-user.ts
@@ -3,6 +3,7 @@
 	setAuthSessionSecret,
 } from '#app/auth-session.ts'
 import { getEnv } from '#app/env.ts'
+import { createStableUserIdFromEmail } from '#worker/user-id.ts'
 import { type McpUserContext } from '@kody-internal/shared/chat.ts'
 
... diff truncated: showing 800 of 1349 lines
Open in Web Open in Cursor 

Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

♻️ Duplicate comments (1)
packages/worker/client/mcp-apps/generated-ui-shell.ts (1)

821-863: ⚠️ Potential issue | 🟠 Major

Treat null from callTool() as a bridge failure.

On Lines 821-863, widget-host-bridge.ts still converts bridge/request failures into null, so this new .catch() path never runs. That leaves the then branch able to emit { result: null, errorMessage: null } for a transport failure, which is indistinguishable from a legitimate null execution result.

Suggested local guard
 						.then((result) => {
 							if (didTimeout) return
 							window.clearTimeout(timeoutId)
+							if (!result) {
+								targetWindow.postMessage(
+									{
+										type: `${childMessagePrefix}execute-result`,
+										payload: {
+											requestId,
+											result: null,
+											errorMessage: 'Code execution failed.',
+										},
+									},
+									'*',
+								)
+								return
+							}
 							const errorMessage = getHostToolErrorMessage(result)
 							const structuredContent = isRecord(result?.structuredContent)
 								? result.structuredContent
 								: null
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/worker/client/mcp-apps/generated-ui-shell.ts` around lines 821 -
863, The current then() branch treats a null return from hostBridge.callTool()
as a successful tool response; change the handler for
hostBridge.callTool({...}).then((result) => { ... }) so that if result === null
it is treated as a bridge/transport failure (same behavior as the .catch()
path): clear the timeout (using timeoutId), skip if didTimeout, and postMessage
via targetWindow.postMessage with type `${childMessagePrefix}execute-result` and
payload containing requestId, result: null and a non-null errorMessage (e.g.,
'Bridge call failed' or the output of getHostToolErrorMessage when available);
leave existing logic for non-null results unchanged so legitimate null execution
results are still distinguishable from transport failures.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/worker/client/routes/saved-ui.tsx`:
- Around line 316-324: The respond() callbacks close over the mutable
latestShellWindow causing async execute responses to be posted to whichever
iframe is current when the fetch resolves; capture the requesting window and
origin at the time execute is invoked and use those captured values inside
respond instead of latestShellWindow/savedUiShellOrigin so responses are pinned
to the correct requester (update the respond closure in the execute handler and
the similar block around lines 349-383 to refer to the locally captured
requestWindow and requestOrigin variables).
- Around line 255-269: The handler for ui/message currently ACKs any non-empty
message via latestShellWindow.postMessage which suppresses
sendUserMessageWithFallback's prompt fallback; update the logic in saved-ui.tsx
(the ui/message branch using getTextContent, latestShellWindow,
savedUiShellOrigin) so you only return a success result (empty {}) when the text
was actually surfaced/forwarded (e.g., passed into the hosted route or processed
by sendUserMessageWithFallback); if you did not surface the text, respond with {
isError: true } (or otherwise not-success) so sendUserMessageWithFallback can
emit the type: 'prompt' fallback. Ensure the check is based on whether the text
was forwarded/handled, not merely non-empty.

---

Duplicate comments:
In `@packages/worker/client/mcp-apps/generated-ui-shell.ts`:
- Around line 821-863: The current then() branch treats a null return from
hostBridge.callTool() as a successful tool response; change the handler for
hostBridge.callTool({...}).then((result) => { ... }) so that if result === null
it is treated as a bridge/transport failure (same behavior as the .catch()
path): clear the timeout (using timeoutId), skip if didTimeout, and postMessage
via targetWindow.postMessage with type `${childMessagePrefix}execute-result` and
payload containing requestId, result: null and a non-null errorMessage (e.g.,
'Bridge call failed' or the output of getHostToolErrorMessage when available);
leave existing logic for non-null results unchanged so legitimate null execution
results are still distinguishable from transport failures.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e39e1246-daef-467f-8c82-e9e173ae24fb

📥 Commits

Reviewing files that changed from the base of the PR and between c42bf23 and 95bac39.

📒 Files selected for processing (4)
  • packages/worker/client/mcp-apps/generated-ui-shell.ts
  • packages/worker/client/mcp-apps/widget-host-bridge.ts
  • packages/worker/client/routes/saved-ui.tsx
  • packages/worker/src/mcp/tools/open-generated-ui.ts

Comment on lines +255 to +269
if (
(message as { method?: unknown }).method === 'ui/message' &&
latestShellWindow
) {
const text = getTextContent(
(message as { params?: { content?: unknown } }).params?.content,
)
latestShellWindow.postMessage(
{
jsonrpc: '2.0',
id: (message as { id?: unknown }).id,
result: text ? {} : { isError: true },
},
savedUiShellOrigin,
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Don't suppress the shell's message fallback.

On Lines 255-269, this ACKs any non-empty ui/message even though the hosted route doesn't surface the text anywhere. sendUserMessageWithFallback() treats that as success, so it never emits the type: 'prompt' fallback and the action is silently dropped.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/worker/client/routes/saved-ui.tsx` around lines 255 - 269, The
handler for ui/message currently ACKs any non-empty message via
latestShellWindow.postMessage which suppresses sendUserMessageWithFallback's
prompt fallback; update the logic in saved-ui.tsx (the ui/message branch using
getTextContent, latestShellWindow, savedUiShellOrigin) so you only return a
success result (empty {}) when the text was actually surfaced/forwarded (e.g.,
passed into the hosted route or processed by sendUserMessageWithFallback); if
you did not surface the text, respond with { isError: true } (or otherwise
not-success) so sendUserMessageWithFallback can emit the type: 'prompt'
fallback. Ensure the check is based on whether the text was forwarded/handled,
not merely non-empty.

Comment on lines +316 to +324
const respond = (result: Record<string, unknown>) => {
latestShellWindow?.postMessage(
{
jsonrpc: '2.0',
id: (message as { id?: unknown }).id,
result,
},
savedUiShellOrigin,
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Pin async execute responses to the requesting shell window.

On Lines 316-324 and 349-383, respond() closes over mutable latestShellWindow. If an execute call outlives an A → B navigation, that global can point at B's iframe by the time the fetch resolves, so the old JSON-RPC response gets posted into the new shell and can satisfy a recycled generated-ui-shell-bridge-* id.

Suggested fix
 			const toolName = (message as { params?: { name?: unknown } }).params?.name
+			const targetWindow = event.source as WindowProxy
 			const respond = (result: Record<string, unknown>) => {
-				latestShellWindow?.postMessage(
+				targetWindow.postMessage(
 					{
 						jsonrpc: '2.0',
 						id: (message as { id?: unknown }).id,
 						result,
 					},

Also applies to: 349-383

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/worker/client/routes/saved-ui.tsx` around lines 316 - 324, The
respond() callbacks close over the mutable latestShellWindow causing async
execute responses to be posted to whichever iframe is current when the fetch
resolves; capture the requesting window and origin at the time execute is
invoked and use those captured values inside respond instead of
latestShellWindow/savedUiShellOrigin so responses are pinned to the correct
requester (update the respond closure in the execute handler and the similar
block around lines 349-383 to refer to the locally captured requestWindow and
requestOrigin variables).

@cursor cursor 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.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

env: input.env,
requestUrl: input.requestUrl,
})
return new URL(`/ui/${input.appId}`, appBaseUrl).toString()

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.

Hosted URL does not encode app id

Low Severity

buildSavedUiUrl interpolates raw appId into the path without encoding. IDs containing reserved URL characters can produce incorrect /ui/:id links, so hosted fallback URLs may point to the wrong route or fail to reopen the saved artifact.

Fix in Cursor Fix in Web

@kentcdodds
kentcdodds merged commit c6d1079 into main Mar 25, 2026
9 checks passed
@kentcdodds
kentcdodds deleted the cursor/ui-display-url-5b4a branch March 25, 2026 20:07
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