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} + +
+