diff --git a/frontend/src/lib/components/assets/AssetGraph/pipelineAiHelpers.ts b/frontend/src/lib/components/assets/AssetGraph/pipelineAiHelpers.ts index 5b22a098b1..fef664602c 100644 --- a/frontend/src/lib/components/assets/AssetGraph/pipelineAiHelpers.ts +++ b/frontend/src/lib/components/assets/AssetGraph/pipelineAiHelpers.ts @@ -214,6 +214,7 @@ export function createPipelineAiHelpers(deps: PipelineAiHelperDeps): PipelineAIC } const helpers: PipelineAIChatHelpers = { + getFolder: folderName, getPipelineContext: buildContext, getNodeBody: async (path) => { const draft = deps.getDrafts().get(path) diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index 7ccdb3d412..be25b4d4d3 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -498,11 +498,12 @@ export class AIChatManager implements ChatViewHost { skipResponsesApi = $state(false) mode = $state(AIMode.NAVIGATOR) - pipelineAiChatHelpers = $state(undefined) + // One per mounted pipeline editor; a session can have several folders open. + #pipelineEditors = new Set() // Resolved when a pipeline editor registers its tools. open_preview(pipeline) // awaits this so the model's next build_pipeline_node call can't race ahead of // the async canvas mount and hit "Unknown tool call". - #pipelineHelpersWaiters = new Set<() => void>() + #pipelineHelpersWaiters = new Set<(helpers: PipelineAIChatHelpers) => void>() readonly isOpen = $derived(chatState.size > 0) savedSize = $state(0) instructions = $state('') @@ -2485,7 +2486,7 @@ export class AIChatManager implements ChatViewHost { // Public because it is purely local, unlike `changeMode(GLOBAL)`, which also // fires the three network refreshes. configureGlobalMode = () => { - const pipeline = this.pipelineAiChatHelpers + const hasPipelines = this.#pipelineEditors.size > 0 const opts = this.globalAssemblyOpts() const baseHelpers: GlobalToolHelpers = { // A session targets its own fixed (possibly forked) workspace, so capture it for @@ -2517,7 +2518,9 @@ export class AIChatManager implements ChatViewHost { } } this.#assembledTools = assembleGlobalTools(opts) - this.helpers = pipeline ? { ...baseHelpers, pipeline } : baseHelpers + this.helpers = hasPipelines + ? { ...baseHelpers, pipelines: () => [...this.#pipelineEditors] } + : baseHelpers this.systemMessage = assembleGlobalSystemMessage(getCustomPromptParts(AIMode.GLOBAL), opts) this.syncArtifactsSession() } @@ -2530,7 +2533,7 @@ export class AIChatManager implements ChatViewHost { mcpServers: this.mcpServers, access: this.sessionAccess, sessionContext: this.sessionContextResolver?.(), - pipelineContext: this.pipelineAiChatHelpers?.getPipelineContext() + pipelineFolders: [...new Set([...this.#pipelineEditors].map((p) => p.getFolder()))] }) // Fetch the workspace's AI skills and, if GLOBAL mode is still active, rebuild @@ -4862,19 +4865,17 @@ export class AIChatManager implements ChatViewHost { // AI edits apply directly as drafts, so there is nothing to auto-accept. // Returns a cleanup that tears the registration back down. setPipelineHelpers = (pipelineHelpers: PipelineAIChatHelpers) => { - this.pipelineAiChatHelpers = pipelineHelpers + this.#pipelineEditors.add(pipelineHelpers) untrack(() => { if (this.mode === AIMode.GLOBAL) { this.configureGlobalMode() } }) // The pipeline tools are now registered — release anything awaiting them. - const waiters = [...this.#pipelineHelpersWaiters] - this.#pipelineHelpersWaiters.clear() - waiters.forEach((resolve) => resolve()) + ;[...this.#pipelineHelpersWaiters].forEach((notify) => notify(pipelineHelpers)) return () => { - this.pipelineAiChatHelpers = undefined + this.#pipelineEditors.delete(pipelineHelpers) untrack(() => { if (this.mode === AIMode.GLOBAL) { this.configureGlobalMode() @@ -4884,15 +4885,16 @@ export class AIChatManager implements ChatViewHost { } /** - * Await the pipeline editor's tool registration. Resolves `true` immediately - * when a pipeline editor is already mounted, or when the next one registers; - * resolves `false` after `timeoutMs` if none registers (e.g. a backgrounded - * session whose preview tab is not mounted, or a closed tab). Callers must - * treat `false` as "tools NOT available" rather than silently reporting - * success — the open_preview handler surfaces that to the model. + * Await the tool registration of the pipeline editor for `folder` (any folder + * when unset). Resolves `true` immediately when one is already mounted, or when + * it registers; resolves `false` after `timeoutMs` if none registers (e.g. a + * session whose runtime is not mounted, or a closed tab). Callers must treat + * `false` as "tools NOT available" rather than silently reporting success — + * the open_preview handler surfaces that to the model. */ - waitForPipelineHelpers = (timeoutMs = 8000): Promise => { - if (this.pipelineAiChatHelpers) return Promise.resolve(true) + waitForPipelineHelpers = (folder?: string, timeoutMs = 8000): Promise => { + const matches = (p: PipelineAIChatHelpers) => folder === undefined || p.getFolder() === folder + if ([...this.#pipelineEditors].some(matches)) return Promise.resolve(true) return new Promise((resolve) => { let settled = false const finish = (registered: boolean) => { @@ -4901,7 +4903,9 @@ export class AIChatManager implements ChatViewHost { this.#pipelineHelpersWaiters.delete(onRegister) resolve(registered) } - const onRegister = () => finish(true) + const onRegister = (p: PipelineAIChatHelpers) => { + if (matches(p)) finish(true) + } this.#pipelineHelpersWaiters.add(onRegister) setTimeout(() => finish(false), timeoutMs) }) diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts index 97a1afb2ff..d83211b1e5 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts @@ -4329,6 +4329,7 @@ describe('DOM selector chips scoped by app path', () => { describe('AIChatManager.waitForPipelineHelpers', () => { function fakePipelineHelpers(): PipelineAIChatHelpers { return { + getFolder: () => 'f', getPipelineContext: () => ({ folder: 'f', mode: 'edit', nodes: [], assets: [] }), getNodeBody: async () => undefined, proposeNode: async () => ({ path: '', detectedReads: [], detectedWrites: [] }), @@ -4341,13 +4342,13 @@ describe('AIChatManager.waitForPipelineHelpers', () => { it('resolves true immediately when a pipeline editor is already registered', async () => { const manager = new AIChatManager() manager.setPipelineHelpers(fakePipelineHelpers()) - await expect(manager.waitForPipelineHelpers(1000)).resolves.toBe(true) + await expect(manager.waitForPipelineHelpers(undefined, 1000)).resolves.toBe(true) }) it('resolves true once a pipeline editor registers', async () => { const manager = new AIChatManager() let outcome: boolean | undefined - const wait = manager.waitForPipelineHelpers(1000).then((v) => (outcome = v)) + const wait = manager.waitForPipelineHelpers(undefined, 1000).then((v) => (outcome = v)) await Promise.resolve() expect(outcome).toBeUndefined() manager.setPipelineHelpers(fakePipelineHelpers()) @@ -4355,12 +4356,24 @@ describe('AIChatManager.waitForPipelineHelpers', () => { expect(outcome).toBe(true) }) + it('waits for the editor of the requested folder, not any editor', async () => { + const manager = new AIChatManager() + manager.setPipelineHelpers(fakePipelineHelpers()) + let outcome: boolean | undefined + const wait = manager.waitForPipelineHelpers('sales', 1000).then((v) => (outcome = v)) + await Promise.resolve() + expect(outcome).toBeUndefined() + manager.setPipelineHelpers({ ...fakePipelineHelpers(), getFolder: () => 'sales' }) + await wait + expect(outcome).toBe(true) + }) + // The false result is the signal the open_preview handler needs: a backgrounded // session's editor never mounts, so it must report "tools unavailable" rather // than silently claim success. it('resolves false after the timeout when no editor ever registers', async () => { const manager = new AIChatManager() - await expect(manager.waitForPipelineHelpers(10)).resolves.toBe(false) + await expect(manager.waitForPipelineHelpers(undefined, 10)).resolves.toBe(false) }) }) diff --git a/frontend/src/lib/components/copilot/chat/global/globalAssembly.test.ts b/frontend/src/lib/components/copilot/chat/global/globalAssembly.test.ts index feffda425e..dcb46ca148 100644 --- a/frontend/src/lib/components/copilot/chat/global/globalAssembly.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/globalAssembly.test.ts @@ -15,22 +15,17 @@ vi.mock('@codingame/monaco-vscode-languages-service-override', () => ({ default: vi.mock('$lib/components/vscode', () => ({})) import { getSessionContextPromptSection, type SessionPromptContext } from './core' -import { getPipelinePromptSection, type PipelineContext } from '../pipeline/core' +import { getPipelinePromptSection } from '../pipeline/core' import { assembleGlobalSystemMessage, assembleGlobalTools } from './globalAssembly' const sessionContext: SessionPromptContext = { workspaceId: 'ws', parentWorkspaceId: 'parent' } -const pipelineContext: PipelineContext = { - folder: 'myfolder', - mode: 'edit', - nodes: [], - assets: [] -} +const pipelineFolders = ['myfolder'] describe('assembleGlobalTools', () => { it('includes the pipeline tools only when a pipeline editor is active', () => { const names = (opts: Parameters[0]) => assembleGlobalTools(opts).map((t) => t.def.function.name) - expect(names({ pipelineContext })).toContain('get_pipeline_graph') + expect(names({ pipelineFolders })).toContain('get_pipeline_graph') expect(names({})).not.toContain('get_pipeline_graph') }) }) @@ -41,13 +36,13 @@ describe('assembleGlobalSystemMessage', () => { it('carries both optional sections when both contexts are given', () => { const content = assembleGlobalSystemMessage(undefined, { sessionContext, - pipelineContext + pipelineFolders }).content as string expect(content).toContain(getSessionContextPromptSection(sessionContext)) - expect(content).toContain(getPipelinePromptSection(pipelineContext)) + expect(content).toContain(getPipelinePromptSection(pipelineFolders)) // Order is part of the assembled output, so it is pinned too. expect(content.indexOf(getSessionContextPromptSection(sessionContext))).toBeLessThan( - content.indexOf(getPipelinePromptSection(pipelineContext)) + content.indexOf(getPipelinePromptSection(pipelineFolders)) ) }) @@ -58,6 +53,6 @@ describe('assembleGlobalSystemMessage', () => { // shares, so a stray section built from any other context still trips them. const heading = (s: string) => s.trim().split('\n')[0] expect(content).not.toContain(heading(getSessionContextPromptSection(sessionContext))) - expect(content).not.toContain(heading(getPipelinePromptSection(pipelineContext))) + expect(content).not.toContain(heading(getPipelinePromptSection(pipelineFolders))) }) }) diff --git a/frontend/src/lib/components/copilot/chat/global/globalAssembly.ts b/frontend/src/lib/components/copilot/chat/global/globalAssembly.ts index a291e93ec7..d1fe80f13d 100644 --- a/frontend/src/lib/components/copilot/chat/global/globalAssembly.ts +++ b/frontend/src/lib/components/copilot/chat/global/globalAssembly.ts @@ -12,7 +12,7 @@ import { type SessionPromptContext } from './core' import { createMcpTools } from './mcpTools' -import { getPipelinePromptSection, pipelineTools, type PipelineContext } from '../pipeline/core' +import { getPipelinePromptSection, pipelineTools } from '../pipeline/core' import { getFolderInstructionsPromptSection, type FolderInstruction } from '../folderInstructions' // Derived, not retyped: an option added to prepareGlobalSystemMessage is reachable @@ -21,7 +21,8 @@ import { getFolderInstructionsPromptSection, type FolderInstruction } from '../f // directly — would get it. export type GlobalAssemblyOpts = NonNullable[1]> & { sessionContext?: SessionPromptContext - pipelineContext?: PipelineContext + /** Folders of the open pipeline editors; the pipeline tools and prompt come with any. */ + pipelineFolders?: readonly string[] folderInstructions?: readonly FolderInstruction[] } @@ -34,8 +35,8 @@ export function assembleGlobalSystemMessage( if (opts.sessionContext) { systemMessage.content += getSessionContextPromptSection(opts.sessionContext, opts.access) } - if (opts.pipelineContext) { - systemMessage.content += getPipelinePromptSection(opts.pipelineContext, opts.access) + if (opts.pipelineFolders?.length) { + systemMessage.content += getPipelinePromptSection(opts.pipelineFolders, opts.access) } return systemMessage } @@ -43,7 +44,7 @@ export function assembleGlobalSystemMessage( export function assembleGlobalTools(opts: GlobalAssemblyOpts): SessionTool[] { return [ ...globalToolsFor({ sessionPreview: opts.previewTools ?? false }), - ...(opts.pipelineContext ? pipelineTools : []), + ...(opts.pipelineFolders?.length ? pipelineTools : []), ...createMcpTools(opts.mcpServers ?? []) ] } diff --git a/frontend/src/lib/components/copilot/chat/global/sessionToolset.test.ts b/frontend/src/lib/components/copilot/chat/global/sessionToolset.test.ts index 42136112c4..143def2fc8 100644 --- a/frontend/src/lib/components/copilot/chat/global/sessionToolset.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/sessionToolset.test.ts @@ -32,7 +32,7 @@ import type { SessionAccess, SessionCapability, SessionTool } from '../sessionCa const ASSEMBLY_OPTS = { previewTools: true, - pipelineContext: { folder: 'my_pipeline', mode: 'edit', nodes: [], assets: [] }, + pipelineFolders: ['my_pipeline'], mcpServers: [{ path: 'f/test/server' } as any] } @@ -168,7 +168,7 @@ describe('session tool policies', () => { user: { username: 'alex', folders: ['shared'], folders_read: ['shared'] }, access, sessionContext: ctx, - pipelineContext: { folder: 'my_pipeline', mode: 'edit', nodes: [], assets: [] } + pipelineFolders: ['my_pipeline'] }) // Both decoration variants: the escalation one adds its own tool mentions. for (const blocks of [0, 9]) { diff --git a/frontend/src/lib/components/copilot/chat/pipeline/core.test.ts b/frontend/src/lib/components/copilot/chat/pipeline/core.test.ts index c0d9b7ac8b..c40bdeb015 100644 --- a/frontend/src/lib/components/copilot/chat/pipeline/core.test.ts +++ b/frontend/src/lib/components/copilot/chat/pipeline/core.test.ts @@ -39,7 +39,7 @@ const sampleContext: PipelineContext = { } function makeHelpers(overrides: Partial = {}): { - helpers: { pipeline: PipelineAIChatHelpers } + helpers: { pipelines: () => PipelineAIChatHelpers[] } calls: Record } { const calls: Record = {} @@ -50,6 +50,7 @@ function makeHelpers(overrides: Partial = {}): { return ret } const pipeline: PipelineAIChatHelpers = { + getFolder: () => sampleContext.folder, getPipelineContext: () => sampleContext, getNodeBody: async (path: string) => { calls.getNodeBody = [...(calls.getNodeBody ?? []), [path]] @@ -67,7 +68,7 @@ function makeHelpers(overrides: Partial = {}): { testNode: async () => 'job-123', ...overrides } - return { helpers: { pipeline }, calls } + return { helpers: { pipelines: () => [pipeline] }, calls } } describe('pipeline tools', () => { @@ -196,9 +197,60 @@ describe('pipeline tools', () => { }) }) +describe('pipeline tools with several editors open', () => { + function editorFor(folder: string, built: string[]): PipelineAIChatHelpers { + return { + ...makeHelpers().helpers.pipelines()[0], + getFolder: () => folder, + getPipelineContext: () => ({ ...sampleContext, folder }), + proposeNode: async (input) => { + built.push(`${folder}:${input.path}`) + return { path: input.path, detectedReads: [], detectedWrites: [] } + } + } + } + const call = (name: string, args: any, editors: PipelineAIChatHelpers[]) => + toolByName(name).fn({ + args, + workspace: 'w', + helpers: { pipelines: () => editors }, + toolCallbacks: noopCallbacks(), + toolId: 't' + }) + + it('routes a node to the editor of its folder', async () => { + const built: string[] = [] + const editors = [editorFor('crm', built), editorFor('sales', built)] + await call( + 'build_pipeline_node', + { path: 'f/sales/clean', language: 'bun', content: '// pipeline' }, + editors + ) + expect(built).toEqual(['sales:f/sales/clean']) + await expect( + call( + 'build_pipeline_node', + { path: 'f/other/clean', language: 'bun', content: '// pipeline' }, + editors + ) + ).rejects.toThrow(/open_preview\(kind="pipeline", path="other"\)/) + }) + + it('get_pipeline_graph needs the folder only when it is ambiguous', async () => { + const editors = [editorFor('crm', []), editorFor('sales', [])] + await expect(call('get_pipeline_graph', {}, editors)).rejects.toThrow(/pass the folder/) + expect( + JSON.parse(await call('get_pipeline_graph', { folder: 'f/sales' }, editors)) + ).toMatchObject({ folder: 'sales' }) + expect(JSON.parse(await call('get_pipeline_graph', {}, [editors[0]]))).toMatchObject({ + folder: 'crm' + }) + }) +}) + describe('getPipelinePromptSection', () => { it('names the active folder and the direct-draft workflow', () => { - const section = getPipelinePromptSection(sampleContext) + const section = getPipelinePromptSection([sampleContext.folder]) expect(section).toContain('/pipeline/analytics') expect(section).toContain('build_pipeline_node') expect(section).toContain('directly as unsaved drafts') diff --git a/frontend/src/lib/components/copilot/chat/pipeline/core.ts b/frontend/src/lib/components/copilot/chat/pipeline/core.ts index a1a8f1ad6b..b1bad57a7e 100644 --- a/frontend/src/lib/components/copilot/chat/pipeline/core.ts +++ b/frontend/src/lib/components/copilot/chat/pipeline/core.ts @@ -10,6 +10,7 @@ import { type SessionTool } from '../sessionCapabilities' import type { PipelineOutputKind } from '$lib/components/assets/AssetGraph/pipelineTemplates' +import { normalizePipelineFolder } from '$lib/utils/pipelineFolder' // ============================================================================ // Pipeline AI chat tools. @@ -57,6 +58,8 @@ export type PipelineContext = { * small — the page owns the draft Map and canvas rendering. */ export interface PipelineAIChatHelpers { + /** Folder name (no `f/` prefix) of the pipeline this editor shows. */ + getFolder: () => string getPipelineContext: () => PipelineContext /** Read a node's source (the in-flight draft body if one exists, else deployed). */ getNodeBody: (path: string) => Promise<{ language: ScriptLang; content: string } | undefined> @@ -81,16 +84,58 @@ export interface PipelineAIChatHelpers { testNode: (path: string, args?: Record) => Promise } -/** Helper bag the pipeline tools receive from the manager in global mode. */ -export type PipelineToolHelpers = { pipeline?: PipelineAIChatHelpers } +/** Helper bag the pipeline tools receive from the manager in global mode: every + * pipeline editor currently open, one per folder. */ +export type PipelineToolHelpers = { pipelines?: () => readonly PipelineAIChatHelpers[] } -function requirePipeline(helpers: PipelineToolHelpers): PipelineAIChatHelpers { - if (!helpers?.pipeline) { +function openPipelines(helpers: PipelineToolHelpers): readonly PipelineAIChatHelpers[] { + const open = helpers?.pipelines?.() ?? [] + if (open.length === 0) { throw new Error( 'No pipeline editor is open. Pipeline tools only work on a /pipeline/ page in edit mode.' ) } - return helpers.pipeline + return open +} + +function notOpenError(folder: string, open: readonly PipelineAIChatHelpers[]): Error { + const names = open.map((p) => `'${p.getFolder()}'`).join(', ') + return new Error( + `No pipeline editor is open for folder '${folder}' (open: ${names}). Open it with open_preview(kind="pipeline", path="${folder}") first.` + ) +} + +/** The editor of the pipeline a node path belongs to (`f//`). With a + * single editor open, a path outside it still goes to that editor, whose own check + * names the corrected path. */ +function pipelineForPath(helpers: PipelineToolHelpers, path: string): PipelineAIChatHelpers { + const open = openPipelines(helpers) + const folder = path.match(/^f\/([^/]+)\//)?.[1] + const match = folder ? open.find((p) => p.getFolder() === folder) : undefined + if (match) return match + if (open.length === 1) return open[0] + throw folder + ? notOpenError(folder, open) + : new Error( + `'${path}' is not a pipeline node path — nodes live at f//, with one of: ${open.map((p) => `'${p.getFolder()}'`).join(', ')}.` + ) +} + +function pipelineForFolder( + helpers: PipelineToolHelpers, + folder: string | undefined +): PipelineAIChatHelpers { + const open = openPipelines(helpers) + if (folder) { + const name = normalizePipelineFolder(folder) + const match = open.find((p) => p.getFolder() === name) + if (!match) throw notOpenError(name, open) + return match + } + if (open.length === 1) return open[0] + throw new Error( + `Several pipeline editors are open (${open.map((p) => `'${p.getFolder()}'`).join(', ')}) — pass the folder to read.` + ) } const scriptLangSchema = z.enum($ScriptLang.enum) @@ -105,12 +150,20 @@ const outputKindSchema = z // Read tools // ---------------------------------------------------------------------------- -const getPipelineGraphSchema = z.object({}) +const getPipelineGraphSchema = z.object({ + folder: z + .string() + .optional() + .describe( + 'Folder of the pipeline to read (e.g. `analytics`). Required when several pipeline editors are open; may be omitted when only one is.' + ) +}) const getPipelineGraphToolDef = createToolDef( getPipelineGraphSchema, 'get_pipeline_graph', - "Read the live pipeline graph for the open /pipeline/ editor: its nodes (scripts), each node's language, asset reads/writes, declared triggers, and whether it has an unsaved draft edit. Call this before building or editing nodes so you reuse existing assets/paths and understand the current DAG." + "Read the live pipeline graph of an open /pipeline/ editor: its nodes (scripts), each node's language, asset reads/writes, declared triggers, and whether it has an unsaved draft edit. Call this before building or editing nodes so you reuse existing assets/paths and understand the current DAG.", + { strict: false } ) const readPipelineNodeSchema = z.object({ @@ -131,7 +184,7 @@ const buildPipelineNodeSchema = z.object({ path: z .string() .describe( - "Workspace path for the new node, e.g. f//. Use the open pipeline's folder. Must not collide with an existing node." + 'Workspace path for the new node, e.g. f//, in the folder of an open pipeline editor. Must not collide with an existing node.' ), language: scriptLangSchema.describe( 'Script language. SQL-shaped data work uses duckdb (DuckLake/S3) or postgresql (data tables); bun/python3 for general transforms.' @@ -217,8 +270,9 @@ export const pipelineTools: SessionTool[] = [ requires: NONE, def: getPipelineGraphToolDef, planModeSafe: true, - fn: async ({ helpers, toolId, toolCallbacks }) => { - const pipeline = requirePipeline(helpers) + fn: async ({ args, helpers, toolId, toolCallbacks }) => { + const { folder } = getPipelineGraphSchema.parse(args ?? {}) + const pipeline = pipelineForFolder(helpers, folder) toolCallbacks.setToolStatus(toolId, { content: 'Reading pipeline graph...' }) const ctx = pipeline.getPipelineContext() toolCallbacks.setToolStatus(toolId, { @@ -233,8 +287,8 @@ export const pipelineTools: SessionTool[] = [ def: readPipelineNodeToolDef, planModeSafe: true, fn: async ({ args, helpers, toolId, toolCallbacks }) => { - const pipeline = requirePipeline(helpers) const { path } = readPipelineNodeSchema.parse(args) + const pipeline = pipelineForPath(helpers, path) toolCallbacks.setToolStatus(toolId, { content: `Reading node '${path}'...` }) const node = await pipeline.getNodeBody(path) if (!node) { @@ -251,8 +305,8 @@ export const pipelineTools: SessionTool[] = [ showDetails: true, showFade: true, fn: async ({ args, helpers, toolId, toolCallbacks }) => { - const pipeline = requirePipeline(helpers) const { path, language, content, output_kind } = buildPipelineNodeSchema.parse(args) + const pipeline = pipelineForPath(helpers, path) toolCallbacks.setToolStatus(toolId, { content: `Building node '${path}'...` }) const { detectedReads, detectedWrites } = await pipeline.proposeNode({ path, @@ -274,8 +328,8 @@ export const pipelineTools: SessionTool[] = [ showDetails: true, showFade: true, fn: async ({ args, helpers, toolId, toolCallbacks }) => { - const pipeline = requirePipeline(helpers) const { path, old_string, new_string, replace_all } = editPipelineNodeSchema.parse(args) + const pipeline = pipelineForPath(helpers, path) const node = await pipeline.getNodeBody(path) if (!node) { return `No pipeline node found at '${path}'. Call get_pipeline_graph to list the available nodes.` @@ -300,8 +354,8 @@ export const pipelineTools: SessionTool[] = [ requires: WRITE_DRAFT, def: removePipelineNodeToolDef, fn: async ({ args, helpers, toolId, toolCallbacks }) => { - const pipeline = requirePipeline(helpers) const { path } = removePipelineNodeSchema.parse(args) + const pipeline = pipelineForPath(helpers, path) toolCallbacks.setToolStatus(toolId, { content: `Discarding draft '${path}'...` }) await pipeline.removeProposedNode(path) toolCallbacks.setToolStatus(toolId, { @@ -319,8 +373,8 @@ export const pipelineTools: SessionTool[] = [ showDetails: true, autoCollapseDetails: false, fn: async ({ args, workspace, helpers, toolId, toolCallbacks }) => { - const pipeline = requirePipeline(helpers) const { path, args: runArgs } = testPipelineNodeSchema.parse(args) + const pipeline = pipelineForPath(helpers, path) return executeTestRun({ jobStarter: async () => { const jobId = await pipeline.testNode(path, runArgs ?? undefined) @@ -350,18 +404,30 @@ export const pipelineTools: SessionTool[] = [ * since the tools it names are withheld from that session; the annotation model stays, * as it is what lets the model read and explain an existing pipeline. */ -export function getPipelinePromptSection(ctx: PipelineContext, access?: SessionAccess): string { +export function getPipelinePromptSection( + folders: readonly string[], + access?: SessionAccess +): string { const canWriteDraft = !access || access.has('write_draft') + const [first] = folders + const openLine = + folders.length === 1 + ? `The user has the /pipeline/${first} editor open.` + : `The user has ${folders.length} pipeline editors open: ${folders.map((f) => `/pipeline/${f}`).join(', ')}. Each tool acts on the pipeline its node path's folder names; pass \`folder\` to get_pipeline_graph.` + const pathLine = + folders.length === 1 + ? `Every node of this pipeline lives at \`f/${first}/\` — \`${first}\` is the folder name and \`f/\` is the owner prefix every workspace path carries, so write it exactly once (never \`f/f/…\`, and never a bare \`\`).` + : `Every node of a pipeline lives at \`f//\` (e.g. \`f/${first}/\`) — \`f/\` is the owner prefix every workspace path carries, so write it exactly once (never \`f/f/…\`, and never a bare \`\`). A node can only be built in the folder of an open pipeline editor.` return ` Data Pipeline editor (ACTIVE): -- The user has the /pipeline/${ctx.folder} editor open. A pipeline is a DAG of scripts (nodes) connected by storage assets (DuckLake tables, data tables, S3 objects, volumes, resources) and execution triggers. +- ${openLine} A pipeline is a DAG of scripts (nodes) connected by storage assets (DuckLake tables, data tables, S3 objects, volumes, resources) and execution triggers. - Annotations are top-of-file comments in the NODE'S OWN comment syntax: \`--\` for SQL (duckdb/postgresql), \`#\` for python3/bash, \`//\` for bun/TS. The \`//\` shown below is the TS form — translate it (a \`// pipeline\` line in a SQL node is a syntax error that won't deploy). - A script becomes a pipeline node when its source starts with the \`// pipeline\` annotation. Declare execution-DAG inputs with \`// on \` (e.g. \`// on ducklake://main/orders\`). Outputs are inferred from what the body writes (wmill SDK calls / SQL CREATE TABLE / writeS3File); declare a managed output with \`// materialize \`. Optional badges: \`// partitioned \`, \`// freshness \`, \`// tag \`, \`// retry [delay]\`, \`// data_test ...\` (managed DuckLake targets only — deploy rejects it beside a \`dbt://\` target), \`// measure = [where ]\`, \`// dimension = \`. - \`materialize\` (the managed output): a managed \`// materialize ducklake:///\` means the runtime writes the node's output table FOR you — write the body as a single SELECT and the runtime wraps it in the create/replace, so do NOT also write your own CREATE TABLE / INSERT. The \`dbt://\` target below is the opposite: the node writes its own DDL and none of the write strategies apply to it. IMPORTANT: a MANAGED \`// materialize\` is **DuckDB-only** and its target MUST be a DuckLake table (\`ducklake:///
\`) — deploy rejects a \`ducklake://\` target on any other language. For a \`python3\`/\`bun\`/\`postgresql\` node writing the lake, do NOT use \`// materialize\`; write the output via the SDK instead (e.g. \`wmill.writeS3File(...)\`, a \`CREATE TABLE\` in postgresql, or \`wmill.databaseUrlFromResource\`/ducklake helpers) and let the output be inferred. Reach for \`duckdb\` when a node should materialize a DuckLake table. The one target any language BUT DBT'S OWN may declare (a dbt project's writes come from its manifest, so \`// materialize\` on a dbt script is rejected at deploy) is a WAREHOUSE RELATION: \`// materialize manual dbt:////\`, with \`\` a warehouse the workspace configures under Settings → dbt. \`manual\` is its only mode — nothing generates warehouse DDL, so the node issues its own write and the annotation records the outcome. Use it on an ingestion node a dbt project reads as a \`source\`: the declared relation and the dbt model become ONE graph node, and a downstream \`// on dbt:////\` fires when that node completes. Write strategy: with no option it REPLACES the whole table each run (full refresh; the only mode whose output columns may change); \`// materialize append\` INSERT-appends rows (incremental); \`// materialize key=\` merges/upserts on \`\`. \`// materialize manual \` opts OUT of managed writes — the script writes its own DDL and the annotation only records the output asset for lineage. \`materialize\` is paired with partitioning for incremental pipelines: a \`// partitioned \` node runs once per partition (append/merge into a fixed-schema table), and the \`{partition}\` token — usable in any asset URI AND in the body SQL — is substituted with the current partition's IDENTITY string at run time. To filter the source to the active slice on a time grain, use the runtime-injected macro: \`WHERE wm_partition() = {partition}\`. \`wm_partition(ts)\` buckets a timestamp with the exact identity format the runtime used (daily/hourly/weekly/monthly), so it always matches and you never hand-write a \`strftime\` format. Do NOT write \`= TIMESTAMP {partition}\`: the identity string is not a valid timestamp literal for hourly/weekly/monthly and errors at runtime. For \`dynamic\` partitioning the identity is your caller-supplied key (not a timestamp, no macro), so filter on it directly: \`WHERE = {partition}\`. \`materialize\` is an output DECLARATION on the node — it is not a command; there is no "materialize run". - \`measure\` / \`dimension\` (declared metrics): on a node that materializes a DuckLake table, \`// measure = [where ]\` names the canonical way to aggregate that table (e.g. \`// measure revenue = sum(amount) where not is_refund\`), and \`// dimension = \` names a way to slice it (e.g. \`// dimension region = region\`, \`// dimension month = date_trunc('month', ordered_at)\`). They execute nothing: they are catalogued at deploy so the editor and other agents can reuse the definition instead of re-deriving it and silently disagreeing. Keep the predicate in the \`where\` clause rather than folding it into the aggregate: it is rendered as \` FILTER (WHERE )\`, which is what lets two measures with different predicates sit under one GROUP BY. DuckLake-only, and only meaningful next to \`// materialize\`. Declare one when a number carries a judgement call someone else would get wrong (refunds excluded, test rows dropped, which column is the amount); do NOT blanket every table with measures, an obvious \`count(*)\` earns nothing. To USE a metric another node declares, read that node with read_pipeline_node and reuse its exact expression rather than guessing it. - Use get_pipeline_graph to see the current nodes/assets/triggers, and read_pipeline_node before editing one. -- Every node of this pipeline lives at \`f/${ctx.folder}/\` — \`${ctx.folder}\` is the folder name and \`f/\` is the owner prefix every workspace path carries, so write it exactly once (never \`f/f/…\`, and never a bare \`\`).${ +- ${pathLine}${ canWriteDraft ? ` - Build new nodes with build_pipeline_node and edit existing ones with edit_pipeline_node. These apply directly as unsaved drafts on the canvas (like the flow/script editor applies AI edits) — they DO NOT deploy. There is no separate Accept/Reject step. Prefer these over the generic write_script/edit_script draft tools while a pipeline is open. diff --git a/frontend/src/lib/components/sessions/PipelineEditorView.svelte b/frontend/src/lib/components/sessions/PipelineEditorView.svelte index 737db997cc..930154505f 100644 --- a/frontend/src/lib/components/sessions/PipelineEditorView.svelte +++ b/frontend/src/lib/components/sessions/PipelineEditorView.svelte @@ -29,7 +29,7 @@ /** Folder name the pipeline graph is scoped to (not a workspace item path). */ path: string workspaceId: string - /** Only the visible session registers the pipeline tools on its manager. */ + /** Only the visible session polls the live run badges. */ isActiveSession?: boolean /** Whether this is the foreground preview tab. */ active?: boolean @@ -43,39 +43,13 @@ // wrong chat — leaving this session's chat unable to build canvas nodes. const aiChatManager = runtime.manager - // Only the foreground tab of the foreground session owns the chat's pipeline - // tools and runs the live-badge poll — a background tab (another preview tab is - // showing, or another session is active) must not shadow that context or poll. + // Only the foreground tab of the foreground session runs the live-badge poll. const engaged = $derived(isActiveSession && active) - // Externalized editor state — lives on the runtime so the drafts persist across - // hide/show of the preview pane (the pane unmounts on hide). - const pe = runtime.pipelineEditorState - - // The reused `pe` is scoped to a folder. A same-folder remount (hide→show) - // keeps the drafts; a retarget to a different folder resets so stale drafts - // don't bleed across folders. untrack the writes so this can't self-loop. - $effect(() => { - const folder = path - untrack(() => { - if (pe.folder !== folder) { - if (pe.folder !== undefined) { - pe.reset() - // A retarget without remount re-scopes the poll, so the release - // effect could never match the old folder's job — drop the hint. - activeRunnable = undefined - activeRunnableJobId = undefined - // Re-scope the Global pipeline prompt to the new folder (the helper - // methods already read the reactive path, but the system message - // string was built for the old one). Only when this tab is engaged — - // its helpers are the registered set; a background tab reconfigures - // when it next becomes the foreground one. - if (engaged) aiChatManager.rebuildGlobalSystemMessage() - } - pe.folder = folder - } - }) - }) + // This folder's editor state lives on the runtime, so its drafts survive a + // remount. The host remounts this view when the tab moves to another folder, so + // `path` is fixed for its lifetime. + const pe = untrack(() => runtime.pipelineEditor(path)) const EMPTY_GRAPH: AssetGraphResponse = { assets: [], runnables: [], edges: [], triggers: [] } const EMPTY_PATH_MAP = new Map>() @@ -285,14 +259,10 @@ } } - // Register the pipeline tools on this session's manager while this tab is the - // engaged (foreground) one. setPipelineHelpers rebuilds the global tool set to - // include the pipeline tools and tears them down on cleanup — so switching to - // another preview tab or session releases them. - $effect(() => { - if (!engaged) return - return aiChatManager.setPipelineHelpers(helpers) - }) + // Register this folder's pipeline tools on the session's own manager for as long + // as the view is mounted — background tabs included, since each folder's tools + // act on its own state — and release them on unmount. + $effect(() => aiChatManager.setPipelineHelpers(helpers))
diff --git a/frontend/src/lib/components/sessions/PreviewTabHost.svelte b/frontend/src/lib/components/sessions/PreviewTabHost.svelte index f1d630caca..1f5ad9ab25 100644 --- a/frontend/src/lib/components/sessions/PreviewTabHost.svelte +++ b/frontend/src/lib/components/sessions/PreviewTabHost.svelte @@ -306,7 +306,10 @@ {#await import('./PipelineEditorView.svelte')} {@render editorLoading()} {:then Module} - + + {#key slot.path} + + {/key} {/await} {:else} {#await import('./RawAppEditorView.svelte')} diff --git a/frontend/src/lib/components/sessions/SessionPicker.svelte b/frontend/src/lib/components/sessions/SessionPicker.svelte index 9707b537b4..a06c4bd45b 100644 --- a/frontend/src/lib/components/sessions/SessionPicker.svelte +++ b/frontend/src/lib/components/sessions/SessionPicker.svelte @@ -1335,8 +1335,8 @@ bind:open={seedOfferOpen} kind="X" enterConfirms={false} - title="Keep this {seedOffer?.route.kind ?? 'item'} in the new session?" - description="You came here from {seedOffer?.route.itemPath ?? ''}." + title="Keep this {seedOffer?.kind ?? 'item'} in the new session?" + description="You came here from {seedOffer?.path ?? ''}." >

Keeping it opens it in the preview, so the chat starts with it as context. diff --git a/frontend/src/lib/components/sessions/openInSessionContext.ts b/frontend/src/lib/components/sessions/openInSessionContext.ts index ddecb01b4f..af1a5113ad 100644 --- a/frontend/src/lib/components/sessions/openInSessionContext.ts +++ b/frontend/src/lib/components/sessions/openInSessionContext.ts @@ -1,6 +1,6 @@ import { getContext, onDestroy, setContext } from 'svelte' import type { OpenInSessionSource } from './OpenInSessionButton.svelte' -import type { PreviewItemRoute } from './previewPaths' +import type { SessionTarget } from './sessionState.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 @@ -38,20 +38,17 @@ export function getOpenInSessionHandoff(): OpenInSessionHandoff | undefined { return getContext(KEY) } -/** The mounted editor's hand-off for the item `route` names, or undefined when - * no editor on screen publishes one for it (a legacy app, a detail page). - * Matched on the item rather than taken as "the latest registered": a script - * editor mounted in a flow's drawer registers too, and the rail wants the - * page's own item. */ +/** The mounted editor's hand-off for `target`, or undefined when no editor on + * screen publishes one for it (a detail page). Matched on the item rather than + * taken as "the latest registered": a script editor mounted in a flow's drawer + * registers too, and the rail wants the page's own item. */ export function findMountedOpenInSessionSource( - route: PreviewItemRoute + target: SessionTarget ): OpenInSessionSource | undefined { - const kind = route.kind === 'app' ? (route.raw_app ? 'raw_app' : undefined) : route.kind - if (!kind) return undefined for (const handoff of mounted) { const source = handoff.source() - const target = source?.target - if (target && target.kind === kind && target.path === route.itemPath) return source + const t = source?.target + if (t && t.kind === target.kind && t.path === target.path) return source } return undefined } diff --git a/frontend/src/lib/components/sessions/previewPaths.ts b/frontend/src/lib/components/sessions/previewPaths.ts index 58eca9504d..6b43afbf95 100644 --- a/frontend/src/lib/components/sessions/previewPaths.ts +++ b/frontend/src/lib/components/sessions/previewPaths.ts @@ -1,5 +1,6 @@ import { base } from '$lib/base' import type { WorkspaceItemKind } from '$lib/components/workspacePicker' +import { normalizePipelineFolder } from '$lib/utils/pipelineFolder' // The paths a preview location can point at, and the base handling around them. Kept apart // from `previewRouter`, which reads a location's *view* from each page's filter schema and @@ -166,3 +167,11 @@ export function parsePreviewItemRoute(fullPath: string): PreviewItemRoute | null if (m[1] === 'apps_raw') return { kind: 'app', raw_app: true, itemPath } return { kind: 'app', raw_app: false, itemPath } } + +// 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. +export function parsePipelineRoute(fullPath: string): string | null { + const m = stripBase(fullPath).match(/^\/pipeline\/([^/?#]+)/) + return m ? normalizePipelineFolder(decodeURIComponent(m[1])) : null +} diff --git a/frontend/src/lib/components/sessions/previewRouter.ts b/frontend/src/lib/components/sessions/previewRouter.ts index de21660f3f..0758ad12f9 100644 --- a/frontend/src/lib/components/sessions/previewRouter.ts +++ b/frontend/src/lib/components/sessions/previewRouter.ts @@ -11,6 +11,7 @@ import { pageKey, pageHref, parsePageItemRoute, + parsePipelineRoute, parsePreviewItemRoute, RESOURCES_PATH, RUNS_PATH, @@ -32,6 +33,7 @@ export { pageKey, pageHref, parsePageItemRoute, + parsePipelineRoute, parsePreviewItemRoute, stripBase, TRIGGER_PAGES, @@ -59,7 +61,6 @@ import { buildVariablesFilterSchema } from '$lib/components/variables/variablesF import { buildResourcesFilterSchema } from '$lib/components/resources/resourcesFilter' import { buildAssetsFilterSchema } from '$lib/components/assets/assetsFilter' import { COMPARE_ITEMS_PARAM } from './modifiedItemsMask' -import { normalizePipelineFolder } from '$lib/utils/pipelineFolder' import type { WorkspaceItem } from '$lib/components/workspacePicker' import type { SessionTargetKind } from './sessionRuntime.svelte' @@ -470,13 +471,6 @@ export function parsePreviewSelectedId(url: string): string | 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. -export function parsePipelineRoute(fullPath: string): string | null { - const m = stripBase(fullPath).match(/^\/pipeline\/([^/?#]+)/) - return m ? normalizePipelineFolder(decodeURIComponent(m[1])) : null -} // The id (before the query) is the artifact's stable routing identity; the name rides in the // hash so the tab strip labels it without a store lookup, and the version in the query so the @@ -530,8 +524,7 @@ export const isArtifactKey = (key: string) => key.startsWith('artifact:') // How a preview tab should render: as an in-process live editor or an iframe // fallback. Any editable item of a wrappable kind (script, flow, raw app) mounts // its per-(kind,path) cell editor; a `/pipeline/` route mounts the -// data-pipeline graph editor (single, shared runtime.pipelineEditorState — `path` -// is the folder); the list page of a page item kind mounts that list; everything +// data-pipeline graph editor of that folder (`path` is the folder); the list page of a page item kind mounts that list; everything // else (static pages, regular drag-and-drop apps, any other route) stays an iframe. export type PreviewSlot = | { kind: 'editor'; editorKind: SessionTargetKind | 'pipeline'; path: string } diff --git a/frontend/src/lib/components/sessions/sessionMode.svelte.ts b/frontend/src/lib/components/sessions/sessionMode.svelte.ts index a2c20e08a8..01f101d300 100644 --- a/frontend/src/lib/components/sessions/sessionMode.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionMode.svelte.ts @@ -1,9 +1,13 @@ import { base } from '$app/paths' import type { SessionTarget } from './sessionState.svelte' +import { normalizePipelineFolder } from '$lib/utils/pipelineFolder' // Maps a session's editor target to the canonical full-page Windmill route. export function sessionTargetHref(target: SessionTarget | undefined): string | undefined { if (!target) return undefined + if (target.kind === 'pipeline') { + return `${base}/pipeline/${encodeURIComponent(normalizePipelineFolder(target.path))}` + } const seg = target.kind === 'script' ? 'scripts/edit' diff --git a/frontend/src/lib/components/sessions/sessionMode.test.ts b/frontend/src/lib/components/sessions/sessionMode.test.ts index 9bc67b9a58..75d3445fed 100644 --- a/frontend/src/lib/components/sessions/sessionMode.test.ts +++ b/frontend/src/lib/components/sessions/sessionMode.test.ts @@ -13,9 +13,13 @@ describe('sessionTargetHref', () => { expect(sessionTargetHref({ kind: 'raw_app', path: 'u/me/app' })).toBe('/apps_raw/edit/u/me/app') }) - it('returns undefined for no target or a kind without a full-page route', () => { + it('maps a pipeline to its folder page, whichever form the folder is given in', () => { + expect(sessionTargetHref({ kind: 'pipeline', path: 'crm' })).toBe('/pipeline/crm') + expect(sessionTargetHref({ kind: 'pipeline', path: 'f/crm' })).toBe('/pipeline/crm') + }) + + it('returns undefined for no target', () => { expect(sessionTargetHref(undefined)).toBeUndefined() - expect(sessionTargetHref({ kind: 'pipeline', path: 'u/me/pipe' })).toBeUndefined() }) }) diff --git a/frontend/src/lib/components/sessions/sessionPreviewTabs.svelte.ts b/frontend/src/lib/components/sessions/sessionPreviewTabs.svelte.ts index a90eb83cc4..442a208f54 100644 --- a/frontend/src/lib/components/sessions/sessionPreviewTabs.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionPreviewTabs.svelte.ts @@ -421,20 +421,15 @@ export class SessionPreviewTabs { } } const url = targetUrl(target) - // Pipeline previews all share one runtime.pipelineEditorState, so keep at - // most one pipeline tab: re-point the existing one to the requested folder - // rather than opening a second pipeline editor that would fight over the - // shared state (`focused` when it already showed this folder, else `opened` - // since the view now shows a different pipeline). + // One tab per pipeline folder: two would mount two editors over one folder's + // state and draft. const pipelineFolder = parsePipelineRoute(url) if (pipelineFolder) { - const existing = this.#tabs.find((t) => parsePipelineRoute(t.url) !== null) + const existing = this.#tabs.find((t) => parsePipelineRoute(t.url) === pipelineFolder) if (existing) { - const same = existing.url === url - this.#retarget(existing, url) this.#activeId = existing.id this.#flush() - return { status: same ? 'focused' : 'opened' } + return { status: 'focused' } } } // Dedupe artifacts by id, not full url: an update may have changed the name the url carries. @@ -506,15 +501,11 @@ export class SessionPreviewTabs { return } } - // Keep at most one pipeline tab (all share runtime.pipelineEditorState): if a - // *different* tab already hosts a pipeline, retarget and focus it rather than - // turning the active tab into a second pipeline editor racing the shared - // state. Same invariant as open(); a no-op when the active tab is that tab. + // Same one-tab-per-folder rule as open(): focus the tab already on this folder. const pipelineFolder = parsePipelineRoute(targetUrl(target)) if (pipelineFolder) { - const existing = this.#tabs.find((x) => parsePipelineRoute(x.url) !== null) + const existing = this.#tabs.find((x) => parsePipelineRoute(x.url) === pipelineFolder) if (existing && existing.id !== t.id) { - this.#retarget(existing, targetUrl(target, existing)) this.#activeId = existing.id this.#flush() return diff --git a/frontend/src/lib/components/sessions/sessionPreviewTabs.test.ts b/frontend/src/lib/components/sessions/sessionPreviewTabs.test.ts index 8b1ba27ea0..f2a8fe5adb 100644 --- a/frontend/src/lib/components/sessions/sessionPreviewTabs.test.ts +++ b/frontend/src/lib/components/sessions/sessionPreviewTabs.test.ts @@ -552,19 +552,22 @@ describe('SessionPreviewTabs.navigate', () => { expect(o.tabs.find((t) => t.id === pageTabId)?.url).toBe('/runs') }) - it('retargets the one pipeline tab instead of turning the active tab into a second', () => { + it('keeps one tab per pipeline folder, several folders side by side', () => { const o = owner() o.open(pipelineTarget) - const pipelineTabId = o.activeId - o.open(scriptTarget) // a second, non-pipeline tab is now active - const scriptTabId = o.activeId - o.navigate(pipelineTarget2) - // No second pipeline editor: the existing one is retargeted and focused. + const crmTabId = o.activeId + o.open(pipelineTarget2) + const salesTabId = o.activeId expect(o.tabs).toHaveLength(2) - expect(o.activeId).toBe(pipelineTabId) - expect(o.tabs.find((t) => t.id === pipelineTabId)?.url).toBe(`${base}/pipeline/sales`) - // The script tab is untouched. - expect(o.tabs.find((t) => t.id === scriptTabId)?.url).toBe('/scripts/edit/u/me/foo') + expect(salesTabId).not.toBe(crmTabId) + o.open(scriptTarget) + // A folder already open is focused, from open() and navigate() alike. + o.open(pipelineTarget) + expect(o.activeId).toBe(crmTabId) + o.navigate(pipelineTarget2) + expect(o.activeId).toBe(salesTabId) + expect(o.tabs).toHaveLength(3) + expect(o.tabs.find((t) => t.id === crmTabId)?.url).toBe(`${base}/pipeline/crm`) }) it('retargets the active pipeline tab in place to a new folder', () => { diff --git a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts index 6109e1ab08..9c1296c0ad 100644 --- a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts @@ -191,9 +191,9 @@ export interface SessionRuntime { // (renderer) and the open_preview/get_preview_status tools cross it, so the // tab model has exactly one live copy. readonly previewTabs: SessionPreviewTabs - // Pipeline target state — persists across editor hide/show (the pane unmounts - // on hide, so this can't be component-local) and across session switches. - readonly pipelineEditorState: PipelineEditorState + // Pipeline editor state per folder — persists across editor remounts and + // session switches, so it can't be component-local. Created on first use. + pipelineEditor(folder: string): PipelineEditorState // Per-(kind, path) editor cells (content/baseline stores + load slot), created // on demand. Each editable preview tab resolves its own cell, so several items // stay live at once. @@ -577,10 +577,17 @@ function createRuntime(session: Session): SessionRuntime { // picker lists, so changeMode's network refreshes would fire once per listing. manager.configureGlobalMode() - // Pipeline target state lives on the runtime (not the PipelineEditorView - // component) so the in-session drafts survive hide/show of the editor pane — - // the pane unmounts on hide, and a component-local store would be discarded. - const pipelineEditorState = new PipelineEditorState() + const pipelineEditors = new Map() + function pipelineEditor(folder: string): PipelineEditorState { + const key = normalizePipelineFolder(folder) + let editor = pipelineEditors.get(key) + if (!editor) { + editor = new PipelineEditorState() + editor.folder = key + pipelineEditors.set(key, editor) + } + return editor + } const runtimeLogRequesters = new Map() // appPath → requester, one entry per mounted raw-app preview tab. @@ -594,7 +601,7 @@ function createRuntime(session: Session): SessionRuntime { sessionId: session.id, manager, previewTabs, - pipelineEditorState, + pipelineEditor, flowCell, loadedEditorPath, @@ -1211,8 +1218,8 @@ setOpenPreviewHandler(async ({ sessionId: callerSessionId, kind, path }) => { // "Unknown tool call" error on the first node it tries to build. if (kind === 'pipeline') { const folder = normalizePipelineFolder(path) - const ready = await runtime.manager.waitForPipelineHelpers() - // A backgrounded session's preview tab does not mount, so its editor never + const ready = await runtime.manager.waitForPipelineHelpers(folder) + // A session whose page is not mounted never mounts the editor, so it never // registers — don't claim success, or the model calls build_pipeline_node // into the void. Tell it the tools aren't available and how to recover. if (!ready) { diff --git a/frontend/src/lib/components/sessions/sessionSwitch.svelte.ts b/frontend/src/lib/components/sessions/sessionSwitch.svelte.ts index 36a91ee453..6824112eec 100644 --- a/frontend/src/lib/components/sessions/sessionSwitch.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionSwitch.svelte.ts @@ -15,7 +15,7 @@ import { type SessionTarget } from './sessionState.svelte' import { sessionTargetHref, withPreviewParams } from './sessionMode.svelte' -import { parsePreviewItemRoute, type PreviewItemRoute } from './previewPaths' +import { parsePipelineRoute, parsePreviewItemRoute } from './previewPaths' 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). @@ -42,10 +42,28 @@ export function rememberNavRoute(pathnameWithSearch: string): void { } /** An item editor the user reached session mode from, as the in-app href a new - * session's preview can open, plus the item it edits (for naming it). */ -export type NewSessionSeed = { url: string; route: PreviewItemRoute } + * session's preview can open, plus what it edits (for naming it). */ +export type NewSessionSeed = { url: string; kind: string; path: string } -// The item (flow, script, app) the user came to session mode from, or undefined +/** What an editor route edits: a session target, or an unset target for an item + * with no in-session editor (a legacy app), which is opened by its page instead. */ +function editedAt( + url: string +): { target: SessionTarget | undefined; kind: string; path: string } | undefined { + const folder = parsePipelineRoute(url) + if (folder) + return { target: { kind: 'pipeline', path: folder }, kind: 'pipeline', path: `f/${folder}` } + const route = parsePreviewItemRoute(url) + if (!route) return undefined + const kind = route.kind === 'app' ? (route.raw_app ? 'raw_app' : undefined) : route.kind + return { + target: kind ? { kind, path: route.itemPath } : undefined, + kind: route.kind, + path: route.itemPath + } +} + +// The item (flow, script, app, pipeline) the user came to session mode from, or undefined // when they came from anywhere else. A "New session" asked for after arriving // from an item is usually a session about that item, but the arrival route // (`enterSessionMode`) resumes whatever session was open, so the picker offers @@ -56,8 +74,8 @@ export type NewSessionSeed = { url: string; route: PreviewItemRoute } export function takeNewSessionSeed(): NewSessionSeed | undefined { if (navRouteOffered) return undefined navRouteOffered = true - const route = parsePreviewItemRoute(lastNavRoute) - return route ? { url: lastNavRoute, route } : undefined + const edited = editedAt(lastNavRoute) + return edited ? { url: lastNavRoute, kind: edited.kind, path: edited.path } : undefined } // The session entering session mode resumes: the active one if selected, else @@ -102,8 +120,8 @@ const RESUME_IDLE_LIMIT_MS = 60 * 60 * 1000 // enterSessionMode does. Rejects when the editor could not persist its draft, // so the caller can stay on the page and say so. export async function enterSessionModeFromNav(): Promise { - const route = parsePreviewItemRoute(lastNavRoute) - if (route) { + const edited = editedAt(lastNavRoute) + if (edited) { const resumable = resumableSession() if (!resumable || Date.now() - sessionLastActivityAt(resumable) > RESUME_IDLE_LIMIT_MS) { // The editor's own hand-off is what its "Open in AI session" button uses: @@ -112,7 +130,7 @@ export async function enterSessionModeFromNav(): Promise { // workspace the item lives in. Only an item with no such editor on // screen (a legacy app, a detail page) is opened by route, as last // persisted, in the workspace the route was scoped to. - const source = findMountedOpenInSessionSource(route) + const source = edited.target && findMountedOpenInSessionSource(edited.target) if (source) await openSourceInSession(source) else await openPageInSession(lastNavRoute, workspaceParamOf(lastNavRoute)) return diff --git a/frontend/src/lib/components/sessions/sessionSwitch.test.ts b/frontend/src/lib/components/sessions/sessionSwitch.test.ts index d550ad8df4..497e3cbad9 100644 --- a/frontend/src/lib/components/sessions/sessionSwitch.test.ts +++ b/frontend/src/lib/components/sessions/sessionSwitch.test.ts @@ -259,15 +259,21 @@ describe('takeNewSessionSeed', () => { rememberNavRoute('/flows/edit/u/me/my_flow?workspace=ws') expect(takeNewSessionSeed()).toEqual({ url: '/flows/edit/u/me/my_flow?workspace=ws', - route: { kind: 'flow', raw_app: false, itemPath: 'u/me/my_flow' } + kind: 'flow', + path: 'u/me/my_flow' }) // A dismissed or taken offer is not repeated until the user leaves again. expect(takeNewSessionSeed()).toBeUndefined() rememberNavRoute('/apps_raw/edit/f/team/dashboard?workspace=ws') - expect(takeNewSessionSeed()?.route).toEqual({ - kind: 'app', - raw_app: true, - itemPath: 'f/team/dashboard' + expect(takeNewSessionSeed()).toMatchObject({ kind: 'app', path: 'f/team/dashboard' }) + }) + + it('offers the pipeline the user came from, named by its folder', () => { + rememberNavRoute('/pipeline/crm?workspace=ws') + expect(takeNewSessionSeed()).toEqual({ + url: '/pipeline/crm?workspace=ws', + kind: 'pipeline', + path: 'f/crm' }) }) diff --git a/frontend/src/routes/(root)/(logged)/pipeline/[folder]/+page.svelte b/frontend/src/routes/(root)/(logged)/pipeline/[folder]/+page.svelte index 46b11dc76f..5a7fda56c8 100644 --- a/frontend/src/routes/(root)/(logged)/pipeline/[folder]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/pipeline/[folder]/+page.svelte @@ -118,6 +118,11 @@ import type { PipelineRecording } from '$lib/components/recording/types' import AutosaveIndicator from '$lib/components/AutosaveIndicator.svelte' import { onMount, tick, untrack } from 'svelte' + import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte' + import OpenInSessionButton, { + type OpenInSessionSource + } from '$lib/components/sessions/OpenInSessionButton.svelte' + import { setOpenInSessionHandoff } from '$lib/components/sessions/openInSessionContext' import { aiChatManager } from '$lib/components/copilot/chat/AIChatManager.svelte' import { AlertTriangle, @@ -589,6 +594,31 @@ onMount(() => aiChatManager.setPipelineHelpers(pipelineAiHelpers)) + // The session's preview hydrates this folder's drafts from its DB bundle, so the + // autosave still inside its debounce has to land first. + async function persistDraftsForSession(): Promise { + const workspace = $workspaceStore + if (!workspace) return + await tick() + await UserDraftDbSyncer.flush({ + workspace, + itemKind: PIPELINE_DRAFT_KIND, + path: pipelineDraftPath + }) + } + + const sessionOpen: OpenInSessionSource | undefined = $derived( + isOperator + ? undefined + : { + target: { kind: 'pipeline', path: folder }, + workspaceId: $workspaceStore ?? undefined, + beforeOpen: persistDraftsForSession + } + ) + // Lets the navigation rail's session switch open this pipeline, with its drafts. + setOpenInSessionHandoff({ source: () => sessionOpen }) + // Navigation guard state. `pendingNavigationUrl` holds the URL the user // tried to leave to so we can complete the navigation after they pick // "Save all" or "Discard all"; `bypassNavigationGuard` is the standard @@ -3265,6 +3295,7 @@ Activity {/if} +