mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-05 08:02:18 +00:00
perf: hand the flow editor's content to the AI session it opens
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KcyBRxE8j3ujCYkum9KrC4
This commit is contained in:
co-authored by
Claude Opus 5
parent
b100606da6
commit
725d40f935
@@ -116,6 +116,7 @@
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
import { UserDraft } from '$lib/userDraft.svelte'
|
||||
import { setOpenInSessionHandoff } from './sessions/openInSessionContext'
|
||||
import { captureEditorSeed, type EditorSeed } from './sessions/editorSeed.svelte'
|
||||
|
||||
let {
|
||||
initialPath = $bindable(''),
|
||||
@@ -736,12 +737,28 @@
|
||||
// falling back to `$pathStore` in drawer mounts that carry no storage path.
|
||||
const sessionTargetPath = $derived(liveEditorDraftStoragePath || $pathStore)
|
||||
|
||||
// Hand the session the flow as this editor holds it, so its preview mounts on
|
||||
// content instead of re-fetching (and rebuilding every step's schema) what is
|
||||
// already on screen. Best-effort: no seed simply loads as before.
|
||||
function seedForSession(): EditorSeed | undefined {
|
||||
if (!sessionTargetPath || !opWorkspace) return undefined
|
||||
return captureEditorSeed({
|
||||
kind: 'flow',
|
||||
path: sessionTargetPath,
|
||||
workspace: opWorkspace,
|
||||
flow: flowStore.val,
|
||||
flowState: flowStateStore.val,
|
||||
saved: savedFlow
|
||||
})
|
||||
}
|
||||
|
||||
const sessionOpen = $derived(
|
||||
sessionTargetPath
|
||||
? {
|
||||
target: { kind: 'flow' as const, path: sessionTargetPath },
|
||||
workspaceId: opWorkspace ?? undefined,
|
||||
beforeOpen: persistDraftForSession
|
||||
beforeOpen: persistDraftForSession,
|
||||
seed: seedForSession
|
||||
}
|
||||
: undefined
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts" module>
|
||||
import type { SessionTarget } from './sessionState.svelte'
|
||||
import type { EditorSeed } from './editorSeed.svelte'
|
||||
|
||||
// What an editor hands over for "Open in AI session": the session target it
|
||||
// maps to, the workspace it lives in, and a persist hook run before routing
|
||||
@@ -14,6 +15,11 @@
|
||||
* (fix this error, run this item) hand it over as text rather than driving
|
||||
* a chat the caller cannot see. */
|
||||
seedPrompt?: string
|
||||
/** The item's live content, so the session's preview renders it without a
|
||||
* round-trip. Read after `beforeOpen`, on click rather than at render, and
|
||||
* optional throughout: without it the preview loads from the persisted
|
||||
* draft as before. */
|
||||
seed?: () => EditorSeed | undefined
|
||||
/** Send `seedPrompt` on arrival rather than parking it in the composer.
|
||||
* For clicks that already stated the intent; leave it off where the prompt
|
||||
* is a proposal the user should read first. */
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { Flow, OpenFlow } from '$lib/gen'
|
||||
|
||||
// The "Open in AI session" content hand-off. Kept out of sessionRuntime so the
|
||||
// editors that capture a seed (FlowBuilder) reach the helper without a runtime
|
||||
// import of the session runtime's graph (chat manager → monaco).
|
||||
|
||||
/** An editor's live content, handed to a fresh session so its preview renders
|
||||
* without re-fetching what the page already holds. Consumed by
|
||||
* `seedEditorCell`. */
|
||||
export type EditorSeed = {
|
||||
kind: 'flow'
|
||||
path: string
|
||||
/** The workspace the content was read from; the seed is dropped when the
|
||||
* session ended up acting on another one (a fork), whose content differs. */
|
||||
workspace: string
|
||||
/** Typed as the editor holds it; the cell it seeds types the same loaded row
|
||||
* as a `Flow` (see seedEditorCell). */
|
||||
flow: OpenFlow
|
||||
/** Per-module schemas and test results — the expensive half: rebuilding it
|
||||
* costs one script fetch per path-referenced step (see initFlowState). */
|
||||
flowState: Record<string, any>
|
||||
saved: (Flow & { no_deployed?: boolean }) | undefined
|
||||
}
|
||||
|
||||
/** Snapshot an editor's stores as an {@link EditorSeed}, detached from their
|
||||
* reactive state so the two editors can't alias one object. Returns undefined
|
||||
* for anything that won't clone, leaving the caller on the fetching path. */
|
||||
export function captureEditorSeed(seed: EditorSeed): EditorSeed | undefined {
|
||||
try {
|
||||
return structuredClone($state.snapshot(seed)) as EditorSeed
|
||||
} catch (e) {
|
||||
console.error('Failed to capture editor seed', e)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
// the response type still need an explicit cast).
|
||||
type SavedScript = Omit<Script & UserDraftOverlay, 'draft'> & { draft?: NewScript }
|
||||
type SavedFlow = Omit<Flow & UserDraftOverlay, 'draft'> & { draft?: Flow }
|
||||
import type { EditorSeed } from './editorSeed.svelte'
|
||||
import type { HiddenRunnable } from '$lib/components/apps/types'
|
||||
import { type RawAppData, DEFAULT_DATA } from '$lib/components/raw_apps/dataTableRefUtils'
|
||||
import { userWorkspaces, workspaceStore } from '$lib/stores'
|
||||
@@ -952,6 +953,13 @@ export function getOrCreateRuntime(session: Session): SessionRuntime {
|
||||
if (!runtime) {
|
||||
runtime = createRuntime(session)
|
||||
runtimes.set(session.id, runtime)
|
||||
// Before the first load: a hand-off's content is what this session's
|
||||
// editor should render, in place of the fetch its first mount triggers.
|
||||
if (pendingSeed?.sessionId === session.id) {
|
||||
const { seed } = pendingSeed
|
||||
pendingSeed = undefined
|
||||
applyEditorSeed(runtime, session.id, seed)
|
||||
}
|
||||
initRuntime(runtime, session).catch((e) => console.error('Failed to init session runtime', e))
|
||||
}
|
||||
return runtime
|
||||
@@ -973,6 +981,49 @@ export function getRuntime(sessionId: string): SessionRuntime | undefined {
|
||||
return runtimes.get(sessionId)
|
||||
}
|
||||
|
||||
// The seed of the hand-off currently navigating, consumed by the runtime the
|
||||
// arriving sessions page creates. Only one can be in flight — a hand-off
|
||||
// navigates immediately — so a later one simply replaces it, and nothing is
|
||||
// left behind if the navigation never lands.
|
||||
let pendingSeed: { sessionId: string; seed: EditorSeed } | undefined = undefined
|
||||
|
||||
/** Hand `seed` to `sessionId`'s editor cell, so its preview mounts on
|
||||
* already-loaded content instead of showing loadFlow's spinner.
|
||||
*
|
||||
* Takes an id, and deliberately does NOT create the runtime. `createSession`
|
||||
* hands back the raw session object while `sessionState.sessions` holds its
|
||||
* `$state` proxy, so fields added afterwards (`previewTabs`) are visible only
|
||||
* through the proxy: building the runtime from the caller's object would
|
||||
* hydrate its preview tabs empty and leave the arriving page with no tab at
|
||||
* all. The seed waits for whoever creates the runtime instead. */
|
||||
export function seedEditorCell(sessionId: string, seed: EditorSeed): void {
|
||||
const runtime = runtimes.get(sessionId)
|
||||
if (runtime) applyEditorSeed(runtime, sessionId, seed)
|
||||
else pendingSeed = { sessionId, seed }
|
||||
}
|
||||
|
||||
/** Fill the cell and mark it loaded, keyed by (kind, path) exactly as loadFlow
|
||||
* would leave it — so `loadFlow` early-returns and a later forced reload still
|
||||
* refetches. */
|
||||
function applyEditorSeed(runtime: SessionRuntime, sessionId: string, seed: EditorSeed): void {
|
||||
// Read the workspace off the live record, not the caller's session object:
|
||||
// between the hand-off and the runtime's creation the session may have
|
||||
// committed to a fork, whose copy of the item is not what was captured.
|
||||
const session = sessionState.sessions.find((s) => s.id === sessionId)
|
||||
if (!session || getEffectiveWorkspaceId(session) !== seed.workspace) return
|
||||
const { slot, store, stateStore, saved } = runtime.flowCell(seed.path)
|
||||
// The page editor types its flow as an `OpenFlow` and its baseline without
|
||||
// the draft overlay; both hold the same loaded rows this cell's own fetch
|
||||
// would have returned.
|
||||
store.val = seed.flow as Flow
|
||||
stateStore.val = seed.flowState
|
||||
saved.val = seed.saved as SavedFlow | undefined
|
||||
slot.loadedPath = seed.path
|
||||
slot.loadedWorkspace = seed.workspace
|
||||
slot.loading = false
|
||||
slot.notFound = false
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cross-tab catch-up
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -19,6 +19,7 @@ import { findMountedOpenInSessionSource } from './openInSessionContext'
|
||||
// Type-only: erased at compile time, so the component graph stays out of this
|
||||
// navigation seam (see the dynamic import in openEditorInSession).
|
||||
import type { OpenInSessionSource } from './OpenInSessionButton.svelte'
|
||||
import type { EditorSeed } from './editorSeed.svelte'
|
||||
|
||||
// The session/navigation switch turns the global rail into either the workspace
|
||||
// navigation (navigation mode) or the sessions sidebar (session mode). Session
|
||||
@@ -152,7 +153,7 @@ export async function openEditorInSession(
|
||||
target: SessionTarget,
|
||||
workspaceId?: string,
|
||||
previewParams?: Record<string, string>,
|
||||
opts?: { seedPrompt?: string; autoSend?: boolean }
|
||||
opts?: { seedPrompt?: string; autoSend?: boolean; seed?: EditorSeed }
|
||||
): Promise<void> {
|
||||
await openInSession(
|
||||
withPreviewParams(sessionTargetHref(target), previewParams),
|
||||
@@ -175,7 +176,7 @@ export async function openPageInSession(
|
||||
async function openInSession(
|
||||
url: string | undefined,
|
||||
workspaceId?: string,
|
||||
opts?: { seedPrompt?: string; autoSend?: boolean }
|
||||
opts?: { seedPrompt?: string; autoSend?: boolean; seed?: EditorSeed }
|
||||
): Promise<void> {
|
||||
// Seed the fresh session's preview with a single tab on `url` so it opens
|
||||
// straight onto what the caller wants (resetSessionPreviewTabs also writes
|
||||
@@ -190,8 +191,12 @@ async function openInSession(
|
||||
// Dynamic import: a static one would drag the runtime's heavy graph
|
||||
// (chat manager → monaco) into this thin navigation seam, breaking its
|
||||
// node-run unit tests.
|
||||
const { resetSessionPreviewTabs } = await import('./sessionRuntime.svelte')
|
||||
const { resetSessionPreviewTabs, seedEditorCell } = await import('./sessionRuntime.svelte')
|
||||
resetSessionPreviewTabs(session.id, url)
|
||||
// After the tabs: reusing a session that already has a runtime seeds its
|
||||
// cell right away, and the tab change prunes every cell no open tab
|
||||
// points at — which would drop this one.
|
||||
if (opts?.seed) seedEditorCell(session.id, opts.seed)
|
||||
// Hand-offs seed the page they leave, so the arrival has opened the item
|
||||
// itself and "New session" need not offer it again.
|
||||
navRouteOffered = true
|
||||
@@ -211,7 +216,11 @@ export async function openSourceInSession(
|
||||
await source.beforeOpen?.()
|
||||
const opts = {
|
||||
seedPrompt: overrides?.seedPrompt ?? source.seedPrompt,
|
||||
autoSend: overrides?.autoSend ?? source.autoSend
|
||||
autoSend: overrides?.autoSend ?? source.autoSend,
|
||||
// Read after beforeOpen: the persist can rewrite what the editor holds
|
||||
// (a new flow's path), and the seed must match what the preview would
|
||||
// have loaded.
|
||||
seed: source.seed?.()
|
||||
}
|
||||
if (source.target) {
|
||||
await openEditorInSession(
|
||||
|
||||
@@ -22,7 +22,10 @@ import { goto } from '$lib/navigation'
|
||||
// Seeding a preview tab dynamically imports the runtime, whose graph reaches
|
||||
// monaco (hence that import being dynamic in the first place) and cannot load
|
||||
// under node.
|
||||
vi.mock('./sessionRuntime.svelte', () => ({ resetSessionPreviewTabs: vi.fn() }))
|
||||
vi.mock('./sessionRuntime.svelte', () => ({
|
||||
resetSessionPreviewTabs: vi.fn(),
|
||||
seedEditorCell: vi.fn()
|
||||
}))
|
||||
import { resetSessionPreviewTabs } from './sessionRuntime.svelte'
|
||||
import { registerMountedOpenInSessionHandoff } from './openInSessionContext'
|
||||
|
||||
|
||||
@@ -94,6 +94,12 @@ export function useUserDraftSync<Draft>(opts: UserDraftSyncOptions<Draft>): void
|
||||
// re-apply only advances the sig, never reverts an edit or fires a save.
|
||||
if (sig === lastInboundSig) return
|
||||
lastInboundSig = sig
|
||||
// The store can already hold exactly this draft — the load wrote it
|
||||
// there, or a hand-off seeded it. Applying anyway costs a full rebuild
|
||||
// of the flow's per-module state (a fetch per path-referenced step) and
|
||||
// discards what it holds, including the seeded test results.
|
||||
const current = codec.storeToDraft(undefined)
|
||||
if (current != null && codec.sig(current) === sig) return
|
||||
codec.applyDraftToStore(incoming)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user