mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-26 08:02:31 +00:00
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:
co-authored by
Claude Opus 5.5
parent
38aa4eaa12
commit
71901852fb
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user