From 50ffad52d765497fa3a561ebe8842b40fc8bd58e Mon Sep 17 00:00:00 2001 From: Diego Imbert <70353967+diegoimbert@users.noreply.github.com> Date: Tue, 29 Sep 2026 19:06:34 +0200 Subject: [PATCH] fix: surface raw app build errors to the session AI (#11415) * fix: surface raw app build errors to the session AI Co-Authored-By: Claude Opus 5.5 (1M context) * fix: route app build state per preview and report pending builds Co-Authored-By: Claude Opus 5.5 (1M context) * fix: name open previews when app logs are ambiguous, re-wait on retry Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- .../components/copilot/chat/global/core.ts | 23 ++++- .../components/raw_apps/RawAppEditor.svelte | 28 +++++- .../src/lib/components/raw_apps/utils.test.ts | 56 ++++++++++- frontend/src/lib/components/raw_apps/utils.ts | 96 ++++++++++++++++++- .../sessions/RawAppEditorView.svelte | 10 +- .../sessions/sessionRuntime.svelte.ts | 83 ++++++++++++++-- 6 files changed, 275 insertions(+), 21 deletions(-) diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index 84715538b2..62eddd1a89 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -1189,7 +1189,13 @@ const getRuntimeLogsSchema = z.object({ .min(1) .max(100) .optional() - .describe('How many of the most recent runtime log lines to return. Defaults to 10.') + .describe('How many of the most recent runtime log lines to return. Defaults to 10.'), + app_path: z + .string() + .optional() + .describe( + 'Path of the raw app whose preview to read. Pass it when several raw app previews are open; defaults to the visible one.' + ) }) const listAppRunsSchema = z.object({ @@ -1466,6 +1472,7 @@ ${pipelineBullet}` - Building a data pipeline: call open_preview(kind="pipeline", path="") as the FIRST step, before creating any node — this opens the pipeline editor the user reviews in. path is the folder, not an item; an empty ${when(canCreateFolder, 'or not-yet-created ')}folder is fine${when(canCreateFolder, ' (create_folder first if needed, then open it)')}. Opening it registers build_pipeline_node / edit_pipeline_node — use ONLY those to add or change pipeline nodes, never write_script for a pipeline node — they apply directly as unsaved drafts on the canvas (no separate accept/reject step) that the user reviews and deploys. Do not write pipeline scripts without first opening the editor.` )} - When debugging a running raw app, call get_app_runtime_logs to read the live preview's browser console output. It needs the raw app preview open (open_preview kind="raw_app"). +- Writing an app file does not compile it: the open preview rebuilds it afterwards. After editing a raw app's frontend files with its preview open, call get_app_runtime_logs to check the build — when it failed, it returns the build errors (e.g. syntax or import errors) and bundler logs to fix. - To inspect what actually rendered in a running raw app (verify an edit landed on screen, diagnose a blank/empty or wrong view, answer "what's showing"), use search_dom (regex over the live HTML) and read_dom (a line-numbered window). Pass a \`selector\` to scope to an element — prefer the selector from a DOM element chip the user attached — or omit it for the whole page. When a chip lists an \`app_path\`, pass it too so the RIGHT app is read (several previews can be open; a query without \`app_path\` hits the visible one). The DOM is read live and is never in context; no match means the element isn't rendered. Both need the raw app preview open. - get_app_runtime_logs only shows the app's browser console. For the server-side logs of a backend runnable the app invoked (a backend. call), call list_app_runs to get that run's job_id from the live preview, then get_run with it. Use this when a backend call errors or returns something unexpected. ${ @@ -4438,7 +4445,7 @@ export const globalTools: SessionTool<{}>[] = [ def: createToolDef( getRuntimeLogsSchema, 'get_app_runtime_logs', - 'Fetch the most recent browser console logs (and uncaught errors) from the raw app preview currently open in this AI session.' + 'Fetch the most recent browser console logs (and uncaught errors) from the raw app preview currently open in this AI session. Also reports the build: right after an edit it waits (up to 20s) for the rebuild, and when the build failed it returns the build error and bundler logs first.' ), planModeSafe: true, showDetails: true, @@ -4446,7 +4453,11 @@ export const globalTools: SessionTool<{}>[] = [ fn: async (ctx) => { const parsed = getRuntimeLogsSchema.parse(ctx.args) ctx.toolCallbacks.setToolStatus(ctx.toolId, { content: 'Reading app runtime logs...' }) - const result = await getSessionRuntimeLogs(parsed.limit ?? 10, sessionIdFromCtx(ctx)) + const result = await getSessionRuntimeLogs( + parsed.limit ?? 10, + sessionIdFromCtx(ctx), + parsed.app_path + ) ctx.toolCallbacks.setToolStatus(ctx.toolId, { content: result.uiMessage, result: result.toolResult @@ -4810,6 +4821,7 @@ function closeSessionPreviewTabs( export type GetRuntimeLogsHandler = (req: { sessionId: string | undefined limit: number + appPath?: string }) => Promise let getRuntimeLogsHandler: GetRuntimeLogsHandler | undefined @@ -4820,7 +4832,8 @@ export function setGetRuntimeLogsHandler(handler: GetRuntimeLogsHandler | undefi function getSessionRuntimeLogs( limit: number, - sessionId: string | undefined + sessionId: string | undefined, + appPath: string | undefined ): Promise { if (!getRuntimeLogsHandler) { return Promise.resolve({ @@ -4830,7 +4843,7 @@ function getSessionRuntimeLogs( toolResult: 'Runtime logs unavailable' }) } - return getRuntimeLogsHandler({ sessionId, limit }) + return getRuntimeLogsHandler({ sessionId, limit, appPath }) } export type ListAppRunsHandler = (req: { diff --git a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte index 98c436b0bd..fd0bd23c53 100644 --- a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte @@ -20,6 +20,7 @@ WMILL_TS_PATH, genWmillTs, normalizeRawAppRuntimeLogs, + createRawAppBuildTracker, type Runnable, type RawAppRuntimeLogEntry, type RawAppRuntimeLogRequester, @@ -406,6 +407,9 @@ // Latest UI Builder error; cleared on next successful build. let buildError = $state(undefined) + const buildTracker = createRawAppBuildTracker() + const PREVIEW_SETTLE_MS = 1000 + let editorDestroyed = false // Latest uncaught runtime error thrown by the rendered app; cleared on next build. let runtimeError = $state(undefined) // Set when a build ran cleanly but never mounted anything into #root — the @@ -909,6 +913,7 @@ if (!target) return iframeFiles = { ...newFiles } iframeFocusPending = undefined + buildTracker.start() const files = Object.fromEntries( Object.entries(newFiles).filter(([path, _]) => !path.endsWith('/')) ) @@ -928,6 +933,7 @@ iframeFiles = { ...newFiles } const focus = iframeFocusPending === pathToSelect iframeFocusPending = undefined + buildTracker.start() const files = Object.fromEntries( Object.entries(newFiles).filter(([path, _]) => !path.endsWith('/')) ) @@ -1356,6 +1362,9 @@ lastBuild = { css: e.data.css, js: e.data.js } feedPreviewIframe(lastBuild) syncExternalPreview() + // Give the fed app a moment to run, so a console read after the wait sees its output. + const gen = buildTracker.generation + setTimeout(() => buildTracker.settle(gen), PREVIEW_SETTLE_MS) return } @@ -1376,6 +1385,8 @@ // `message: undefined` arrives on the next successful build and clears the banner. if (fromUiBuilder && e.data.type === 'buildError') { buildError = typeof e.data.message === 'string' ? e.data.message : undefined + // A successful build settles once the preview has it (see `preview` above). + if (buildError !== undefined) buildTracker.settle() return } @@ -1713,7 +1724,20 @@ pending.resolve(entries) } - const requestRuntimeLogs: RawAppRuntimeLogRequester = (limit) => { + const requestRuntimeLogs: RawAppRuntimeLogRequester = async (limit) => { + await buildTracker.wait() + if (editorDestroyed) { + return { entries: undefined, buildError: undefined, buildPending: false, buildLogs: '' } + } + return { + entries: await requestPreviewConsoleLogs(limit), + buildError, + buildPending: buildTracker.pending, + buildLogs: logs + } + } + + function requestPreviewConsoleLogs(limit: number) { const win = previewIframe?.contentWindow if (!win || !previewIframeLoaded) return Promise.resolve(undefined) const requestId = randomUUID() @@ -2029,6 +2053,8 @@ onScreenshotRequester?.(undefined) for (const requestId of Array.from(pendingRuntimeLogReqs.keys())) resolvePendingRuntimeLogRequest(requestId, undefined) + editorDestroyed = true + buildTracker.release() } }) diff --git a/frontend/src/lib/components/raw_apps/utils.test.ts b/frontend/src/lib/components/raw_apps/utils.test.ts index 96fc110b95..1470895019 100644 --- a/frontend/src/lib/components/raw_apps/utils.test.ts +++ b/frontend/src/lib/components/raw_apps/utils.test.ts @@ -1,7 +1,9 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { canonicalRawAppDiffValue, + createRawAppBuildTracker, + formatBuildFailureForChat, formatRuntimeLogsForChat, genWmillTs, normalizeRawAppRuntimeLogs, @@ -85,6 +87,58 @@ describe('normalizeRawAppRuntimeLogs', () => { }) }) +describe('formatBuildFailureForChat', () => { + it('reports the build error with the bundler log tail, without install noise', () => { + const logs = [ + 'Installing react …', + 'Using cached resolution for react@19.0.0: 19.0.0', + 'Resolved react@19.0.0', + 'Using idb cache for react@19.0.0 …', + '[esbuild] Build started...', + '[esbuild] Build failed: Build failed with 1 error:', + 'App.tsx:1:15: ERROR: Unexpected ";"' + ].join('\n') + const report = formatBuildFailureForChat('App.tsx:1:15: ERROR: Unexpected ";"', logs) + + expect(report).toContain('Build error:\nApp.tsx:1:15: ERROR: Unexpected ";"') + expect(report).toContain('[esbuild] Build failed') + expect(report).toContain('Installing react') + expect(report).not.toContain('Using cached resolution') + expect(report).not.toContain('Resolved react') + }) +}) + +describe('createRawAppBuildTracker', () => { + it('keeps a timed-out build pending until it settles', async () => { + vi.useFakeTimers() + try { + const tracker = createRawAppBuildTracker(1000) + tracker.start() + const first = tracker.wait() + await vi.advanceTimersByTimeAsync(1000) + await first + expect(tracker.pending).toBe(true) + + let secondDone = false + void tracker.wait().then(() => (secondDone = true)) + tracker.settle() + await vi.advanceTimersByTimeAsync(0) + expect(secondDone).toBe(true) + expect(tracker.pending).toBe(false) + } finally { + vi.useRealTimers() + } + }) + + it('ignores a settle for a generation an edit has superseded', () => { + const tracker = createRawAppBuildTracker(1000) + const gen = tracker.start() + tracker.start() + tracker.settle(gen) + expect(tracker.pending).toBe(true) + }) +}) + // A deployed raw-app row as returned by getAppByPath: nested `value`, plus the // server-managed columns and a recomputed inline-script lock. function deployedRow() { diff --git a/frontend/src/lib/components/raw_apps/utils.ts b/frontend/src/lib/components/raw_apps/utils.ts index 244a9ba1cc..a1f7408380 100644 --- a/frontend/src/lib/components/raw_apps/utils.ts +++ b/frontend/src/lib/components/raw_apps/utils.ts @@ -83,9 +83,71 @@ export type RawAppRuntimeLogEntry = { message: string ts: number } -export type RawAppRuntimeLogRequester = ( - limit: number -) => Promise +export type RawAppPreviewLogs = { + /** Console output of the rendered app; undefined when the preview document isn't loaded. */ + entries: RawAppRuntimeLogEntry[] | undefined + /** Set while the latest UI Builder build failed. A failed build never reaches the preview + * document, so its console cannot report it. */ + buildError: string | undefined + /** Files were sent to the UI Builder and its build has not reported back in time. */ + buildPending: boolean + buildLogs: string +} +export type RawAppRuntimeLogRequester = (limit: number) => Promise + +export const RAW_APP_BUILD_WAIT_MS = 20_000 + +/** Whether files sent to the UI Builder have produced a build result yet, so a read made + * right after an edit waits for that edit's build instead of reporting the previous one. + * The UI Builder carries no build id: when edits land while a build runs, the result of + * the older build can settle the wait. */ +export function createRawAppBuildTracker(timeoutMs = RAW_APP_BUILD_WAIT_MS) { + // Stays set after a timed-out wait: the UI Builder never builds an app with no + // entrypoint, so a build may never report, and a later read must not call it done. + let pending = false + let generation = 0 + let waiters: (() => void)[] = [] + function release() { + const toRelease = waiters + waiters = [] + toRelease.forEach((w) => w()) + } + return { + get pending() { + return pending + }, + /** Files were sent for a build; returns the generation to pass to `settle`. */ + start(): number { + pending = true + return ++generation + }, + /** A build reported. With `gen`, only settles if no edit was sent since. */ + settle(gen?: number) { + if (gen !== undefined && gen !== generation) return + pending = false + release() + }, + /** Ends every wait without settling, e.g. when the editor unmounts. */ + release, + get generation() { + return generation + }, + wait(): Promise { + if (!pending) return Promise.resolve() + return new Promise((resolve) => { + const done = () => { + clearTimeout(timer) + resolve() + } + const timer = setTimeout(() => { + waiters = waiters.filter((w) => w !== done) + resolve() + }, timeoutMs) + waiters.push(done) + }) + } + } +} const RAW_APP_RUNTIME_LOG_LEVELS = new Set([ 'log', @@ -127,6 +189,34 @@ export function formatRuntimeLogsForChat(entries: RawAppRuntimeLogEntry[]): stri return lines.join('\n') } +const BUILD_LOG_TAIL_LINES = 40 +const BUILD_LOG_TAIL_CHARS = 6000 +// Per-package install progress: dozens of lines per build that would crowd the errors out of the tail. +const ROUTINE_INSTALL_LOG_LINE = /^\s*(Using cached resolution|Using idb cache|Resolved \S+@|Extract(ing|ed) )/ + +export function formatBuildLogTailForChat(buildLogs: string): string { + // The bundler log accumulates every install and build since the editor opened. + const lines = buildLogs + .split('\n') + .filter((line) => line.trim() && !ROUTINE_INSTALL_LOG_LINE.test(line)) + .slice(-BUILD_LOG_TAIL_LINES) + let tail = lines.join('\n') + while (tail.length > BUILD_LOG_TAIL_CHARS && lines.length > 1) { + lines.shift() + tail = lines.join('\n') + } + tail = tail.slice(-BUILD_LOG_TAIL_CHARS) + return tail ? `Recent bundler logs:\n${tail}` : 'No bundler logs yet.' +} + +export function formatBuildFailureForChat(buildError: string, buildLogs: string): string { + return [ + 'The app build FAILED, so the preview is not running the current code. Fix these errors in the frontend files, then call get_app_runtime_logs again to confirm the build passes.', + `Build error:\n${buildError}`, + formatBuildLogTailForChat(buildLogs) + ].join('\n\n') +} + export type RawAppRunSummary = { job_id: string component: string diff --git a/frontend/src/lib/components/sessions/RawAppEditorView.svelte b/frontend/src/lib/components/sessions/RawAppEditorView.svelte index e95bc142e5..abe7ba91aa 100644 --- a/frontend/src/lib/components/sessions/RawAppEditorView.svelte +++ b/frontend/src/lib/components/sessions/RawAppEditorView.svelte @@ -79,9 +79,17 @@ invalidateWorkspaceDrafts(workspaceId) } + let runtimeLogRequester = $state(undefined) function registerRuntimeLogRequester(requester: RawAppRuntimeLogRequester | undefined) { - runtime.setRuntimeLogRequester(requester) + runtimeLogRequester = requester } + $effect(() => { + const p = path + const r = runtimeLogRequester + if (!r) return + runtime.registerRuntimeLogRequester(p, r) + return () => runtime.unregisterRuntimeLogRequester(p, r) + }) function registerRunsProvider(provider: RawAppRunsProvider | undefined) { runtime.setAppRunsProvider(provider) diff --git a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts index 6edc8ab1dc..6109e1ab08 100644 --- a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts @@ -86,8 +86,11 @@ import { } from '$lib/components/copilot/chat/global/core' import { formatRuntimeLogsForChat, + formatBuildFailureForChat, + formatBuildLogTailForChat, + RAW_APP_BUILD_WAIT_MS, formatAppRunsForChat, - type RawAppRuntimeLogEntry, + type RawAppPreviewLogs, type RawAppRuntimeLogRequester, type RawAppRunSummary, type RawAppRunsProvider, @@ -209,8 +212,16 @@ export interface SessionRuntime { force?: boolean, deployedOnly?: boolean ): Promise - setRuntimeLogRequester(requester: RawAppRuntimeLogRequester | undefined): void - requestRuntimeLogs(limit: number): Promise + /** Register a mounted raw-app preview's log requester, keyed by app path, like + * `registerDomRequester`: build state is per editor, so reads route to the app edited. */ + registerRuntimeLogRequester(appPath: string, requester: RawAppRuntimeLogRequester): void + unregisterRuntimeLogRequester(appPath: string, requester: RawAppRuntimeLogRequester): void + requestRuntimeLogs( + limit: number, + appPath?: string + ): Promise< + RawAppPreviewLogs | { closedAppPath: string } | { ambiguousAppPaths: string[] } | undefined + > /** Register a mounted raw-app preview's DOM requester, keyed by app path. * ALL mounted preview tabs register (hidden ones stay mounted), so a * DOM-scoped turn can read its own app even when another tab is visible. */ @@ -571,7 +582,7 @@ function createRuntime(session: Session): SessionRuntime { // the pane unmounts on hide, and a component-local store would be discarded. const pipelineEditorState = new PipelineEditorState() - let runtimeLogRequester: RawAppRuntimeLogRequester | undefined = undefined + const runtimeLogRequesters = new Map() // appPath → requester, one entry per mounted raw-app preview tab. const domRequesters = new Map() let activeDomAppPath: string | undefined = undefined @@ -925,11 +936,24 @@ function createRuntime(session: Session): SessionRuntime { armRestartOnFirstInteraction(workspace, kind, path) }, - setRuntimeLogRequester(requester) { - runtimeLogRequester = requester + registerRuntimeLogRequester(appPath, requester) { + runtimeLogRequesters.set(appPath, requester) }, - async requestRuntimeLogs(limit) { - return runtimeLogRequester ? runtimeLogRequester(limit) : undefined + unregisterRuntimeLogRequester(appPath, requester) { + if (runtimeLogRequesters.get(appPath) === requester) runtimeLogRequesters.delete(appPath) + }, + async requestRuntimeLogs(limit, appPath) { + const path = + appPath ?? + activeDomAppPath ?? + (runtimeLogRequesters.size === 1 ? [...runtimeLogRequesters.keys()][0] : undefined) + if (path === undefined) { + return runtimeLogRequesters.size > 1 + ? { ambiguousAppPaths: [...runtimeLogRequesters.keys()] } + : undefined + } + const requester = runtimeLogRequesters.get(path) + return requester ? requester(limit) : { closedAppPath: path } }, registerDomRequester(appPath, requester) { domRequesters.set(appPath, requester) @@ -1286,7 +1310,7 @@ setDeployedInSessionHandler(({ sessionId: callerSessionId, kind, path }) => { runtime.syncPreviewWithDeployed(session.workspace_id, kind, path) }) -setGetRuntimeLogsHandler(async ({ sessionId: callerSessionId, limit }) => { +setGetRuntimeLogsHandler(async ({ sessionId: callerSessionId, limit, appPath }) => { const sessionId = callerSessionId ?? sessionState.currentSessionId const runtime = sessionId ? runtimes.get(sessionId) : undefined if (!runtime) { @@ -1297,7 +1321,46 @@ setGetRuntimeLogsHandler(async ({ sessionId: callerSessionId, limit }) => { toolResult: 'Runtime logs unavailable' } } - const entries = await runtime.requestRuntimeLogs(limit) + const previewLogs = await runtime.requestRuntimeLogs(limit, appPath) + if (previewLogs && 'closedAppPath' in previewLogs) { + return { + aiResult: `The preview for "${previewLogs.closedAppPath}" is not open, so its logs can't be read. Call open_preview with kind="raw_app" and that path, then call get_app_runtime_logs again.`, + uiMessage: 'Runtime logs unavailable', + toolResult: 'Runtime logs unavailable' + } + } + if (previewLogs && 'ambiguousAppPaths' in previewLogs) { + return { + aiResult: `Several raw app previews are open (${previewLogs.ambiguousAppPaths.join(', ')}) and none is visible. Call get_app_runtime_logs again with app_path set to the app you want.`, + uiMessage: 'Runtime logs unavailable', + toolResult: 'Runtime logs unavailable' + } + } + // Checked before `buildError`: while a build is pending, that error belongs to the previous build. + if (previewLogs?.buildPending) { + const previous = previewLogs.buildError + ? `\n\nThe PREVIOUS build failed with:\n${previewLogs.buildError}` + : '' + return { + aiResult: + `The app is still building (or its build never started, e.g. no index entrypoint file) — no build result arrived within ${RAW_APP_BUILD_WAIT_MS / 1000} seconds, so the preview may still run an older build. Check the bundler logs below, wait, then call get_app_runtime_logs again.${previous}\n\n` + + formatBuildLogTailForChat(previewLogs.buildLogs), + uiMessage: 'App build still running', + toolResult: 'App build still running' + } + } + if (previewLogs?.buildError !== undefined) { + const report = formatBuildFailureForChat(previewLogs.buildError, previewLogs.buildLogs) + const consoleLogs = previewLogs.entries?.length + ? `\n\nConsole output of the last successful build still shown in the preview (stale until the build is fixed):\n${formatRuntimeLogsForChat(previewLogs.entries.slice(-limit))}` + : '' + return { + aiResult: `${report}${consoleLogs}`, + uiMessage: 'App build failed', + toolResult: report + } + } + const entries = previewLogs?.entries if (entries === undefined) { return { aiResult: