Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,16 @@ platform API version.

### Added

- **A product whose CLI has no `run` says so: `cliRuns: false`.** The
toolkit then registers no run driver and the chat offers no resume. The
webview learns it from a derived `clientBehavior.runDriver` (like
`chatBackend`; `resumeAtStep` is now derived with it), and a diagram with no
run driver behind it shows no Run, Stop or edge toggle in the palette
header and no Agent Tools switches in the property panel, all of which
called the driver's commands. That covers ndf once it opts out, and today
chisel-ide and the library-mode mlir-viewer, whose Run invoked a command
nothing registered. sv and vhdl keep theirs: `hdl run` simulates through
it.
- **A product's own webview bundle starts the stock diagram.**
`startStockDiagram({ features })` (`@dialogram/diagram-client/stock-starter`)
is what the stock bundle runs, with the product's feature modules loaded
Expand Down
16 changes: 16 additions & 0 deletions packages/diagram-client/src/profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,22 @@ export function clientBehavior(): DiagramClientBehavior {
return configured && typeof configured === 'object' ? configured : {};
}

/**
* Whether the diagram offers what drives a run: Run, Stop, the toggle for what
* a run puts on the edges, and the agent-tool switches the run driver serves.
* Yes unless the host says no run driver is behind it; a webview the host did
* not describe (dev, standalone) keeps them, as before.
*/
export function offersRun(): boolean {
return clientBehavior().runDriver !== false;
}

/** The run controls the palette header adds, by {@link offersRun}. */
export function runHeaderControls(): { run: boolean; stop: boolean; edgeToggle: boolean } {
const runs = offersRun();
return { run: runs, stop: runs, edgeToggle: runs };
}

/**
* The host resolves part of the behavior after the webview is up (what depends
* on the machine or the workspace, e.g. the ACP connectors the runtime knows)
Expand Down
7 changes: 4 additions & 3 deletions packages/diagram-client/src/property-panel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ import { VscodeUi } from './vscode-ui';
import { ppField, ppNumberField, ppReadonlyRow, renderInto } from './property-fields';
import { type PropertyElement, readNumberArg } from './property-model';
import { PropertyPanelChrome } from './property-panel-chrome';
import { clientBehavior, commandId, operationKind } from './profile';
import { clientBehavior, commandId, operationKind, offersRun } from './profile';
import { classifyLink, decodeURIComponentSafe, openTargetOf, referenceAction, type PanelLink } from './open-target';
import { producerFields, type ProducerField } from './producer-fields';

Expand Down Expand Up @@ -577,8 +577,9 @@ export class PropertyPanel implements ISelectionListener, IGModelRootListener {
content.appendChild(this.buildDefinitionAnnotationsSection(node.id, args, undefined, requestAgentDiscovery));
}

// Agent Tools section (only rendered for @agent nodes)
const agentToolsSection = this.buildAgentToolsSection(entityName, args);
// Agent Tools section (only rendered for @agent nodes, and only where a run
// driver is behind the diagram: its two commands are the driver's).
const agentToolsSection = offersRun() ? this.buildAgentToolsSection(entityName, args) : null;
if (agentToolsSection) {
content.appendChild(agentToolsSection);
}
Expand Down
15 changes: 8 additions & 7 deletions packages/diagram-client/src/tool-palette-header-controls.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { codiconCSSClasses } from '@eclipse-glsp/sprotty';
import { VscodeUi } from './vscode-ui';
import { exportDiagramSvg } from './svg-export-command';
import { commandId, feedbackEdgesVisibleStorageKey, queueTraceVisibleStorageKey } from './profile';
import { commandId, feedbackEdgesVisibleStorageKey, queueTraceVisibleStorageKey, runHeaderControls } from './profile';

type DiagramContext = {
sourceUri?: string;
Expand Down Expand Up @@ -169,6 +169,7 @@ async function exec(command: string, args: unknown[] = []): Promise<void> {
}

function patchHeaderTools(headerTools: HTMLElement): void {
const offered = runHeaderControls();
// 1) Replace reset-viewport behavior: screen-normal -> fit + center
const resetIcon = headerTools.querySelector('i.codicon.codicon-screen-normal') as HTMLElement | null;
if (resetIcon) {
Expand All @@ -182,8 +183,8 @@ function patchHeaderTools(headerTools: HTMLElement): void {
};
}

// 2) Add Run button as first header tool.
if (!headerTools.querySelector('i.codicon.codicon-play.cal-run-workflow')) {
// 2) Add Run button as first header tool, when something runs the diagram.
if (offered.run && !headerTools.querySelector('i.codicon.codicon-play.cal-run-workflow')) {
const runIcon = createHeaderIcon('play');
runIcon.classList.add('cal-run-workflow');
runIcon.title = 'Run workflow';
Expand All @@ -209,8 +210,8 @@ function patchHeaderTools(headerTools: HTMLElement): void {
headerTools.insertBefore(runIcon, headerTools.firstChild);
}

// 3) Add Stop button to cancel an active workflow run.
if (!headerTools.querySelector('i.codicon.codicon-debug-stop.cal-stop-workflow')) {
// 3) Add Stop button to cancel an active workflow run, under the same condition.
if (offered.stop && !headerTools.querySelector('i.codicon.codicon-debug-stop.cal-stop-workflow')) {
const stopIcon = createHeaderIcon('debug-stop');
stopIcon.classList.add('cal-stop-workflow');
stopIcon.title = 'Stop workflow run';
Expand All @@ -225,8 +226,8 @@ function patchHeaderTools(headerTools: HTMLElement): void {
headerTools.insertBefore(stopIcon, headerTools.firstChild);
}

// 4) Add queue visibility toggle icon.
if (!headerTools.querySelector('i.cal-toggle-queues')) {
// 4) Add queue visibility toggle icon: what a run puts on the edges, so only where something runs.
if (offered.edgeToggle && !headerTools.querySelector('i.cal-toggle-queues')) {
const queueIcon = createHeaderIcon('eye');
queueIcon.classList.add('cal-toggle-queues');

Expand Down
31 changes: 31 additions & 0 deletions packages/diagram-client/test/offers-run.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/**
* What drives a run is offered unless the host says no run driver is behind
* the diagram. The palette header adds Run, Stop and the edge toggle by
* {@link runHeaderControls}; the property panel shows the agent-tool switches
* by {@link offersRun}. (The client tests run without a DOM, so the header's
* own wiring is not exercised here.)
*/
import { afterEach, describe, expect, it } from 'vitest';
import { offersRun, runHeaderControls } from '../src/profile';

afterEach(() => {
delete (globalThis as any).diagramIdentifier;
});

describe('the run controls', () => {
it('are gone when the host says no run driver is behind the diagram', () => {
(globalThis as any).diagramIdentifier = { clientBehavior: { runDriver: false } };
expect(offersRun()).toBe(false);
expect(runHeaderControls()).toEqual({ run: false, stop: false, edgeToggle: false });
});

it('are there when one is', () => {
(globalThis as any).diagramIdentifier = { clientBehavior: { runDriver: true } };
expect(offersRun()).toBe(true);
expect(runHeaderControls()).toEqual({ run: true, stop: true, edgeToggle: true });
});

it('stay in a webview the host did not describe', () => {
expect(offersRun()).toBe(true);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -990,6 +990,11 @@ export class WorkflowEditorProvider extends GlspEditorProvider {
// absent is exactly what "the chat backend is not activated"
// means, so it is the honest source.
chatBackend: this.profile.chat !== undefined,
// The same for the run driver: Run and Stop are offered exactly
// when something is registered to run the diagram, and a run is
// resumable only where something can run it again.
runDriver: this.profile.runDriver !== undefined,
resumeAtStep: this.profile.clientBehavior?.resumeAtStep === true && this.profile.runDriver !== undefined,
// The same for the status source: the menu offers exactly the
// commands the host registered, or none. Data only; `collect`
// stays on the host.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ describe('WorkflowEditorProvider default webview assets (no clientAssets)', () =
<script nonce="NONCE">
// Pass diagram identifier to the client. Serialized as one JSON literal
// with '<' escaped so no value (e.g. a URI) can break out of the script.
window.diagramIdentifier = {"clientId":"client-0","diagramType":"cal-network-diagram","uri":"file:///workspace/pipeline.py","settingsNamespace":"test","clientBehavior":{"chatBackend":false},"libavoidWasmUri":"https://webview/fake-ext/dist/webview/libavoid.wasm","libavoidModuleUri":"https://webview/fake-ext/dist/webview/libavoid.mjs"};
window.diagramIdentifier = {"clientId":"client-0","diagramType":"cal-network-diagram","uri":"file:///workspace/pipeline.py","settingsNamespace":"test","clientBehavior":{"chatBackend":false,"runDriver":false,"resumeAtStep":false},"libavoidWasmUri":"https://webview/fake-ext/dist/webview/libavoid.wasm","libavoidModuleUri":"https://webview/fake-ext/dist/webview/libavoid.mjs"};

// Edge route/bendpoint overlay (WorkflowEdgeView), a purely visual
// client-side debug aid injected from the extension host (launch.json env).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,3 +74,22 @@ describe('the status source, as the webview sees it', () => {
expect(clientBehaviorFor(PROFILE)).not.toHaveProperty('statusSource');
});
});

describe('the run driver, as the webview sees it', () => {
it('is there exactly when the profile has one, so Run and Stop are offered only then', () => {
const runDriver = (() => ({ dispose() {} })) as unknown as DiagramProfile['runDriver'];
expect(clientBehaviorFor({ ...PROFILE, runDriver }).runDriver).toBe(true);
expect(clientBehaviorFor(PROFILE).runDriver).toBe(false);
});

it('is the platform\'s to say, whatever the product wrote', () => {
const behavior = clientBehaviorFor({ ...PROFILE, clientBehavior: { runDriver: true } as any });
expect(behavior.runDriver).toBe(false);
});

it('takes resuming with it: nothing could run the resumed run', () => {
const runDriver = (() => ({ dispose() {} })) as unknown as DiagramProfile['runDriver'];
expect(clientBehaviorFor({ ...PROFILE, runDriver, clientBehavior: { resumeAtStep: true } as any }).resumeAtStep).toBe(true);
expect(clientBehaviorFor({ ...PROFILE, clientBehavior: { resumeAtStep: true } as any }).resumeAtStep).toBe(false);
});
});
18 changes: 13 additions & 5 deletions packages/shared/src/client-behavior.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,12 +114,20 @@ export interface DiagramClientBehavior {
* write it.
*/
statusSource?: { label: string; showCommand: string; clearCommand: string };
/**
* Whether a run driver is behind this diagram: the Run and Stop buttons are
* offered exactly then. Derived by the platform from `DiagramProfile.runDriver`,
* like {@link chatBackend}; read it, do not write it. A button that invokes a
* command nothing registered, or a CLI subcommand that does not exist, is
* worse than no button.
*/
runDriver?: boolean;
}

/**
* The fields a product may set. The other four ({@link DiagramClientBehavior.chatBackend},
* `acpConnectors`, `resumeAtStep`, `statusSource`) the platform derives on
* the way to the webview; a product-facing type that declared them would
* invite setting them by hand, the one way the two halves could disagree.
* The fields a product may set. The other five ({@link DiagramClientBehavior.chatBackend},
* `acpConnectors`, `resumeAtStep`, `statusSource`, `runDriver`) the platform
* derives on the way to the webview; a product-facing type that declared them
* would invite setting them by hand, the one way the two halves could disagree.
*/
export type ProductClientBehavior = Omit<DiagramClientBehavior, 'chatBackend' | 'acpConnectors' | 'resumeAtStep' | 'statusSource'>;
export type ProductClientBehavior = Omit<DiagramClientBehavior, 'chatBackend' | 'acpConnectors' | 'resumeAtStep' | 'statusSource' | 'runDriver'>;
18 changes: 14 additions & 4 deletions packages/sidecar-toolkit/src/sidecar-diagram-profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,14 @@ export interface SidecarProfileInput {
* it; without it the stepper offers no resume.
*/
cliResumeArgs?: (runDir: string, step: number) => string[];
/**
* Whether the product's CLI runs a diagram (`<cli> run <file> --out-dir ...`),
* which the toolkit's run driver calls. Default `true`. `false` for a
* product whose CLI has no `run`: no run driver is registered, the
* diagram offers no Run, Stop, run toggle or agent-tool switches, the chat
* offers no resume, and the run-driver setting keys are not read.
*/
cliRuns?: boolean;

// User-visible strings / behavior.
/** Appended to every undo label. Default ` (<key>)`. */
Expand Down Expand Up @@ -628,11 +636,13 @@ export function createSidecarDiagramProfile(givenInput: SidecarProfileInput) {
// A product that can resume runs gives the chat a way to offer it: the
// agent that fixed a failed run proposes resuming it, and the person says
// yes or no.
if (input.cliResumeArgs) {
// A run can only be resumed, or driven at all, where the CLI runs one.
const runs = input.cliRuns !== false;
if (runs && input.cliResumeArgs) {
chatTools.push(resumeFailedRunTool(input.commands.runWorkflow));
}

const runDriver = (context: vscode.ExtensionContext, host: RunHost): vscode.Disposable => {
const runDriver = runs ? (context: vscode.ExtensionContext, host: RunHost): vscode.Disposable => {
const config: CliRunDriverConfig = {
settingsNamespace: input.settingsNamespace,
customEditorViewType: input.customEditorViewType,
Expand Down Expand Up @@ -680,7 +690,7 @@ export function createSidecarDiagramProfile(givenInput: SidecarProfileInput) {
onSignature: (listener) => driver.onLiveOverlaySignature(listener)
});
return { dispose: () => driver.dispose() };
};
} : undefined;

const newSourceFile = (context: vscode.ExtensionContext): vscode.Disposable => {
// The raw sidecar commands need a sidecar; a read-only profile has none.
Expand Down Expand Up @@ -726,7 +736,7 @@ export function createSidecarDiagramProfile(givenInput: SidecarProfileInput) {
// A projection creates nothing, whatever the input says.
supportsElementCreation: readOnly ? false : input.supportsElementCreation,
operationKinds: input.operationKinds,
clientBehavior: input.cliResumeArgs
clientBehavior: runs && input.cliResumeArgs
? { ...input.clientBehavior, resumeAtStep: true }
: input.clientBehavior,
clientStylesheets: input.clientStylesheets,
Expand Down
12 changes: 12 additions & 0 deletions packages/sidecar-toolkit/test/sidecar-diagram-profile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,18 @@ describe('createSidecarDiagramProfile', () => {
expect(withResume.clientBehavior).toMatchObject({ noneSentinel: 'x', resumeAtStep: true });
});

it('builds no run driver, offers no resume, for a product whose CLI has no run', () => {
const resume = (runDir: string, step: number) => ['--resume-from', runDir, '--at-step', String(step)];
const runs = createSidecarDiagramProfile(baseInput({ cliResumeArgs: resume }));
expect(typeof runs.runDriver).toBe('function');
expect(runs.chat?.tools?.map(t => t.name)).toContain('resume_failed_run');

const none = createSidecarDiagramProfile(baseInput({ cliRuns: false, cliResumeArgs: resume }));
expect(none.runDriver).toBeUndefined();
expect((none.clientBehavior as any)?.resumeAtStep).toBeUndefined();
expect(none.chat?.tools?.map(t => t.name)).not.toContain('resume_failed_run');
});

it('leaves the creation palette alone unless the product suppresses it', () => {
// Default `true` by omission, so the assembler cannot silently empty the
// palette of a product that never mentioned it.
Expand Down
Loading