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
5 changes: 3 additions & 2 deletions apps/ai-studio/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ Reference frontend for the Workflow Builder AI Studio product. Consumes `@workfl
A complete, runnable AI workflow product built on top of the Workflow Builder SDK. It demonstrates:

- Connecting to the reference Hono backend over HTTP + Server-Sent Events
- AI Studio–specific node types (`ai-studio/trigger`, `ai-studio/ai-agent`, `ai-studio/decision`)
- Live execution UI: Play/Stop controls, log panel, per-node status markers, edge highlighting, node-detail overlay
- AI Studio–specific node types (`ai-studio/trigger`, `ai-studio/ai-agent`, `ai-studio/decision`, `ai-studio/human-decision`, `ai-studio/visualize`)
- A run that stops for a person: `ai-studio/human-decision` parks the run (its executor returns `{ waiting: true }`) until `POST /api/executions/:id/decision` delivers a decision; the "Refund Review" template shows the loop. The node renders through its own template, keyed by the palette type in `nodeTemplates`, with one output handle per action of its `decisionRequest` that carries a port.
- Live execution UI: Play/Stop controls, log panel, per-node status markers (including a waiting marker), edge highlighting, node-detail overlay

This is a sibling to `apps/demo`, not a layer over it. They share the SDK; nothing else.

Expand Down
6 changes: 6 additions & 0 deletions apps/ai-studio/src/app/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,16 @@ import { ExecutionLogPanel } from '../components/execution/log-panel';
import { aiStudioTemplates } from '../data/ai-studio-templates';
import { aiStudioNodeTypes } from '../data/node-types';
import { supportTriageFlow } from '../data/support-triage-flow';
import { humanDecisionNodeType } from '../nodes/human-decision';
import { HumanDecisionNodeTemplate } from '../nodes/human-decision/human-decision-template';
import { plugin as aiStudioFeaturesPlugin } from '../plugin';
import { plugin as undoRedoPlugin } from '../plugins/undo-redo/plugin-exports';

const flagship = supportTriageFlow.value;

// Module-level: `nodeTemplates` must keep the same reference across renders.
const nodeTemplates = { [humanDecisionNodeType]: HumanDecisionNodeTemplate };

// A start node is where the run begins, so it can never be a connection target.
const isValidConnection: WorkflowBuilderIsValidConnection = ({ targetNode }) => !targetNode.data.isStartNode;

Expand All @@ -30,6 +35,7 @@ export function App() {
initialNodes={flagship.diagram.nodes}
initialEdges={flagship.diagram.edges}
nodeTypes={aiStudioNodeTypes}
nodeTemplates={nodeTemplates}
diagramTemplates={aiStudioTemplates}
isValidConnection={isValidConnection}
plugins={[aiStudioFeaturesPlugin, undoRedoPlugin]}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import type { ExecutionStatus } from '@workflow-builder/types/workflow-execution/execution-events';

import { applySnapshot, resetExecution } from '../../stores/use-execution-store';
import { AiStudioControls } from './ai-studio-controls';

vi.mock('@workflowbuilder/sdk', async (importOriginal) => {
const actual = await importOriginal<typeof import('@workflowbuilder/sdk')>();
return { ...actual, Icon: ({ name }: { name: string }) => <i data-icon={name} /> };
});

vi.mock('../../hooks/use-has-start-node', () => ({ useHasStartNode: () => true }));

declare global {
// eslint-disable-next-line no-var
var IS_REACT_ACT_ENVIRONMENT: boolean;
}
globalThis.IS_REACT_ACT_ENVIRONMENT = true;

function setRunStatus(status: ExecutionStatus) {
act(() => applySnapshot({ executionId: 'exec-1', status, lastSequence: 0, events: [] }));
}

describe('AiStudioControls', () => {
let container: HTMLDivElement;
let root: ReturnType<typeof createRoot>;

beforeEach(() => {
resetExecution();
container = document.createElement('div');
document.body.append(container);
root = createRoot(container);
act(() => root.render(<AiStudioControls />));
});

afterEach(() => {
act(() => root.unmount());
container.remove();
});

const icons = () => [...container.querySelectorAll<HTMLElement>('[data-icon]')].map((icon) => icon.dataset['icon']);

it('offers Stop while the run waits for a decision, the same as while it runs', () => {
setRunStatus('running');
expect(icons()).toEqual(['Stop']);

setRunStatus('waiting');
expect(icons()).toEqual(['Stop']);
});

it('offers Play and Reset once the run has ended', () => {
setRunStatus('waiting');
setRunStatus('completed');

expect(icons()).toEqual(['Play', 'ArrowCounterClockwise']);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export function AiStudioControls() {
}
}, [executeFromCanvas]);

const isRunning = status === 'pending' || status === 'running';
const isRunning = status === 'pending' || status === 'running' || status === 'waiting';
const isDone = status === 'completed' || status === 'incomplete' || status === 'failed' || status === 'cancelled';

return (
Expand Down
2 changes: 2 additions & 0 deletions apps/ai-studio/src/components/execution/highlighting.css
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@ html[data-theme='dark'] {

:root {
--ai-studio-status-color--completed: var(--ax-txt-success-default);
--ai-studio-status-color--waiting: var(--ax-txt-info-default);
--ai-studio-status-color--failed: var(--ax-txt-error-default);
--ai-studio-status-bg--completed: color-mix(in srgb, var(--ai-studio-status-color--completed), transparent 85%);
--ai-studio-status-bg--waiting: color-mix(in srgb, var(--ai-studio-status-color--waiting), transparent 85%);
--ai-studio-status-bg--incomplete: color-mix(in srgb, var(--ai-studio-status-color--incomplete), transparent 85%);
--ai-studio-status-bg--failed: color-mix(in srgb, var(--ai-studio-status-color--failed), transparent 85%);

Expand Down
56 changes: 56 additions & 0 deletions apps/ai-studio/src/components/execution/highlighting.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import type { ExecutionEvent } from '@workflow-builder/types/workflow-execution/execution-events';

import { applyEvent, resetExecution, setExecutionStarted } from '../../stores/use-execution-store';
import { ExecutionHighlighting } from './highlighting';

vi.mock('@workflowbuilder/sdk', async (importOriginal) => {
const actual = await importOriginal<typeof import('@workflowbuilder/sdk')>();
return { ...actual, getStoreEdges: () => [] };
});

declare global {
// eslint-disable-next-line no-var
var IS_REACT_ACT_ENVIRONMENT: boolean;
}
globalThis.IS_REACT_ACT_ENVIRONMENT = true;

const waiting = (nodeId: string): ExecutionEvent => ({
executionId: 'exec-1',
sequence: 1,
timestamp: '2026-09-15T12:00:00.000Z',
type: 'node_waiting',
nodeId,
});

describe('ExecutionHighlighting', () => {
let container: HTMLDivElement;
let root: ReturnType<typeof createRoot>;

beforeEach(() => {
resetExecution();
setExecutionStarted('exec-1', '/stream');
container = document.createElement('div');
document.body.append(container);
root = createRoot(container);
});

afterEach(() => {
act(() => root.unmount());
container.remove();
document.querySelector('#us-css-ai-studio-highlighting')?.remove();
});

it('keeps the active shadow on a node that waits for a decision', () => {
act(() => root.render(<ExecutionHighlighting />));
act(() => applyEvent(waiting('human-1')));

const css = document.querySelector('#us-css-ai-studio-highlighting')?.innerHTML ?? '';
const activeRule = css.split('}').find((rule) => rule.includes('[data-id="human-1"]'));

expect(activeRule).toContain('--ai-studio-node-shadow--active');
});
});
3 changes: 2 additions & 1 deletion apps/ai-studio/src/components/execution/highlighting.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ export function ExecutionHighlighting() {
}

switch (state.status) {
case 'running': {
case 'running':
case 'waiting': {
byStatus.running.push(nodeId);
break;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,10 @@
color: var(--ax-txt-secondary-default, #888);
}

.badge--node_waiting {
color: var(--ai-studio-status-color--waiting);
}

.badge--execution_incomplete {
color: var(--ai-studio-status-color--incomplete);
}
Expand Down Expand Up @@ -163,6 +167,11 @@
background: var(--ai-studio-status-bg--completed);
}

.status--waiting {
color: var(--ai-studio-status-color--waiting);
background: var(--ai-studio-status-bg--waiting);
}

.status--incomplete {
color: var(--ai-studio-status-color--incomplete);
background: var(--ai-studio-status-bg--incomplete);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@
animation: spin 1.6s linear infinite;
}

.icon--waiting {
color: var(--ai-studio-status-color--waiting);
}

@keyframes spin {
from {
transform: rotate(0deg);
Expand Down
86 changes: 86 additions & 0 deletions apps/ai-studio/src/components/execution/node-markers.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import type { ExecutionEvent } from '@workflow-builder/types/workflow-execution/execution-events';

import styles from './node-markers.module.css';

import {
applyEvent,
resetExecution,
setExecutionStarted,
setLogCollapsed,
useExecutionStore,
} from '../../stores/use-execution-store';
import { ExecutionNodeMarkers } from './node-markers';

vi.mock('@workflowbuilder/sdk', async (importOriginal) => {
const actual = await importOriginal<typeof import('@workflowbuilder/sdk')>();
return { ...actual, Icon: ({ name }: { name: string }) => <i data-icon={name} /> };
});

declare global {
// eslint-disable-next-line no-var
var IS_REACT_ACT_ENVIRONMENT: boolean;
}
globalThis.IS_REACT_ACT_ENVIRONMENT = true;

function nodeEvent(type: 'node_started' | 'node_waiting' | 'node_completed', nodeId: string): ExecutionEvent {
const base = { executionId: 'exec-1', sequence: 1, timestamp: '2026-09-15T12:00:00.000Z', nodeId };
return (type === 'node_completed' ? { ...base, type, payload: { output: {} } } : { ...base, type }) as ExecutionEvent;
}

describe('ExecutionNodeMarkers', () => {
let container: HTMLDivElement;
let root: ReturnType<typeof createRoot>;

beforeEach(() => {
resetExecution();
setExecutionStarted('exec-1', '/stream');
container = document.createElement('div');
document.body.append(container);
root = createRoot(container);
});

afterEach(() => {
act(() => root.unmount());
container.remove();
});

function render(nodeId: string) {
act(() => root.render(<ExecutionNodeMarkers props={{ nodeId }} />));
}

function click() {
act(() => {
container.firstElementChild?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
}

it('shows an hourglass for a waiting node and does not open the log on click', () => {
act(() => applyEvent(nodeEvent('node_waiting', 'human-1')));
setLogCollapsed(true);

render('human-1');

expect(container.querySelector('[data-icon="HourglassMedium"]')).not.toBeNull();
expect(container.firstElementChild?.classList.contains(styles['container--clickable']!)).toBe(false);

click();
expect(useExecutionStore.getState().isLogCollapsed).toBe(true);
});

it('swaps the hourglass for the completed flag once the decision lands, and that one opens the log', () => {
act(() => applyEvent(nodeEvent('node_waiting', 'human-1')));
render('human-1');
act(() => applyEvent(nodeEvent('node_completed', 'human-1')));
setLogCollapsed(true);

expect(container.querySelector('[data-icon="HourglassMedium"]')).toBeNull();
expect(container.querySelector('[data-icon="FlagBannerFold"]')).not.toBeNull();

click();
expect(useExecutionStore.getState().isLogCollapsed).toBe(false);
});
});
5 changes: 5 additions & 0 deletions apps/ai-studio/src/components/execution/node-markers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ export function ExecutionNodeMarkers({ props }: Props) {
<Spinner />
</span>
)}
{nodeState.status === 'waiting' && (
<span className={`${styles['icon']} ${styles['icon--waiting']}`}>
<Icon name="HourglassMedium" />
</span>
)}
{nodeState.status === 'completed' && (
<span className={`${styles['icon']} ${styles['icon--completed']}`}>
<Icon name="FlagBannerFold" />
Expand Down
2 changes: 2 additions & 0 deletions apps/ai-studio/src/data/ai-studio-templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { TemplateModel } from '@workflowbuilder/sdk';
import { aiDebateFlow } from './ai-debate-flow';
import { contentRepurposerFlow } from './content-repurposer-flow';
import { meetingNotesFlow } from './meeting-notes-flow';
import { refundReviewFlow } from './refund-review-flow';
import { researchFlow } from './research-flow';
import { supportTriageFlow } from './support-triage-flow';

Expand All @@ -12,4 +13,5 @@ export const aiStudioTemplates: TemplateModel[] = [
contentRepurposerFlow,
meetingNotesFlow,
researchFlow,
refundReviewFlow,
];
9 changes: 8 additions & 1 deletion apps/ai-studio/src/data/node-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,20 @@ import type { PaletteItemOrGroup } from '@workflowbuilder/sdk';

import { aiAgentPaletteItem } from '../nodes/ai-agent';
import { decisionPaletteItem } from '../nodes/decision';
import { humanDecisionPaletteItem } from '../nodes/human-decision';
import { triggerPaletteItem } from '../nodes/trigger';
import { visualizePaletteItem } from '../nodes/visualize';

export const aiStudioNodeTypes: PaletteItemOrGroup[] = [
{
label: 'AI Studio',
isOpen: true,
groupItems: [triggerPaletteItem, aiAgentPaletteItem, decisionPaletteItem, visualizePaletteItem],
groupItems: [
triggerPaletteItem,
aiAgentPaletteItem,
decisionPaletteItem,
humanDecisionPaletteItem,
visualizePaletteItem,
],
},
];
Loading
Loading