diff --git a/packages/diagram-client/src/diagram-client.css b/packages/diagram-client/src/diagram-client.css
index 85a9f3f..e875b71 100644
--- a/packages/diagram-client/src/diagram-client.css
+++ b/packages/diagram-client/src/diagram-client.css
@@ -2936,7 +2936,8 @@ details[open] > summary .agent-chat-expand-arrow {
.workflow-fab-btn[hidden] {
display: none;
}
-.workflow-debug-toggle-btn.active {
+.workflow-debug-toggle-btn.active,
+.workflow-hierarchy-outline-toggle.active {
color: var(--vscode-focusBorder, #4daafc);
border-color: var(--vscode-focusBorder, #4daafc);
}
@@ -3273,23 +3274,13 @@ details[open] > summary .agent-chat-expand-arrow {
/* ── Hierarchy outline (hierarchy-outline.ts) ─────────────────────────── */
-.workflow-hierarchy-outline-toggle {
+/* The toggle lives in the floating button stack, under the chat button. Until
+ the stack exists it floats on its own, where the stack will be. */
+.workflow-hierarchy-outline-toggle.floating {
position: fixed;
- top: 44px;
- left: 8px;
- z-index: 30;
- width: 28px;
- height: 28px;
- border: 1px solid var(--vscode-editorWidget-border, #454545);
- border-radius: 4px;
- background: var(--vscode-editorWidget-background, #252526);
- color: var(--vscode-foreground, #cccccc);
- cursor: pointer;
-}
-
-.workflow-hierarchy-outline-toggle.active,
-.workflow-hierarchy-outline-toggle:hover {
- background: var(--vscode-toolbar-hoverBackground, #2a2d2e);
+ bottom: calc(var(--workflow-wf-nav-height, 0px) + 16px);
+ right: 14px;
+ z-index: 1002;
}
.workflow-hierarchy-outline {
diff --git a/packages/diagram-client/src/hierarchy-outline.ts b/packages/diagram-client/src/hierarchy-outline.ts
index f9092ac..f383fe8 100644
--- a/packages/diagram-client/src/hierarchy-outline.ts
+++ b/packages/diagram-client/src/hierarchy-outline.ts
@@ -121,7 +121,7 @@ export class HierarchyOutlinePanel {
placeholder="Filter instances…" aria-label="Filter instances" spellcheck="false" />
- `;
@@ -168,6 +168,10 @@ export class HierarchyOutlinePanel {
const panel = document.getElementById(PANEL_ID);
if (toggle) {
toggle.hidden = !available;
+ placeInButtonStack(toggle);
+ // The stack and the chat button may be put in place after this
+ // listener runs; place it again once they are.
+ requestAnimationFrame(() => placeInButtonStack(toggle));
}
if (panel) {
panel.hidden = !available || !this.open;
@@ -358,6 +362,32 @@ export class HierarchyOutlinePanel {
}
}
+/**
+ * Put the hierarchy button in the floating button stack, directly under the
+ * chat button. The stack is a reversed column -- what comes first in it sits
+ * lowest -- so "under chat" is just before it. Without a stack yet, the button
+ * floats on its own until one exists.
+ */
+export function placeInButtonStack(
+ toggle: HTMLElement,
+ doc: Pick = document
+): void {
+ const stack = doc.querySelector('.workflow-fab-stack');
+ if (!stack) {
+ toggle.classList.add('floating');
+ return;
+ }
+ toggle.classList.remove('floating');
+ const chat = doc.getElementById('workflow-chat-toggle-btn');
+ if (chat && chat.parentElement === stack) {
+ if (toggle.nextElementSibling !== chat) {
+ stack.insertBefore(toggle, chat);
+ }
+ } else if (toggle.parentElement !== stack) {
+ stack.appendChild(toggle);
+ }
+}
+
/** The navigation trail a model root carries (`wf:navTrail`, JSON or a list). */
export function trailOfRoot(root: unknown): Array<{ workflowName: string; workflowInstanceName?: string }> {
const raw = (root as { args?: Record })?.args?.['wf:navTrail'];
diff --git a/packages/diagram-client/src/initial-viewport-service.ts b/packages/diagram-client/src/initial-viewport-service.ts
index 7b9b470..9914a08 100644
--- a/packages/diagram-client/src/initial-viewport-service.ts
+++ b/packages/diagram-client/src/initial-viewport-service.ts
@@ -1,43 +1,27 @@
import { CenterAction } from '@eclipse-glsp/protocol';
-import { TYPES, type IActionDispatcher, EditorContextService } from '@eclipse-glsp/client';
+import { TYPES, type IActionDispatcher } from '@eclipse-glsp/client';
import { inject, injectable } from 'inversify';
-type RootArgs = {
- sourceUri?: string;
- 'wf:selectedWorkflow'?: string;
- 'cal:networkName'?: string;
- 'wf:workflowName'?: string;
-};
-
+/**
+ * Centers the diagram when a view is shown for the first time.
+ *
+ * Which views that is, is decided by `ViewportPreservingSetModelCommand`: a view
+ * never shown in this editor is centered, a view navigated back to returns to
+ * where it was left, and a refresh of the view shown keeps its viewport.
+ */
@injectable()
export class InitialViewportService {
- private readonly centeredKeys = new Set();
-
constructor(
- @inject(TYPES.IActionDispatcher) private readonly actionDispatcher: IActionDispatcher,
- @inject(EditorContextService) private readonly editorContext: EditorContextService
+ @inject(TYPES.IActionDispatcher) private readonly actionDispatcher: IActionDispatcher
) {}
/**
- * Center the diagram once when a workflow model is first shown.
+ * Center the diagram once it has rendered.
*
- * Uses a double-rAF so we run after the first paint/bounds computation.
+ * Uses a double-rAF so it runs after the first paint/bounds computation,
+ * when the viewport has a real size.
*/
- maybeCenterOnInitialModel(root: unknown): void {
- const args = ((root as any)?.args ?? {}) as RootArgs;
- const sourceUri = args.sourceUri ?? this.editorContext.sourceUri;
- const workflowName = args['wf:selectedWorkflow'] ?? args['cal:networkName'] ?? args['wf:workflowName'];
- if (!sourceUri || !workflowName) {
- return;
- }
-
- const key = `${sourceUri}::${workflowName}`;
- if (this.centeredKeys.has(key)) {
- return;
- }
- this.centeredKeys.add(key);
-
- // Run after initial render so viewport has a real size.
+ centerSoon(): void {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
void this.actionDispatcher.dispatch(CenterAction.create([], { animate: false }));
diff --git a/packages/diagram-client/src/viewport-preserving-set-model-command.ts b/packages/diagram-client/src/viewport-preserving-set-model-command.ts
index d11e140..9294c71 100644
--- a/packages/diagram-client/src/viewport-preserving-set-model-command.ts
+++ b/packages/diagram-client/src/viewport-preserving-set-model-command.ts
@@ -17,8 +17,13 @@ type RootArgs = {
/** One-shot guard for the always-on `firstSetModel` webview breadcrumb (see below). */
let firstSetModelLogged = false;
-function modelViewportKey(root: unknown): string | undefined {
- const args = ((root as any)?.args ?? {}) as RootArgs;
+/**
+ * Which view a model root is: its file, its workflow, and -- for a nested
+ * workflow shown in place -- the instance path that reached it, so two
+ * instances of one workflow are two views.
+ */
+export function modelViewportKey(root: unknown): string | undefined {
+ const args = ((root as any)?.args ?? {}) as RootArgs & { 'wf:navTrail'?: unknown };
const sourceUri = typeof args.sourceUri === 'string' ? args.sourceUri : undefined;
const workflowName =
(typeof args['wf:selectedWorkflow'] === 'string' ? args['wf:selectedWorkflow'] : undefined)
@@ -27,7 +32,68 @@ function modelViewportKey(root: unknown): string | undefined {
if (!sourceUri || !workflowName) {
return undefined;
}
- return `${sourceUri}::${workflowName}`;
+ return `${sourceUri}::${workflowName}${instancePathKey(args['wf:navTrail'])}`;
+}
+
+function instancePathKey(rawTrail: unknown): string {
+ try {
+ const trail = typeof rawTrail === 'string' ? JSON.parse(rawTrail) : rawTrail;
+ if (!Array.isArray(trail) || trail.length < 2) {
+ return '';
+ }
+ return '::' + trail.slice(1).map((crumb: any) => crumb?.workflowInstanceName ?? crumb?.workflowName ?? '').join('/');
+ } catch {
+ return '';
+ }
+}
+
+/**
+ * The viewport each view was last left at, so navigating back to one returns
+ * to where it was -- as a browser's back does -- rather than to the default
+ * scroll, which puts the graph off-center. Per webview: one editor's views.
+ */
+const viewportsByView = new Map();
+
+type Viewport = { zoom: number; scroll: { x: number; y: number } };
+type ViewportRoot = { zoom?: number; scroll?: { x: number; y: number } };
+
+/**
+ * Where the new model's viewport should be, decided across a `SetModelAction`:
+ *
+ * - the same view refreshed (a save, a run, a live preview): kept as it is;
+ * - a view navigated to that this editor showed before: back where it was left;
+ * - a view never shown here: centered (returns `'center'`).
+ *
+ * The view left is remembered in `store` first. Sets `newRoot`'s zoom and
+ * scroll in place for the first two cases.
+ */
+export function settleViewport(
+ previousRoot: ViewportRoot,
+ newRoot: ViewportRoot,
+ store: Map
+): 'center' | undefined {
+ const previousKey = modelViewportKey(previousRoot);
+ const nextKey = modelViewportKey(newRoot);
+ const sameView = !!previousKey && !!nextKey && previousKey === nextKey;
+ const hasViewport = (root: ViewportRoot): root is Viewport => typeof root.zoom === 'number' && !!root.scroll;
+
+ if (sameView) {
+ if (hasViewport(previousRoot) && hasViewport(newRoot)) {
+ newRoot.zoom = previousRoot.zoom;
+ newRoot.scroll = { ...previousRoot.scroll };
+ }
+ return undefined;
+ }
+ if (previousKey && hasViewport(previousRoot)) {
+ store.set(previousKey, { zoom: previousRoot.zoom, scroll: { ...previousRoot.scroll } });
+ }
+ const remembered = nextKey ? store.get(nextKey) : undefined;
+ if (remembered) {
+ newRoot.zoom = remembered.zoom;
+ newRoot.scroll = { ...remembered.scroll };
+ return undefined;
+ }
+ return 'center';
}
/**
@@ -83,24 +149,9 @@ export class ViewportPreservingSetModelCommand extends FeedbackAwareSetModelComm
};
this.workflowNavUi?.onModelChanged(newRoot);
- this.initialViewport.maybeCenterOnInitialModel(newRoot);
-
- const previousKey = modelViewportKey(previousRoot);
- const nextKey = modelViewportKey(newRoot);
- const sameWorkflowModel = !!previousKey && !!nextKey && previousKey === nextKey;
-
- // Preserve viewport (scroll/zoom) if present on both roots.
- // Important: do NOT carry viewport across workflow switches (e.g. parent -> child),
- // otherwise nested workflows inherit stale parent viewport framing.
- if (
- sameWorkflowModel &&
- typeof previousRoot.zoom === 'number' &&
- previousRoot.scroll &&
- typeof newRoot.zoom === 'number' &&
- newRoot.scroll
- ) {
- newRoot.zoom = previousRoot.zoom;
- newRoot.scroll = { ...previousRoot.scroll };
+
+ if (settleViewport(previousRoot, newRoot, viewportsByView) === 'center') {
+ this.initialViewport.centerSoon();
}
const idsToSelect = this.postEditSelection.consumeMatchingIds(newRoot);
diff --git a/packages/diagram-client/test/outline-button-and-viewport.test.ts b/packages/diagram-client/test/outline-button-and-viewport.test.ts
new file mode 100644
index 0000000..969b777
--- /dev/null
+++ b/packages/diagram-client/test/outline-button-and-viewport.test.ts
@@ -0,0 +1,114 @@
+/**
+ * The hierarchy button sits in the floating button stack, under the chat
+ * button; and navigating back to a view returns it to where it was left
+ * instead of to the default scroll, which put the graph off-center.
+ */
+import { describe, expect, it } from 'vitest';
+import { placeInButtonStack } from '../src/hierarchy-outline';
+import { modelViewportKey, settleViewport } from '../src/viewport-preserving-set-model-command';
+
+/** A minimal element tree: enough for parent/sibling bookkeeping. */
+class FakeElement {
+ parentElement: FakeElement | null = null;
+ children: FakeElement[] = [];
+ readonly classes = new Set();
+ constructor(readonly id = '') {}
+ get nextElementSibling(): FakeElement | null {
+ const siblings = this.parentElement?.children ?? [];
+ return siblings[siblings.indexOf(this) + 1] ?? null;
+ }
+ get classList() {
+ const classes = this.classes;
+ return { add: (c: string) => classes.add(c), remove: (c: string) => classes.delete(c) };
+ }
+ private detach(child: FakeElement): void {
+ const from = child.parentElement;
+ if (from) {
+ from.children.splice(from.children.indexOf(child), 1);
+ }
+ }
+ appendChild(child: FakeElement): void {
+ this.detach(child);
+ child.parentElement = this;
+ this.children.push(child);
+ }
+ insertBefore(child: FakeElement, before: FakeElement): void {
+ this.detach(child);
+ child.parentElement = this;
+ this.children.splice(this.children.indexOf(before), 0, child);
+ }
+}
+
+function docWith(stack: FakeElement | null, chat?: FakeElement): any {
+ return { querySelector: () => stack, getElementById: (id: string) => (chat && chat.id === id ? chat : null) };
+}
+
+describe('the hierarchy button', () => {
+ it('goes directly under the chat button: just before it in the reversed stack', () => {
+ const stack = new FakeElement();
+ const runs = new FakeElement('runs');
+ const chat = new FakeElement('workflow-chat-toggle-btn');
+ stack.appendChild(runs);
+ stack.appendChild(chat);
+ const toggle = new FakeElement('hierarchy');
+
+ placeInButtonStack(toggle as any, docWith(stack, chat));
+
+ expect(stack.children.map(c => c.id)).toEqual(['runs', 'hierarchy', 'workflow-chat-toggle-btn']);
+ expect(toggle.classes.has('floating')).toBe(false);
+ });
+
+ it('stays put when placed again', () => {
+ const stack = new FakeElement();
+ const chat = new FakeElement('workflow-chat-toggle-btn');
+ stack.appendChild(chat);
+ const toggle = new FakeElement('hierarchy');
+ placeInButtonStack(toggle as any, docWith(stack, chat));
+ placeInButtonStack(toggle as any, docWith(stack, chat));
+ expect(stack.children.map(c => c.id)).toEqual(['hierarchy', 'workflow-chat-toggle-btn']);
+ });
+
+ it('floats on its own until there is a stack', () => {
+ const toggle = new FakeElement('hierarchy');
+ placeInButtonStack(toggle as any, docWith(null));
+ expect(toggle.classes.has('floating')).toBe(true);
+ });
+});
+
+const view = (workflow: string, instance?: string, viewport?: { zoom: number; scroll: { x: number; y: number } }) => ({
+ args: {
+ sourceUri: 'file:///w/top.py',
+ 'wf:selectedWorkflow': workflow,
+ ...(instance ? { 'wf:navTrail': JSON.stringify([{ workflowName: 'top' }, { workflowName: workflow, workflowInstanceName: instance }]) } : {})
+ },
+ zoom: viewport?.zoom ?? 1,
+ scroll: viewport?.scroll ?? { x: 0, y: 0 }
+});
+
+describe('the viewport across a model change', () => {
+ it('tells two instances of one workflow apart', () => {
+ expect(modelViewportKey(view('block', 'b1'))).not.toBe(modelViewportKey(view('block', 'b2')));
+ });
+
+ it('centers a view never shown', () => {
+ const store = new Map();
+ expect(settleViewport(view('top', undefined, { zoom: 2, scroll: { x: 50, y: 60 } }), view('block', 'b1'), store)).toBe('center');
+ });
+
+ it('returns to where a view was left when navigating back to it', () => {
+ const store = new Map();
+ const leftAt = { zoom: 0.8, scroll: { x: 120, y: -40 } };
+ // Down from the root into b1, then back up.
+ settleViewport(view('top', undefined, leftAt), view('block', 'b1'), store);
+ const back = view('top');
+
+ expect(settleViewport(view('block', 'b1', { zoom: 1.5, scroll: { x: 9, y: 9 } }), back, store)).toBeUndefined();
+ expect({ zoom: back.zoom, scroll: back.scroll }).toEqual(leftAt);
+ });
+
+ it('keeps the viewport of the view refreshed', () => {
+ const refreshed = view('top');
+ expect(settleViewport(view('top', undefined, { zoom: 1.2, scroll: { x: 5, y: 6 } }), refreshed, new Map())).toBeUndefined();
+ expect({ zoom: refreshed.zoom, scroll: refreshed.scroll }).toEqual({ zoom: 1.2, scroll: { x: 5, y: 6 } });
+ });
+});