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()} void | Promise + /** Where inside the item the preview should open (a flow's `selected` + * step). Steers the editor only — tab identity is (kind, path). */ + previewParams?: Record } {#if show} - + {:else if !inSessionPanel} {@render fallback?.()} {/if} diff --git a/frontend/src/lib/components/sessions/PreviewTabHost.svelte b/frontend/src/lib/components/sessions/PreviewTabHost.svelte index e9768de93c..93559023bb 100644 --- a/frontend/src/lib/components/sessions/PreviewTabHost.svelte +++ b/frontend/src/lib/components/sessions/PreviewTabHost.svelte @@ -9,7 +9,7 @@ } from './sessionState.svelte' import type { SessionRuntime } from './sessionRuntime.svelte' import { Loader2 } from 'lucide-svelte' - import { resolvePreviewTab, parsePreviewItemRoute } from './previewRouter' + import { resolvePreviewTab, parsePreviewItemRoute, parsePreviewSelectedId } from './previewRouter' import { withMenuHidden } from './sessionMode.svelte' import ArtifactViewer from '../copilot/chat/artifacts/ArtifactViewer.svelte' import { setOverlayHost } from '../common/overlayHost.svelte' @@ -57,6 +57,10 @@ // any editable item (script/flow/raw app) or a pipeline folder mounts its own // live editor. const slot = $derived(resolvePreviewTab(tab.url)) + // Where inside the editor the tab was opened on ("open this flow step in a + // session"). Only the in-process editors need it handed over — an iframe tab + // loads the URL whole, params included. + const selectedId = $derived(parsePreviewSelectedId(tab.url)) const workspaceId = $derived( session ? (getEffectiveWorkspaceId(session) ?? $workspaceStore ?? '') : '' ) @@ -193,6 +197,7 @@ {onNavigate} {isActiveSession} {active} + initialSelectedId={selectedId} /> {/await} {:else if slot.editorKind === 'script'} diff --git a/frontend/src/lib/components/sessions/openInSessionContext.ts b/frontend/src/lib/components/sessions/openInSessionContext.ts new file mode 100644 index 0000000000..27284802cc --- /dev/null +++ b/frontend/src/lib/components/sessions/openInSessionContext.ts @@ -0,0 +1,23 @@ +import { getContext, setContext } from 'svelte' +import type { OpenInSessionSource } from './OpenInSessionButton.svelte' + +// The "Open in AI session" hand-off, published by the component that owns the +// item being edited (FlowBuilder, RawAppEditor) for AI entry points too deep in +// the tree to be handed it as a prop — the inline code editor's toolbar sits +// four levels below the builder, behind a recursive module wrapper. + +const KEY = 'OpenInSessionHandoff' + +export type OpenInSessionHandoff = { + /** The editor's hand-off, opening on `moduleId` when it addresses its parts + * (a flow step). `undefined` while the item has no path to open yet. */ + source: (opts?: { moduleId?: string }) => OpenInSessionSource | undefined +} + +export function setOpenInSessionHandoff(handoff: OpenInSessionHandoff): void { + setContext(KEY, handoff) +} + +export function getOpenInSessionHandoff(): OpenInSessionHandoff | undefined { + return getContext(KEY) +} diff --git a/frontend/src/lib/components/sessions/previewRouter.test.ts b/frontend/src/lib/components/sessions/previewRouter.test.ts index e28878d437..4185078e7c 100644 --- a/frontend/src/lib/components/sessions/previewRouter.test.ts +++ b/frontend/src/lib/components/sessions/previewRouter.test.ts @@ -6,6 +6,7 @@ import { matchReusablePage, parseArtifactRoute, parsePreviewItemRoute, + parsePreviewSelectedId, previewLocationLabel, resolvePreviewTab } from './previewRouter' @@ -157,6 +158,22 @@ describe('resolvePreviewTab', () => { }) }) +describe('parsePreviewSelectedId', () => { + it('reads the step a tab was opened on, and stays out of the tab identity', () => { + const url = '/flows/edit/f/foo/bar?selected=b' + expect(parsePreviewSelectedId(url)).toBe('b') + expect(resolvePreviewTab(url)).toEqual({ + kind: 'editor', + editorKind: 'flow', + path: 'f/foo/bar' + }) + }) + + it('is undefined without the param', () => { + expect(parsePreviewSelectedId('/flows/edit/f/foo/bar')).toBeUndefined() + }) +}) + describe('artifact route', () => { it('round-trips id and name through artifactUrl → parseArtifactRoute, including special chars', () => { for (const [id, name] of [ diff --git a/frontend/src/lib/components/sessions/previewRouter.ts b/frontend/src/lib/components/sessions/previewRouter.ts index 957c9adee5..315facc8bb 100644 --- a/frontend/src/lib/components/sessions/previewRouter.ts +++ b/frontend/src/lib/components/sessions/previewRouter.ts @@ -182,6 +182,18 @@ export function parsePreviewItemRoute(fullPath: string): PreviewItemRoute | null return { kind: 'app', raw_app: false, itemPath } } +// The place inside a previewed flow editor its tab URL asks for (`?selected=`, +// the same param the full-page flow editor reads). Live editors are mounted in +// process rather than in an iframe, so the host has to read this off the tab URL +// and seed the editor with it. +export function parsePreviewSelectedId(url: string): string | undefined { + try { + return new URL(url, 'http://_').searchParams.get('selected') || undefined + } catch { + return undefined + } +} + // A `/pipeline/` route is the data-pipeline graph editor for that folder // (the folder is a single path segment, not a workspace item path). The bare // `/pipeline` list page is not an editor. Returns the folder name, or null. diff --git a/frontend/src/lib/components/sessions/sessionMode.svelte.ts b/frontend/src/lib/components/sessions/sessionMode.svelte.ts index 54a27d977a..a4972a9afb 100644 --- a/frontend/src/lib/components/sessions/sessionMode.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionMode.svelte.ts @@ -16,6 +16,19 @@ export function sessionTargetHref(target: SessionTarget | undefined): string | u return `${base}/${seg}/${target.path}` } +// Point a preview tab's URL at a place inside the previewed editor (the flow +// step to select). The params are not part of the item's identity — every tab +// resolver strips the query (see previewRouter's stripBase) — so they only ever +// steer where the editor opens. No-op without params. +export function withPreviewParams( + url: string | undefined, + params: Record | undefined +): string | undefined { + if (!url || !params) return url + const qs = new URLSearchParams(params).toString() + return qs ? `${url}${url.includes('?') ? '&' : '?'}${qs}` : url +} + // Force the global sidebar off in the previewed page (the sessions page already // has its own navigation rail) by setting Windmill's `nomenubar` query flag. // A session deliberately never switches the global workspaceStore, so the iframe diff --git a/frontend/src/lib/components/sessions/sessionMode.test.ts b/frontend/src/lib/components/sessions/sessionMode.test.ts index 0a9dab80eb..9bc67b9a58 100644 --- a/frontend/src/lib/components/sessions/sessionMode.test.ts +++ b/frontend/src/lib/components/sessions/sessionMode.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect } from 'vitest' -import { sessionTargetHref, withMenuHidden, withWorkspaceParam } from './sessionMode.svelte' +import { + sessionTargetHref, + withMenuHidden, + withPreviewParams, + withWorkspaceParam +} from './sessionMode.svelte' describe('sessionTargetHref', () => { it('maps each editor kind to its full-page route', () => { @@ -14,6 +19,20 @@ describe('sessionTargetHref', () => { }) }) +describe('withPreviewParams', () => { + it('points the tab at a step inside the flow it opens', () => { + expect( + withPreviewParams(sessionTargetHref({ kind: 'flow', path: 'u/me/bar' }), { selected: 'b' }) + ).toBe('/flows/edit/u/me/bar?selected=b') + }) + + it('is a no-op without params or without a URL', () => { + expect(withPreviewParams('/flows/edit/u/me/bar', undefined)).toBe('/flows/edit/u/me/bar') + expect(withPreviewParams('/flows/edit/u/me/bar', {})).toBe('/flows/edit/u/me/bar') + expect(withPreviewParams(undefined, { selected: 'b' })).toBeUndefined() + }) +}) + describe('withMenuHidden', () => { it('appends the nomenubar flag', () => { expect(withMenuHidden('/runs')).toBe('/runs?nomenubar=true') diff --git a/frontend/src/lib/components/sessions/sessionSwitch.svelte.ts b/frontend/src/lib/components/sessions/sessionSwitch.svelte.ts index 38ca3bf6dd..0eb5179dc2 100644 --- a/frontend/src/lib/components/sessions/sessionSwitch.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionSwitch.svelte.ts @@ -9,7 +9,7 @@ import { setSessionPendingWorkspace, type SessionTarget } from './sessionState.svelte' -import { sessionTargetHref } from './sessionMode.svelte' +import { sessionTargetHref, withPreviewParams } from './sessionMode.svelte' // The session/navigation switch turns the global rail into either the workspace // navigation (navigation mode) or the sessions sidebar (session mode). Session @@ -70,17 +70,19 @@ export async function exitSessionMode(): Promise { // so the caller MUST persist any unsaved edits first (e.g. save a draft) for the // preview to reflect the live state. `workspaceId` scopes the session to the // editor's workspace (instead of createSession's root default) so it opens the -// same flow/script the user was editing. +// same flow/script the user was editing. `previewParams` ride on the tab URL to +// tell the previewed editor where to open (a flow's `selected` step). export async function openEditorInSession( target: SessionTarget, - workspaceId?: string + workspaceId?: string, + previewParams?: Record ): Promise { // Seed the fresh session's preview with a single tab on `target` so it opens // straight onto the editor the caller wants (resetSessionPreviewTabs also // writes through a live runtime if one already exists for this id). const session = createSession() if (workspaceId) setSessionPendingWorkspace(session.id, workspaceId) - const url = sessionTargetHref(target) + const url = withPreviewParams(sessionTargetHref(target), previewParams) if (url) { // Dynamic import: a static one would drag the runtime's heavy graph // (chat manager → monaco) into this thin navigation seam, breaking its
- Enable Windmill AI in the - workspace settings - -
+ Enable Windmill AI in the + workspace settings + +