fix: open an AI session from the editor bar's AI button, on the step (#10504)

* fix: open an AI session from the editor bar's AI button, on the step

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

* fix: withhold the session hand-off under disableAi, forward button styling

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

* fix: flush the code editor's pending keystrokes before opening the session

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-08-04 12:11:07 +00:00
committed by GitHub
parent 141ed7bae0
commit ea4f3ecc6e
15 changed files with 274 additions and 98 deletions
+5 -1
View File
@@ -1309,7 +1309,11 @@ JsonNode ${windmillPathToCamelCaseName(path)} = JsonNode.Parse(await client.GetS
{#if customUi?.aiGen != false}
{#if openAiChat}
<FlowInlineScriptAiButton {moduleId} btnProps={{ variant: 'subtle' }} />
<FlowInlineScriptAiButton
{moduleId}
flushEditor={() => editor?.flushPendingChanges()}
btnProps={{ variant: 'subtle' }}
/>
{/if}
{/if}
+28 -10
View File
@@ -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<string | undefined>(savedFlow?.on_behalf_of_email)
let savedOnBehalfOfPermissionedAs = writable<string | undefined>(
savedFlow?.on_behalf_of
)
let savedOnBehalfOfPermissionedAs = writable<string | undefined>(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}
@@ -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<typeof Button>
}
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}
<!-- This button only opens the docked chat pane. Without one there is nothing to
open, so it hides rather than rendering a dead click — the flow and raw-app
toolbars carry the "open in AI session" entry point in that mode. -->
{#if chatState.dockedChatAvailable}
{#if $copilotInfo.enabled}
{@render button(() => {
aiChatManager.openChat()
const availableContext = aiChatManager.contextManager.getAvailableContext()
aiChatManager.contextManager.setSelectedModuleContext(moduleId, availableContext)
})}
{:else}
<Popover
floatingConfig={{
middleware: [
autoPlacement({
allowedPlacements: [
'bottom-start',
'bottom-end',
'top-start',
'top-end',
'top',
'bottom'
<OpenInSessionButton source={sessionSource} {btnProps}>
{#snippet fallback()}
<!-- Legacy docked chat: this button only opens that pane, so without one there
is nothing to open and it hides rather than rendering a dead click. -->
{#if chatState.dockedChatAvailable}
{#if $copilotInfo.enabled}
{@render button(() => {
aiChatManager.openChat()
const availableContext = aiChatManager.contextManager.getAvailableContext()
aiChatManager.contextManager.setSelectedModuleContext(moduleId, availableContext)
})}
{:else}
<Popover
floatingConfig={{
middleware: [
autoPlacement({
allowedPlacements: [
'bottom-start',
'bottom-end',
'top-start',
'top-end',
'top',
'bottom'
]
})
]
})
]
}}
>
{#snippet trigger()}
{@render button()}
{/snippet}
{#snippet content({ close })}
<div class="p-4">
<p class="text-sm">
Enable Windmill AI in the <a
href="{base}/workspace_settings?tab=ai"
target="_blank"
class="inline-flex flex-row items-center gap-1"
>
workspace settings <ExternalLink size={16} />
</a>
</p>
</div>
{/snippet}
</Popover>
{/if}
{/if}
}}
>
{#snippet trigger()}
{@render button()}
{/snippet}
{#snippet content({ close })}
<div class="p-4">
<p class="text-sm">
Enable Windmill AI in the <a
href="{base}/workspace_settings?tab=ai"
target="_blank"
class="inline-flex flex-row items-center gap-1"
>
workspace settings <ExternalLink size={16} />
</a>
</p>
</div>
{/snippet}
</Popover>
{/if}
{/if}
{/snippet}
</OpenInSessionButton>
@@ -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<typeof Button>
/** Tooltip + accessible text of the icon-only button. */
label?: string
} = $props()
@@ -58,6 +63,7 @@
onClick={onPress}
startIcon={{ icon: MessagesSquare }}
iconOnly
{...btnProps}
{btnClasses}
>
{label}
@@ -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<void> {
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}
@@ -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<string, Runnable>
files: Record<string, string> | 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<void> {
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 @@
</Button>
</div>
<AppExportButton bind:this={appExport} />
<OpenInSessionButton
source={appPath
? {
target: { kind: 'raw_app', path: appPath },
workspaceId: opWorkspace ?? undefined,
// Persist the draft (and materialize a brand-new one) so the session
// preview opens the app exactly as it is in the editor right now.
beforeOpen: persistDraftForSession
}
: undefined}
>
<OpenInSessionButton source={sessionOpen}>
{#snippet fallback()}
<Button
unifiedSize={headerBtnSize}
@@ -16,7 +16,8 @@
workspaceId,
onNavigate,
isActiveSession = true,
active = true
active = true,
initialSelectedId
}: {
runtime: SessionRuntime
path: string
@@ -27,11 +28,16 @@
isActiveSession?: boolean
/** Whether this is the visible preview tab (forwarded as isActiveTab). */
active?: boolean
/** Step the tab was opened on ("open this step in a session"), from its
* URL's `selected` param. */
initialSelectedId?: string
} = $props()
// This tab's own flow cell; each open flow editor binds its own store.
const cell = $derived(runtime.flowCell(path))
let selectedId = $state('settings-metadata')
// Derived, not state: retargeting the tab at another flow swaps the URL, and a
// step id from the previous one must not survive into the new flow.
const selectedId = $derived(initialSelectedId ?? 'settings-metadata')
let diffDrawer: DiffDrawer | undefined = $state()
// Restore actions for the diff drawer. A `loadFlow`-based handler is a no-op:
@@ -8,11 +8,14 @@
target: SessionTarget
workspaceId?: string
beforeOpen?: () => void | Promise<void>
/** Where inside the item the preview should open (a flow's `selected`
* step). Steers the editor only — tab identity is (kind, path). */
previewParams?: Record<string, string>
}
</script>
<script lang="ts">
import { getContext, type Snippet } from 'svelte'
import { getContext, type ComponentProps, type Snippet } from 'svelte'
import { BROWSER } from 'esm-env'
import AIButton from '$lib/components/copilot/chat/AIButton.svelte'
import { AIBtnClasses } from '$lib/components/copilot/chat/AIButtonStyle'
@@ -22,11 +25,15 @@
let {
source,
btnClasses,
btnProps,
fallback
}: {
/** Undefined (e.g. an item without a path yet) renders the fallback. */
source?: OpenInSessionSource
btnClasses?: string
/** Button styling overrides for hosts with their own conventions (an
* editor toolbar). */
btnProps?: ComponentProps<typeof AIButton>['btnProps']
/** Rendered instead when the user opted out of the sessions beta
* (typically the editor's inline-chat toggle). Never rendered inside
* the session panel. */
@@ -48,7 +55,7 @@
opening = true
try {
await source.beforeOpen?.()
await openEditorInSession(source.target, source.workspaceId)
await openEditorInSession(source.target, source.workspaceId, source.previewParams)
} finally {
opening = false
}
@@ -56,7 +63,7 @@
</script>
{#if show}
<AIButton togglePanel={open} btnClasses={btnClasses ?? AIBtnClasses('default')} />
<AIButton togglePanel={open} btnClasses={btnClasses ?? AIBtnClasses('default')} {btnProps} />
{:else if !inSessionPanel}
{@render fallback?.()}
{/if}
@@ -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'}
@@ -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<OpenInSessionHandoff | undefined>(KEY)
}
@@ -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 [
@@ -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/<folder>` 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.
@@ -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<string, string> | 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
@@ -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')
@@ -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<void> {
// 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<string, string>
): Promise<void> {
// 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