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) <noreply@anthropic.com>

* feat: set up a new app in one modal over its ai session

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: show schema creation progress and put the new app modal over the page

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: say when no worker can create a new app's schema instead of waiting

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: drop the summary from the new app modal

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat: list existing tables only under an existing schema, explore from the schema row

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat: start a new app from two cards, with ai recommended

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-10-07 11:57:03 +02:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 38aa4eaa12
commit 71901852fb
15 changed files with 639 additions and 390 deletions
@@ -0,0 +1,10 @@
<script lang="ts">
import type { Snippet } from 'svelte'
import { clearOverlayHost } from './overlayHost.svelte'
let { children }: { children?: Snippet } = $props()
clearOverlayHost()
</script>
{@render children?.()}
@@ -25,6 +25,12 @@ export function setOverlayHost(host: OverlayHostContext) {
setContext<OverlayHostContext>(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<OverlayHostContext | undefined>(OVERLAY_HOST_KEY, undefined)
}
export function getOverlayHost(): OverlayHostContext | undefined {
return getContext<OverlayHostContext | undefined>(OVERLAY_HOST_KEY)
}
@@ -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 {
@@ -5038,6 +5038,9 @@ function getSessionScreenshot(sessionId: string | undefined): Promise<SessionScr
export type DeployedInSessionHandler = (req: {
sessionId: string | undefined
kind: 'script' | 'flow' | 'raw_app'
/** The path the item's draft, and so its open editor, is keyed on. */
storagePath: string
/** Where the deploy landed. */
path: string
}) => 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<Record<WorkspaceItemType, 'script' | 'flow' | 'raw_app'>> = {
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'
}
@@ -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
}
@@ -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<string, string>
runnables: Record<string, Runnable>
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<string | undefined>(undefined)
let schemaMode = $state<'none' | 'new' | 'existing'>('new')
let selectedSchema = $state<string | undefined>(undefined)
let newSchemaName = $state('')
let appSummary = $state('')
let preWhitelistedTables = $state<DataTableRef[]>([])
/** The role each pre-whitelisted table's data table was browsed as. */
let preWhitelistedRoles = $state<Record<string, string>>({})
@@ -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<boolean> {
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
)
}
</script>
@@ -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. -->
<Modal kind="X" bind:open title={shownStep === 'choice' ? 'New app' : 'New app setup'}>
{#if shownStep === 'choice'}
<div class="flex flex-col gap-4 min-w-sm">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Button
variant="default"
onClick={buildWithAI}
disabled={!isAiEnabled || !dataSettled}
btnClasses="{choiceCardClasses} border-ai/30 bg-ai/5 hover:bg-ai/10 disabled:opacity-50 disabled:cursor-not-allowed"
>
{#if aiConfigLoaded && dataSettled}
<Sparkles size={28} class="text-ai" />
{:else}
<Loader2 size={28} class="text-ai animate-spin" />
{/if}
<span class="text-base font-semibold text-emphasis">Start with AI</span>
<span class="text-xs text-secondary">
{handsOffToSession
? 'Describe your app in an AI session and let it build the frontend, backend and tables.'
: 'Describe your app to the AI chat and let it build the frontend, backend and tables.'}
</span>
</Button>
<Button variant="default" onClick={() => (step = 'setup')} btnClasses={choiceCardClasses}>
<Code size={28} class="text-primary" />
<span class="text-base font-semibold text-emphasis">Start with code editor</span>
<span class="text-xs text-secondary">
Pick a framework and data configuration, then write the app in the editor.
</span>
</Button>
<Modal kind="X" bind:open title="New app">
<div class="flex flex-col gap-6 min-w-sm">
<div>
<h2 class="text-xs font-semibold text-emphasis mb-3">Framework</h2>
<div class="flex flex-wrap gap-3">
{#each templates as t, i}
<button
onclick={() => (selectedTemplateIndex = i)}
class="relative w-20 h-[4.5rem] flex justify-between py-3 flex-col {selectedTemplateIndex ===
i
? 'bg-surface-accent-selected border border-accent'
: ''} hover:bg-surface-hover border rounded-lg transition-all"
>
<div class="w-full flex items-center justify-center">
<FileEditorIcon file={'.' + t.icon} size={22} />
</div>
<div class="center-center w-full text-xs text-secondary">{t.name}</div>
{#if t.recommended}
<div class="absolute -top-3 left-1/2 -translate-x-1/2">
<Badge color="blue" small>Recommended</Badge>
</div>
{/if}
</button>
{/each}
</div>
<p class="text-xs text-hint text-center">You can always switch later.</p>
{#if aiConfigLoaded && !isAiEnabled}
<Alert type="info" title="AI is not configured.">
You can still create an app manually but using AI is highly recommended.
</div>
<div class="pt-6">
<h2 class="text-xs font-semibold text-emphasis mb-1">Data configuration</h2>
{#if hasNoDatatables}
<Alert type="warning" title="No datatables configured.">
You can still create an app, but for data storage you won't be able to use data tables
which are <b>highly recommended</b>.
<br />
{#if $userStore?.is_admin}
Configure AI in
Configure datatables in
<a
href="{base}/workspace_settings?tab=ai"
href="/workspace_settings?tab=windmill_data_tables"
target="_blank"
class="inline-flex items-center gap-1 font-semibold"
class="inline-flex items-center gap-1"
>workspace settings <ExternalLinkIcon size={16} />
</a>
{#if $superadmin}
or
<a
href="{base}/?workspace=admins#superadmin-settings"
target="_blank"
class="inline-flex items-center gap-1 font-semibold"
>instance settings <ExternalLinkIcon size={16} />
</a>
{/if} to enable this feature.
{:else if $superadmin}
Configure AI in
<a
href="{base}/?workspace=admins#superadmin-settings"
target="_blank"
class="inline-flex items-center gap-1 font-semibold"
>instance settings <ExternalLinkIcon size={16} />
</a> 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}
</Alert>
{/if}
</div>
{:else}
<div class="flex flex-col gap-6 min-w-sm">
<div>
<h2 class="text-xs font-semibold text-emphasis mb-1">Summary</h2>
<TextInput
bind:value={appSummary}
inputProps={{
placeholder: "Brief description of the app (e.g., 'Todo list with authentication')"
}}
/>
</div>
<div class="pt-6">
<h2 class="text-xs font-semibold text-emphasis mb-1">Framework</h2>
<div class="flex flex-wrap gap-3">
{#each templates as t, i}
<button
onclick={() => (selectedTemplateIndex = i)}
class="w-24 h-24 flex justify-between py-5 flex-col {selectedTemplateIndex === i
? 'bg-surface-accent-selected border border-accent'
: ''} hover:bg-surface-hover border rounded-lg transition-all"
>
<div class="w-full flex items-center justify-center">
<FileEditorIcon file={'.' + t.icon} size={32} />
</div>
<div class="center-center w-full text-sm text-secondary">{t.name}</div>
</button>
{/each}
</div>
</div>
<div class="pt-6">
<h2 class="text-xs font-semibold text-emphasis mb-1">Data configuration</h2>
{#if hasNoDatatables}
<Alert type="warning" title="No datatables configured.">
You can still create an app, but for data storage you won't be able to use data tables
which are <b>highly recommended</b>.
<br />
{#if $userStore?.is_admin}
Configure datatables in
<a
href="/workspace_settings?tab=windmill_data_tables"
target="_blank"
class="inline-flex items-center gap-1"
>workspace settings <ExternalLinkIcon size={16} />
</a> to enable this feature.
{:else}
Ask your workspace admin to configure datatables in workspace settings to enable
this feature.
{/if}
</Alert>
{:else}
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-1">
<span class="text-xs text-secondary mb-1 block"
>Default settings for new tables</span
>
<div class="flex flex-col gap-4 rounded-md p-4 border">
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-1">
<label class="text-xs text-emphasis font-semibold" for="datatable"
>Datatable</label
>
<div class="flex flex-row items-center gap-2">
{:else}
<div class="flex flex-col gap-4">
<MissingWorkerTagAlert
tag={SCHEMA_JOB_TAG}
subject="Data table queries and schema changes"
/>
<div class="flex flex-col gap-1">
<span class="text-xs text-secondary mb-1 block">Default settings for new tables</span>
<div class="flex flex-col gap-4 rounded-md p-4 border">
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-1">
<label class="text-xs text-emphasis font-semibold" for="datatable"
>Datatable</label
>
<div class="flex flex-row items-center gap-2">
<Select
id="datatable"
disablePortal
items={datatableItems}
bind:value={selectedDatatable}
placeholder="Datatable"
size="sm"
class="w-40"
/>
{#if showRolePicker}
<!-- Reads as one phrase, "main as analyst", so the role needs no label. -->
<span class="text-xs text-secondary">as</span>
<Select
id="datatable"
id="datatable-role"
disablePortal
items={datatableItems}
bind:value={selectedDatatable}
placeholder="Datatable"
items={loadedRoles.map((r) => ({ value: r, label: r }))}
bind:value={() => selectedRole, pickRole}
clearable={false}
placeholder="Role"
size="sm"
class="w-40"
/>
{#if showRolePicker}
<!-- Reads as one phrase, "main as analyst", so the role needs no label. -->
<span class="text-xs text-secondary">as</span>
<Select
id="datatable-role"
disablePortal
items={loadedRoles.map((r) => ({ value: r, label: r }))}
bind:value={() => selectedRole, pickRole}
clearable={false}
placeholder="Role"
size="sm"
class="w-40"
/>
{/if}
{#if noUsableRole || rolesUnknown || accessUnknown}
<span
class="text-xs text-red-600 dark:text-red-400"
title={access.current.error}
>
{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
</span>
{/if}
</div>
</div>
<div>
<span class="text-xs text-emphasis font-semibold">Schema</span>
<div class="flex flex-row gap-1 w-full items-center">
<div>
<ToggleButtonGroup bind:selected={schemaMode} noWFull>
{#snippet children({ item })}
<ToggleButton value="none" label="None" icon={Ban} {item} size="sm" />
<ToggleButton
value="new"
label="New"
icon={Plus}
disabled={!canCreateSchema}
tooltip={canCreateSchema
? undefined
: noUsableRole
? `You can use no role of ${selectedDatatable}`
: accessUnknown
? `Could not read what may be created in ${selectedDatatable}`
: `${effectiveRole ?? 'This connection'} cannot create schemas in ${selectedDatatable}`}
{item}
size="sm"
/>
<ToggleButton
value="existing"
label="Existing"
icon={List}
{item}
size="sm"
/>
{/snippet}
</ToggleButtonGroup>
</div>
{#if schemaMode === 'new'}
<TextInput
bind:value={newSchemaName}
inputProps={{
placeholder: 'Schema name',
oninput: () => (userEditedSchemaName = true)
}}
class="flex-1"
error={newSchemaAlreadyExists}
size="sm"
/>
{:else if schemaMode === 'existing'}
<div class="flex-1">
<Select
disablePortal
items={schemaItems}
bind:value={selectedSchema}
placeholder="Schema"
size="sm"
/>
</div>
{/if}
</div>
{#if newSchemaAlreadyExists}
<span class="text-xs text-red-500"
>Schema "{newSchemaName}" already exists</span
{/if}
{#if noUsableRole || rolesUnknown || accessUnknown}
<span
class="text-xs text-red-600 dark:text-red-400"
title={access.current.error}
>
{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
</span>
{/if}
</div>
</div>
<div>
<span class="text-xs text-emphasis font-semibold">Schema</span>
<div class="flex flex-row gap-1 w-full items-center">
<div>
<ToggleButtonGroup bind:selected={schemaMode} noWFull>
{#snippet children({ item })}
<ToggleButton value="none" label="None" icon={Ban} {item} size="sm" />
<ToggleButton
value="new"
label="New"
icon={Plus}
disabled={!canCreateSchema}
tooltip={canCreateSchema
? undefined
: noUsableRole
? `You can use no role of ${selectedDatatable}`
: accessUnknown
? `Could not read what may be created in ${selectedDatatable}`
: `${effectiveRole ?? 'This connection'} cannot create schemas in ${selectedDatatable}`}
{item}
size="sm"
/>
<ToggleButton
value="existing"
label="Existing"
icon={List}
{item}
size="sm"
/>
{/snippet}
</ToggleButtonGroup>
</div>
<Button
variant="default"
unifiedSize="sm"
iconOnly
startIcon={{ icon: Database }}
title="Explore {selectedDatatable ?? 'the data table'}"
onclick={exploreDatatable}
/>
{#if schemaMode === 'new'}
<TextInput
bind:value={newSchemaName}
inputProps={{
placeholder: 'Schema name',
oninput: () => (userEditedSchemaName = true)
}}
class="flex-1"
error={newSchemaAlreadyExists}
size="sm"
/>
{:else if schemaMode === 'existing'}
<div class="flex-1">
<Select
disablePortal
items={schemaItems}
bind:value={selectedSchema}
placeholder="Schema"
size="sm"
/>
</div>
{/if}
</div>
{#if newSchemaAlreadyExists}
<span class="text-xs text-red-500"
>Schema "{newSchemaName}" already exists</span
>
{/if}
</div>
</div>
</div>
</div>
{#if schemaMode === 'existing'}
<div class="pt-6">
<RawAppDataTableList
dataTableRefs={preWhitelistedTables}
@@ -627,51 +603,122 @@
roles={pickerRoles}
standalone
hideDefaultSelector
onAdd={() => dataTableDrawer?.openDrawer()}
onAdd={exploreDatatable}
onRemove={(index) => {
preWhitelistedTables = preWhitelistedTables.filter((_, i) => i !== index)
}}
/>
</div>
</div>
{/if}
</div>
{/if}
</div>
{/if}
</div>
<div class="pt-6 flex items-center justify-end gap-3">
{#if isAiEnabled}
{@const AiIcon = handsOffToSession ? MessagesSquare : WandSparkles}
<p class="mr-auto text-xs text-hint">
Click on
<span
class="mx-0.5 inline-flex h-5 w-5 items-center justify-center rounded-md border align-middle {AIBtnClasses(
'default'
)}"
aria-label="AI"
>
<AiIcon size={12} />
</span>
in the editor to {handsOffToSession ? 'switch to AI sessions' : 'open the AI chat'} later
on.
</p>
{#if aiOffered && aiConfigLoaded && !isAiEnabled}
<Alert type="info" title="AI is not configured.">
You can still create an app manually but using AI is highly recommended.
<br />
{#if $userStore?.is_admin}
Configure AI in
<a
href="{base}/workspace_settings?tab=ai"
target="_blank"
class="inline-flex items-center gap-1 font-semibold"
>workspace settings <ExternalLinkIcon size={16} />
</a>
{#if $superadmin}
or
<a
href="{base}/?workspace=admins#superadmin-settings"
target="_blank"
class="inline-flex items-center gap-1 font-semibold"
>instance settings <ExternalLinkIcon size={16} />
</a>
{/if} to enable this feature.
{:else if $superadmin}
Configure AI in
<a
href="{base}/?workspace=admins#superadmin-settings"
target="_blank"
class="inline-flex items-center gap-1 font-semibold"
>instance settings <ExternalLinkIcon size={16} />
</a> to enable this feature.
{:else}
Ask your workspace admin to configure AI in workspace settings to enable this feature.
{/if}
{#if !$copilotInfo.workspaceDisabled}
<Button variant="subtle" unifiedSize="md" onclick={() => (step = 'choice')}>Back</Button
</Alert>
{/if}
{#if aiOffered}
<div class="pt-6 flex flex-col gap-2">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<Button
variant="default"
onClick={() => start('ai')}
disabled={!canStart || !isAiEnabled || starting !== undefined}
btnClasses="{startCardClasses} border-ai/40 bg-ai/5 hover:bg-ai/10 disabled:opacity-50 disabled:cursor-not-allowed"
>
<span class="flex w-full items-center gap-2">
{#if !aiConfigLoaded || starting?.mode === 'ai'}
<Loader2 size={18} class="text-ai animate-spin" />
{:else}
<Sparkles size={18} class="text-ai" />
{/if}
<span class="text-sm font-semibold text-emphasis whitespace-nowrap"
>Start with AI</span
>
</span>
<span class="text-xs font-normal text-secondary">
Describe your app and let the AI build it.
</span>
<span class="absolute -top-3 right-3">
<Badge color="blue" small>Recommended</Badge>
</span>
</Button>
<Button
variant="default"
onClick={() => start('code')}
disabled={!canStart || starting !== undefined}
btnClasses="{startCardClasses} disabled:opacity-50 disabled:cursor-not-allowed"
>
<span class="flex w-full items-center gap-2">
{#if starting?.mode === 'code'}
<Loader2 size={18} class="text-primary animate-spin" />
{:else}
<Code size={18} class="text-primary" />
{/if}
<span class="text-sm font-semibold text-emphasis whitespace-nowrap">
Start with code editor
</span>
</span>
<span class="text-xs font-normal text-secondary">
Write the app yourself in the editor.
</span>
</Button>
</div>
<p class="text-xs text-center {starting?.schema ? 'text-secondary' : 'text-hint'}">
{starting?.schema
? `Creating schema ${starting.schema}…`
: 'You can always switch later.'}
</p>
</div>
{:else}
<div class="pt-6 flex items-center justify-end gap-3">
{#if starting?.schema}
<p class="mr-auto text-xs text-secondary">Creating schema {starting.schema}…</p>
{/if}
<Button
variant="accent"
unifiedSize="md"
onclick={() => start()}
disabled={!templates[selectedTemplateIndex] ||
newSchemaAlreadyExists ||
!rolesSettled ||
!accessSettled}
onclick={() => start('code')}
loading={starting?.mode === 'code'}
disabled={!canStart || starting !== undefined}
>
Create app
</Button>
</div>
</div>
{/if}
{/if}
</div>
</Modal>
{/if}
@@ -686,6 +733,8 @@
...refs
]
preWhitelistedRoles = { ...preWhitelistedRoles, ...browsedRoles }
// Tables picked while exploring are existing ones to use: show them.
if (refs.length > 0) schemaMode = 'existing'
if (selectedDatatable !== undefined && browsedRoles[selectedDatatable] !== undefined) {
selectedRole = browsedRoles[selectedDatatable]
}
@@ -9,7 +9,7 @@
type Session,
type SessionPreviewTab
} from './sessionState.svelte'
import type { NewRawAppSeed, SessionRuntime } from './sessionRuntime.svelte'
import type { SessionRuntime } from './sessionRuntime.svelte'
import { Loader2 } from 'lucide-svelte'
import {
resolvePreviewTab,
@@ -77,16 +77,10 @@
slot.kind === 'editor' || slot.kind === 'viewer' ? slot.path : undefined
)
const mode = $derived(slot.kind === 'viewer' ? 'view' : 'edit')
// `new_draft` marks a raw app opened from the new-app builder before anything was saved.
const newRawApp: NewRawAppSeed | undefined = $derived.by(() => {
if (itemKind !== 'raw_app') return undefined
const params = new URL(tab.url, 'http://x').searchParams
if (params.get('new_draft') !== 'true') return undefined
return {
datatable: params.get('datatable') ?? undefined,
role: params.get('datatable_role') ?? undefined
}
})
// `new_draft` marks a raw app handed over by the new-app builder.
const newRawApp = $derived(
itemKind === 'raw_app' && new URL(tab.url, 'http://x').searchParams.get('new_draft') === 'true'
)
// A pinned deployed version, read from the tab URL rather than passed down: the tab
// URL is the single record of where this tab points.
const pinnedVersion = $derived(
@@ -1,10 +1,22 @@
<script lang="ts">
import { untrack } from 'svelte'
import { tick, untrack } from 'svelte'
import { base } from '$lib/base'
import { goto } from '$lib/navigation'
import { sendUserToast } from '$lib/toast'
import { UserDraft } from '$lib/userDraft.svelte'
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
import RawAppTemplatePicker, {
type RawAppBuildMode,
type RawAppTemplatePickerResult
} from '$lib/components/raw_apps/RawAppTemplatePicker.svelte'
import { runtimeRawAppToDraft } from './appDraftCodec'
import ViewportOverlays from '$lib/components/common/ViewportOverlays.svelte'
import { withWorkspaceParam } from './sessionMode.svelte'
import RawAppEditor from '$lib/components/raw_apps/RawAppEditor.svelte'
import DiffDrawer from '$lib/components/DiffDrawer.svelte'
import type { WorkspaceItem } from '$lib/components/workspacePicker'
import type { NewRawAppSeed, SessionRuntime } from './sessionRuntime.svelte'
import { react19Template } from '$lib/components/raw_apps/templates'
import { removeSession, type SessionRuntime } from './sessionRuntime.svelte'
import { FRAMEWORK_TEMPLATES, STARTER_RUNNABLES } from '$lib/components/raw_apps/templates'
import SessionEditorTarget from './SessionEditorTarget.svelte'
import { runResetToDeployed } from '$lib/userDraftToast'
import { invalidateWorkspaceDrafts } from '$lib/workspaceDrafts.svelte'
@@ -23,7 +35,7 @@
onNavigate,
isActiveSession = true,
active = true,
newRawApp = undefined
newRawApp = false
}: {
runtime: SessionRuntime
path: string
@@ -34,8 +46,8 @@
isActiveSession?: boolean
/** Whether this is the visible preview tab (forwarded as isActiveTab). */
active?: boolean
/** Forwarded to SessionEditorTarget: the tab was opened on a brand-new app. */
newRawApp?: NewRawAppSeed
/** The tab was opened on an app the new-app builder just started. */
newRawApp?: boolean
} = $props()
// This tab's own raw-app cell; each open app editor binds its own store.
@@ -45,15 +57,63 @@
// is a fresh app would steer it away from the work already there.
const untouchedTemplate = $derived.by(() => {
const files = cell.store.val?.files
if (!newRawApp || cell.saved.val || !files) return false
const keys = Object.keys(react19Template)
return (
Object.keys(files).length === keys.length &&
keys.every((k) => files[k] === react19Template[k as keyof typeof react19Template])
)
if (!newRawApp || (cell.saved.val && !cell.saved.val.no_deployed) || !files) return false
const count = Object.keys(files).length
return Object.values(FRAMEWORK_TEMPLATES).some((template: Record<string, string>) => {
const keys = Object.keys(template)
return count === keys.length && keys.every((k) => files[k] === template[k])
})
})
let diffDrawer: DiffDrawer | undefined = $state()
// A new app arrives here before anything is saved, on the starter template: its setup
// is asked over the session it will be built in. Once saved it has been set up, which
// is also what a reload sees.
let setupSettled = $state(false)
const awaitingSetup = $derived(
newRawApp &&
!setupSettled &&
isActiveSession &&
active &&
cell.slot.loadedPath === path &&
!!cell.store.val &&
!cell.saved.val
)
async function onSetup(result: RawAppTemplatePickerResult, mode: RawAppBuildMode) {
setupSettled = true
const val = cell.store.val
if (!val) return
const setUp = {
...val,
files: { ...result.files },
runnables: { ...result.runnables, ...structuredClone(STARTER_RUNNABLES) },
data: result.data,
policy: result.policy
}
cell.store.val = setUp
// Saved now rather than on the autosave debounce: both ways out read it back by path.
const key = { workspace: workspaceId, itemKind: 'raw_app' as const, path }
UserDraft.save('raw_app', path, runtimeRawAppToDraft(setUp), { workspace: workspaceId })
await tick()
await UserDraftDbSyncer.flush(key)
await UserDraft.forcePersist('raw_app', path, { workspace: workspaceId })
const saved = UserDraftDbSyncer.getState(key)
if (saved.state === 'failed') {
sendUserToast(`Could not save the app: ${saved.failureMessage ?? 'unknown error'}`, true)
return
}
if (mode === 'code') {
// The session was opened for this app alone; with nothing said in it, it goes.
const unused = runtime.manager.displayMessages.length === 0
await goto(withWorkspaceParam(`${base}/apps_raw/edit/${path}`, workspaceId))
if (unused) removeSession(runtime.sessionId)
return
}
// Remounts the editor so the preview builds the picked template.
await runtime.loadRawApp(workspaceId, path, true)
}
// Path typed in the editor header, surfaced when it differs from the stored
// path. Mirror it into the runtime draft as `draft_path` so the rename
// mutates this cell's store → the autosave sig changes → the draft is saved
@@ -258,6 +318,13 @@
(cell.store.val?.draft_path || cell.store.val?.path) ?? path}
>
{#snippet editor()}
<!-- Over the whole page, not the preview pane: it decides what the session is for. -->
<ViewportOverlays>
<RawAppTemplatePicker
bind:open={() => awaitingSetup, (open) => (setupSettled = !open)}
onStart={onSetup}
/>
</ViewportOverlays>
{#if cell.store.val}
<!-- newApp: a draft-only app (no_deployed=true) has a truthy synthesized
savedApp but no deployed row, so it must deploy via createApp — keying
@@ -292,7 +359,7 @@
{onNavigate}
condensedHeader={true}
onResetToDeployed={reloadDeployed}
onDeploy={(e) => runtime.itemDeployed(workspaceId, 'raw_app', e.path)}
onDeploy={(e) => runtime.itemDeployed(workspaceId, 'raw_app', path, e.path)}
defaultSidebarCollapsed
sidebarStorageKey="raw-app-sidebar-collapsed-preview"
defaultSplitWithPreview={false}
@@ -3,7 +3,7 @@
import { Loader2 } from 'lucide-svelte'
import type { WorkspaceItem } from '$lib/components/workspacePicker'
import { UserDraft } from '$lib/userDraft.svelte'
import type { NewRawAppSeed, SessionRuntime, SessionTargetKind } from './sessionRuntime.svelte'
import type { SessionRuntime, SessionTargetKind } from './sessionRuntime.svelte'
import { useUserDraftSync, type DraftSyncCodec } from './useUserDraftSync.svelte'
import { makeFlowCodec, makeScriptCodec, makeRawAppCodec } from './sessionDraftCodecs'
import { itemDisplayName } from './previewRouter'
@@ -21,7 +21,7 @@
onNavigate,
isActiveSession = true,
isActiveTab = true,
newRawApp = undefined,
newRawApp = false,
isNew = undefined
}: {
runtime: SessionRuntime
@@ -45,9 +45,9 @@
* tab must not, else chat actions resolve to the wrong item's path.
*/
isActiveTab?: boolean
/** A raw app just started from the new-app builder: when nothing exists at `path` yet
* the editor opens on the template, in memory until the first edit saves it. */
newRawApp?: NewRawAppSeed
/** A raw app the new-app builder just started: when nothing exists at `path` yet
* the editor opens on the starter template. */
newRawApp?: boolean
/** Whether the item is still the template a new-item builder started it on. */
isNew?: () => boolean
} = $props()
@@ -26,6 +26,7 @@ import {
type PreviewTarget
} from './previewRouter'
import type { SessionPreviewTab, SessionTarget } from './sessionState.svelte'
import { sessionTargetHref } from './sessionMode.svelte'
import type { Kind } from '$lib/utils_deployable'
import { pipelineFolderFromBundlePath } from '$lib/pipelinePaths'
import {
@@ -651,6 +652,25 @@ export class SessionPreviewTabs {
this.#flush()
}
/** Follow an item its editor deployed under a new path, in place: the editor is keyed
* on the path, and nothing is left to edit at the old one. */
retargetEditor(from: SessionTarget, to: SessionTarget): void {
const tab = this.#tabs.find((t) => isItemTabFor(t.url, from, 'edit'))
if (!tab) return
// One editor per item: a tab already on the new path keeps it.
const existing = this.#tabs.find((t) => t.id !== tab.id && isItemTabFor(t.url, to, 'edit'))
if (existing) {
const wasActive = this.#activeId === tab.id
this.close(tab.id)
if (wasActive) this.#activeId = existing.id
} else {
const url = sessionTargetHref(to)
if (!url) return
retargetTab(tab, url)
}
this.#flush()
}
closeArtifact(artifactId: string): void {
const tab = this.#tabs.find((t) => parseArtifactRoute(t.url)?.id === artifactId)
if (tab) this.close(tab.id)
@@ -241,6 +241,21 @@ describe('page item tabs', () => {
])
})
it('follows an editor deployed under a new path in place', () => {
const o = owner()
o.open({
type: 'item',
item: { kind: 'app', raw_app: true, path: 'u/me/draft_1', summary: '' }
})
const id = o.tabs[0].id
o.retargetEditor(
{ kind: 'raw_app', path: 'u/me/draft_1' },
{ kind: 'raw_app', path: 'u/me/app' }
)
const url = `${base}/apps_raw/edit/u/me/app`
expect(o.tabs).toEqual([{ id, url, loc: url }])
})
it('restores a tab saved on a row’s drawer as the row it was last on', () => {
const snap = hydratePreviewTabs({
previewTabs: [
@@ -135,9 +135,6 @@ export interface LoadSlot {
export type SessionTargetKind = 'flow' | 'script' | 'raw_app'
/** The data config a raw app opened from the new-app builder starts with. */
export type NewRawAppSeed = { datatable?: string; role?: string }
// The live runtime value a raw-app editor cell binds. Legacy drag-and-drop apps
// are intentionally NOT hosted in the session preview (only code-based raw apps).
export interface RawAppRuntimeValue {
@@ -237,8 +234,9 @@ export interface SessionRuntime {
path: string,
force?: boolean,
deployedOnly?: boolean,
/** Open the starter template when nothing exists at `path` yet (a new-app hand-off). */
seedIfMissing?: NewRawAppSeed
/** Open the starter template when nothing exists at `path` yet: a new app whose
* setup has not been confirmed. Held in the cell only, nothing is saved. */
seedIfMissing?: boolean
): Promise<void>
/** 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. */
@@ -271,7 +269,14 @@ export interface SessionRuntime {
* the deploy just changed. One call rather than a sequence at each deploy site — a
* caller that remembers three of the four leaves a stale draft count or a picker
* that still calls the item a draft. */
itemDeployed(workspace: string, kind: 'script' | 'flow' | 'raw_app', path: string): void
itemDeployed(
workspace: string,
kind: 'script' | 'flow' | 'raw_app',
path: string,
/** Where the deploy landed, when not at `path`: a new item parked at a
* `draft_<uuid>` storage path deploys under the name it was given. */
deployedPath?: string
): void
}
const runtimes = new SvelteMap<string, SessionRuntime>()
@@ -827,7 +832,7 @@ function createRuntime(session: Session): SessionRuntime {
path: string,
force = false,
deployedOnly = false,
seedIfMissing: NewRawAppSeed | undefined = undefined
seedIfMissing = false
) {
const { slot, store, saved } = rawAppCell(path)
if (slot.loadedPath === path && slot.loadedWorkspace === workspace && !force) return
@@ -959,21 +964,12 @@ function createRuntime(session: Session): SessionRuntime {
slot.loadedWorkspace = workspace
} catch (err) {
if (seedIfMissing && !deployedOnly && err instanceof ApiError && err.status === 404) {
// Held in the cell only: the editor's draft sync swallows this first write,
// so nothing is saved until the template is actually changed.
const user = get(userStore)
saved.val = undefined
store.val = {
files: { ...react19Template },
runnables: structuredClone(STARTER_RUNNABLES),
data: {
...DEFAULT_DATA,
datatable: seedIfMissing.datatable,
roles:
seedIfMissing.datatable && seedIfMissing.role
? { [seedIfMissing.datatable]: seedIfMissing.role }
: undefined
},
data: { ...DEFAULT_DATA },
policy: {
on_behalf_of: user?.username.includes('@') ? user.username : `u/${user?.username}`,
on_behalf_of_email: user?.email,
@@ -996,7 +992,16 @@ function createRuntime(session: Session): SessionRuntime {
}
},
itemDeployed(workspace, kind, path) {
itemDeployed(workspace, kind, storagePath, deployedPath = storagePath) {
if (deployedPath !== storagePath) {
// Nothing is deployed at the storage path, so its editor would go on
// creating the item again and its draft would outlive the deploy: drop
// the draft and move the tab to where the item now lives.
UserDraft.stopSync(kind, storagePath, { workspace })
UserDraft.discard(kind, storagePath, undefined, { workspace })
previewTabs.retargetEditor({ kind, path: storagePath }, { kind, path: deployedPath })
}
const path = deployedPath
// After deploy the editor state equals the deployed value; the reload
// below re-seeds the cell from it, which must NOT POST as a fresh draft.
// The full-page editor guards this with discardDraftAfterDeploy, but the
@@ -1387,7 +1392,7 @@ setClosePreviewTabsHandler(({ sessionId: callerSessionId, all, match }) => {
// After a chat deploy, reload the calling session's preview — only if it's open
// showing that exact item.
setDeployedInSessionHandler(({ sessionId: callerSessionId, kind, path }) => {
setDeployedInSessionHandler(({ sessionId: callerSessionId, kind, storagePath, path }) => {
const sessionId = callerSessionId ?? sessionState.currentSessionId
if (!sessionId) return
const session = sessionState.sessions.find((s) => s.id === sessionId)
@@ -1396,12 +1401,12 @@ setDeployedInSessionHandler(({ sessionId: callerSessionId, kind, path }) => {
// Peek without creating a cell: a deploy for an item with no open editor tab
// must not allocate an empty cell that lingers until the next prune. The caches
// still answer for the item, so they are dropped either way.
if (runtime.loadedEditorPath(kind, path) !== path) {
if (runtime.loadedEditorPath(kind, storagePath) !== storagePath) {
invalidateWorkspaceDrafts(session.workspace_id)
invalidateWorkspaceItems(session.workspace_id, kind === 'raw_app' ? 'app' : kind)
return
}
runtime.itemDeployed(session.workspace_id, kind, path)
runtime.itemDeployed(session.workspace_id, kind, storagePath, path)
})
setGetRuntimeLogsHandler(async ({ sessionId: callerSessionId, limit, appPath }) => {
@@ -178,6 +178,24 @@ async function openInSession(
workspaceId?: string,
opts?: { seedPrompt?: string; autoSend?: boolean }
): Promise<void> {
await goto(await sessionHrefFor(url, workspaceId, opts))
}
/** A fresh session's page, with an editor in its preview. For a `load` that sends the
* user there by redirect, where a `goto` would start a second navigation. */
export function editorSessionHref(
target: SessionTarget,
workspaceId?: string,
previewParams?: Record<string, string>
): Promise<string> {
return sessionHrefFor(withPreviewParams(sessionTargetHref(target), previewParams), workspaceId)
}
async function sessionHrefFor(
url: string | undefined,
workspaceId?: string,
opts?: { seedPrompt?: string; autoSend?: boolean }
): Promise<string> {
// Seed the fresh session's preview with a single tab on `url` so it opens
// straight onto what the caller wants (resetSessionPreviewTabs also writes
// through a live runtime if one already exists for this id).
@@ -198,7 +216,7 @@ async function openInSession(
navRouteOffered = true
}
selectSession(session.id)
await goto(sessionPageHref(session.id))
return sessionPageHref(session.id)
}
// Open an editor's own hand-off, running its `beforeOpen` (which persists the
@@ -1,6 +1,41 @@
import { redirect } from '@sveltejs/kit'
import { get } from 'svelte/store'
import { makeDraftAddLoad } from '$lib/draftAddRedirect'
import { mintDraftPath } from '$lib/mintDraftPath'
import { userStore, workspaceStore } from '$lib/stores'
import { copilotInfo, copilotWorkspace } from '$lib/aiStore'
import { loadCopilot } from '$lib/components/copilot/loadCopilot'
import { prefersSessionHandoff } from '$lib/components/copilot/chat/global/gate'
import { editorSessionHref } from '$lib/components/sessions/sessionSwitch.svelte'
import { importStore } from '$lib/components/apps/store'
import type { PageLoad } from './$types'
export const prerender = false
export const load: PageLoad = makeDraftAddLoad('apps_raw/edit')
const openInEditor = makeDraftAddLoad('apps_raw/edit')
/** A blank new app is set up in an AI session, the recommended way to build one, whenever
* the user has sessions and AI to build with. Anything that arrives with content of its
* own (an import, a seeded path) is an editor hand-off and stays one. */
async function newAppSessionHref(url: URL): Promise<string | undefined> {
const params = [...url.searchParams.keys()].filter((k) => k !== 'workspace')
if (params.length > 0 || get(importStore) || sessionStorage.getItem('rawAppImport')) {
return undefined
}
const user = get(userStore)
const workspace = get(workspaceStore)
// Neither is known yet on a cold load of this URL, which then opens the editor.
if (!user || !workspace || !prefersSessionHandoff(user.operator)) return undefined
if ((url.searchParams.get('workspace') ?? workspace) !== workspace) return undefined
if (get(copilotWorkspace) !== workspace) await loadCopilot(workspace)
if (get(copilotWorkspace) !== workspace || !get(copilotInfo).enabled) return undefined
return editorSessionHref({ kind: 'raw_app', path: mintDraftPath() }, workspace, {
new_draft: 'true'
})
}
export const load: PageLoad = async (event) => {
const href = await newAppSessionHref(event.url)
if (href) redirect(307, href)
return openInEditor(event)
}
@@ -1,5 +1,5 @@
<script lang="ts">
import { onDestroy, untrack } from 'svelte'
import { onDestroy, tick, untrack } from 'svelte'
import { stripNewDraftFlag, stripNewDraftFlagOnSave, shouldSeedNewDraft } from '$lib/newDraftFlag'
import { AppService } from '$lib/gen'
@@ -520,24 +520,44 @@
let rawAppEditor: RawAppEditor | undefined = $state()
function onTemplatePickerStart(result: RawAppTemplatePickerResult, mode: RawAppBuildMode) {
if (mode === 'ai' && prefersSessionHandoff($userStore?.operator) && $workspaceStore) {
// Nothing is written here: the session opens this path on the template in memory
// (`new_draft`) and saves it only once it changes.
const { datatable, roles } = result.data
const role = datatable ? roles?.[datatable] : undefined
void openEditorInSession({ kind: 'raw_app', path }, $workspaceStore, {
new_draft: 'true',
...(datatable ? { datatable } : {}),
...(role ? { datatable_role: role } : {})
})
return
async function onTemplatePickerStart(result: RawAppTemplatePickerResult, mode: RawAppBuildMode) {
const picked = {
files: { ...result.files },
runnables: { ...result.runnables, ...structuredClone(STARTER_RUNNABLES) },
data: result.data,
summary: '',
policy: result.policy
}
files = { ...result.files }
runnables = { ...result.runnables, ...structuredClone(STARTER_RUNNABLES) }
data = result.data
summary = result.summary
policy = result.policy
const workspace = $workspaceStore
if (mode === 'ai' && prefersSessionHandoff($userStore?.operator) && workspace) {
// The session loads the app by path, so the picked setup is saved before it
// opens. `new_draft` tells it the app is still the starter template.
try {
draftSync.draft = {
...picked,
...(pendingDraftPath ? { draft_path: pendingDraftPath } : {})
} as RawAppDraft
// The autosave this write queues would otherwise land after the session has
// read the draft, leaving it a baseline older than the row.
await tick()
await UserDraftDbSyncer.flush({ workspace, itemKind: 'raw_app', path })
await UserDraft.forcePersist('raw_app', path, { workspace })
// A failed save is recorded, not thrown, and the session would open on nothing.
const saved = UserDraftDbSyncer.getState({ workspace, itemKind: 'raw_app', path })
if (saved.state === 'failed') {
throw new Error(saved.failureMessage ?? 'the app could not be saved')
}
await openEditorInSession({ kind: 'raw_app', path }, workspace, { new_draft: 'true' })
return
} catch (e) {
sendUserToast(`Could not open the AI session: ${e?.body ?? e?.message ?? e}`, true)
}
}
files = picked.files
runnables = picked.runnables
data = picked.data
summary = picked.summary
policy = picked.policy
// Remount RawAppEditor so the iframe picks up the new files.
redraw++
// Sync to aiChatManager so its prompts respect the picked data config.