From fa253976e213c1053d1832c1d1fb7bedf7ce8c45 Mon Sep 17 00:00:00 2001 From: AlexRV12 <71396855+AlexRV12@users.noreply.github.com> Date: Wed, 7 Oct 2026 10:52:41 +0200 Subject: [PATCH 01/18] feat: isolate the raw-app editor preview when the app opts into sandboxing (#11572) * feat: isolate the raw-app editor preview when the app opts into sandboxing Co-Authored-By: Claude Opus 5.5 * fix: gate job reads from a sandboxed editor preview like the deployed app Co-Authored-By: Claude Opus 5.5 * fix: send a plain user in a sandboxed raw app's startup message Co-Authored-By: Claude Opus 5.5 * chore: pin ui_builder to d3fdb70 Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- backend/windmill-api/src/static_assets.rs | 4 +- frontend/package-lock.json | 7 - frontend/package.json | 1 - frontend/scripts/ui_builder_artifact.json | 4 +- .../raw_apps/RawAppBackgroundRunner.svelte | 28 +- .../components/raw_apps/RawAppEditor.svelte | 571 +++++++++--------- .../components/raw_apps/RawAppPreview.svelte | 43 +- frontend/src/lib/components/raw_apps/utils.ts | 32 + 8 files changed, 367 insertions(+), 323 deletions(-) diff --git a/backend/windmill-api/src/static_assets.rs b/backend/windmill-api/src/static_assets.rs index 78c2b48f3b..e469e91249 100644 --- a/backend/windmill-api/src/static_assets.rs +++ b/backend/windmill-api/src/static_assets.rs @@ -103,7 +103,9 @@ fn serve_path(path: &str, original_path: &str, query: Option<&str>) -> Response< // The raw-app preview shell evaluates whatever js is posted to it, so it is // only ever served opaque-origin, with the deployed app wrapper's exact flags // (`get_raw_app_data`). Matched on the embedded file, not the request path, - // so `//ui_builder/...` is covered. The editor loads a same-origin blob: copy. + // so `//ui_builder/...` is covered. A sandboxed app's editor preview loads it + // as served, framed with the same flags (`RAW_APP_SANDBOX_FLAGS` in the + // frontend); an unsandboxed one loads a same-origin blob: copy. if path == "ui_builder/app-preview.html" { res = res.header( header::CONTENT_SECURITY_POLICY, diff --git a/frontend/package-lock.json b/frontend/package-lock.json index dfb2336b70..894d7067ab 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -61,7 +61,6 @@ "mdast-util-find-and-replace": "^3.0.2", "mermaid": "^11.16.1", "minimatch": "^10.2.3", - "modern-screenshot": "^4.7.0", "monaco-editor": "npm:@codingame/monaco-vscode-editor-api@=25.0.0", "monaco-languageclient": "10.6.0", "monaco-vim": "^0.4.1", @@ -9657,12 +9656,6 @@ "dev": true, "license": "MIT" }, - "node_modules/modern-screenshot": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/modern-screenshot/-/modern-screenshot-4.7.0.tgz", - "integrity": "sha512-9YxN+ddPSMMlhylOv25VHzXrl9u67QRxoh7+SEewGtgUw7t6hHTrjptSDJUSne9oG4Xk/h2cwG15nIt4Hc9ujg==", - "license": "MIT" - }, "node_modules/monaco-editor": { "name": "@codingame/monaco-vscode-editor-api", "version": "25.0.0", diff --git a/frontend/package.json b/frontend/package.json index 1785e9b2ad..22f7723b2d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -127,7 +127,6 @@ "mdast-util-find-and-replace": "^3.0.2", "mermaid": "^11.16.1", "minimatch": "^10.2.3", - "modern-screenshot": "^4.7.0", "monaco-editor": "npm:@codingame/monaco-vscode-editor-api@=25.0.0", "monaco-languageclient": "10.6.0", "monaco-vim": "^0.4.1", diff --git a/frontend/scripts/ui_builder_artifact.json b/frontend/scripts/ui_builder_artifact.json index 8940f265ce..c82e327126 100644 --- a/frontend/scripts/ui_builder_artifact.json +++ b/frontend/scripts/ui_builder_artifact.json @@ -1,5 +1,5 @@ { "baseUrl": "https://pub-06154ed168a24e73a86ab84db6bf15d8.r2.dev", - "version": "9fddc52", - "sha256": "b45558951f3d4c9dce7b88b5139d66b0f5f01fe561a22efcb0b8400a13240522" + "version": "d3fdb70", + "sha256": "8c894b3f02d5d1872a966c37d43314f05ce42900f7330bdcf45a18a772deb2b9" } diff --git a/frontend/src/lib/components/raw_apps/RawAppBackgroundRunner.svelte b/frontend/src/lib/components/raw_apps/RawAppBackgroundRunner.svelte index 6cdcd6a427..c3eadc1f68 100644 --- a/frontend/src/lib/components/raw_apps/RawAppBackgroundRunner.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppBackgroundRunner.svelte @@ -5,6 +5,7 @@ import type { JobById } from '../apps/types' import { JobService } from '$lib/gen' import type { Runnable } from './rawAppPolicy' + import { RAW_APP_PREVIEW_RELAY } from './utils' import { undefinedIfEmpty } from '$lib/utils' interface Props { @@ -19,10 +20,9 @@ * Restrict waitJob/getJob/streamJob to job ids launched by this app * instance (WIN-2006): a SANDBOXED bundle must not read arbitrary * workspace jobs through the credentialed bridge. Off for unsandboxed - * renders (the default, and editor preview) — there the bundle holds - * the same credential as the bridge, so gating adds nothing and would - * only break unsandboxed apps that poll persisted or runnable-returned - * job ids. + * renders — there the bundle holds the same credential as the bridge, + * so gating adds nothing and would only break unsandboxed apps that + * poll persisted or runnable-returned job ids. */ gateJobIds?: boolean /** @@ -32,7 +32,7 @@ * `window.opener` (this window), so the bridge must accept its * `event.source` and reply to it. A getter so it tracks the live handle * without a reactive prop. Editor-only — the detached window runs the - * same unsandboxed bundle as the inline preview. + * same bundle, sandboxed or not, as the inline preview. */ extraSourceWindow?: () => Window | null | undefined } @@ -62,7 +62,11 @@ if (!iframe || !sourceWindow) return if (sourceWindow !== iframe.contentWindow && sourceWindow !== detachedWindow) return - const data = event.data + // A sandboxed detached preview speaks through a same-origin relay, wrapped. + const data = + sourceWindow === detachedWindow && event.data?.type === RAW_APP_PREVIEW_RELAY + ? event.data.data + : event.data // Reply to whichever window sent the request (inline iframe or the // detached preview), not a hardcoded target — otherwise the detached @@ -81,7 +85,7 @@ result = e } - if (event.data.type == 'backend' || event.data.type == 'waitJob') { + if (data.type == 'backend' || data.type == 'waitJob') { respond({ result, error }) } if (editor) { @@ -101,7 +105,7 @@ } return result } - if (event.data.type == 'backend' || event.data.type == 'backendAsync') { + if (data.type == 'backend' || data.type == 'backendAsync') { const runnable_id = data.runnable_id let runnable = runnables[runnable_id] if (runnable) { @@ -152,7 +156,7 @@ ) launchedJobs.add(uuid) let job: JobById = { component: runnable_id, created_at: Date.now(), job: uuid } - if (event.data.type == 'backendAsync') { + if (data.type == 'backendAsync') { let result = uuid respond({ result }) } @@ -169,20 +173,20 @@ } else { console.error('No runnable found for', runnable_id) } - } else if (event.data.type == 'waitJob') { + } else if (data.type == 'waitJob') { if (gateJobIds && !launchedJobs.has(data.jobId)) { respond({ result: { message: 'Unknown job' }, error: true }) return } await respondWithResult(data.jobId) - } else if (event.data.type == 'getJob') { + } else if (data.type == 'getJob') { if (gateJobIds && !launchedJobs.has(data.jobId)) { respond({ result: { message: 'Unknown job' }, error: true }) return } const job = await JobService.getJob({ workspace, id: data.jobId }) respond({ result: job }) - } else if (event.data.type == 'streamJob') { + } else if (data.type == 'streamJob') { // Stream job results using SSE const jobId = data.jobId const reqId = data.reqId diff --git a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte index d74c3f7791..8a0655d70e 100644 --- a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte @@ -26,7 +26,12 @@ type RawAppRuntimeLogRequester, type RawAppRunSummary, type RawAppRunsProvider, - type RawAppScreenshotRequester + type RawAppScreenshotRequester, + RAW_APP_SANDBOX_FLAGS, + RAW_APP_PREVIEW_RELAY, + rawAppStorageKey, + readRawAppStorage, + applyRawAppStorageOp } from './utils' import { runDomQueryOnHtml, type RawAppDomQuery, type RawAppDomRequester } from './rawAppDom' import InlineElementPrompt from './InlineElementPrompt.svelte' @@ -72,7 +77,7 @@ appDatatableRole } from './dataTableRefUtils' import { datatableReference } from '../dbTypes' - import { randomUUID } from '$lib/utils/uuid' + import { randomSecret, randomUUID } from '$lib/utils/uuid' import { editorFontSize } from '$lib/editorFontSize.svelte' import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' @@ -370,11 +375,11 @@ let iframe: HTMLIFrameElement | undefined = $state(undefined) const PREVIEW_SHELL_URL = '/ui_builder/app-preview.html' - // The served shell is `CSP: sandbox` (opaque origin), but the editor reads the - // preview's DOM and feeds it builds as a same-origin document, so it loads a - // per-mount blob: copy instead — the same split as `unsandboxedRawAppHtml`, and - // like it with no ``, so the app resolves URLs as it will once deployed. - let previewShellUrl: string | undefined = $state(undefined) + // The served shell is `CSP: sandbox` (opaque origin). An app without sandbox + // isolation runs same-origin once deployed, so its preview loads a per-mount + // blob: copy instead — the same split as `unsandboxedRawAppHtml`, and like it + // with no ``, so the app resolves URLs as it will once deployed. + let previewBlobUrl: string | undefined = $state(undefined) onMount(() => { let url: string | undefined let destroyed = false @@ -386,7 +391,7 @@ .then((html) => { if (destroyed) return url = URL.createObjectURL(new Blob([html], { type: 'text/html' })) - previewShellUrl = url + previewBlobUrl = url }) .catch((e) => sendUserToast(`Could not load the app preview: ${e}`, true)) return () => { @@ -394,8 +399,50 @@ if (url) URL.revokeObjectURL(url) } }) + // An app with sandbox isolation previews the way it runs once deployed: in the + // served, opaque-origin shell, framed with the deployed wrapper's flags. The nonce + // in its URL authenticates its `windmill:ready`, as in `RawAppPreview`. + const sandboxed = $derived(policy?.sandbox === true) + const previewStorageKey = $derived(rawAppStorageKey(opWorkspace ?? '', path, true)) + const handshakeNonce = randomSecret() + const sandboxedShellUrl = `${PREVIEW_SHELL_URL}?wm_hs=${handshakeNonce}` + const previewShellUrl = $derived(sandboxed ? sandboxedShellUrl : previewBlobUrl) + // The pop-out of a sandboxed preview: a same-origin page framing the shell. A + // top-level window can't be sandboxed by its opener, and the opener can't address + // an opaque-origin window safely. It relays the frame's messages, and their + // ports, to the editor, and the editor's replies back down. Relayed messages + // carry our origin, so they travel wrapped: listeners that trust a same-origin + // message must not see the isolated app's as their own. + let previewControllerUrl: string | undefined = undefined + onDestroy(() => { + if (previewControllerUrl) URL.revokeObjectURL(previewControllerUrl) + }) + function getPreviewControllerUrl(): string { + previewControllerUrl ??= URL.createObjectURL( + new Blob( + [ + `App preview + + + - - Where dbt projects in this workspace run. A project names a warehouse by name in its descriptor (profile.warehouse) and reaches - {DEFAULT_WAREHOUSE} when it names none, so a project carries no - connection of its own. The name is also what its tables are keyed on in the asset graph (dbt://{DEFAULT_WAREHOUSE}/schema/table), so two projects on one warehouse share their nodes. Each entry points either at one of - Windmill's own connection resources, whose fields are translated into a dbt target, or at a - dbt_profile resource, which carries a - profiles.yml target as it is and so reaches any adapter dbt has. Configuring - one here is what makes it available: anyone who may run a dbt script builds with it and reads its models, - without being granted the resource, the same bargain workspace object storage makes. - +profile.warehouse in its descriptor, and gets ${DEFAULT_WAREHOUSE} when it names none. Point each warehouse at a Windmill connection resource, or at a dbt_profile resource holding a profiles.yml target for any other adapter. Anyone who can run a dbt script builds with these warehouses without being granted the resource.`} + link="https://www.windmill.dev/docs/getting_started/scripts_quickstart/dbt" +/> @@ -154,14 +146,10 @@ class="min-w-24" /> - - +
+ +
From 71901852fb3cc40515d6675a0a88cd158e942d56 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 7 Oct 2026 11:57:03 +0200 Subject: [PATCH 04/18] feat: set up a new app in one modal over its AI session, fix its deploy (#11582) * fix: set up a new app before starting with AI and follow its deploy Co-Authored-By: Claude Opus 5.5 (1M context) * feat: set up a new app in one modal over its ai session Co-Authored-By: Claude Opus 5.5 (1M context) * fix: show schema creation progress and put the new app modal over the page Co-Authored-By: Claude Opus 5.5 (1M context) * fix: say when no worker can create a new app's schema instead of waiting Co-Authored-By: Claude Opus 5.5 (1M context) * fix: drop the summary from the new app modal Co-Authored-By: Claude Opus 5.5 (1M context) * feat: list existing tables only under an existing schema, explore from the schema row Co-Authored-By: Claude Opus 5.5 (1M context) * feat: start a new app from two cards, with ai recommended Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- .../components/common/ViewportOverlays.svelte | 10 + .../components/common/overlayHost.svelte.ts | 6 + .../copilot/chat/global/core.test.ts | 1 + .../components/copilot/chat/global/core.ts | 28 +- .../raw_apps/RawAppDataTableDrawer.svelte | 9 +- .../raw_apps/RawAppTemplatePicker.svelte | 661 ++++++++++-------- .../components/sessions/PreviewTabHost.svelte | 16 +- .../sessions/RawAppEditorView.svelte | 93 ++- .../sessions/SessionEditorTarget.svelte | 10 +- .../sessions/sessionPreviewTabs.svelte.ts | 20 + .../sessions/sessionPreviewTabs.test.ts | 15 + .../sessions/sessionRuntime.svelte.ts | 47 +- .../sessions/sessionSwitch.svelte.ts | 20 +- .../(root)/(logged)/apps_raw/add/+page.ts | 37 +- .../apps_raw/edit/[...path]/+page.svelte | 56 +- 15 files changed, 639 insertions(+), 390 deletions(-) create mode 100644 frontend/src/lib/components/common/ViewportOverlays.svelte diff --git a/frontend/src/lib/components/common/ViewportOverlays.svelte b/frontend/src/lib/components/common/ViewportOverlays.svelte new file mode 100644 index 0000000000..59dc2e55b6 --- /dev/null +++ b/frontend/src/lib/components/common/ViewportOverlays.svelte @@ -0,0 +1,10 @@ + + +{@render children?.()} diff --git a/frontend/src/lib/components/common/overlayHost.svelte.ts b/frontend/src/lib/components/common/overlayHost.svelte.ts index e556b51e67..824e239519 100644 --- a/frontend/src/lib/components/common/overlayHost.svelte.ts +++ b/frontend/src/lib/components/common/overlayHost.svelte.ts @@ -25,6 +25,12 @@ export function setOverlayHost(host: OverlayHostContext) { setContext(OVERLAY_HOST_KEY, host) } +/** Hand this subtree's overlays back to the viewport, for one that speaks for the whole + * page rather than the pane it happens to be opened from. */ +export function clearOverlayHost() { + setContext(OVERLAY_HOST_KEY, undefined) +} + export function getOverlayHost(): OverlayHostContext | undefined { return getContext(OVERLAY_HOST_KEY) } diff --git a/frontend/src/lib/components/copilot/chat/global/core.test.ts b/frontend/src/lib/components/copilot/chat/global/core.test.ts index dd89a1fbec..692c78bfb8 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.test.ts @@ -4709,6 +4709,7 @@ describe('global AI tools', () => { expect(onDeployed).toHaveBeenCalledWith({ sessionId: 'sess-123', kind: 'raw_app', + storagePath: 'f/apps/report', path: 'f/apps/report' }) } finally { diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index e75f15d1eb..41bbad5abe 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -5038,6 +5038,9 @@ function getSessionScreenshot(sessionId: string | undefined): Promise void @@ -8651,24 +8654,29 @@ async function deployDraft( // synthetic storage key never exists deployed, so the entry would otherwise // stop matching anything after the draft is gone. const deployedKind = itemKindFor(type, triggerKind) + const draftStoragePath = getGlobalDraftStoragePath(workspace, type, path, triggerKind) if (deployedKind) { - toolCallbacks.onItemDeployed?.( - deployedKind, - getGlobalDraftStoragePath(workspace, type, path, triggerKind), - deployedPath - ) + toolCallbacks.onItemDeployed?.(deployedKind, draftStoragePath, deployedPath) } - // Reload the session preview if it's open on the deployed item. Map the - // deploy type to the preview kind — a raw app deploys under 'app' but the - // preview addresses it as 'raw_app'; non-previewable types map to undefined. + // Reload the session preview if it's open on the deployed item, which its editor + // knows by the draft's storage path. Map the deploy type to the preview kind — a + // raw app deploys under 'app' but the preview addresses it as 'raw_app'; + // non-previewable types map to undefined. const previewKindByType: Partial> = { script: 'script', flow: 'flow', app: 'raw_app' } const kind = previewKindByType[type] - if (kind) deployedInSessionHandler?.({ sessionId, kind, path }) + if (kind) { + deployedInSessionHandler?.({ + sessionId, + kind, + storagePath: draftStoragePath, + path: deployedPath + }) + } toolCallbacks.setToolStatus(toolId, { content: `Deployed ${type} "${path}"`, @@ -8942,8 +8950,6 @@ export function prepareGlobalUserMessage( content += `path: ${activeEditor.path}\n` content += `isLiveDraft: true\n` if (activeEditor.isNew) { - // The template only lives in the open editor until the first edit saves it, so - // init_app would see no draft and overwrite it with a second app. content += 'isNew: true — the user just started this item from the new-item builder; it holds the starter template and is the item to build. Edit it in place; do not create another one.\n' } diff --git a/frontend/src/lib/components/raw_apps/RawAppDataTableDrawer.svelte b/frontend/src/lib/components/raw_apps/RawAppDataTableDrawer.svelte index 7ccf89a275..af0af7ed5b 100644 --- a/frontend/src/lib/components/raw_apps/RawAppDataTableDrawer.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppDataTableDrawer.svelte @@ -256,11 +256,14 @@ selectedRole = undefined } - export function openDrawer() { + /** Opens on `at` when given, a data table and optionally one of its schemas. */ + export function openDrawer(at?: { datatable: string; schema?: string }) { resetSelection() - selectedSchemaKey = undefined + selectedSchemaKey = at?.schema selectedTableKey = undefined - selectDatatable(datatables.current.includes('main') ? 'main' : datatables.current[0]) + selectDatatable( + at?.datatable ?? (datatables.current.includes('main') ? 'main' : datatables.current[0]) + ) expand = false open = true } diff --git a/frontend/src/lib/components/raw_apps/RawAppTemplatePicker.svelte b/frontend/src/lib/components/raw_apps/RawAppTemplatePicker.svelte index 27ebdbda85..0928ddce7a 100644 --- a/frontend/src/lib/components/raw_apps/RawAppTemplatePicker.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppTemplatePicker.svelte @@ -6,13 +6,13 @@ List, Ban, ExternalLinkIcon, - Loader2, Code, - MessagesSquare, - WandSparkles + Database, + Loader2 } from 'lucide-svelte' - import { AIBtnClasses } from '$lib/components/copilot/chat/AIButtonStyle' - import type { Policy } from '$lib/gen' + import { WorkerService, type Policy } from '$lib/gen' + import MissingWorkerTagAlert from '$lib/components/jobs/MissingWorkerTagAlert.svelte' + import { hasWorkerForTag, queuedWithoutWorkerMessage } from '$lib/components/jobs/missingWorker' import { superadmin, userStore } from '$lib/stores' import { base } from '$lib/base' import { sendUserToast } from '$lib/toast' @@ -22,8 +22,7 @@ import Select from '$lib/components/select/Select.svelte' import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte' import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte' - import { Alert } from '$lib/components/common' - import { prefersSessionHandoff } from '$lib/components/copilot/chat/global/gate' + import { Alert, Badge } from '$lib/components/common' import { copilotInfo, copilotWorkspace } from '$lib/aiStore' import { loadCopilot } from '$lib/components/copilot/loadCopilot' import { react18Template, react19Template, svelte5Template } from './templates' @@ -52,11 +51,10 @@ files: Record runnables: Record data: RawAppData - summary: string policy: Policy } - /** `ai` hands the new app to the AI right away; `code` opens the editor on the setup's choices. */ + /** Where the set-up app opens: `ai` hands it to the AI, `code` opens it in the editor. */ export type RawAppBuildMode = 'ai' | 'code' let { @@ -68,18 +66,16 @@ } = $props() const templates = [ - { name: 'React 19', icon: 'tsx', files: react19Template }, + { name: 'React 19', icon: 'tsx', files: react19Template, recommended: true }, { name: 'React 18', icon: 'tsx', files: react18Template }, { name: 'Svelte 5', icon: 'svelte', files: svelte5Template } ] - let step = $state<'choice' | 'setup'>('choice') let selectedTemplateIndex = $state(0) let selectedDatatable = $state(undefined) let schemaMode = $state<'none' | 'new' | 'existing'>('new') let selectedSchema = $state(undefined) let newSchemaName = $state('') - let appSummary = $state('') let preWhitelistedTables = $state([]) /** The role each pre-whitelisted table's data table was browsed as. */ let preWhitelistedRoles = $state>({}) @@ -271,12 +267,7 @@ // would announce AI as unconfigured while it is merely unknown. Gate on the // config describing opWs, and load it here so the claim owns its own evidence. const aiConfigLoaded = $derived(!!opWs && $copilotWorkspace === opWs) - // Say where the card leads: the route hands the app to a fresh AI session for - // everyone who has one, and opens the docked chat for the rest. - const handsOffToSession = $derived(prefersSessionHandoff($userStore?.operator)) const isAiEnabled = $derived(aiConfigLoaded && $copilotInfo.enabled) - // The AI starts on the default data table and its role, so both must have answered. - const dataSettled = $derived(hasNoDatatables || (rolesSettled && accessSettled)) $effect(() => { if (open && opWs && !aiConfigLoaded) { @@ -285,9 +276,11 @@ }) // With AI turned off for the workspace there is nothing to choose between. - const shownStep = $derived($copilotInfo.workspaceDisabled ? 'setup' : step) - const choiceCardClasses = - 'h-full flex-col items-start justify-start gap-3 rounded-lg p-6 text-left whitespace-normal' + const aiOffered = $derived(!$copilotInfo.workspaceDisabled) + + const canStart = $derived( + !!templates[selectedTemplateIndex] && !newSchemaAlreadyExists && rolesSettled && accessSettled + ) function appPolicy(): Policy { return { @@ -299,60 +292,103 @@ } } - /** The AI builds on the default template and the default data table: it picks the framework - * and creates the tables it needs itself. */ - function buildWithAI() { - const datatable = usableDatatable - const role = rolesAnswered ? effectiveRole : undefined - open = false - onStart( - { - files: templates[0].files, - runnables: {}, - data: { - tables: [], - datatable, - schema: undefined, - roles: datatable && role ? { [datatable]: role } : undefined - }, - summary: '', - policy: appPolicy() - }, - 'ai' + const startCardClasses = + 'relative h-full flex-col items-start justify-start gap-1.5 rounded-lg p-3 text-left whitespace-normal' + + async function createSchema(input: { + workspace: string + datatable: string + schema: string + role: string | undefined + migrationRole: string | undefined + }) { + try { + const { dbSchemaOpsWithPreviewScripts } = await import('$lib/components/dbOps') + const dbOps = dbSchemaOpsWithPreviewScripts({ + workspace: input.workspace, + input: { + type: 'database', + resourceType: 'postgresql', + resourcePath: `datatable://${input.datatable}`, + role: input.role, + migrationRole: input.migrationRole + } + }) + await dbOps.onCreateSchema({ schema: input.schema }) + } catch (e) { + console.error('Failed to create schema:', e) + sendUserToast(`Failed to create schema ${input.schema}: ${e}`, true) + } + } + + // The start that is in flight, and the schema it is waiting on: creating one is a job, + // which can sit in the queue long enough for a silent modal to look dead. + let starting = $state<{ mode: RawAppBuildMode; schema?: string } | undefined>(undefined) + + const SCHEMA_JOB_TAG = 'postgresql' + + /** Whether a worker can pick up the job that creates a schema. Only a definite "no" + * counts: with per-workspace default tags the job runs on a variant of the tag, and a + * failed lookup establishes nothing. */ + async function schemaJobsServed(workspace: string): Promise { + try { + if (await WorkerService.isDefaultTagsPerWorkspace()) return true + return await hasWorkerForTag(workspace, SCHEMA_JOB_TAG) + } catch { + return true + } + } + + // Existing tables go with an existing schema: under the other modes the list is not shown, + // and what is not shown is not saved. + const tablesToUse = $derived(schemaMode === 'existing' ? preWhitelistedTables : []) + + function exploreDatatable() { + dataTableDrawer?.openDrawer( + selectedDatatable + ? { + datatable: selectedDatatable, + schema: schemaMode === 'existing' ? selectedSchema : undefined + } + : undefined ) } - async function start() { + async function start(mode: RawAppBuildMode) { + if (starting) return const template = templates[selectedTemplateIndex] if (schemaMode === 'new' && newSchemaName && usableDatatable && opWs) { - try { - const { dbSchemaOpsWithPreviewScripts } = await import('$lib/components/dbOps') - const dbOps = dbSchemaOpsWithPreviewScripts({ - workspace: opWs, - input: { - type: 'database', - resourceType: 'postgresql', - resourcePath: `datatable://${usableDatatable}`, - role: effectiveRole, - migrationRole: defaultMigrationRole( - usableDatatable, - roles.current.permissioned, - roles.current.defaultRole - ) - } - }) - await dbOps.onCreateSchema({ schema: newSchemaName }) - } catch (e) { - console.error('Failed to create schema:', e) - sendUserToast(`Failed to create schema: ${e}`, true) + starting = { mode, schema: newSchemaName } + const creation = createSchema({ + workspace: opWs, + datatable: usableDatatable, + schema: newSchemaName, + role: effectiveRole, + migrationRole: defaultMigrationRole( + usableDatatable, + roles.current.permissioned, + roles.current.defaultRole + ) + }) + if (await schemaJobsServed(opWs)) { + await creation + } else { + // The job stays queued for as long as no worker serves its tag: waiting on it + // would hold the app back for nothing, so say why the schema is missing instead. + sendUserToast( + `Schema "${newSchemaName}" is not created yet. ${queuedWithoutWorkerMessage(SCHEMA_JOB_TAG)}`, + true + ) } } - const formattedTables = preWhitelistedTables.map(formatDataTableRef) + starting = undefined + + const formattedTables = tablesToUse.map(formatDataTableRef) const keepsDatatable = usableDatatable !== undefined // The roles shown, for the data tables the app ends up using. - const usedDatatables = new Set(preWhitelistedTables.map((t) => t.datatable)) + const usedDatatables = new Set(tablesToUse.map((t) => t.datatable)) if (keepsDatatable) usedDatatables.add(usableDatatable!) const shownRoles = Object.entries(pickerRoles ?? {}).filter(([dt]) => usedDatatables.has(dt)) const appRoles = shownRoles.length > 0 ? Object.fromEntries(shownRoles) : undefined @@ -371,10 +407,9 @@ files: template.files, runnables: {}, data, - summary: appSummary.trim(), policy: appPolicy() }, - 'code' + mode ) } @@ -385,240 +420,181 @@ Modal closes its own UI but the picker's `open` prop stays true, so the route's `templatePicker → false` watcher never fires and autosave stays suspended after the dismissal. --> - - {#if shownStep === 'choice'} -
-
- - + +
+
+

Framework

+
+ {#each templates as t, i} + + {/each}
-

You can always switch later.

- {#if aiConfigLoaded && !isAiEnabled} - - You can still create an app manually but using AI is highly recommended. +
+ +
+

Data configuration

+ + {#if hasNoDatatables} + + You can still create an app, but for data storage you won't be able to use data tables + which are highly recommended.
{#if $userStore?.is_admin} - Configure AI in + Configure datatables in workspace settings - - {#if $superadmin} - or - instance settings - - {/if} to enable this feature. - {:else if $superadmin} - Configure AI in - instance settings to enable this feature. {:else} - Ask your workspace admin to configure AI in workspace settings to enable this feature. + Ask your workspace admin to configure datatables in workspace settings to enable this + feature. {/if}
- {/if} -
- {:else} -
-
-

Summary

- -
- -
-

Framework

-
- {#each templates as t, i} - - {/each} -
-
- -
-

Data configuration

- - {#if hasNoDatatables} - - You can still create an app, but for data storage you won't be able to use data tables - which are highly recommended. -
- {#if $userStore?.is_admin} - Configure datatables in - workspace settings - to enable this feature. - {:else} - Ask your workspace admin to configure datatables in workspace settings to enable - this feature. - {/if} -
- {:else} -
-
- Default settings for new tables -
-
-
- -
+ {:else} +
+ +
+ Default settings for new tables +
+
+
+ +
+ ({ value: r, label: r }))} + bind:value={() => selectedRole, pickRole} + clearable={false} + placeholder="Role" size="sm" class="w-40" /> - {#if showRolePicker} - - as - -
- {/if} -
- {#if newSchemaAlreadyExists} - Schema "{newSchemaName}" already exists + {rolesUnknown + ? 'could not read its roles' + : noUsableRole + ? 'no role you can use' + : 'could not reach it'}; the app is created without a default data + table + {/if}
+
+ Schema +
+
+ + {#snippet children({ item })} + + + + {/snippet} + +
+