diff --git a/frontend/src/lib/components/EditorBar.svelte b/frontend/src/lib/components/EditorBar.svelte index 04f181f4fc..dc2f046850 100644 --- a/frontend/src/lib/components/EditorBar.svelte +++ b/frontend/src/lib/components/EditorBar.svelte @@ -1309,7 +1309,11 @@ JsonNode ${windmillPathToCamelCaseName(path)} = JsonNode.Parse(await client.GetS {#if customUi?.aiGen != false} {#if openAiChat} - + editor?.flushPendingChanges()} + btnProps={{ variant: 'subtle' }} + /> {/if} {/if} diff --git a/frontend/src/lib/components/FlowBuilder.svelte b/frontend/src/lib/components/FlowBuilder.svelte index fabf9047c0..6314a38678 100644 --- a/frontend/src/lib/components/FlowBuilder.svelte +++ b/frontend/src/lib/components/FlowBuilder.svelte @@ -114,6 +114,7 @@ import { buildForkEditUrl, editInForkAllowed, editInForkLabel } from '$lib/utils/editInFork' import { isCloudHosted } from '$lib/cloud' import { UserDraft } from '$lib/userDraft.svelte' + import { setOpenInSessionHandoff } from './sessions/openInSessionContext' let { initialPath = $bindable(''), @@ -171,9 +172,7 @@ // For preserve_on_behalf_of feature let preserveOnBehalfOf = writable(false) let savedOnBehalfOfEmail = writable(savedFlow?.on_behalf_of_email) - let savedOnBehalfOfPermissionedAs = writable( - savedFlow?.on_behalf_of - ) + let savedOnBehalfOfPermissionedAs = writable(savedFlow?.on_behalf_of) // Keep savedOnBehalfOfEmail in sync when savedFlow is loaded asynchronously $effect(() => { @@ -701,6 +700,31 @@ // falling back to `$pathStore` in drawer mounts that carry no storage path. const sessionTargetPath = $derived(liveEditorDraftStoragePath || $pathStore) + const sessionOpen = $derived( + sessionTargetPath + ? { + target: { kind: 'flow' as const, path: sessionTargetPath }, + workspaceId: opWorkspace ?? undefined, + beforeOpen: persistDraftForSession + } + : undefined + ) + + // Reaches the AI entry point in a step's inline-editor toolbar, which the + // recursive module wrapper sits too deep under to be handed a prop. `selected` + // is the flow editor's own step param, so the session preview opens on the + // step whose code the user was editing. Withheld under `disableAi` (same gate + // as the graph toolbar's button): an embed that turned AI off must not get an + // entry point that navigates the host out to /sessions. + setOpenInSessionHandoff({ + source: (opts) => + disableAi || !sessionOpen + ? undefined + : opts?.moduleId + ? { ...sessionOpen, previewParams: { selected: opts.moduleId } } + : sessionOpen + }) + $effect(() => { if (liveEditorDraftStoragePath === undefined || !opWorkspace) return const workspace = opWorkspace @@ -1455,13 +1479,7 @@ aiChatOpen={aiChatManager.open} showFlowAiButton={!disableAi && customUi?.topBar?.aiBuilder != false} toggleAiChat={() => aiChatManager.toggleOpen()} - sessionOpen={sessionTargetPath - ? { - target: { kind: 'flow', path: sessionTargetPath }, - workspaceId: opWorkspace ?? undefined, - beforeOpen: persistDraftForSession - } - : undefined} + {sessionOpen} onOpenPreview={flowPreviewButtons?.openPreview} localModuleStates={showJobStatus ? localModuleStates : {}} {showJobStatus} diff --git a/frontend/src/lib/components/copilot/FlowInlineScriptAIButton.svelte b/frontend/src/lib/components/copilot/FlowInlineScriptAIButton.svelte index c2bef886dd..29ce045042 100644 --- a/frontend/src/lib/components/copilot/FlowInlineScriptAIButton.svelte +++ b/frontend/src/lib/components/copilot/FlowInlineScriptAIButton.svelte @@ -8,14 +8,39 @@ import { aiChatManager, AIMode } from './chat/AIChatManager.svelte' import { chatState } from './chat/sharedChatState.svelte' import { copilotInfo } from '$lib/aiStore' - import type { ComponentProps } from 'svelte' + import { tick, type ComponentProps } from 'svelte' + import OpenInSessionButton from '$lib/components/sessions/OpenInSessionButton.svelte' + import { getOpenInSessionHandoff } from '$lib/components/sessions/openInSessionContext' interface Props { moduleId?: string + /** Materializes Monaco's in-flight keystrokes into the draft. This button + * sits in the code editor's own toolbar, so "type, then click" is the + * normal case, and the session preview loads the item from its draft — + * without this the last (sub-second) edits would not be in it. */ + flushEditor?: () => void btnProps?: ComponentProps } - const { moduleId, btnProps }: Props = $props() + const { moduleId, flushEditor, btnProps }: Props = $props() + + // The enclosing editor's "Open in AI session" hand-off, opening the preview on + // the step this toolbar edits. + const handoff = getOpenInSessionHandoff() + const sessionSource = $derived.by(() => { + const source = handoff?.source({ moduleId }) + if (!source || !flushEditor) return source + return { + ...source, + beforeOpen: async () => { + flushEditor() + // The flush lands in the draft store through an effect; let it run + // before the hand-off persists that store. + await tick() + await source.beforeOpen?.() + } + } + }) const aiChatScriptModeClasses = $derived( aiChatManager.mode === AIMode.SCRIPT && aiChatManager.isOpen @@ -37,49 +62,52 @@ /> {/snippet} - -{#if chatState.dockedChatAvailable} - {#if $copilotInfo.enabled} - {@render button(() => { - aiChatManager.openChat() - const availableContext = aiChatManager.contextManager.getAvailableContext() - aiChatManager.contextManager.setSelectedModuleContext(moduleId, availableContext) - })} - {:else} - + {#snippet fallback()} + + {#if chatState.dockedChatAvailable} + {#if $copilotInfo.enabled} + {@render button(() => { + aiChatManager.openChat() + const availableContext = aiChatManager.contextManager.getAvailableContext() + aiChatManager.contextManager.setSelectedModuleContext(moduleId, availableContext) + })} + {:else} + - {#snippet trigger()} - {@render button()} - {/snippet} - {#snippet content({ close })} -
-

- Enable Windmill AI in the - workspace settings - -

-
- {/snippet} -
- {/if} -{/if} + }} + > + {#snippet trigger()} + {@render button()} + {/snippet} + {#snippet content({ close })} +
+

+ Enable Windmill AI in the + workspace settings + +

+
+ {/snippet} +
+ {/if} + {/if} + {/snippet} + diff --git a/frontend/src/lib/components/copilot/chat/AIButton.svelte b/frontend/src/lib/components/copilot/chat/AIButton.svelte index 7f9ccda715..3fe9acec77 100644 --- a/frontend/src/lib/components/copilot/chat/AIButton.svelte +++ b/frontend/src/lib/components/copilot/chat/AIButton.svelte @@ -6,14 +6,19 @@ import DarkPopover from '$lib/components/Popover.svelte' import { ExternalLink, MessagesSquare } from 'lucide-svelte' import Button from '$lib/components/common/button/Button.svelte' + import type { ComponentProps } from 'svelte' let { togglePanel, btnClasses, + btnProps, label = 'Open in AI session' }: { togglePanel: () => void btnClasses?: string + /** Overrides for the host's button styling (an editor toolbar sizes and + * flattens it to match its neighbours). `btnClasses` still wins. */ + btnProps?: ComponentProps /** Tooltip + accessible text of the icon-only button. */ label?: string } = $props() @@ -58,6 +63,7 @@ onClick={onPress} startIcon={{ icon: MessagesSquare }} iconOnly + {...btnProps} {btnClasses} > {label} diff --git a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte index 0e6349405a..9d49f7b7ea 100644 --- a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte @@ -58,6 +58,8 @@ import { RawAppHistoryManager } from './RawAppHistoryManager.svelte' import { sendUserToast } from '$lib/utils' import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte' + import { UserDraft } from '$lib/userDraft.svelte' + import { setOpenInSessionHandoff } from '$lib/components/sessions/openInSessionContext' import { buildDataTableWhitelist, parseDataTableRef, @@ -208,6 +210,40 @@ // drawers, DB selector) so their lookups target the app's workspace too. setRawAppOperatingWorkspace(() => opWorkspace) + // The path autosaves land on, which is what the session preview loads the app by. + const draftStoragePath = $derived(autosavePath ?? liveEditorDraftStoragePath) + + // Materialize a brand-new app's draft before the session preview loads it by + // path — an untouched new app never autosaved, so forcePersist is the only + // thing that creates the row. Gated to never-deployed: forcePersist skips the + // discardIf baseline, safe only when there is none. + async function persistDraftForSession(): Promise { + if (!opWorkspace || draftStoragePath === undefined) return + await UserDraftDbSyncer.flush({ + workspace: opWorkspace, + itemKind: 'raw_app', + path: draftStoragePath + }) + if (newApp) { + await UserDraft.forcePersist('raw_app', draftStoragePath, { workspace: opWorkspace }) + } + } + + const sessionOpen = $derived( + path + ? { + target: { kind: 'raw_app' as const, path }, + workspaceId: opWorkspace ?? undefined, + beforeOpen: persistDraftForSession + } + : undefined + ) + + // Reaches the AI entry point in an inline script's toolbar, which sits too deep + // in the sidebar to be handed a prop. A raw app has no addressable sub-editor, + // so the preview just opens the app. + setOpenInSessionHandoff({ source: () => sessionOpen }) + // Convert to object format for child components let dataTableRefsObjects = $derived(data.tables.map(parseDataTableRef)) let dataTableWhitelist = $derived(buildDataTableWhitelist(dataTableRefsObjects)) @@ -2201,6 +2237,7 @@ {newPath} {labels} appPath={path} + {sessionOpen} {liveEditorDraftStoragePath} {autosaveWorkspace} {autosavePath} diff --git a/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte b/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte index 31a98554e0..926328751c 100644 --- a/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte @@ -9,8 +9,9 @@ import { AppService, type Policy } from '$lib/gen' import { UserDraft } from '$lib/userDraft.svelte' - import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte' - import OpenInSessionButton from '$lib/components/sessions/OpenInSessionButton.svelte' + import OpenInSessionButton, { + type OpenInSessionSource + } from '$lib/components/sessions/OpenInSessionButton.svelte' import { discardDraftAfterDeploy } from '$lib/userDraftToast' import { enterpriseLicense, userStore, userWorkspaces, workspaceStore } from '$lib/stores' import { @@ -113,6 +114,9 @@ /** Initial labels for the app, threaded from the loaded app data. */ labels?: string[] appPath: string + /** "Open in AI session" hand-off, owned by the editor (it persists the + * draft the session preview loads). Undefined until the app has a path. */ + sessionOpen?: OpenInSessionSource runnables: Record files: Record | undefined /** Data configuration including tables and creation policy */ @@ -178,6 +182,7 @@ newPath = '', labels: initialLabels = undefined, appPath, + sessionOpen, runnables, data, files, @@ -216,22 +221,6 @@ const opWorkspace = $derived(autosaveWorkspace ?? $workspaceStore) const indicatorPath = $derived(autosavePath ?? liveEditorDraftStoragePath) - // Materialize a brand-new app's draft before the session preview loads it by - // path — an untouched new app never autosaved, so forcePersist is the only - // thing that creates the row (`appPath === indicatorPath` in the full-page - // editor). Gated to never-deployed: forcePersist skips the discardIf baseline. - async function persistDraftForSession(): Promise { - if (!opWorkspace || indicatorPath === undefined) return - await UserDraftDbSyncer.flush({ - workspace: opWorkspace, - itemKind: 'raw_app', - path: indicatorPath - }) - if (newApp) { - await UserDraft.forcePersist('raw_app', indicatorPath, { workspace: opWorkspace }) - } - } - $effect(() => { const typed = newEditedPath const baseline = savedApp?.path ?? '' @@ -870,17 +859,7 @@ - + {#snippet fallback()}