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}
-
- {#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}
- Click on
-
-
-
- in the editor to {handsOffToSession ? 'switch to AI sessions' : 'open the AI chat'} later
- on.
-
+ {#if aiOffered && aiConfigLoaded && !isAiEnabled}
+
+ You can still create an app manually but using AI is highly recommended.
+
+ {#if $userStore?.is_admin}
+ Configure AI 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.
{/if}
- {#if !$copilotInfo.workspaceDisabled}
-
+ {/if}
+
+ {#if aiOffered}
+