mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-18 16:02:10 +00:00
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:
@@ -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}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user