feat(flows): improve workflow builder UX and diagnostics (#4574)

This commit is contained in:
Steven Enamakel
2026-07-05 17:09:08 -07:00
committed by GitHub
parent d8644f105d
commit cadeee5d89
75 changed files with 5958 additions and 689 deletions
+42 -24
View File
@@ -46,6 +46,18 @@ export interface ChatComposerProps {
* attach button, hidden file input, and preview strip are not rendered.
*/
attachmentsEnabled: boolean;
/**
* Whether the voice-mode (mic) button is shown. Defaults to `true` for the
* main chat; surfaces that don't have a voice mode (e.g. the flows Workflow
* Copilot) pass `false` to hide it while still reusing this composer.
*/
micEnabled?: boolean;
/**
* Overrides the textarea placeholder. Defaults to the main chat's
* type-a-message / follow-up hint; surfaces like the Workflow Copilot pass
* their own prompt.
*/
placeholder?: string;
/**
* Optional nodes stacked above the input box but *outside* its focus
* highlight — e.g. the queued-follow-ups strip and the thread-goal editor.
@@ -82,6 +94,8 @@ export default function ChatComposer({
maxAttachments,
allowedMimeTypes,
attachmentsEnabled,
micEnabled = true,
placeholder,
headerSlots = [],
}: ChatComposerProps) {
const { t } = useT();
@@ -264,7 +278,9 @@ export default function ChatComposer({
}}
onKeyDown={handleInputKeyDown}
onPaste={attachmentsEnabled ? handlePaste : undefined}
placeholder={allowParallelSend ? t('chat.followupHint') : t('chat.typeMessage')}
placeholder={
placeholder ?? (allowParallelSend ? t('chat.followupHint') : t('chat.typeMessage'))
}
rows={1}
disabled={textareaDisabled}
className="relative z-10 w-full resize-none border-0 bg-transparent py-0.5 px-0.5 text-sm leading-5 whitespace-pre-wrap break-words font-sans text-content placeholder:text-stone-400 dark:placeholder:text-neutral-500 outline-none focus:outline-none focus-visible:outline-none focus:ring-0 focus-visible:ring-0 overflow-hidden disabled:opacity-50 disabled:cursor-not-allowed"
@@ -272,29 +288,31 @@ export default function ChatComposer({
</div>
{/* Voice mode */}
<button
type="button"
data-analytics-id="chat-composer-voice-mode"
aria-label={t('composer.voiceMode')}
title={t('composer.voiceMode')}
onClick={onSwitchToMicCloud}
disabled={composerInteractionBlocked || isSending}
className="flex-shrink-0 flex items-center justify-center w-6 h-6 text-content-faint hover:text-content-secondary transition-colors disabled:opacity-40 disabled:cursor-not-allowed">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 1a3 3 0 00-3 3v8a3 3 0 006 0V4a3 3 0 00-3-3z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 10v2a7 7 0 01-14 0v-2M12 19v4m-4 0h8"
/>
</svg>
</button>
{micEnabled && (
<button
type="button"
data-analytics-id="chat-composer-voice-mode"
aria-label={t('composer.voiceMode')}
title={t('composer.voiceMode')}
onClick={onSwitchToMicCloud}
disabled={composerInteractionBlocked || isSending}
className="flex-shrink-0 flex items-center justify-center w-6 h-6 text-content-faint hover:text-content-secondary transition-colors disabled:opacity-40 disabled:cursor-not-allowed">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 1a3 3 0 00-3 3v8a3 3 0 006 0V4a3 3 0 00-3-3z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 10v2a7 7 0 01-14 0v-2M12 19v4m-4 0h8"
/>
</svg>
</button>
)}
{/* Send / Stop button — while a turn is in flight and a cancel handler
is wired, the Send button becomes a Stop button so generation can
+52 -172
View File
@@ -3,15 +3,16 @@
* Workflows list page. Asserts the name/status rendering, the
* last-run/never-run text (including the localized relative-time strings),
* that the toggle/Run/View runs controls call back with the row's `Flow`,
* and that the flow name itself is the "View" affordance that opens the
* read-only Workflow Canvas (issue B5b.1).
* that the flow name itself is the "View" affordance that opens the read-only
* Workflow Canvas (issue B5b.1), and that the overflow menu routes
* Export/Duplicate/Delete.
*/
import { fireEvent, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import type { Flow } from '../../services/api/flowsApi';
import { renderWithProviders } from '../../test/test-utils';
import FlowListRow from './FlowListRow';
import FlowListRow, { type FlowListRowProps } from './FlowListRow';
function makeFlow(overrides: Partial<Flow> = {}): Flow {
return {
@@ -28,243 +29,122 @@ function makeFlow(overrides: Partial<Flow> = {}): Flow {
};
}
/** Render a row with no-op defaults for every callback, overridable per test. */
function renderRow(overrides: Partial<FlowListRowProps> = {}) {
const props: FlowListRowProps = {
flow: makeFlow(),
onToggle: vi.fn(),
onRun: vi.fn(),
onViewRuns: vi.fn(),
onView: vi.fn(),
onExport: vi.fn(),
onDuplicate: vi.fn(),
onDelete: vi.fn(),
...overrides,
};
renderWithProviders(<FlowListRow {...props} />);
return props;
}
describe('FlowListRow', () => {
it('renders the flow name and an Enabled badge when enabled', () => {
renderWithProviders(
<FlowListRow
flow={makeFlow()}
onToggle={vi.fn()}
onRun={vi.fn()}
onViewRuns={vi.fn()}
onView={vi.fn()}
onExport={vi.fn()}
/>
);
renderRow();
expect(screen.getByText('Daily digest')).toBeInTheDocument();
expect(screen.getByTestId('flow-status-flow-1')).toHaveTextContent('Enabled');
});
it('renders a Paused badge when disabled', () => {
renderWithProviders(
<FlowListRow
flow={makeFlow({ enabled: false })}
onToggle={vi.fn()}
onRun={vi.fn()}
onViewRuns={vi.fn()}
onView={vi.fn()}
onExport={vi.fn()}
/>
);
renderRow({ flow: makeFlow({ enabled: false }) });
expect(screen.getByTestId('flow-status-flow-1')).toHaveTextContent('Paused');
});
it('shows "Never run" when the flow has no last_run_at', () => {
renderWithProviders(
<FlowListRow
flow={makeFlow()}
onToggle={vi.fn()}
onRun={vi.fn()}
onViewRuns={vi.fn()}
onView={vi.fn()}
onExport={vi.fn()}
/>
);
renderRow();
expect(screen.getByText('Never run')).toBeInTheDocument();
});
it('shows the capitalized status and "Just now" for a run seconds ago', () => {
renderWithProviders(
<FlowListRow
flow={makeFlow({ last_run_at: new Date().toISOString(), last_status: 'completed' })}
onToggle={vi.fn()}
onRun={vi.fn()}
onViewRuns={vi.fn()}
onView={vi.fn()}
onExport={vi.fn()}
/>
);
renderRow({
flow: makeFlow({ last_run_at: new Date().toISOString(), last_status: 'completed' }),
});
expect(screen.getByText('Completed · Just now')).toBeInTheDocument();
});
it('shows a minutes-ago relative time', () => {
const fiveMinAgo = new Date(Date.now() - 5 * 60_000).toISOString();
renderWithProviders(
<FlowListRow
flow={makeFlow({ last_run_at: fiveMinAgo, last_status: 'completed' })}
onToggle={vi.fn()}
onRun={vi.fn()}
onViewRuns={vi.fn()}
onView={vi.fn()}
onExport={vi.fn()}
/>
);
renderRow({ flow: makeFlow({ last_run_at: fiveMinAgo, last_status: 'completed' }) });
expect(screen.getByText('Completed · 5m ago')).toBeInTheDocument();
});
it('shows an hours-ago relative time', () => {
const threeHoursAgo = new Date(Date.now() - 3 * 60 * 60_000).toISOString();
renderWithProviders(
<FlowListRow
flow={makeFlow({ last_run_at: threeHoursAgo, last_status: 'failed' })}
onToggle={vi.fn()}
onRun={vi.fn()}
onViewRuns={vi.fn()}
onView={vi.fn()}
onExport={vi.fn()}
/>
);
renderRow({ flow: makeFlow({ last_run_at: threeHoursAgo, last_status: 'failed' }) });
expect(screen.getByText('Failed · 3h ago')).toBeInTheDocument();
});
it('shows a days-ago relative time', () => {
const twoDaysAgo = new Date(Date.now() - 2 * 24 * 60 * 60_000).toISOString();
renderWithProviders(
<FlowListRow
flow={makeFlow({ last_run_at: twoDaysAgo, last_status: 'pending_approval' })}
onToggle={vi.fn()}
onRun={vi.fn()}
onViewRuns={vi.fn()}
onView={vi.fn()}
onExport={vi.fn()}
/>
);
renderRow({ flow: makeFlow({ last_run_at: twoDaysAgo, last_status: 'pending_approval' }) });
expect(screen.getByText('Pending_approval · 2d ago')).toBeInTheDocument();
});
it('calls onToggle with the flow when the switch is clicked', () => {
const onToggle = vi.fn();
renderWithProviders(
<FlowListRow
flow={makeFlow()}
onToggle={onToggle}
onRun={vi.fn()}
onViewRuns={vi.fn()}
onView={vi.fn()}
onExport={vi.fn()}
/>
);
const { onToggle } = renderRow();
fireEvent.click(screen.getByTestId('flow-toggle-flow-1'));
expect(onToggle).toHaveBeenCalledWith(makeFlow());
});
it('calls onRun with the flow when the Run button is clicked', () => {
const onRun = vi.fn();
renderWithProviders(
<FlowListRow
flow={makeFlow()}
onToggle={vi.fn()}
onRun={onRun}
onViewRuns={vi.fn()}
onView={vi.fn()}
onExport={vi.fn()}
/>
);
const { onRun } = renderRow();
fireEvent.click(screen.getByTestId('flow-run-flow-1'));
expect(onRun).toHaveBeenCalledWith(makeFlow());
});
it('renders a "View runs" control and calls onViewRuns with the flow when clicked', () => {
const onViewRuns = vi.fn();
renderWithProviders(
<FlowListRow
flow={makeFlow()}
onToggle={vi.fn()}
onRun={vi.fn()}
onViewRuns={onViewRuns}
onView={vi.fn()}
onExport={vi.fn()}
/>
);
const { onViewRuns } = renderRow();
const viewRunsButton = screen.getByTestId('flow-view-runs-flow-1');
expect(viewRunsButton).toHaveTextContent('View runs');
fireEvent.click(viewRunsButton);
expect(onViewRuns).toHaveBeenCalledWith(makeFlow());
});
it('renders the flow name as a "View" affordance and calls onView with the flow when clicked', () => {
const onView = vi.fn();
renderWithProviders(
<FlowListRow
flow={makeFlow()}
onToggle={vi.fn()}
onRun={vi.fn()}
onViewRuns={vi.fn()}
onView={onView}
onExport={vi.fn()}
/>
);
const { onView } = renderRow();
const viewButton = screen.getByTestId('flow-view-flow-1');
expect(viewButton).toHaveTextContent('Daily digest');
fireEvent.click(viewButton);
expect(onView).toHaveBeenCalledWith(makeFlow());
});
it('shows the running label and disables Run while busy', () => {
renderWithProviders(
<FlowListRow
flow={makeFlow()}
onToggle={vi.fn()}
onRun={vi.fn()}
onViewRuns={vi.fn()}
onView={vi.fn()}
onExport={vi.fn()}
busy="run"
/>
);
renderRow({ busy: 'run' });
const runButton = screen.getByTestId('flow-run-flow-1');
expect(runButton).toHaveTextContent('Running…');
expect(runButton).toBeDisabled();
});
it('disables the toggle while busy=toggle', () => {
renderWithProviders(
<FlowListRow
flow={makeFlow()}
onToggle={vi.fn()}
onRun={vi.fn()}
onViewRuns={vi.fn()}
onView={vi.fn()}
onExport={vi.fn()}
busy="toggle"
/>
);
renderRow({ busy: 'toggle' });
expect(screen.getByTestId('flow-toggle-flow-1')).toBeDisabled();
});
it('renders an Export control and calls onExport with the flow when clicked', () => {
const onExport = vi.fn();
renderWithProviders(
<FlowListRow
flow={makeFlow()}
onToggle={vi.fn()}
onRun={vi.fn()}
onViewRuns={vi.fn()}
onView={vi.fn()}
onExport={onExport}
/>
);
const exportButton = screen.getByTestId('flow-export-flow-1');
expect(exportButton).toHaveTextContent('Export');
fireEvent.click(exportButton);
it('routes Export / Duplicate / Delete through the overflow menu', () => {
const { onExport, onDuplicate, onDelete } = renderRow();
// The secondary actions live behind the "⋯" menu, not the flat button row.
expect(screen.queryByTestId('flow-export-flow-1')).not.toBeInTheDocument();
fireEvent.click(screen.getByTestId('flow-menu-flow-1'));
const exportItem = screen.getByTestId('flow-export-flow-1');
expect(exportItem).toHaveTextContent('Export');
fireEvent.click(exportItem);
expect(onExport).toHaveBeenCalledWith(makeFlow());
fireEvent.click(screen.getByTestId('flow-menu-flow-1'));
fireEvent.click(screen.getByTestId('flow-duplicate-flow-1'));
expect(onDuplicate).toHaveBeenCalledWith(makeFlow());
fireEvent.click(screen.getByTestId('flow-menu-flow-1'));
fireEvent.click(screen.getByTestId('flow-delete-flow-1'));
expect(onDelete).toHaveBeenCalledWith(makeFlow());
});
});
+53 -25
View File
@@ -24,6 +24,7 @@ import { useT } from '../../lib/i18n/I18nContext';
import type { Flow } from '../../services/api/flowsApi';
import SettingsSwitch from '../settings/controls/SettingsSwitch';
import Button from '../ui/Button';
import FlowRowMenu from './FlowRowMenu';
/** Which of this row's actions currently has a request in flight, if any. */
export type FlowListRowBusy = 'toggle' | 'run' | null;
@@ -40,6 +41,10 @@ export interface FlowListRowProps {
onView: (flow: Flow) => void;
/** Downloads this flow's `WorkflowGraph` as a JSON file (Phase 4d export). */
onExport: (flow: Flow) => void;
/** Creates a disabled copy of this flow (`flows_duplicate`). */
onDuplicate: (flow: Flow) => void;
/** Permanently deletes this flow after a confirm (`flows_delete`). */
onDelete: (flow: Flow) => void;
busy?: FlowListRowBusy;
}
@@ -75,6 +80,8 @@ const FlowListRow = ({
onViewRuns,
onView,
onExport,
onDuplicate,
onDelete,
busy = null,
}: FlowListRowProps) => {
const { t } = useT();
@@ -103,26 +110,29 @@ const FlowListRow = ({
</button>
<div className="mt-0.5 text-[11px] text-content-faint">{lastRunLabel}</div>
</div>
<span
data-testid={`flow-status-${flow.id}`}
className={`flex-shrink-0 rounded-full border px-2 py-1 text-[11px] font-semibold uppercase ${
flow.enabled
? 'border-sage-200 bg-sage-50 text-sage-700 dark:border-sage-500/30 dark:bg-sage-500/10 dark:text-sage-300'
: 'border-line bg-surface-subtle text-content-secondary'
}`}>
{flow.enabled ? t('flows.list.enabled') : t('flows.list.paused')}
</span>
{/* Enable/disable control paired with its state label as a single group,
so the toggle is self-describing (the bare switch was ambiguous) and
state isn't split between a top-right badge and a bottom-left switch. */}
<div className="flex flex-shrink-0 items-center gap-2">
<span
data-testid={`flow-status-${flow.id}`}
className={`text-[11px] font-semibold uppercase tracking-wide ${
flow.enabled ? 'text-sage-700 dark:text-sage-300' : 'text-content-secondary'
}`}>
{flow.enabled ? t('flows.list.enabled') : t('flows.list.paused')}
</span>
<SettingsSwitch
id={`flow-toggle-${flow.id}`}
data-testid={`flow-toggle-${flow.id}`}
checked={flow.enabled}
disabled={toggleBusy}
aria-label={t('flows.list.toggleEnabled')}
onCheckedChange={() => onToggle(flow)}
/>
</div>
</div>
<div className="flex flex-wrap items-center gap-3">
<SettingsSwitch
id={`flow-toggle-${flow.id}`}
data-testid={`flow-toggle-${flow.id}`}
checked={flow.enabled}
disabled={toggleBusy}
aria-label={t('flows.list.toggleEnabled')}
onCheckedChange={() => onToggle(flow)}
/>
<Button
type="button"
variant="secondary"
@@ -140,14 +150,32 @@ const FlowListRow = ({
onClick={() => onRun(flow)}>
{runBusy ? t('flows.list.running') : t('flows.list.runNow')}
</Button>
<Button
type="button"
variant="tertiary"
size="sm"
data-testid={`flow-export-${flow.id}`}
onClick={() => onExport(flow)}>
{t('flows.list.export')}
</Button>
<div className="ml-auto">
<FlowRowMenu
rowId={flow.id}
items={[
{
key: 'export',
label: t('flows.list.export'),
onSelect: () => onExport(flow),
testId: `flow-export-${flow.id}`,
},
{
key: 'duplicate',
label: t('flows.list.duplicate'),
onSelect: () => onDuplicate(flow),
testId: `flow-duplicate-${flow.id}`,
},
{
key: 'delete',
label: t('flows.list.delete'),
onSelect: () => onDelete(flow),
danger: true,
testId: `flow-delete-${flow.id}`,
},
]}
/>
</div>
</div>
</div>
);
+98
View File
@@ -0,0 +1,98 @@
/**
* FlowRowMenu — the "⋯" overflow menu on a Workflows list row. Holds the
* secondary/rare actions (Export, Duplicate, Delete) so the row's primary
* actions (View runs, Run) stay uncluttered, and keeps the destructive Delete
* out of the flat button row. Closes on Escape, outside click, or item select.
*
* Presentational + local open state only — each item calls back up to
* `FlowListRow`, which routes to `FlowsPage`'s handlers.
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import { useEscapeKey } from '../../hooks/useEscapeKey';
import { useT } from '../../lib/i18n/I18nContext';
export interface FlowRowMenuItem {
key: string;
label: string;
onSelect: () => void;
/** Renders the item in the destructive coral tone (e.g. Delete). */
danger?: boolean;
testId?: string;
}
export interface FlowRowMenuProps {
items: FlowRowMenuItem[];
/** Suffixed onto test ids so multiple rows stay addressable. */
rowId: string;
}
function KebabIcon() {
return (
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<circle cx="12" cy="5" r="1.6" />
<circle cx="12" cy="12" r="1.6" />
<circle cx="12" cy="19" r="1.6" />
</svg>
);
}
export default function FlowRowMenu({ items, rowId }: FlowRowMenuProps) {
const { t } = useT();
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement | null>(null);
useEscapeKey(() => setOpen(false), open);
// Close on any click outside the menu container.
useEffect(() => {
if (!open) return;
const onPointerDown = (event: MouseEvent) => {
if (!containerRef.current?.contains(event.target as Node | null)) setOpen(false);
};
document.addEventListener('mousedown', onPointerDown);
return () => document.removeEventListener('mousedown', onPointerDown);
}, [open]);
const select = useCallback((onSelect: () => void) => {
setOpen(false);
onSelect();
}, []);
return (
<div className="relative" ref={containerRef}>
<button
type="button"
data-testid={`flow-menu-${rowId}`}
aria-haspopup="menu"
aria-expanded={open}
aria-label={t('flows.list.moreActions')}
title={t('flows.list.moreActions')}
onClick={() => setOpen(o => !o)}
className="flex h-8 w-8 items-center justify-center rounded-lg border border-line text-content-muted transition-colors hover:bg-surface-hover hover:text-content-secondary">
<KebabIcon />
</button>
{open && (
<div
role="menu"
data-testid={`flow-menu-list-${rowId}`}
className="absolute right-0 z-20 mt-1 min-w-[10rem] overflow-hidden rounded-xl border border-line bg-surface py-1 shadow-lg">
{items.map(item => (
<button
key={item.key}
type="button"
role="menuitem"
data-testid={item.testId}
onClick={() => select(item.onSelect)}
className={`block w-full px-3 py-1.5 text-left text-xs transition-colors hover:bg-surface-hover ${
item.danger ? 'text-coral-600 dark:text-coral-400' : 'text-content-secondary'
}`}>
{item.label}
</button>
))}
</div>
)}
</div>
);
}
@@ -147,6 +147,23 @@ function StepRow({
</span>
)}
</div>
{/* Null-resolution diagnostics: each config `=`-expression that resolved
to null during this step (a wiring smell, not a hard failure). */}
{step.diagnostics && step.diagnostics.length > 0 && (
<div
data-testid={`flow-run-step-diagnostics-${index}`}
className="mt-1.5 rounded-lg border border-amber-200 bg-amber-50 px-2 py-1.5 text-[11px] text-amber-700 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-300">
<div className="font-medium">{t('flowRuns.inspector.diagnosticsTitle')}</div>
<ul className="mt-0.5 space-y-0.5">
{step.diagnostics.map((diag, diagIdx) => (
<li key={`${diag.location}-${diagIdx}`} className="break-all font-mono">
{diag.location} {diag.expression}{' '}
<span className="font-sans">{t('flowRuns.inspector.diagnosticResolvedNull')}</span>
</li>
))}
</ul>
</div>
)}
{items.length > 0 && (
<details className="mt-1.5">
<summary className="cursor-pointer text-[11px] font-medium text-content-faint hover:text-content-secondary">
@@ -0,0 +1,151 @@
/**
* FlowRunsSidebar — the workflow's recent runs, projected into the root shell's
* dynamic left sidebar while a flow is open on the canvas (`/flows/:id`). A
* compact, scannable run history (status dot + status + relative time); clicking
* a run opens the full {@link FlowRunInspectorDrawer} (which polls its live
* status). One-shot fetch via `listFlowRuns` with a manual refresh — the engine
* emits no list-level socket events, so this mirrors `FlowRunsDrawer`'s model.
*
* Rendered by `FlowCanvasPage` inside a `SidebarContent` portal, so it only
* appears for a persisted flow (a draft has no runs yet).
*/
import createDebug from 'debug';
import { useCallback, useEffect, useState } from 'react';
import { useT } from '../../lib/i18n/I18nContext';
import { type FlowRun, listFlowRuns } from '../../services/api/flowsApi';
import { CenteredLoadingState, ErrorBanner } from '../ui/LoadingState';
import {
FLOW_RUN_STATUS_ACCENT,
FLOW_RUN_STATUS_DOT,
FLOW_RUN_STATUS_KEY,
FlowRunInspectorDrawer,
} from './FlowRunInspectorDrawer';
/** Matches `useT()`'s `t` signature. */
type TFn = (key: string, fallback?: string) => string;
function relativeTime(iso: string, t: TFn): string {
const ms = Date.now() - new Date(iso).getTime();
const mins = Math.floor(ms / 60000);
if (mins < 1) return t('flows.list.justNow');
if (mins < 60) return t('flows.list.minutesAgo').replace('{count}', String(mins));
const hrs = Math.floor(mins / 60);
if (hrs < 24) return t('flows.list.hoursAgo').replace('{count}', String(hrs));
const days = Math.floor(hrs / 24);
return t('flows.list.daysAgo').replace('{count}', String(days));
}
const log = createDebug('app:flows:runs-sidebar');
export interface FlowRunsSidebarProps {
flowId: string;
}
export default function FlowRunsSidebar({ flowId }: FlowRunsSidebarProps) {
const { t } = useT();
const [runs, setRuns] = useState<FlowRun[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedRunId, setSelectedRunId] = useState<string | null>(null);
const load = useCallback(async () => {
log('loading runs for flow=%s', flowId);
setLoading(true);
setError(null);
try {
const result = await listFlowRuns(flowId);
setRuns(result);
log('loaded %d runs', result.length);
} catch (err) {
log('load failed: %o', err);
setError(t('flows.runs.loadError'));
} finally {
setLoading(false);
}
}, [flowId, t]);
useEffect(() => {
void load();
}, [load]);
return (
<div className="flex h-full flex-col" data-testid="flow-runs-sidebar">
<div className="flex flex-shrink-0 items-center justify-between gap-2 px-3 py-2">
<span className="text-[11px] font-semibold uppercase tracking-wide text-content-faint">
{t('flows.runs.sidebarTitle')}
</span>
<button
type="button"
onClick={() => void load()}
disabled={loading}
data-testid="flow-runs-sidebar-refresh"
aria-label={t('flows.runs.refresh')}
title={t('flows.runs.refresh')}
className="rounded-md p-1 text-content-faint transition-colors hover:bg-surface-hover hover:text-content-secondary disabled:opacity-50">
<svg
className="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h5M20 20v-5h-5M4 9a8 8 0 0114-3m2 8a8 8 0 01-14 3"
/>
</svg>
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-2">
{loading && runs.length === 0 && <CenteredLoadingState label={t('flows.runs.loading')} />}
{error && (
<div className="px-1">
<ErrorBanner message={error} />
</div>
)}
{!loading && !error && runs.length === 0 && (
<p
className="px-2 py-6 text-center text-xs text-content-faint"
data-testid="flow-runs-sidebar-empty">
{t('flows.runs.empty')}
</p>
)}
<ul className="space-y-1">
{runs.map(run => (
<li key={run.id}>
<button
type="button"
data-testid={`flow-runs-sidebar-run-${run.id}`}
onClick={() => setSelectedRunId(run.id)}
className={`flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-surface-hover ${
selectedRunId === run.id ? 'bg-surface-hover' : ''
}`}>
<span
className={`h-2 w-2 shrink-0 rounded-full ${FLOW_RUN_STATUS_DOT[run.status]}`}
aria-hidden="true"
/>
<span className="min-w-0 flex-1">
<span
className={`inline-flex items-center rounded-full border px-1.5 py-0.5 text-[10px] font-medium ${FLOW_RUN_STATUS_ACCENT[run.status]}`}>
{t(FLOW_RUN_STATUS_KEY[run.status])}
</span>
<span className="mt-0.5 block truncate text-[11px] text-content-faint">
{relativeTime(run.started_at, t)}
</span>
</span>
</button>
</li>
))}
</ul>
</div>
<FlowRunInspectorDrawer runId={selectedRunId} onClose={() => setSelectedRunId(null)} />
</div>
);
}
@@ -6,7 +6,6 @@
* trigger node and no edges, then navigates into the new flow's canvas.
* - "From a template" reveals the gallery; picking a card calls `flows_create`
* with that template's exact graph and navigates into the canvas.
* - "Describe it" invokes the `onDescribe` hand-off (Chat).
* - A `flows_create` rejection surfaces the localized error banner.
*
* `react-router-dom`'s `useNavigate` and `flowsApi.createFlow` are mocked so the
@@ -29,9 +28,8 @@ vi.mock('../../services/api/flowsApi', () => ({ createFlow }));
function renderModal() {
const onClose = vi.fn();
const onDescribe = vi.fn();
render(<NewWorkflowModal onClose={onClose} onDescribe={onDescribe} />);
return { onClose, onDescribe };
render(<NewWorkflowModal onClose={onClose} />);
return { onClose };
}
describe('NewWorkflowModal', () => {
@@ -72,13 +70,9 @@ describe('NewWorkflowModal', () => {
await waitFor(() => expect(navigate).toHaveBeenCalledWith('/flows/flow-tpl'));
});
it('describe it triggers the onDescribe hand-off and does not create a flow', () => {
const { onDescribe } = renderModal();
fireEvent.click(screen.getByTestId('new-workflow-describe'));
expect(onDescribe).toHaveBeenCalledTimes(1);
expect(createFlow).not.toHaveBeenCalled();
it('does not offer a redundant "Describe it" option (the prompt bar covers it)', () => {
renderModal();
expect(screen.queryByTestId('new-workflow-describe')).not.toBeInTheDocument();
});
it('can navigate from the gallery back to the chooser', () => {
+5 -16
View File
@@ -6,8 +6,10 @@
* `manual` trigger, then open it in the editable canvas.
* - **From a template** — switch to the {@link FlowTemplateGallery} view;
* picking a card creates a flow from that template's graph and opens it.
* - **Describe it** — interim: seed the intent in Chat so the user can invoke
* `propose_workflow`. Superseded by the Phase 5 in-place prompt bar.
*
* The old "Describe it" option was dropped: the Flows page already shows the
* `WorkflowPromptBar` at all times (hero when empty, compact otherwise), so a
* modal option that only re-focused that same bar was redundant.
*
* Create + navigate is delegated to {@link useCreateFlow}; this component only
* owns which view is showing and assembles the name/graph for each path.
@@ -24,12 +26,6 @@ import { BLANK_FLOW_KEY, useCreateFlow } from './useCreateFlow';
interface NewWorkflowModalProps {
onClose: () => void;
/**
* "Describe it" handler — navigates to Chat with the workflow-building intent.
* TODO(phase-5): replace this Chat hand-off with the in-place prompt bar that
* runs `propose_workflow` directly on the canvas.
*/
onDescribe: () => void;
}
type View = 'chooser' | 'gallery';
@@ -63,7 +59,7 @@ function ChooserOption({
const log = createDebug('app:flows:new');
export default function NewWorkflowModal({ onClose, onDescribe }: NewWorkflowModalProps) {
export default function NewWorkflowModal({ onClose }: NewWorkflowModalProps) {
const { t } = useT();
const [view, setView] = useState<View>('chooser');
const { create, busyKey, error, clearError } = useCreateFlow();
@@ -128,13 +124,6 @@ export default function NewWorkflowModal({ onClose, onDescribe }: NewWorkflowMod
disabled={busy}
onClick={openGallery}
/>
<ChooserOption
testId="new-workflow-describe"
title={t('flows.chooser.describeTitle')}
description={t('flows.chooser.describeDescription')}
disabled={busy}
onClick={onDescribe}
/>
</>
) : (
<>
@@ -10,6 +10,7 @@ vi.mock('../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (key: string) =
const hookState = vi.hoisted(() => ({
sending: false,
proposal: null as WorkflowProposal | null,
messages: [] as Array<{ id: string; content: string; sender: 'user' | 'agent' }>,
error: null as string | null,
send: vi.fn(),
clearProposal: vi.fn(),
@@ -38,6 +39,7 @@ describe('WorkflowCopilotPanel', () => {
beforeEach(() => {
hookState.sending = false;
hookState.proposal = null;
hookState.messages = [];
hookState.error = null;
hookState.send = vi.fn().mockResolvedValue(undefined);
hookState.clearProposal = vi.fn();
@@ -53,10 +55,12 @@ describe('WorkflowCopilotPanel', () => {
onClose={vi.fn()}
/>
);
fireEvent.change(screen.getByTestId('workflow-copilot-input'), {
// The copilot now uses the shared ChatComposer (textarea by placeholder,
// `send-message-button` for send).
fireEvent.change(screen.getByPlaceholderText('flows.copilot.placeholder'), {
target: { value: 'add a Slack notification on failure' },
});
fireEvent.click(screen.getByTestId('workflow-copilot-send'));
fireEvent.click(screen.getByTestId('send-message-button'));
expect(hookState.send).toHaveBeenCalledTimes(1);
const arg = hookState.send.mock.calls[0][0];
@@ -64,6 +68,28 @@ describe('WorkflowCopilotPanel', () => {
expect(arg.prompt).toContain(JSON.stringify(baseGraph));
});
it('renders the conversation transcript (user + agent turns)', () => {
hookState.messages = [
{ id: 'm1', content: 'add a Slack step', sender: 'user' },
{ id: 'm2', content: 'Done — proposed a Slack notification.', sender: 'agent' },
];
render(
<WorkflowCopilotPanel
graph={baseGraph}
onProposal={vi.fn()}
onAccept={vi.fn()}
onReject={vi.fn()}
onClose={vi.fn()}
/>
);
expect(screen.getByTestId('workflow-copilot-user')).toHaveTextContent('add a Slack step');
expect(screen.getByTestId('workflow-copilot-agent')).toHaveTextContent(
'Done — proposed a Slack notification.'
);
// With a transcript present, the empty-state hint is gone.
expect(screen.queryByTestId('workflow-copilot-empty')).not.toBeInTheDocument();
});
it('surfaces a new proposal to the host and shows the added/removed diff', () => {
const onProposal = vi.fn();
// proposed drops "b" and adds "c" vs. base [a, b].
+106 -39
View File
@@ -3,9 +3,15 @@
* `workflow_builder` specialist, docked on the editable canvas. The user asks
* for changes ("add a Slack notification on failure", "make the schedule
* weekdays only"); each turn injects the CURRENT draft graph as context and the
* agent returns a `revise_workflow` proposal. The panel surfaces the proposal's
* node-level diff and hands Accept/Reject up to the host, which applies it to
* the local draft overlay — a `revise_workflow` loop.
* agent returns a `revise_workflow` proposal (and can now discover + connect the
* Composio apps a step needs). The panel renders the full conversation
* transcript, surfaces each proposal's node-level diff, and hands Accept/Reject
* up to the host, which applies it to the local draft overlay.
*
* Chat UI parity: the composer is the same {@link ChatComposer} the main chat
* windows use (mic/attachments off here), and turns render as bubbles via the
* shared {@link BubbleMarkdown}, so the copilot reads like a real chat rather
* than a one-shot form.
*
* Invariant: the copilot only PROPOSES. Accept applies to the UNSAVED local
* draft (no `flows_update`); persistence stays behind the canvas's own Save.
@@ -21,12 +27,19 @@ import {
type RepairPromptContext,
} from '../../lib/flows/workflowBuilderPrompt';
import { useT } from '../../lib/i18n/I18nContext';
import { BubbleMarkdown } from '../../pages/conversations/components/AgentMessageBubble';
import type { WorkflowProposal } from '../../store/chatRuntimeSlice';
import ChatComposer from '../chat/ChatComposer';
import Button from '../ui/Button';
interface Props {
/** The current draft graph, injected as context for each revise turn. */
graph: WorkflowGraph;
/**
* The saved flow's id (or `null`/absent for an unsaved draft), injected into
* revise turns so the agent can `run_workflow` it to test — with confirmation.
*/
flowId?: string | null;
/**
* Fires when the agent returns a fresh proposal, so the host can enter its
* diff-preview overlay. The host computes/holds the preview; this panel only
@@ -44,20 +57,43 @@ interface Props {
* repair turn once on mount so the copilot opens already diagnosing.
*/
repairSeed?: RepairPromptContext | null;
/**
* The workflow's persisted copilot thread id (from the per-flow cache), so
* reopening the panel resumes the same conversation instead of starting fresh.
*/
seedThreadId?: string | null;
/** Reports the live thread id up so the host can persist it per workflow. */
onThreadIdChange?: (threadId: string | null) => void;
}
export default function WorkflowCopilotPanel({
graph,
flowId = null,
onProposal,
onAccept,
onReject,
onClose,
repairSeed = null,
seedThreadId = null,
onThreadIdChange,
}: Props) {
const { t } = useT();
const { sending, proposal, error, send, clearProposal } = useWorkflowBuilderChat();
const { threadId, sending, proposal, messages, error, send, clearProposal } =
useWorkflowBuilderChat(seedThreadId);
const [text, setText] = useState('');
// Report the (lazily-created) thread id up so the host persists it per flow —
// reopening the copilot then resumes this same conversation.
useEffect(() => {
onThreadIdChange?.(threadId);
}, [threadId, onThreadIdChange]);
// ChatComposer plumbing (mic/attachments are off, so most refs are inert).
const textInputRef = useRef<HTMLTextAreaElement | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const isComposingTextRef = useRef(false);
const scrollRef = useRef<HTMLDivElement | null>(null);
// Surface each NEW proposal to the host exactly once (enter preview overlay).
const lastSurfacedRef = useRef<WorkflowProposal | null>(null);
useEffect(() => {
@@ -78,16 +114,25 @@ export default function WorkflowCopilotPanel({
});
}, [repairSeed, send, t]);
const submit = useCallback(async () => {
const trimmed = text.trim();
if (!trimmed || sending) return;
await send({ displayText: trimmed, prompt: buildRevisePrompt(trimmed, graph) });
setText('');
}, [text, sending, send, graph]);
// Keep the transcript pinned to the newest message / thinking indicator.
// `scrollTo` is optional-chained: jsdom (tests) doesn't implement it.
useEffect(() => {
scrollRef.current?.scrollTo?.({ top: scrollRef.current.scrollHeight });
}, [messages, sending, proposal]);
const onKeyDown = useCallback(
const submit = useCallback(
async (raw?: string) => {
const trimmed = (raw ?? text).trim();
if (!trimmed || sending) return;
setText('');
await send({ displayText: trimmed, prompt: buildRevisePrompt(trimmed, graph, flowId) });
},
[text, sending, send, graph, flowId]
);
const handleInputKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (event.key === 'Enter' && !event.shiftKey) {
if (event.key === 'Enter' && !event.shiftKey && !isComposingTextRef.current) {
event.preventDefault();
void submit();
}
@@ -95,6 +140,9 @@ export default function WorkflowCopilotPanel({
[submit]
);
const noopAttach = useCallback(async () => {}, []);
const noop = useCallback(() => {}, []);
const accept = useCallback(() => {
if (!proposal) return;
onAccept(proposal);
@@ -109,6 +157,7 @@ export default function WorkflowCopilotPanel({
}, [onReject, clearProposal]);
const diff = proposal ? diffGraphs(graph, proposal.graph as WorkflowGraph) : null;
const isEmpty = messages.length === 0 && !proposal && !sending && !error;
return (
<aside
@@ -129,13 +178,34 @@ export default function WorkflowCopilotPanel({
</button>
</header>
<div className="flex-1 space-y-3 overflow-y-auto px-3 py-3 text-sm">
{!proposal && !sending && (
<div
ref={scrollRef}
className="flex-1 space-y-3 overflow-y-auto px-3 py-3"
data-testid="workflow-copilot-transcript">
{isEmpty && (
<p className="text-xs text-content-muted" data-testid="workflow-copilot-empty">
{t('flows.copilot.emptyState')}
</p>
)}
{/* Conversation transcript: user turns right-aligned, agent turns left. */}
{messages.map(message =>
message.sender === 'user' ? (
<div key={message.id} className="flex justify-end" data-testid="workflow-copilot-user">
<div className="max-w-[85%] rounded-2xl bg-primary-500 px-3 py-1.5 text-sm text-content-inverted">
{message.content}
</div>
</div>
) : (
<div
key={message.id}
className="max-w-[92%] rounded-2xl bg-surface-subtle px-3 py-1.5"
data-testid="workflow-copilot-agent">
<BubbleMarkdown content={message.content} />
</div>
)
)}
{sending && (
<p className="text-xs text-content-muted" data-testid="workflow-copilot-thinking">
{t('flows.copilot.thinking')}
@@ -200,31 +270,28 @@ export default function WorkflowCopilotPanel({
</div>
<div className="border-t border-line px-3 py-2.5">
<label htmlFor="workflow-copilot-input" className="sr-only">
{t('flows.copilot.placeholder')}
</label>
<div className="flex items-end gap-2">
<textarea
id="workflow-copilot-input"
data-testid="workflow-copilot-input"
value={text}
onChange={e => setText(e.target.value)}
onKeyDown={onKeyDown}
rows={2}
disabled={sending}
placeholder={t('flows.copilot.placeholder')}
className="min-h-[42px] flex-1 resize-none rounded-lg border border-line bg-surface px-3 py-2 text-sm text-content placeholder:text-content-faint focus:border-ocean-400 focus:outline-none disabled:opacity-60"
/>
<Button
type="button"
variant="primary"
size="sm"
data-testid="workflow-copilot-send"
disabled={sending || text.trim().length === 0}
onClick={() => void submit()}>
{sending ? t('flows.copilot.thinking') : t('flows.copilot.send')}
</Button>
</div>
<ChatComposer
inputValue={text}
setInputValue={setText}
onSend={submit}
textInputRef={textInputRef}
fileInputRef={fileInputRef}
composerInteractionBlocked={sending}
isSending={sending}
attachments={[]}
onAttachFiles={noopAttach}
onRemoveAttachment={noop}
attachError={null}
onSwitchToMicCloud={noop}
handleInputKeyDown={handleInputKeyDown}
inlineCompletionSuffix=""
isComposingTextRef={isComposingTextRef}
maxAttachments={0}
allowedMimeTypes={[]}
attachmentsEnabled={false}
micEnabled={false}
placeholder={t('flows.copilot.placeholder')}
/>
</div>
</aside>
);
@@ -46,10 +46,12 @@ import {
type WorkflowGraphMeta,
xyflowToWorkflowGraph,
} from '../../../lib/flows/graphAdapter';
import { PALETTE_ENTRIES, type PaletteEntry } from '../../../lib/flows/nodeKindMeta';
import type { NodeKind, WorkflowGraph } from '../../../lib/flows/types';
import { useT } from '../../../lib/i18n/I18nContext';
import { type FlowConnection, listFlowConnections } from '../../../services/api/flowsApi';
import Button from '../../ui/Button';
import { type CanvasActions, CanvasActionsContext } from './canvasActions';
import './flowCanvasStyles.css';
import FlowNodeComponent from './FlowNodeComponent';
import FlowValidationBanner from './FlowValidationBanner';
@@ -59,6 +61,44 @@ import { useFlowValidation } from './useFlowValidation';
const log = createDebug('app:flows:canvas:edit');
function UndoIcon() {
return (
<svg
className="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 14L4 9l5-5" />
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 9h11a5 5 0 010 10h-1"
/>
</svg>
);
}
function RedoIcon() {
return (
<svg
className="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 14l5-5-5-5" />
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M20 9H9a5 5 0 000 10h1"
/>
</svg>
);
}
const NODE_TYPES = { [FLOW_NODE_TYPE]: FlowNodeComponent };
const DELETE_KEYS = ['Backspace', 'Delete'];
@@ -152,8 +192,80 @@ function EditableFlowCanvas({
const [nodes, setNodes, onNodesChange] = useNodesState<FlowNode>(initialNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState<FlowEdge>(initialEdges);
const rfRef = useRef<ReactFlowInstance<FlowNode, FlowEdge> | null>(null);
// ── Undo / redo history ───────────────────────────────────────────────────
// A bounded past/future stack of {nodes, edges} snapshots so structural edits
// (add / connect / delete / move) and config edits are recoverable without
// nuking ALL edits via Discard. Every mutating action snapshots the PRE-change
// state via `pushHistory` before it mutates; undo/redo swap the current state
// with the neighbouring snapshot. Consecutive config edits to the same node
// coalesce into one history entry (see `lastConfigNodeRef`) so typing in the
// config drawer doesn't produce a per-keystroke undo trail.
type FlowSnapshot = { nodes: FlowNode[]; edges: FlowEdge[] };
const HISTORY_LIMIT = 50;
const [history, setHistory] = useState<{ past: FlowSnapshot[]; future: FlowSnapshot[] }>({
past: [],
future: [],
});
const nodesRef = useRef(nodes);
const edgesRef = useRef(edges);
nodesRef.current = nodes;
edgesRef.current = edges;
// Node id whose config edits are currently being coalesced into one entry, or
// `null` when the last snapshot was any other kind of change.
const lastConfigNodeRef = useRef<string | null>(null);
// True while a drag is in flight so we snapshot the pre-drag positions exactly
// once per drag, not on every intermediate position change.
const draggingRef = useRef(false);
const pushHistory = useCallback((kind: 'config' | 'structural', nodeId?: string) => {
// Coalesce a run of config edits to the same node into a single undo step.
if (kind === 'config' && lastConfigNodeRef.current === nodeId) return;
lastConfigNodeRef.current = kind === 'config' ? (nodeId ?? null) : null;
setHistory(h => ({
past: [...h.past, { nodes: nodesRef.current, edges: edgesRef.current }].slice(-HISTORY_LIMIT),
future: [],
}));
}, []);
const undo = useCallback(() => {
lastConfigNodeRef.current = null;
setHistory(h => {
if (h.past.length === 0) return h;
const previous = h.past[h.past.length - 1];
const current = { nodes: nodesRef.current, edges: edgesRef.current };
setNodes(previous.nodes);
setEdges(previous.edges);
log(
'undo: restored snapshot nodes=%d edges=%d',
previous.nodes.length,
previous.edges.length
);
return { past: h.past.slice(0, -1), future: [...h.future, current].slice(-HISTORY_LIMIT) };
});
}, [setNodes, setEdges]);
const redo = useCallback(() => {
lastConfigNodeRef.current = null;
setHistory(h => {
if (h.future.length === 0) return h;
const next = h.future[h.future.length - 1];
const current = { nodes: nodesRef.current, edges: edgesRef.current };
setNodes(next.nodes);
setEdges(next.edges);
log('redo: restored snapshot nodes=%d edges=%d', next.nodes.length, next.edges.length);
return { past: [...h.past, current].slice(-HISTORY_LIMIT), future: h.future.slice(0, -1) };
});
}, [setNodes, setEdges]);
const canUndo = history.past.length > 0;
const canRedo = history.future.length > 0;
// First-run hint visibility: a near-empty canvas (≤1 node, no edges, and no
// copilot diff preview in flight) is almost certainly a fresh scratch flow.
const showOnboarding =
nodes.length <= 1 && edges.length === 0 && addedNodeIds.size === 0 && removedNodeIds.size === 0;
const addCounter = useRef(0);
const [selectionCount, setSelectionCount] = useState(0);
// Id of the single selected node whose config the drawer edits (`null` when
// zero or multiple nodes — or any edge — are selected).
const [configNodeId, setConfigNodeId] = useState<string | null>(null);
@@ -270,6 +382,35 @@ function EditableFlowCanvas({
return `new-${kind}-${addCounter.current++}`;
}, []);
// Snapshot on structural node/edge removals (covers xyflow's own
// Backspace/Delete path, which bypasses `handleDeleteSelected`) and once at
// the start of a drag (pre-drag positions), so both are undoable.
const handleNodesChange = useCallback(
(changes: Parameters<typeof onNodesChange>[0]) => {
if (changes.some(c => c.type === 'remove')) {
pushHistory('structural');
} else {
const dragging = changes.some(c => c.type === 'position' && c.dragging === true);
const dragEnded = changes.some(c => c.type === 'position' && c.dragging === false);
if (dragging && !draggingRef.current) {
draggingRef.current = true;
pushHistory('structural');
}
if (dragEnded) draggingRef.current = false;
}
onNodesChange(changes);
},
[onNodesChange, pushHistory]
);
const handleEdgesChange = useCallback(
(changes: Parameters<typeof onEdgesChange>[0]) => {
if (changes.some(c => c.type === 'remove')) pushHistory('structural');
onEdgesChange(changes);
},
[onEdgesChange, pushHistory]
);
const onConnect = useCallback(
(connection: Connection) => {
if (!isValidFlowConnection(connection, nodes, edges)) {
@@ -278,9 +419,10 @@ function EditableFlowCanvas({
return;
}
log('onConnect: accepted %o', connection);
pushHistory('structural');
setEdges(current => addEdge(connection, current));
},
[nodes, edges, setEdges, onInvalidConnection]
[nodes, edges, setEdges, onInvalidConnection, pushHistory]
);
// Live drag feedback: React Flow calls this while dragging a new connection
@@ -292,20 +434,26 @@ function EditableFlowCanvas({
);
const addNode = useCallback(
(kind: NodeKind, position: { x: number; y: number }) => {
const id = nextNodeId(kind);
const name = t(`flows.nodeKind.${kind}`, kind);
const node = createFlowNode(kind, position, id, name);
log('addNode: kind=%s id=%s at %o', kind, id, position);
setNodes(current => [...current, node]);
(entry: PaletteEntry, position: { x: number; y: number }) => {
const id = nextNodeId(entry.kind);
const name = t(entry.labelKey, entry.kind);
const node = createFlowNode(entry.kind, position, id, name);
// Merge the palette entry's preset config (e.g. tool_call provider) so the
// two tool nodes (App action / Tool) start in the right mode.
const withPreset = entry.preset
? { ...node, data: { ...node.data, config: { ...node.data.config, ...entry.preset } } }
: node;
log('addNode: key=%s kind=%s id=%s at %o', entry.key, entry.kind, id, position);
pushHistory('structural');
setNodes(current => [...current, withPreset]);
},
[nextNodeId, setNodes, t]
[nextNodeId, setNodes, t, pushHistory]
);
const handlePaletteAdd = useCallback(
(kind: NodeKind) => {
(entry: PaletteEntry) => {
const step = addCounter.current * CLICK_ADD_STEP;
addNode(kind, { x: CLICK_ADD_ORIGIN.x + step, y: CLICK_ADD_ORIGIN.y + step });
addNode(entry, { x: CLICK_ADD_ORIGIN.x + step, y: CLICK_ADD_ORIGIN.y + step });
},
[addNode]
);
@@ -313,13 +461,14 @@ function EditableFlowCanvas({
const handleDrop = useCallback(
(event: React.DragEvent) => {
event.preventDefault();
const kind = event.dataTransfer.getData(PALETTE_DND_MIME) as NodeKind;
if (!kind) return;
const key = event.dataTransfer.getData(PALETTE_DND_MIME);
const entry = PALETTE_ENTRIES.find(e => e.key === key);
if (!entry) return;
const instance = rfRef.current;
const position = instance
? instance.screenToFlowPosition({ x: event.clientX, y: event.clientY })
: { ...CLICK_ADD_ORIGIN };
addNode(kind, position);
addNode(entry, position);
},
[addNode]
);
@@ -329,22 +478,35 @@ function EditableFlowCanvas({
event.dataTransfer.dropEffect = 'copy';
}, []);
const handleDeleteSelected = useCallback(() => {
const removedNodeIds = new Set(nodes.filter(n => n.selected).map(n => n.id));
const removedEdgeIds = new Set(edges.filter(e => e.selected).map(e => e.id));
if (removedNodeIds.size === 0 && removedEdgeIds.size === 0) return;
log('deleteSelected: nodes=%d edges=%d', removedNodeIds.size, removedEdgeIds.size);
setNodes(current => current.filter(n => !removedNodeIds.has(n.id)));
// Drop explicitly-selected edges AND any edge left dangling by a removed node.
setEdges(current =>
current.filter(
e =>
!removedEdgeIds.has(e.id) &&
!removedNodeIds.has(e.source) &&
!removedNodeIds.has(e.target)
)
);
}, [nodes, edges, setNodes, setEdges]);
// Delete a single node (the selected node card's Delete action) plus its
// incident edges. Keyboard Backspace/Delete still removes the multi-selection
// through React Flow's native `deleteKeyCode` (snapshotted for undo in
// `handleNodesChange`), so this is the only explicit delete affordance left.
const deleteNode = useCallback(
(nodeId: string) => {
log('deleteNode: id=%s', nodeId);
pushHistory('structural');
setNodes(current => current.filter(n => n.id !== nodeId));
setEdges(current => current.filter(e => e.source !== nodeId && e.target !== nodeId));
},
[setNodes, setEdges, pushHistory]
);
// Detach a single edge (from the config drawer's connections list).
const removeEdge = useCallback(
(edgeId: string) => {
log('removeEdge: id=%s', edgeId);
pushHistory('structural');
setEdges(current => current.filter(e => e.id !== edgeId));
},
[setEdges, pushHistory]
);
// Node id → display name, for labelling the other end of each connection.
const nodeLabelById = useMemo(
() => Object.fromEntries(nodes.map(n => [n.id, n.data.name])),
[nodes]
);
const handleSave = useCallback(async () => {
// Hard errors block Save (warnings are allowed through). Belt-and-braces:
@@ -380,31 +542,47 @@ function EditableFlowCanvas({
baseline.nodes.length,
baseline.edges.length
);
// Snapshot pre-discard so Discard itself is undoable.
pushHistory('structural');
setNodes(baseline.nodes);
setEdges(baseline.edges);
setConfigNodeId(null);
setSaveError(null);
setForcedDirty(false);
}, [baseline, setNodes, setEdges]);
}, [baseline, setNodes, setEdges, pushHistory]);
const handleValidate = useCallback(() => {
log('validate: manual trigger');
void validateNow();
}, [validateNow]);
// Canvas actions surfaced on the selected node card (delete this node /
// validate the graph) — see `canvasActions.ts`. Memoised so the context
// value is stable across renders that don't change validation state.
const canvasActions = useMemo<CanvasActions>(
() => ({ deleteNode, validate: () => void validateNow(), validating }),
[deleteNode, validateNow, validating]
);
// Open the config drawer on an explicit node CLICK only. React Flow doesn't
// fire `onNodeClick` for a drag (dragging emits drag events instead), so
// grabbing a node to move it no longer pops the drawer open — the fix for
// "dragging the card opens the sidebar".
const onNodeClick = useCallback((_event: React.MouseEvent, node: FlowNode) => {
log('nodeClick: id=%s — opening config', node.id);
setConfigNodeId(node.id);
}, []);
const onSelectionChange = useCallback(
({ nodes: selNodes, edges: selEdges }: { nodes: FlowNode[]; edges: FlowEdge[] }) => {
setSelectionCount(selNodes.length + selEdges.length);
// Open the config drawer only for an unambiguous single-node selection;
// any edge in the selection, or 0/2+ nodes, closes it.
const nextId = selEdges.length === 0 && selNodes.length === 1 ? selNodes[0].id : null;
log(
'selectionChange: nodes=%d edges=%d configNode=%s',
selNodes.length,
selEdges.length,
nextId ?? 'none'
);
setConfigNodeId(nextId);
// Selection only CLOSES the drawer now (opening is `onNodeClick`'s job):
// clicking empty canvas, selecting an edge, or multi-selecting drops the
// single-node config context. A lone node stays as-is (opened by a click,
// left closed after a drag).
const isSingleNode = selEdges.length === 0 && selNodes.length === 1;
if (!isSingleNode) {
log(
'selectionChange: nodes=%d edges=%d — closing config',
selNodes.length,
selEdges.length
);
setConfigNodeId(null);
}
},
[]
);
@@ -418,6 +596,8 @@ function EditableFlowCanvas({
patch.name ?? '(unchanged)',
patch.config ? 'present' : '(unchanged)'
);
// Coalesce consecutive edits to the same node into one undo step.
pushHistory('config', nodeId);
setNodes(current =>
current.map(n =>
n.id === nodeId
@@ -433,7 +613,29 @@ function EditableFlowCanvas({
)
);
},
[setNodes]
[setNodes, pushHistory]
);
// Keyboard undo/redo: Cmd/Ctrl+Z undoes, Cmd/Ctrl+Shift+Z (or Ctrl+Y) redoes.
// Ignored while typing in the config drawer / any field so text-edit undo in a
// focused input isn't hijacked.
const handleCanvasKeyDown = useCallback(
(event: React.KeyboardEvent) => {
const target = event.target as HTMLElement | null;
const tag = target?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || target?.isContentEditable) return;
const mod = event.metaKey || event.ctrlKey;
if (!mod) return;
const key = event.key.toLowerCase();
if (key === 'z' && !event.shiftKey) {
event.preventDefault();
undo();
} else if ((key === 'z' && event.shiftKey) || key === 'y') {
event.preventDefault();
redo();
}
},
[undo, redo]
);
// Close the drawer AND clear the selection, so re-clicking the same node
@@ -449,105 +651,139 @@ function EditableFlowCanvas({
const configNode = configNodeId ? (nodes.find(n => n.id === configNodeId) ?? null) : null;
return (
<div
className="flow-canvas relative h-full w-full"
data-testid="flow-canvas"
data-editable="true"
onDrop={handleDrop}
onDragOver={handleDragOver}>
<NodePalette onAdd={handlePaletteAdd} />
<CanvasActionsContext.Provider value={canvasActions}>
<div
className="flow-canvas relative h-full w-full"
data-testid="flow-canvas"
data-editable="true"
onDrop={handleDrop}
onDragOver={handleDragOver}
onKeyDown={handleCanvasKeyDown}>
<NodePalette onAdd={handlePaletteAdd} />
<div className="pointer-events-none absolute right-3 top-3 z-10 flex items-center gap-2">
{dirty && (
<span
className="pointer-events-auto rounded-full bg-amber-100 px-2 py-0.5 text-[11px] font-medium text-amber-700 dark:bg-amber-500/15 dark:text-amber-300"
data-testid="flow-editor-dirty">
{t('flows.editor.unsaved')}
</span>
)}
<Button
type="button"
variant="secondary"
tone="danger"
size="xs"
className="pointer-events-auto"
data-testid="flow-editor-delete"
disabled={selectionCount === 0}
onClick={handleDeleteSelected}>
{t('flows.editor.deleteSelected')}
</Button>
<Button
type="button"
variant="secondary"
size="xs"
className="pointer-events-auto"
data-testid="flow-editor-validate"
disabled={validating}
onClick={handleValidate}>
{validating ? t('flows.editor.validating') : t('flows.editor.validate')}
</Button>
<Button
type="button"
variant="tertiary"
size="xs"
className="pointer-events-auto"
data-testid="flow-editor-discard"
disabled={!dirty || saving}
onClick={handleDiscard}>
{t('flows.editor.discard')}
</Button>
{onSave && (
<Button
type="button"
variant="primary"
size="xs"
className="pointer-events-auto"
data-testid="flow-editor-save"
title={hasErrors ? t('flows.editor.saveBlocked') : undefined}
disabled={!dirty || hasErrors || saving || saveDisabled}
onClick={handleSave}>
{saving ? t('flows.editor.saving') : t('flows.editor.save')}
</Button>
)}
</div>
<div className="pointer-events-none absolute inset-x-3 bottom-3 z-10 flex justify-center">
<div className="pointer-events-auto w-full max-w-md">
<FlowValidationBanner validation={validation} saveError={saveError} />
{/* Undo/redo on the left, then the draft-state cluster (unsaved badge →
Discard → Save). Per-node Validate/Delete now live on the selected node
card (see FlowNodeComponent), so they're no longer in this toolbar. */}
<div className="pointer-events-none absolute right-3 top-3 z-10 flex items-center gap-2">
<div className="pointer-events-auto flex items-center gap-1">
<Button
type="button"
variant="tertiary"
size="xs"
iconOnly
data-testid="flow-editor-undo"
aria-label={t('flows.editor.undo')}
title={t('flows.editor.undo')}
disabled={!canUndo}
onClick={undo}>
<UndoIcon />
</Button>
<Button
type="button"
variant="tertiary"
size="xs"
iconOnly
data-testid="flow-editor-redo"
aria-label={t('flows.editor.redo')}
title={t('flows.editor.redo')}
disabled={!canRedo}
onClick={redo}>
<RedoIcon />
</Button>
</div>
<div className="pointer-events-auto flex items-center gap-2 border-l border-line pl-2">
{dirty && (
<span
className="rounded-full bg-amber-100 px-2 py-0.5 text-[11px] font-medium text-amber-700 dark:bg-amber-500/15 dark:text-amber-300"
data-testid="flow-editor-dirty">
{t('flows.editor.unsaved')}
</span>
)}
<Button
type="button"
variant="tertiary"
size="xs"
data-testid="flow-editor-discard"
disabled={!dirty || saving}
onClick={handleDiscard}>
{t('flows.editor.discard')}
</Button>
{onSave && (
<Button
type="button"
variant="primary"
size="xs"
data-testid="flow-editor-save"
title={hasErrors ? t('flows.editor.saveBlocked') : undefined}
disabled={!dirty || hasErrors || saving || saveDisabled}
onClick={handleSave}>
{saving ? t('flows.editor.saving') : t('flows.editor.save')}
</Button>
)}
</div>
</div>
{/* First-run hint: a near-empty canvas (a fresh scratch flow opens with
just its trigger) gets a non-blocking nudge toward the palette. Hides
itself as soon as a second node lands. */}
{showOnboarding && (
<div
className="pointer-events-none absolute inset-0 z-0 flex items-center justify-center px-6"
data-testid="flow-editor-onboarding">
<div className="max-w-xs rounded-2xl border border-dashed border-line bg-surface/70 px-5 py-4 text-center backdrop-blur-sm">
<p className="text-sm font-semibold text-content">
{t('flows.editor.onboardingTitle')}
</p>
<p className="mt-1 text-xs leading-relaxed text-content-muted">
{t('flows.editor.onboardingBody')}
</p>
</div>
</div>
)}
<div className="pointer-events-none absolute inset-x-3 bottom-3 z-10 flex justify-center">
<div className="pointer-events-auto w-full max-w-md">
<FlowValidationBanner validation={validation} saveError={saveError} />
</div>
</div>
<ReactFlow
nodes={displayNodes}
edges={edges}
nodeTypes={NODE_TYPES}
onInit={instance => {
rfRef.current = instance;
}}
onNodesChange={handleNodesChange}
onEdgesChange={handleEdgesChange}
onConnect={onConnect}
isValidConnection={isValidConnection}
onNodeClick={onNodeClick}
onSelectionChange={onSelectionChange}
deleteKeyCode={DELETE_KEYS}
nodesDraggable
nodesConnectable
elementsSelectable
fitView
panOnScroll
zoomOnScroll>
<Background variant={BackgroundVariant.Dots} gap={16} size={1} />
<MiniMap pannable zoomable />
<Controls showInteractive={false} />
</ReactFlow>
<NodeConfigDrawer
node={configNode}
onClose={handleCloseConfig}
onChange={updateNode}
connections={connections}
nodes={nodes}
edges={edges}
nodeLabelById={nodeLabelById}
onRemoveEdge={removeEdge}
/>
</div>
<ReactFlow
nodes={displayNodes}
edges={edges}
nodeTypes={NODE_TYPES}
onInit={instance => {
rfRef.current = instance;
}}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
isValidConnection={isValidConnection}
onSelectionChange={onSelectionChange}
deleteKeyCode={DELETE_KEYS}
nodesDraggable
nodesConnectable
elementsSelectable
fitView
panOnScroll
zoomOnScroll>
<Background variant={BackgroundVariant.Dots} gap={16} size={1} />
<MiniMap pannable zoomable />
<Controls showInteractive={false} />
</ReactFlow>
<NodeConfigDrawer
node={configNode}
onClose={handleCloseConfig}
onChange={updateNode}
connections={connections}
/>
</div>
</CanvasActionsContext.Provider>
);
}
@@ -1,37 +1,85 @@
/**
* FlowNodeComponent — the custom xyflow node renderer for the read-only
* Workflow Canvas (issue B5b.1). Renders one rounded card per `WorkflowNode`:
* a per-kind emoji + colored accent, the node's name, and a `Handle` per
* effective input port (left) / output port (right) — see
* `graphAdapter.ts`'s `FlowNodeData` for why "effective" ports aren't simply
* `data.ports`.
* FlowNodeComponent — the custom xyflow node renderer for the Workflow Canvas
* (issue B5b.1). Renders one rounded card per `WorkflowNode`: a per-kind emoji +
* colored accent header, the node's name, a dynamic one-line summary of what the
* node will do (derived from its live config via {@link describeNode}), and a
* labelled row per input port (left) / output port (right).
*
* Emoji (not an icon library) matches the repo's existing convention —
* there is no `lucide-react` (or any icon-font) dependency in this app today
* (icons are hand-rolled inline SVG, see `components/ui/icons.tsx`), and
* adding one is out of scope for this slice's single approved dependency
* (`@xyflow/react`).
* Ports read as labelled handle rows rather than a plaintext list: each port's
* `Handle` sits inline next to its name so it's unambiguous which dot carries
* which input/output (e.g. a `condition`'s `true`/`false` outputs). Branch ports
* are colour-coded (true → sage, false/error → coral). A lone implicit `main`
* port shows just its handle dot — left = input, right = output.
*
* An unrecognized `kind` (not one of the 12 `NodeKind` values — e.g. a future
* tinyflows addition, since `Flow.graph` is `unknown` on the wire) renders as
* a plain neutral node rather than throwing, since a thrown render error here
* has no error boundary around `<ReactFlow>` and would take down the whole
* canvas.
* When the card is selected in the editable canvas, an in-card action row
* (Validate / Delete) appears via {@link useCanvasActions} — the read-only
* viewer has no actions context, so it never shows them.
*
* An unrecognized `kind` renders as a plain neutral node rather than throwing,
* since a thrown render error here has no error boundary around `<ReactFlow>`.
*/
import { Handle, type NodeProps, Position } from '@xyflow/react';
import { memo } from 'react';
import { type CSSProperties, memo } from 'react';
import type { FlowNode } from '../../../lib/flows/graphAdapter';
import { COLOR_CLASSES, handleOffsets, nodeKindMeta } from '../../../lib/flows/nodeKindMeta';
import { COLOR_CLASSES, nodeKindMeta } from '../../../lib/flows/nodeKindMeta';
import { describeNode } from '../../../lib/flows/nodeSummary';
import { useT } from '../../../lib/i18n/I18nContext';
import { useCanvasActions } from './canvasActions';
function FlowNodeComponent({ data, selected }: NodeProps<FlowNode>) {
/**
* Inline the handle into the port row instead of React Flow's default absolute
* edge placement, so each dot flows next to its label. React Flow still derives
* the connection point from the handle's measured position, so edges attach
* correctly.
*/
const INLINE_HANDLE_STYLE: CSSProperties = {
position: 'relative',
top: 'auto',
left: 'auto',
right: 'auto',
transform: 'none',
};
/** The implicit single port; shown as a bare dot with no redundant label. */
const IMPLICIT_PORT = 'main';
/** Semantic colours for the well-known branch ports so routing reads at a glance. */
function portPillClass(port: string): string {
const base = 'rounded px-1.5 py-0.5 text-[10px] font-medium leading-none';
const key = port.toLowerCase();
if (key === 'true') {
return `${base} bg-sage-100 text-sage-700 dark:bg-sage-500/20 dark:text-sage-300`;
}
if (key === 'false' || key === 'error') {
return `${base} bg-coral-100 text-coral-700 dark:bg-coral-500/20 dark:text-coral-300`;
}
return `${base} bg-surface-subtle text-content-secondary`;
}
function FlowNodeComponent({ id, data, selected }: NodeProps<FlowNode>) {
const { t } = useT();
const meta = nodeKindMeta(data.kind);
const actions = useCanvasActions();
const baseMeta = nodeKindMeta(data.kind);
// A native "Tool" node (provider=openhuman / oh: slug) reads differently from
// the Composio "App action" node even though both are `tool_call`.
const isNativeTool =
data.kind === 'tool_call' &&
(data.config?.provider === 'openhuman' ||
(typeof data.config?.slug === 'string' && data.config.slug.startsWith('oh:')));
const meta = isNativeTool ? { ...baseMeta, emoji: '🛠️', color: 'primary' as const } : baseMeta;
const colors = COLOR_CLASSES[meta.color];
const inputOffsets = handleOffsets(data.inputPorts.length);
const outputOffsets = handleOffsets(data.outputPorts.length);
const kindLabel = t(`flows.nodeKind.${data.kind}`, data.kind);
const summary = describeNode(data.kind, data.config ?? {}, data.outputPorts);
// Only label ports when there's something to disambiguate: more than one port,
// or a single explicitly-named (non-`main`) port. A lone implicit `main` shows
// just its dot.
const labelInputs = data.inputPorts.length > 1 || data.inputPorts.some(p => p !== IMPLICIT_PORT);
const labelOutputs =
data.outputPorts.length > 1 || data.outputPorts.some(p => p !== IMPLICIT_PORT);
const hasPorts = data.inputPorts.length > 0 || data.outputPorts.length > 0;
const showActions = Boolean(actions) && selected;
return (
<div
@@ -40,49 +88,79 @@ function FlowNodeComponent({ data, selected }: NodeProps<FlowNode>) {
className={`relative min-w-[180px] max-w-[240px] rounded-xl border-2 bg-surface shadow-sm ${colors.border} ${
selected ? 'ring-2 ring-primary-500/40' : ''
}`}>
{data.inputPorts.map((port, i) => (
<Handle
key={`in-${port}`}
id={port}
type="target"
position={Position.Left}
style={{ top: `${inputOffsets[i]}%` }}
title={port}
/>
))}
<div className={`flex items-center gap-2 rounded-t-[10px] px-3 py-2 ${colors.chip}`}>
<span className="text-base leading-none" aria-hidden="true">
{meta.emoji}
</span>
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-content">{data.name}</div>
<div className="truncate text-[10px] uppercase tracking-wide text-content-faint">
{kindLabel}
</div>
</div>
<div className="min-w-0 truncate text-sm font-semibold text-content">{data.name}</div>
</div>
{data.outputPorts.length > 1 && (
<div className="space-y-0.5 px-3 py-2 text-[10px] text-content-faint">
{data.outputPorts.map(port => (
<div key={port} className="truncate">
{port}
</div>
))}
{/* Dynamic "what this does" line, derived from the node's live config. The
emoji already conveys the kind, so we show the description here rather
than repeating the kind label (which duplicates a default node's name).
Falls back to the kind label only when there's no config summary. */}
<div
className="px-3 pt-2 text-[11px] leading-snug text-content-muted"
data-testid="flow-node-summary">
{summary || kindLabel}
</div>
{hasPorts && (
<div className="flex items-start justify-between gap-4 px-2 py-2">
{/* Inputs — handle on the left edge, label to its right. */}
<div className="flex min-w-0 flex-col gap-1.5">
{data.inputPorts.map(port => (
<div key={`in-${port}`} className="flex items-center gap-1.5">
<Handle
id={port}
type="target"
position={Position.Left}
style={INLINE_HANDLE_STYLE}
title={port}
/>
{labelInputs && <span className={`truncate ${portPillClass(port)}`}>{port}</span>}
</div>
))}
</div>
{/* Outputs — label first, handle on the right edge. */}
<div className="flex min-w-0 flex-col items-end gap-1.5">
{data.outputPorts.map(port => (
<div key={`out-${port}`} className="flex items-center gap-1.5">
{labelOutputs && <span className={`truncate ${portPillClass(port)}`}>{port}</span>}
<Handle
id={port}
type="source"
position={Position.Right}
style={INLINE_HANDLE_STYLE}
title={port}
/>
</div>
))}
</div>
</div>
)}
{data.outputPorts.map((port, i) => (
<Handle
key={`out-${port}`}
id={port}
type="source"
position={Position.Right}
style={{ top: `${outputOffsets[i]}%` }}
title={port}
/>
))}
{/* Per-node actions on the selected card (editable canvas only). */}
{showActions && actions && (
<div className="flex items-center justify-end gap-1 border-t border-line px-2 py-1.5">
<button
type="button"
data-testid="flow-node-validate"
disabled={actions.validating}
onClick={() => actions.validate()}
className="rounded-md px-2 py-1 text-[11px] font-medium text-content-secondary transition-colors hover:bg-surface-hover disabled:opacity-50">
{actions.validating ? t('flows.editor.validating') : t('flows.editor.validate')}
</button>
<button
type="button"
data-testid="flow-node-delete"
onClick={() => actions.deleteNode(id)}
className="rounded-md px-2 py-1 text-[11px] font-medium text-coral-600 transition-colors hover:bg-coral-50 dark:text-coral-400 dark:hover:bg-coral-500/10">
{t('flows.editor.deleteNode')}
</button>
</div>
)}
</div>
);
}
+49 -43
View File
@@ -1,28 +1,31 @@
/**
* NodePalette — the editable Workflow Canvas's insert palette (issue B5b.2 /
* Phase 3a). Lists all 12 tinyflows `NodeKind`s with the same emoji + accent
* `FlowNodeComponent` renders (both pull from `lib/flows/nodeKindMeta.ts`), and
* offers two ways to add a node:
* Phase 3a). Lists the tinyflows node kinds as {@link PaletteEntry}s grouped
* into labelled sections (Triggers / Actions / Logic). `tool_call` splits into
* two entries — an "App action" (Composio OAuth) and a "Tool" (native OpenHuman)
* — so the two are distinct nodes in the palette. Two ways to add:
*
* - **click** an entry → `onAdd(kind)` (the canvas drops it at a default
* position). Keyboard-accessible and the path the unit tests drive, since
* jsdom can't produce real drag geometry.
* - **drag** an entry onto the canvas → sets a `application/tinyflows-node`
* dataTransfer payload the canvas's `onDrop` reads to place the node under
* the cursor. Pointer-only affordance, layered on top of click.
* - **click** an entry → `onAdd(entry)` (the canvas drops it at a default
* position). Keyboard-accessible and the path the unit tests drive.
* - **drag** an entry onto the canvas → sets the entry `key` on a
* `application/tinyflows-node` payload the canvas's `onDrop` resolves.
*/
import { memo } from 'react';
import { COLOR_CLASSES, NODE_KINDS, nodeKindMeta } from '../../../lib/flows/nodeKindMeta';
import type { NodeKind } from '../../../lib/flows/types';
import {
COLOR_CLASSES,
NODE_GROUP_ORDER,
PALETTE_ENTRIES_BY_GROUP,
type PaletteEntry,
} from '../../../lib/flows/nodeKindMeta';
import { useT } from '../../../lib/i18n/I18nContext';
/** dataTransfer MIME key for a palette drag — read by the canvas `onDrop`. */
export const PALETTE_DND_MIME = 'application/tinyflows-node';
export interface NodePaletteProps {
/** Add a node of `kind` at the canvas's default insert position (click path). */
onAdd: (kind: NodeKind) => void;
/** Add a node from the given palette entry at the canvas's default position. */
onAdd: (entry: PaletteEntry) => void;
}
function NodePalette({ onAdd }: NodePaletteProps) {
@@ -30,38 +33,41 @@ function NodePalette({ onAdd }: NodePaletteProps) {
return (
<aside
className="pointer-events-auto absolute left-3 top-3 z-10 flex max-h-[calc(100%-1.5rem)] w-44 flex-col overflow-hidden rounded-xl border border-line bg-surface/95 shadow-sm backdrop-blur"
className="pointer-events-auto absolute left-3 top-3 z-10 flex max-h-[calc(100%-1.5rem)] w-48 flex-col overflow-hidden rounded-xl border border-line bg-surface/95 shadow-sm backdrop-blur"
data-testid="flow-node-palette"
aria-label={t('flows.palette.title')}>
<div className="border-b border-line px-3 py-2 text-[11px] font-semibold uppercase tracking-wide text-content-faint">
{t('flows.palette.title')}
</div>
<div className="flex flex-col gap-1 overflow-y-auto p-2">
{NODE_KINDS.map(kind => {
const meta = nodeKindMeta(kind);
const colors = COLOR_CLASSES[meta.color];
const label = t(`flows.nodeKind.${kind}`, kind);
return (
<button
key={kind}
type="button"
draggable
data-testid={`flow-palette-item-${kind}`}
data-node-kind={kind}
onClick={() => onAdd(kind)}
onDragStart={event => {
event.dataTransfer.setData(PALETTE_DND_MIME, kind);
event.dataTransfer.effectAllowed = 'copy';
}}
title={t('flows.palette.addNode').replace('{kind}', label)}
className={`flex items-center gap-2 rounded-lg border px-2 py-1.5 text-left text-xs text-content transition-colors hover:bg-surface-hover ${colors.border}`}>
<span className="text-base leading-none" aria-hidden="true">
{meta.emoji}
</span>
<span className="truncate">{label}</span>
</button>
);
})}
<div className="flex flex-col gap-2 overflow-y-auto p-2">
{NODE_GROUP_ORDER.map(group => (
<div key={group} className="flex flex-col gap-1">
<div className="px-1 text-[10px] font-semibold uppercase tracking-wide text-content-faint">
{t(`flows.palette.group.${group}`)}
</div>
{PALETTE_ENTRIES_BY_GROUP[group].map(entry => {
const colors = COLOR_CLASSES[entry.color];
const label = t(entry.labelKey, entry.kind);
return (
<button
key={entry.key}
type="button"
draggable
data-testid={`flow-palette-item-${entry.key}`}
data-node-kind={entry.kind}
onClick={() => onAdd(entry)}
onDragStart={event => {
event.dataTransfer.setData(PALETTE_DND_MIME, entry.key);
event.dataTransfer.effectAllowed = 'copy';
}}
title={t('flows.palette.addNode').replace('{kind}', label)}
className={`flex items-center gap-2 rounded-lg border px-2 py-1.5 text-left text-xs text-content transition-colors hover:bg-surface-hover ${colors.tint} ${colors.border}`}>
<span className="text-base leading-none" aria-hidden="true">
{entry.emoji}
</span>
<span className="truncate">{label}</span>
</button>
);
})}
</div>
))}
</div>
</aside>
);
@@ -93,9 +93,40 @@ describe('FlowCanvas (editable)', () => {
expect(graph.edges).toEqual([]);
});
it('disables the delete button when nothing is selected', () => {
it('does not render Delete/Validate in the top toolbar (moved onto node cards)', () => {
renderCanvas(<FlowCanvas editable nodes={[triggerNode()]} edges={[]} />);
expect(screen.getByTestId('flow-editor-delete')).toBeDisabled();
expect(screen.queryByTestId('flow-editor-delete')).not.toBeInTheDocument();
expect(screen.queryByTestId('flow-editor-validate')).not.toBeInTheDocument();
// Undo/redo + Save still live in the toolbar.
expect(screen.getByTestId('flow-editor-undo')).toBeInTheDocument();
});
it('shows the onboarding hint on a near-empty canvas and hides it after a node is added', () => {
renderCanvas(<FlowCanvas editable nodes={[triggerNode()]} edges={[]} />);
expect(screen.getByTestId('flow-editor-onboarding')).toBeInTheDocument();
fireEvent.click(screen.getByTestId('flow-palette-item-agent'));
expect(screen.queryByTestId('flow-editor-onboarding')).not.toBeInTheDocument();
});
it('undoes and redoes a palette add', () => {
renderCanvas(<FlowCanvas editable nodes={[triggerNode()]} edges={[]} />);
// Undo starts disabled (empty history); redo too.
expect(screen.getByTestId('flow-editor-undo')).toBeDisabled();
expect(screen.getByTestId('flow-editor-redo')).toBeDisabled();
fireEvent.click(screen.getByTestId('flow-palette-item-agent'));
expect(screen.getAllByTestId('flow-node')).toHaveLength(2);
expect(screen.getByTestId('flow-editor-undo')).not.toBeDisabled();
// Undo removes the added node and enables redo.
fireEvent.click(screen.getByTestId('flow-editor-undo'));
expect(screen.getAllByTestId('flow-node')).toHaveLength(1);
expect(screen.getByTestId('flow-editor-undo')).toBeDisabled();
expect(screen.getByTestId('flow-editor-redo')).not.toBeDisabled();
// Redo brings it back.
fireEvent.click(screen.getByTestId('flow-editor-redo'));
expect(screen.getAllByTestId('flow-node')).toHaveLength(2);
});
it('exposes no Save button when onSave is not provided', () => {
@@ -67,8 +67,8 @@ describe('EditableFlowCanvas — validation + dirty state', () => {
// Make an edit so the graph is dirty (Save is only ever enabled when dirty).
fireEvent.click(screen.getByTestId('flow-palette-item-agent'));
// Force validation immediately via the explicit button.
fireEvent.click(screen.getByTestId('flow-editor-validate'));
// Validation runs automatically on the debounce after the edit (the manual
// Validate button now lives on the selected node card).
const errors = await screen.findByTestId('flow-editor-errors');
expect(errors).toHaveTextContent('invalid config for node t: missing schedule');
@@ -93,7 +93,7 @@ describe('EditableFlowCanvas — validation + dirty state', () => {
renderCanvas();
fireEvent.click(screen.getByTestId('flow-palette-item-agent'));
fireEvent.click(screen.getByTestId('flow-editor-validate'));
// Auto-validation (debounced) surfaces the warning.
const warnings = await screen.findByTestId('flow-editor-warnings');
expect(warnings).toHaveTextContent('does not fire automatically');
@@ -63,6 +63,31 @@ describe('FlowCanvas', () => {
expect(screen.getByText('Reply')).toBeInTheDocument();
});
it("labels named output ports (e.g. a condition's true/false) instead of a plaintext dump", () => {
const conditionNode: FlowNode = {
id: 'c',
type: 'flowNode',
position: { x: 0, y: 0 },
data: {
kind: 'condition',
name: 'Check status',
config: {},
ports: [],
inputPorts: ['main'],
outputPorts: ['true', 'false'],
} satisfies FlowNodeData,
};
render(<FlowCanvas nodes={[conditionNode]} edges={[]} />);
expect(screen.getByText('true')).toBeInTheDocument();
expect(screen.getByText('false')).toBeInTheDocument();
});
it('does not label a lone implicit main port (just its handle dot)', () => {
render(<FlowCanvas nodes={sampleNodes()} edges={sampleEdges()} />);
// A plain agent/trigger node with a single `main` in/out shows no "main" text.
expect(screen.queryByText('main')).not.toBeInTheDocument();
});
it('renders the minimap and zoom/pan controls', () => {
const { container } = render(<FlowCanvas nodes={sampleNodes()} edges={sampleEdges()} />);
expect(container.querySelector('.react-flow__minimap')).not.toBeNull();
@@ -0,0 +1,24 @@
/**
* Canvas actions context — lets a selected {@link FlowNodeComponent} card
* trigger the editor-level actions (delete this node, validate the graph)
* without threading callbacks through React Flow's node `data` (which is part
* of the serialized graph). `EditableFlowCanvas` provides it; the read-only
* viewer leaves it `null`, so node cards render no actions there.
*/
import { createContext, useContext } from 'react';
export interface CanvasActions {
/** Remove a single node (and its incident edges) by id. */
deleteNode: (nodeId: string) => void;
/** Run a full-graph validation pass. */
validate: () => void;
/** True while a validation pass is in flight. */
validating: boolean;
}
export const CanvasActionsContext = createContext<CanvasActions | null>(null);
/** Returns the canvas actions, or `null` in the read-only viewer. */
export function useCanvasActions(): CanvasActions | null {
return useContext(CanvasActionsContext);
}
@@ -10,6 +10,19 @@
* *standalone Artifacts*, not this Tailwind `darkMode: 'class'` app.
*/
/*
* Contain React Flow's internal stacking. The `.react-flow` container sets no
* z-index of its own, so its descendants' z-indexes (edges, and especially the
* dragged `.react-flow__connectionline` at z-index 1001) leak into the shared
* context and can paint OVER our sibling overlays (node palette, toolbar, and
* the node-config "edit box" drawer at z-20) — the drawer then reads as clipped
* / behind the canvas. Isolating the subtree keeps those z-indexes internal, so
* the overlays reliably sit above the whole canvas.
*/
.flow-canvas .react-flow {
isolation: isolate;
}
.flow-canvas .react-flow__background {
background-color: rgb(var(--surface-canvas));
}
@@ -59,6 +72,64 @@
border-radius: 0.75rem;
}
/*
* MiniMap node + viewport mask: React Flow paints these with hard-coded light
* greys (an `#e2e2e2` node fill + a translucent light mask) that read wrong on
* the dark theme. CSS `fill` wins over the SVG presentation attribute React
* Flow sets, so route both through theme tokens instead. `.react-flow__minimap`
* itself is already themed above.
*/
.flow-canvas .react-flow__minimap-node {
fill: rgb(var(--line-strong));
stroke: none;
}
.flow-canvas .react-flow__minimap-mask {
fill: rgb(var(--surface-canvas) / 0.6);
}
/*
* Connection line: the ghost edge drawn while dragging a new connection. React
* Flow's default stroke is near-black; use the primary accent (matching a
* hovered/selected edge) so it's visible on both themes.
*/
.flow-canvas .react-flow__connectionline .react-flow__connection-path,
.flow-canvas .react-flow__connectionline path {
stroke: rgb(var(--primary-500));
stroke-width: 1.5;
}
/*
* Drag-select rectangle + the bounding box around a multi-node selection: the
* defaults are a fixed blue; tint them with the primary accent at low opacity.
*/
.flow-canvas .react-flow__selection,
.flow-canvas .react-flow__nodesselection-rect {
background: rgb(var(--primary-500) / 0.08);
border: 1px solid rgb(var(--primary-500) / 0.6);
}
/*
* Handle connection feedback: while dragging a new edge React Flow (v12) flags
* the source handle (`.connectingfrom`), the hovered target (`.connectingto`),
* and a target that would form a valid edge (`.valid`). Paint the source
* primary, an invalid hovered target coral, and a valid one sage — so the
* port-aware `isValidConnection` result reads at a glance on both themes. The
* `.valid` rules come last so they win over the coral `.connectingto` default.
*/
.flow-canvas .react-flow__handle.connectingfrom {
background: rgb(var(--primary-500));
}
.flow-canvas .react-flow__handle.connectingto {
background: rgb(var(--coral-500));
}
.flow-canvas .react-flow__handle.connectingto.valid,
.flow-canvas .react-flow__handle.valid {
background: rgb(var(--sage-500));
}
.flow-canvas .react-flow__attribution {
background: rgb(var(--surface) / 0.7);
color: rgb(var(--content-muted));
@@ -19,12 +19,15 @@ import createDebug from 'debug';
import { memo, useCallback, useMemo, useState } from 'react';
import { useEscapeKey } from '../../../../hooks/useEscapeKey';
import type { FlowNode } from '../../../../lib/flows/graphAdapter';
import type { FlowEdge, FlowNode } from '../../../../lib/flows/graphAdapter';
import { nodeKindMeta } from '../../../../lib/flows/nodeKindMeta';
import { describeNode } from '../../../../lib/flows/nodeSummary';
import { useT } from '../../../../lib/i18n/I18nContext';
import type { FlowConnection } from '../../../../services/api/flowsApi';
import { JsonField } from './nodeConfigFields';
import { NODE_CONFIG_FORMS } from './nodeConfigForms';
import { NodeConnections } from './NodeConnections';
import { type UpstreamExpressionOption, upstreamExpressionOptions } from './upstreamOptions';
const log = createDebug('app:flows:nodeConfig:drawer');
@@ -41,16 +44,26 @@ export interface NodeConfigDrawerProps {
onChange: (nodeId: string, patch: NodeConfigPatch) => void;
/** Secret-free credential refs for the picker (loaded once by the canvas). */
connections: FlowConnection[];
/** All graph nodes — used to derive the upstream `=nodes.…` picker options. */
nodes?: FlowNode[];
/** All graph edges — the drawer shows the selected node's incident ones. */
edges?: FlowEdge[];
/** Node id → display name, for labelling the other end of each connection. */
nodeLabelById?: Record<string, string>;
/** Remove a single edge by id (from the connections list). */
onRemoveEdge?: (edgeId: string) => void;
}
function NodeConfigBody({
node,
onChange,
connections,
upstreamOptions,
}: {
node: FlowNode;
onChange: (nodeId: string, patch: NodeConfigPatch) => void;
connections: FlowConnection[];
upstreamOptions: UpstreamExpressionOption[];
}) {
const { t } = useT();
const config = useMemo(() => node.data.config ?? {}, [node.data.config]);
@@ -94,7 +107,12 @@ function NodeConfigBody({
)}
{Form && !rawMode ? (
<Form config={config} onChange={mergeConfig} connections={connections} />
<Form
config={config}
onChange={mergeConfig}
connections={connections}
upstreamOptions={upstreamOptions}
/>
) : (
<JsonField
label={t('flows.nodeConfig.rawJsonLabel')}
@@ -109,7 +127,16 @@ function NodeConfigBody({
);
}
function NodeConfigDrawer({ node, onClose, onChange, connections }: NodeConfigDrawerProps) {
function NodeConfigDrawer({
node,
onClose,
onChange,
connections,
nodes = [],
edges = [],
nodeLabelById = {},
onRemoveEdge = () => {},
}: NodeConfigDrawerProps) {
const { t } = useT();
useEscapeKey(() => {
@@ -117,10 +144,20 @@ function NodeConfigDrawer({ node, onClose, onChange, connections }: NodeConfigDr
onClose();
}, node !== null);
// Upstream `=nodes.…` picker options for the selected node's expression
// fields — its transitive ancestors' outputs (Feature: `nodes` scope).
const upstreamOptions = useMemo(
() => (node ? upstreamExpressionOptions(node.id, nodes, edges) : []),
[node, nodes, edges]
);
if (!node) return null;
const meta = nodeKindMeta(node.data.kind);
const kindLabel = t(`flows.nodeKind.${node.data.kind}`, node.data.kind);
// Dynamic "what this node will do", derived from the live config — updates as
// the fields below are edited (same summary shown on the node card).
const summary = describeNode(node.data.kind, node.data.config ?? {}, node.data.outputPorts);
return (
// `pointer-events-none` wrapper so the drawer floats over the canvas
@@ -135,12 +172,16 @@ function NodeConfigDrawer({ node, onClose, onChange, connections }: NodeConfigDr
{meta.emoji}
</span>
<div className="min-w-0 flex-1">
<div className="text-[11px] font-semibold uppercase tracking-wide text-content-faint">
{kindLabel}
</div>
{/* Kind eyebrow — hidden when it just repeats the name (a default,
unrenamed node), so the header doesn't show the title twice. */}
{node.data.name.trim() !== kindLabel && (
<div className="text-[11px] font-semibold uppercase tracking-wide text-content-faint">
{kindLabel}
</div>
)}
<input
type="text"
className="mt-0.5 w-full border-0 bg-transparent p-0 text-sm font-semibold text-content focus:outline-none focus:ring-0"
className="w-full border-0 bg-transparent p-0 text-sm font-semibold text-content focus:outline-none focus:ring-0"
value={node.data.name}
aria-label={t('flows.nodeConfig.nameLabel')}
placeholder={t('flows.nodeConfig.namePlaceholder')}
@@ -158,9 +199,30 @@ function NodeConfigDrawer({ node, onClose, onChange, connections }: NodeConfigDr
</button>
</header>
<div className="flex-1 overflow-y-auto px-3.5 py-3.5">
<div className="flex-1 space-y-4 overflow-y-auto px-3.5 py-3.5">
{/* Live, config-derived description of what this node will do. */}
{summary && (
<p
className="rounded-lg border border-line bg-surface-muted px-2.5 py-1.5 text-[11px] leading-snug text-content-muted"
data-testid="node-config-summary">
{summary}
</p>
)}
{/* Incoming/outgoing edge connections — inspect + remove them here. */}
<NodeConnections
nodeId={node.id}
edges={edges}
nodeLabelById={nodeLabelById}
onRemoveEdge={onRemoveEdge}
/>
{/* Keyed by node id so the JSON editor's local buffer re-seeds on switch. */}
<NodeConfigBody key={node.id} node={node} onChange={onChange} connections={connections} />
<NodeConfigBody
key={node.id}
node={node}
onChange={onChange}
connections={connections}
upstreamOptions={upstreamOptions}
/>
</div>
</aside>
</div>
@@ -0,0 +1,138 @@
/**
* NodeConnections — the "Connections" section of the node-config drawer. Lists
* the selected node's incident edges as rows (Inputs = edges arriving at this
* node, Outputs = edges leaving it), each showing the other node's name + the
* ports involved, with a remove (✕) button. Handy for debugging a wiring at a
* glance and detaching an edge without hunting for it on the canvas.
*
* Presentational: the canvas owns the edge state and passes `onRemoveEdge`.
*/
import { useMemo } from 'react';
import type { FlowEdge } from '../../../../lib/flows/graphAdapter';
import { useT } from '../../../../lib/i18n/I18nContext';
export interface NodeConnectionsProps {
nodeId: string;
edges: FlowEdge[];
nodeLabelById: Record<string, string>;
onRemoveEdge: (edgeId: string) => void;
}
/** A port name worth showing — `main` is the implicit default, so hide it. */
function portSuffix(handle: string | null | undefined): string {
return handle && handle !== 'main' ? `:${handle}` : '';
}
function ConnectionRow({
edgeId,
label,
onRemove,
removeLabel,
}: {
edgeId: string;
label: string;
onRemove: () => void;
removeLabel: string;
}) {
return (
<li
className="flex items-center justify-between gap-2 rounded-md border border-line bg-surface-muted px-2 py-1"
data-testid={`node-connection-${edgeId}`}>
<span className="min-w-0 truncate font-mono text-[11px] text-content-secondary">{label}</span>
<button
type="button"
aria-label={removeLabel}
title={removeLabel}
data-testid={`node-connection-remove-${edgeId}`}
onClick={onRemove}
className="shrink-0 rounded p-0.5 text-content-faint transition-colors hover:bg-surface-hover hover:text-coral-600 dark:hover:text-coral-400">
</button>
</li>
);
}
export function NodeConnections({
nodeId,
edges,
nodeLabelById,
onRemoveEdge,
}: NodeConnectionsProps) {
const { t } = useT();
const { inputs, outputs } = useMemo(() => {
const label = (id: string) => nodeLabelById[id] ?? id;
return {
// Incoming: this node is the target. Read as "<source>[:port] → :thisPort".
inputs: edges
.filter(e => e.target === nodeId)
.map(e => ({
id: e.id,
label: `${label(e.source)}${portSuffix(e.sourceHandle)}${portSuffix(e.targetHandle) || 'in'}`,
})),
// Outgoing: this node is the source. Read as "thisPort: → <target>[:port]".
outputs: edges
.filter(e => e.source === nodeId)
.map(e => ({
id: e.id,
label: `${portSuffix(e.sourceHandle) || 'out'}${label(e.target)}${portSuffix(e.targetHandle)}`,
})),
};
}, [edges, nodeId, nodeLabelById]);
const removeLabel = t('flows.nodeConfig.connections.remove');
const hasAny = inputs.length > 0 || outputs.length > 0;
return (
<section className="space-y-2" data-testid="node-connections">
<h3 className="text-[11px] font-semibold uppercase tracking-wide text-content-muted">
{t('flows.nodeConfig.connections.title')}
</h3>
{!hasAny && (
<p className="text-[11px] text-content-faint" data-testid="node-connections-empty">
{t('flows.nodeConfig.connections.none')}
</p>
)}
{inputs.length > 0 && (
<div className="space-y-1">
<div className="text-[10px] font-medium uppercase tracking-wide text-content-faint">
{t('flows.nodeConfig.connections.inputs')}
</div>
<ul className="space-y-1" data-testid="node-connections-inputs">
{inputs.map(edge => (
<ConnectionRow
key={edge.id}
edgeId={edge.id}
label={edge.label}
removeLabel={removeLabel}
onRemove={() => onRemoveEdge(edge.id)}
/>
))}
</ul>
</div>
)}
{outputs.length > 0 && (
<div className="space-y-1">
<div className="text-[10px] font-medium uppercase tracking-wide text-content-faint">
{t('flows.nodeConfig.connections.outputs')}
</div>
<ul className="space-y-1" data-testid="node-connections-outputs">
{outputs.map(edge => (
<ConnectionRow
key={edge.id}
edgeId={edge.id}
label={edge.label}
removeLabel={removeLabel}
onRemove={() => onRemoveEdge(edge.id)}
/>
))}
</ul>
</div>
)}
</section>
);
}
@@ -0,0 +1,197 @@
/**
* ScheduleField — the friendly schedule builder for a `schedule`-kind trigger
* (replaces the raw cron text box). Instead of hand-writing a cron expression,
* the author picks a frequency (every N minutes / hours, or a daily time) and
* optional weekdays; the field compiles that to the same bare cron string the
* flows engine already stores in `trigger.config.schedule`. A live plain-English
* summary ("Every 5 minutes on Wed") sits above the controls, and an "Advanced"
* toggle swaps in a raw cron input for power users / expressions the builder
* doesn't model (which round-trip untouched).
*
* Controlled: it holds no schedule state of its own — the cron string in
* `value` is the single source of truth, derived back into the visual controls
* via {@link parseCron} on every render.
*/
import { useCallback, useEffect, useId, useMemo, useState } from 'react';
import {
buildCron,
type CronFreq,
type CronSpec,
DEFAULT_CRON_SPEC,
describeCron,
formatTime,
parseCron,
WEEKDAY_INITIAL,
WEEKDAY_SHORT,
} from '../../../../lib/flows/cron';
import { useT } from '../../../../lib/i18n/I18nContext';
import { Field, INPUT_CLASS, MONO_CLASS } from './nodeConfigFields';
export interface ScheduleFieldProps {
/** The cron string stored on `config.schedule`. */
value: string;
onChange: (value: string) => void;
testId?: string;
}
const FREQUENCIES: CronFreq[] = ['minutes', 'hours', 'daily'];
export function ScheduleField({ value, onChange, testId }: ScheduleFieldProps) {
const { t } = useT();
const id = useId();
const parsed = useMemo(() => parseCron(value), [value]);
// Open in advanced mode only when the current value is a real cron the builder
// can't model; an empty or builder-shaped value starts in the visual editor.
const [advanced, setAdvanced] = useState(() => value.trim() !== '' && parsed === null);
// Seed a sensible default the first time the field mounts empty (picking the
// "schedule" trigger kind), so the summary + controls are immediately live.
useEffect(() => {
if (value.trim() === '') onChange(buildCron(DEFAULT_CRON_SPEC));
// Run once on mount; the guard keeps it from clobbering a real value.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const spec: CronSpec = parsed ?? DEFAULT_CRON_SPEC;
const patch = useCallback(
(next: Partial<CronSpec>) => onChange(buildCron({ ...spec, ...next })),
[spec, onChange]
);
const toggleWeekday = useCallback(
(day: number) => {
const set = new Set(spec.weekdays);
if (set.has(day)) set.delete(day);
else set.add(day);
patch({ weekdays: [...set] });
},
[spec.weekdays, patch]
);
const enterAdvanced = () => setAdvanced(true);
const exitAdvanced = () => {
// Returning to the visual editor: if the raw cron doesn't parse, reset to a
// builder-shaped default so the controls have something valid to show.
if (!parseCron(value)) onChange(buildCron(DEFAULT_CRON_SPEC));
setAdvanced(false);
};
return (
<Field label={t('flows.nodeConfig.trigger.scheduleLabel')}>
<div className="space-y-2.5" data-testid={testId}>
{/* Live plain-English summary of the compiled cron. */}
<div
className="rounded-lg border border-primary-200 bg-primary-50/60 px-2.5 py-1.5 text-xs font-medium text-primary-700 dark:border-primary-500/30 dark:bg-primary-500/10 dark:text-primary-300"
data-testid={testId ? `${testId}-summary` : undefined}>
{describeCron(value)}
</div>
{advanced ? (
<input
id={id}
type="text"
className={`${INPUT_CLASS} ${MONO_CLASS}`}
value={value}
placeholder="0 9 * * 1"
aria-label={t('flows.nodeConfig.trigger.scheduleCronLabel')}
data-testid={testId ? `${testId}-cron` : undefined}
onChange={e => onChange(e.target.value)}
/>
) : (
<div className="space-y-2.5">
{/* Frequency + interval / time row. */}
<div className="flex flex-wrap items-center gap-2">
<select
className={`${INPUT_CLASS} w-auto flex-none`}
value={spec.freq}
aria-label={t('flows.nodeConfig.trigger.scheduleFreqLabel')}
data-testid={testId ? `${testId}-freq` : undefined}
onChange={e => patch({ freq: e.target.value as CronFreq })}>
{FREQUENCIES.map(f => (
<option key={f} value={f}>
{t(`flows.nodeConfig.trigger.scheduleFreq_${f}`)}
</option>
))}
</select>
{(spec.freq === 'minutes' || spec.freq === 'hours') && (
<label className="flex items-center gap-1.5 text-xs text-content-muted">
{t('flows.nodeConfig.trigger.scheduleEvery')}
<input
type="number"
min={1}
max={spec.freq === 'minutes' ? 59 : 23}
className={`${INPUT_CLASS} w-16`}
value={spec.interval}
aria-label={t('flows.nodeConfig.trigger.scheduleInterval')}
data-testid={testId ? `${testId}-interval` : undefined}
onChange={e => patch({ interval: Number(e.target.value) })}
/>
{t(`flows.nodeConfig.trigger.scheduleUnit_${spec.freq}`)}
</label>
)}
{spec.freq === 'daily' && (
<label className="flex items-center gap-1.5 text-xs text-content-muted">
{t('flows.nodeConfig.trigger.scheduleAt')}
<input
type="time"
className={`${INPUT_CLASS} w-auto`}
value={formatTime(spec.hour, spec.minute)}
aria-label={t('flows.nodeConfig.trigger.scheduleTime')}
data-testid={testId ? `${testId}-time` : undefined}
onChange={e => {
const [h, m] = e.target.value.split(':').map(Number);
patch({ hour: h || 0, minute: m || 0 });
}}
/>
</label>
)}
</div>
{/* Weekday restriction — applies to every frequency; none = every day. */}
<div className="space-y-1">
<span className="block text-[11px] text-content-faint">
{t('flows.nodeConfig.trigger.scheduleDays')}
</span>
<div className="flex gap-1" data-testid={testId ? `${testId}-weekdays` : undefined}>
{WEEKDAY_INITIAL.map((initial, day) => {
const active = spec.weekdays.includes(day);
return (
<button
key={day}
type="button"
aria-pressed={active}
aria-label={WEEKDAY_SHORT[day]}
title={WEEKDAY_SHORT[day]}
data-testid={testId ? `${testId}-day-${day}` : undefined}
onClick={() => toggleWeekday(day)}
className={`h-7 w-7 rounded-full text-[11px] font-semibold transition-colors ${
active
? 'bg-primary-500 text-content-inverted'
: 'border border-line-strong text-content-muted hover:bg-surface-hover'
}`}>
{initial}
</button>
);
})}
</div>
</div>
</div>
)}
<button
type="button"
className="text-[11px] font-medium text-primary-600 hover:underline dark:text-primary-400"
data-testid={testId ? `${testId}-advanced-toggle` : undefined}
onClick={advanced ? exitAdvanced : enterAdvanced}>
{advanced
? t('flows.nodeConfig.trigger.scheduleSimple')
: t('flows.nodeConfig.trigger.scheduleAdvanced')}
</button>
</div>
</Field>
);
}
@@ -45,6 +45,49 @@ describe('NodeConfigDrawer', () => {
expect(screen.getByTestId('node-config-http-method')).toBeInTheDocument();
});
it("lists the node's input/output connections and removes one", () => {
const onRemoveEdge = vi.fn();
render(
<NodeConfigDrawer
node={makeNode()}
onClose={vi.fn()}
onChange={vi.fn()}
connections={[]}
edges={[
{ id: 'e-in', source: 't', target: 'n1', sourceHandle: 'main', targetHandle: 'main' },
{ id: 'e-out', source: 'n1', target: 'a', sourceHandle: 'main', targetHandle: 'main' },
]}
nodeLabelById={{ t: 'Start', n1: 'Fetch data', a: 'Reply' }}
onRemoveEdge={onRemoveEdge}
/>
);
// Incoming edge shows the source node; outgoing shows the target node.
expect(screen.getByTestId('node-connections-inputs')).toHaveTextContent('Start');
expect(screen.getByTestId('node-connections-outputs')).toHaveTextContent('Reply');
fireEvent.click(screen.getByTestId('node-connection-remove-e-out'));
expect(onRemoveEdge).toHaveBeenCalledWith('e-out');
});
it('shows an empty connections state for an unconnected node', () => {
render(
<NodeConfigDrawer node={makeNode()} onClose={vi.fn()} onChange={vi.fn()} connections={[]} />
);
expect(screen.getByTestId('node-connections-empty')).toBeInTheDocument();
});
it('shows a dynamic, config-derived description of the node', () => {
render(
<NodeConfigDrawer
node={makeNode({ config: { method: 'POST', url: 'https://x.test/hook' } })}
onClose={vi.fn()}
onChange={vi.fn()}
connections={[]}
/>
);
expect(screen.getByTestId('node-config-summary')).toHaveTextContent('POST https://x.test/hook');
});
it('emits a name patch when the name is edited', () => {
const onChange = vi.fn();
render(
@@ -0,0 +1,58 @@
/**
* Behavior tests for the friendly schedule builder. Asserts it compiles the
* visual controls (frequency, interval, weekday toggles) to a cron string, shows
* a live plain-English summary, seeds a default when empty, and round-trips a
* custom cron through the advanced text field. `useT()` falls back to the
* bundled English map with no provider mounted (same as the sibling tests).
*/
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { ScheduleField } from '../ScheduleField';
function setup(value = '*/5 * * * *') {
const onChange = vi.fn();
render(<ScheduleField value={value} onChange={onChange} testId="sched" />);
return { onChange };
}
describe('ScheduleField', () => {
it('renders a plain-English summary of the current cron', () => {
setup('*/5 * * * 3');
expect(screen.getByTestId('sched-summary')).toHaveTextContent('Every 5 minutes on Wed');
});
it('seeds a default cron when mounted empty', () => {
const { onChange } = setup('');
// Mount effect writes the default daily-9am schedule.
expect(onChange).toHaveBeenCalledWith('0 9 * * *');
});
it('recompiles the cron when the interval changes', () => {
const { onChange } = setup('*/5 * * * *');
fireEvent.change(screen.getByTestId('sched-interval'), { target: { value: '10' } });
expect(onChange).toHaveBeenLastCalledWith('*/10 * * * *');
});
it('recompiles the cron when the frequency changes to daily', () => {
const { onChange } = setup('*/5 * * * *');
fireEvent.change(screen.getByTestId('sched-freq'), { target: { value: 'daily' } });
// Default daily time (09:00), keeping "every day".
expect(onChange).toHaveBeenLastCalledWith('0 9 * * *');
});
it('toggles a weekday into the cron', () => {
const { onChange } = setup('*/5 * * * *');
// Day index 3 = Wednesday.
fireEvent.click(screen.getByTestId('sched-day-3'));
expect(onChange).toHaveBeenLastCalledWith('*/5 * * * 3');
});
it('opens the advanced cron field for an unmodellable expression', () => {
const { onChange } = setup('0 9 1 * *'); // day-of-month set → advanced
const cron = screen.getByTestId('sched-cron');
expect(cron).toHaveValue('0 9 1 * *');
fireEvent.change(cron, { target: { value: '15 3 * * *' } });
expect(onChange).toHaveBeenLastCalledWith('15 3 * * *');
});
});
@@ -76,6 +76,33 @@ describe('TransformForm', () => {
});
});
describe('AgentForm', () => {
it('offers model hints and patches config.model with the chosen hint', () => {
const { onChange } = renderForm('agent', {});
fireEvent.change(screen.getByTestId('node-config-agent-model'), {
target: { value: 'hint:coding' },
});
expect(onChange).toHaveBeenLastCalledWith({ model: 'hint:coding' });
});
it('reveals a custom model input when Custom is selected', () => {
const { onChange } = renderForm('agent', {});
// No custom text box until Custom is picked.
expect(screen.queryByTestId('node-config-agent-model-custom')).not.toBeInTheDocument();
fireEvent.change(screen.getByTestId('node-config-agent-model'), {
target: { value: '__custom__' },
});
const custom = screen.getByTestId('node-config-agent-model-custom');
fireEvent.change(custom, { target: { value: 'gpt-4o-mini' } });
expect(onChange).toHaveBeenLastCalledWith({ model: 'gpt-4o-mini' });
});
it('opens in custom mode when config.model is a raw model id', () => {
renderForm('agent', { config: { model: 'claude-sonnet-5' } });
expect(screen.getByTestId('node-config-agent-model-custom')).toHaveValue('claude-sonnet-5');
});
});
describe('TriggerForm', () => {
it('reveals the cron schedule field only for the schedule kind and patches it', () => {
const { onChange } = renderForm('trigger', {});
@@ -88,10 +115,12 @@ describe('TriggerForm', () => {
expect(onChange).toHaveBeenLastCalledWith({ trigger_kind: 'schedule' });
});
it('shows the schedule input when config already has a schedule trigger_kind', () => {
const { onChange } = renderForm('trigger', { config: { trigger_kind: 'schedule' } });
const schedule = screen.getByTestId('node-config-trigger-schedule');
fireEvent.change(schedule, { target: { value: '0 9 * * 1' } });
expect(onChange).toHaveBeenLastCalledWith({ schedule: '0 9 * * 1' });
it('shows the friendly schedule builder (with summary) for a schedule trigger', () => {
renderForm('trigger', { config: { trigger_kind: 'schedule', schedule: '*/5 * * * 3' } });
expect(screen.getByTestId('node-config-trigger-schedule')).toBeInTheDocument();
// Compiled plain-English summary instead of a raw cron box.
expect(screen.getByTestId('node-config-trigger-schedule-summary')).toHaveTextContent(
'Every 5 minutes on Wed'
);
});
});
@@ -0,0 +1,326 @@
/**
* Composio-aware dropdown fields for the node-config forms. Instead of making
* users hand-type a toolkit slug, an action slug, or a trigger slug, these pick
* from the user's ACTIVE connections and the live Composio catalog:
*
* - {@link ComposioToolkitField} — the connected apps the user actually has
* (derived from the `FlowConnection[]` the canvas already loaded), so a
* `trigger` app_event picks its app from a dropdown.
* - {@link ComposioActionField} — the real action slugs for a toolkit
* (`composio_list_tools`), for a `tool_call` node's action.
* - {@link ComposioTriggerField} — the available trigger slugs for a toolkit
* (`composio_list_available_triggers`), for an app_event's trigger.
*
* The action/trigger lists are fetched on demand (per toolkit) and cached in
* local state; they keep the current value selectable even if the catalog fetch
* fails, and offer a "Custom…" escape hatch so an advanced/unavailable slug is
* never a dead end. All fetches are guarded — outside Tauri (or on error) the
* field degrades to the custom input rather than throwing.
*/
import { useEffect, useMemo, useState } from 'react';
import { listAvailableTriggers, listTools } from '../../../../lib/composio/composioApi';
import { useT } from '../../../../lib/i18n/I18nContext';
import type { FlowConnection } from '../../../../services/api/flowsApi';
import { Field, INPUT_CLASS, MONO_CLASS } from './nodeConfigFields';
/** Sentinel select value that reveals a raw text input. */
const CUSTOM = '__custom__';
/** Prettify a toolkit slug for display (`googlesheets` → `Googlesheets`). */
function toolkitLabel(slug: string): string {
return slug ? slug.charAt(0).toUpperCase() + slug.slice(1) : slug;
}
/** Distinct connected Composio toolkits from the canvas's loaded connections. */
export function connectedToolkits(connections: FlowConnection[]): string[] {
const seen = new Set<string>();
for (const c of connections) {
if (c.kind === 'composio' && c.toolkit) seen.add(c.toolkit);
}
return [...seen].sort();
}
// ── toolkit (app) picker ─────────────────────────────────────────────────────
export interface ComposioToolkitFieldProps {
label: string;
hint?: string;
value: string;
onChange: (value: string) => void;
connections: FlowConnection[];
testId?: string;
}
export function ComposioToolkitField({
label,
hint,
value,
onChange,
connections,
testId,
}: ComposioToolkitFieldProps) {
const { t } = useT();
const toolkits = useMemo(() => connectedToolkits(connections), [connections]);
if (toolkits.length === 0) {
return (
<Field label={label} hint={hint}>
<p
className="rounded-lg border border-dashed border-line-strong px-2.5 py-1.5 text-xs text-content-faint"
data-testid={testId ? `${testId}-empty` : undefined}>
{t('flows.nodeConfig.composio.noConnections')}
</p>
</Field>
);
}
// Keep a saved-but-now-disconnected toolkit selectable so editing an existing
// flow never silently drops it.
const options = toolkits.includes(value) || value === '' ? toolkits : [value, ...toolkits];
return (
<Field label={label} hint={hint}>
<select
className={INPUT_CLASS}
value={value}
data-testid={testId}
onChange={e => onChange(e.target.value)}>
<option value="">{t('flows.nodeConfig.composio.selectApp')}</option>
{options.map(tk => (
<option key={tk} value={tk}>
{toolkitLabel(tk)}
</option>
))}
</select>
</Field>
);
}
// ── shared catalog-slug dropdown (actions + triggers) ────────────────────────
interface CatalogSlugFieldProps {
label: string;
hint?: string;
value: string;
onChange: (value: string) => void;
/** The toolkit whose catalog to load; empty → prompt to pick a connection. */
toolkit: string;
/** Fetch the slugs for a toolkit. */
fetchSlugs: (toolkit: string) => Promise<string[]>;
emptyPrompt: string;
testId?: string;
}
function CatalogSlugField({
label,
hint,
value,
onChange,
toolkit,
fetchSlugs,
emptyPrompt,
testId,
}: CatalogSlugFieldProps) {
const { t } = useT();
const [slugs, setSlugs] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const [failed, setFailed] = useState(false);
// Custom mode is entered explicitly, or forced when the catalog can't load so
// the field never traps the user with an empty dropdown.
const [custom, setCustom] = useState(false);
useEffect(() => {
if (!toolkit) {
setSlugs([]);
setFailed(false);
return;
}
let cancelled = false;
setLoading(true);
setFailed(false);
void (async () => {
try {
const result = await fetchSlugs(toolkit);
if (!cancelled) setSlugs(result);
} catch {
if (!cancelled) setFailed(true);
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [toolkit, fetchSlugs]);
if (!toolkit) {
return (
<Field label={label} hint={hint}>
<p
className="rounded-lg border border-dashed border-line-strong px-2.5 py-1.5 text-xs text-content-faint"
data-testid={testId ? `${testId}-needs-connection` : undefined}>
{emptyPrompt}
</p>
</Field>
);
}
const showCustomInput = custom || (failed && slugs.length === 0);
// Keep the current value selectable even if it's not in the fetched list.
const options = value && !slugs.includes(value) ? [value, ...slugs] : slugs;
return (
<Field label={label} hint={hint}>
{showCustomInput ? (
<input
type="text"
className={`${INPUT_CLASS} ${MONO_CLASS}`}
value={value}
placeholder={t('flows.nodeConfig.composio.customPlaceholder')}
data-testid={testId ? `${testId}-custom` : undefined}
onChange={e => onChange(e.target.value)}
/>
) : (
<select
className={INPUT_CLASS}
value={value}
disabled={loading}
data-testid={testId}
onChange={e => {
if (e.target.value === CUSTOM) {
setCustom(true);
return;
}
onChange(e.target.value);
}}>
<option value="">
{loading
? t('flows.nodeConfig.composio.loading')
: t('flows.nodeConfig.composio.select')}
</option>
{options.map(slug => (
<option key={slug} value={slug}>
{slug}
</option>
))}
<option value={CUSTOM}>{t('flows.nodeConfig.composio.custom')}</option>
</select>
)}
</Field>
);
}
// ── tool_call action ─────────────────────────────────────────────────────────
async function fetchActionSlugs(toolkit: string): Promise<string[]> {
const res = await listTools([toolkit]);
return res.tools
.map(tool => tool.function?.name)
.filter((name): name is string => typeof name === 'string' && name.length > 0);
}
/** Parsed argument schema of one Composio action (from its JSON-schema `parameters`). */
export interface ComposioActionSchema {
/** Argument names the action requires (`parameters.required`). */
required: string[];
/** The remaining declared argument names (`parameters.properties` minus required). */
optional: string[];
/** Raw per-argument JSON-schema fragments (`parameters.properties`). */
properties: Record<string, unknown>;
}
/** Extract a {@link ComposioActionSchema} from a tool's raw `parameters` object. */
function parseActionSchema(parameters: Record<string, unknown> | undefined): ComposioActionSchema {
const rawProps = parameters?.properties;
const properties =
rawProps && typeof rawProps === 'object' && !Array.isArray(rawProps)
? (rawProps as Record<string, unknown>)
: {};
const rawRequired = parameters?.required;
const required = Array.isArray(rawRequired)
? rawRequired.filter((name): name is string => typeof name === 'string')
: [];
const optional = Object.keys(properties).filter(name => !required.includes(name));
return { required, optional, properties };
}
/**
* Per-toolkit cache of the action → schema map, sharing one in-flight
* `composio_list_tools` call per toolkit across all consumers. Failed fetches
* are evicted so a transient error doesn't poison the session.
*/
const actionSchemaCache = new Map<string, Promise<Map<string, ComposioActionSchema>>>();
/**
* Fetch the argument schema of one Composio action (`toolkit` + action `slug`)
* from the live catalog. Returns `null` when the action isn't in the catalog
* (e.g. a custom slug); rejects when the catalog fetch itself fails — callers
* degrade to the raw args editor in both cases.
*/
export async function fetchActionSchema(
toolkit: string,
slug: string
): Promise<ComposioActionSchema | null> {
let promise = actionSchemaCache.get(toolkit);
if (!promise) {
promise = listTools([toolkit]).then(res => {
const bySlug = new Map<string, ComposioActionSchema>();
for (const tool of res.tools) {
const name = tool.function?.name;
if (typeof name !== 'string' || name.length === 0) continue;
bySlug.set(name, parseActionSchema(tool.function.parameters));
}
return bySlug;
});
actionSchemaCache.set(toolkit, promise);
promise.catch(() => actionSchemaCache.delete(toolkit));
}
const bySlug = await promise;
return bySlug.get(slug) ?? null;
}
export function ComposioActionField(props: {
label: string;
hint?: string;
value: string;
onChange: (value: string) => void;
toolkit: string;
testId?: string;
}) {
const { t } = useT();
return (
<CatalogSlugField
{...props}
fetchSlugs={fetchActionSlugs}
emptyPrompt={t('flows.nodeConfig.tool.pickConnection')}
/>
);
}
// ── app_event trigger ────────────────────────────────────────────────────────
async function fetchTriggerSlugs(toolkit: string): Promise<string[]> {
const res = await listAvailableTriggers(toolkit);
return res.triggers
.map(trigger => trigger.slug)
.filter((slug): slug is string => typeof slug === 'string' && slug.length > 0);
}
export function ComposioTriggerField(props: {
label: string;
hint?: string;
value: string;
onChange: (value: string) => void;
toolkit: string;
testId?: string;
}) {
const { t } = useT();
return (
<CatalogSlugField
{...props}
fetchSlugs={fetchTriggerSlugs}
emptyPrompt={t('flows.nodeConfig.trigger.pickApp')}
/>
);
}
@@ -0,0 +1,111 @@
/**
* NativeToolField — the tool picker for the "Tool" node (native OpenHuman tools,
* as opposed to the Composio "App action" node). Loads the agent's tool
* registry from `listRuntimeTools` (`openhuman.javascript_list_tools`) and lets
* the author pick one from a dropdown; the value stored on `config.slug` is
* `oh:<tool_name>`, which the flow engine routes to the native tool registry.
*
* Shows the selected tool's description + a peek at its parameters so the author
* knows what `args` to supply. Fetches are guarded — outside Tauri / on error it
* falls back to a raw text input so a slug is never a dead end.
*/
import { useEffect, useMemo, useState } from 'react';
import { useT } from '../../../../lib/i18n/I18nContext';
import { listRuntimeTools, type RuntimeTool } from '../../../../services/api/runtimeToolsApi';
import { Field, INPUT_CLASS, MONO_CLASS } from './nodeConfigFields';
/** The `oh:` prefix that marks a native-tool slug (mirrors the Rust constant). */
export const NATIVE_TOOL_PREFIX = 'oh:';
export interface NativeToolFieldProps {
label: string;
hint?: string;
/** The full `config.slug`, e.g. `oh:web_search` (or empty / `oh:`). */
value: string;
onChange: (value: string) => void;
testId?: string;
}
/** Strip the `oh:` prefix to the bare tool name. */
function toolName(slug: string): string {
return slug.startsWith(NATIVE_TOOL_PREFIX) ? slug.slice(NATIVE_TOOL_PREFIX.length) : slug;
}
export function NativeToolField({ label, hint, value, onChange, testId }: NativeToolFieldProps) {
const { t } = useT();
const [tools, setTools] = useState<RuntimeTool[]>([]);
const [loading, setLoading] = useState(true);
const [failed, setFailed] = useState(false);
useEffect(() => {
let cancelled = false;
void (async () => {
try {
const list = await listRuntimeTools();
if (!cancelled) setTools(list);
} catch {
if (!cancelled) setFailed(true);
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, []);
const current = toolName(value);
const selected = useMemo(() => tools.find(tool => tool.name === current), [tools, current]);
// Catalog unavailable → raw text input so the author can still enter a slug.
if (failed && tools.length === 0) {
return (
<Field label={label} hint={hint}>
<input
type="text"
className={`${INPUT_CLASS} ${MONO_CLASS}`}
value={current}
placeholder="web_search"
data-testid={testId ? `${testId}-custom` : undefined}
onChange={e =>
onChange(e.target.value ? `${NATIVE_TOOL_PREFIX}${e.target.value.trim()}` : '')
}
/>
</Field>
);
}
// Keep a saved-but-unknown tool selectable.
const names = current && !tools.some(tk => tk.name === current) ? [current] : [];
const options = [...names, ...tools.map(tk => tk.name)];
return (
<Field label={label} hint={hint}>
<div className="space-y-1.5">
<select
className={INPUT_CLASS}
value={current}
disabled={loading}
data-testid={testId}
onChange={e => onChange(e.target.value ? `${NATIVE_TOOL_PREFIX}${e.target.value}` : '')}>
<option value="">
{loading ? t('flows.nodeConfig.native.loading') : t('flows.nodeConfig.native.select')}
</option>
{options.map(name => (
<option key={name} value={name}>
{name}
</option>
))}
</select>
{selected?.description && (
<p
className="text-[11px] leading-snug text-content-muted"
data-testid="node-config-native-desc">
{selected.description}
</p>
)}
</div>
</Field>
);
}
@@ -20,14 +20,15 @@ import { useCallback, useId, useMemo, useState } from 'react';
import { useT } from '../../../../lib/i18n/I18nContext';
import type { FlowConnection } from '../../../../services/api/flowsApi';
import type { UpstreamExpressionOption } from './upstreamOptions';
const log = createDebug('app:flows:nodeConfig:fields');
const INPUT_CLASS =
export const INPUT_CLASS =
'w-full rounded-lg border border-line-strong bg-surface px-2.5 py-1.5 text-sm text-content ' +
'placeholder-content-faint transition-colors focus:border-primary-500 focus:outline-none ' +
'focus:ring-2 focus:ring-primary-500/20 disabled:opacity-50';
const MONO_CLASS = 'font-mono text-[13px]';
export const MONO_CLASS = 'font-mono text-[13px]';
/** Read a string field off a free-form config object, defaulting to `''`. */
export function configString(config: Record<string, unknown>, key: string): string {
@@ -166,6 +167,153 @@ export function SelectField({ label, hint, value, onChange, options, testId }: S
);
}
/**
* Canonical model route hints (mirrors `AgentEditorPage`'s list, which in turn
* mirrors the Rust `ModelSpec::Hint(...)` slugs). Selecting one routes the
* agent node by capability tier; the workspace resolves the concrete model.
*/
export const AGENT_MODEL_HINTS = [
'hint:reasoning',
'hint:chat',
'hint:agentic',
'hint:burst',
'hint:coding',
'hint:summarization',
'hint:vision',
] as const;
/** Sentinel select value for "type a raw model id" — never persisted. */
const CUSTOM_MODEL = '__custom__';
export interface ModelHintFieldProps {
label: string;
hint?: string;
value: string;
onChange: (value: string) => void;
testId?: string;
}
/**
* Model selector for the `agent` node: a dropdown of the workspace's model
* route hints (`hint:chat`, `hint:coding`, …) with an "inherit" default and a
* custom escape hatch for a raw BYOK model id. Writes `hint:<tier>` (or the raw
* id, or `''` to inherit) onto `config.model`. Mirrors the agent-editor model
* picker so hints stay consistent across the app.
*/
export function ModelHintField({ label, hint, value, onChange, testId }: ModelHintFieldProps) {
const { t } = useT();
const id = useId();
// A value that's neither empty nor a known hint is a raw custom model id, so
// the picker opens in custom mode showing it in the text box.
const isKnown = value === '' || (AGENT_MODEL_HINTS as readonly string[]).includes(value);
const [customMode, setCustomMode] = useState(value !== '' && !isKnown);
const handleSelect = useCallback(
(next: string) => {
if (next === CUSTOM_MODEL) {
setCustomMode(true);
// Entering custom from a hint/inherit starts with an empty raw id.
if (isKnown) onChange('');
return;
}
setCustomMode(false);
onChange(next);
},
[isKnown, onChange]
);
return (
<Field label={label} hint={hint} htmlFor={id}>
<div className="space-y-2">
<select
id={id}
className={INPUT_CLASS}
value={customMode ? CUSTOM_MODEL : value}
data-testid={testId}
onChange={e => handleSelect(e.target.value)}>
<option value="">{t('flows.nodeConfig.agent.modelInherit')}</option>
<optgroup label={t('flows.nodeConfig.agent.modelHints')}>
{AGENT_MODEL_HINTS.map(h => (
<option key={h} value={h}>
{h}
</option>
))}
</optgroup>
<option value={CUSTOM_MODEL}>{t('flows.nodeConfig.agent.modelCustom')}</option>
</select>
{customMode && (
<input
type="text"
className={`${INPUT_CLASS} ${MONO_CLASS}`}
value={value}
placeholder={t('flows.nodeConfig.agent.modelCustomPlaceholder')}
aria-label={t('flows.nodeConfig.agent.modelCustomPlaceholder')}
data-testid={testId ? `${testId}-custom` : undefined}
onChange={e => onChange(e.target.value)}
/>
)}
</div>
</Field>
);
}
/**
* Compact "insert an upstream value" dropdown (Feature: `nodes` scope picker).
* Always renders with the empty placeholder selected; picking an option calls
* `onInsert(expression)` and snaps back to the placeholder, so it acts as a
* one-shot menu button rather than a stateful select (same sentinel-select
* pattern as `composioFields.tsx`). Renders nothing when there are no options.
*/
export function UpstreamInsertSelect({
options,
onInsert,
testId,
className,
}: {
options: UpstreamExpressionOption[];
onInsert: (expression: string) => void;
testId?: string;
className?: string;
}) {
const { t } = useT();
if (options.length === 0) return null;
return (
<select
className={
className ??
'max-w-[45%] shrink-0 cursor-pointer border-l border-line-strong bg-surface-muted px-1.5 text-[11px] text-content-muted focus:outline-none'
}
value=""
title={t('flows.nodeConfig.upstream.insertLabel', 'Insert a value from a previous step')}
aria-label={t('flows.nodeConfig.upstream.insertLabel', 'Insert a value from a previous step')}
data-testid={testId}
onChange={e => {
if (!e.target.value) return;
log('UpstreamInsertSelect: insert %s', e.target.value);
onInsert(e.target.value);
}}>
<option value="" disabled>
{t('flows.nodeConfig.upstream.insert', 'Insert…')}
</option>
{options.map(opt => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
);
}
export interface ExpressionFieldProps extends TextFieldProps {
/**
* `=nodes.…` expressions from upstream nodes; when non-empty a compact
* insert dropdown renders beside the input (picking replaces the value).
*/
upstreamOptions?: UpstreamExpressionOption[];
/** Non-blocking warning (amber border + message), e.g. "Required — not wired". */
warning?: string;
}
/**
* A field whose value is commonly a tinyflows `=`-expression. Monospace input
* with a leading "Expression" badge + hint so authors recognize `=item.foo`
@@ -178,12 +326,18 @@ export function ExpressionField({
onChange,
placeholder,
testId,
}: TextFieldProps) {
upstreamOptions,
warning,
}: ExpressionFieldProps) {
const { t } = useT();
const id = useId();
const borderClass = warning
? 'border-amber-400 focus-within:border-amber-500 focus-within:ring-amber-500/20'
: 'border-line-strong focus-within:border-primary-500 focus-within:ring-primary-500/20';
return (
<Field label={label} hint={hint ?? t('flows.nodeConfig.expressionHint')} htmlFor={id}>
<div className="flex items-stretch overflow-hidden rounded-lg border border-line-strong bg-surface focus-within:border-primary-500 focus-within:ring-2 focus-within:ring-primary-500/20">
<div
className={`flex items-stretch overflow-hidden rounded-lg border bg-surface focus-within:ring-2 ${borderClass}`}>
<span
className="flex select-none items-center border-r border-line-strong bg-surface-muted px-2 font-mono text-[11px] font-semibold text-content-muted"
title={t('flows.nodeConfig.expressionBadge')}
@@ -199,7 +353,19 @@ export function ExpressionField({
data-testid={testId}
onChange={e => onChange(e.target.value)}
/>
{upstreamOptions && upstreamOptions.length > 0 && (
<UpstreamInsertSelect
options={upstreamOptions}
onInsert={onChange}
testId={testId ? `${testId}-upstream` : undefined}
/>
)}
</div>
{warning && (
<p className="text-[11px] font-medium text-amber-600 dark:text-amber-400" role="alert">
{warning}
</p>
)}
</Field>
);
}
@@ -210,6 +376,8 @@ export interface KeyMapFieldProps {
value: Record<string, string>;
onChange: (value: Record<string, string>) => void;
monoValues?: boolean;
/** When non-empty, each row's value gets an upstream-expression insert menu. */
upstreamOptions?: UpstreamExpressionOption[];
testId?: string;
}
@@ -224,6 +392,7 @@ export function KeyMapField({
value,
onChange,
monoValues,
upstreamOptions,
testId,
}: KeyMapFieldProps) {
const { t } = useT();
@@ -272,6 +441,17 @@ export function KeyMapField({
commit(next);
}}
/>
{upstreamOptions && upstreamOptions.length > 0 && (
<UpstreamInsertSelect
options={upstreamOptions}
onInsert={expr => {
const next = rows.slice();
next[i] = [k, expr];
commit(next);
}}
className="w-20 shrink-0 cursor-pointer rounded-md border border-line-strong bg-surface-muted px-1 py-1 text-[11px] text-content-muted focus:outline-none"
/>
)}
<button
type="button"
className="shrink-0 rounded-md px-1.5 py-1 text-content-faint hover:bg-surface-hover hover:text-coral-600"
@@ -15,9 +15,20 @@
* - `transform` → `set` (key → =-expression map)
* - `trigger` → `trigger_kind` + kind-specific (`schedule`, `toolkit`/`trigger_slug`)
*/
import createDebug from 'debug';
import { useEffect, useState } from 'react';
import type { NodeKind } from '../../../../lib/flows/types';
import { useT } from '../../../../lib/i18n/I18nContext';
import type { FlowConnection } from '../../../../services/api/flowsApi';
import {
ComposioActionField,
type ComposioActionSchema,
ComposioToolkitField,
ComposioTriggerField,
fetchActionSchema,
} from './composioFields';
import { NATIVE_TOOL_PREFIX, NativeToolField } from './nativeToolFields';
import {
configString,
configStringMap,
@@ -25,16 +36,34 @@ import {
ExpressionField,
JsonField,
KeyMapField,
ModelHintField,
SelectField,
TextAreaField,
TextField,
UpstreamInsertSelect,
} from './nodeConfigFields';
import { ScheduleField } from './ScheduleField';
import type { UpstreamExpressionOption } from './upstreamOptions';
const log = createDebug('app:flows:nodeConfig:forms');
/** Derive the toolkit slug from a `composio:<toolkit>:<conn>` connection ref. */
function toolkitFromConnectionRef(ref: string): string {
const parts = ref.split(':');
return parts[0] === 'composio' && parts.length >= 2 ? parts[1] : '';
}
export interface NodeConfigFormProps {
config: Record<string, unknown>;
/** Shallow-merge patch into the node's config (undefined values are still set). */
onChange: (patch: Record<string, unknown>) => void;
connections: FlowConnection[];
/**
* `=nodes.…` expressions referencing this node's upstream ancestors, for the
* insert pickers on expression-bearing fields. Optional — absent (or empty)
* simply hides the pickers.
*/
upstreamOptions?: UpstreamExpressionOption[];
}
export type NodeConfigForm = (props: NodeConfigFormProps) => React.ReactElement;
@@ -43,9 +72,10 @@ export type NodeConfigForm = (props: NodeConfigFormProps) => React.ReactElement;
const TRIGGER_KINDS = ['manual', 'schedule', 'webhook', 'app_event'] as const;
function TriggerForm({ config, onChange }: NodeConfigFormProps) {
function TriggerForm({ config, onChange, connections }: NodeConfigFormProps) {
const { t } = useT();
const kind = configString(config, 'trigger_kind') || 'manual';
const toolkit = configString(config, 'toolkit');
return (
<div className="space-y-3">
<SelectField
@@ -59,28 +89,28 @@ function TriggerForm({ config, onChange }: NodeConfigFormProps) {
}))}
/>
{kind === 'schedule' && (
<TextField
label={t('flows.nodeConfig.trigger.scheduleLabel')}
hint={t('flows.nodeConfig.trigger.scheduleHint')}
<ScheduleField
value={configString(config, 'schedule')}
onChange={v => onChange({ schedule: v })}
placeholder="0 9 * * 1"
testId="node-config-trigger-schedule"
/>
)}
{kind === 'app_event' && (
<>
<TextField
<ComposioToolkitField
label={t('flows.nodeConfig.trigger.toolkitLabel')}
value={configString(config, 'toolkit')}
onChange={v => onChange({ toolkit: v })}
placeholder="github"
value={toolkit}
// Changing the app clears a now-mismatched trigger slug.
onChange={v => onChange({ toolkit: v, trigger_slug: '' })}
connections={connections}
testId="node-config-trigger-toolkit"
/>
<TextField
<ComposioTriggerField
label={t('flows.nodeConfig.trigger.triggerSlugLabel')}
value={configString(config, 'trigger_slug')}
onChange={v => onChange({ trigger_slug: v })}
placeholder="GITHUB_STAR_ADDED"
toolkit={toolkit}
testId="node-config-trigger-slug"
/>
</>
)}
@@ -97,7 +127,7 @@ function TriggerForm({ config, onChange }: NodeConfigFormProps) {
const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
function HttpRequestForm({ config, onChange, connections }: NodeConfigFormProps) {
function HttpRequestForm({ config, onChange, connections, upstreamOptions }: NodeConfigFormProps) {
const { t } = useT();
return (
<div className="space-y-3">
@@ -113,6 +143,7 @@ function HttpRequestForm({ config, onChange, connections }: NodeConfigFormProps)
value={configString(config, 'url')}
onChange={v => onChange({ url: v })}
placeholder="https://api.example.com/v1/resource"
upstreamOptions={upstreamOptions}
testId="node-config-http-url"
/>
<CredentialPickerField
@@ -141,23 +172,36 @@ function HttpRequestForm({ config, onChange, connections }: NodeConfigFormProps)
// ── agent ────────────────────────────────────────────────────────────────────
function AgentForm({ config, onChange, connections }: NodeConfigFormProps) {
function AgentForm({ config, onChange, connections, upstreamOptions }: NodeConfigFormProps) {
const { t } = useT();
const prompt = configString(config, 'prompt');
return (
<div className="space-y-3">
<TextAreaField
label={t('flows.nodeConfig.agent.promptLabel')}
value={configString(config, 'prompt')}
value={prompt}
onChange={v => onChange({ prompt: v })}
placeholder={t('flows.nodeConfig.agent.promptPlaceholder')}
rows={5}
testId="node-config-agent-prompt"
/>
<TextField
{upstreamOptions && upstreamOptions.length > 0 && (
// Appends the picked `=nodes.…` expression to the prompt (a prompt is
// prose, so inserting must not clobber what's already written).
<div className="-mt-2 flex justify-end">
<UpstreamInsertSelect
options={upstreamOptions}
onInsert={expr => onChange({ prompt: prompt ? `${prompt} ${expr}` : expr })}
testId="node-config-agent-prompt-upstream"
className="cursor-pointer rounded-md border border-line-strong bg-surface-muted px-1.5 py-1 text-[11px] text-content-muted focus:outline-none"
/>
</div>
)}
<ModelHintField
label={t('flows.nodeConfig.agent.modelLabel')}
hint={t('flows.nodeConfig.agent.modelHint')}
value={configString(config, 'model')}
onChange={v => onChange({ model: v })}
placeholder="gpt-4o-mini"
testId="node-config-agent-model"
/>
<CredentialPickerField
@@ -172,25 +216,148 @@ function AgentForm({ config, onChange, connections }: NodeConfigFormProps) {
// ── tool_call ─────────────────────────────────────────────────────────────────
function ToolCallForm({ config, onChange, connections }: NodeConfigFormProps) {
/** Read `config.args` as a plain object (Composio args are a JSON object). */
function configArgs(config: Record<string, unknown>): Record<string, unknown> {
const args = config.args;
return args && typeof args === 'object' && !Array.isArray(args)
? (args as Record<string, unknown>)
: {};
}
function ToolCallForm({ config, onChange, connections, upstreamOptions }: NodeConfigFormProps) {
const { t } = useT();
const slug = configString(config, 'slug');
// Two flavours of tool_call: a native OpenHuman "Tool" (provider=openhuman /
// slug `oh:...`) vs a Composio "App action". The palette seeds `provider`.
const isNative =
configString(config, 'provider') === 'openhuman' || slug.startsWith(NATIVE_TOOL_PREFIX);
// The connected account is chosen first; its toolkit scopes the action list.
const connectionRef = configString(config, 'connection_ref');
const toolkit = toolkitFromConnectionRef(connectionRef);
// Required-arg preflight rows (Composio only): once an action is selected,
// fetch its JSON-schema `parameters` so each required arg gets its own
// labeled ExpressionField row. `null` (fetch failed / unknown action /
// native tool) degrades gracefully to just the raw JsonField below. The
// fetched schema is tagged with its toolkit/slug key so switching action
// discards a stale schema without a synchronous setState in the effect.
const schemaKey = `${toolkit}${slug}`;
const [fetchedSchema, setFetchedSchema] = useState<{
key: string;
schema: ComposioActionSchema | null;
} | null>(null);
const actionSchema = fetchedSchema?.key === schemaKey ? fetchedSchema.schema : null;
// Remount key for the raw args JsonField: it holds a local text buffer that
// is seeded once, so row edits bump this to re-seed it with the merged args.
const [argsSeed, setArgsSeed] = useState(0);
useEffect(() => {
if (isNative || !toolkit || !slug) return;
const key = `${toolkit}${slug}`;
let cancelled = false;
void (async () => {
try {
const schema = await fetchActionSchema(toolkit, slug);
if (!cancelled) {
log(
'ToolCallForm: schema %s/%s → required=%d optional=%d',
toolkit,
slug,
schema?.required.length ?? -1,
schema?.optional.length ?? -1
);
setFetchedSchema({ key, schema });
}
} catch {
// Catalog fetch failed — keep `null` and fall back to the raw editor.
log('ToolCallForm: schema fetch failed for %s/%s — raw args only', toolkit, slug);
}
})();
return () => {
cancelled = true;
};
}, [isNative, toolkit, slug]);
if (isNative) {
return (
<div className="space-y-3">
<NativeToolField
label={t('flows.nodeConfig.native.toolLabel')}
hint={t('flows.nodeConfig.native.toolHint')}
value={slug}
onChange={v => onChange({ slug: v })}
testId="node-config-tool-slug"
/>
<JsonField
label={t('flows.nodeConfig.tool.argsLabel')}
value={config.args ?? null}
onChange={v => onChange({ args: v })}
testId="node-config-tool-args"
/>
</div>
);
}
const args = configArgs(config);
const requiredArgs = actionSchema?.required ?? [];
const setArg = (name: string, value: string) => {
const next = { ...args };
if (value === '') {
delete next[name];
} else {
next[name] = value;
}
log('ToolCallForm: setArg %s (empty=%s)', name, value === '');
onChange({ args: next });
// Re-seed the raw editor so it reflects the row edit.
setArgsSeed(s => s + 1);
};
return (
<div className="space-y-3">
<TextField
label={t('flows.nodeConfig.tool.slugLabel')}
value={configString(config, 'slug')}
onChange={v => onChange({ slug: v })}
placeholder="GITHUB_CREATE_ISSUE"
testId="node-config-tool-slug"
/>
<CredentialPickerField
value={configString(config, 'connection_ref')}
onChange={v => onChange({ connection_ref: v })}
value={connectionRef}
// Changing the account can change the toolkit → clear a stale action.
onChange={v => onChange({ connection_ref: v, slug: '' })}
connections={connections}
kinds={['composio']}
testId="node-config-tool-credential"
/>
<ComposioActionField
label={t('flows.nodeConfig.tool.slugLabel')}
value={slug}
onChange={v => onChange({ slug: v })}
toolkit={toolkit}
testId="node-config-tool-slug"
/>
{requiredArgs.map(name => {
const raw = args[name];
const value = typeof raw === 'string' ? raw : raw == null ? '' : JSON.stringify(raw);
const missing = value.trim() === '';
return (
<ExpressionField
key={name}
label={`${name} · ${t('flows.nodeConfig.tool.requiredMark', 'required')}`}
hint=""
value={value}
onChange={v => setArg(name, v)}
upstreamOptions={upstreamOptions}
warning={
missing
? t('flows.nodeConfig.tool.requiredMissing', 'Required — not wired')
: undefined
}
testId={`node-config-tool-arg-${name}`}
/>
);
})}
<JsonField
label={t('flows.nodeConfig.tool.argsLabel')}
key={argsSeed}
label={
requiredArgs.length > 0
? t('flows.nodeConfig.tool.argsAdvancedLabel', 'All args (advanced)')
: t('flows.nodeConfig.tool.argsLabel')
}
value={config.args ?? null}
onChange={v => onChange({ args: v })}
testId="node-config-tool-args"
@@ -219,7 +386,7 @@ function ConditionForm({ config, onChange }: NodeConfigFormProps) {
// ── switch ────────────────────────────────────────────────────────────────────
function SwitchForm({ config, onChange }: NodeConfigFormProps) {
function SwitchForm({ config, onChange, upstreamOptions }: NodeConfigFormProps) {
const { t } = useT();
return (
<div className="space-y-3">
@@ -229,6 +396,7 @@ function SwitchForm({ config, onChange }: NodeConfigFormProps) {
value={configString(config, 'expression')}
onChange={v => onChange({ expression: v })}
placeholder="item.type"
upstreamOptions={upstreamOptions}
testId="node-config-switch-expression"
/>
<TextField
@@ -244,7 +412,7 @@ function SwitchForm({ config, onChange }: NodeConfigFormProps) {
// ── transform ─────────────────────────────────────────────────────────────────
function TransformForm({ config, onChange }: NodeConfigFormProps) {
function TransformForm({ config, onChange, upstreamOptions }: NodeConfigFormProps) {
const { t } = useT();
return (
<div className="space-y-3">
@@ -254,6 +422,7 @@ function TransformForm({ config, onChange }: NodeConfigFormProps) {
value={configStringMap(config, 'set')}
onChange={v => onChange({ set: v })}
monoValues
upstreamOptions={upstreamOptions}
testId="node-config-transform-set"
/>
</div>
@@ -0,0 +1,108 @@
/**
* Upstream-output picker options for the node-config drawer (`nodes` scope).
*
* The tinyflows engine exposes every upstream node's output to expressions as
* `=nodes.<node_id>.item` (and `=nodes.<node_id>.item.<field>`). This module
* walks the graph's edges backward from the selected node to find its
* transitive ancestors and turns each into insertable expression options,
* labelled with the node's human name so authors pick "Extract recipient →
* email" instead of hand-typing node ids.
*
* Per-field options are only offered where the upstream node's output shape is
* statically knowable from its config:
* - `agent` with `config.output_parser.schema.properties` → one per property;
* - `transform` with `config.set` → one per set key;
* - everything else → just the node-level `item` option.
*/
import createDebug from 'debug';
import type { FlowEdge, FlowNode } from '../../../../lib/flows/graphAdapter';
import type { NodeKind } from '../../../../lib/flows/types';
const log = createDebug('app:flows:nodeConfig:upstreamOptions');
/** One insertable `=nodes.…` expression, labelled for the picker dropdown. */
export interface UpstreamExpressionOption {
/** The full expression to insert, e.g. `=nodes.extract.item.email`. */
value: string;
/** Human label, e.g. "Extract recipient → email". */
label: string;
}
/** Narrow an unknown to a plain (non-array) object, else `null`. */
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
/**
* Output field names statically knowable from an upstream node's config.
* Empty when the shape can't be derived — the caller then offers only the
* node-level `item` option.
*/
function knownOutputFields(kind: NodeKind, config: Record<string, unknown>): string[] {
if (kind === 'agent') {
const schema = asRecord(asRecord(config.output_parser)?.schema);
const properties = asRecord(schema?.properties);
return properties ? Object.keys(properties) : [];
}
if (kind === 'transform') {
const set = asRecord(config.set);
return set ? Object.keys(set) : [];
}
return [];
}
/**
* Build the `=nodes.…` picker options for the node identified by `nodeId`:
* a breadth-first backward walk over `edges` collects the node's transitive
* upstream ancestors (nearest first, cycle-safe), then each ancestor yields a
* node-level option plus one option per statically-known output field.
*/
export function upstreamExpressionOptions(
nodeId: string,
nodes: FlowNode[],
edges: FlowEdge[]
): UpstreamExpressionOption[] {
const sourcesByTarget = new Map<string, string[]>();
for (const edge of edges) {
const sources = sourcesByTarget.get(edge.target) ?? [];
sources.push(edge.source);
sourcesByTarget.set(edge.target, sources);
}
// BFS backward from the selected node — ancestors surface nearest-first.
const visited = new Set<string>([nodeId]);
const ancestors: string[] = [];
const queue: string[] = [nodeId];
while (queue.length > 0) {
const current = queue.shift();
if (current === undefined) break;
for (const source of sourcesByTarget.get(current) ?? []) {
if (visited.has(source)) continue;
visited.add(source);
ancestors.push(source);
queue.push(source);
}
}
const nodeById = new Map(nodes.map(node => [node.id, node]));
const options: UpstreamExpressionOption[] = [];
for (const id of ancestors) {
const node = nodeById.get(id);
if (!node) continue;
const name = node.data.name.trim() || id;
options.push({ value: `=nodes.${id}.item`, label: `${name} → item` });
for (const field of knownOutputFields(node.data.kind, node.data.config ?? {})) {
options.push({ value: `=nodes.${id}.item.${field}`, label: `${name}${field}` });
}
}
log(
'upstreamExpressionOptions: node=%s ancestors=%d options=%d',
nodeId,
ancestors.length,
options.length
);
return options;
}
@@ -0,0 +1,29 @@
/**
* Session-lived cache of each workflow's copilot chat thread id, keyed by flow
* (a persisted flow id, or `'draft'` for an unsaved draft). The copilot panel
* unmounts when closed and `FlowEditor` remounts when switching flows, so
* without this the `workflow_builder` thread — and its transcript — would be
* lost on every open/close. Persisting the thread id lets the panel reseed the
* same thread (its messages live in the Redux `messagesByThreadId` store), so
* reopening the copilot restores the conversation for that workflow.
*
* Module-level (session) scope is deliberate: it survives component remounts
* without coupling to Redux, and a fresh session legitimately starts a new
* authoring conversation.
*/
const copilotThreadByFlow = new Map<string, string>();
/** Cache key for a flow: its persisted id, or `'draft'` for an unsaved draft. */
export function copilotThreadKey(flowId: string | null): string {
return flowId ?? 'draft';
}
export function getCopilotThreadId(flowId: string | null): string | null {
return copilotThreadByFlow.get(copilotThreadKey(flowId)) ?? null;
}
export function setCopilotThreadId(flowId: string | null, threadId: string | null): void {
const key = copilotThreadKey(flowId);
if (threadId) copilotThreadByFlow.set(key, threadId);
else copilotThreadByFlow.delete(key);
}