mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(flows): improve workflow builder UX and diagnostics (#4574)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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].
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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} | ||||