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);
}
+6 -1
View File
@@ -15,12 +15,16 @@ const dispatch = vi.hoisted(() => vi.fn());
const selectorState = vi.hoisted(() => ({
activeThreadIds: {} as Record<string, true>,
proposals: {} as Record<string, WorkflowProposal>,
messagesByThreadId: {} as Record<string, unknown[]>,
}));
vi.mock('../store/hooks', () => ({
useAppDispatch: () => dispatch,
useAppSelector: (sel: (s: unknown) => unknown) =>
sel({
thread: { activeThreadIds: selectorState.activeThreadIds },
thread: {
activeThreadIds: selectorState.activeThreadIds,
messagesByThreadId: selectorState.messagesByThreadId,
},
chatRuntime: { pendingWorkflowProposalsByThread: selectorState.proposals },
}),
}));
@@ -45,6 +49,7 @@ describe('useWorkflowBuilderChat', () => {
chatSend.mockReset().mockResolvedValue(undefined);
selectorState.activeThreadIds = {};
selectorState.proposals = {};
selectorState.messagesByThreadId = {};
dispatch.mockReset().mockImplementation((action: { type: string }) => {
if (action.type === 'createNewThread') {
return { unwrap: () => Promise.resolve({ id: 'builder-1' }) };
+16 -1
View File
@@ -54,6 +54,13 @@ export interface UseWorkflowBuilderChat {
sending: boolean;
/** The latest proposal the agent returned on this thread, or `null`. */
proposal: WorkflowProposal | null;
/**
* The dedicated thread's transcript (user + agent turns) so a caller can
* render the full conversation, not just the latest proposal. Empty until the
* first send. Sourced from the same `messagesByThreadId` store the main chat
* transcript reads.
*/
messages: ThreadMessage[];
/** Last send error (thread create / RPC failure), or `null`. */
error: string | null;
/** Send a builder turn, creating the dedicated thread on first use. */
@@ -62,6 +69,8 @@ export interface UseWorkflowBuilderChat {
clearProposal: () => void;
}
const EMPTY_MESSAGES: ThreadMessage[] = [];
/**
* @param seedThreadId Optional existing thread to bind to instead of creating a
* fresh one — lets a caller reuse a thread across mounts (unused today; the
@@ -78,12 +87,18 @@ export function useWorkflowBuilderChat(seedThreadId?: string | null): UseWorkflo
const proposalsByThread = useAppSelector(
state => state.chatRuntime.pendingWorkflowProposalsByThread
);
const messagesByThreadId = useAppSelector(state => state.thread.messagesByThreadId);
const proposal = useMemo(
() => (threadId ? (proposalsByThread[threadId] ?? null) : null),
[threadId, proposalsByThread]
);
const messages = useMemo(
() => (threadId ? (messagesByThreadId[threadId] ?? EMPTY_MESSAGES) : EMPTY_MESSAGES),
[threadId, messagesByThreadId]
);
// "Sending" = we're mid-dispatch OR the runtime still marks the thread active.
const runtimeActive = threadId ? Boolean(activeThreadIds[threadId]) : false;
const sending = localSending || runtimeActive;
@@ -157,5 +172,5 @@ export function useWorkflowBuilderChat(seedThreadId?: string | null): UseWorkflo
if (threadId) dispatch(clearWorkflowProposalForThread({ threadId }));
}, [dispatch, threadId]);
return { threadId, sending, proposal, error, send, clearProposal };
return { threadId, sending, proposal, messages, error, send, clearProposal };
}
+92
View File
@@ -0,0 +1,92 @@
/**
* Unit tests for the cron builder helper. Covers build → parse round-trips for
* the three supported shapes (minutes / hours / daily, with and without
* weekday restrictions), plaintext descriptions, and the graceful fallbacks for
* cron strings the visual builder doesn't model.
*/
import { describe, expect, it } from 'vitest';
import { buildCron, type CronSpec, DEFAULT_CRON_SPEC, describeCron, parseCron } from './cron';
function spec(overrides: Partial<CronSpec>): CronSpec {
return { ...DEFAULT_CRON_SPEC, ...overrides };
}
describe('buildCron', () => {
it('compiles every-N-minutes', () => {
expect(buildCron(spec({ freq: 'minutes', interval: 5 }))).toBe('*/5 * * * *');
});
it('compiles every-N-minutes restricted to weekdays', () => {
expect(buildCron(spec({ freq: 'minutes', interval: 5, weekdays: [3] }))).toBe('*/5 * * * 3');
});
it('compiles every-N-hours at a minute', () => {
expect(buildCron(spec({ freq: 'hours', interval: 2, minute: 30 }))).toBe('30 */2 * * *');
});
it('compiles daily at a time', () => {
expect(buildCron(spec({ freq: 'daily', hour: 9, minute: 30 }))).toBe('30 9 * * *');
});
it('compiles a weekly time on selected days (deduped + sorted)', () => {
expect(buildCron(spec({ freq: 'daily', hour: 14, minute: 0, weekdays: [5, 1, 3, 1] }))).toBe(
'0 14 * * 1,3,5'
);
});
it('clamps out-of-range values', () => {
expect(buildCron(spec({ freq: 'minutes', interval: 999 }))).toBe('*/59 * * * *');
expect(buildCron(spec({ freq: 'daily', hour: 30, minute: -5 }))).toBe('0 23 * * *');
});
});
describe('parseCron', () => {
it('round-trips each supported shape', () => {
for (const expr of [
'*/5 * * * *',
'*/5 * * * 3',
'30 */2 * * *',
'30 9 * * *',
'0 14 * * 1,3,5',
]) {
const parsed = parseCron(expr);
expect(parsed).not.toBeNull();
expect(buildCron(parsed!)).toBe(expr);
}
});
it('maps cron Sunday (7) to 0', () => {
expect(parseCron('0 9 * * 7')?.weekdays).toEqual([0]);
});
it('returns null for shapes the builder does not model', () => {
expect(parseCron('0 9 1 * *')).toBeNull(); // day-of-month set
expect(parseCron('0 9 * 6 *')).toBeNull(); // month set
expect(parseCron('0 9 * * MON')).toBeNull(); // named weekday
expect(parseCron('not a cron')).toBeNull();
expect(parseCron('0 9 * *')).toBeNull(); // wrong field count
});
});
describe('describeCron', () => {
it('describes the common shapes in plain language', () => {
expect(describeCron('*/5 * * * *')).toBe('Every 5 minutes');
expect(describeCron('*/1 * * * *')).toBe('Every minute');
expect(describeCron('*/5 * * * 3')).toBe('Every 5 minutes on Wed');
expect(describeCron('0 */2 * * *')).toBe('Every 2 hours');
expect(describeCron('30 9 * * *')).toBe('Every day at 09:30');
expect(describeCron('0 14 * * 1,3,5')).toBe('At 14:00 on Mon, Wed, Fri');
});
it('collapses full weekday sets to friendly phrases', () => {
expect(describeCron('0 9 * * 1,2,3,4,5')).toBe('At 09:00 on weekdays');
expect(describeCron('0 9 * * 0,6')).toBe('At 09:00 on weekends');
expect(describeCron('0 9 * * 0,1,2,3,4,5,6')).toBe('Every day at 09:00');
});
it('falls back for custom / empty expressions', () => {
expect(describeCron('0 9 1 * *')).toBe('Custom schedule (0 9 1 * *)');
expect(describeCron('')).toBe('No schedule set');
});
});
+174
View File
@@ -0,0 +1,174 @@
/**
* Small cron helper for the schedule-trigger builder (`ScheduleField`). The
* flows engine stores `trigger.config.schedule` as a bare 5-field cron string
* (`minute hour day-of-month month day-of-week`) — `crate::openhuman::cron::
* Schedule` deserializes a bare string as `Cron { expr }` — so the visual
* builder compiles to and parses from that same string, staying compatible with
* existing saved flows and the workflow-builder agent.
*
* Scope: the builder covers the three common shapes (every N minutes, every N
* hours, daily/weekly at a time), each optionally restricted to selected
* weekdays. Any other cron string round-trips untouched through the advanced
* text field; {@link parseCron} returns `null` for it (→ advanced mode) and
* {@link describeCron} falls back to a generic label.
*/
/** How often the schedule fires. */
export type CronFreq = 'minutes' | 'hours' | 'daily';
/** Structured schedule the visual builder edits; compiles to a cron string. */
export interface CronSpec {
freq: CronFreq;
/** Interval for `minutes` (159) / `hours` (123). Ignored for `daily`. */
interval: number;
/** Hour of day 023 (`daily`). */
hour: number;
/** Minute of hour 059 (`daily` + `hours`' "at minute"). */
minute: number;
/** Selected weekdays, 0=Sun … 6=Sat. Empty = every day. */
weekdays: number[];
}
/** Short weekday names indexed 0=Sun … 6=Sat. */
export const WEEKDAY_SHORT = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] as const;
/** Single-letter weekday initials for compact toggles (Sun-first). */
export const WEEKDAY_INITIAL = ['S', 'M', 'T', 'W', 'T', 'F', 'S'] as const;
export const DEFAULT_CRON_SPEC: CronSpec = {
freq: 'daily',
interval: 1,
hour: 9,
minute: 0,
weekdays: [],
};
function clamp(n: number, lo: number, hi: number): number {
if (!Number.isFinite(n)) return lo;
return Math.min(hi, Math.max(lo, Math.floor(n)));
}
/** Normalize a weekday list: dedupe, map cron's `7`→`0` (Sun), keep 06, sort. */
function normalizeWeekdays(days: number[]): number[] {
return [...new Set(days.map(d => (d === 7 ? 0 : d)))]
.filter(d => d >= 0 && d <= 6)
.sort((a, b) => a - b);
}
/** Compile a {@link CronSpec} to a 5-field cron expression. */
export function buildCron(spec: CronSpec): string {
const days = normalizeWeekdays(spec.weekdays);
const dow = days.length > 0 ? days.join(',') : '*';
if (spec.freq === 'minutes') {
return `*/${clamp(spec.interval, 1, 59)} * * * ${dow}`;
}
if (spec.freq === 'hours') {
return `${clamp(spec.minute, 0, 59)} */${clamp(spec.interval, 1, 23)} * * ${dow}`;
}
return `${clamp(spec.minute, 0, 59)} ${clamp(spec.hour, 0, 23)} * * ${dow}`;
}
/** Parse a step field ("star-slash-N"); returns `null` if it isn't one. */
function parseStep(field: string): { step: number } | null {
const m = /^\*\/(\d+)$/.exec(field);
return m ? { step: Number(m[1]) } : null;
}
function parseWeekdayField(field: string): number[] | null {
if (field === '*') return [];
const parts = field.split(',').map(p => p.trim());
const nums: number[] = [];
for (const p of parts) {
if (!/^\d+$/.test(p)) return null; // named days (MON) etc. → advanced
nums.push(Number(p));
}
const norm = normalizeWeekdays(nums);
return norm.length > 0 ? norm : null;
}
/**
* Parse a cron string back into a {@link CronSpec}, or `null` when it's outside
* the builder's covered shapes (→ the caller falls back to the advanced text
* field). Only recognizes the exact forms {@link buildCron} emits: a `*`
* day-of-month and month, with a stepped minute/hour and a numeric weekday list.
*/
export function parseCron(expr: string): CronSpec | null {
const fields = expr.trim().split(/\s+/);
if (fields.length !== 5) return null;
const [min, hour, dom, mon, dowField] = fields;
if (dom !== '*' || mon !== '*') return null;
const weekdays = parseWeekdayField(dowField);
if (weekdays === null) return null;
// Every N minutes: `*/N * * * dow`
const minStep = parseStep(min);
if (minStep && hour === '*') {
return { ...DEFAULT_CRON_SPEC, freq: 'minutes', interval: minStep.step, weekdays };
}
// Every N hours: `M */N * * dow`
const hourStep = parseStep(hour);
if (hourStep && /^\d+$/.test(min)) {
return {
...DEFAULT_CRON_SPEC,
freq: 'hours',
interval: hourStep.step,
minute: Number(min),
weekdays,
};
}
// Daily / weekly at a time: `M H * * dow`
if (/^\d+$/.test(min) && /^\d+$/.test(hour)) {
return {
...DEFAULT_CRON_SPEC,
freq: 'daily',
hour: Number(hour),
minute: Number(min),
weekdays,
};
}
return null;
}
/** Zero-padded `HH:MM`. */
export function formatTime(hour: number, minute: number): string {
return `${String(clamp(hour, 0, 23)).padStart(2, '0')}:${String(clamp(minute, 0, 59)).padStart(2, '0')}`;
}
/** Human phrase for a weekday set: "every day" / "weekdays" / "weekends" / "Mon, Wed". */
function describeWeekdays(days: number[]): string {
const norm = normalizeWeekdays(days);
if (norm.length === 0 || norm.length === 7) return 'every day';
if (norm.join(',') === '1,2,3,4,5') return 'weekdays';
if (norm.join(',') === '0,6') return 'weekends';
return norm.map(d => WEEKDAY_SHORT[d]).join(', ');
}
/**
* A plain-language summary of a cron string ("Every 5 minutes on Wednesday",
* "Every day at 09:00"). Falls back to a generic label for expressions the
* builder doesn't model, so an advanced user's custom cron still gets a
* (non-misleading) description.
*/
export function describeCron(expr: string): string {
const spec = parseCron(expr);
if (!spec) {
return expr.trim() ? `Custom schedule (${expr.trim()})` : 'No schedule set';
}
const daysPhrase = describeWeekdays(spec.weekdays);
const onDays = daysPhrase === 'every day' ? '' : ` on ${daysPhrase}`;
if (spec.freq === 'minutes') {
const unit = spec.interval === 1 ? 'minute' : `${spec.interval} minutes`;
return `Every ${unit}${onDays}`;
}
if (spec.freq === 'hours') {
const unit = spec.interval === 1 ? 'hour' : `${spec.interval} hours`;
return `Every ${unit}${onDays}`;
}
// daily / weekly
const time = formatTime(spec.hour, spec.minute);
return daysPhrase === 'every day' ? `Every day at ${time}` : `At ${time} on ${daysPhrase}`;
}
+99 -22
View File
@@ -15,9 +15,17 @@ import type { NodeKind } from './types';
export type NodeColor = 'sage' | 'primary' | 'amber' | 'coral' | 'neutral';
/**
* Palette grouping for the node kinds: `triggers` (what starts a run),
* `actions` (do work / call out), `logic` (route, branch, reshape data). Used
* by {@link NodePalette} to render labelled sections instead of a flat list.
*/
export type NodeGroup = 'triggers' | 'actions' | 'logic';
export interface NodeKindMeta {
emoji: string;
color: NodeColor;
group: NodeGroup;
}
/**
@@ -40,20 +48,88 @@ export const NODE_KINDS: NodeKind[] = [
'sub_workflow',
];
/** Per-kind emoji + border/chip color. See the module doc for the color model. */
/** Per-kind emoji + border/chip color + palette group. See the module doc. */
export const NODE_KIND_META: Record<NodeKind, NodeKindMeta> = {
trigger: { emoji: '⚡', color: 'sage' },
agent: { emoji: '🤖', color: 'primary' },
tool_call: { emoji: '🔧', color: 'amber' },
http_request: { emoji: '🌐', color: 'coral' },
code: { emoji: '📝', color: 'sage' },
condition: { emoji: '🔀', color: 'primary' },
switch: { emoji: '🔁', color: 'amber' },
merge: { emoji: '🔗', color: 'coral' },
split_out: { emoji: '📤', color: 'sage' },
transform: { emoji: '♻️', color: 'primary' },
output_parser: { emoji: '📋', color: 'amber' },
sub_workflow: { emoji: '🧩', color: 'coral' },
trigger: { emoji: '⚡', color: 'sage', group: 'triggers' },
agent: { emoji: '🤖', color: 'primary', group: 'actions' },
tool_call: { emoji: '🔧', color: 'amber', group: 'actions' },
http_request: { emoji: '🌐', color: 'coral', group: 'actions' },
code: { emoji: '📝', color: 'sage', group: 'actions' },
sub_workflow: { emoji: '🧩', color: 'coral', group: 'actions' },
condition: { emoji: '🔀', color: 'primary', group: 'logic' },
switch: { emoji: '🔁', color: 'amber', group: 'logic' },
merge: { emoji: '🔗', color: 'coral', group: 'logic' },
split_out: { emoji: '📤', color: 'sage', group: 'logic' },
transform: { emoji: '♻️', color: 'primary', group: 'logic' },
output_parser: { emoji: '📋', color: 'amber', group: 'logic' },
};
/** Palette group render order + the kinds in each, derived from NODE_KIND_META. */
export const NODE_GROUP_ORDER: NodeGroup[] = ['triggers', 'actions', 'logic'];
export const NODE_KINDS_BY_GROUP: Record<NodeGroup, NodeKind[]> = {
triggers: NODE_KINDS.filter(k => NODE_KIND_META[k].group === 'triggers'),
actions: NODE_KINDS.filter(k => NODE_KIND_META[k].group === 'actions'),
logic: NODE_KINDS.filter(k => NODE_KIND_META[k].group === 'logic'),
};
/**
* One palette entry. Usually 1:1 with a `NodeKind`, but `tool_call` splits into
* TWO entries — an "App action" (Composio OAuth) node and a "Tool" (native
* OpenHuman) node — distinguished by the `preset` config (`provider`) merged
* onto the new node. `key` is the palette/testid id; `labelKey` its i18n label.
*/
export interface PaletteEntry {
key: string;
kind: NodeKind;
group: NodeGroup;
emoji: string;
color: NodeColor;
labelKey: string;
/** Default config merged onto a node created from this entry. */
preset?: Record<string, unknown>;
}
export const PALETTE_ENTRIES: PaletteEntry[] = NODE_KINDS.flatMap((kind): PaletteEntry[] => {
const meta = NODE_KIND_META[kind];
if (kind === 'tool_call') {
return [
{
key: 'tool_call',
kind: 'tool_call',
group: 'actions',
emoji: '🔌',
color: 'amber',
labelKey: 'flows.palette.appAction',
preset: { provider: 'composio' },
},
{
key: 'oh_tool',
kind: 'tool_call',
group: 'actions',
emoji: '🛠️',
color: 'primary',
labelKey: 'flows.palette.ohTool',
preset: { provider: 'openhuman' },
},
];
}
return [
{
key: kind,
kind,
group: meta.group,
emoji: meta.emoji,
color: meta.color,
labelKey: `flows.nodeKind.${kind}`,
},
];
});
export const PALETTE_ENTRIES_BY_GROUP: Record<NodeGroup, PaletteEntry[]> = {
triggers: PALETTE_ENTRIES.filter(e => e.group === 'triggers'),
actions: PALETTE_ENTRIES.filter(e => e.group === 'actions'),
logic: PALETTE_ENTRIES.filter(e => e.group === 'logic'),
};
/**
@@ -64,35 +140,36 @@ export const NODE_KIND_META: Record<NodeKind, NodeKindMeta> = {
* here so an unrecognized kind renders as a plain neutral node instead of
* crashing the whole canvas (there's no error boundary around `<ReactFlow>`).
*/
export const DEFAULT_NODE_META: NodeKindMeta = { emoji: '❔', color: 'neutral' };
export const DEFAULT_NODE_META: NodeKindMeta = { emoji: '❔', color: 'neutral', group: 'actions' };
/** Resolve a kind's metadata, falling back to {@link DEFAULT_NODE_META}. */
export function nodeKindMeta(kind: NodeKind): NodeKindMeta {
return NODE_KIND_META[kind] ?? DEFAULT_NODE_META;
}
export const COLOR_CLASSES: Record<NodeColor, { border: string; chip: string }> = {
// `tint` is a faint wash of the accent for the node card body, so the whole
// node reads as gently colour-coded rather than a stark white box under the
// stronger header `chip`.
export const COLOR_CLASSES: Record<NodeColor, { border: string; chip: string; tint: string }> = {
sage: {
border: 'border-sage-400 dark:border-sage-500/60',
chip: 'bg-sage-100 dark:bg-sage-500/20',
tint: 'bg-sage-50/60 dark:bg-sage-500/[0.07]',
},
primary: {
border: 'border-primary-400 dark:border-primary-500/60',
chip: 'bg-primary-100 dark:bg-primary-500/20',
tint: 'bg-primary-50/60 dark:bg-primary-500/[0.07]',
},
amber: {
border: 'border-amber-400 dark:border-amber-500/60',
chip: 'bg-amber-100 dark:bg-amber-500/20',
tint: 'bg-amber-50/60 dark:bg-amber-500/[0.07]',
},
coral: {
border: 'border-coral-400 dark:border-coral-500/60',
chip: 'bg-coral-100 dark:bg-coral-500/20',
tint: 'bg-coral-50/60 dark:bg-coral-500/[0.07]',
},
neutral: { border: 'border-line-strong', chip: 'bg-surface-subtle' },
neutral: { border: 'border-line-strong', chip: 'bg-surface-subtle', tint: 'bg-surface' },
};
/** Even vertical offsets (in %) for `count` handles along one side of a card. */
export function handleOffsets(count: number): number[] {
if (count <= 1) return [50];
return Array.from({ length: count }, (_, i) => ((i + 1) / (count + 1)) * 100);
}
+49
View File
@@ -0,0 +1,49 @@
/**
* Unit tests for describeNode — the dynamic per-node card summary. Asserts the
* config-driven text for representative kinds plus the generic fallbacks when
* config isn't filled in.
*/
import { describe, expect, it } from 'vitest';
import { describeNode } from './nodeSummary';
describe('describeNode', () => {
it('describes a schedule trigger via its cron', () => {
expect(describeNode('trigger', { trigger_kind: 'schedule', schedule: '*/5 * * * 3' })).toBe(
'Every 5 minutes on Wed'
);
expect(describeNode('trigger', { trigger_kind: 'manual' })).toBe('Runs on demand');
});
it('describes an http_request from method + url', () => {
expect(describeNode('http_request', { method: 'POST', url: 'https://api.x.com/v1' })).toBe(
'POST https://api.x.com/v1'
);
expect(describeNode('http_request', {})).toBe('GET request (set a URL)');
});
it('describes an agent by model hint', () => {
expect(describeNode('agent', { model: 'hint:coding' })).toBe('Asks the coding');
expect(describeNode('agent', { prompt: 'Summarize the thread', model: '' })).toContain(
'Summarize the thread'
);
});
it('describes branch nodes and reflects output routes', () => {
expect(describeNode('condition', { field: 'status' })).toBe('If status → true / false');
expect(describeNode('switch', { expression: 'item.type' }, ['a', 'b', 'default'])).toBe(
'Routes by item.type (3 routes)'
);
});
it('falls back for tool_call / transform with empty config', () => {
expect(describeNode('tool_call', {})).toBe('Runs an app action (pick one)');
expect(describeNode('transform', { set: { a: '=1', b: '=2' } })).toBe(
'Sets 2 fields on each item'
);
});
it('returns empty string for an unknown kind', () => {
expect(describeNode('time_travel', {})).toBe('');
});
});
+95
View File
@@ -0,0 +1,95 @@
/**
* describeNode — a short, dynamic plain-language line for a workflow node card
* ("GET https://…", "Every 5 minutes", "If status → true / false"), derived
* from the node's live config so the card explains what it will do at a glance
* without opening the config drawer. Falls back to a generic per-kind label
* when the config isn't filled in yet.
*
* Pure + dependency-light (only {@link describeCron}) so it's trivially
* testable and can be called on every render of `FlowNodeComponent`.
*/
import { describeCron } from './cron';
import type { NodeKind } from './types';
function str(config: Record<string, unknown>, key: string): string {
const v = config[key];
return typeof v === 'string' ? v.trim() : '';
}
function truncate(value: string, max = 52): string {
return value.length > max ? `${value.slice(0, max - 1)}` : value;
}
/**
* @param kind the node kind (may be an unknown string for a future kind)
* @param config the node's free-form config object
* @param outputPorts effective output ports (used to hint branch routing)
*/
export function describeNode(
kind: NodeKind | string,
config: Record<string, unknown>,
outputPorts: string[] = []
): string {
switch (kind) {
case 'trigger': {
const tk = str(config, 'trigger_kind') || 'manual';
if (tk === 'manual') return 'Runs on demand';
if (tk === 'schedule') return describeCron(str(config, 'schedule'));
if (tk === 'webhook') return 'Runs on an incoming webhook';
if (tk === 'app_event') {
const parts = [str(config, 'toolkit'), str(config, 'trigger_slug')].filter(Boolean);
return parts.length ? `On ${parts.join(' · ')}` : 'Runs on an app event';
}
return `Trigger: ${tk}`;
}
case 'agent': {
const prompt = str(config, 'prompt');
const model = str(config, 'model');
const modelLabel = model ? model.replace(/^hint:/, '') : 'default model';
return prompt ? `${truncate(prompt, 40)}” · ${modelLabel}` : `Asks the ${modelLabel}`;
}
case 'tool_call': {
const slug = str(config, 'slug');
if (str(config, 'provider') === 'openhuman' || slug.startsWith('oh:')) {
const name = slug.replace(/^oh:/, '');
return name ? `Runs ${name}` : 'Runs an OpenHuman tool (pick one)';
}
return slug ? `Runs ${slug}` : 'Runs an app action (pick one)';
}
case 'http_request': {
const method = str(config, 'method') || 'GET';
const url = str(config, 'url');
return url ? `${method} ${truncate(url, 40)}` : `${method} request (set a URL)`;
}
case 'code': {
const lang = str(config, 'language') || 'javascript';
return `Runs ${lang} code`;
}
case 'condition': {
const field = str(config, 'field');
return field ? `If ${field} → true / false` : 'Branches to true / false';
}
case 'switch': {
const expr = str(config, 'expression') || str(config, 'field');
const routes = outputPorts.length > 0 ? ` (${outputPorts.length} routes)` : '';
return expr ? `Routes by ${expr}${routes}` : `Routes by a value${routes}`;
}
case 'merge':
return 'Merges parallel branches';
case 'split_out': {
const path = str(config, 'path');
return path ? `Splits each ${path}` : 'Splits a list into items';
}
case 'transform': {
const set = config.set;
const n = set && typeof set === 'object' && !Array.isArray(set) ? Object.keys(set).length : 0;
return n > 0 ? `Sets ${n} field${n > 1 ? 's' : ''} on each item` : 'Reshapes each item';
}
case 'output_parser':
return 'Parses the previous output';
case 'sub_workflow':
return 'Runs a nested workflow';
default:
return '';
}
}
+24 -7
View File
@@ -23,6 +23,14 @@ import type { WorkflowGraph } from './types';
const DELEGATE_DIRECTIVE =
'Use the workflow builder to design a tinyflows automation and return a workflow proposal for me to review. Do not save, enable, or run anything.';
/**
* Revise variant: still "propose, never persist" for saving/enabling, but the
* copilot may run an ALREADY-SAVED flow to test it — only when I ask and after
* confirming with me first (the specialist's own prompt enforces the ask).
*/
const DELEGATE_DIRECTIVE_REVISE =
'Use the workflow builder to revise this tinyflows automation and return the revised proposal. Do not save or enable anything (I save via the UI). You may run_workflow the SAVED flow to test it, but ONLY if I ask and only after you confirm with me first.';
/** Serialize a graph compactly for injection as agent context. */
function serializeGraph(graph: WorkflowGraph): string {
try {
@@ -47,19 +55,28 @@ export function buildCreatePrompt(description: string): string {
* than starting over. `instruction` is the user's change request ("add a Slack
* notification on failure", "make the schedule weekdays only").
*/
export function buildRevisePrompt(instruction: string, graph: WorkflowGraph): string {
export function buildRevisePrompt(
instruction: string,
graph: WorkflowGraph,
flowId?: string | null
): string {
const trimmed = instruction.trim();
return [
DELEGATE_DIRECTIVE,
const lines = [
DELEGATE_DIRECTIVE_REVISE,
'',
'Here is the current workflow draft (tinyflows WorkflowGraph JSON):',
'```json',
serializeGraph(graph),
'```',
'',
'Revise it as follows and return the full revised proposal:',
trimmed,
].join('\n');
];
if (flowId) {
lines.push(
'',
`This workflow is saved with flow id \`${flowId}\` — if I ask you to run/test it, you may run_workflow that id, but confirm with me first.`
);
}
lines.push('', 'Revise it as follows and return the full revised proposal:', trimmed);
return lines.join('\n');
}
/** Context for a repair turn opened from a failed run's inspector. */
+71
View File
@@ -3614,6 +3614,7 @@ const messages: TranslationMap = {
'flowRuns.status.completed': 'مكتمل',
'flowRuns.status.pending_approval': 'بانتظار الموافقة',
'flowRuns.status.failed': 'فشل',
'flowRuns.status.cancelled': 'ملغى',
'flows.page.title': 'سير العمل',
'flows.page.description': 'أتمتة محفوظة يمكنك تفعيلها وتشغيلها ومتابعتها.',
@@ -3730,6 +3731,55 @@ const messages: TranslationMap = {
'flows.editor.leaveStay': 'ابقَ',
'flows.editor.leaveDiscard': 'غادر',
// ── Node config drawer (issue B5b / Phase 3b) — per-kind config forms
'flowRuns.inspector.diagnosticsTitle': 'تحذيرات التعبير',
'flowRuns.inspector.diagnosticResolvedNull': 'تم حله إلى null',
'flows.runs.sidebarTitle': 'التشغيلات',
'flows.runs.refresh': 'تحديث التشغيلات',
'flows.palette.appAction': 'إجراء تطبيق',
'flows.palette.ohTool': 'أداة',
'flows.editor.deleteNode': 'حذف',
'flows.nodeConfig.connections.title': 'الاتصالات',
'flows.nodeConfig.connections.inputs': 'المدخلات',
'flows.nodeConfig.connections.outputs': 'المخرجات',
'flows.nodeConfig.connections.none': 'غير متصل بأي عقدة أخرى بعد.',
'flows.nodeConfig.connections.remove': 'إزالة الاتصال',
'flows.nodeConfig.trigger.scheduleCronLabel': 'تعبير cron',
'flows.nodeConfig.trigger.scheduleFreqLabel': 'التكرار',
'flows.nodeConfig.trigger.scheduleFreq_minutes': 'كل N دقيقة',
'flows.nodeConfig.trigger.scheduleFreq_hours': 'كل N ساعة',
'flows.nodeConfig.trigger.scheduleFreq_daily': 'في وقت محدد كل يوم',
'flows.nodeConfig.trigger.scheduleEvery': 'كل',
'flows.nodeConfig.trigger.scheduleInterval': 'الفاصل',
'flows.nodeConfig.trigger.scheduleUnit_minutes': 'دقيقة',
'flows.nodeConfig.trigger.scheduleUnit_hours': 'ساعة',
'flows.nodeConfig.trigger.scheduleAt': 'عند',
'flows.nodeConfig.trigger.scheduleTime': 'وقت اليوم',
'flows.nodeConfig.trigger.scheduleDays': 'في الأيام (اختياري — اتركه فارغًا لكل يوم)',
'flows.nodeConfig.trigger.scheduleAdvanced': 'متقدم (تحرير cron)',
'flows.nodeConfig.trigger.scheduleSimple': 'العودة إلى الجدولة البسيطة',
'flows.nodeConfig.trigger.pickApp': 'اختر تطبيقًا متصلًا أولًا.',
'flows.nodeConfig.tool.pickConnection': 'اختر اتصالًا أولًا.',
'flows.nodeConfig.composio.noConnections':
'لا توجد تطبيقات متصلة بعد. صِل تطبيقًا من الإعدادات → التطبيقات، ثم اختره هنا.',
'flows.nodeConfig.composio.selectApp': 'اختر تطبيقًا…',
'flows.nodeConfig.composio.select': 'اختر…',
'flows.nodeConfig.composio.loading': 'جارٍ التحميل…',
'flows.nodeConfig.composio.custom': 'إدخال يدوي…',
'flows.nodeConfig.composio.customPlaceholder': 'SLUG_NAME',
'flows.nodeConfig.agent.modelHint': 'اختر مستوى القدرة — سيحدد مساحة العمل النموذج.',
'flows.nodeConfig.agent.modelInherit': 'الافتراضي (موروث)',
'flows.nodeConfig.agent.modelHints': 'تلميحات النموذج',
'flows.nodeConfig.agent.modelCustom': 'نموذج مخصص…',
'flows.nodeConfig.agent.modelCustomPlaceholder': 'مثل gpt-4o-mini',
'flows.nodeConfig.tool.requiredMark': 'مطلوب',
'flows.nodeConfig.tool.requiredMissing': 'مطلوب — غير موصول',
'flows.nodeConfig.tool.argsAdvancedLabel': 'كل الوسيطات (متقدم)',
'flows.nodeConfig.upstream.insert': 'إدراج…',
'flows.nodeConfig.upstream.insertLabel': 'إدراج قيمة من خطوة سابقة',
'flows.nodeConfig.native.toolLabel': 'أداة',
'flows.nodeConfig.native.toolHint': 'إحدى أدوات المساعد المدمجة (البحث، الوسائط، الملفات، …).',
'flows.nodeConfig.native.select': 'اختر أداة…',
'flows.nodeConfig.native.loading': 'جارٍ تحميل الأدوات…',
'flows.nodeConfig.close': 'إغلاق الإعدادات',
'flows.nodeConfig.nameLabel': 'الاسم',
'flows.nodeConfig.namePlaceholder': 'اسم العقدة',
@@ -6658,6 +6708,27 @@ const messages: TranslationMap = {
// Code block chrome
'codeBlock.copy': 'نسخ',
'codeBlock.copied': 'تم النسخ!',
'flows.editor.undo': 'تراجع',
'flows.editor.redo': 'إعادة',
'flows.editor.onboardingTitle': 'أنشئ سير عملك',
'flows.editor.onboardingBody':
'أضِف عقدة من اللوحة على اليسار، ثم اسحب بين النقاط على كل بطاقة لربطها.',
'flows.palette.search': 'البحث عن العقد…',
'flows.palette.noResults': 'لا توجد عقد مطابقة',
'flows.palette.group.triggers': 'المشغّلات',
'flows.palette.group.actions': 'الإجراءات',
'flows.palette.group.logic': 'المنطق',
'flows.list.duplicate': 'تكرار',
'flows.list.duplicated': 'تم تكرار سير العمل',
'flows.list.delete': 'حذف',
'flows.list.deleted': 'تم حذف سير العمل',
'flows.list.moreActions': 'المزيد من الإجراءات',
'flows.delete.title': 'حذف سير العمل؟',
'flows.delete.body': 'سيتم حذف "{name}" وسجل تشغيله نهائيًا. لا يمكن التراجع عن ذلك.',
'flows.delete.cancel': 'إلغاء',
'flows.delete.confirm': 'حذف',
'flows.delete.deleting': 'جارٍ الحذف…',
'flows.canvas.renameLabel': 'إعادة تسمية سير العمل',
};
export default messages;
+72
View File
@@ -3697,6 +3697,7 @@ const messages: TranslationMap = {
'flowRuns.status.completed': 'সম্পন্ন',
'flowRuns.status.pending_approval': 'অনুমোদনের অপেক্ষায়',
'flowRuns.status.failed': 'ব্যর্থ',
'flowRuns.status.cancelled': 'বাতিল করা হয়েছে',
'flows.page.title': 'ওয়ার্কফ্লো',
'flows.page.description': 'সংরক্ষিত অটোমেশন যা আপনি সক্ষম, চালাতে এবং পর্যবেক্ষণ করতে পারেন।',
@@ -3819,6 +3820,55 @@ const messages: TranslationMap = {
'flows.editor.leaveStay': 'থাকুন',
'flows.editor.leaveDiscard': 'চলে যান',
// ── Node config drawer (issue B5b / Phase 3b) — per-kind config forms
'flowRuns.inspector.diagnosticsTitle': 'এক্সপ্রেশন সতর্কতা',
'flowRuns.inspector.diagnosticResolvedNull': 'null হিসেবে সমাধান হয়েছে',
'flows.runs.sidebarTitle': 'রান',
'flows.runs.refresh': 'রান রিফ্রেশ করুন',
'flows.palette.appAction': 'অ্যাপ অ্যাকশন',
'flows.palette.ohTool': 'টুল',
'flows.editor.deleteNode': 'মুছুন',
'flows.nodeConfig.connections.title': 'সংযোগ',
'flows.nodeConfig.connections.inputs': 'ইনপুট',
'flows.nodeConfig.connections.outputs': 'আউটপুট',
'flows.nodeConfig.connections.none': 'এখনও অন্য কোনো নোডের সাথে যুক্ত নয়।',
'flows.nodeConfig.connections.remove': 'সংযোগ সরান',
'flows.nodeConfig.trigger.scheduleCronLabel': 'Cron এক্সপ্রেশন',
'flows.nodeConfig.trigger.scheduleFreqLabel': 'ফ্রিকোয়েন্সি',
'flows.nodeConfig.trigger.scheduleFreq_minutes': 'প্রতি N মিনিটে',
'flows.nodeConfig.trigger.scheduleFreq_hours': 'প্রতি N ঘণ্টায়',
'flows.nodeConfig.trigger.scheduleFreq_daily': 'প্রতিদিন নির্দিষ্ট সময়ে',
'flows.nodeConfig.trigger.scheduleEvery': 'প্রতি',
'flows.nodeConfig.trigger.scheduleInterval': 'ব্যবধান',
'flows.nodeConfig.trigger.scheduleUnit_minutes': 'মিনিট',
'flows.nodeConfig.trigger.scheduleUnit_hours': 'ঘণ্টা',
'flows.nodeConfig.trigger.scheduleAt': 'এ',
'flows.nodeConfig.trigger.scheduleTime': 'দিনের সময়',
'flows.nodeConfig.trigger.scheduleDays': 'দিনগুলোতে (ঐচ্ছিক — প্রতিদিনের জন্য খালি রাখুন)',
'flows.nodeConfig.trigger.scheduleAdvanced': 'উন্নত (cron সম্পাদনা)',
'flows.nodeConfig.trigger.scheduleSimple': 'সহজ সময়সূচিতে ফিরুন',
'flows.nodeConfig.trigger.pickApp': 'আগে একটি সংযুক্ত অ্যাপ বেছে নিন।',
'flows.nodeConfig.tool.pickConnection': 'আগে একটি সংযোগ বেছে নিন।',
'flows.nodeConfig.composio.noConnections':
'এখনও কোনো অ্যাপ সংযুক্ত নেই। Settings → Apps থেকে একটি সংযুক্ত করে এখানে বেছে নিন।',
'flows.nodeConfig.composio.selectApp': 'একটি অ্যাপ বেছে নিন…',
'flows.nodeConfig.composio.select': 'বেছে নিন…',
'flows.nodeConfig.composio.loading': 'লোড হচ্ছে…',
'flows.nodeConfig.composio.custom': 'ম্যানুয়ালি লিখুন…',
'flows.nodeConfig.composio.customPlaceholder': 'SLUG_NAME',
'flows.nodeConfig.agent.modelHint': 'ক্ষমতার স্তর বেছে নিন — ওয়ার্কস্পেস মডেল নির্ধারণ করবে।',
'flows.nodeConfig.agent.modelInherit': 'ডিফল্ট (উত্তরাধিকার)',
'flows.nodeConfig.agent.modelHints': 'মডেল ইঙ্গিত',
'flows.nodeConfig.agent.modelCustom': 'কাস্টম মডেল…',
'flows.nodeConfig.agent.modelCustomPlaceholder': 'যেমন gpt-4o-mini',
'flows.nodeConfig.tool.requiredMark': 'আবশ্যক',
'flows.nodeConfig.tool.requiredMissing': 'আবশ্যক — যুক্ত নয়',
'flows.nodeConfig.tool.argsAdvancedLabel': 'সব আর্গুমেন্ট (উন্নত)',
'flows.nodeConfig.upstream.insert': 'সন্নিবেশ করুন…',
'flows.nodeConfig.upstream.insertLabel': 'আগের ধাপ থেকে একটি মান সন্নিবেশ করুন',
'flows.nodeConfig.native.toolLabel': 'টুল',
'flows.nodeConfig.native.toolHint': 'সহকারীর বিল্ট-ইন টুলগুলোর একটি (সার্চ, মিডিয়া, ফাইল, …)।',
'flows.nodeConfig.native.select': 'একটি টুল বেছে নিন…',
'flows.nodeConfig.native.loading': 'টুল লোড হচ্ছে…',
'flows.nodeConfig.close': 'সেটিংস বন্ধ করুন',
'flows.nodeConfig.nameLabel': 'নাম',
'flows.nodeConfig.namePlaceholder': 'নোডের নাম',
@@ -6808,6 +6858,28 @@ const messages: TranslationMap = {
// Code block chrome
'codeBlock.copy': 'কপি করুন',
'codeBlock.copied': 'কপি হয়েছে!',
'flows.editor.undo': 'পূর্বাবস্থায় ফিরুন',
'flows.editor.redo': 'পুনরায় করুন',
'flows.editor.onboardingTitle': 'আপনার ওয়ার্কফ্লো তৈরি করুন',
'flows.editor.onboardingBody':
'বাঁদিকের প্যালেট থেকে একটি নোড যোগ করুন, তারপর প্রতিটি কার্ডের বিন্দুগুলোর মধ্যে টেনে সেগুলো সংযুক্ত করুন।',
'flows.palette.search': 'নোড খুঁজুন…',
'flows.palette.noResults': 'কোনো মিলে যাওয়া নোড নেই',
'flows.palette.group.triggers': 'ট্রিগার',
'flows.palette.group.actions': 'ক্রিয়া',
'flows.palette.group.logic': 'লজিক',
'flows.list.duplicate': 'অনুলিপি করুন',
'flows.list.duplicated': 'ওয়ার্কফ্লো অনুলিপি হয়েছে',
'flows.list.delete': 'মুছুন',
'flows.list.deleted': 'ওয়ার্কফ্লো মুছে ফেলা হয়েছে',
'flows.list.moreActions': 'আরও ক্রিয়া',
'flows.delete.title': 'ওয়ার্কফ্লো মুছবেন?',
'flows.delete.body':
'"{name}" এবং এর রান ইতিহাস স্থায়ীভাবে মুছে ফেলা হবে। এটি পূর্বাবস্থায় ফেরানো যাবে না।',
'flows.delete.cancel': 'বাতিল করুন',
'flows.delete.confirm': 'মুছুন',
'flows.delete.deleting': 'মুছে ফেলা হচ্ছে…',
'flows.canvas.renameLabel': 'ওয়ার্কফ্লো পুনঃনামকরণ করুন',
};
export default messages;
+74
View File
@@ -3787,6 +3787,7 @@ const messages: TranslationMap = {
'flowRuns.status.completed': 'Abgeschlossen',
'flowRuns.status.pending_approval': 'Wartet auf Genehmigung',
'flowRuns.status.failed': 'Fehlgeschlagen',
'flowRuns.status.cancelled': 'Abgebrochen',
'flows.page.title': 'Workflows',
'flows.page.description':
@@ -3915,6 +3916,57 @@ const messages: TranslationMap = {
'flows.editor.leaveStay': 'Bleiben',
'flows.editor.leaveDiscard': 'Verlassen',
// ── Node config drawer (issue B5b / Phase 3b) — per-kind config forms
'flowRuns.inspector.diagnosticsTitle': 'Ausdruckswarnungen',
'flowRuns.inspector.diagnosticResolvedNull': 'wurde zu null aufgelöst',
'flows.runs.sidebarTitle': 'Läufe',
'flows.runs.refresh': 'Läufe aktualisieren',
'flows.palette.appAction': 'App-Aktion',
'flows.palette.ohTool': 'Werkzeug',
'flows.editor.deleteNode': 'Löschen',
'flows.nodeConfig.connections.title': 'Verbindungen',
'flows.nodeConfig.connections.inputs': 'Eingaben',
'flows.nodeConfig.connections.outputs': 'Ausgaben',
'flows.nodeConfig.connections.none': 'Noch mit keinem anderen Knoten verbunden.',
'flows.nodeConfig.connections.remove': 'Verbindung entfernen',
'flows.nodeConfig.trigger.scheduleCronLabel': 'Cron-Ausdruck',
'flows.nodeConfig.trigger.scheduleFreqLabel': 'Häufigkeit',
'flows.nodeConfig.trigger.scheduleFreq_minutes': 'Alle N Minuten',
'flows.nodeConfig.trigger.scheduleFreq_hours': 'Alle N Stunden',
'flows.nodeConfig.trigger.scheduleFreq_daily': 'Jeden Tag zu einer Uhrzeit',
'flows.nodeConfig.trigger.scheduleEvery': 'alle',
'flows.nodeConfig.trigger.scheduleInterval': 'Intervall',
'flows.nodeConfig.trigger.scheduleUnit_minutes': 'Min.',
'flows.nodeConfig.trigger.scheduleUnit_hours': 'Std.',
'flows.nodeConfig.trigger.scheduleAt': 'um',
'flows.nodeConfig.trigger.scheduleTime': 'Tageszeit',
'flows.nodeConfig.trigger.scheduleDays': 'An Tagen (optional — leer lassen für jeden Tag)',
'flows.nodeConfig.trigger.scheduleAdvanced': 'Erweitert (Cron bearbeiten)',
'flows.nodeConfig.trigger.scheduleSimple': 'Zur einfachen Planung zurück',
'flows.nodeConfig.trigger.pickApp': 'Wähle zuerst eine verbundene App aus.',
'flows.nodeConfig.tool.pickConnection': 'Wähle zuerst eine Verbindung aus.',
'flows.nodeConfig.composio.noConnections':
'Noch keine Apps verbunden. Verbinde eine unter Einstellungen → Apps und wähle sie dann hier aus.',
'flows.nodeConfig.composio.selectApp': 'App auswählen…',
'flows.nodeConfig.composio.select': 'Auswählen…',
'flows.nodeConfig.composio.loading': 'Wird geladen…',
'flows.nodeConfig.composio.custom': 'Manuell eingeben…',
'flows.nodeConfig.composio.customPlaceholder': 'SLUG_NAME',
'flows.nodeConfig.agent.modelHint':
'Wähle eine Fähigkeitsstufe — der Workspace löst das Modell auf.',
'flows.nodeConfig.agent.modelInherit': 'Standard (erben)',
'flows.nodeConfig.agent.modelHints': 'Modellhinweise',
'flows.nodeConfig.agent.modelCustom': 'Benutzerdefiniertes Modell…',
'flows.nodeConfig.agent.modelCustomPlaceholder': 'z. B. gpt-4o-mini',
'flows.nodeConfig.tool.requiredMark': 'erforderlich',
'flows.nodeConfig.tool.requiredMissing': 'Erforderlich — nicht verdrahtet',
'flows.nodeConfig.tool.argsAdvancedLabel': 'Alle Argumente (erweitert)',
'flows.nodeConfig.upstream.insert': 'Einfügen…',
'flows.nodeConfig.upstream.insertLabel': 'Wert aus einem vorherigen Schritt einfügen',
'flows.nodeConfig.native.toolLabel': 'Werkzeug',
'flows.nodeConfig.native.toolHint':
'Eines der integrierten Assistentenwerkzeuge (Suche, Medien, Dateien, …).',
'flows.nodeConfig.native.select': 'Werkzeug auswählen…',
'flows.nodeConfig.native.loading': 'Werkzeuge werden geladen…',
'flows.nodeConfig.close': 'Einstellungen schließen',
'flows.nodeConfig.nameLabel': 'Name',
'flows.nodeConfig.namePlaceholder': 'Knotenname',
@@ -6997,6 +7049,28 @@ const messages: TranslationMap = {
// Code block chrome
'codeBlock.copy': 'Kopieren',
'codeBlock.copied': 'Kopiert!',
'flows.editor.undo': 'Rückgängig',
'flows.editor.redo': 'Wiederherstellen',
'flows.editor.onboardingTitle': 'Workflow erstellen',
'flows.editor.onboardingBody':
'Füge über die Palette links einen Knoten hinzu und ziehe dann zwischen den Punkten auf den Karten, um sie zu verbinden.',
'flows.palette.search': 'Knoten suchen…',
'flows.palette.noResults': 'Keine passenden Knoten',
'flows.palette.group.triggers': 'Auslöser',
'flows.palette.group.actions': 'Aktionen',
'flows.palette.group.logic': 'Logik',
'flows.list.duplicate': 'Duplizieren',
'flows.list.duplicated': 'Workflow dupliziert',
'flows.list.delete': 'Löschen',
'flows.list.deleted': 'Workflow gelöscht',
'flows.list.moreActions': 'Weitere Aktionen',
'flows.delete.title': 'Workflow löschen?',
'flows.delete.body':
'"{name}" und der zugehörige Ausführungsverlauf werden dauerhaft entfernt. Dies kann nicht rückgängig gemacht werden.',
'flows.delete.cancel': 'Abbrechen',
'flows.delete.confirm': 'Löschen',
'flows.delete.deleting': 'Wird gelöscht…',
'flows.canvas.renameLabel': 'Workflow umbenennen',
};
export default messages;
+83 -5
View File
@@ -4318,6 +4318,10 @@ const en: TranslationMap = {
'flowRuns.inspector.noSteps': 'No steps recorded yet.',
'flowRuns.inspector.output': 'Output',
'flowRuns.inspector.port': 'Port',
// Null-resolution diagnostics: `=`-expressions in a step's config that
// resolved to null during the run (likely a mis-wired reference).
'flowRuns.inspector.diagnosticsTitle': 'Expression warnings',
'flowRuns.inspector.diagnosticResolvedNull': 'resolved to null',
'flowRuns.inspector.loading': 'Loading run…',
'flowRuns.inspector.loadError': 'Could not load this run',
// Phase 5c — "Fix with agent" opens the canvas copilot preloaded with this
@@ -4339,6 +4343,7 @@ const en: TranslationMap = {
'flowRuns.status.completed': 'Completed',
'flowRuns.status.pending_approval': 'Awaiting approval',
'flowRuns.status.failed': 'Failed',
'flowRuns.status.cancelled': 'Cancelled',
// ── Workflows list page + nav tab (B5a) — the `flows::` domain's
// discoverable hub at /flows. Distinct from the legacy SKILL.md
@@ -4369,6 +4374,17 @@ const en: TranslationMap = {
'flows.list.view': 'View workflow',
'flows.list.export': 'Export',
'flows.list.exported': 'Workflow exported',
'flows.list.duplicate': 'Duplicate',
'flows.list.duplicated': 'Workflow duplicated',
'flows.list.delete': 'Delete',
'flows.list.deleted': 'Workflow deleted',
'flows.list.moreActions': 'More actions',
'flows.delete.title': 'Delete workflow?',
'flows.delete.body':
'"{name}" and its run history will be permanently removed. This cannot be undone.',
'flows.delete.cancel': 'Cancel',
'flows.delete.confirm': 'Delete',
'flows.delete.deleting': 'Deleting…',
'flows.page.import': 'Import',
'flows.import.invalidFile': 'That file is not valid workflow JSON.',
'flows.import.error': 'Could not import this workflow. Check the file and try again.',
@@ -4378,6 +4394,8 @@ const en: TranslationMap = {
'flows.runs.loading': 'Loading runs…',
'flows.runs.loadError': 'Could not load runs',
'flows.runs.empty': 'No runs yet',
'flows.runs.sidebarTitle': 'Runs',
'flows.runs.refresh': 'Refresh runs',
// ── Phase 5c: prompt-first authoring + canvas copilot ────────────────────
// The Flows prompt bar (describe a workflow → builder agent proposes it) and
@@ -4423,7 +4441,7 @@ const en: TranslationMap = {
'flows.copilot.removed': '{count} removed',
'flows.copilot.noChanges': 'No node changes in this proposal.',
'flows.copilot.accept': 'Apply to draft',
'flows.copilot.reject': 'Discard',
'flows.copilot.reject': 'Dismiss',
'flows.copilot.previewHint': 'Reviewing a proposed draft — nothing is saved yet.',
'flows.copilot.repairDisplay': 'A run failed — please look at it and propose a fix.',
@@ -4436,6 +4454,7 @@ const en: TranslationMap = {
'flows.canvas.notFound': 'This workflow could not be found.',
'flows.canvas.draftMissing': 'No workflow draft to open. Propose one from chat first.',
'flows.canvas.backToList': 'Back to workflows',
'flows.canvas.renameLabel': 'Rename workflow',
'flows.nodeKind.trigger': 'Trigger',
'flows.nodeKind.agent': 'Agent',
'flows.nodeKind.tool_call': 'Tool call',
@@ -4453,12 +4472,25 @@ const en: TranslationMap = {
// and editor toolbar layered on top of the read-only canvas above.
'flows.palette.title': 'Nodes',
'flows.palette.addNode': 'Add {kind} node',
'flows.palette.search': 'Search nodes…',
'flows.palette.noResults': 'No matching nodes',
'flows.palette.group.triggers': 'Triggers',
'flows.palette.group.actions': 'Actions',
'flows.palette.group.logic': 'Logic',
'flows.palette.appAction': 'App action',
'flows.palette.ohTool': 'Tool',
'flows.editor.save': 'Save',
'flows.editor.saving': 'Saving…',
'flows.editor.run': 'Run',
'flows.editor.running': 'Running…',
'flows.editor.runFailed': 'Could not start run',
'flows.editor.deleteSelected': 'Delete selected',
'flows.editor.deleteNode': 'Delete',
'flows.editor.undo': 'Undo',
'flows.editor.redo': 'Redo',
'flows.editor.onboardingTitle': 'Build your workflow',
'flows.editor.onboardingBody':
'Add a node from the palette on the left, then drag between the dots on each card to connect them.',
// ── Validation UX + draft/dirty state (issue B5b / Phase 3c3d)
'flows.editor.validate': 'Validate',
'flows.editor.validating': 'Validating…',
@@ -4475,6 +4507,11 @@ const en: TranslationMap = {
'flows.editor.leaveDiscard': 'Leave',
// ── Node config drawer (issue B5b / Phase 3b) — per-kind config forms
'flows.nodeConfig.close': 'Close settings',
'flows.nodeConfig.connections.title': 'Connections',
'flows.nodeConfig.connections.inputs': 'Inputs',
'flows.nodeConfig.connections.outputs': 'Outputs',
'flows.nodeConfig.connections.none': 'Not connected to any other node yet.',
'flows.nodeConfig.connections.remove': 'Remove connection',
'flows.nodeConfig.nameLabel': 'Name',
'flows.nodeConfig.namePlaceholder': 'Node name',
'flows.nodeConfig.editForm': 'Edit as form',
@@ -4498,10 +4535,33 @@ const en: TranslationMap = {
'flows.nodeConfig.trigger.kind_schedule': 'Schedule',
'flows.nodeConfig.trigger.kind_webhook': 'Webhook',
'flows.nodeConfig.trigger.kind_app_event': 'App event',
'flows.nodeConfig.trigger.scheduleLabel': 'Cron schedule',
'flows.nodeConfig.trigger.scheduleLabel': 'Schedule',
'flows.nodeConfig.trigger.scheduleHint': 'Cron expression: minute hour day month weekday.',
'flows.nodeConfig.trigger.toolkitLabel': 'Toolkit',
'flows.nodeConfig.trigger.triggerSlugLabel': 'Trigger slug',
'flows.nodeConfig.trigger.scheduleCronLabel': 'Cron expression',
'flows.nodeConfig.trigger.scheduleFreqLabel': 'Frequency',
'flows.nodeConfig.trigger.scheduleFreq_minutes': 'Every N minutes',
'flows.nodeConfig.trigger.scheduleFreq_hours': 'Every N hours',
'flows.nodeConfig.trigger.scheduleFreq_daily': 'At a time each day',
'flows.nodeConfig.trigger.scheduleEvery': 'every',
'flows.nodeConfig.trigger.scheduleInterval': 'Interval',
'flows.nodeConfig.trigger.scheduleUnit_minutes': 'min',
'flows.nodeConfig.trigger.scheduleUnit_hours': 'hr',
'flows.nodeConfig.trigger.scheduleAt': 'at',
'flows.nodeConfig.trigger.scheduleTime': 'Time of day',
'flows.nodeConfig.trigger.scheduleDays': 'On days (optional — leave empty for every day)',
'flows.nodeConfig.trigger.scheduleAdvanced': 'Advanced (edit cron)',
'flows.nodeConfig.trigger.scheduleSimple': 'Back to simple schedule',
'flows.nodeConfig.trigger.toolkitLabel': 'App',
'flows.nodeConfig.trigger.triggerSlugLabel': 'Trigger',
'flows.nodeConfig.trigger.pickApp': 'Pick a connected app first.',
'flows.nodeConfig.tool.pickConnection': 'Pick a connection first.',
'flows.nodeConfig.composio.noConnections':
'No connected apps yet. Connect one in Settings → Apps, then pick it here.',
'flows.nodeConfig.composio.selectApp': 'Select an app…',
'flows.nodeConfig.composio.select': 'Select…',
'flows.nodeConfig.composio.loading': 'Loading…',
'flows.nodeConfig.composio.custom': 'Enter manually…',
'flows.nodeConfig.composio.customPlaceholder': 'SLUG_NAME',
'flows.nodeConfig.trigger.webhookHint':
'Webhook triggers are saved but not dispatched automatically yet.',
'flows.nodeConfig.http.methodLabel': 'Method',
@@ -4511,8 +4571,26 @@ const en: TranslationMap = {
'flows.nodeConfig.agent.promptLabel': 'Prompt',
'flows.nodeConfig.agent.promptPlaceholder': 'Instructions for the agent…',
'flows.nodeConfig.agent.modelLabel': 'Model',
'flows.nodeConfig.tool.slugLabel': 'Tool slug',
'flows.nodeConfig.agent.modelHint': 'Pick a capability tier — the workspace resolves the model.',
'flows.nodeConfig.agent.modelInherit': 'Default (inherit)',
'flows.nodeConfig.agent.modelHints': 'Model hints',
'flows.nodeConfig.agent.modelCustom': 'Custom model…',
'flows.nodeConfig.agent.modelCustomPlaceholder': 'e.g. gpt-4o-mini',
'flows.nodeConfig.tool.slugLabel': 'Action',
'flows.nodeConfig.tool.argsLabel': 'Arguments (JSON)',
// Required-arg preflight rows for Composio actions (per-arg ExpressionField
// rows above the raw JSON editor, which stays as the advanced escape hatch).
'flows.nodeConfig.tool.requiredMark': 'required',
'flows.nodeConfig.tool.requiredMissing': 'Required — not wired',
'flows.nodeConfig.tool.argsAdvancedLabel': 'All args (advanced)',
// Upstream-output picker (`=nodes.<id>.item…` insert menu on expression fields).
'flows.nodeConfig.upstream.insert': 'Insert…',
'flows.nodeConfig.upstream.insertLabel': 'Insert a value from a previous step',
'flows.nodeConfig.native.toolLabel': 'Tool',
'flows.nodeConfig.native.toolHint':
"One of the assistant's built-in tools (search, media, files, …).",
'flows.nodeConfig.native.select': 'Select a tool…',
'flows.nodeConfig.native.loading': 'Loading tools…',
'flows.nodeConfig.condition.fieldLabel': 'Field',
'flows.nodeConfig.condition.fieldHint':
'Key on the input item to test for truthiness. Routes to true or false.',
+74
View File
@@ -3760,6 +3760,7 @@ const messages: TranslationMap = {
'flowRuns.status.completed': 'Completado',
'flowRuns.status.pending_approval': 'Esperando aprobación',
'flowRuns.status.failed': 'Fallido',
'flowRuns.status.cancelled': 'Cancelado',
'flows.page.title': 'Flujos de trabajo',
'flows.page.description':
@@ -3882,6 +3883,57 @@ const messages: TranslationMap = {
'flows.editor.leaveStay': 'Quedarme',
'flows.editor.leaveDiscard': 'Salir',
// ── Node config drawer (issue B5b / Phase 3b) — per-kind config forms
'flowRuns.inspector.diagnosticsTitle': 'Advertencias de expresión',
'flowRuns.inspector.diagnosticResolvedNull': 'se resolvió como null',
'flows.runs.sidebarTitle': 'Ejecuciones',
'flows.runs.refresh': 'Actualizar ejecuciones',
'flows.palette.appAction': 'Acción de app',
'flows.palette.ohTool': 'Herramienta',
'flows.editor.deleteNode': 'Eliminar',
'flows.nodeConfig.connections.title': 'Conexiones',
'flows.nodeConfig.connections.inputs': 'Entradas',
'flows.nodeConfig.connections.outputs': 'Salidas',
'flows.nodeConfig.connections.none': 'Aún no está conectado a ningún otro nodo.',
'flows.nodeConfig.connections.remove': 'Quitar conexión',
'flows.nodeConfig.trigger.scheduleCronLabel': 'Expresión cron',
'flows.nodeConfig.trigger.scheduleFreqLabel': 'Frecuencia',
'flows.nodeConfig.trigger.scheduleFreq_minutes': 'Cada N minutos',
'flows.nodeConfig.trigger.scheduleFreq_hours': 'Cada N horas',
'flows.nodeConfig.trigger.scheduleFreq_daily': 'A una hora cada día',
'flows.nodeConfig.trigger.scheduleEvery': 'cada',
'flows.nodeConfig.trigger.scheduleInterval': 'Intervalo',
'flows.nodeConfig.trigger.scheduleUnit_minutes': 'min.',
'flows.nodeConfig.trigger.scheduleUnit_hours': 'h',
'flows.nodeConfig.trigger.scheduleAt': 'a las',
'flows.nodeConfig.trigger.scheduleTime': 'Hora del día',
'flows.nodeConfig.trigger.scheduleDays': 'En días (opcional — deja vacío para todos los días)',
'flows.nodeConfig.trigger.scheduleAdvanced': 'Avanzado (editar cron)',
'flows.nodeConfig.trigger.scheduleSimple': 'Volver al horario simple',
'flows.nodeConfig.trigger.pickApp': 'Primero elige una app conectada.',
'flows.nodeConfig.tool.pickConnection': 'Primero elige una conexión.',
'flows.nodeConfig.composio.noConnections':
'Aún no hay apps conectadas. Conecta una en Ajustes → Apps y luego selecciónala aquí.',
'flows.nodeConfig.composio.selectApp': 'Selecciona una app…',
'flows.nodeConfig.composio.select': 'Seleccionar…',
'flows.nodeConfig.composio.loading': 'Cargando…',
'flows.nodeConfig.composio.custom': 'Introducir manualmente…',
'flows.nodeConfig.composio.customPlaceholder': 'SLUG_NAME',
'flows.nodeConfig.agent.modelHint':
'Elige un nivel de capacidad; el espacio de trabajo resuelve el modelo.',
'flows.nodeConfig.agent.modelInherit': 'Predeterminado (heredar)',
'flows.nodeConfig.agent.modelHints': 'Sugerencias de modelo',
'flows.nodeConfig.agent.modelCustom': 'Modelo personalizado…',
'flows.nodeConfig.agent.modelCustomPlaceholder': 'p. ej., gpt-4o-mini',
'flows.nodeConfig.tool.requiredMark': 'obligatorio',
'flows.nodeConfig.tool.requiredMissing': 'Obligatorio — no conectado',
'flows.nodeConfig.tool.argsAdvancedLabel': 'Todos los argumentos (avanzado)',
'flows.nodeConfig.upstream.insert': 'Insertar…',
'flows.nodeConfig.upstream.insertLabel': 'Insertar un valor de un paso anterior',
'flows.nodeConfig.native.toolLabel': 'Herramienta',
'flows.nodeConfig.native.toolHint':
'Una de las herramientas integradas del asistente (búsqueda, medios, archivos, …).',
'flows.nodeConfig.native.select': 'Selecciona una herramienta…',
'flows.nodeConfig.native.loading': 'Cargando herramientas…',
'flows.nodeConfig.close': 'Cerrar ajustes',
'flows.nodeConfig.nameLabel': 'Nombre',
'flows.nodeConfig.namePlaceholder': 'Nombre del nodo',
@@ -6944,6 +6996,28 @@ const messages: TranslationMap = {
// Code block chrome
'codeBlock.copy': 'Copiar',
'codeBlock.copied': '¡Copiado!',
'flows.editor.undo': 'Deshacer',
'flows.editor.redo': 'Rehacer',
'flows.editor.onboardingTitle': 'Crea tu flujo de trabajo',
'flows.editor.onboardingBody':
'Añade un nodo desde la paleta de la izquierda y luego arrastra entre los puntos de cada tarjeta para conectarlos.',
'flows.palette.search': 'Buscar nodos…',
'flows.palette.noResults': 'No hay nodos coincidentes',
'flows.palette.group.triggers': 'Disparadores',
'flows.palette.group.actions': 'Acciones',
'flows.palette.group.logic': 'Lógica',
'flows.list.duplicate': 'Duplicar',
'flows.list.duplicated': 'Flujo de trabajo duplicado',
'flows.list.delete': 'Eliminar',
'flows.list.deleted': 'Flujo de trabajo eliminado',
'flows.list.moreActions': 'Más acciones',
'flows.delete.title': '¿Eliminar el flujo de trabajo?',
'flows.delete.body':
'"{name}" y su historial de ejecuciones se eliminarán de forma permanente. Esta acción no se puede deshacer.',
'flows.delete.cancel': 'Cancelar',
'flows.delete.confirm': 'Eliminar',
'flows.delete.deleting': 'Eliminando…',
'flows.canvas.renameLabel': 'Cambiar el nombre del flujo de trabajo',
};
export default messages;
+76 -2
View File
@@ -3775,6 +3775,7 @@ const messages: TranslationMap = {
'flowRuns.status.completed': 'Terminé',
'flowRuns.status.pending_approval': "En attente d'approbation",
'flowRuns.status.failed': 'Échoué',
'flowRuns.status.cancelled': 'Annulé',
'flows.page.title': 'Workflows',
'flows.page.description':
@@ -3817,9 +3818,9 @@ const messages: TranslationMap = {
'Des automatisations que le Flow Scout juge utiles, selon votre façon de travailler.',
'flows.suggest.discover': 'Découvrir',
'flows.suggest.rediscover': 'Actualiser',
'flows.suggest.discovering': 'Recherche d\'automatisations…',
'flows.suggest.discovering': "Recherche d'automatisations…",
'flows.suggest.empty':
'Aucune suggestion pour l\'instant. Lancez la découverte et le Flow Scout parcourra votre travail pour trouver des automatisations à mettre en place.',
"Aucune suggestion pour l'instant. Lancez la découverte et le Flow Scout parcourra votre travail pour trouver des automatisations à mettre en place.",
'flows.suggest.error': 'Impossible de lancer la découverte. Veuillez réessayer.',
'flows.suggest.why': 'Pourquoi',
'flows.suggest.build': 'Créer ceci',
@@ -3898,6 +3899,57 @@ const messages: TranslationMap = {
'flows.editor.leaveStay': 'Rester',
'flows.editor.leaveDiscard': 'Quitter',
// ── Node config drawer (issue B5b / Phase 3b) — per-kind config forms
'flowRuns.inspector.diagnosticsTitle': "Avertissements d'expression",
'flowRuns.inspector.diagnosticResolvedNull': 'résolu en null',
'flows.runs.sidebarTitle': 'Exécutions',
'flows.runs.refresh': 'Actualiser les exécutions',
'flows.palette.appAction': "Action d'app",
'flows.palette.ohTool': 'Outil',
'flows.editor.deleteNode': 'Supprimer',
'flows.nodeConfig.connections.title': 'Connexions',
'flows.nodeConfig.connections.inputs': 'Entrées',
'flows.nodeConfig.connections.outputs': 'Sorties',
'flows.nodeConfig.connections.none': 'Pas encore connecté à un autre nœud.',
'flows.nodeConfig.connections.remove': 'Supprimer la connexion',
'flows.nodeConfig.trigger.scheduleCronLabel': 'Expression cron',
'flows.nodeConfig.trigger.scheduleFreqLabel': 'Fréquence',
'flows.nodeConfig.trigger.scheduleFreq_minutes': 'Toutes les N minutes',
'flows.nodeConfig.trigger.scheduleFreq_hours': 'Toutes les N heures',
'flows.nodeConfig.trigger.scheduleFreq_daily': 'À une heure chaque jour',
'flows.nodeConfig.trigger.scheduleEvery': 'tous les',
'flows.nodeConfig.trigger.scheduleInterval': 'Intervalle',
'flows.nodeConfig.trigger.scheduleUnit_minutes': 'min',
'flows.nodeConfig.trigger.scheduleUnit_hours': 'h',
'flows.nodeConfig.trigger.scheduleAt': 'à',
'flows.nodeConfig.trigger.scheduleTime': 'Heure de la journée',
'flows.nodeConfig.trigger.scheduleDays': 'Les jours (facultatif — laisser vide pour chaque jour)',
'flows.nodeConfig.trigger.scheduleAdvanced': 'Avancé (modifier le cron)',
'flows.nodeConfig.trigger.scheduleSimple': 'Revenir au planning simple',
'flows.nodeConfig.trigger.pickApp': "Choisissez d'abord une app connectée.",
'flows.nodeConfig.tool.pickConnection': "Choisissez d'abord une connexion.",
'flows.nodeConfig.composio.noConnections':
"Aucune app connectée pour l'instant. Connectez-en une dans Réglages → Apps, puis choisissez-la ici.",
'flows.nodeConfig.composio.selectApp': 'Sélectionner une app…',
'flows.nodeConfig.composio.select': 'Sélectionner…',
'flows.nodeConfig.composio.loading': 'Chargement…',
'flows.nodeConfig.composio.custom': 'Saisir manuellement…',
'flows.nodeConfig.composio.customPlaceholder': 'SLUG_NAME',
'flows.nodeConfig.agent.modelHint':
'Choisissez un niveau de capacité — lespace de travail résout le modèle.',
'flows.nodeConfig.agent.modelInherit': 'Par défaut (hériter)',
'flows.nodeConfig.agent.modelHints': 'Indications de modèle',
'flows.nodeConfig.agent.modelCustom': 'Modèle personnalisé…',
'flows.nodeConfig.agent.modelCustomPlaceholder': 'p. ex. gpt-4o-mini',
'flows.nodeConfig.tool.requiredMark': 'obligatoire',
'flows.nodeConfig.tool.requiredMissing': 'Obligatoire — non câblé',
'flows.nodeConfig.tool.argsAdvancedLabel': 'Tous les arguments (avancé)',
'flows.nodeConfig.upstream.insert': 'Insérer…',
'flows.nodeConfig.upstream.insertLabel': "Insérer une valeur d'une étape précédente",
'flows.nodeConfig.native.toolLabel': 'Outil',
'flows.nodeConfig.native.toolHint':
'Un des outils intégrés de lassistant (recherche, médias, fichiers, …).',
'flows.nodeConfig.native.select': 'Sélectionner un outil…',
'flows.nodeConfig.native.loading': 'Chargement des outils…',
'flows.nodeConfig.close': 'Fermer les réglages',
'flows.nodeConfig.nameLabel': 'Nom',
'flows.nodeConfig.namePlaceholder': 'Nom du nœud',
@@ -6968,6 +7020,28 @@ const messages: TranslationMap = {
// Code block chrome
'codeBlock.copy': 'Copier',
'codeBlock.copied': 'Copié !',
'flows.editor.undo': 'Annuler',
'flows.editor.redo': 'Rétablir',
'flows.editor.onboardingTitle': 'Créez votre workflow',
'flows.editor.onboardingBody':
'Ajoutez un nœud depuis la palette à gauche, puis faites glisser entre les points de chaque carte pour les connecter.',
'flows.palette.search': 'Rechercher des nœuds…',
'flows.palette.noResults': 'Aucun nœud correspondant',
'flows.palette.group.triggers': 'Déclencheurs',
'flows.palette.group.actions': 'Actions',
'flows.palette.group.logic': 'Logique',
'flows.list.duplicate': 'Dupliquer',
'flows.list.duplicated': 'Workflow dupliqué',
'flows.list.delete': 'Supprimer',
'flows.list.deleted': 'Workflow supprimé',
'flows.list.moreActions': "Plus d'actions",
'flows.delete.title': 'Supprimer le workflow ?',
'flows.delete.body':
'"{name}" et son historique d\'exécution seront définitivement supprimés. Cette action est irréversible.',
'flows.delete.cancel': 'Annuler',
'flows.delete.confirm': 'Supprimer',
'flows.delete.deleting': 'Suppression…',
'flows.canvas.renameLabel': 'Renommer le workflow',
};
export default messages;
+72
View File
@@ -3698,6 +3698,7 @@ const messages: TranslationMap = {
'flowRuns.status.completed': 'पूर्ण',
'flowRuns.status.pending_approval': 'अनुमोदन की प्रतीक्षा में',
'flowRuns.status.failed': 'विफल',
'flowRuns.status.cancelled': 'रद्द किया गया',
'flows.page.title': 'वर्कफ़्लो',
'flows.page.description': 'सहेजे गए ऑटोमेशन जिन्हें आप सक्षम, चला और मॉनिटर कर सकते हैं।',
@@ -3818,6 +3819,55 @@ const messages: TranslationMap = {
'flows.editor.leaveStay': 'रुकें',
'flows.editor.leaveDiscard': 'छोड़ें',
// ── Node config drawer (issue B5b / Phase 3b) — per-kind config forms
'flowRuns.inspector.diagnosticsTitle': 'अभिव्यक्ति चेतावनियाँ',
'flowRuns.inspector.diagnosticResolvedNull': 'null में बदला',
'flows.runs.sidebarTitle': 'रन',
'flows.runs.refresh': 'रन रीफ़्रेश करें',
'flows.palette.appAction': 'ऐप क्रिया',
'flows.palette.ohTool': 'टूल',
'flows.editor.deleteNode': 'हटाएँ',
'flows.nodeConfig.connections.title': 'कनेक्शन',
'flows.nodeConfig.connections.inputs': 'इनपुट',
'flows.nodeConfig.connections.outputs': 'आउटपुट',
'flows.nodeConfig.connections.none': 'अभी किसी दूसरे नोड से जुड़ा नहीं है।',
'flows.nodeConfig.connections.remove': 'कनेक्शन हटाएँ',
'flows.nodeConfig.trigger.scheduleCronLabel': 'Cron अभिव्यक्ति',
'flows.nodeConfig.trigger.scheduleFreqLabel': 'आवृत्ति',
'flows.nodeConfig.trigger.scheduleFreq_minutes': 'हर N मिनट',
'flows.nodeConfig.trigger.scheduleFreq_hours': 'हर N घंटे',
'flows.nodeConfig.trigger.scheduleFreq_daily': 'हर दिन एक समय पर',
'flows.nodeConfig.trigger.scheduleEvery': 'हर',
'flows.nodeConfig.trigger.scheduleInterval': 'अंतराल',
'flows.nodeConfig.trigger.scheduleUnit_minutes': 'मिनट',
'flows.nodeConfig.trigger.scheduleUnit_hours': 'घंटे',
'flows.nodeConfig.trigger.scheduleAt': 'पर',
'flows.nodeConfig.trigger.scheduleTime': 'दिन का समय',
'flows.nodeConfig.trigger.scheduleDays': 'दिनों पर (वैकल्पिक — हर दिन के लिए खाली छोड़ें)',
'flows.nodeConfig.trigger.scheduleAdvanced': 'उन्नत (cron संपादित करें)',
'flows.nodeConfig.trigger.scheduleSimple': 'सरल शेड्यूल पर वापस',
'flows.nodeConfig.trigger.pickApp': 'पहले कोई जुड़ा हुआ ऐप चुनें।',
'flows.nodeConfig.tool.pickConnection': 'पहले कोई कनेक्शन चुनें।',
'flows.nodeConfig.composio.noConnections':
'अभी कोई ऐप जुड़ा नहीं है। Settings → Apps में एक जोड़ें, फिर यहाँ चुनें।',
'flows.nodeConfig.composio.selectApp': 'ऐप चुनें…',
'flows.nodeConfig.composio.select': 'चुनें…',
'flows.nodeConfig.composio.loading': 'लोड हो रहा है…',
'flows.nodeConfig.composio.custom': 'मैन्युअल दर्ज करें…',
'flows.nodeConfig.composio.customPlaceholder': 'SLUG_NAME',
'flows.nodeConfig.agent.modelHint': 'क्षमता स्तर चुनें — वर्कस्पेस मॉडल तय करेगा।',
'flows.nodeConfig.agent.modelInherit': 'डिफ़ॉल्ट (विरासत में)',
'flows.nodeConfig.agent.modelHints': 'मॉडल संकेत',
'flows.nodeConfig.agent.modelCustom': 'कस्टम मॉडल…',
'flows.nodeConfig.agent.modelCustomPlaceholder': 'जैसे gpt-4o-mini',
'flows.nodeConfig.tool.requiredMark': 'आवश्यक',
'flows.nodeConfig.tool.requiredMissing': 'आवश्यक — वायर नहीं किया गया',
'flows.nodeConfig.tool.argsAdvancedLabel': 'सभी आर्ग्युमेंट (उन्नत)',
'flows.nodeConfig.upstream.insert': 'डालें…',
'flows.nodeConfig.upstream.insertLabel': 'पिछले चरण से मान डालें',
'flows.nodeConfig.native.toolLabel': 'टूल',
'flows.nodeConfig.native.toolHint': 'सहायक के बिल्ट-इन टूल में से एक (खोज, मीडिया, फ़ाइलें, …)।',
'flows.nodeConfig.native.select': 'टूल चुनें…',
'flows.nodeConfig.native.loading': 'टूल लोड हो रहे हैं…',
'flows.nodeConfig.close': 'सेटिंग बंद करें',
'flows.nodeConfig.nameLabel': 'नाम',
'flows.nodeConfig.namePlaceholder': 'नोड नाम',
@@ -6807,6 +6857,28 @@ const messages: TranslationMap = {
// Code block chrome
'codeBlock.copy': 'कॉपी करें',
'codeBlock.copied': 'कॉपी हो गया!',
'flows.editor.undo': 'पूर्ववत करें',
'flows.editor.redo': 'फिर से करें',
'flows.editor.onboardingTitle': 'अपना वर्कफ़्लो बनाएं',
'flows.editor.onboardingBody':
'बाईं ओर के पैलेट से एक नोड जोड़ें, फिर उन्हें जोड़ने के लिए प्रत्येक कार्ड पर बिंदुओं के बीच खींचें।',
'flows.palette.search': 'नोड खोजें…',
'flows.palette.noResults': 'कोई मिलता-जुलता नोड नहीं',
'flows.palette.group.triggers': 'ट्रिगर',
'flows.palette.group.actions': 'क्रियाएं',
'flows.palette.group.logic': 'लॉजिक',
'flows.list.duplicate': 'डुप्लिकेट करें',
'flows.list.duplicated': 'वर्कफ़्लो डुप्लिकेट किया गया',
'flows.list.delete': 'हटाएं',
'flows.list.deleted': 'वर्कफ़्लो हटाया गया',
'flows.list.moreActions': 'और क्रियाएं',
'flows.delete.title': 'वर्कफ़्लो हटाएं?',
'flows.delete.body':
'"{name}" और इसका रन इतिहास स्थायी रूप से हटा दिया जाएगा। इसे पूर्ववत नहीं किया जा सकता।',
'flows.delete.cancel': 'रद्द करें',
'flows.delete.confirm': 'हटाएं',
'flows.delete.deleting': 'हटाया जा रहा है…',
'flows.canvas.renameLabel': 'वर्कफ़्लो का नाम बदलें',
};
export default messages;
+72
View File
@@ -3706,6 +3706,7 @@ const messages: TranslationMap = {
'flowRuns.status.completed': 'Selesai',
'flowRuns.status.pending_approval': 'Menunggu persetujuan',
'flowRuns.status.failed': 'Gagal',
'flowRuns.status.cancelled': 'Dibatalkan',
'flows.page.title': 'Alur Kerja',
'flows.page.description': 'Otomatisasi tersimpan yang dapat Anda aktifkan, jalankan, dan pantau.',
@@ -3827,6 +3828,55 @@ const messages: TranslationMap = {
'flows.editor.leaveStay': 'Tetap di sini',
'flows.editor.leaveDiscard': 'Keluar',
// ── Node config drawer (issue B5b / Phase 3b) — per-kind config forms
'flowRuns.inspector.diagnosticsTitle': 'Peringatan ekspresi',
'flowRuns.inspector.diagnosticResolvedNull': 'diselesaikan menjadi null',
'flows.runs.sidebarTitle': 'Jalankan',
'flows.runs.refresh': 'Segarkan daftar jalan',
'flows.palette.appAction': 'Aksi aplikasi',
'flows.palette.ohTool': 'Alat',
'flows.editor.deleteNode': 'Hapus',
'flows.nodeConfig.connections.title': 'Koneksi',
'flows.nodeConfig.connections.inputs': 'Masukan',
'flows.nodeConfig.connections.outputs': 'Keluaran',
'flows.nodeConfig.connections.none': 'Belum terhubung ke node lain.',
'flows.nodeConfig.connections.remove': 'Hapus koneksi',
'flows.nodeConfig.trigger.scheduleCronLabel': 'Ekspresi cron',
'flows.nodeConfig.trigger.scheduleFreqLabel': 'Frekuensi',
'flows.nodeConfig.trigger.scheduleFreq_minutes': 'Setiap N menit',
'flows.nodeConfig.trigger.scheduleFreq_hours': 'Setiap N jam',
'flows.nodeConfig.trigger.scheduleFreq_daily': 'Pada waktu tertentu setiap hari',
'flows.nodeConfig.trigger.scheduleEvery': 'setiap',
'flows.nodeConfig.trigger.scheduleInterval': 'Interval',
'flows.nodeConfig.trigger.scheduleUnit_minutes': 'mnt',
'flows.nodeConfig.trigger.scheduleUnit_hours': 'jam',
'flows.nodeConfig.trigger.scheduleAt': 'pada',
'flows.nodeConfig.trigger.scheduleTime': 'Waktu dalam hari',
'flows.nodeConfig.trigger.scheduleDays': 'Pada hari (opsional — kosongkan untuk setiap hari)',
'flows.nodeConfig.trigger.scheduleAdvanced': 'Lanjutan (edit cron)',
'flows.nodeConfig.trigger.scheduleSimple': 'Kembali ke jadwal sederhana',
'flows.nodeConfig.trigger.pickApp': 'Pilih aplikasi yang terhubung terlebih dahulu.',
'flows.nodeConfig.tool.pickConnection': 'Pilih koneksi terlebih dahulu.',
'flows.nodeConfig.composio.noConnections':
'Belum ada aplikasi terhubung. Hubungkan satu di Pengaturan → Apps, lalu pilih di sini.',
'flows.nodeConfig.composio.selectApp': 'Pilih aplikasi…',
'flows.nodeConfig.composio.select': 'Pilih…',
'flows.nodeConfig.composio.loading': 'Memuat…',
'flows.nodeConfig.composio.custom': 'Masukkan manual…',
'flows.nodeConfig.composio.customPlaceholder': 'SLUG_NAME',
'flows.nodeConfig.agent.modelHint': 'Pilih tingkat kemampuan — workspace akan menentukan model.',
'flows.nodeConfig.agent.modelInherit': 'Default (warisi)',
'flows.nodeConfig.agent.modelHints': 'Petunjuk model',
'flows.nodeConfig.agent.modelCustom': 'Model kustom…',
'flows.nodeConfig.agent.modelCustomPlaceholder': 'mis. gpt-4o-mini',
'flows.nodeConfig.tool.requiredMark': 'wajib',
'flows.nodeConfig.tool.requiredMissing': 'Wajib — belum tersambung',
'flows.nodeConfig.tool.argsAdvancedLabel': 'Semua argumen (lanjutan)',
'flows.nodeConfig.upstream.insert': 'Sisipkan…',
'flows.nodeConfig.upstream.insertLabel': 'Sisipkan nilai dari langkah sebelumnya',
'flows.nodeConfig.native.toolLabel': 'Alat',
'flows.nodeConfig.native.toolHint': 'Salah satu alat bawaan asisten (pencarian, media, file, …).',
'flows.nodeConfig.native.select': 'Pilih alat…',
'flows.nodeConfig.native.loading': 'Memuat alat…',
'flows.nodeConfig.close': 'Tutup pengaturan',
'flows.nodeConfig.nameLabel': 'Nama',
'flows.nodeConfig.namePlaceholder': 'Nama node',
@@ -6834,6 +6884,28 @@ const messages: TranslationMap = {
// Code block chrome
'codeBlock.copy': 'Salin',
'codeBlock.copied': 'Disalin!',
'flows.editor.undo': 'Urungkan',
'flows.editor.redo': 'Ulangi',
'flows.editor.onboardingTitle': 'Bangun alur kerja Anda',
'flows.editor.onboardingBody':
'Tambahkan node dari palet di sebelah kiri, lalu seret di antara titik-titik pada setiap kartu untuk menghubungkannya.',
'flows.palette.search': 'Cari node…',
'flows.palette.noResults': 'Tidak ada node yang cocok',
'flows.palette.group.triggers': 'Pemicu',
'flows.palette.group.actions': 'Tindakan',
'flows.palette.group.logic': 'Logika',
'flows.list.duplicate': 'Duplikat',
'flows.list.duplicated': 'Alur kerja diduplikasi',
'flows.list.delete': 'Hapus',
'flows.list.deleted': 'Alur kerja dihapus',
'flows.list.moreActions': 'Tindakan lainnya',
'flows.delete.title': 'Hapus alur kerja?',
'flows.delete.body':
'"{name}" dan riwayat prosesnya akan dihapus secara permanen. Tindakan ini tidak dapat dibatalkan.',
'flows.delete.cancel': 'Batal',
'flows.delete.confirm': 'Hapus',
'flows.delete.deleting': 'Menghapus…',
'flows.canvas.renameLabel': 'Ganti nama alur kerja',
};
export default messages;
+76 -2
View File
@@ -3755,6 +3755,7 @@ const messages: TranslationMap = {
'flowRuns.status.completed': 'Completato',
'flowRuns.status.pending_approval': 'In attesa di approvazione',
'flowRuns.status.failed': 'Non riuscito',
'flowRuns.status.cancelled': 'Annullato',
'flows.page.title': 'Flussi di lavoro',
'flows.page.description': 'Automazioni salvate che puoi abilitare, eseguire e monitorare.',
@@ -3792,8 +3793,7 @@ const messages: TranslationMap = {
'flows.promptBar.error': 'Impossibile raggiungere il generatore di flussi. Riprova.',
'flows.promptBar.offline': 'Sei offline. Riconnettiti per creare un flusso.',
'flows.suggest.title': 'Consigliati per te',
'flows.suggest.subtitle':
'Automazioni che il Flow Scout ritiene utili, in base a come lavori.',
'flows.suggest.subtitle': 'Automazioni che il Flow Scout ritiene utili, in base a come lavori.',
'flows.suggest.discover': 'Scopri',
'flows.suggest.rediscover': 'Aggiorna',
'flows.suggest.discovering': 'Ricerca di automazioni…',
@@ -3876,6 +3876,58 @@ const messages: TranslationMap = {
'flows.editor.leaveStay': 'Resta',
'flows.editor.leaveDiscard': 'Esci',
// ── Node config drawer (issue B5b / Phase 3b) — per-kind config forms
'flowRuns.inspector.diagnosticsTitle': 'Avvisi espressione',
'flowRuns.inspector.diagnosticResolvedNull': 'risolto in null',
'flows.runs.sidebarTitle': 'Esecuzioni',
'flows.runs.refresh': 'Aggiorna esecuzioni',
'flows.palette.appAction': 'Azione app',
'flows.palette.ohTool': 'Strumento',
'flows.editor.deleteNode': 'Elimina',
'flows.nodeConfig.connections.title': 'Connessioni',
'flows.nodeConfig.connections.inputs': 'Input',
'flows.nodeConfig.connections.outputs': 'Output',
'flows.nodeConfig.connections.none': 'Non ancora connesso ad altri nodi.',
'flows.nodeConfig.connections.remove': 'Rimuovi connessione',
'flows.nodeConfig.trigger.scheduleCronLabel': 'Espressione cron',
'flows.nodeConfig.trigger.scheduleFreqLabel': 'Frequenza',
'flows.nodeConfig.trigger.scheduleFreq_minutes': 'Ogni N minuti',
'flows.nodeConfig.trigger.scheduleFreq_hours': 'Ogni N ore',
'flows.nodeConfig.trigger.scheduleFreq_daily': 'A un orario ogni giorno',
'flows.nodeConfig.trigger.scheduleEvery': 'ogni',
'flows.nodeConfig.trigger.scheduleInterval': 'Intervallo',
'flows.nodeConfig.trigger.scheduleUnit_minutes': 'min.',
'flows.nodeConfig.trigger.scheduleUnit_hours': 'h',
'flows.nodeConfig.trigger.scheduleAt': 'alle',
'flows.nodeConfig.trigger.scheduleTime': 'Ora del giorno',
'flows.nodeConfig.trigger.scheduleDays':
'Nei giorni (facoltativo — lascia vuoto per ogni giorno)',
'flows.nodeConfig.trigger.scheduleAdvanced': 'Avanzato (modifica cron)',
'flows.nodeConfig.trigger.scheduleSimple': 'Torna alla pianificazione semplice',
'flows.nodeConfig.trigger.pickApp': "Scegli prima un'app connessa.",
'flows.nodeConfig.tool.pickConnection': 'Scegli prima una connessione.',
'flows.nodeConfig.composio.noConnections':
'Nessuna app connessa. Collegane una in Impostazioni → Apps, poi sceglila qui.',
'flows.nodeConfig.composio.selectApp': "Seleziona un'app…",
'flows.nodeConfig.composio.select': 'Seleziona…',
'flows.nodeConfig.composio.loading': 'Caricamento…',
'flows.nodeConfig.composio.custom': 'Inserisci manualmente…',
'flows.nodeConfig.composio.customPlaceholder': 'SLUG_NAME',
'flows.nodeConfig.agent.modelHint':
'Scegli un livello di capacità — lo spazio di lavoro risolve il modello.',
'flows.nodeConfig.agent.modelInherit': 'Predefinito (eredita)',
'flows.nodeConfig.agent.modelHints': 'Suggerimenti modello',
'flows.nodeConfig.agent.modelCustom': 'Modello personalizzato…',
'flows.nodeConfig.agent.modelCustomPlaceholder': 'es. gpt-4o-mini',
'flows.nodeConfig.tool.requiredMark': 'obbligatorio',
'flows.nodeConfig.tool.requiredMissing': 'Obbligatorio — non collegato',
'flows.nodeConfig.tool.argsAdvancedLabel': 'Tutti gli argomenti (avanzato)',
'flows.nodeConfig.upstream.insert': 'Inserisci…',
'flows.nodeConfig.upstream.insertLabel': 'Inserisci un valore da un passaggio precedente',
'flows.nodeConfig.native.toolLabel': 'Strumento',
'flows.nodeConfig.native.toolHint':
"Uno degli strumenti integrati dell'assistente (ricerca, media, file, …).",
'flows.nodeConfig.native.select': 'Seleziona uno strumento…',
'flows.nodeConfig.native.loading': 'Caricamento strumenti…',
'flows.nodeConfig.close': 'Chiudi impostazioni',
'flows.nodeConfig.nameLabel': 'Nome',
'flows.nodeConfig.namePlaceholder': 'Nome del nodo',
@@ -6932,6 +6984,28 @@ const messages: TranslationMap = {
// Code block chrome
'codeBlock.copy': 'Copia',
'codeBlock.copied': 'Copiato!',
'flows.editor.undo': 'Annulla',
'flows.editor.redo': 'Ripristina',
'flows.editor.onboardingTitle': 'Crea il tuo flusso di lavoro',
'flows.editor.onboardingBody':
'Aggiungi un nodo dalla palette a sinistra, poi trascina tra i punti di ogni scheda per collegarli.',
'flows.palette.search': 'Cerca nodi…',
'flows.palette.noResults': 'Nessun nodo corrispondente',
'flows.palette.group.triggers': 'Trigger',
'flows.palette.group.actions': 'Azioni',
'flows.palette.group.logic': 'Logica',
'flows.list.duplicate': 'Duplica',
'flows.list.duplicated': 'Flusso di lavoro duplicato',
'flows.list.delete': 'Elimina',
'flows.list.deleted': 'Flusso di lavoro eliminato',
'flows.list.moreActions': 'Altre azioni',
'flows.delete.title': 'Eliminare il flusso di lavoro?',
'flows.delete.body':
'"{name}" e la relativa cronologia delle esecuzioni verranno rimossi definitivamente. Questa azione non può essere annullata.',
'flows.delete.cancel': 'Annulla',
'flows.delete.confirm': 'Elimina',
'flows.delete.deleting': 'Eliminazione…',
'flows.canvas.renameLabel': 'Rinomina flusso di lavoro',
};
export default messages;
+72
View File
@@ -3661,6 +3661,7 @@ const messages: TranslationMap = {
'flowRuns.status.completed': '완료됨',
'flowRuns.status.pending_approval': '승인 대기 중',
'flowRuns.status.failed': '실패',
'flowRuns.status.cancelled': '취소됨',
'flows.page.title': '워크플로',
'flows.page.description': '활성화, 실행, 모니터링할 수 있는 저장된 자동화입니다.',
@@ -3778,6 +3779,55 @@ const messages: TranslationMap = {
'flows.editor.leaveStay': '머무르기',
'flows.editor.leaveDiscard': '나가기',
// ── Node config drawer (issue B5b / Phase 3b) — per-kind config forms
'flowRuns.inspector.diagnosticsTitle': '표현식 경고',
'flowRuns.inspector.diagnosticResolvedNull': 'null로 확인됨',
'flows.runs.sidebarTitle': '실행',
'flows.runs.refresh': '실행 새로고침',
'flows.palette.appAction': '앱 작업',
'flows.palette.ohTool': '도구',
'flows.editor.deleteNode': '삭제',
'flows.nodeConfig.connections.title': '연결',
'flows.nodeConfig.connections.inputs': '입력',
'flows.nodeConfig.connections.outputs': '출력',
'flows.nodeConfig.connections.none': '아직 다른 노드에 연결되지 않았습니다.',
'flows.nodeConfig.connections.remove': '연결 제거',
'flows.nodeConfig.trigger.scheduleCronLabel': 'Cron 표현식',
'flows.nodeConfig.trigger.scheduleFreqLabel': '빈도',
'flows.nodeConfig.trigger.scheduleFreq_minutes': 'N분마다',
'flows.nodeConfig.trigger.scheduleFreq_hours': 'N시간마다',
'flows.nodeConfig.trigger.scheduleFreq_daily': '매일 지정한 시간에',
'flows.nodeConfig.trigger.scheduleEvery': '매',
'flows.nodeConfig.trigger.scheduleInterval': '간격',
'flows.nodeConfig.trigger.scheduleUnit_minutes': '분',
'flows.nodeConfig.trigger.scheduleUnit_hours': '시간',
'flows.nodeConfig.trigger.scheduleAt': '에',
'flows.nodeConfig.trigger.scheduleTime': '하루 중 시간',
'flows.nodeConfig.trigger.scheduleDays': '요일 (선택 사항 — 매일이면 비워 두세요)',
'flows.nodeConfig.trigger.scheduleAdvanced': '고급 (cron 편집)',
'flows.nodeConfig.trigger.scheduleSimple': '간단한 일정으로 돌아가기',
'flows.nodeConfig.trigger.pickApp': '먼저 연결된 앱을 선택하세요.',
'flows.nodeConfig.tool.pickConnection': '먼저 연결을 선택하세요.',
'flows.nodeConfig.composio.noConnections':
'아직 연결된 앱이 없습니다. 설정 → Apps에서 연결한 뒤 여기에서 선택하세요.',
'flows.nodeConfig.composio.selectApp': '앱 선택…',
'flows.nodeConfig.composio.select': '선택…',
'flows.nodeConfig.composio.loading': '불러오는 중…',
'flows.nodeConfig.composio.custom': '수동 입력…',
'flows.nodeConfig.composio.customPlaceholder': 'SLUG_NAME',
'flows.nodeConfig.agent.modelHint': '기능 등급을 선택하세요. 작업 공간이 모델을 결정합니다.',
'flows.nodeConfig.agent.modelInherit': '기본값 (상속)',
'flows.nodeConfig.agent.modelHints': '모델 힌트',
'flows.nodeConfig.agent.modelCustom': '사용자 지정 모델…',
'flows.nodeConfig.agent.modelCustomPlaceholder': '예: gpt-4o-mini',
'flows.nodeConfig.tool.requiredMark': '필수',
'flows.nodeConfig.tool.requiredMissing': '필수 — 연결되지 않음',
'flows.nodeConfig.tool.argsAdvancedLabel': '모든 인수 (고급)',
'flows.nodeConfig.upstream.insert': '삽입…',
'flows.nodeConfig.upstream.insertLabel': '이전 단계의 값 삽입',
'flows.nodeConfig.native.toolLabel': '도구',
'flows.nodeConfig.native.toolHint': '도우미의 내장 도구 중 하나입니다(검색, 미디어, 파일 등).',
'flows.nodeConfig.native.select': '도구 선택…',
'flows.nodeConfig.native.loading': '도구 불러오는 중…',
'flows.nodeConfig.close': '설정 닫기',
'flows.nodeConfig.nameLabel': '이름',
'flows.nodeConfig.namePlaceholder': '노드 이름',
@@ -6732,6 +6782,28 @@ const messages: TranslationMap = {
// Code block chrome
'codeBlock.copy': '복사',
'codeBlock.copied': '복사됨!',
'flows.editor.undo': '실행 취소',
'flows.editor.redo': '다시 실행',
'flows.editor.onboardingTitle': '워크플로 만들기',
'flows.editor.onboardingBody':
'왼쪽 팔레트에서 노드를 추가한 다음 각 카드의 점 사이를 드래그하여 연결하세요.',
'flows.palette.search': '노드 검색…',
'flows.palette.noResults': '일치하는 노드가 없습니다',
'flows.palette.group.triggers': '트리거',
'flows.palette.group.actions': '작업',
'flows.palette.group.logic': '로직',
'flows.list.duplicate': '복제',
'flows.list.duplicated': '워크플로를 복제했습니다',
'flows.list.delete': '삭제',
'flows.list.deleted': '워크플로를 삭제했습니다',
'flows.list.moreActions': '추가 작업',
'flows.delete.title': '워크플로를 삭제할까요?',
'flows.delete.body':
'"{name}" 및 해당 실행 기록이 영구적으로 삭제됩니다. 이 작업은 취소할 수 없습니다.',
'flows.delete.cancel': '취소',
'flows.delete.confirm': '삭제',
'flows.delete.deleting': '삭제 중…',
'flows.canvas.renameLabel': '워크플로 이름 바꾸기',
};
export default messages;
+73
View File
@@ -3741,6 +3741,7 @@ const messages: TranslationMap = {
'flowRuns.status.completed': 'Zakończono',
'flowRuns.status.pending_approval': 'Oczekuje na zatwierdzenie',
'flowRuns.status.failed': 'Niepowodzenie',
'flowRuns.status.cancelled': 'Anulowano',
'flows.page.title': 'Przepływy pracy',
'flows.page.description':
@@ -3865,6 +3866,56 @@ const messages: TranslationMap = {
'flows.editor.leaveStay': 'Zostań',
'flows.editor.leaveDiscard': 'Wyjdź',
// ── Node config drawer (issue B5b / Phase 3b) — per-kind config forms
'flowRuns.inspector.diagnosticsTitle': 'Ostrzeżenia wyrażeń',
'flowRuns.inspector.diagnosticResolvedNull': 'rozwiązano jako null',
'flows.runs.sidebarTitle': 'Uruchomienia',
'flows.runs.refresh': 'Odśwież uruchomienia',
'flows.palette.appAction': 'Akcja aplikacji',
'flows.palette.ohTool': 'Narzędzie',
'flows.editor.deleteNode': 'Usuń',
'flows.nodeConfig.connections.title': 'Połączenia',
'flows.nodeConfig.connections.inputs': 'Wejścia',
'flows.nodeConfig.connections.outputs': 'Wyjścia',
'flows.nodeConfig.connections.none': 'Nie połączono jeszcze z żadnym innym węzłem.',
'flows.nodeConfig.connections.remove': 'Usuń połączenie',
'flows.nodeConfig.trigger.scheduleCronLabel': 'Wyrażenie cron',
'flows.nodeConfig.trigger.scheduleFreqLabel': 'Częstotliwość',
'flows.nodeConfig.trigger.scheduleFreq_minutes': 'Co N minut',
'flows.nodeConfig.trigger.scheduleFreq_hours': 'Co N godzin',
'flows.nodeConfig.trigger.scheduleFreq_daily': 'O wybranej porze każdego dnia',
'flows.nodeConfig.trigger.scheduleEvery': 'co',
'flows.nodeConfig.trigger.scheduleInterval': 'Interwał',
'flows.nodeConfig.trigger.scheduleUnit_minutes': 'min',
'flows.nodeConfig.trigger.scheduleUnit_hours': 'godz.',
'flows.nodeConfig.trigger.scheduleAt': 'o',
'flows.nodeConfig.trigger.scheduleTime': 'Pora dnia',
'flows.nodeConfig.trigger.scheduleDays': 'W dni (opcjonalnie — pozostaw puste dla każdego dnia)',
'flows.nodeConfig.trigger.scheduleAdvanced': 'Zaawansowane (edytuj cron)',
'flows.nodeConfig.trigger.scheduleSimple': 'Wróć do prostego harmonogramu',
'flows.nodeConfig.trigger.pickApp': 'Najpierw wybierz połączoną aplikację.',
'flows.nodeConfig.tool.pickConnection': 'Najpierw wybierz połączenie.',
'flows.nodeConfig.composio.noConnections':
'Brak połączonych aplikacji. Połącz jedną w Ustawienia → Apps, a potem wybierz ją tutaj.',
'flows.nodeConfig.composio.selectApp': 'Wybierz aplikację…',
'flows.nodeConfig.composio.select': 'Wybierz…',
'flows.nodeConfig.composio.loading': 'Ładowanie…',
'flows.nodeConfig.composio.custom': 'Wprowadź ręcznie…',
'flows.nodeConfig.composio.customPlaceholder': 'SLUG_NAME',
'flows.nodeConfig.agent.modelHint': 'Wybierz poziom możliwości — obszar roboczy rozwiąże model.',
'flows.nodeConfig.agent.modelInherit': 'Domyślnie (dziedzicz)',
'flows.nodeConfig.agent.modelHints': 'Wskazówki modelu',
'flows.nodeConfig.agent.modelCustom': 'Model niestandardowy…',
'flows.nodeConfig.agent.modelCustomPlaceholder': 'np. gpt-4o-mini',
'flows.nodeConfig.tool.requiredMark': 'wymagane',
'flows.nodeConfig.tool.requiredMissing': 'Wymagane — niepodłączone',
'flows.nodeConfig.tool.argsAdvancedLabel': 'Wszystkie argumenty (zaawansowane)',
'flows.nodeConfig.upstream.insert': 'Wstaw…',
'flows.nodeConfig.upstream.insertLabel': 'Wstaw wartość z poprzedniego kroku',
'flows.nodeConfig.native.toolLabel': 'Narzędzie',
'flows.nodeConfig.native.toolHint':
'Jedno z wbudowanych narzędzi asystenta (wyszukiwanie, media, pliki, …).',
'flows.nodeConfig.native.select': 'Wybierz narzędzie…',
'flows.nodeConfig.native.loading': 'Ładowanie narzędzi…',
'flows.nodeConfig.close': 'Zamknij ustawienia',
'flows.nodeConfig.nameLabel': 'Nazwa',
'flows.nodeConfig.namePlaceholder': 'Nazwa węzła',
@@ -6908,6 +6959,28 @@ const messages: TranslationMap = {
// Code block chrome
'codeBlock.copy': 'Kopiuj',
'codeBlock.copied': 'Skopiowano!',
'flows.editor.undo': 'Cofnij',
'flows.editor.redo': 'Ponów',
'flows.editor.onboardingTitle': 'Zbuduj swój przepływ pracy',
'flows.editor.onboardingBody':
'Dodaj węzeł z palety po lewej stronie, a następnie przeciągnij między kropkami na każdej karcie, aby je połączyć.',
'flows.palette.search': 'Szukaj węzłów…',
'flows.palette.noResults': 'Brak pasujących węzłów',
'flows.palette.group.triggers': 'Wyzwalacze',
'flows.palette.group.actions': 'Akcje',
'flows.palette.group.logic': 'Logika',
'flows.list.duplicate': 'Duplikuj',
'flows.list.duplicated': 'Zduplikowano przepływ pracy',
'flows.list.delete': 'Usuń',
'flows.list.deleted': 'Usunięto przepływ pracy',
'flows.list.moreActions': 'Więcej akcji',
'flows.delete.title': 'Usunąć przepływ pracy?',
'flows.delete.body':
'„{name}” oraz jego historia uruchomień zostaną trwale usunięte. Tej operacji nie można cofnąć.',
'flows.delete.cancel': 'Anuluj',
'flows.delete.confirm': 'Usuń',
'flows.delete.deleting': 'Usuwanie…',
'flows.canvas.renameLabel': 'Zmień nazwę przepływu pracy',
};
export default messages;
+74
View File
@@ -3756,6 +3756,7 @@ const messages: TranslationMap = {
'flowRuns.status.completed': 'Concluído',
'flowRuns.status.pending_approval': 'Aguardando aprovação',
'flowRuns.status.failed': 'Falhou',
'flowRuns.status.cancelled': 'Cancelado',
'flows.page.title': 'Fluxos de trabalho',
'flows.page.description': 'Automações salvas que você pode habilitar, executar e monitorar.',
@@ -3877,6 +3878,57 @@ const messages: TranslationMap = {
'flows.editor.leaveStay': 'Ficar',
'flows.editor.leaveDiscard': 'Sair',
// ── Node config drawer (issue B5b / Phase 3b) — per-kind config forms
'flowRuns.inspector.diagnosticsTitle': 'Avisos de expressão',
'flowRuns.inspector.diagnosticResolvedNull': 'resolvido como null',
'flows.runs.sidebarTitle': 'Execuções',
'flows.runs.refresh': 'Atualizar execuções',
'flows.palette.appAction': 'Ação de app',
'flows.palette.ohTool': 'Ferramenta',
'flows.editor.deleteNode': 'Excluir',
'flows.nodeConfig.connections.title': 'Conexões',
'flows.nodeConfig.connections.inputs': 'Entradas',
'flows.nodeConfig.connections.outputs': 'Saídas',
'flows.nodeConfig.connections.none': 'Ainda não conectado a nenhum outro nó.',
'flows.nodeConfig.connections.remove': 'Remover conexão',
'flows.nodeConfig.trigger.scheduleCronLabel': 'Expressão cron',
'flows.nodeConfig.trigger.scheduleFreqLabel': 'Frequência',
'flows.nodeConfig.trigger.scheduleFreq_minutes': 'A cada N minutos',
'flows.nodeConfig.trigger.scheduleFreq_hours': 'A cada N horas',
'flows.nodeConfig.trigger.scheduleFreq_daily': 'Em um horário todos os dias',
'flows.nodeConfig.trigger.scheduleEvery': 'a cada',
'flows.nodeConfig.trigger.scheduleInterval': 'Intervalo',
'flows.nodeConfig.trigger.scheduleUnit_minutes': 'min.',
'flows.nodeConfig.trigger.scheduleUnit_hours': 'h',
'flows.nodeConfig.trigger.scheduleAt': 'às',
'flows.nodeConfig.trigger.scheduleTime': 'Hora do dia',
'flows.nodeConfig.trigger.scheduleDays': 'Nos dias (opcional — deixe vazio para todos os dias)',
'flows.nodeConfig.trigger.scheduleAdvanced': 'Avançado (editar cron)',
'flows.nodeConfig.trigger.scheduleSimple': 'Voltar ao agendamento simples',
'flows.nodeConfig.trigger.pickApp': 'Escolha primeiro um app conectado.',
'flows.nodeConfig.tool.pickConnection': 'Escolha primeiro uma conexão.',
'flows.nodeConfig.composio.noConnections':
'Ainda não há apps conectados. Conecte um em Configurações → Apps e escolha aqui.',
'flows.nodeConfig.composio.selectApp': 'Selecionar um app…',
'flows.nodeConfig.composio.select': 'Selecionar…',
'flows.nodeConfig.composio.loading': 'Carregando…',
'flows.nodeConfig.composio.custom': 'Inserir manualmente…',
'flows.nodeConfig.composio.customPlaceholder': 'SLUG_NAME',
'flows.nodeConfig.agent.modelHint':
'Escolha um nível de capacidade — o workspace resolve o modelo.',
'flows.nodeConfig.agent.modelInherit': 'Padrão (herdar)',
'flows.nodeConfig.agent.modelHints': 'Dicas de modelo',
'flows.nodeConfig.agent.modelCustom': 'Modelo personalizado…',
'flows.nodeConfig.agent.modelCustomPlaceholder': 'ex.: gpt-4o-mini',
'flows.nodeConfig.tool.requiredMark': 'obrigatório',
'flows.nodeConfig.tool.requiredMissing': 'Obrigatório — não conectado',
'flows.nodeConfig.tool.argsAdvancedLabel': 'Todos os argumentos (avançado)',
'flows.nodeConfig.upstream.insert': 'Inserir…',
'flows.nodeConfig.upstream.insertLabel': 'Inserir um valor de uma etapa anterior',
'flows.nodeConfig.native.toolLabel': 'Ferramenta',
'flows.nodeConfig.native.toolHint':
'Uma das ferramentas integradas do assistente (busca, mídia, arquivos, …).',
'flows.nodeConfig.native.select': 'Selecionar uma ferramenta…',
'flows.nodeConfig.native.loading': 'Carregando ferramentas…',
'flows.nodeConfig.close': 'Fechar configurações',
'flows.nodeConfig.nameLabel': 'Nome',
'flows.nodeConfig.namePlaceholder': 'Nome do nó',
@@ -6921,6 +6973,28 @@ const messages: TranslationMap = {
// Code block chrome
'codeBlock.copy': 'Copiar',
'codeBlock.copied': 'Copiado!',
'flows.editor.undo': 'Desfazer',
'flows.editor.redo': 'Refazer',
'flows.editor.onboardingTitle': 'Crie seu fluxo de trabalho',
'flows.editor.onboardingBody':
'Adicione um nó a partir da paleta à esquerda e, em seguida, arraste entre os pontos de cada cartão para conectá-los.',
'flows.palette.search': 'Pesquisar nós…',
'flows.palette.noResults': 'Nenhum nó correspondente',
'flows.palette.group.triggers': 'Gatilhos',
'flows.palette.group.actions': 'Ações',
'flows.palette.group.logic': 'Lógica',
'flows.list.duplicate': 'Duplicar',
'flows.list.duplicated': 'Fluxo de trabalho duplicado',
'flows.list.delete': 'Excluir',
'flows.list.deleted': 'Fluxo de trabalho excluído',
'flows.list.moreActions': 'Mais ações',
'flows.delete.title': 'Excluir fluxo de trabalho?',
'flows.delete.body':
'"{name}" e seu histórico de execuções serão removidos permanentemente. Isso não pode ser desfeito.',
'flows.delete.cancel': 'Cancelar',
'flows.delete.confirm': 'Excluir',
'flows.delete.deleting': 'Excluindo…',
'flows.canvas.renameLabel': 'Renomear fluxo de trabalho',
};
export default messages;
+75
View File
@@ -3730,6 +3730,7 @@ const messages: TranslationMap = {
'flowRuns.status.completed': 'Завершено',
'flowRuns.status.pending_approval': 'Ожидает подтверждения',
'flowRuns.status.failed': 'Не удалось',
'flowRuns.status.cancelled': 'Отменено',
'flows.page.title': 'Рабочие процессы',
'flows.page.description':
@@ -3854,6 +3855,58 @@ const messages: TranslationMap = {
'flows.editor.leaveStay': 'Остаться',
'flows.editor.leaveDiscard': 'Выйти',
// ── Node config drawer (issue B5b / Phase 3b) — per-kind config forms
'flowRuns.inspector.diagnosticsTitle': 'Предупреждения выражений',
'flowRuns.inspector.diagnosticResolvedNull': 'разрешилось в null',
'flows.runs.sidebarTitle': 'Запуски',
'flows.runs.refresh': 'Обновить запуски',
'flows.palette.appAction': 'Действие приложения',
'flows.palette.ohTool': 'Инструмент',
'flows.editor.deleteNode': 'Удалить',
'flows.nodeConfig.connections.title': 'Соединения',
'flows.nodeConfig.connections.inputs': 'Входы',
'flows.nodeConfig.connections.outputs': 'Выходы',
'flows.nodeConfig.connections.none': 'Пока не подключено ни к одному другому узлу.',
'flows.nodeConfig.connections.remove': 'Удалить соединение',
'flows.nodeConfig.trigger.scheduleCronLabel': 'Cron-выражение',
'flows.nodeConfig.trigger.scheduleFreqLabel': 'Частота',
'flows.nodeConfig.trigger.scheduleFreq_minutes': 'Каждые N минут',
'flows.nodeConfig.trigger.scheduleFreq_hours': 'Каждые N часов',
'flows.nodeConfig.trigger.scheduleFreq_daily': 'В заданное время каждый день',
'flows.nodeConfig.trigger.scheduleEvery': 'каждые',
'flows.nodeConfig.trigger.scheduleInterval': 'Интервал',
'flows.nodeConfig.trigger.scheduleUnit_minutes': 'мин',
'flows.nodeConfig.trigger.scheduleUnit_hours': 'ч',
'flows.nodeConfig.trigger.scheduleAt': 'в',
'flows.nodeConfig.trigger.scheduleTime': 'Время дня',
'flows.nodeConfig.trigger.scheduleDays':
'По дням (необязательно — оставьте пустым для каждого дня)',
'flows.nodeConfig.trigger.scheduleAdvanced': 'Расширенно (редактировать cron)',
'flows.nodeConfig.trigger.scheduleSimple': 'Вернуться к простому расписанию',
'flows.nodeConfig.trigger.pickApp': 'Сначала выберите подключенное приложение.',
'flows.nodeConfig.tool.pickConnection': 'Сначала выберите соединение.',
'flows.nodeConfig.composio.noConnections':
'Подключенных приложений пока нет. Подключите одно в Настройки → Apps, затем выберите его здесь.',
'flows.nodeConfig.composio.selectApp': 'Выберите приложение…',
'flows.nodeConfig.composio.select': 'Выбрать…',
'flows.nodeConfig.composio.loading': 'Загрузка…',
'flows.nodeConfig.composio.custom': 'Ввести вручную…',
'flows.nodeConfig.composio.customPlaceholder': 'SLUG_NAME',
'flows.nodeConfig.agent.modelHint':
'Выберите уровень возможностей — рабочая область определит модель.',
'flows.nodeConfig.agent.modelInherit': 'По умолчанию (наследовать)',
'flows.nodeConfig.agent.modelHints': 'Подсказки модели',
'flows.nodeConfig.agent.modelCustom': 'Пользовательская модель…',
'flows.nodeConfig.agent.modelCustomPlaceholder': 'например, gpt-4o-mini',
'flows.nodeConfig.tool.requiredMark': 'обязательно',
'flows.nodeConfig.tool.requiredMissing': 'Обязательно — не подключено',
'flows.nodeConfig.tool.argsAdvancedLabel': 'Все аргументы (расширенно)',
'flows.nodeConfig.upstream.insert': 'Вставить…',
'flows.nodeConfig.upstream.insertLabel': 'Вставить значение из предыдущего шага',
'flows.nodeConfig.native.toolLabel': 'Инструмент',
'flows.nodeConfig.native.toolHint':
'Один из встроенных инструментов ассистента (поиск, медиа, файлы, …).',
'flows.nodeConfig.native.select': 'Выберите инструмент…',
'flows.nodeConfig.native.loading': 'Загрузка инструментов…',
'flows.nodeConfig.close': 'Закрыть настройки',
'flows.nodeConfig.nameLabel': 'Имя',
'flows.nodeConfig.namePlaceholder': 'Имя узла',
@@ -6880,6 +6933,28 @@ const messages: TranslationMap = {
// Code block chrome
'codeBlock.copy': 'Копировать',
'codeBlock.copied': 'Скопировано!',
'flows.editor.undo': 'Отменить',
'flows.editor.redo': 'Повторить',
'flows.editor.onboardingTitle': 'Создайте свой рабочий процесс',
'flows.editor.onboardingBody':
'Добавьте узел из палитры слева, затем перетащите линию между точками на карточках, чтобы соединить их.',
'flows.palette.search': 'Поиск узлов…',
'flows.palette.noResults': 'Нет подходящих узлов',
'flows.palette.group.triggers': 'Триггеры',
'flows.palette.group.actions': 'Действия',
'flows.palette.group.logic': 'Логика',
'flows.list.duplicate': 'Дублировать',
'flows.list.duplicated': 'Рабочий процесс продублирован',
'flows.list.delete': 'Удалить',
'flows.list.deleted': 'Рабочий процесс удалён',
'flows.list.moreActions': 'Дополнительные действия',
'flows.delete.title': 'Удалить рабочий процесс?',
'flows.delete.body':
'«{name}» и история его запусков будут удалены навсегда. Это действие нельзя отменить.',
'flows.delete.cancel': 'Отмена',
'flows.delete.confirm': 'Удалить',
'flows.delete.deleting': 'Удаление…',
'flows.canvas.renameLabel': 'Переименовать рабочий процесс',
};
export default messages;
+70
View File
@@ -3504,6 +3504,7 @@ const messages: TranslationMap = {
'flowRuns.status.completed': '已完成',
'flowRuns.status.pending_approval': '等待批准',
'flowRuns.status.failed': '失败',
'flowRuns.status.cancelled': '已取消',
'flows.page.title': '工作流',
'flows.page.description': '已保存的自动化流程,可启用、运行并监控。',
@@ -3618,6 +3619,55 @@ const messages: TranslationMap = {
'flows.editor.leaveStay': '留下',
'flows.editor.leaveDiscard': '离开',
// ── Node config drawer (issue B5b / Phase 3b) — per-kind config forms
'flowRuns.inspector.diagnosticsTitle': '表达式警告',
'flowRuns.inspector.diagnosticResolvedNull': '解析为 null',
'flows.runs.sidebarTitle': '运行记录',
'flows.runs.refresh': '刷新运行记录',
'flows.palette.appAction': '应用操作',
'flows.palette.ohTool': '工具',
'flows.editor.deleteNode': '删除',
'flows.nodeConfig.connections.title': '连接',
'flows.nodeConfig.connections.inputs': '输入',
'flows.nodeConfig.connections.outputs': '输出',
'flows.nodeConfig.connections.none': '尚未连接到任何其他节点。',
'flows.nodeConfig.connections.remove': '移除连接',
'flows.nodeConfig.trigger.scheduleCronLabel': 'Cron 表达式',
'flows.nodeConfig.trigger.scheduleFreqLabel': '频率',
'flows.nodeConfig.trigger.scheduleFreq_minutes': '每 N 分钟',
'flows.nodeConfig.trigger.scheduleFreq_hours': '每 N 小时',
'flows.nodeConfig.trigger.scheduleFreq_daily': '每天指定时间',
'flows.nodeConfig.trigger.scheduleEvery': '每',
'flows.nodeConfig.trigger.scheduleInterval': '间隔',
'flows.nodeConfig.trigger.scheduleUnit_minutes': '分钟',
'flows.nodeConfig.trigger.scheduleUnit_hours': '小时',
'flows.nodeConfig.trigger.scheduleAt': '在',
'flows.nodeConfig.trigger.scheduleTime': '一天中的时间',
'flows.nodeConfig.trigger.scheduleDays': '在这些天(可选,留空表示每天)',
'flows.nodeConfig.trigger.scheduleAdvanced': '高级(编辑 cron',
'flows.nodeConfig.trigger.scheduleSimple': '返回简单计划',
'flows.nodeConfig.trigger.pickApp': '请先选择一个已连接的应用。',
'flows.nodeConfig.tool.pickConnection': '请先选择一个连接。',
'flows.nodeConfig.composio.noConnections':
'还没有已连接的应用。请在“设置 → 应用”中连接一个,然后在这里选择。',
'flows.nodeConfig.composio.selectApp': '选择应用…',
'flows.nodeConfig.composio.select': '选择…',
'flows.nodeConfig.composio.loading': '正在加载…',
'flows.nodeConfig.composio.custom': '手动输入…',
'flows.nodeConfig.composio.customPlaceholder': 'SLUG_NAME',
'flows.nodeConfig.agent.modelHint': '选择能力层级,工作区会解析具体模型。',
'flows.nodeConfig.agent.modelInherit': '默认(继承)',
'flows.nodeConfig.agent.modelHints': '模型提示',
'flows.nodeConfig.agent.modelCustom': '自定义模型…',
'flows.nodeConfig.agent.modelCustomPlaceholder': '例如 gpt-4o-mini',
'flows.nodeConfig.tool.requiredMark': '必填',
'flows.nodeConfig.tool.requiredMissing': '必填 — 尚未接线',
'flows.nodeConfig.tool.argsAdvancedLabel': '所有参数(高级)',
'flows.nodeConfig.upstream.insert': '插入…',
'flows.nodeConfig.upstream.insertLabel': '插入上一步的值',
'flows.nodeConfig.native.toolLabel': '工具',
'flows.nodeConfig.native.toolHint': '助手内置工具之一(搜索、媒体、文件等)。',
'flows.nodeConfig.native.select': '选择工具…',
'flows.nodeConfig.native.loading': '正在加载工具…',
'flows.nodeConfig.close': '关闭设置',
'flows.nodeConfig.nameLabel': '名称',
'flows.nodeConfig.namePlaceholder': '节点名称',
@@ -6438,6 +6488,26 @@ const messages: TranslationMap = {
// Code block chrome
'codeBlock.copy': '复制',
'codeBlock.copied': '已复制!',
'flows.editor.undo': '撤销',
'flows.editor.redo': '重做',
'flows.editor.onboardingTitle': '构建你的工作流',
'flows.editor.onboardingBody': '从左侧面板添加节点,然后在各卡片的圆点之间拖动以连接它们。',
'flows.palette.search': '搜索节点…',
'flows.palette.noResults': '没有匹配的节点',
'flows.palette.group.triggers': '触发器',
'flows.palette.group.actions': '操作',
'flows.palette.group.logic': '逻辑',
'flows.list.duplicate': '复制',
'flows.list.duplicated': '工作流已复制',
'flows.list.delete': '删除',
'flows.list.deleted': '工作流已删除',
'flows.list.moreActions': '更多操作',
'flows.delete.title': '删除工作流?',
'flows.delete.body': '“{name}”及其运行历史将被永久删除。此操作无法撤销。',
'flows.delete.cancel': '取消',
'flows.delete.confirm': '删除',
'flows.delete.deleting': '正在删除…',
'flows.canvas.renameLabel': '重命名工作流',
};
export default messages;
+89 -2
View File
@@ -22,9 +22,15 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useLocation, useNavigate, useParams } from 'react-router-dom';
import FlowCanvas from '../components/flows/canvas/FlowCanvas';
import FlowRunsSidebar from '../components/flows/FlowRunsSidebar';
import WorkflowCopilotPanel from '../components/flows/WorkflowCopilotPanel';
import {
getCopilotThreadId,
setCopilotThreadId as setCopilotThreadIdCache,
} from '../components/flows/workflowCopilotThreads';
import { ToastContainer } from '../components/intelligence/Toast';
import PanelPage from '../components/layout/PanelPage';
import { SidebarContent } from '../components/layout/shell/SidebarSlot';
import Button from '../components/ui/Button';
import { CenteredLoadingState, ErrorBanner } from '../components/ui/LoadingState';
import { asFlowCanvasDraftState } from '../lib/flows/canvasDraft';
@@ -123,11 +129,45 @@ function FlowEditor({
const [running, setRunning] = useState(false);
const [runError, setRunError] = useState<string | null>(null);
const { flowId, name, graph, requireApproval } = editorFlow;
const { flowId, graph, requireApproval } = editorFlow;
// Draft (unsaved) canvases have no persisted id yet; Save creates the flow
// rather than updating one, and there is nothing runnable to run.
const isDraft = flowId === null;
// Editable flow name. `name` is the committed value (used by Save + the run
// header); `titleDraft` is the in-progress input buffer. Renaming a persisted
// flow is metadata-only (`flows_update({ name })`) — it never touches the
// graph, so it can't fire a schedule, and is safe to persist on blur/Enter
// without the graph's explicit-Save gate. A draft just updates locally; the
// name rides into `flows_create` when the draft is first Saved.
const [name, setName] = useState(editorFlow.name);
const [titleDraft, setTitleDraft] = useState(editorFlow.name);
const [renaming, setRenaming] = useState(false);
const commitRename = useCallback(async () => {
const trimmed = titleDraft.trim();
if (!trimmed || trimmed === name) {
setTitleDraft(name);
return;
}
if (isDraft) {
log('rename (draft): %s', trimmed);
setName(trimmed);
return;
}
setRenaming(true);
try {
log('rename: flow id=%s name=%s', flowId, trimmed);
await updateFlow(flowId, { name: trimmed });
setName(trimmed);
} catch (err) {
log('rename failed: id=%s err=%o', flowId, err);
setTitleDraft(name);
} finally {
setRenaming(false);
}
}, [titleDraft, name, isDraft, flowId]);
// ── Canvas copilot + draft overlay (Phase 5c) ─────────────────────────────
// `draftGraph` is the current ACCEPTED draft (starts as the loaded graph),
// kept in sync with manual canvas edits via `onGraphChange`. A copilot
@@ -136,6 +176,19 @@ function FlowEditor({
// commits the proposed graph into `draftGraph`; Reject reverts to the frozen
// base. NOTHING here persists — the canvas's own Save is the only gate.
const [copilotOpen, setCopilotOpen] = useState(initialCopilotSeed !== null);
// Per-workflow copilot thread: seeded from the session cache so opening/closing
// the panel (or switching flows and back) resumes the same conversation
// instead of starting a fresh `workflow_builder` thread each time.
const [copilotThreadId, setCopilotThreadId] = useState<string | null>(() =>
getCopilotThreadId(flowId)
);
const handleCopilotThreadId = useCallback(
(id: string | null) => {
setCopilotThreadId(id);
setCopilotThreadIdCache(flowId, id);
},
[flowId]
);
const [draftGraph, setDraftGraph] = useState<WorkflowGraph>(graph);
const [preview, setPreview] = useState<{
proposal: WorkflowProposal;
@@ -346,13 +399,44 @@ function FlowEditor({
</div>
);
// Editable title: an unstyled input that reads as the page heading until
// focused, so renaming is discoverable without a separate edit affordance.
const titleNode = (
<input
type="text"
value={titleDraft}
disabled={renaming}
data-testid="flow-canvas-title"
aria-label={t('flows.canvas.renameLabel')}
onChange={e => setTitleDraft(e.target.value)}
onBlur={() => void commitRename()}
onKeyDown={e => {
if (e.key === 'Enter') {
e.preventDefault();
e.currentTarget.blur();
} else if (e.key === 'Escape') {
setTitleDraft(name);
e.currentTarget.blur();
}
}}
className="w-full max-w-md truncate rounded-md border border-transparent bg-transparent px-1 py-0.5 text-base font-semibold text-content hover:border-line focus:border-primary-400 focus:outline-none disabled:opacity-60"
/>
);
return (
<PanelPage
testId="flow-canvas-page"
title={name}
title={titleNode}
leading={backButton}
action={headerActions}
contentClassName="h-full p-0">
{/* Project this flow's run history into the left shell sidebar while it's
open (persisted flows only — a draft has no runs yet). */}
{!isDraft && flowId && (
<SidebarContent>
<FlowRunsSidebar flowId={flowId} />
</SidebarContent>
)}
<div className="flex h-full w-full">
<div className="relative h-full flex-1">
<FlowCanvas
@@ -421,11 +505,14 @@ function FlowEditor({
{copilotOpen && (
<WorkflowCopilotPanel
graph={preview?.base ?? draftGraph}
flowId={flowId}
onProposal={handleProposal}
onAccept={handleAcceptProposal}
onReject={handleRejectProposal}
onClose={() => setCopilotOpen(false)}
repairSeed={copilotRepairSeed}
seedThreadId={copilotThreadId}
onThreadIdChange={handleCopilotThreadId}
/>
)}
</div>
+39 -8
View File
@@ -23,6 +23,8 @@ const runFlow = vi.hoisted(() => vi.fn());
const listFlowRuns = vi.hoisted(() => vi.fn());
const createFlow = vi.hoisted(() => vi.fn());
const importFlow = vi.hoisted(() => vi.fn());
const deleteFlow = vi.hoisted(() => vi.fn());
const duplicateFlow = vi.hoisted(() => vi.fn());
// Flow Scout discovery clients — rendered via the SuggestedWorkflows section.
const discoverWorkflows = vi.hoisted(() => vi.fn());
const listSuggestions = vi.hoisted(() => vi.fn());
@@ -35,6 +37,8 @@ vi.mock('../services/api/flowsApi', () => ({
listFlowRuns,
createFlow,
importFlow,
deleteFlow,
duplicateFlow,
discoverWorkflows,
listSuggestions,
dismissSuggestion,
@@ -198,18 +202,17 @@ describe('FlowsPage', () => {
expect(screen.getByTestId('new-workflow-modal')).toBeInTheDocument();
});
it('"describe it" in the chooser focuses the in-place prompt bar (no Chat hand-off)', async () => {
it('always shows the in-place prompt bar and the chooser no longer duplicates it', async () => {
listFlows.mockResolvedValue([makeFlow()]);
renderWithProviders(<FlowsPage />);
fireEvent.click(await screen.findByTestId('flows-new-workflow'));
fireEvent.click(screen.getByTestId('new-workflow-describe'));
// The prompt bar is the single "describe a workflow" entry point.
expect(await screen.findByTestId('workflow-prompt-bar')).toBeInTheDocument();
// Phase 5c: no more /chat hand-off — the chooser closes and the prompt bar
// (already rendered at the top of the page) takes focus for authoring.
expect(mockNavigate).not.toHaveBeenCalledWith('/chat');
expect(screen.getByTestId('workflow-prompt-bar')).toBeInTheDocument();
expect(screen.getByTestId('workflow-prompt-input')).toHaveFocus();
// The chooser modal offers scratch + template only — no redundant describe.
fireEvent.click(await screen.findByTestId('flows-new-workflow'));
expect(screen.getByTestId('new-workflow-scratch')).toBeInTheDocument();
expect(screen.queryByTestId('new-workflow-describe')).not.toBeInTheDocument();
});
it('empty-state template gallery creates a flow and opens its canvas', async () => {
@@ -238,11 +241,39 @@ describe('FlowsPage', () => {
listFlows.mockResolvedValue([makeFlow({ graph: { nodes: [], edges: [] } })]);
renderWithProviders(<FlowsPage />);
// Export now lives behind the row's "⋯" overflow menu.
fireEvent.click(await screen.findByTestId('flow-menu-flow-1'));
fireEvent.click(await screen.findByTestId('flow-export-flow-1'));
expect(downloadFlowGraph).toHaveBeenCalledWith('Daily digest', { nodes: [], edges: [] });
});
it('deletes a flow via the overflow menu + confirm dialog', async () => {
listFlows.mockResolvedValueOnce([makeFlow()]).mockResolvedValueOnce([]);
deleteFlow.mockResolvedValue('flow-1');
renderWithProviders(<FlowsPage />);
fireEvent.click(await screen.findByTestId('flow-menu-flow-1'));
fireEvent.click(await screen.findByTestId('flow-delete-flow-1'));
// Confirm dialog gates the destructive call.
expect(deleteFlow).not.toHaveBeenCalled();
fireEvent.click(await screen.findByTestId('flow-delete-confirm-button'));
await waitFor(() => expect(deleteFlow).toHaveBeenCalledWith('flow-1'));
});
it('duplicates a flow via the overflow menu', async () => {
listFlows.mockResolvedValue([makeFlow()]);
duplicateFlow.mockResolvedValue(makeFlow({ id: 'flow-2', name: 'Daily digest copy' }));
renderWithProviders(<FlowsPage />);
fireEvent.click(await screen.findByTestId('flow-menu-flow-1'));
fireEvent.click(await screen.findByTestId('flow-duplicate-flow-1'));
await waitFor(() => expect(duplicateFlow).toHaveBeenCalledWith('flow-1'));
});
it('imports a picked JSON file and opens the result as a draft canvas', async () => {
listFlows.mockResolvedValue([]);
const graph = { schema_version: 1, name: 'Imported', nodes: [], edges: [] };
+80 -24
View File
@@ -26,12 +26,15 @@ import { ToastContainer } from '../components/intelligence/Toast';
import PanelPage from '../components/layout/PanelPage';
import Button from '../components/ui/Button';
import { CenteredLoadingState, ErrorBanner } from '../components/ui/LoadingState';
import { ModalShell } from '../components/ui/ModalShell';
import { FLOW_CANVAS_DRAFT_ROUTE, type FlowCanvasDraftState } from '../lib/flows/canvasDraft';
import { downloadFlowGraph } from '../lib/flows/exportFlow';
import { type FlowTemplate, templateNameKey } from '../lib/flows/templates';
import type { WorkflowGraph } from '../lib/flows/types';
import { useT } from '../lib/i18n/I18nContext';
import {
deleteFlow,
duplicateFlow,
type Flow,
importFlow,
listFlows,
@@ -63,12 +66,13 @@ export default function FlowsPage() {
const [selectedFlowId, setSelectedFlowId] = useState<string | null>(null);
// Whether the Phase 4a "New workflow" chooser modal is open.
const [chooserOpen, setChooserOpen] = useState(false);
// Bumped by the chooser's "Describe it" action so the prompt bar remounts and
// takes focus (Phase 5c). Starts at 0 (no autofocus on initial page load).
const [describeNonce, setDescribeNonce] = useState(0);
// Create-and-open logic for the empty-state inline template gallery. (The
// chooser modal owns its own `useCreateFlow` instance.)
const emptyCreate = useCreateFlow();
// Flow queued for deletion behind the confirm dialog (`null` = closed), plus
// an in-flight flag so the confirm button can show progress + block re-entry.
const [deleteTarget, setDeleteTarget] = useState<Flow | null>(null);
const [deleting, setDeleting] = useState(false);
const addToast = useCallback((toast: Omit<ToastNotification, 'id'>) => {
setToasts(prev => [...prev, { ...toast, id: `toast-${Date.now()}-${Math.random()}` }]);
@@ -201,6 +205,43 @@ export default function FlowsPage() {
[addToast, t]
);
/** Duplicate a flow (server creates a disabled copy), then refresh the list. */
const handleDuplicate = useCallback(
async (flow: Flow) => {
log('duplicate: id=%s', flow.id);
setError(null);
try {
const copy = await duplicateFlow(flow.id);
addToast({ type: 'success', title: t('flows.list.duplicated') });
log('duplicate: created id=%s', copy.id);
await loadFlows();
} catch (err) {
log('duplicate failed: id=%s err=%o', flow.id, err);
setError(errorMessage(err));
}
},
[addToast, loadFlows, t]
);
/** Confirm-gated delete: the row's Delete opens the dialog; this commits it. */
const handleConfirmDelete = useCallback(async () => {
if (!deleteTarget) return;
setDeleting(true);
setError(null);
log('delete: confirming id=%s', deleteTarget.id);
try {
await deleteFlow(deleteTarget.id);
addToast({ type: 'success', title: t('flows.list.deleted') });
setDeleteTarget(null);
await loadFlows();
} catch (err) {
log('delete failed: id=%s err=%o', deleteTarget.id, err);
setError(errorMessage(err));
} finally {
setDeleting(false);
}
}, [deleteTarget, addToast, loadFlows, t]);
// Hidden file input backing the header "Import" action. Clicking the button
// opens the OS file picker; the change handler reads + imports the file.
const importInputRef = useRef<HTMLInputElement | null>(null);
@@ -258,18 +299,6 @@ export default function FlowsPage() {
setChooserOpen(true);
}, []);
/**
* "Describe it" hand-off (Phase 5c): rather than punting to Chat, focus the
* in-place prompt bar at the top of this page — it spawns a `workflow_builder`
* turn in a dedicated thread and renders the proposal inline. Bumping the
* nonce remounts the bar so it takes focus even though it's already visible.
*/
const handleDescribe = useCallback(() => {
log('new workflow: describe — focusing the prompt bar');
setChooserOpen(false);
setDescribeNonce(n => n + 1);
}, []);
/** Create a flow from an empty-state gallery card and open its canvas. */
const handleEmptyTemplate = useCallback(
(template: FlowTemplate) => {
@@ -315,13 +344,9 @@ export default function FlowsPage() {
<div className="mx-auto w-full max-w-3xl space-y-4">
{/* Prompt-first authoring (Phase 5c): describe a workflow and let the
builder agent propose it. Hero presentation when the list is empty,
compact otherwise. Keyed by `describeNonce` so the chooser's
"Describe it" action remounts + focuses it. */}
<WorkflowPromptBar
key={`prompt-bar-${describeNonce}`}
variant={!loading && flows.length === 0 ? 'hero' : 'compact'}
autoFocus={describeNonce > 0}
/>
compact otherwise. Always visible, so it's the single "describe a
workflow" entry point (the chooser modal no longer duplicates it). */}
<WorkflowPromptBar variant={!loading && flows.length === 0 ? 'hero' : 'compact'} />
{/* Flow Scout discovery: proactive, buildable workflow suggestions
grounded in how the user works. Read-only until they click "Build
@@ -388,6 +413,8 @@ export default function FlowsPage() {
onViewRuns={handleViewRuns}
onView={handleView}
onExport={handleExport}
onDuplicate={f => void handleDuplicate(f)}
onDelete={setDeleteTarget}
/>
))}
</div>
@@ -401,8 +428,37 @@ export default function FlowsPage() {
onFixWithAgent={handleFixWithAgent}
/>
{chooserOpen && (
<NewWorkflowModal onClose={() => setChooserOpen(false)} onDescribe={handleDescribe} />
{chooserOpen && <NewWorkflowModal onClose={() => setChooserOpen(false)} />}
{deleteTarget && (
<ModalShell
onClose={() => (deleting ? undefined : setDeleteTarget(null))}
title={t('flows.delete.title')}
subtitle={t('flows.delete.body').replace('{name}', deleteTarget.name)}
titleId="flow-delete-modal-title"
maxWidthClassName="max-w-sm">
<div className="flex justify-end gap-2" data-testid="flow-delete-confirm">
<Button
type="button"
variant="secondary"
size="sm"
disabled={deleting}
data-testid="flow-delete-cancel"
onClick={() => setDeleteTarget(null)}>
{t('flows.delete.cancel')}
</Button>
<Button
type="button"
variant="primary"
tone="danger"
size="sm"
disabled={deleting}
data-testid="flow-delete-confirm-button"
onClick={() => void handleConfirmDelete()}>
{deleting ? t('flows.delete.deleting') : t('flows.delete.confirm')}
</Button>
</div>
</ModalShell>
)}
<ToastContainer notifications={toasts} onRemove={removeToast} />
@@ -91,7 +91,21 @@ describe('FlowCanvasPage', () => {
await waitFor(() => expect(screen.getByTestId('flow-canvas')).toBeInTheDocument());
expect(getFlow).toHaveBeenCalledWith('test-id');
expect(screen.getByText('Daily digest')).toBeInTheDocument();
expect(screen.getByTestId('flow-canvas-title')).toHaveValue('Daily digest');
});
it('renames a persisted flow via the editable title (metadata-only update)', async () => {
getFlow.mockResolvedValue(makeFlow());
updateFlow.mockResolvedValue(makeFlow({ name: 'Renamed' }));
renderAtFlowId('test-id');
const title = await screen.findByTestId('flow-canvas-title');
fireEvent.change(title, { target: { value: 'Renamed' } });
fireEvent.blur(title);
await waitFor(() => expect(updateFlow).toHaveBeenCalledWith('test-id', { name: 'Renamed' }));
// Name-only update — no graph in the payload, so it can't fire a schedule.
expect(updateFlow.mock.calls[0][1]).not.toHaveProperty('graph');
});
it('shows a not-found state when the flow does not exist', async () => {
@@ -128,7 +142,7 @@ describe('FlowCanvasPage', () => {
// Navigate away before the old id's fetch resolves.
router.navigate('/flows/new-id');
await waitFor(() => expect(screen.getByText('New flow')).toBeInTheDocument());
await waitFor(() => expect(screen.getByTestId('flow-canvas-title')).toHaveValue('New flow'));
// Now let the stale old-id fetch resolve — it must not clobber the
// already-rendered new-id state.
@@ -136,8 +150,8 @@ describe('FlowCanvasPage', () => {
await Promise.resolve();
await Promise.resolve();
expect(screen.getByText('New flow')).toBeInTheDocument();
expect(screen.queryByText('Old flow (stale)')).not.toBeInTheDocument();
expect(screen.getByTestId('flow-canvas-title')).toHaveValue('New flow');
expect(screen.queryByDisplayValue('Old flow (stale)')).not.toBeInTheDocument();
});
function renderEditor(id = 'test-id') {
@@ -231,7 +245,7 @@ describe('FlowCanvasPage', () => {
renderDraft({ name: 'Proposed flow', graph: draftGraph, requireApproval: true });
await waitFor(() => expect(screen.getByTestId('flow-canvas')).toBeInTheDocument());
expect(screen.getByText('Proposed flow')).toBeInTheDocument();
expect(screen.getByTestId('flow-canvas-title')).toHaveValue('Proposed flow');
// A draft is not fetched, is not runnable, and has persisted nothing.
expect(getFlow).not.toHaveBeenCalled();
expect(createFlow).not.toHaveBeenCalled();
+39
View File
@@ -55,6 +55,11 @@ export interface FlowRunStep {
output: unknown;
/** Output port the node routed on, if any (branching/switch nodes). Omitted when absent. */
port?: string;
/**
* Config `=`-expressions that resolved to `null` while running this step
* (`location` is the config path, e.g. `args.to`). Empty/absent when clean.
*/
diagnostics?: Array<{ location: string; expression: string }>;
}
/** A persisted flow run record (`src/openhuman/flows/types.rs::FlowRun`). */
@@ -380,6 +385,38 @@ export async function runFlow(id: string, input?: unknown): Promise<FlowResumeRe
return result;
}
/**
* Permanently delete a saved flow via `openhuman.flows_delete`. The server
* unbinds any live trigger (schedule cron job / app-event binding) before
* removing the row, so deleting an enabled flow also stops it firing. Returns
* the removed id (the payload is `{ id, removed: true }`); callers typically
* just refetch the list.
*/
export async function deleteFlow(id: string): Promise<string> {
log('deleteFlow: request id=%s', id);
const response = await callCoreRpc<unknown>({ method: 'openhuman.flows_delete', params: { id } });
const payload = unwrapCliEnvelope<{ id: string; removed: boolean }>(response);
log('deleteFlow: response id=%s removed=%s', payload.id, payload.removed);
return payload.id;
}
/**
* Duplicate a saved flow via `openhuman.flows_duplicate`. The copy is created
* DISABLED and unbound (no live trigger), with a derived name, so duplicating an
* enabled flow never silently starts a second live schedule. Returns the new
* `Flow` row.
*/
export async function duplicateFlow(id: string): Promise<Flow> {
log('duplicateFlow: request id=%s', id);
const response = await callCoreRpc<unknown>({
method: 'openhuman.flows_duplicate',
params: { id },
});
const flow = unwrapCliEnvelope<Flow>(response);
log('duplicateFlow: response newId=%s name=%s', flow.id, flow.name);
return flow;
}
/**
* Update a saved flow's name and/or graph via `openhuman.flows_update` (the
* Workflow Canvas Save path, B5b.2 / Phase 3d). The server re-validates the
@@ -557,6 +594,8 @@ export const flowsApi = {
setFlowEnabled,
runFlow,
updateFlow,
deleteFlow,
duplicateFlow,
validateFlow,
listFlowConnections,
};
+58
View File
@@ -0,0 +1,58 @@
/**
* Frontend client for the agent's native tool registry, exposed over
* `openhuman.javascript_list_tools` (the same registry the assistant uses). The
* flows "Tool" node (native OpenHuman tools, as opposed to the Composio "App
* action" node) uses this to offer a dropdown of real tool names + descriptions.
*/
import debug from 'debug';
import { callCoreRpc } from '../coreRpcClient';
const log = debug('runtimeToolsApi');
/** One agent-callable tool (`RuntimeToolSummary` on the Rust side). */
export interface RuntimeTool {
name: string;
description: string;
category: string;
permission_level: string;
scope: string;
supports_markdown: boolean;
/** JSON-schema-ish parameters object for the tool's args. */
parameters: unknown;
}
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
/** Peel the `{ result, logs }` CLI envelope, like `flowsApi` does. */
function unwrapCliEnvelope<T>(payload: unknown): T {
const record = asRecord(payload);
if (record && 'result' in record && 'logs' in record && Array.isArray(record.logs)) {
return record.result as T;
}
return payload as T;
}
/**
* List the native agent tools available to a flow's "Tool" node. The payload is
* `{ tools: RuntimeTool[] }` (or a bare array on some cores); both are handled.
*/
export async function listRuntimeTools(): Promise<RuntimeTool[]> {
log('listRuntimeTools: request');
const response = await callCoreRpc<unknown>({
method: 'openhuman.javascript_list_tools',
params: {},
});
const payload = unwrapCliEnvelope<unknown>(response);
const record = asRecord(payload);
const tools = Array.isArray(payload)
? (payload as RuntimeTool[])
: record && Array.isArray(record.tools)
? (record.tools as RuntimeTool[])
: [];
log('listRuntimeTools: response count=%d', tools.length);
return tools;
}
@@ -0,0 +1,171 @@
# tinyflows — Node I/O Alignment & Selectable Agent Kinds (POA)
> **Status**: proposed · **Date**: 2026-07-05
> **Scope**: (A) fix the node input/output contract so producer/consumer shapes line up (agent ↔ tool*call ↔ merge), and (B) let a workflow pick \_which kind of agent* an `agent` node runs (coding agent, researcher, crypto agent, …) so each brings its own curated tool access.
> **Companion**: extends `docs/plans/tinyflows-integration/README.md`; audit source `vendor/tinyflows/docs/AUDIT-n8n-gap.md` (§3 bugs, §4b I/O alignment).
> **Reference product**: [n8n](https://github.com/n8n-io/n8n) — declared per-node output schemas, per-item execution, AI Agent node with pluggable tool/model sub-nodes.
---
## 0. Why this plan
Two problems surfaced in the audit that share a root cause — **nodes have no declared I/O contract**, so what one node emits and what the next reads are only accidentally aligned:
1. **Shape drift.** Every capability node wraps its host capability's raw return verbatim into `Item.json` (`agent.rs:115`, `tool_call.rs:31`, `http_request.rs:24`). The `agent` node emits three different shapes depending on sub-ports, and its plain shape flips between "parsed model JSON" and `{text}` at runtime (`caps.rs:359,369`). Downstream `=item.<field>` expressions therefore guess.
2. **No agent identity.** The `agent` node is a single bare `provider.chat` call with a free-form inline `tools` list (`caps.rs` `OpenHumanLlm::complete`, "no agent loop is driven here"). There is no way to say "run this step as the **coding** agent" or "as the **researcher**", even though OpenHuman already ships a 35-agent registry where each agent (`researcher`, `code_executor`, `crypto_agent`, …) declares its own toolset, model hint, sandbox, and iteration policy in an `agent.toml`.
Part A fixes the contract; Part B builds selectable agent kinds on top of the existing registry. They are sequenced so A1 (the agent envelope) lands before B (agent kinds emit into that same envelope).
---
## Part A — Node I/O alignment
### A0. Design principle: a normalized item envelope
Adopt a small, stable **output envelope** for capability nodes so every downstream expression has a guaranteed accessor, regardless of provider or config:
```jsonc
// agent / tool_call / http_request / code emit items shaped:
{
"json": <structured payload | null>, // parsed/structured result when there is one
"text": <string | null>, // human-readable text when there is one
"raw": <provider-native value>, // escape hatch: the untouched capability return
"error": <null | { message, ... }> // present only on continue/route error items
}
```
Rules: `=item.text` always resolves (or is explicitly `null`); `=item.json.<field>` is the structured path; `=item.raw` preserves today's behavior for anyone who needs the provider blob. This is additive — `raw` is exactly what nodes emit today — so migration is mechanical.
> Keep the crate **host-agnostic**: the envelope is defined in `vendor/tinyflows` (`src/data.rs` / node executors). The host adapters (`caps.rs`) already produce `{text}` / parsed JSON, so they map onto `json`/`text` directly.
### A1. Normalize the `agent` node output — _highest leverage, do first_
- **Crate** (`vendor/tinyflows/src/nodes/integration/agent.rs:115`): wrap the completion in the envelope instead of `Item::new(value)`. If `output_parser` ran, the coerced value goes in `json`; the completion text (when present) in `text`; the untouched response in `raw`; a model-elected tool result stays under `json.tool_result` **and** mirrors to a stable `tool_result` accessor (see A2).
- **Host** (`src/openhuman/tinyflows/caps.rs` `OpenHumanLlm::complete`): return `{ json: <parsed-or-null>, text: <response.text>, raw: <full response> }` rather than either the bare parsed object _or_ the `{text}` fallback. Removes the runtime shape-flip (audit M1).
- **Tests**: update `agent.rs` unit tests + `caps.rs` seam tests; add an e2e asserting `=item.text` resolves on both a JSON-emitting and a prose-emitting model (mock both).
### A2. Unify inline-tool vs `tool_call`-node result shape (audit M2)
Make a tool result reachable at the **same** path whether the tool ran inline in an `agent` node or as a standalone `tool_call` node. Standardize on the envelope: `tool_call` node emits `{ json: <tool output>, raw: <composio envelope> }`; the agent's inline tool result lands at `item.json.tool_result` and the node envelope's `raw` keeps the full completion. Document the one canonical accessor.
### A3. Per-item execution for integration nodes (audit M3 — the silent-drop trap)
Today `agent`/`tool_call`/`http_request` **always emit one item** and bind config against `input.first()` only, so `split_out (N) → tool_call` fires **once** and drops N1 items.
- Add a node config flag `execution: "once" | "per_item"` (default `per_item` for `tool_call`/`http_request`; default `once` for `agent`, since an agent turn is usually batch-level — but allow `per_item`).
- In `per_item` mode: map the executor over `ctx.input`, re-resolving config per item (so `=item.x` means _this_ item), emit one output item per input, carry `paired_item` (`vendor/tinyflows/src/data.rs`).
- Touches `agent.rs`, `tool_call.rs`, `http_request.rs`, and the per-item resolution path in `nodes/mod.rs`.
- **Tests**: `split_out → tool_call` runs N times; `paired_item` lineage preserved; `once` mode unchanged.
### A4. Port-aware `collect_input` (audit M4 — untaken-branch leak + BUG-3/BUG-4)
`collect_input` (`vendor/tinyflows/src/engine.rs:171`) concatenates items from **every** predecessor slot regardless of `Edge.to_port` (stored, never read) or which port the predecessor emitted on. A node after a `condition` reads the not-taken branch's items.
- Read `Edge.to_port` / the predecessor's recorded `port`; collect only items the predecessor actually emitted on the connecting port.
- Enables **named merge inputs** (input A vs B) and removes the leak.
- Fold in the merge-barrier gap (BUG-4) and mixed-port fan-out drop (BUG-3) from the audit — same routing/lowering surface (`engine.rs:422`, `789-826`).
- **Tests**: condition-false slot not visible to a node wired on the true port; merge fed by a branching predecessor barriers correctly; the `main→a, main→b, error→h` shape runs both `a` and `b`.
### A5. `merge` modes (audit M5)
Add `merge.mode`: `append` (today's concat), `combine_by_key` (join items by a key field), `combine_by_position` (zip). Config-only change in `vendor/tinyflows/src/nodes/control_flow/merge.rs`; barrier semantics unchanged.
### A6. Author-time alignment lint (feeds Part C tooling)
A validation pass that, given a producer node's known/declared output envelope, flags downstream `=item.<field>` references the producer cannot emit — surfaced as structured, node-addressed diagnostics. Depends on tightening `validate.rs` (audit BUG-10). Wire into the agent-facing `validate` / `revise_workflow` tools so the builder agent gets the feedback.
### A-bugs (bundle with the above — from audit §3)
- **BUG-1 (security, hotfix now):** jq `env` builtin leaks host env — disable `jaq-std` default features / filter `env`/`input*` in `vendor/tinyflows/src/expr.rs:302`.
- **BUG-2:** `switch`/`transform` don't get the `nodes` scope — use `expr_scope` (`switch.rs:26`, `transform.rs:27`).
- **BUG-5/6:** sub-workflow HITL dropped; `on_run_finish` never fires on failure — verify against `FlowRunObserver`.
- **BUG-9:** `code`/`output_parser`/`sub_workflow` skip `=`-resolution — make expression binding uniform.
---
## Part B — Selectable agent kinds
### B0. The idea
Let an `agent` node declare **which agent** runs it, by referencing an OpenHuman registry agent:
```jsonc
{
"kind": "agent",
"config": {
"agent_ref": "code_executor", // or "researcher", "crypto_agent", …
"prompt": "=item.text",
"connection_ref": "…", // still host-resolved, never model-supplied
// optional per-node overrides:
"model": "…",
"max_iterations": 6,
"tools_allow": ["grep", "edit"],
},
}
```
When `agent_ref` is set, the host runs that **registered agent** — with _its_ curated toolset, model hint, sandbox mode, and iteration policy — as a full multi-turn agent loop, instead of the current single `provider.chat` call. A coding step gets coding tools; a research step gets `web_search`/`web_fetch`; a crypto step gets market tools. This is exactly the registry's existing contract (`src/openhuman/agent_registry/agents/*/agent.toml`).
### B1. Crate seam — new `AgentRunner` capability (host-agnostic)
The crate must not know about OpenHuman's registry, and "run a named agent to completion (multi-turn, tool-using)" is a **different capability** than `LlmProvider.complete` (single shot). Add a trait to `vendor/tinyflows/src/caps/mod.rs`:
```rust
#[async_trait]
pub trait AgentRunner: Send + Sync {
/// Run a host-registered agent identified by `agent_ref` to completion.
/// `request` carries prompt/input/overrides; `conn` is the opaque credential.
async fn run_agent(&self, agent_ref: &str, request: Value, conn: Option<&str>) -> Result<Value>;
}
```
- Add `agent: Option<Arc<dyn AgentRunner>>` to `Capabilities` (optional so hosts without a registry keep working).
- **`agent.rs` dispatch**: if `config.agent_ref` is present _and_ `caps.agent` is wired → `run_agent(...)`; else fall back to today's `LlmProvider.complete` path. Both emit the A1 envelope, so downstream expressions are identical regardless.
- Mock impl in `caps/mock.rs` (echo `agent_ref` + request) so crate tests exercise both paths.
- `agent_ref` is **trusted config only**, never taken from model output (same rule as `tool_call.connection_ref`, `agent.rs:71-79`).
### B2. Host adapter — implement `AgentRunner` over the registry + delegate runtime
- `src/openhuman/tinyflows/caps.rs`: new `OpenHumanAgentRunner` implementing `AgentRunner`.
- `run_agent(agent_ref, request, conn)`:
1. `agent_registry::ops::get_agent(agent_ref)` → resolve the entry (tools, model hint, sandbox, `max_iterations`, `iteration_policy`).
2. Apply optional per-node overrides (`model`, `max_iterations`, and a **narrowing-only** `tools_allow` — a node may _subset_ the agent's tools, never add).
3. Drive the existing delegate runtime (`src/openhuman/agent/tools/delegate.rs`) with a `TurnOrigin` of the flow run (`src/openhuman/agent/turn_origin.rs` already has a flow origin variant).
4. Return `{ json, text, raw }` (A1 envelope): final structured output in `json`, final message text in `text`.
- **Autonomy/security**: the agent kind's tools are still gated by `SecurityPolicy` / autonomy tier; a `sandboxed` agent (e.g. `code_executor`) runs sandboxed; the flow's own approval gate still parks outbound actions. No new privilege path.
- **Depth/cost guard**: an agent node running a full agent loop inside a flow can fan out cost — bound it (reuse `MAX_SUB_WORKFLOW_DEPTH`-style counter or a per-run agent-invocation cap) and thread cancellation into the delegate run (today's sub-workflow path drops the token — BUG-5 — fix here too).
### B3. Authoring surface — ground the choice for the builder agent
- **Builder tool** `list_agent_profiles` in `src/openhuman/flows/builder_tools.rs`, backed by `agent_registry::ops::list_agents(false)`, returning `{ id, display_name, when_to_use, tools, sandbox_mode }`. Mirrors the existing `search_tool_catalog` pattern so the `workflow_builder` sub-agent picks a real `agent_ref` instead of hallucinating one.
- **Validation** (`vendor/tinyflows/src/validate.rs` + host): an `agent` node with an `agent_ref` that doesn't resolve is a structured validation error (needs the host to pass the known-agent set into validation, or validate host-side in `flows::ops::validate`).
- **workflow_builder prompt** (`src/openhuman/agent_registry/agents/workflow_builder/prompt.md`): document `agent_ref` and when to prefer a specialized agent over a bare completion.
### B4. UI — agent-kind picker
- The (upcoming) node config panel (`U2` in the integration plan) gets an **Agent kind** dropdown for `agent` nodes, populated from `agent_registry` `list_agents` RPC (already exists), showing `display_name` + `when_to_use`, with an optional tool-subset multiselect.
- Read-only canvas: show the chosen agent kind as a node badge.
---
## Part C — Sequencing & ownership
| Phase | Work | Depends on | Surface |
| ------ | -------------------------------------------------------------------------------- | --------------------- | ------------------------------------------------- |
| **C0** | BUG-1 env-leak hotfix; BUG-2 switch/transform scope | — | `vendor/tinyflows/src/expr.rs`, `control_flow/*` |
| **C1** | A0 envelope + **A1 agent output normalization** | C0 | `agent.rs`, `caps.rs` |
| **C2** | A2 tool-shape unification; A3 per-item execution | C1 | `tool_call.rs`, `http_request.rs`, `nodes/mod.rs` |
| **C3** | A4 port-aware `collect_input` (+ BUG-3/4); A5 merge modes | — (parallel to C1/C2) | `engine.rs`, `merge.rs` |
| **C4** | **B1 `AgentRunner` capability** + mock | C1 (shared envelope) | `caps/mod.rs`, `caps/mock.rs`, `agent.rs` |
| **C5** | **B2 host `OpenHumanAgentRunner`** over registry+delegate (+ BUG-5 cancellation) | C4 | `tinyflows/caps.rs`, `agent/tools/delegate.rs` |
| **C6** | B3 `list_agent_profiles` + `agent_ref` validation; A6 alignment lint | C5, BUG-10 | `flows/builder_tools.rs`, `validate.rs` |
| **C7** | B4 UI agent-kind picker | C5, U2 node panel | `app/src/components/flows/*` |
Each phase ships with tests (crate unit + host seam + JSON-RPC E2E) per the repo's "tests before the next layer" rule, and records design decisions in `vendor/tinyflows/local/docs/11-decisions.md`.
## Open questions
1. **`agent_ref` in the crate**: prefer a dedicated `AgentRunner` capability (B1, recommended — keeps `LlmProvider.complete` a clean single-shot contract) vs. overloading `complete` to loop when it sees `agent_ref`. Recommendation: the capability.
2. **Per-node model/tool overrides**: allow narrowing only (`tools_allow` subsets the agent's toolset) — never widen, to preserve the registry's security envelope. Confirm this constraint.
3. **Cost/loop bounds** for agent-kind nodes inside a flow (and inside a sub-workflow): reuse the depth counter or introduce a per-run agent-invocation budget?
4. **Custom agents**: `upsert_custom_agent` already exists — user-defined agent kinds are addressable by `agent_ref` for free. Confirm they should be selectable in flows.
+13 -4
View File
@@ -989,10 +989,15 @@ mod tests {
#[test]
fn workflow_builder_is_registered_worker_with_narrow_propose_or_read_scope() {
// Phase 5a/5b: the workflow-builder must be a Worker-tier leaf whose
// tool scope is EXACTLY the propose-or-read belt — no persistence
// (flows_create/update/set_enabled), no shell, no file writes, no
// channel sends. This pins the "propose, never persist" invariant in
// the agent definition itself, not just the tool implementations.
// tool scope is EXACTLY the propose-or-read + Composio discovery/connect
// + confirmed test-run belt — no persistence (flows_create/update/
// set_enabled), no shell, no file writes, no channel sends, and no
// composio_execute. It can list toolkits/connections, raise the inline
// connect card, and `run_workflow` a flow the user already SAVED to test
// it (a real run the prompt gates behind user confirmation), but it can
// never persist/enable a flow or perform a raw integration action. This
// pins the invariant in the agent definition itself, not just the tool
// implementations.
let def = find("workflow_builder");
assert_eq!(def.agent_tier, AgentTier::Worker);
assert_eq!(def.delegate_name.as_deref(), Some("build_workflow"));
@@ -1013,6 +1018,10 @@ mod tests {
"list_flow_connections",
"search_tool_catalog",
"dry_run_workflow",
"run_workflow",
"composio_list_toolkits",
"composio_list_connections",
"composio_connect",
];
for required in expected {
assert!(
@@ -23,9 +23,15 @@ hint = "burst"
[tools]
# DELIBERATELY NARROW: propose/revise (validate-only) + read (flows, runs,
# connections, tool catalog) + sandbox dry-run. NO shell, NO file writes, NO
# channel sends, NO flows_create/update/set_enabled — the agent can never
# persist or enable a flow.
# connections, tool catalog) + sandbox dry-run + Composio discovery/connect +
# a confirmed real test-run of a SAVED flow. NO shell, NO file writes, NO
# channel sends, NO composio_execute, and NO flows_create/update/set_enabled —
# the agent can never persist or enable a flow, or perform a real integration
# action directly. Composio access is limited to LISTING toolkits/connections
# and raising the inline CONNECT card (an approval-gated OAuth hand-off).
# `run_workflow` executes a flow the user has ALREADY saved to test it — a real
# run, but the prompt requires asking the user to confirm first, and the flow's
# own approval gate still pauses outbound-action nodes.
named = [
"propose_workflow",
"revise_workflow",
@@ -35,4 +41,8 @@ named = [
"list_flow_connections",
"search_tool_catalog",
"dry_run_workflow",
"run_workflow",
"composio_list_toolkits",
"composio_list_connections",
"composio_connect",
]
@@ -8,8 +8,8 @@ user to review and save.
## The one invariant you must never break: propose, never persist
You **cannot and must not** create, update, enable, disable, or run a real saved
flow. You have no tool that does — by design. Your only outputs are:
You **cannot and must not** create, update, enable, or disable a saved flow. You
have no tool that does — by design. Your authoring outputs are:
- **`propose_workflow`** / **`revise_workflow`** — these *validate* a candidate
graph and hand back a proposal summary. They **never** save anything.
@@ -21,6 +21,24 @@ Only the user's own "Save & enable" click in the review card persists a flow
(via `flows_create`, which re-validates server-side). If a user says "just turn
it on for me", explain that you can only propose it — they confirm the save.
## Testing a saved flow: `run_workflow` (ask first!)
Once the user has **saved** a flow, you can `run_workflow { flow_id }` to test it
end-to-end. Unlike `dry_run_workflow`, this is a **real run** — real effects can
fire (the flow's own approval gate still pauses outbound-action nodes, but treat
it as real). Rules:
1. **Only a saved flow.** `run_workflow` needs a `flow_id`; if the workflow isn't
saved yet, tell the user to Save it first (you can't run a draft — use
`dry_run_workflow` for a draft wiring check).
2. **ALWAYS ask for confirmation and wait for an explicit "yes"** before calling
`run_workflow`. Say what it will do ("This will run the flow for real and may
send/act on live data — run it now?") and only proceed once they agree. Never
run a workflow unprompted or as a surprise side effect of another request.
3. After a run, read the result (status + any nodes paused for approval) and
report what happened; if it failed, `get_flow_run` for the steps and propose a
fix.
## Your authoring loop
1. **Understand the trigger and the steps.** What starts the flow? What should
@@ -34,6 +52,37 @@ it on for me", explain that you can only propose it — they confirm the save.
`http_request` node or tell the user the integration isn't available.
- `list_flows` / `get_flow` → reuse or clone an existing flow instead of
duplicating one.
- **Missing the integration the workflow needs?** See "Connecting
integrations" below — you can help the user link it before you build,
rather than dead-ending.
## Connecting integrations
A workflow often needs an app the user hasn't linked yet (a `tool_call` on
Gmail, Slack, Notion…). You can close that gap yourself instead of telling the
user to go do it elsewhere:
- **`composio_list_toolkits`** — the catalog of connectable apps (slugs like
`gmail`, `slack`, `googlesheets`). Use it to find the right toolkit for what
the user described.
- **`composio_list_connections`** — which toolkits the user has ALREADY
connected (mirrors `list_flow_connections`' Composio side). Check here first —
never ask someone to connect an app they've already linked.
- **`composio_connect`** — raises an inline **Connect** card for a toolkit and
waits for the user to approve the OAuth hand-off. Call it when the workflow
needs an app that isn't in `composio_list_connections` yet. After it returns
connected, re-run `list_flow_connections` to pick up the fresh
`connection_ref` and put it on the node.
Still bounded: you can **discover and connect** apps, but you have **no** tool to
*execute* a Composio action (`composio_execute` is deliberately out of scope) and
**no** tool to persist a flow. Connecting is a setup step in service of a
proposal — the user still saves the workflow themselves.
Typical setup arc: user asks for a Slack step → `composio_list_connections`
shows Slack isn't linked → `composio_connect { toolkit: "slack" }` → once
connected, `list_flow_connections` → build the `tool_call` node with the real
`connection_ref` + a `search_tool_catalog` slug → dry-run → propose.
3. **Build the graph** (see the model below).
4. **Self-check with `dry_run_workflow`** on the draft — catch missing edges,
wrong ports, unreachable nodes. Fix and re-run.
@@ -57,9 +106,22 @@ A `WorkflowGraph` is `{ name?, nodes: [...], edges: [...] }`.
1. **`trigger`** — the entry point (`config.trigger_kind`, see triggers below).
2. **`agent`** — an LLM step. `config.prompt` (may use `=` expressions).
3. **`tool_call`** — a Composio action. `config.slug` **REQUIRED** (from
`search_tool_catalog`) + `config.args`; `config.connection_ref` for the
account.
**If the agent's output feeds a `tool_call`, it MUST declare an output
schema** — set `config.output_parser.schema` (a JSON Schema object) — so
its emitted item is a structured object whose fields downstream nodes can
address (`=nodes.<agent_id>.item.<field>`). Without a schema the agent
emits `{text: "..."}` and `=item.to`-style bindings resolve to null.
3. **`tool_call`** — an action. Two flavours by `config.slug`:
- **Composio app action** — `config.slug` = a real action slug (from
`search_tool_catalog`, e.g. `GMAIL_SEND_EMAIL`) + `config.connection_ref`
for the account. **Wire every REQUIRED arg in `config.args` from a named
upstream node** — e.g. an email send needs `to`/`recipient_email`, usually
`"to": "=nodes.<upstream_id>.item.email"`. A required arg left unwired (or
whose expression misses) now fails BEFORE the provider call — both in
`dry_run_workflow` and in real runs — with an error naming the field.
- **Native OpenHuman tool** — `config.slug` = `oh:<tool_name>` (e.g.
`oh:web_search`) to call one of the assistant's own built-in tools (search,
media generation, files, …). No `connection_ref`. Args go in `config.args`.
4. **`http_request`** — `config.method` + `config.url`, optional `headers` /
`body`; `config.connection_ref` = an `http_cred:<name>` for auth.
5. **`code`** — `config.language` (`"javascript"` | `"python"`) + `config.source`.
@@ -85,8 +147,39 @@ the run scope (`.`):
- A string **without** a leading `=` is a literal. To emit a literal `=`, don't
start the string with it.
The scope exposes:
- `item` / `items` — the **direct predecessor(s)'** output (first item / all
items, in edge order).
- `run` — run metadata and the trigger payload.
- `nodes`**every completed node's output, keyed by node id**:
`nodes.<id>.item` (first item) and `nodes.<id>.items` (all items). Use this
to reference ANY upstream node — not just the immediate predecessor — and to
disambiguate a fan-in node's inputs. Dotted form: `"=nodes.fetch.item.email"`;
jq form: `"=.nodes[\"fetch\"].items[0].email"`. Ids (not names) are the key.
Use expressions to thread data between steps (a `transform`'s `set`, an
`agent`'s `prompt`, a `tool_call`'s `args`).
`agent`'s `prompt`, a `tool_call`'s `args`). Prefer `=nodes.<id>.…` for
`tool_call` args so the binding survives graph re-wiring.
**Worked example — agent → Gmail send.** The agent must declare a schema, and
the tool_call wires each required arg from the agent BY ID:
```json
{ "id": "extract", "kind": "agent", "config": {
"prompt": "=\"Extract the recipient and a reply from: \" + .item.text",
"output_parser": { "schema": { "type": "object",
"required": ["email", "subject", "body"],
"properties": { "email": {"type": "string"}, "subject": {"type": "string"}, "body": {"type": "string"} } } } } }
{ "id": "send", "kind": "tool_call", "config": {
"slug": "GMAIL_SEND_EMAIL", "connection_ref": "composio:gmail:<conn_id>",
"args": { "to": "=nodes.extract.item.email",
"subject": "=nodes.extract.item.subject",
"body": "=nodes.extract.item.body" } } }
```
Without the schema, `=nodes.extract.item.email` would be null (the agent's
item would be `{text: ...}`) and the send would fail preflight naming `to`.
### Trigger kinds — which ones actually fire
+28 -4
View File
@@ -21,6 +21,14 @@
//! executes against `tinyflows`' deterministic **mock** capabilities so no real
//! LLM / tool / HTTP / code side effect can fire. Only the user's own
//! "Save & enable" click (→ `openhuman.flows_create`) writes anything.
//!
//! The agent's full tool scope (see `agent_registry/agents/workflow_builder/
//! agent.toml`) also grants the Composio **discovery/connect** tools —
//! `composio_list_toolkits`, `composio_list_connections`, `composio_connect`
//! (defined in `composio/tools.rs`) — so the builder can link an app the
//! workflow needs before proposing. Those stay within the invariant: connect
//! is an approval-gated OAuth hand-off, and `composio_execute` (running a real
//! action) remains deliberately OUT of scope.
use std::sync::Arc;
@@ -156,7 +164,10 @@ impl Tool for ReviseWorkflowTool {
};
let summary = super::tools::build_summary(&graph);
let warnings = ops::graph_trigger_warnings(&graph);
let mut warnings = ops::graph_trigger_warnings(&graph);
// Author-time wiring check: unwired REQUIRED Composio args come back
// as warnings naming the field, before the user ever saves.
warnings.extend(ops::graph_wiring_warnings(&self.config, &graph).await);
let graph_value = serde_json::to_value(&graph)?;
tracing::info!(
@@ -613,13 +624,20 @@ impl Tool for SearchToolCatalogTool {
/// Autonomy-tier gated (issue: Phase 2 node gating): read-only tier refuses,
/// mirroring the `SecurityPolicy` contract that a read-only session cannot
/// exercise executable capability even in simulation.
///
/// **Wiring preflight:** the mock tool invoker is wrapped in the host's
/// [`PreflightToolInvoker`](crate::openhuman::tinyflows::caps::PreflightToolInvoker),
/// so a Composio `tool_call` whose required arg is missing or `=`-resolved to
/// null fails the dry run with the same actionable, field-naming error a real
/// run would produce — the echo mocks alone would happily accept a null `to`.
pub struct DryRunWorkflowTool {
security: Arc<SecurityPolicy>,
config: Arc<Config>,
}
impl DryRunWorkflowTool {
pub fn new(security: Arc<SecurityPolicy>) -> Self {
Self { security }
pub fn new(security: Arc<SecurityPolicy>, config: Arc<Config>) -> Self {
Self { security, config }
}
}
@@ -719,7 +737,13 @@ impl Tool for DryRunWorkflowTool {
}
};
let caps = tinyflows::caps::mock::mock_capabilities();
let mut caps = tinyflows::caps::mock::mock_capabilities();
// Wiring preflight over the echo mocks (see the struct doc): required
// Composio args must be present and non-null even in the sandbox.
caps.tools = std::sync::Arc::new(crate::openhuman::tinyflows::caps::PreflightToolInvoker {
config: self.config.clone(),
inner: caps.tools.clone(),
});
let run = tinyflows::engine::run(&compiled, input, &caps);
let outcome = match tokio::time::timeout(
std::time::Duration::from_secs(DRY_RUN_TIMEOUT_SECS),
+122 -4
View File
@@ -201,7 +201,10 @@ async fn search_tool_catalog_missing_query_is_error() {
#[test]
fn dry_run_is_execute_permission() {
let tool = DryRunWorkflowTool::new(policy(AutonomyLevel::Supervised));
let tool = DryRunWorkflowTool::new(
policy(AutonomyLevel::Supervised),
test_config(&TempDir::new().unwrap()),
);
assert_eq!(tool.name(), "dry_run_workflow");
assert_eq!(tool.permission_level(), PermissionLevel::Execute);
// Mock-backed: no real outbound effect.
@@ -210,7 +213,10 @@ fn dry_run_is_execute_permission() {
#[tokio::test]
async fn dry_run_refused_under_readonly_tier() {
let tool = DryRunWorkflowTool::new(policy(AutonomyLevel::ReadOnly));
let tool = DryRunWorkflowTool::new(
policy(AutonomyLevel::ReadOnly),
test_config(&TempDir::new().unwrap()),
);
let result = tool
.execute(json!({ "graph": valid_graph() }))
.await
@@ -221,7 +227,10 @@ async fn dry_run_refused_under_readonly_tier() {
#[tokio::test]
async fn dry_run_supervised_runs_against_mock_and_labels_sandbox() {
let tool = DryRunWorkflowTool::new(policy(AutonomyLevel::Supervised));
let tool = DryRunWorkflowTool::new(
policy(AutonomyLevel::Supervised),
test_config(&TempDir::new().unwrap()),
);
let result = tool
.execute(json!({ "graph": valid_graph(), "input": { "x": 1 } }))
.await
@@ -239,10 +248,119 @@ async fn dry_run_supervised_runs_against_mock_and_labels_sandbox() {
#[tokio::test]
async fn dry_run_invalid_graph_is_error() {
let tool = DryRunWorkflowTool::new(policy(AutonomyLevel::Full));
let tool = DryRunWorkflowTool::new(
policy(AutonomyLevel::Full),
test_config(&TempDir::new().unwrap()),
);
let result = tool
.execute(json!({ "graph": { "nodes": [], "edges": [] } }))
.await
.unwrap();
assert!(result.is_error);
}
#[tokio::test]
async fn dry_run_catches_unwired_required_composio_arg() {
// Seed the preflight schema cache so no live Composio backend is needed.
// NOTE: the cache is process-global and other tests seed the `gmail`
// toolkit too — keep every seeding of GMAIL_SEND_EMAIL identical
// (`to` + `body`) so test order can't change the outcome.
let mut entries = std::collections::HashMap::new();
entries.insert(
"GMAIL_SEND_EMAIL".to_string(),
vec!["to".to_string(), "body".to_string()],
);
crate::openhuman::tinyflows::caps::seed_required_args_cache("gmail", entries);
let tmp = TempDir::new().unwrap();
let tool = DryRunWorkflowTool::new(policy(AutonomyLevel::Supervised), test_config(&tmp));
let graph_with = |args: Value| {
json!({
"nodes": [
{ "id": "t", "kind": "trigger", "name": "Manual" },
{ "id": "send", "kind": "tool_call", "name": "Send email",
"config": { "slug": "GMAIL_SEND_EMAIL", "args": args } }
],
"edges": [ { "from_node": "t", "to_node": "send" } ]
})
};
// `to` is a `=`-expression that misses (trigger input has no `email`):
// the dry run must fail BEFORE the (mock) tool call, naming the field.
let result = tool
.execute(json!({
"graph": graph_with(json!({ "to": "=item.email", "body": "hello" })),
"input": {}
}))
.await
.unwrap();
let out = result.output();
assert!(
out.contains("`to`") && out.contains("required"),
"dry run must name the unwired required arg: {out}"
);
// The same flow with `to` wired from the trigger passes the preflight.
let result = tool
.execute(json!({
"graph": graph_with(json!({ "to": "=item.email", "body": "hello" })),
"input": { "email": "a@b.com" }
}))
.await
.unwrap();
assert!(!result.is_error, "{}", result.output());
let parsed: Value = serde_json::from_str(&result.output()).unwrap();
assert_eq!(parsed["sandbox"], true);
assert_eq!(
parsed["ok"], true,
"wired flow must dry-run green: {parsed}"
);
}
#[tokio::test]
async fn revise_workflow_warns_on_unwired_required_composio_arg() {
let mut entries = std::collections::HashMap::new();
entries.insert(
"GMAIL_SEND_EMAIL".to_string(),
vec!["to".to_string(), "body".to_string()],
);
crate::openhuman::tinyflows::caps::seed_required_args_cache("gmail", entries);
let tmp = TempDir::new().unwrap();
let tool = ReviseWorkflowTool::new(test_config(&tmp));
let result = tool
.execute(json!({
"name": "Send mail",
"graph": {
"nodes": [
{ "id": "t", "kind": "trigger", "name": "Manual" },
{ "id": "send", "kind": "tool_call", "name": "Send",
// `body` wired via expression (counts as wired); `to` absent.
"config": { "slug": "GMAIL_SEND_EMAIL",
"args": { "body": "=item.text" } } }
],
"edges": [ { "from_node": "t", "to_node": "send" } ]
}
}))
.await
.unwrap();
assert!(!result.is_error, "{}", result.output());
let parsed: Value = serde_json::from_str(&result.output()).unwrap();
let warnings = parsed["warnings"].as_array().unwrap();
assert!(
warnings.iter().any(|w| {
let w = w.as_str().unwrap_or_default();
w.contains("`to`") && w.contains("send")
}),
"expected a warning naming node `send` and arg `to`: {warnings:?}"
);
// `body` is wired (expression) — no warning for it.
assert!(
!warnings
.iter()
.any(|w| w.as_str().unwrap_or_default().contains("`body`")),
"wired arg must not warn: {warnings:?}"
);
}
+51
View File
@@ -162,6 +162,56 @@ pub(crate) fn graph_trigger_warnings(graph: &WorkflowGraph) -> Vec<String> {
)]
}
/// Author-time wiring warnings for Composio `tool_call` nodes: flags every
/// **required** arg (per the action's schema, best-effort cached lookup) that
/// is absent or a literal `null` in `config.args` — the exact mis-wiring that
/// would later fail the run's required-arg preflight.
///
/// Static by design: an arg carrying an `=`-expression counts as wired (only
/// the runtime preflight can tell whether it resolves), a `=`-derived slug is
/// skipped (can't know the action), and native `oh:` tools are skipped (no
/// Composio schema). Best-effort like the runtime preflight — no schema, no
/// warning, never a block.
pub(crate) async fn graph_wiring_warnings(config: &Config, graph: &WorkflowGraph) -> Vec<String> {
use crate::openhuman::tinyflows::caps::{composio_required_args, missing_required_args};
let mut warnings = Vec::new();
for node in &graph.nodes {
if node.kind != tinyflows::model::NodeKind::ToolCall {
continue;
}
let Some(slug) = node.config.get("slug").and_then(Value::as_str) else {
continue;
};
// `=`-derived slugs are resolved at runtime; native tools have no
// Composio schema to check against.
if slug.starts_with('=') || slug.starts_with("oh:") {
continue;
}
let Some(required) = composio_required_args(config, slug).await else {
tracing::debug!(target: "flows", node = %node.id, %slug, "[flows] wiring check: no schema — skipping node");
continue;
};
let args = node.config.get("args").cloned().unwrap_or(Value::Null);
for missing in missing_required_args(&required, &args) {
tracing::warn!(
target: "flows",
node = %node.id,
%slug,
arg = %missing,
"[flows] wiring check: required arg not wired"
);
warnings.push(format!(
"Node '{}': required arg `{missing}` of `{slug}` is not wired — set \
args.{missing}, e.g. \"=nodes.<upstream_id>.item.<field>\" (an agent feeding \
this value needs an output schema so its fields are addressable).",
node.id
));
}
}
warnings
}
/// Validates a candidate graph without persisting it — the same
/// migrate/validate path `flows_create` and `ProposeWorkflowTool` use — and
/// reports structural errors alongside non-fatal trigger warnings
@@ -1506,6 +1556,7 @@ fn reconstruct_steps(output: &Value) -> Vec<FlowRunStep> {
// Reconstructed post-hoc: no live status/timing (see FlowRunStep).
status: None,
duration_ms: None,
diagnostics: Vec::new(),
})
.collect()
}
+3
View File
@@ -1044,12 +1044,14 @@ async fn observer_persists_each_step_incrementally() {
status: StepStatus::Success,
output: json!([{ "json": { "ok": true } }]),
duration_ms: 7,
diagnostics: Vec::new(),
});
observer.on_step_finish(&ExecutionStep {
node_id: "b".to_string(),
status: StepStatus::Error,
output: Value::Null,
duration_ms: 3,
diagnostics: Vec::new(),
});
// The store now holds both live steps with real status + timing — proof of
@@ -1069,6 +1071,7 @@ async fn observer_persists_each_step_incrementally() {
status: StepStatus::Success,
output: json!([{ "json": { "ok": true } }]),
duration_ms: 42,
diagnostics: Vec::new(),
});
let row = store::get_flow_run(&config, &run_id).unwrap().unwrap();
assert_eq!(row.steps.len(), 2, "re-firing a node must not duplicate it");
+108
View File
@@ -177,6 +177,13 @@ impl Tool for ProposeWorkflowTool {
};
let summary = build_summary(&graph);
// Author-time warnings: unfired trigger kinds + unwired REQUIRED
// Composio args (see `ops::graph_wiring_warnings`) — surfaced on the
// proposal so the builder fixes wiring before the user saves.
let mut warnings = crate::openhuman::flows::ops::graph_trigger_warnings(&graph);
warnings.extend(
crate::openhuman::flows::ops::graph_wiring_warnings(&self.config, &graph).await,
);
let graph_value = serde_json::to_value(&graph)?;
tracing::info!(
@@ -184,6 +191,7 @@ impl Tool for ProposeWorkflowTool {
%name,
node_count = graph.nodes.len(),
require_approval,
warning_count = warnings.len(),
"[flows] propose_workflow: proposal ready for user review"
);
@@ -193,10 +201,110 @@ impl Tool for ProposeWorkflowTool {
"graph": graph_value,
"require_approval": require_approval,
"summary": summary,
"warnings": warnings,
}))?))
}
}
/// Runs a **saved** workflow by id so the `workflow-builder` agent can *test*
/// it end-to-end. Unlike [`crate::openhuman::flows::builder_tools::DryRunWorkflowTool`]
/// (a MOCK sandbox), this is a **real** run — so it is `PermissionLevel::Write`
/// with `external_effect() == true`. Two safety layers remain: the flow's own
/// `require_approval` gate still pauses outbound-action nodes mid-run, and the
/// agent prompt requires it to ASK THE USER for confirmation before ever
/// calling this. It only runs an already-persisted flow (no `flow_id`, no run).
pub struct RunFlowTool {
config: Arc<Config>,
}
impl RunFlowTool {
pub fn new(config: Arc<Config>) -> Self {
Self { config }
}
}
#[async_trait]
impl Tool for RunFlowTool {
fn name(&self) -> &str {
"run_workflow"
}
fn description(&self) -> &str {
"Run a SAVED workflow by id to TEST it end-to-end. This is a REAL run, not a \
simulation — real effects can fire (use dry_run_workflow for a safe MOCK run \
instead). It only works on a flow the user has already saved; pass its `flow_id`. \
You MUST ask the user to confirm and wait for an explicit 'yes' before calling this \
— never run a workflow unprompted. The flow's own approval gate still pauses \
outbound-action nodes. Params: { flow_id (required), input? }. Returns the run's \
status + any nodes paused for approval."
}
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"flow_id": {
"type": "string",
"description": "Id of the SAVED flow to run (the user must have saved it first)."
},
"input": {
"description": "Optional trigger input passed to the run (defaults to {})."
}
},
"required": ["flow_id"]
})
}
fn permission_level(&self) -> PermissionLevel {
// A real run with real effects — gated like a Write-class action.
PermissionLevel::Write
}
fn external_effect(&self) -> bool {
true
}
async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
let flow_id =
match args.get("flow_id").and_then(Value::as_str).map(str::trim) {
Some(id) if !id.is_empty() => id.to_string(),
_ => return Ok(ToolResult::error(
"Missing 'flow_id' — run_workflow only works on a SAVED flow. Ask the user \
to Save the workflow first, then run it by id."
.to_string(),
)),
};
let input = args.get("input").cloned().unwrap_or_else(|| json!({}));
tracing::info!(
target: "flows",
%flow_id,
"[flows] run_workflow: agent-initiated test run starting"
);
match crate::openhuman::flows::ops::flows_run(
&self.config,
&flow_id,
input,
crate::openhuman::flows::types::FlowRunTrigger::Rpc,
)
.await
{
Ok(outcome) => Ok(ToolResult::success(serde_json::to_string_pretty(&json!({
"type": "workflow_run_result",
"flow_id": flow_id,
"result": outcome.value,
}))?)),
Err(e) => {
tracing::debug!(target: "flows", %flow_id, error = %e, "[flows] run_workflow: failed");
Ok(ToolResult::error(format!(
"Could not run flow '{flow_id}': {e}"
)))
}
}
}
}
/// Builds the `{ trigger, steps }` summary surfaced to both the LLM (in the
/// tool result) and the chat UI's `WorkflowProposalCard`.
///
+7
View File
@@ -141,6 +141,13 @@ pub struct FlowRunStep {
/// observed incrementally. `None` for a step recovered post-hoc.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub duration_ms: Option<u64>,
/// Data-binding diagnostics from the engine: each config `=`-expression
/// that resolved to `null` during this step, as
/// `{ "location": "args.to", "expression": "=item.to" }`. Lets the run
/// view point at the exact unresolved wiring. Empty for clean steps and
/// for steps recovered post-hoc.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub diagnostics: Vec<serde_json::Value>,
}
/// A resolvable connection the flows UI / agent picker can attach to a node's
+193 -2
View File
@@ -6,8 +6,8 @@ use crate::openhuman::agent::host_runtime::{NativeRuntime, RuntimeAdapter};
use crate::openhuman::config::Config;
use crate::openhuman::memory::Memory;
use crate::openhuman::runtime_node::types::{ExecuteToolOutcome, RuntimeToolSummary};
use crate::openhuman::security::SecurityPolicy;
use crate::openhuman::tools::{self, Tool, ToolCallOptions, ToolScope};
use crate::openhuman::security::{CommandClass, SecurityPolicy};
use crate::openhuman::tools::{self, PermissionLevel, Tool, ToolCallOptions, ToolScope};
use tracing::{debug, trace};
fn tool_scope_label(scope: ToolScope) -> &'static str {
@@ -30,6 +30,51 @@ fn summarize_tool(tool: &dyn Tool) -> RuntimeToolSummary {
}
}
fn classify_shell_tool_call(security: &SecurityPolicy, args: &serde_json::Value) -> CommandClass {
let command = args
.get("command")
.and_then(|value| value.as_str())
.unwrap_or("");
let mut class = security.classify_command(command);
if let Some(declared) = args
.get("category")
.and_then(|value| value.as_str())
.and_then(SecurityPolicy::parse_declared_class)
{
class = class.max(declared);
}
class
}
fn command_class_for_tool(
security: &SecurityPolicy,
tool: &dyn Tool,
args: &serde_json::Value,
) -> CommandClass {
if tool.name() == "shell" {
return classify_shell_tool_call(security, args);
}
let permission = tool.permission_level_with_args(args);
match permission {
PermissionLevel::None | PermissionLevel::ReadOnly => {
if tool.external_effect_with_args(args) {
CommandClass::Network
} else {
CommandClass::Read
}
}
PermissionLevel::Write | PermissionLevel::Execute => {
if tool.external_effect_with_args(args) {
CommandClass::Network
} else {
CommandClass::Write
}
}
PermissionLevel::Dangerous => CommandClass::Destructive,
}
}
fn build_runtime_tools(config: &Config) -> Result<Vec<Box<dyn Tool>>, String> {
debug!(
workspace = %config.workspace_dir.display(),
@@ -104,6 +149,36 @@ pub fn list_tools(config: &Config) -> Result<Vec<RuntimeToolSummary>, String> {
Ok(summaries)
}
pub fn classify_tool_call(
config: &Config,
tool_name: &str,
args: &serde_json::Value,
) -> Result<CommandClass, String> {
debug!(tool_name, "[runtime_node::ops] classify_tool_call: start");
let security =
SecurityPolicy::from_config(&config.autonomy, &config.workspace_dir, &config.action_dir);
let tools = build_runtime_tools(config)?;
let tool = tools
.into_iter()
.find(|tool| tool.name() == tool_name)
.ok_or_else(|| {
debug!(
tool_name,
"[runtime_node::ops] classify_tool_call: tool not found"
);
format!("unknown tool `{tool_name}`")
})?;
let class = command_class_for_tool(&security, tool.as_ref(), args);
debug!(
tool_name,
?class,
permission = %tool.permission_level_with_args(args),
external_effect = tool.external_effect_with_args(args),
"[runtime_node::ops] classify_tool_call: done"
);
Ok(class)
}
pub async fn execute_tool(
config: &Config,
tool_name: &str,
@@ -217,6 +292,42 @@ mod tests {
}
}
struct PolicyTool {
name: &'static str,
permission: PermissionLevel,
external_effect: bool,
}
#[async_trait]
impl Tool for PolicyTool {
fn name(&self) -> &str {
self.name
}
fn description(&self) -> &str {
"Policy test tool"
}
fn parameters_schema(&self) -> serde_json::Value {
json!({"type": "object"})
}
fn permission_level(&self) -> PermissionLevel {
self.permission
}
fn external_effect_with_args(&self, _args: &serde_json::Value) -> bool {
self.external_effect
}
async fn execute(
&self,
_args: serde_json::Value,
) -> anyhow::Result<crate::openhuman::skills::types::ToolResult> {
Ok(crate::openhuman::skills::types::ToolResult::success("ok"))
}
}
#[test]
fn summarize_tool_exposes_metadata() {
let summary = summarize_tool(&DummyTool);
@@ -232,4 +343,84 @@ mod tests {
assert_eq!(tool_scope_label(ToolScope::AgentOnly), "agent_only");
assert_eq!(tool_scope_label(ToolScope::CliRpcOnly), "cli_rpc_only");
}
#[test]
fn command_class_for_tool_maps_metadata_to_policy_buckets() {
let security = SecurityPolicy::default();
let read = PolicyTool {
name: "read_tool",
permission: PermissionLevel::ReadOnly,
external_effect: false,
};
assert_eq!(
command_class_for_tool(&security, &read, &json!({})),
CommandClass::Read
);
let write = PolicyTool {
name: "write_tool",
permission: PermissionLevel::Write,
external_effect: false,
};
assert_eq!(
command_class_for_tool(&security, &write, &json!({})),
CommandClass::Write
);
let outbound = PolicyTool {
name: "outbound_tool",
permission: PermissionLevel::Write,
external_effect: true,
};
assert_eq!(
command_class_for_tool(&security, &outbound, &json!({})),
CommandClass::Network
);
let dangerous = PolicyTool {
name: "dangerous_tool",
permission: PermissionLevel::Dangerous,
external_effect: true,
};
assert_eq!(
command_class_for_tool(&security, &dangerous, &json!({})),
CommandClass::Destructive
);
}
#[test]
fn command_class_for_shell_uses_command_args() {
let security = SecurityPolicy::default();
let shell = PolicyTool {
name: "shell",
permission: PermissionLevel::Execute,
external_effect: true,
};
assert_eq!(
command_class_for_tool(&security, &shell, &json!({"command": "ls src"})),
CommandClass::Read
);
assert_eq!(
command_class_for_tool(&security, &shell, &json!({"command": "touch out.txt"})),
CommandClass::Write
);
assert_eq!(
command_class_for_tool(
&security,
&shell,
&json!({"command": "curl https://example.com"})
),
CommandClass::Network
);
assert_eq!(
command_class_for_tool(
&security,
&shell,
&json!({"command": "cat Cargo.toml", "category": "destructive"})
),
CommandClass::Destructive
);
}
}
+308 -1
View File
@@ -198,12 +198,60 @@ fn escalated_origin_for_prompt(
}
}
/// Returns true when an agent-node completion `request` asked for structured
/// output: an `output_parser.schema` is configured on the node, or the config
/// sets `response_format: "json"`.
///
/// This is the host-side contract for **agent → tool wiring**: downstream
/// `=item.<field>` bindings only work when the agent's emitted item is a
/// structured object, so an agent feeding a `tool_call` should declare an
/// output schema (or `response_format: "json"`).
fn structured_output_requested(request: &Value) -> bool {
let has_schema = request
.get("output_parser")
.and_then(|p| p.get("schema"))
.is_some_and(|s| !s.is_null());
let json_format = request.get("response_format").and_then(Value::as_str) == Some("json");
has_schema || json_format
}
/// Best-effort parse of an LLM completion as structured JSON.
///
/// Accepts a bare JSON object/array or one wrapped in a markdown code fence
/// (```json … ``` or ``` … ```). Returns `None` for anything that doesn't
/// parse to an object or array — scalars pass through the legacy `{text}`
/// shape instead, since `item.<field>` addressing is meaningless on them.
pub(crate) fn parse_llm_json(text: &str) -> Option<Value> {
let trimmed = text.trim();
let candidate = match trimmed.strip_prefix("```") {
Some(rest) => {
let rest = rest.strip_prefix("json").unwrap_or(rest);
match rest.rsplit_once("```") {
Some((inner, _)) => inner.trim(),
None => trimmed,
}
}
None => trimmed,
};
let parsed = serde_json::from_str::<Value>(candidate).ok()?;
matches!(parsed, Value::Object(_) | Value::Array(_)).then_some(parsed)
}
/// [`LlmProvider`] adapter over OpenHuman's inference stack
/// (`src/openhuman/inference/provider/`).
///
/// The `agent` node is single-completion in tinyflows 0.2 (no tool-calling
/// loop, no sub-ports), so `complete` performs exactly one `provider.chat`
/// call and returns its result — no agent loop is driven here.
///
/// **Structured output**: when the node requested it (an
/// `output_parser.schema` or `response_format: "json"` in the config), the
/// completion text is parsed as JSON and the **parsed object** is returned as
/// the response value — so a downstream node can bind `=item.<field>` (or
/// `=nodes.<agent_id>.item.<field>`) instead of receiving an opaque
/// `{text: "..."}` blob. A completion that doesn't parse falls back to the
/// legacy shape, where the agent node's `output_parser` sub-port can still
/// coerce it via the schema auto-fix path.
pub struct OpenHumanLlm {
pub config: Arc<Config>,
}
@@ -230,7 +278,10 @@ impl LlmProvider for OpenHumanLlm {
.and_then(Value::as_u64)
.and_then(|n| u32::try_from(n).ok());
let messages: Vec<ChatMessage> = match request.get("messages").and_then(Value::as_array) {
let structured = structured_output_requested(&request);
let mut messages: Vec<ChatMessage> = match request.get("messages").and_then(Value::as_array)
{
Some(entries) if !entries.is_empty() => entries
.iter()
.filter_map(|entry| {
@@ -253,10 +304,29 @@ impl LlmProvider for OpenHumanLlm {
}
};
// Structured mode: steer the model toward parseable JSON. The schema
// (when configured) rides along so the model knows the exact shape.
if structured {
let mut instruction = "Respond with a single JSON object only — no prose, no \
markdown code fences."
.to_string();
if let Some(schema) = request
.get("output_parser")
.and_then(|p| p.get("schema"))
.filter(|s| !s.is_null())
{
instruction.push_str(&format!(
" The object must match this JSON Schema:\n{schema}"
));
}
messages.insert(0, ChatMessage::system(instruction));
}
tracing::debug!(
target: "flows",
role,
message_count = messages.len(),
structured,
"[flows] llm.complete: dispatching agent-node completion"
);
@@ -277,6 +347,26 @@ impl LlmProvider for OpenHumanLlm {
.await
.map_err(|e| EngineError::Capability(e.to_string()))?;
// Structured mode: surface the parsed object itself so downstream
// `=item.<field>` / `=nodes.<id>.item.<field>` bindings work. The
// agent node's output_parser sub-port then validates it against the
// configured schema (and auto-fixes when it doesn't parse here).
if structured {
if let Some(parsed) = response.text.as_deref().and_then(parse_llm_json) {
tracing::debug!(
target: "flows",
"[flows] llm.complete: structured output parsed from completion text"
);
return Ok(parsed);
}
tracing::warn!(
target: "flows",
"[flows] llm.complete: structured output requested but the completion did not \
parse as JSON — falling back to the {{text}} shape (the output_parser sub-port \
may still coerce it)"
);
}
Ok(json!({
"text": response.text,
"tool_calls": response.tool_calls,
@@ -542,9 +632,220 @@ pub struct OpenHumanTools {
pub config: Arc<Config>,
}
/// Prefix marking a `tool_call` node's slug as a NATIVE OpenHuman tool (the
/// "Tool" node) rather than a Composio action (the "App action" node). e.g.
/// `oh:web_search`. Native tools run through the same agent tool registry the
/// assistant uses (`runtime_node::ops::execute_tool`), so a flow can call
/// search / media generation / file / shell / etc. — the full toolset.
pub(crate) const NATIVE_TOOL_PREFIX: &str = "oh:";
/// Process-level cache for [`composio_required_args`]: toolkit → (uppercase
/// action slug → required top-level arg names). One `list_tools` fetch per
/// toolkit per process; schemas are effectively static within a session.
static REQUIRED_ARGS_CACHE: std::sync::OnceLock<
std::sync::Mutex<
std::collections::HashMap<String, std::collections::HashMap<String, Vec<String>>>,
>,
> = std::sync::OnceLock::new();
/// Seeds the required-args cache for a toolkit — test hook so preflight
/// behavior can be exercised without a live Composio backend.
#[cfg(test)]
pub(crate) fn seed_required_args_cache(
toolkit: &str,
entries: std::collections::HashMap<String, Vec<String>>,
) {
REQUIRED_ARGS_CACHE
.get_or_init(Default::default)
.lock()
.expect("required-args cache poisoned")
.insert(toolkit.to_string(), entries);
}
/// Best-effort lookup of a Composio action's **required** top-level parameter
/// names, from the toolkit's tool schemas (`parameters.required`).
///
/// Returns `None` when the schema is unavailable — unknown toolkit, client
/// construction failure, or a failed/empty listing — so callers can skip the
/// preflight rather than block execution on a catalog hiccup. Results are
/// cached per toolkit for the life of the process.
pub(crate) async fn composio_required_args(config: &Config, slug: &str) -> Option<Vec<String>> {
let toolkit = crate::openhuman::memory_sync::composio::providers::toolkit_from_slug(slug)?;
let slug_key = slug.to_ascii_uppercase();
if let Some(by_slug) = REQUIRED_ARGS_CACHE
.get_or_init(Default::default)
.lock()
.ok()?
.get(&toolkit)
{
return by_slug.get(&slug_key).cloned();
}
tracing::debug!(target: "flows", %toolkit, %slug, "[flows] preflight: fetching tool schemas for toolkit");
let resp = crate::openhuman::composio::ops::composio_list_tools(
config,
Some(vec![toolkit.clone()]),
None,
)
.await
.map_err(|e| {
tracing::debug!(target: "flows", %toolkit, error = %e, "[flows] preflight: schema fetch failed — skipping check");
e
})
.ok()?;
let mut by_slug = std::collections::HashMap::new();
for tool in &resp.value.tools {
let required: Vec<String> = tool
.function
.parameters
.as_ref()
.and_then(|p| p.get("required"))
.and_then(Value::as_array)
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default();
by_slug.insert(tool.function.name.to_ascii_uppercase(), required);
}
let found = by_slug.get(&slug_key).cloned();
if let Ok(mut cache) = REQUIRED_ARGS_CACHE.get_or_init(Default::default).lock() {
cache.insert(toolkit, by_slug);
}
found
}
/// Returns the names in `required` that are absent or `null` in `args`.
pub(crate) fn missing_required_args(required: &[String], args: &Value) -> Vec<String> {
required
.iter()
.filter(|name| match args.get(name.as_str()) {
None => true,
Some(v) => v.is_null(),
})
.cloned()
.collect()
}
/// Required-arg preflight for a Composio `tool_call`: fails **before** the
/// Composio dispatch when a required arg is missing or resolved to `null`,
/// with a message that names the field and the likely fix — instead of letting
/// the raw provider error surface from deep inside the call.
///
/// Best-effort by design: when the action's schema cannot be looked up the
/// check is skipped (never blocks on catalog availability).
pub(crate) async fn preflight_composio_args(
config: &Config,
slug: &str,
args: &Value,
) -> Result<()> {
let Some(required) = composio_required_args(config, slug).await else {
tracing::debug!(target: "flows", %slug, "[flows] preflight: no schema for action — skipping required-arg check");
return Ok(());
};
let missing = missing_required_args(&required, args);
if missing.is_empty() {
tracing::debug!(target: "flows", %slug, "[flows] preflight: all required args present");
return Ok(());
}
tracing::warn!(target: "flows", %slug, ?missing, "[flows] preflight: required arg(s) missing or null — failing before dispatch");
let list = missing
.iter()
.map(|m| format!("`{m}`"))
.collect::<Vec<_>>()
.join(", ");
let first = &missing[0];
Err(EngineError::Capability(format!(
"tool_call `{slug}`: required arg(s) {list} missing or resolved to null — wire each from \
an upstream node's output, e.g. \"{first}\": \"=nodes.<node_id>.item.<field>\". If the \
value comes from an agent node, give that agent an output schema \
(config.output_parser.schema) so its fields are addressable."
)))
}
/// A [`ToolInvoker`] decorator that runs the host's Composio required-arg
/// preflight before delegating to `inner`.
///
/// Used by `dry_run_workflow`: the dry-run path executes against tinyflows'
/// echo mocks, which would happily accept a `null` required arg — wrapping
/// the mock invoker with this makes the wiring check actually check wiring,
/// so an unwired required arg fails the dry run with the same actionable
/// message a real run would produce.
pub struct PreflightToolInvoker {
/// Host config, for the Composio schema lookup.
pub config: Arc<Config>,
/// The delegate that performs the actual invocation (e.g. the mock).
pub inner: Arc<dyn ToolInvoker>,
}
#[async_trait]
impl ToolInvoker for PreflightToolInvoker {
async fn invoke(&self, slug: &str, args: Value, conn: Option<&str>) -> Result<Value> {
if !slug.starts_with(NATIVE_TOOL_PREFIX) {
preflight_composio_args(&self.config, slug, &args).await?;
}
self.inner.invoke(slug, args, conn).await
}
}
#[async_trait]
impl ToolInvoker for OpenHumanTools {
async fn invoke(&self, slug: &str, args: Value, conn: Option<&str>) -> Result<Value> {
// Native OpenHuman tool path (the "Tool" node): `oh:<tool_name>`. Bypasses
// the Composio curation gate (it isn't a Composio slug) but still runs
// through the autonomy-tier + approval gates, then dispatches to the
// agent tool registry.
if let Some(tool_name) = slug.strip_prefix(NATIVE_TOOL_PREFIX) {
let tool_name = tool_name.trim();
if tool_name.is_empty() {
return Err(EngineError::Capability(
"tool_call node: native tool slug is empty (expected `oh:<tool_name>`)"
.to_string(),
));
}
let security = SecurityPolicy::from_config(
&self.config.autonomy,
&self.config.workspace_dir,
&self.config.action_dir,
);
let class = crate::openhuman::runtime_node::ops::classify_tool_call(
&self.config,
tool_name,
&args,
)
.map_err(EngineError::Capability)?;
let tier_decision = enforce_node_tier_gate(&security, class, "tool_call")?;
let summary = crate::openhuman::approval::summarize_action(tool_name, &args);
let redacted = crate::openhuman::approval::redact_args(&args);
let (outcome, _request_id) =
gate_call_for_tier(tier_decision, tool_name, &summary, redacted).await;
if let crate::openhuman::approval::GateOutcome::Deny { reason } = outcome {
return Err(EngineError::Capability(reason));
}
tracing::debug!(
target: "flows",
%tool_name,
?class,
?tier_decision,
"[flows] tool_call: dispatching NATIVE OpenHuman tool"
);
let outcome = crate::openhuman::runtime_node::ops::execute_tool(
&self.config,
tool_name,
args,
false,
)
.await
.map_err(EngineError::Capability)?;
return serde_json::to_value(&outcome.result).map_err(|e| {
EngineError::Capability(format!("could not serialize tool result: {e}"))
});
}
// Curation + scope gate — hard allowlist (see [`is_curated_flow_tool`]'s
// doc for why this differs from the general agent tool-call path).
// Runs before anything else — a rejected slug never reaches the
@@ -561,6 +862,12 @@ impl ToolInvoker for OpenHumanTools {
)));
}
// Required-arg preflight — fail with an actionable, field-naming error
// BEFORE the approval gate and the Composio dispatch, so a mis-wired
// arg (`=`-expression that resolved to null) never reaches the
// provider or asks the user to approve a call that cannot succeed.
preflight_composio_args(&self.config, slug, &args).await?;
// Approval gate (see the struct doc). Mirrors
// `tinyagents/middleware.rs::ApprovalSecurityMiddleware::wrap_tool`'s
// shape exactly: compute summary/redacted args only when a gate is
+16
View File
@@ -125,12 +125,27 @@ impl RunObserver for FlowRunObserver {
// Persist the live step. `duration_ms` is `u128` on the engine side;
// clamp into `u64` (a node executor taking > 584 million years is not a
// real concern, but never panic on cast).
if !step.diagnostics.is_empty() {
tracing::warn!(
target: "flows",
flow_id = %self.flow_id,
run_id = %self.run_id,
node = %step.node_id,
diagnostics = ?step.diagnostics,
"[flows] observer: step reported null-resolved expression(s) — persisting for the run view"
);
}
let flow_step = FlowRunStep {
node_id: step.node_id.clone(),
output: step.output.clone(),
port: None,
status: Some(status.to_string()),
duration_ms: Some(u64::try_from(step.duration_ms).unwrap_or(u64::MAX)),
diagnostics: step
.diagnostics
.iter()
.map(|d| serde_json::to_value(d).unwrap_or(serde_json::Value::Null))
.collect(),
};
if let Err(e) = upsert_flow_run_step(&self.config, &self.run_id, &flow_step) {
tracing::warn!(
@@ -191,6 +206,7 @@ mod tests {
status: StepStatus::Success,
output: Value::Null,
duration_ms: 5,
diagnostics: Vec::new(),
});
observer.on_run_finish(&Run {
id: "run-1".to_string(),
+134
View File
@@ -354,3 +354,137 @@ fn http_cred_name_returns_none_for_non_http_cred_ref_or_empty_name() {
assert_eq!(super::caps::http_cred_name("composio:slack:acct_1"), None);
assert_eq!(super::caps::http_cred_name("http_cred:"), None);
}
// ── structured agent output (parse_llm_json) ────────────────────────────
#[test]
fn parse_llm_json_accepts_bare_and_fenced_objects() {
let obj = super::caps::parse_llm_json(r#"{ "to": "a@b.com", "subject": "hi" }"#)
.expect("bare object parses");
assert_eq!(obj["to"], "a@b.com");
let fenced = "```json\n{ \"to\": \"a@b.com\" }\n```";
let obj = super::caps::parse_llm_json(fenced).expect("fenced object parses");
assert_eq!(obj["to"], "a@b.com");
let fenced_plain = "```\n[1, 2]\n```";
assert_eq!(
super::caps::parse_llm_json(fenced_plain),
Some(serde_json::json!([1, 2]))
);
}
#[test]
fn parse_llm_json_rejects_prose_and_scalars() {
// Prose is not JSON.
assert_eq!(super::caps::parse_llm_json("Sure! Here's the email."), None);
// Scalars parse as JSON but are not addressable — legacy shape instead.
assert_eq!(super::caps::parse_llm_json("42"), None);
assert_eq!(super::caps::parse_llm_json("\"just a string\""), None);
}
// ── tool_call required-arg preflight ─────────────────────────────────────
#[test]
fn missing_required_args_flags_absent_and_null() {
let required = vec!["to".to_string(), "subject".to_string(), "body".to_string()];
let args = json!({ "to": null, "subject": "hi" });
assert_eq!(
super::caps::missing_required_args(&required, &args),
vec!["to".to_string(), "body".to_string()]
);
let full = json!({ "to": "a@b.com", "subject": "hi", "body": "text" });
assert!(super::caps::missing_required_args(&required, &full).is_empty());
}
#[tokio::test]
async fn preflight_fails_before_dispatch_naming_the_missing_field() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);
// Seed the schema cache so no live Composio backend is needed.
let mut entries = std::collections::HashMap::new();
entries.insert(
"GMAIL_SEND_EMAIL".to_string(),
vec!["to".to_string(), "subject".to_string(), "body".to_string()],
);
super::caps::seed_required_args_cache("gmail", entries);
// `to` resolved to null (the classic mis-wired agent → tool_call case).
let err = super::caps::preflight_composio_args(
&config,
"GMAIL_SEND_EMAIL",
&json!({ "to": null, "subject": "hi", "body": "text" }),
)
.await
.expect_err("null required arg must fail preflight");
let msg = err.to_string();
assert!(msg.contains("`to`"), "error must name the field: {msg}");
assert!(
msg.contains("=nodes.<node_id>.item.<field>"),
"error must suggest the wiring fix: {msg}"
);
assert!(
msg.contains("output schema"),
"error must mention the agent output schema rule: {msg}"
);
// Fully-wired args pass.
super::caps::preflight_composio_args(
&config,
"GMAIL_SEND_EMAIL",
&json!({ "to": "a@b.com", "subject": "hi", "body": "text" }),
)
.await
.expect("wired args must pass preflight");
}
#[tokio::test]
async fn preflight_skips_when_no_schema_is_available() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);
// A slug whose toolkit is unknown to the curation catalog has no schema
// source at all — the preflight must skip, never block.
super::caps::preflight_composio_args(&config, "NOT_A_REAL_TOOLKIT_ACTION", &json!({}))
.await
.expect("preflight must be best-effort when no schema is available");
}
#[tokio::test]
async fn preflight_invoker_gates_the_mock_tool_path() {
use tinyflows::caps::ToolInvoker as _;
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);
let mut entries = std::collections::HashMap::new();
entries.insert("GMAIL_SEND_EMAIL".to_string(), vec!["to".to_string()]);
super::caps::seed_required_args_cache("gmail", entries);
let mock = tinyflows::caps::mock::mock_capabilities();
let invoker = super::caps::PreflightToolInvoker {
config,
inner: mock.tools.clone(),
};
// Unwired required arg: fails with the named field even though the inner
// mock would echo anything.
let err = invoker
.invoke("GMAIL_SEND_EMAIL", json!({ "to": null }), None)
.await
.expect_err("dry-run preflight must catch the unwired arg");
assert!(err.to_string().contains("`to`"));
// Wired arg: delegates to the mock echo.
let ok = invoker
.invoke("GMAIL_SEND_EMAIL", json!({ "to": "a@b.com" }), None)
.await
.expect("wired arg passes through to the mock");
assert_eq!(ok["tool"], "GMAIL_SEND_EMAIL");
// Native `oh:` slugs bypass the Composio preflight (no Composio schema).
// The mock echoes them unchecked.
let ok = invoker
.invoke("oh:web_search", json!({}), None)
.await
.expect("native slug bypasses composio preflight");
assert_eq!(ok["tool"], "oh:web_search");
}
+5 -1
View File
@@ -286,7 +286,11 @@ pub fn all_tools_with_runtime(
Box::new(GetFlowRunTool::new(config.clone())),
Box::new(ListFlowConnectionsTool::new(config.clone())),
Box::new(SearchToolCatalogTool::new()),
Box::new(DryRunWorkflowTool::new(security.clone())),
Box::new(DryRunWorkflowTool::new(security.clone(), config.clone())),
// Real end-to-end test run of a SAVED flow (Write / external-effect). The
// workflow-builder prompt requires it to ask the user for confirmation
// first, and the flow's own approval gate still pauses outbound nodes.
Box::new(RunFlowTool::new(config.clone())),
// Flow Scout discovery: the `flow_discovery` agent's terminal emit
// sink. Read-only reasoning over the user's data ends by calling
// `suggest_workflows`, which persists workflow ideas for the Flows page