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:
Diego Imbert
2026-09-04 12:08:22 +02:00
co-authored by Claude Opus 5
parent b100606da6
commit 725d40f935
7 changed files with 133 additions and 6 deletions
+18 -1
View File
@@ -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)
})
})