From 3f0c33cb9555370eaa50592bb991f9d445c8bc44 Mon Sep 17 00:00:00 2001 From: Endri Bezati Date: Fri, 2 Oct 2026 11:24:11 +0200 Subject: [PATCH] One editor per workflow hierarchy: nested workflows in any file open in place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A diagram whose nested workflows live in other files opened a new editor for every one drilled into -- a tab per level, each with its own chat, a Run button running the workflow on screen, and its own layout. With the profile's `nestedNavigation: 'in-place'` (default `'new-editor'`, so other products are unchanged), a drill-down or breadcrumb into any file shows that workflow in the same editor, as a view of the editor's root: - the client keys its navigation stack by the editor's root, and "Used By" goes in place to an ancestor, or opens a caller in another file as a new root with no trail; - the host keys the refresh context by the editor the request came from and remembers the shown file, so saves, external changes and live preview refresh the view shown (another file from disk); - the root runs: ▶ Run, ⟲ and "Rerun from Here" use the root, the latter rerunning the root-level instance a nested view is in, and a refresh during a run keeps the view rather than jumping to the root; - the chat stays the root's, and its turn context now names the nested view on screen and the file defining it; - "Open in Its Own Editor" on a nested workflow opens its file as a root of its own. Phases 1 and 2 of docs/proposals/single-editor-hierarchy.md. Claude-Session: https://claude.ai/code/session_015VK7fH1c4aKbexnq2QcuKU --- docs/proposals/single-editor-hierarchy.md | 28 ++-- .../src/chat-panel-integrated.ts | 17 ++- .../src/editing-action-handlers.ts | 62 +++++++- packages/diagram-client/src/navigation-ui.ts | 133 ++++++++++++++++-- .../src/network-navigation-mouse-listener.ts | 11 +- packages/diagram-client/src/profile.ts | 4 + .../src/stock-features.module.ts | 3 + .../src/tool-palette-header-controls.ts | 10 +- .../test/container-parity.test.ts | 7 +- .../fixtures/container-parity.baseline.json | 5 + .../test/in-place-navigation.test.ts | 104 ++++++++++++++ .../src/server/context-menu-item-provider.ts | 31 ++++ .../test/open-in-own-editor.test.ts | 52 +++++++ packages/extension-core/src/api.ts | 4 + .../src/extension/chat/chat-runtime.ts | 54 +++++++ .../diagram/diagram-editor-provider.ts | 33 ++++- .../src/extension/diagram/glsp-activation.ts | 20 ++- .../test/chat-view-context.test.ts | 25 ++++ ...m-editor-provider-in-place-refresh.test.ts | 87 ++++++++++++ .../network-navigation-mouse-listener.test.ts | 55 +++++++- .../src/sidecar-diagram-profile.ts | 4 + 21 files changed, 702 insertions(+), 47 deletions(-) create mode 100644 packages/diagram-client/test/in-place-navigation.test.ts create mode 100644 packages/diagram-server/test/open-in-own-editor.test.ts create mode 100644 packages/extension-core/test/chat-view-context.test.ts create mode 100644 packages/extension-core/test/diagram-editor-provider-in-place-refresh.test.ts diff --git a/docs/proposals/single-editor-hierarchy.md b/docs/proposals/single-editor-hierarchy.md index 658765a..ab3a99e 100644 --- a/docs/proposals/single-editor-hierarchy.md +++ b/docs/proposals/single-editor-hierarchy.md @@ -1,6 +1,6 @@ # Proposal: one editor for a whole workflow hierarchy -**Status:** proposal. Nothing here is implemented. +**Status:** phases 1 and 2 are implemented (in-place navigation, the root's run, and the view in the chat's context). The GLSP-MCP root/view split, phases 3 to 5 and the outline are not. **Affects:** dialogram (most of it), wfpy (a hierarchy export), wfpy-ide (one profile flag). @@ -197,13 +197,25 @@ comes from the same export, and is the cheap part of it: names, files and sizes. ## Phasing -1. **In-place navigation.** The profile flag, drill-down / breadcrumb / "Used - By" in place, the host's per-editor trail and refresh context, and "Open in - its own editor". -2. **The root runs, the chat knows the view.** Root fields in the diagram - context, and Run, Rerun from Here and ⟲ from the root. The run driver and - refresh keyed by the editor. The trail in the chat's turn context, and the - GLSP-MCP root/view split. +1. **In-place navigation.** *Done.* `clientBehavior.nestedNavigation` + (`'in-place'` / `'new-editor'`, default `'new-editor'`). Drill-down and the + breadcrumb stay in the editor for any file. "Used By" navigates in place to + an ancestor in the trail or to a workflow of the editor's own document, and + opens a caller in another file as a new root with no trail. The client's + navigation stack is keyed by the editor's root. The host keys the refresh + context by the editor (from the request's client) and remembers the shown + file, so a save, an external change or a live preview refreshes the view + shown. For another file it reloads from disk, since live-preview content is + the root's text. A node menu item opens a nested workflow's file in its own + editor. +2. **The root runs, the chat knows the view.** *Done, except the GLSP-MCP + split.* The diagram context carries `rootSourceUri`, `rootWorkflowName` and + the trail. ▶ Run, ⟲ and "Rerun from Here" use the root, and inside a nested + view "Rerun from Here" reruns the root-level instance the view is in. The + refresh during and after a run reloads the editor's current view (file, + workflow, trail) rather than jumping to the root. The chat panel sends the + view's trail with the selection and each message, and the turn context names + the nested view and the file defining it. 3. **Editing nested views.** Secondary documents saved after undo/redo, the root editor watching every file in its trail, and the concurrency behaviour tested. diff --git a/packages/diagram-client/src/chat-panel-integrated.ts b/packages/diagram-client/src/chat-panel-integrated.ts index 2e9eeb7..83d395c 100644 --- a/packages/diagram-client/src/chat-panel-integrated.ts +++ b/packages/diagram-client/src/chat-panel-integrated.ts @@ -160,6 +160,20 @@ interface CommandEntry { * Communication with the extension host happens over the GLSP vscode-messenger * channel (the diagram webview's raw postMessage is owned by GLSP). */ + +/** + * The view on screen, as a trail from the editor's root, when it is not the + * root itself. + * + * The chat is the root's for the whole hierarchy, so without this the agent + * cannot tell that the diagram shows a nested workflow -- nor which instance + * the selected nodes belong to. + */ +export function currentViewTrail(): Array<{ sourceUri: string; workflowName: string; workflowInstanceName?: string }> | undefined { + const trail = (globalThis as any).__calDiagramContext?.trail; + return Array.isArray(trail) && trail.length > 1 ? trail : undefined; +} + @injectable() export class ChatPanel implements IDiagramStartup, ISelectionListener { /** Run after the diagram model is ready so the webview DOM exists. */ @@ -258,7 +272,7 @@ export class ChatPanel implements IDiagramStartup, ISelectionListener { */ selectionChanged(_root: unknown, selectedElements: string[]): void { this.selectedNodeIds = selectedElements ?? []; - this.sendToHost('chat.selection', { selectedNodeIds: this.selectedNodeIds }); + this.sendToHost('chat.selection', { selectedNodeIds: this.selectedNodeIds, viewTrail: currentViewTrail() }); if (this.initialized) this.update(); } @@ -811,6 +825,7 @@ export class ChatPanel implements IDiagramStartup, ISelectionListener { sessionId: this.currentSessionId, mode: this.currentMode, selectedNodeIds: this.selectedNodeIds, + viewTrail: currentViewTrail(), }); if (!ok) { diff --git a/packages/diagram-client/src/editing-action-handlers.ts b/packages/diagram-client/src/editing-action-handlers.ts index b266962..1f25fb9 100644 --- a/packages/diagram-client/src/editing-action-handlers.ts +++ b/packages/diagram-client/src/editing-action-handlers.ts @@ -1,8 +1,10 @@ import { inject, injectable, optional } from 'inversify'; import { EditorContextService, IActionDispatcher, IGridManager, TYPES } from '@eclipse-glsp/client'; -import { Action as GlspAction, ApplyLabelEditOperation, ICommand, IActionHandler } from '@eclipse-glsp/sprotty'; +import { Action as GlspAction, ApplyLabelEditOperation, ICommand, IActionHandler, NavigateToExternalTargetAction } from '@eclipse-glsp/sprotty'; import { VscodeUi, type VscodeQuickPickItem } from './vscode-ui'; import { clientBehavior, commandId, settingsNamespace } from './profile'; +import { rerunActorFor } from './navigation-ui'; +import { NETWORK_NAME_ARG, OPEN_DIAGRAM_ARG } from './network-navigation-target'; import { EXECUTION_OVERLAY_ACTION_KIND, type ExecutionOverlayActionPayload } from '@dialogram/shared'; export type WorkflowWorkspaceEntity = { @@ -87,6 +89,21 @@ export namespace WorkflowRerunFromHereAction { } } +export namespace WorkflowOpenInOwnEditorAction { + export const KIND = 'dialogram.openInOwnEditor'; + + export interface Action extends GlspAction { + kind: typeof KIND; + uri: string; + workflowName: string; + } + + export function is(action: unknown): action is Action { + return !!action && typeof action === 'object' && (action as any).kind === KIND + && typeof (action as any).uri === 'string' && typeof (action as any).workflowName === 'string'; + } +} + export namespace WorkflowRenameEntityOperation { export const KIND = 'dialogram.renameEntity'; @@ -1198,7 +1215,14 @@ export class WorkflowRerunFromHereActionHandler implements IActionHandler { return; } const context = (globalThis as any).__calDiagramContext as - | { sourceUri?: string; workflowName?: string; runDir?: string } + | { + sourceUri?: string; + workflowName?: string; + runDir?: string; + rootSourceUri?: string; + rootWorkflowName?: string; + trail?: Array<{ workflowInstanceName?: string }>; + } | undefined; // The menu offers it only for a run that can be resumed; anything the // run driver then finds wrong with it, the driver reports. @@ -1206,11 +1230,39 @@ export class WorkflowRerunFromHereActionHandler implements IActionHandler { console.warn('[workflow] Rerun from Here: no resumable run is shown'); return; } + // The root's run, at the root's step: inside a nested view, the node is + // rerun by rerunning the instance it is in. void VscodeUi.instance.executeCommand(commandId('runWorkflow'), [{ - sourceUri: context.sourceUri, - workflowName: context.workflowName, + sourceUri: context.rootSourceUri ?? context.sourceUri, + workflowName: context.rootWorkflowName ?? context.workflowName, resumeFrom: context.runDir, - resumeAtActor: action.entityName + resumeAtActor: rerunActorFor(action.entityName, context.trail ?? []) }]); } } + + +/** + * "Open in Its Own Editor": open a nested workflow's file as a root of its own. + * + * No trail goes with it, on purpose: the new editor's root is that file, with + * its own chat and runs, not a view of this editor's hierarchy. + */ +@injectable() +export class WorkflowOpenInOwnEditorActionHandler implements IActionHandler { + @inject(TYPES.IActionDispatcher) + protected readonly actionDispatcher!: IActionDispatcher; + + handle(action: GlspAction): ICommand | GlspAction | void { + if (!WorkflowOpenInOwnEditorAction.is(action)) { + return; + } + void this.actionDispatcher.dispatch(NavigateToExternalTargetAction.create({ + uri: action.uri, + args: { + [OPEN_DIAGRAM_ARG]: true, + [NETWORK_NAME_ARG]: action.workflowName + } + }) as never); + } +} diff --git a/packages/diagram-client/src/navigation-ui.ts b/packages/diagram-client/src/navigation-ui.ts index 6546b16..21ae160 100644 --- a/packages/diagram-client/src/navigation-ui.ts +++ b/packages/diagram-client/src/navigation-ui.ts @@ -135,6 +135,51 @@ function isQueueTraceVisible(): boolean { } } +/** Whether a nested workflow in another file opens in this editor, not its own. */ +export function navigatesInPlace(): boolean { + return clientBehavior().nestedNavigation === 'in-place'; +} + +/** + * The workflow this editor runs: the root of its hierarchy, not the view. + * + * Navigating in place, the root is the first crumb, the editor's own document. + * Otherwise a stack can start in another editor's file (a drill-down passes its + * trail along), so the root is the first crumb in this editor's document -- the + * top of what this editor shows. Without a stack, the view is the root. + */ +export function rootOfStack( + stack: Array<{ sourceUri: string; workflowName: string }>, + editorSourceUri: string | undefined, + inPlace: boolean, + shown: { sourceUri: string; workflowName: string } +): { sourceUri: string; workflowName: string } { + if (stack.length === 0) { + return shown; + } + if (inPlace) { + return { sourceUri: stack[0].sourceUri, workflowName: stack[0].workflowName }; + } + const editorKey = editorSourceUri ? normalizeSourceUriKey(editorSourceUri) : shown.sourceUri; + const first = stack.find(crumb => crumb.sourceUri === editorKey); + return first ? { sourceUri: first.sourceUri, workflowName: first.workflowName } : shown; +} + +/** + * The node a "Rerun from Here" reruns, as the run's trace names it. + * + * The trace has a step per firing of the root workflow's own actors. A node + * inside a nested view is not one of them: the instance the view is inside, + * at the root's level, is -- rerunning it reruns the node. + */ +export function rerunActorFor( + entityName: string, + stack: Array<{ workflowInstanceName?: string }> +): string { + const top = stack.slice(1).find(crumb => typeof crumb.workflowInstanceName === 'string' && crumb.workflowInstanceName.trim() !== ''); + return top?.workflowInstanceName?.trim() ?? entityName; +} + /** The body class that shows the queue-size badges on the edges. */ export const DEBUG_EXPANDED_CLASS = 'workflow-debug-expanded'; @@ -215,6 +260,27 @@ export class WorkflowNavigationUi { @inject(EditorContextService) private readonly editorContext: EditorContextService ) {} + /** + * The navigation stack a view belongs to. + * + * Navigating in place, every view in this editor -- in any file -- is a view + * of one hierarchy, rooted at the editor's own document, so they share its + * stack. Opening a nested file in its own editor, the stack is the shown + * file's, as it always was. + */ + private stackKey(sourceUri: string): string { + const root = this.editorContext.sourceUri; + return normalizeSourceUriKey(navigatesInPlace() && root ? root : sourceUri); + } + + private getStack(sourceUri: string): NavigationCrumb[] | undefined { + return this.stacksBySourceUri.get(this.stackKey(sourceUri)); + } + + private setStack(sourceUri: string, stack: NavigationCrumb[]): void { + this.stacksBySourceUri.set(this.stackKey(sourceUri), stack); + } + buildNavigationTrail( currentSourceUri: string | undefined, targetSourceUri: string, @@ -223,7 +289,7 @@ export class WorkflowNavigationUi { ): NavigationCrumb[] { const currentSourceKey = currentSourceUri ? normalizeSourceUriKey(currentSourceUri) : undefined; const targetSourceKey = normalizeSourceUriKey(targetSourceUri); - const fromCurrent = currentSourceKey ? (this.stacksBySourceUri.get(currentSourceKey) ?? []) : []; + const fromCurrent = currentSourceKey ? (this.getStack(currentSourceKey) ?? []) : []; const trail = [...fromCurrent]; if (trail.length === 0 && currentSourceKey) { @@ -252,20 +318,20 @@ export class WorkflowNavigationUi { const sourceKey = normalizeSourceUriKey(sourceUri); if (Array.isArray(trail) && trail.length > 0) { - this.stacksBySourceUri.set(sourceKey, this.normalizeTrail(trail)); + this.setStack(sourceKey, this.normalizeTrail(trail)); return; } - const stack = this.stacksBySourceUri.get(sourceKey) ?? []; + const stack = this.getStack(sourceKey) ?? []; if (stack.length === 0) { - this.stacksBySourceUri.set(sourceKey, [{ sourceUri: sourceKey, workflowName }]); + this.setStack(sourceKey, [{ sourceUri: sourceKey, workflowName }]); return; } const last = stack[stack.length - 1]; if (last?.sourceUri === sourceKey && last?.workflowName === workflowName) { return; } - this.stacksBySourceUri.set(sourceKey, [...stack, { sourceUri: sourceKey, workflowName }]); + this.setStack(sourceKey, [...stack, { sourceUri: sourceKey, workflowName }]); } onModelChanged(root: unknown): void { @@ -327,7 +393,20 @@ export class WorkflowNavigationUi { const incomingTrail = this.parseTrail(args[NAV_TRAIL_ARG]); const nextStack = this.reconcileStack(sourceUri, selected, entryWorkflows, incomingTrail); - this.stacksBySourceUri.set(sourceUri, nextStack); + this.setStack(sourceUri, nextStack); + + // What ▶ Run, "Rerun from Here" and the stepper's ⟲ act on: the root of + // this editor's hierarchy, whatever view is on screen. + const hierarchyRoot = rootOfStack(nextStack, this.editorContext.sourceUri, navigatesInPlace(), { sourceUri, workflowName: selected }); + try { + Object.assign((globalThis as any).__calDiagramContext ?? {}, { + rootSourceUri: hierarchyRoot.sourceUri, + rootWorkflowName: hierarchyRoot.workflowName, + trail: nextStack + }); + } catch { + // ignore + } const runtimeProfile = typeof args['wf:runtimeProfile'] === 'string' ? args['wf:runtimeProfile'] : undefined; @@ -362,7 +441,7 @@ export class WorkflowNavigationUi { } } - const current = this.stacksBySourceUri.get(sourceUri) ?? []; + const current = this.getStack(sourceUri) ?? []; if (current.length === 0) { return [{ sourceUri, workflowName: selected }]; } @@ -418,7 +497,7 @@ export class WorkflowNavigationUi { } this.setStoredEntryWorkflow(sourceUri, picked); const nextTrail = [{ sourceUri, workflowName: picked }]; - this.stacksBySourceUri.set(sourceUri, nextTrail); + this.setStack(sourceUri, nextTrail); void this.actionDispatcher.dispatch(this.requestModel(sourceUri, picked, undefined, nextTrail)); }); } @@ -508,7 +587,7 @@ export class WorkflowNavigationUi { btn.className = 'workflow-wf-breadcrumb'; const displayName = entry.workflowInstanceName || entry.workflowName; btn.textContent = displayName; - const isCrossFile = entry.sourceUri !== currentSourceKey; + const isCrossFile = entry.sourceUri !== currentSourceKey && !navigatesInPlace(); btn.title = idx === stack.length - 1 ? `Current: ${displayName}` : (isCrossFile ? `Open ${displayName} in referenced file` : `Go back to ${displayName}`); @@ -516,8 +595,8 @@ export class WorkflowNavigationUi { btn.addEventListener('click', () => { const nextTrail = stack.slice(0, idx + 1); const next = nextTrail[nextTrail.length - 1]; - this.stacksBySourceUri.set(next.sourceUri, nextTrail); - if (next.sourceUri === currentSourceKey) { + this.setStack(next.sourceUri, nextTrail); + if (next.sourceUri === currentSourceKey || navigatesInPlace()) { void this.actionDispatcher.dispatch(this.requestModel(next.sourceUri, next.workflowName, undefined, nextTrail, selectedRunId)); return; } @@ -801,7 +880,7 @@ export class WorkflowNavigationUi { } this.setStoredEntryWorkflow(sourceUri, picked); const nextTrail = [{ sourceUri, workflowName: picked }]; - this.stacksBySourceUri.set(sourceUri, nextTrail); + this.setStack(sourceUri, nextTrail); void this.actionDispatcher.dispatch(this.requestModel(sourceUri, picked, undefined, nextTrail, meta.selectedRunId)); } @@ -827,7 +906,29 @@ export class WorkflowNavigationUi { const nextTrail = existingParentIndex >= 0 ? stack.slice(0, existingParentIndex + 1) : [{ sourceUri: selectedParent.sourceUri, workflowName: selectedParent.workflowName }]; - this.stacksBySourceUri.set(selectedParent.sourceUri, nextTrail); + if (navigatesInPlace()) { + const editorRoot = this.editorContext.sourceUri ? normalizeSourceUriKey(this.editorContext.sourceUri) : sourceUri; + // An ancestor already in this editor's trail, or a workflow of the + // editor's own document: a view of this editor, shown in place. + if (existingParentIndex >= 0 || selectedParent.sourceUri === editorRoot) { + this.setStack(sourceUri, nextTrail); + void this.actionDispatcher.dispatch(this.requestModel(selectedParent.sourceUri, selectedParent.workflowName, undefined, nextTrail, meta.selectedRunId)); + return; + } + // A caller in another file is a root of its own: its own editor, and + // no trail of this one, which keeps its root, chat and run. + void this.actionDispatcher.dispatch( + NavigateToExternalTargetAction.create({ + uri: selectedParent.sourceUri, + args: { + [OPEN_DIAGRAM_ARG]: true, + [NETWORK_NAME_ARG]: selectedParent.workflowName + } + }) + ); + return; + } + this.setStack(selectedParent.sourceUri, nextTrail); if (selectedParent.sourceUri === sourceUri) { void this.actionDispatcher.dispatch(this.requestModel(sourceUri, selectedParent.workflowName, undefined, nextTrail, meta.selectedRunId)); return; @@ -912,11 +1013,13 @@ export class WorkflowNavigationUi { container.appendChild(status); container.appendChild(next); + // The run is the root's, so a resume is too, whatever view is shown. + const context = (globalThis as any).__calDiagramContext; const resumeRequest = stepperResumeRequest( queueTrace, clientBehavior().resumeAtStep === true, - sourceUri, - meta.selected + typeof context?.rootSourceUri === 'string' ? context.rootSourceUri : sourceUri, + typeof context?.rootWorkflowName === 'string' ? context.rootWorkflowName : meta.selected ); if (resumeRequest) { const resume = document.createElement('button'); diff --git a/packages/diagram-client/src/network-navigation-mouse-listener.ts b/packages/diagram-client/src/network-navigation-mouse-listener.ts index fc6675e..c8ba4fb 100644 --- a/packages/diagram-client/src/network-navigation-mouse-listener.ts +++ b/packages/diagram-client/src/network-navigation-mouse-listener.ts @@ -9,7 +9,7 @@ import { } from '@eclipse-glsp/sprotty'; import { inject, injectable } from 'inversify'; import { WorkflowDiagramTypes, WorkflowDiagramMetadata } from '@dialogram/shared'; -import { WorkflowNavigationUi } from './navigation-ui'; +import { navigatesInPlace, WorkflowNavigationUi } from './navigation-ui'; import { clientBehavior } from './profile'; import { buildCrossFileNavigationTarget, @@ -164,8 +164,13 @@ export class WorkflowNetworkNavigationMouseListener extends MouseListener implem ? navTrail[0]?.workflowName?.trim() || currentWorkflowName : undefined; - // Same-file: switch diagram model within the current editor. - if (normalizedCurrentSourceUri && normalizedReferencedUri === normalizedCurrentSourceUri) { + // Same file, or any file when navigating in place: switch the diagram + // model within the current editor. The request names the defining file; + // the trail keeps the editor's root at its head. + if ( + (normalizedCurrentSourceUri && normalizedReferencedUri === normalizedCurrentSourceUri) + || navigatesInPlace() + ) { this.workflowNavUi.noteNavigate(referencedUri, targetNetworkName, navTrail); const includeGraphSourceFallback = useGraphSourceNavigation && !!normalizedCurrentSourceUri diff --git a/packages/diagram-client/src/profile.ts b/packages/diagram-client/src/profile.ts index 2c252db..63e5236 100644 --- a/packages/diagram-client/src/profile.ts +++ b/packages/diagram-client/src/profile.ts @@ -29,6 +29,10 @@ type DiagramClientBehavior = { acpConnectors?: AcpConnectorInfo[]; /** Whether a run can be resumed at a step of its queue trace. */ resumeAtStep?: boolean; + /** Where a nested workflow defined in another file opens: `in-place` shows it in + * this editor, as a view of the editor's root (one editor per hierarchy); + * `new-editor` (the default) opens the file in its own editor. */ + nestedNavigation?: 'in-place' | 'new-editor'; }; export type AcpConnectorInfo = { diff --git a/packages/diagram-client/src/stock-features.module.ts b/packages/diagram-client/src/stock-features.module.ts index cfdbbda..194580e 100644 --- a/packages/diagram-client/src/stock-features.module.ts +++ b/packages/diagram-client/src/stock-features.module.ts @@ -33,6 +33,8 @@ import { WorkflowPromptRenameEntityActionHandler, WorkflowRerunFromHereAction, WorkflowRerunFromHereActionHandler, + WorkflowOpenInOwnEditorAction, + WorkflowOpenInOwnEditorActionHandler, WorkflowEditParametersAction, WorkflowEditParametersActionHandler, WorkflowEditAnnotationsAction, @@ -102,6 +104,7 @@ export const workflowFeaturesModule = new ContainerModule((bind, unbind, isBound configureActionHandler(context, WorkflowToggleGridAction.KIND, WorkflowToggleGridActionHandler); configureActionHandler(context, WorkflowPromptRenameEntityAction.KIND, WorkflowPromptRenameEntityActionHandler); configureActionHandler(context, WorkflowRerunFromHereAction.KIND, WorkflowRerunFromHereActionHandler); + configureActionHandler(context, WorkflowOpenInOwnEditorAction.KIND, WorkflowOpenInOwnEditorActionHandler); configureActionHandler(context, WorkflowEditParametersAction.KIND, WorkflowEditParametersActionHandler); configureActionHandler(context, WorkflowEditAnnotationsAction.KIND, WorkflowEditAnnotationsActionHandler); configureActionHandler(context, WorkflowShowWorkspaceEntitiesAction.KIND, WorkflowShowWorkspaceEntitiesActionHandler); diff --git a/packages/diagram-client/src/tool-palette-header-controls.ts b/packages/diagram-client/src/tool-palette-header-controls.ts index f18fb90..2d96478 100644 --- a/packages/diagram-client/src/tool-palette-header-controls.ts +++ b/packages/diagram-client/src/tool-palette-header-controls.ts @@ -8,6 +8,9 @@ type DiagramContext = { runtimeProfile?: string; namespaceName?: string; queueTraceVisible?: boolean; + /** The root of the editor's hierarchy: what runs (navigation-ui `rootOfStack`). */ + rootSourceUri?: string; + rootWorkflowName?: string; }; const QUEUE_TRACE_VISIBLE_STORAGE_KEY = queueTraceVisibleStorageKey(); @@ -186,7 +189,12 @@ function patchHeaderTools(headerTools: HTMLElement): void { void (async () => { const ctx = getDiagramContext(); if (ctx?.sourceUri && ctx?.workflowName) { - await exec(commandId('runWorkflow'), [ctx]); + // The root of the hierarchy runs, not the nested view on screen. + await exec(commandId('runWorkflow'), [{ + ...ctx, + sourceUri: ctx.rootSourceUri ?? ctx.sourceUri, + workflowName: ctx.rootWorkflowName ?? ctx.workflowName + }]); } else { // Fallback: host command uses active diagram tab. await exec(commandId('runWorkflow')); diff --git a/packages/diagram-client/test/container-parity.test.ts b/packages/diagram-client/test/container-parity.test.ts index 9ff263d..3f25731 100644 --- a/packages/diagram-client/test/container-parity.test.ts +++ b/packages/diagram-client/test/container-parity.test.ts @@ -15,11 +15,12 @@ * * The baseline is no longer a pure capture of that commit: features added since * the split are appended to it deliberately, and the fixture currently carries - * four additions — `IEdgeRouter -> LibavoidEdgeRouter` (the client-side live + * five additions — `IEdgeRouter -> LibavoidEdgeRouter` (the client-side live * routing tier), `ChangeBoundsTool -> WorkflowChangeBoundsTool` (the mouse-drag * threshold) and `IDiagramStartup -> WorkflowPaletteIconStartup` (draws the - * palette icons a product contributed), and the `dialogram.rerunFromHere` action handler - * (resumes the shown run before a node's last firing) — plus one change: `label:boundary:type` is bound to + * palette icons a product contributed), the `dialogram.rerunFromHere` action handler + * (resumes the shown run before a node's last firing) and the `dialogram.openInOwnEditor` + * one (opens a nested workflow's file as a root of its own) — plus one change: `label:boundary:type` is bound to * the non-editable `BoundaryLabel`, because editing it renamed the port. * The oracle still does its job: it fails on any binding this composition gains * or loses, and updating the fixture is the deliberate act of accepting one. diff --git a/packages/diagram-client/test/fixtures/container-parity.baseline.json b/packages/diagram-client/test/fixtures/container-parity.baseline.json index a20b690..9bb2fc0 100644 --- a/packages/diagram-client/test/fixtures/container-parity.baseline.json +++ b/packages/diagram-client/test/fixtures/container-parity.baseline.json @@ -34,6 +34,11 @@ "kind": "dialogram.rerunFromHere", "op": "actionHandler" }, + { + "handler": "WorkflowOpenInOwnEditorActionHandler", + "kind": "dialogram.openInOwnEditor", + "op": "actionHandler" + }, { "handler": "WorkflowShowAgentSkillsActionHandler", "kind": "dialogram.showAgentSkills", diff --git a/packages/diagram-client/test/in-place-navigation.test.ts b/packages/diagram-client/test/in-place-navigation.test.ts new file mode 100644 index 0000000..f354f9c --- /dev/null +++ b/packages/diagram-client/test/in-place-navigation.test.ts @@ -0,0 +1,104 @@ +/** + * Navigating a hierarchy in place: one editor, anchored to its root. + * + * Whatever view is shown -- a nested workflow, in any file -- the editor's + * root is what runs, what a resume resumes, and what "Rerun from Here" reruns + * in. A nested workflow can still be opened as a root of its own, on purpose. + */ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { VscodeUi } from '../src/vscode-ui'; +import { navigatesInPlace, rerunActorFor, rootOfStack } from '../src/navigation-ui'; +import { + WorkflowOpenInOwnEditorActionHandler, + WorkflowRerunFromHereActionHandler +} from '../src/editing-action-handlers'; + +const TOP = 'file:///w/top.py'; +const CHILD = 'file:///w/layers/block.py'; +const stack = [ + { sourceUri: TOP, workflowName: 'top' }, + { sourceUri: CHILD, workflowName: 'block', workflowInstanceName: 'b2' }, + { sourceUri: CHILD, workflowName: 'leaf', workflowInstanceName: 'l1' } +]; + +afterEach(() => { + (VscodeUi as any)._instance = undefined; + delete (globalThis as any).diagramIdentifier; + delete (globalThis as any).__calDiagramContext; +}); + +describe('whether the editor navigates in place', () => { + it('is the product’s choice, off by default', () => { + expect(navigatesInPlace()).toBe(false); + (globalThis as any).diagramIdentifier = { clientBehavior: { nestedNavigation: 'in-place' } }; + expect(navigatesInPlace()).toBe(true); + }); +}); + +describe('the root of an editor’s hierarchy', () => { + const shown = { sourceUri: CHILD, workflowName: 'leaf' }; + + it('is the first crumb, navigating in place', () => { + expect(rootOfStack(stack, TOP, true, shown)).toEqual({ sourceUri: TOP, workflowName: 'top' }); + }); + + it('is the first crumb in the editor’s own file otherwise', () => { + // An editor opened on block.py from top.py carries top.py's trail: its + // own top is the first crumb in block.py. + expect(rootOfStack(stack, CHILD, false, shown)).toEqual({ sourceUri: CHILD, workflowName: 'block' }); + }); + + it('is the view when there is no stack', () => { + expect(rootOfStack([], TOP, true, shown)).toEqual(shown); + }); +}); + +describe('what "Rerun from Here" reruns', () => { + it('is the node itself at the root’s level', () => { + expect(rerunActorFor('review', [{ sourceUri: TOP, workflowName: 'top' } as any])).toBe('review'); + }); + + it('is the root-level instance a nested view is inside', () => { + expect(rerunActorFor('leafNode', stack)).toBe('b2'); + }); + + it('resumes the root’s run from a nested view', () => { + const executeCommand = vi.fn(async () => undefined); + (VscodeUi as any)._instance = { executeCommand }; + (globalThis as any).diagramIdentifier = { + commandIds: { runWorkflow: 'product.runWorkflow' }, + clientBehavior: { resumeAtStep: true } + }; + (globalThis as any).__calDiagramContext = { + sourceUri: CHILD, + workflowName: 'leaf', + runDir: '/w/wf-out/r1', + rootSourceUri: TOP, + rootWorkflowName: 'top', + trail: stack + }; + + new WorkflowRerunFromHereActionHandler().handle({ kind: 'dialogram.rerunFromHere', entityName: 'leafNode' } as any); + + expect(executeCommand).toHaveBeenCalledWith('product.runWorkflow', [{ + sourceUri: TOP, + workflowName: 'top', + resumeFrom: '/w/wf-out/r1', + resumeAtActor: 'b2' + }]); + }); +}); + +describe('Open in Its Own Editor', () => { + it('opens the defining file as a root of its own: no trail goes with it', () => { + const handler = new WorkflowOpenInOwnEditorActionHandler(); + const dispatch = vi.fn(); + (handler as any).actionDispatcher = { dispatch }; + + handler.handle({ kind: 'dialogram.openInOwnEditor', uri: CHILD, workflowName: 'block' } as any); + + const navigate = dispatch.mock.calls[0][0]; + expect(navigate.uri).toBe(CHILD); + expect(navigate.args).toEqual({ 'cal:openDiagram': true, 'cal:networkName': 'block' }); + }); +}); diff --git a/packages/diagram-server/src/server/context-menu-item-provider.ts b/packages/diagram-server/src/server/context-menu-item-provider.ts index 03451da..5476102 100644 --- a/packages/diagram-server/src/server/context-menu-item-provider.ts +++ b/packages/diagram-server/src/server/context-menu-item-provider.ts @@ -20,6 +20,7 @@ const PROMPT_LABEL_EDIT_KIND = 'dialogram.promptLabelEdit'; const EDIT_PARAMETERS_KIND = 'dialogram.editParameters'; const PROMPT_RENAME_ENTITY_KIND = 'dialogram.promptRenameEntity'; const RERUN_FROM_HERE_KIND = 'dialogram.rerunFromHere'; +const OPEN_IN_OWN_EDITOR_KIND = 'dialogram.openInOwnEditor'; const RESET_EDGE_ROUTES_KIND = 'dialogram.resetEdgeRoutes'; const REROUTE_EDGES_AVOID_OVERLAPS_KIND = 'dialogram.rerouteEdgesAvoidOverlaps'; const LAYOUT_BOUNDARY_FLOW_KIND = 'dialogram.layoutBoundaryFlow'; @@ -136,6 +137,12 @@ function workflowDefinitionTargetFromRoot(rootElement: unknown, fallbackSourceUr return undefined; } +/** Whether two source URIs name the same file, ignoring query and fragment. */ +function sameSourceFile(a: string, b: string): boolean { + const strip = (uri: string): string => uri.trim().replace(/[?#].*$/, ''); + return strip(a) === strip(b); +} + function displayNameFromQualifiedName(qualifiedName: string): string { const afterDots = qualifiedName.split('.').pop() ?? qualifiedName; const parts = afterDots.split('__'); @@ -289,6 +296,30 @@ export class WorkflowContextMenuItemProvider extends ContextMenuItemProvider { elementType === WorkflowDiagramTypes.NODE_EXTERNAL_ACTOR ) { const entityName = elementArgs?.[WorkflowDiagramMetadata.ENTITY_NAME]; + // A nested workflow defined in another file can be opened as a root + // of its own: its own editor, chat and runs. Navigating in place, a + // double-click shows it here instead, so this is the way to the old + // behaviour on purpose. + const referencedUri = elementArgs?.[WorkflowDiagramMetadata.REFERENCED_URI]; + const referencedName = elementArgs?.[WorkflowDiagramMetadata.REFERENCED_ENTITY_NAME] + ?? elementArgs?.[WorkflowDiagramMetadata.ENTITY_TYPE]; + if ( + elementArgs?.[WorkflowDiagramMetadata.IS_NETWORK_INSTANCE] === true + && typeof referencedUri === 'string' && referencedUri.trim() !== '' + && typeof referencedName === 'string' && referencedName.trim() !== '' + && !sameSourceFile(referencedUri, sourceUri) + ) { + items.push({ + id: 'dialogram.openInOwnEditor', + label: 'Open in Its Own Editor', + sortString: 'a8', + actions: [{ + kind: OPEN_IN_OWN_EDITOR_KIND, + uri: referencedUri, + workflowName: displayNameFromQualifiedName(referencedName.trim()) + } as any] + }); + } // The run the overlay shows can be resumed at a step: offer to // resume it just before this node's last firing, so it fires again. const rootArgs = (this.modelState.root as any)?.args as Args | undefined; diff --git a/packages/diagram-server/test/open-in-own-editor.test.ts b/packages/diagram-server/test/open-in-own-editor.test.ts new file mode 100644 index 0000000..fc856f8 --- /dev/null +++ b/packages/diagram-server/test/open-in-own-editor.test.ts @@ -0,0 +1,52 @@ +/** + * "Open in Its Own Editor" on a nested workflow defined in another file. + * + * Navigating in place, a double-click shows a nested workflow inside this + * editor. This item is the way to open its file as a root of its own instead. + * It is offered only where that means something: a workflow instance whose + * definition is in a different file from the one shown. + */ +import { describe, expect, it } from 'vitest'; +import { WorkflowDiagramMetadata, WorkflowDiagramTypes } from '@dialogram/shared'; +import { WorkflowContextMenuItemProvider } from '../src/server/context-menu-item-provider'; + +const SHOWN = 'file:///w/top.py'; + +function itemsFor(args: Record): any[] { + const root: any = { id: 'root', type: 'graph', args: {} }; + const node: any = { id: 'n', type: WorkflowDiagramTypes.NODE_NETWORK, args, parent: root }; + const provider: any = new WorkflowContextMenuItemProvider(); + provider.modelState = { sourceUri: SHOWN, root, index: { find: () => node } }; + return provider.getItems(['n'], { x: 0, y: 0 }); +} + +const instance = (referencedUri: string) => ({ + [WorkflowDiagramMetadata.ENTITY_NAME]: 'b2', + [WorkflowDiagramMetadata.IS_NETWORK_INSTANCE]: true, + [WorkflowDiagramMetadata.REFERENCED_URI]: referencedUri, + [WorkflowDiagramMetadata.REFERENCED_ENTITY_NAME]: 'layers.block.block' +}); + +describe('Open in Its Own Editor', () => { + it('opens a workflow defined in another file, by its name', () => { + const item = itemsFor(instance('file:///w/layers/block.py')).find(i => i.id === 'dialogram.openInOwnEditor'); + + expect(item).toBeDefined(); + expect(item.actions).toEqual([{ + kind: 'dialogram.openInOwnEditor', + uri: 'file:///w/layers/block.py', + workflowName: 'block' + }]); + }); + + it('is not offered for a workflow defined in the file shown', () => { + const ids = itemsFor(instance(SHOWN)).map(i => i.id); + expect(ids).not.toContain('dialogram.openInOwnEditor'); + }); + + it('is not offered for a node that is not a workflow instance', () => { + const ids = itemsFor({ ...instance('file:///w/layers/block.py'), [WorkflowDiagramMetadata.IS_NETWORK_INSTANCE]: false }) + .map(i => i.id); + expect(ids).not.toContain('dialogram.openInOwnEditor'); + }); +}); diff --git a/packages/extension-core/src/api.ts b/packages/extension-core/src/api.ts index 0323c43..36dd0d2 100644 --- a/packages/extension-core/src/api.ts +++ b/packages/extension-core/src/api.ts @@ -295,6 +295,10 @@ export interface DiagramClientBehavior { */ paletteIcons?: Record; nodeFamilies?: NodeFamilySpec[]; + /** Where a nested workflow defined in another file opens: `in-place` shows it in + * this editor, as a view of the editor's root (one editor per hierarchy); + * `new-editor` (the default) opens the file in its own editor. */ + nestedNavigation?: 'in-place' | 'new-editor'; /** * Whether the host has a chat backend behind this diagram. * diff --git a/packages/extension-core/src/extension/chat/chat-runtime.ts b/packages/extension-core/src/extension/chat/chat-runtime.ts index 79b4c27..cdad878 100644 --- a/packages/extension-core/src/extension/chat/chat-runtime.ts +++ b/packages/extension-core/src/extension/chat/chat-runtime.ts @@ -91,6 +91,41 @@ export interface ChatRuntimeConfig { postTurnHook?: (file: string, text: string) => Promise; } +/** One step of the trail from the root workflow down to the view on screen. */ +export interface ViewCrumb { + sourceUri?: string; + workflowName: string; + workflowInstanceName?: string; +} + +/** + * Tell the agent which nested workflow the diagram is showing. + * + * The chat session is the root's for the whole hierarchy: the agent reads the + * root file and its graph. Navigating in place, the diagram can show a nested + * workflow, possibly defined in another file -- and the selected node ids are + * that view's. Nothing when the root itself is on screen. + */ +export function viewContextText(trail: ViewCrumb[] | undefined): string | undefined { + if (!trail || trail.length < 2) { + return undefined; + } + const path = trail + .map((crumb, i) => + i === 0 + ? crumb.workflowName + : `${crumb.workflowInstanceName ?? crumb.workflowName} (${crumb.workflowName})`, + ) + .join(" › "); + const shown = trail[trail.length - 1]; + const file = shown.sourceUri ? decodeURIComponent(shown.sourceUri.replace(/^file:\/\//, "")) : undefined; + return ( + `The diagram is showing a nested workflow, not the root: ${path}.` + + (file ? ` It is defined in ${file}.` : "") + + " Selected nodes belong to this view." + ); +} + export class ChatRuntime { private readonly acp = new ACPClientService(); private readonly sessions: SessionManager; @@ -110,6 +145,8 @@ export class ChatRuntime { private pendingModel: string | undefined; /** Latest diagram selection (node ids) per file, fed to the turn context. */ private readonly selectionByFile = new Map(); + /** The nested view on screen per file, when it is not the root (see `viewContextText`). */ + private readonly viewTrailByFile = new Map(); private readonly output: vscode.OutputChannel; /** Tears down the ACP -> webview event forwarding registered in the ctor. */ private readonly acpForwardingDisposer: () => void; @@ -151,6 +188,10 @@ export class ChatRuntime { } this.acp.setTurnContextBlocksProvider(async (file) => { const blocks: any[] = []; + const view = file ? viewContextText(this.viewTrailByFile.get(file)) : undefined; + if (view) { + blocks.push({ type: "text", text: view }); + } if (file && this.config.selectionContext !== false) { const selected = this.selectionByFile.get(file) ?? []; if (selected.length > 0) { @@ -467,6 +508,17 @@ export class ChatRuntime { await this.sendModels(uri); } + private setViewTrail(file: string, raw: unknown): void { + const trail = Array.isArray(raw) + ? raw.filter((c: any) => c && typeof c.workflowName === "string") + : []; + if (trail.length > 1) { + this.viewTrailByFile.set(file, trail as ViewCrumb[]); + } else { + this.viewTrailByFile.delete(file); + } + } + private fileFor(uri: string): string { return vscode.Uri.parse(uri).fsPath; } @@ -650,11 +702,13 @@ export class ChatRuntime { ? data.selectedNodeIds.map(String) : [], ); + this.setViewTrail(file, data?.viewTrail); return; case "chat.sendMessage": { if (Array.isArray(data?.selectedNodeIds)) { this.selectionByFile.set(file, data.selectedNodeIds.map(String)); } + this.setViewTrail(file, data?.viewTrail); await this.ensureStarted(cwd); // Fall back to the current session if the panel didn't pass one. let sessionId: string | undefined = diff --git a/packages/extension-core/src/extension/diagram/diagram-editor-provider.ts b/packages/extension-core/src/extension/diagram/diagram-editor-provider.ts index d4b65a3..5b365f4 100644 --- a/packages/extension-core/src/extension/diagram/diagram-editor-provider.ts +++ b/packages/extension-core/src/extension/diagram/diagram-editor-provider.ts @@ -54,7 +54,7 @@ export class WorkflowEditorProvider extends GlspEditorProvider { private uriToClientId = new Map(); /** Per-URI webview handles, for the profile's raw `postToWebview` channel. */ private uriToWebview = new Map(); - private uriToRefreshContext = new Map(); + private uriToRefreshContext = new Map(); private uriToLiveOverlaySignature = new Map(); /** @@ -566,17 +566,28 @@ export class WorkflowEditorProvider extends GlspEditorProvider { ): void { const refreshContext = this.getRefreshContext(sourceUri); + // The editor refreshes the view it shows. Navigating in place that can be + // a workflow in another file than the editor's own document: refresh that + // file, from disk -- live-preview `content` is the editor document's text, + // which is not the shown file's. + const shownSourceUri = refreshContext?.shownSourceUri; + const showsOtherFile = typeof shownSourceUri === 'string' + && this.canonicalizeUriString(shownSourceUri) !== this.canonicalizeUriString(sourceUri); + const effectiveOptions = showsOtherFile && options.content !== undefined + ? { forceReloadFromDisk: true } + : options; + // Create a RequestModelAction to refresh the diagram const action = RequestModelAction.create({ requestId: 'refresh-' + Date.now(), options: { - sourceUri, + sourceUri: showsOtherFile ? shownSourceUri : sourceUri, diagramType: this.diagramType, ...(typeof refreshContext?.queueTraceVisible === 'boolean' ? { queueTraceVisible: refreshContext.queueTraceVisible } : {}), ...(refreshContext?.networkName ? { networkName: refreshContext.networkName } : {}), ...(refreshContext?.navTrail ? { 'wf:navTrail': refreshContext.navTrail } : {}), ...(refreshContext?.runId ? { [RUN_ID_ARG]: refreshContext.runId } : {}), - ...options + ...effectiveOptions } }); @@ -587,7 +598,7 @@ export class WorkflowEditorProvider extends GlspEditorProvider { setRefreshContext( documentUri: vscode.Uri | string, - context: { networkName?: string; navTrail?: string; queueTraceVisible?: boolean; runId?: string } + context: { networkName?: string; navTrail?: string; queueTraceVisible?: boolean; runId?: string; shownSourceUri?: string } ): void { const key = this.canonicalizeUriString(documentUri); const next = { @@ -595,7 +606,7 @@ export class WorkflowEditorProvider extends GlspEditorProvider { ...context }; - if (!next.networkName && !next.navTrail && typeof next.queueTraceVisible !== 'boolean' && !next.runId) { + if (!next.networkName && !next.navTrail && typeof next.queueTraceVisible !== 'boolean' && !next.runId && !next.shownSourceUri) { this.uriToRefreshContext.delete(key); return; } @@ -603,7 +614,7 @@ export class WorkflowEditorProvider extends GlspEditorProvider { this.uriToRefreshContext.set(key, next); } - getRefreshContext(documentUri: vscode.Uri | string): { networkName?: string; navTrail?: string; queueTraceVisible?: boolean; runId?: string } | undefined { + getRefreshContext(documentUri: vscode.Uri | string): { networkName?: string; navTrail?: string; queueTraceVisible?: boolean; runId?: string; shownSourceUri?: string } | undefined { return this.uriToRefreshContext.get(this.canonicalizeUriString(documentUri)); } @@ -1024,6 +1035,16 @@ export class WorkflowEditorProvider extends GlspEditorProvider { * Resolve the GLSP client/session id for a given CAL document URI. * Useful for dispatching actions when the diagram tab is not the active webview. */ + /** The document an editor's client was opened on: its root. */ + getDocumentUriForClientId(clientId: string): string | undefined { + for (const [uri, id] of this.uriToClientId) { + if (id === clientId) { + return uri; + } + } + return undefined; + } + getClientIdForDocumentUri(uri: vscode.Uri): string | undefined { return this.uriToClientId.get(this.canonicalizeUriString(uri)); } diff --git a/packages/extension-core/src/extension/diagram/glsp-activation.ts b/packages/extension-core/src/extension/diagram/glsp-activation.ts index 5d935af..f8db054 100644 --- a/packages/extension-core/src/extension/diagram/glsp-activation.ts +++ b/packages/extension-core/src/extension/diagram/glsp-activation.ts @@ -863,7 +863,14 @@ export async function activateGlspIntegration( }; if (typeof sourceUri === 'string') { const finalOptions = (patched.action.options as any) ?? {}; - editorProvider?.setRefreshContext(sourceUri, { + // Keyed by the editor the request came from, not the file it + // asks for: navigating in place, an editor shows workflows of + // other files, and refreshes them as views of its own root. + const editorDocumentUri = typeof (message as any).clientId === 'string' + ? editorProvider?.getDocumentUriForClientId((message as any).clientId) + : undefined; + editorProvider?.setRefreshContext(editorDocumentUri ?? sourceUri, { + shownSourceUri: sourceUri, ...(typeof finalOptions.networkName === 'string' && finalOptions.networkName.trim() !== '' ? { networkName: finalOptions.networkName.trim() } : {}), @@ -1422,14 +1429,21 @@ function registerCalDiagramCommands( runOutput.appendLine(`[wf-lang live] SKIP ${kind === 'agentContextOnly' ? 'agent-ctx' : 'full'} refresh: provider=true clientId=undefined`); return; } + // The run is the root's; the view may be a nested workflow under it, + // in another file. Refresh the view the editor shows -- its file, + // workflow and trail -- or a run would pull it back to the root. + const view = editorProvider.getRefreshContext(uri); const refreshAction = RequestModelAction.create({ requestId: (kind === 'agentContextOnly' ? 'refresh-agent-ctx-' : 'refresh-during-run-') + Date.now(), options: { - sourceUri: uri.toString(), + sourceUri: view?.shownSourceUri ?? uri.toString(), diagramType: WORKFLOW_DIAGRAM_TYPE, ...(kind === 'agentContextOnly' ? { agentContextOnly: true } : {}), queueTraceVisible: getQueueTraceVisible(), - ...(networkName ? { networkName } : {}) + ...(view?.networkName + ? { networkName: view.networkName } + : (networkName ? { networkName } : {})), + ...(view?.navTrail ? { [NAV_TRAIL_ARG]: view.navTrail } : {}) } }); runOutput.appendLine(`[wf-lang live] dispatching ${kind === 'agentContextOnly' ? 'agent-ctx-only refresh' : 'full refresh'}`); diff --git a/packages/extension-core/test/chat-view-context.test.ts b/packages/extension-core/test/chat-view-context.test.ts new file mode 100644 index 0000000..fe97cb9 --- /dev/null +++ b/packages/extension-core/test/chat-view-context.test.ts @@ -0,0 +1,25 @@ +/** + * The chat is the root's for the whole hierarchy; the agent is told which + * nested workflow the diagram shows, so "this" and the selected nodes mean the + * view on screen, not the root. + */ +import { describe, expect, it } from 'vitest'; +import { viewContextText } from '../src/extension/chat/chat-runtime'; + +describe('the view on screen, in the chat’s turn context', () => { + it('says nothing when the root is on screen', () => { + expect(viewContextText(undefined)).toBeUndefined(); + expect(viewContextText([{ workflowName: 'top' }])).toBeUndefined(); + }); + + it('names the path from the root, the instance, and the file defining it', () => { + const text = viewContextText([ + { sourceUri: 'file:///w/top.py', workflowName: 'top' }, + { sourceUri: 'file:///w/layers/block.py', workflowName: 'block', workflowInstanceName: 'b2' } + ]); + + expect(text).toContain('top › b2 (block)'); + expect(text).toContain('/w/layers/block.py'); + expect(text).toContain('Selected nodes belong to this view'); + }); +}); diff --git a/packages/extension-core/test/diagram-editor-provider-in-place-refresh.test.ts b/packages/extension-core/test/diagram-editor-provider-in-place-refresh.test.ts new file mode 100644 index 0000000..0a87b67 --- /dev/null +++ b/packages/extension-core/test/diagram-editor-provider-in-place-refresh.test.ts @@ -0,0 +1,87 @@ +/** + * An editor refreshes the view it shows, which navigating in place can be a + * workflow in another file than its own document. + * + * The refresh context is kept per editor (its own document) and remembers the + * shown file. A refresh -- after a save, an external change, a live preview -- + * reloads that file at the view's workflow and trail. Live-preview content is + * the editor document's text, so for another file the view reloads from disk. + */ +import { describe, expect, it, vi } from 'vitest'; +import * as vscode from 'vscode'; +import type { DiagramProfile } from '../src/api'; + +vi.mock('@eclipse-glsp/vscode-integration', () => ({ + GlspEditorProvider: class { + onDidChangeCustomDocument: unknown; + constructor(protected readonly glspVscodeConnector: any) { + this.onDidChangeCustomDocument = glspVscodeConnector?.onDidChangeCustomDocument; + } + }, + GlspVscodeConnector: class {} +})); + +const { WorkflowEditorProvider } = await import('../src/extension/diagram/diagram-editor-provider'); + +const ROOT = vscode.Uri.file('/w/top.py'); +const CHILD = vscode.Uri.file('/w/layers/block.py'); + +function makeProvider() { + const dispatched: Array<{ action: any; clientId: string }> = []; + const connector = { + onDidChangeCustomDocument: undefined, + dispatchAction: (action: any, clientId: string) => dispatched.push({ action, clientId }) + } as any; + const context = { subscriptions: [] } as unknown as vscode.ExtensionContext; + const provider: any = new WorkflowEditorProvider(context, connector, {} as unknown as DiagramProfile); + provider.uriToClientId.set(provider.canonicalizeUriString(ROOT), 'client-top'); + return { provider, dispatched }; +} + +const trail = JSON.stringify([ + { sourceUri: ROOT.toString(), workflowName: 'top' }, + { sourceUri: CHILD.toString(), workflowName: 'block', workflowInstanceName: 'b2' } +]); + +describe('refreshing an editor that navigates in place', () => { + it('finds the editor’s document from its client', () => { + const { provider } = makeProvider(); + expect(provider.getDocumentUriForClientId('client-top')).toBe(provider.canonicalizeUriString(ROOT)); + expect(provider.getDocumentUriForClientId('nobody')).toBeUndefined(); + }); + + it('reloads the shown file at the view’s workflow and trail', () => { + const { provider, dispatched } = makeProvider(); + provider.setRefreshContext(ROOT, { shownSourceUri: CHILD.toString(), networkName: 'block', navTrail: trail }); + + provider.dispatchModelRefresh('client-top', provider.canonicalizeUriString(ROOT), { forceReloadFromDisk: true }); + + const options = dispatched[0].action.options; + expect(dispatched[0].clientId).toBe('client-top'); + expect(options.sourceUri).toBe(CHILD.toString()); + expect(options.networkName).toBe('block'); + expect(options['wf:navTrail']).toBe(trail); + }); + + it('reloads another file from disk rather than with the editor document’s text', () => { + const { provider, dispatched } = makeProvider(); + provider.setRefreshContext(ROOT, { shownSourceUri: CHILD.toString(), networkName: 'block' }); + + provider.dispatchModelRefresh('client-top', provider.canonicalizeUriString(ROOT), { content: 'top.py text' }); + + const options = dispatched[0].action.options; + expect(options.content).toBeUndefined(); + expect(options.forceReloadFromDisk).toBe(true); + }); + + it('refreshes its own document as before when that is what it shows', () => { + const { provider, dispatched } = makeProvider(); + provider.setRefreshContext(ROOT, { shownSourceUri: ROOT.toString(), networkName: 'top' }); + + provider.dispatchModelRefresh('client-top', provider.canonicalizeUriString(ROOT), { content: 'top.py text' }); + + const options = dispatched[0].action.options; + expect(options.sourceUri).toBe(provider.canonicalizeUriString(ROOT)); + expect(options.content).toBe('top.py text'); + }); +}); diff --git a/packages/extension-core/test/network-navigation-mouse-listener.test.ts b/packages/extension-core/test/network-navigation-mouse-listener.test.ts index 5e1c67f..2fb435b 100644 --- a/packages/extension-core/test/network-navigation-mouse-listener.test.ts +++ b/packages/extension-core/test/network-navigation-mouse-listener.test.ts @@ -17,7 +17,8 @@ vi.mock('@eclipse-glsp/client', () => ({ })); vi.mock('../../diagram-client/src/navigation-ui', () => ({ - WorkflowNavigationUi: class {} + WorkflowNavigationUi: class {}, + navigatesInPlace: () => (globalThis as any).diagramIdentifier?.clientBehavior?.nestedNavigation === 'in-place' })); vi.mock('@eclipse-glsp/sprotty', () => ({ @@ -142,4 +143,54 @@ describe('WorkflowNetworkNavigationMouseListener', () => { const actions = listener.doubleClick(target, {} as MouseEvent); expect(actions).toEqual([]); }); -}); \ No newline at end of file +}); + +/** + * A nested workflow defined in another file: a new editor by default, a view in + * this editor when the product navigates in place. + */ +describe('cross-file drill-down', () => { + const root = 'file:///workspace/top.py'; + const child = 'file:///workspace/layers/block.py'; + const navTrail = [ + { sourceUri: root, workflowName: 'top' }, + { sourceUri: child, workflowName: 'block', workflowInstanceName: 'b2' } + ]; + + function drillDown(nestedNavigation?: string) { + const listener = new WorkflowNetworkNavigationMouseListener(); + (listener as any).editorContext = { sourceUri: root, diagramType: 'workflow-diagram' }; + (listener as any).workflowNavUi = { buildNavigationTrail: () => navTrail, noteNavigate: () => {} }; + (globalThis as any).diagramIdentifier = { + clientBehavior: nestedNavigation ? { nestedNavigation } : {} + }; + (globalThis as any).__calDiagramContext = { workflowName: 'top' }; + return listener.doubleClick({ + type: WorkflowDiagramTypes.NODE_NETWORK, + args: { + [WorkflowDiagramMetadata.IS_NETWORK_INSTANCE]: true, + [WorkflowDiagramMetadata.REFERENCED_URI]: child, + [WorkflowDiagramMetadata.REFERENCED_ENTITY_NAME]: 'block', + 'wf:entityInstanceName': 'b2' + } + } as any, {} as MouseEvent); + } + + it('opens a new editor by default', () => { + const actions = drillDown(); + expect(actions).toHaveLength(1); + expect((actions[0] as any).kind).toBe('navigateToExternalTarget'); + }); + + it('shows the nested workflow in this editor when navigating in place', () => { + const actions = drillDown('in-place'); + + expect(actions).toHaveLength(1); + expect(RequestModelAction.is(actions[0] as any)).toBe(true); + const request = actions[0] as any; + // The defining file is what is rendered; the trail keeps the root first. + expect(request.options.sourceUri).toBe(child); + expect(request.options.networkName).toBe('block'); + expect(JSON.parse(request.options[NAV_TRAIL_ARG])[0]).toEqual({ sourceUri: root, workflowName: 'top' }); + }); +}); diff --git a/packages/sidecar-toolkit/src/sidecar-diagram-profile.ts b/packages/sidecar-toolkit/src/sidecar-diagram-profile.ts index ed6d98d..fb7b37e 100644 --- a/packages/sidecar-toolkit/src/sidecar-diagram-profile.ts +++ b/packages/sidecar-toolkit/src/sidecar-diagram-profile.ts @@ -57,6 +57,10 @@ export interface SidecarClientBehavior { networkNavigationLabels?: boolean; noneSentinel?: string; scriptInterpreterCommands?: string[]; + /** Where a nested workflow defined in another file opens: `in-place` shows it in + * this editor, as a view of the editor's root (one editor per hierarchy); + * `new-editor` (the default) opens the file in its own editor. */ + nestedNavigation?: 'in-place' | 'new-editor'; } /** The 24 consumer-owned command ids a sidecar diagram profile carries. */