Skip to content
Open
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 @@ -30,6 +30,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.
- **Selection trace** (`clientBehavior.selectionTrace`, opt-in): selecting a
node draws every path through it, what leads to it in green and what it
leads to in blue, and fades the rest; after mlir-viewer's trace highlighter.
Expand Down
18 changes: 18 additions & 0 deletions packages/diagram-client/src/profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ type DiagramClientBehavior = {
nodeFamilies?: NodeFamilySpec[];
/** Whether the host has a chat backend; derived by the platform. */
chatBackend?: boolean;
/** Whether a run driver is behind the diagram; derived by the platform. */
runDriver?: boolean;
/** The ACP connectors the runtime discovered or the user declared, for an
* agent's `connector` (extension-core `AcpConnectorInfo`). */
acpConnectors?: AcpConnectorInfo[];
Expand Down Expand Up @@ -156,6 +158,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 @@ -627,8 +627,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);
});
});
8 changes: 8 additions & 0 deletions packages/extension-core/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,14 @@ export interface DiagramClientBehavior {
/** What the hierarchy outline panel is called (its title and its button's
* tooltip); "Hierarchy" by default. */
outlineTitle?: 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;
/**
* Whether the host has a chat backend behind this diagram.
*
Expand Down
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: 14 additions & 4 deletions packages/sidecar-toolkit/src/sidecar-diagram-profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,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.
undoLabelSuffix: string;
Expand Down Expand Up @@ -568,11 +576,13 @@ export function createSidecarDiagramProfile(input: 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 @@ -620,7 +630,7 @@ export function createSidecarDiagramProfile(input: 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 @@ -666,7 +676,7 @@ export function createSidecarDiagramProfile(input: 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
4 changes: 3 additions & 1 deletion packages/sidecar-toolkit/test/client-behavior-twin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,10 @@ function fieldsOf(file: string, name: string): string[] {
* the arguments it needs
* statusSource derived from `DiagramProfile.statusSource`: the context
* menu offers exactly the commands the host registered
* runDriver derived from `DiagramProfile.runDriver` being present: Run
* and Stop are offered exactly when something runs the diagram
*/
const PLATFORM_DERIVED = ['chatBackend', 'acpConnectors', 'resumeAtStep', 'statusSource'];
const PLATFORM_DERIVED = ['chatBackend', 'acpConnectors', 'resumeAtStep', 'statusSource', 'runDriver'];

describe('sidecar client behavior mirrors the platform', () => {
const sidecar = fieldsOf(
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 @@ -167,6 +167,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