Repository navigation
Add authenticated hosted saved UI route - #50
Conversation
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
📝 WalkthroughWalkthroughAdds 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
Sequence DiagramsequenceDiagram
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)
Estimated code review effort🎯 4 (Complex) | ⏱️ ~50 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
|
🔎 Preview deployed: https://kody-pr-50.kentcdodds.workers.dev Worker: Mocks:
|
There was a problem hiding this comment.
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
📒 Files selected for processing (17)
e2e/saved-ui.spec.tspackages/worker/client/app.tsxpackages/worker/client/routes/index.tsxpackages/worker/client/routes/saved-ui.tsxpackages/worker/src/app/authenticated-user.tspackages/worker/src/app/handlers/saved-ui.tspackages/worker/src/app/router.tspackages/worker/src/app/routes.tspackages/worker/src/chat-agent.tspackages/worker/src/mcp/capabilities/apps/ui-save-app.tspackages/worker/src/mcp/mcp-server-e2e.test.tspackages/worker/src/mcp/tools/open-generated-ui.tspackages/worker/src/mcp/ui-artifacts-repo.tspackages/worker/src/oauth-handlers.tspackages/worker/src/ui-artifact-urls.tspackages/worker/src/user-id.test.tspackages/worker/src/user-id.ts
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
There was a problem hiding this comment.
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/:idchanges, preventing stale artifacts.
- Added a router navigation listener to re-queue saved UI fetches when the
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
|
@cursoragent could you please address valid feedback on this PR and check out the failing CI E2E tests and fix those? Thanks! |
This comment has been minimized.
This comment has been minimized.
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>
There was a problem hiding this comment.
Actionable comments posted: 2
♻️ Duplicate comments (1)
packages/worker/client/routes/saved-ui.tsx (1)
103-127:⚠️ Potential issue | 🔴 CriticalPin the iframe bridge to the shell origin.
event.source === frameElement.contentWindowis not a trust boundary once the iframe navigates. A page that takes over that browsing context can still issuetools/call/ui_load_app_source, and every reply/render-data post here uses'*', so the authenticated responses go back to that page. Requireevent.originto match the expected/dev/generated-uiorigin and use that origin for everypostMessagein 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
📒 Files selected for processing (3)
e2e/saved-ui.spec.tspackages/worker/client/mcp-apps/generated-ui-shell.tspackages/worker/client/routes/saved-ui.tsx
🚧 Files skipped from review as they are similar to previous changes (1)
- e2e/saved-ui.spec.ts
There was a problem hiding this comment.
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.
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>
|
@cursoragent, please address the valid feedback in this PR. |
|
Bugbot Autofix prepared a fix for the issue found in the latest run.
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 |
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
There was a problem hiding this comment.
Actionable comments posted: 2
♻️ Duplicate comments (1)
packages/worker/client/mcp-apps/generated-ui-shell.ts (1)
821-863:⚠️ Potential issue | 🟠 MajorTreat
nullfromcallTool()as a bridge failure.On Lines 821-863,
widget-host-bridge.tsstill converts bridge/request failures intonull, so this new.catch()path never runs. That leaves thethenbranch able to emit{ result: null, errorMessage: null }for a transport failure, which is indistinguishable from a legitimatenullexecution 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
📒 Files selected for processing (4)
packages/worker/client/mcp-apps/generated-ui-shell.tspackages/worker/client/mcp-apps/widget-host-bridge.tspackages/worker/client/routes/saved-ui.tsxpackages/worker/src/mcp/tools/open-generated-ui.ts
| 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, | ||
| ) |
There was a problem hiding this comment.
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.
| const respond = (result: Record<string, unknown>) => { | ||
| latestShellWindow?.postMessage( | ||
| { | ||
| jsonrpc: '2.0', | ||
| id: (message as { id?: unknown }).id, | ||
| result, | ||
| }, | ||
| savedUiShellOrigin, | ||
| ) |
There was a problem hiding this comment.
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).
| env: input.env, | ||
| requestUrl: input.requestUrl, | ||
| }) | ||
| return new URL(`/ui/${input.appId}`, appBaseUrl).toString() |
There was a problem hiding this comment.





Summary
/ui/:idbrowser route plus owner-scoped saved-ui data/execute endpointsopen_generated_uimessagingTesting
bun run typecheckbun 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.tsbun run test:mcpbun run test:e2e e2e/saved-ui.spec.ts/ui/manual-demo-saved-uiSummary by CodeRabbit
New Features
Authentication
Tests