mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(flows): tinyflows Phases 3–8 — editable canvas, authoring/templates, workflow-builder agent, engine 0.5.0, Skills rename (#4570)
This commit is contained in:
Generated
+1
-1
@@ -6888,7 +6888,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tinyflows"
|
||||
version = "0.3.2"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"futures-timer",
|
||||
|
||||
+6
-1
@@ -47,7 +47,12 @@ tinyplace = "2.0"
|
||||
# `src/openhuman/tinyflows/` + the `flows::` domain. Pulls tinyagents 1.7 transitively
|
||||
# (same version openhuman already uses — no conflict). Published on crates.io
|
||||
# and patched below to the vendored submodule.
|
||||
tinyflows = "0.3"
|
||||
#
|
||||
# `mock` feature: enables `tinyflows::caps::mock::mock_capabilities()` — the
|
||||
# deterministic in-memory capability bundle the flows `dry_run_workflow` agent
|
||||
# tool (Phase 5b) runs a *draft* graph against so the workflow-builder agent can
|
||||
# self-verify a proposal without any real side effects (no real LLM/tool/HTTP/code).
|
||||
tinyflows = { version = "0.5", features = ["mock"] }
|
||||
# TinyJuice — host-agnostic TokenJuice compression engine. OpenHuman keeps
|
||||
# config/RPC/tool/runtime adapters in `src/openhuman/tokenjuice/` and patches
|
||||
# this dependency to the vendored submodule below.
|
||||
|
||||
Generated
+5
-5
@@ -205,7 +205,7 @@ version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||
dependencies = [
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -216,7 +216,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"once_cell_polyfill",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8144,7 +8144,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -9237,7 +9237,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tinyflows"
|
||||
version = "0.3.2"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"futures-timer",
|
||||
@@ -9805,7 +9805,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
|
||||
dependencies = [
|
||||
"memoffset",
|
||||
"tempfile",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -38,7 +38,6 @@ vi.mock('./pages/Rewards', () => ({ default: () => <div /> }));
|
||||
vi.mock('./pages/Settings', () => ({ default: () => <div /> }));
|
||||
vi.mock('./pages/Skills', () => ({ default: () => <div /> }));
|
||||
vi.mock('./pages/Welcome', () => ({ default: () => <div /> }));
|
||||
vi.mock('./pages/WorkflowNew', () => ({ default: () => <div /> }));
|
||||
vi.mock('./pages/WorkflowsRun', () => ({ default: () => <div /> }));
|
||||
|
||||
const AppRoutes = (await import('./AppRoutes')).default;
|
||||
|
||||
+17
-15
@@ -12,7 +12,7 @@ import Accounts from './pages/Accounts';
|
||||
import Brain from './pages/Brain';
|
||||
import AgentInsightsPreview from './pages/dev/AgentInsightsPreview';
|
||||
import Feedback from './pages/Feedback';
|
||||
import FlowCanvasPage from './pages/FlowCanvasPage';
|
||||
import FlowCanvasPage, { FlowCanvasDraftPage } from './pages/FlowCanvasPage';
|
||||
import FlowsPage from './pages/FlowsPage';
|
||||
import Invites from './pages/Invites';
|
||||
import Notifications from './pages/Notifications';
|
||||
@@ -22,7 +22,6 @@ import Rewards from './pages/Rewards';
|
||||
import Skills from './pages/Skills';
|
||||
import WebCallbackPage from './pages/WebCallbackPage';
|
||||
import Welcome from './pages/Welcome';
|
||||
import WorkflowNew from './pages/WorkflowNew';
|
||||
import WorkflowsRun from './pages/WorkflowsRun';
|
||||
|
||||
interface AppRoutesProps {
|
||||
@@ -113,6 +112,19 @@ const AppRoutes = ({ location }: AppRoutesProps = {}) => {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
{/* Unsaved draft canvas (Phase 4e) — the chat WorkflowProposalCard's
|
||||
"Open in canvas" action lands here with the proposed graph in
|
||||
`location.state`. Declared BEFORE `/flows/:id` so it matches first;
|
||||
otherwise `:id` would capture "draft" and try to `flows_get('draft')`.
|
||||
Opening a draft never persists — the canvas's own Save is the gate. */}
|
||||
<Route
|
||||
path="/flows/draft"
|
||||
element={
|
||||
<ProtectedRoute requireAuth={true}>
|
||||
<FlowCanvasDraftPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/flows/:id"
|
||||
element={
|
||||
@@ -130,19 +142,9 @@ const AppRoutes = ({ location }: AppRoutesProps = {}) => {
|
||||
The old /skills path is kept as a back-compat redirect so bookmarks
|
||||
and deep links continue to work. `?tab=` query params are preserved
|
||||
by Navigate (replace) so existing deep links still land on the right
|
||||
sub-tab.
|
||||
`/workflows/new` is the create-a-skill authoring page.
|
||||
Order matters: keep `/workflows/new` before `/connections` so it wins
|
||||
the prefix match. */}
|
||||
<Route
|
||||
path="/workflows/new"
|
||||
element={
|
||||
<ProtectedRoute requireAuth={true}>
|
||||
<WorkflowNew />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
sub-tab. */}
|
||||
{/* `/workflows/run` is the single-purpose Skill runner page — the live
|
||||
destination of the Run button in the Automations tab (WorkflowsTab). */}
|
||||
<Route
|
||||
path="/workflows/run"
|
||||
element={
|
||||
|
||||
@@ -7,13 +7,24 @@ import { WorkflowProposalCard } from './WorkflowProposalCard';
|
||||
// Echo i18n keys so we can assert on the stable key string.
|
||||
vi.mock('../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (key: string) => key }) }));
|
||||
|
||||
const mockCreateFlow = vi.fn();
|
||||
// `vi.mock` factories are hoisted above the module's top-level statements, so
|
||||
// every handle a factory closes over must be declared via `vi.hoisted` rather
|
||||
// than a plain top-level `const` — otherwise it'd be a TDZ reference at the
|
||||
// time the (hoisted) factory runs. (These specific names happened to work
|
||||
// without it, since Vitest's compiler special-cases `mock`-prefixed
|
||||
// identifiers, but that's an incidental heuristic, not a guarantee.)
|
||||
const { mockCreateFlow, mockUpdateFlow, mockDispatch, mockNavigate } = vi.hoisted(() => ({
|
||||
mockCreateFlow: vi.fn(),
|
||||
mockUpdateFlow: vi.fn(),
|
||||
mockDispatch: vi.fn(),
|
||||
mockNavigate: vi.fn(),
|
||||
}));
|
||||
vi.mock('../../services/api/flowsApi', () => ({
|
||||
createFlow: (...args: unknown[]) => mockCreateFlow(...args),
|
||||
updateFlow: (...args: unknown[]) => mockUpdateFlow(...args),
|
||||
}));
|
||||
|
||||
const mockDispatch = vi.fn();
|
||||
vi.mock('../../store/hooks', () => ({ useAppDispatch: () => mockDispatch }));
|
||||
vi.mock('react-router-dom', () => ({ useNavigate: () => mockNavigate }));
|
||||
|
||||
function proposal(partial: Partial<WorkflowProposal> = {}): WorkflowProposal {
|
||||
return {
|
||||
@@ -34,7 +45,9 @@ function proposal(partial: Partial<WorkflowProposal> = {}): WorkflowProposal {
|
||||
describe('WorkflowProposalCard', () => {
|
||||
beforeEach(() => {
|
||||
mockCreateFlow.mockReset().mockResolvedValue({ id: 'f1', name: 'Daily standup summary' });
|
||||
mockUpdateFlow.mockReset();
|
||||
mockDispatch.mockReset();
|
||||
mockNavigate.mockReset();
|
||||
});
|
||||
|
||||
it('renders the name, trigger, and steps with node-kind badges', () => {
|
||||
@@ -85,6 +98,28 @@ describe('WorkflowProposalCard', () => {
|
||||
expect(mockDispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('opens the proposed graph in the canvas as an unsaved draft without persisting', () => {
|
||||
const p = proposal();
|
||||
render(<WorkflowProposalCard threadId="t1" proposal={p} />);
|
||||
fireEvent.click(screen.getByText('chat.flowProposal.openInCanvas'));
|
||||
|
||||
// Navigates to the draft canvas route, carrying the graph in router state.
|
||||
expect(mockNavigate).toHaveBeenCalledTimes(1);
|
||||
const [route, opts] = mockNavigate.mock.calls[0];
|
||||
expect(route).toBe('/flows/draft');
|
||||
expect(opts.state).toEqual({
|
||||
name: p.name,
|
||||
graph: p.graph,
|
||||
requireApproval: p.requireApproval,
|
||||
});
|
||||
|
||||
// The single persistence gate is untouched — no create/update, and the
|
||||
// proposal is left intact in the thread (not dismissed).
|
||||
expect(mockCreateFlow).not.toHaveBeenCalled();
|
||||
expect(mockUpdateFlow).not.toHaveBeenCalled();
|
||||
expect(mockDispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('dismiss clears the proposal without calling createFlow', () => {
|
||||
render(<WorkflowProposalCard threadId="t1" proposal={proposal()} />);
|
||||
fireEvent.click(screen.getByText('chat.flowProposal.dismiss'));
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import debug from 'debug';
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { FLOW_CANVAS_DRAFT_ROUTE, type FlowCanvasDraftState } from '../../lib/flows/canvasDraft';
|
||||
import type { WorkflowGraph } from '../../lib/flows/types';
|
||||
import { useT } from '../../lib/i18n/I18nContext';
|
||||
import { createFlow } from '../../services/api/flowsApi';
|
||||
import {
|
||||
@@ -33,6 +36,7 @@ interface Props {
|
||||
export const WorkflowProposalCard: React.FC<Props> = ({ threadId, proposal }) => {
|
||||
const { t } = useT();
|
||||
const dispatch = useAppDispatch();
|
||||
const navigate = useNavigate();
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||
|
||||
@@ -40,6 +44,31 @@ export const WorkflowProposalCard: React.FC<Props> = ({ threadId, proposal }) =>
|
||||
dispatch(clearWorkflowProposalForThread({ threadId }));
|
||||
};
|
||||
|
||||
/**
|
||||
* Open the proposed graph in the editable Workflow Canvas as an UNSAVED
|
||||
* draft. This deliberately does NOT persist or enable anything — no
|
||||
* `flows_create`/`flows_update` — so the user can review/edit first; the
|
||||
* canvas's own Save button stays the single persistence gate. The proposal
|
||||
* is left intact in the thread (not dismissed) so returning without saving
|
||||
* loses nothing.
|
||||
*/
|
||||
const openInCanvas = () => {
|
||||
const graph = proposal.graph as WorkflowGraph;
|
||||
// Log shape, not the user-authored `proposal.name` (no secrets/PII in logs).
|
||||
log(
|
||||
'openInCanvas: threadId=%s node_count=%d edge_count=%d',
|
||||
threadId,
|
||||
graph.nodes.length,
|
||||
graph.edges.length
|
||||
);
|
||||
const draft: FlowCanvasDraftState = {
|
||||
name: proposal.name,
|
||||
graph,
|
||||
requireApproval: proposal.requireApproval,
|
||||
};
|
||||
navigate(FLOW_CANVAS_DRAFT_ROUTE, { state: draft });
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (saving) return;
|
||||
setSaving(true);
|
||||
@@ -125,6 +154,14 @@ export const WorkflowProposalCard: React.FC<Props> = ({ threadId, proposal }) =>
|
||||
disabled={saving}>
|
||||
{saving ? t('chat.flowProposal.saving') : t('chat.flowProposal.save')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
data-analytics-id="workflow-proposal-open-canvas"
|
||||
onClick={openInCanvas}
|
||||
disabled={saving}>
|
||||
{t('chat.flowProposal.openInCanvas')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
|
||||
@@ -37,6 +37,7 @@ describe('FlowListRow', () => {
|
||||
onRun={vi.fn()}
|
||||
onViewRuns={vi.fn()}
|
||||
onView={vi.fn()}
|
||||
onExport={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -52,6 +53,7 @@ describe('FlowListRow', () => {
|
||||
onRun={vi.fn()}
|
||||
onViewRuns={vi.fn()}
|
||||
onView={vi.fn()}
|
||||
onExport={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -66,6 +68,7 @@ describe('FlowListRow', () => {
|
||||
onRun={vi.fn()}
|
||||
onViewRuns={vi.fn()}
|
||||
onView={vi.fn()}
|
||||
onExport={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -80,6 +83,7 @@ describe('FlowListRow', () => {
|
||||
onRun={vi.fn()}
|
||||
onViewRuns={vi.fn()}
|
||||
onView={vi.fn()}
|
||||
onExport={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -95,6 +99,7 @@ describe('FlowListRow', () => {
|
||||
onRun={vi.fn()}
|
||||
onViewRuns={vi.fn()}
|
||||
onView={vi.fn()}
|
||||
onExport={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -110,6 +115,7 @@ describe('FlowListRow', () => {
|
||||
onRun={vi.fn()}
|
||||
onViewRuns={vi.fn()}
|
||||
onView={vi.fn()}
|
||||
onExport={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -125,6 +131,7 @@ describe('FlowListRow', () => {
|
||||
onRun={vi.fn()}
|
||||
onViewRuns={vi.fn()}
|
||||
onView={vi.fn()}
|
||||
onExport={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -140,6 +147,7 @@ describe('FlowListRow', () => {
|
||||
onRun={vi.fn()}
|
||||
onViewRuns={vi.fn()}
|
||||
onView={vi.fn()}
|
||||
onExport={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -157,6 +165,7 @@ describe('FlowListRow', () => {
|
||||
onRun={onRun}
|
||||
onViewRuns={vi.fn()}
|
||||
onView={vi.fn()}
|
||||
onExport={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -174,6 +183,7 @@ describe('FlowListRow', () => {
|
||||
onRun={vi.fn()}
|
||||
onViewRuns={onViewRuns}
|
||||
onView={vi.fn()}
|
||||
onExport={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -193,6 +203,7 @@ describe('FlowListRow', () => {
|
||||
onRun={vi.fn()}
|
||||
onViewRuns={vi.fn()}
|
||||
onView={onView}
|
||||
onExport={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -211,6 +222,7 @@ describe('FlowListRow', () => {
|
||||
onRun={vi.fn()}
|
||||
onViewRuns={vi.fn()}
|
||||
onView={vi.fn()}
|
||||
onExport={vi.fn()}
|
||||
busy="run"
|
||||
/>
|
||||
);
|
||||
@@ -228,10 +240,31 @@ describe('FlowListRow', () => {
|
||||
onRun={vi.fn()}
|
||||
onViewRuns={vi.fn()}
|
||||
onView={vi.fn()}
|
||||
onExport={vi.fn()}
|
||||
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);
|
||||
|
||||
expect(onExport).toHaveBeenCalledWith(makeFlow());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,6 +38,8 @@ export interface FlowListRowProps {
|
||||
onViewRuns: (flow: Flow) => void;
|
||||
/** Opens the read-only Workflow Canvas for this flow (issue B5b.1). */
|
||||
onView: (flow: Flow) => void;
|
||||
/** Downloads this flow's `WorkflowGraph` as a JSON file (Phase 4d export). */
|
||||
onExport: (flow: Flow) => void;
|
||||
busy?: FlowListRowBusy;
|
||||
}
|
||||
|
||||
@@ -72,6 +74,7 @@ const FlowListRow = ({
|
||||
onRun,
|
||||
onViewRuns,
|
||||
onView,
|
||||
onExport,
|
||||
busy = null,
|
||||
}: FlowListRowProps) => {
|
||||
const { t } = useT();
|
||||
@@ -137,6 +140,14 @@ 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>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -25,8 +25,24 @@ import debug from 'debug';
|
||||
|
||||
import { useEscapeKey } from '../../hooks/useEscapeKey';
|
||||
import { useFlowRunPoller } from '../../hooks/useFlowRunPoller';
|
||||
import { type FlowNodeRunStatus, useFlowRunProgress } from '../../hooks/useFlowRunProgress';
|
||||
import { type FlowRunItem, normalizeItems } from '../../lib/flows/runItems';
|
||||
import { useT } from '../../lib/i18n/I18nContext';
|
||||
import type { FlowRunStatus, FlowRunStep } from '../../services/api/flowsApi';
|
||||
import Button from '../ui/Button';
|
||||
import { RunItemDataBrowser } from './RunItemDataBrowser';
|
||||
|
||||
/**
|
||||
* Context handed to the "Fix with agent" action (Phase 5c) so the canvas
|
||||
* copilot can open preloaded with the failed run. `flowId` routes to the flow's
|
||||
* canvas; the rest seeds the repair prompt.
|
||||
*/
|
||||
export interface FlowRepairRequest {
|
||||
flowId: string;
|
||||
runId: string;
|
||||
error?: string | null;
|
||||
failingNodeIds?: string[];
|
||||
}
|
||||
|
||||
const log = debug('flows:run-inspector-drawer');
|
||||
|
||||
@@ -76,27 +92,50 @@ function formatTimestamp(value: string | null | undefined): string | null {
|
||||
}).format(new Date(parsed));
|
||||
}
|
||||
|
||||
/** Render a step's `output` — pretty-printed JSON for objects/arrays, verbatim for strings. */
|
||||
function formatStepOutput(output: unknown): string {
|
||||
if (output == null) return '';
|
||||
if (typeof output === 'string') return output;
|
||||
try {
|
||||
return JSON.stringify(output, null, 2);
|
||||
} catch {
|
||||
return String(output);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Live per-node status dot colour, keyed off the socket `flow:run_progress`
|
||||
* feed (Phase 3e). Mirrors the run-level {@link FLOW_RUN_STATUS_DOT} language:
|
||||
* ocean (running, pulsing), sage (success), coral (error). Falls back to the
|
||||
* faint dot when the node has no live status yet (the poller stays the source
|
||||
* of truth for the durable step list).
|
||||
*/
|
||||
const FLOW_STEP_LIVE_DOT: Record<string, string> = {
|
||||
running: 'bg-ocean-500 animate-pulse',
|
||||
success: 'bg-sage-500',
|
||||
error: 'bg-coral-500',
|
||||
failed: 'bg-coral-500',
|
||||
};
|
||||
|
||||
function StepRow({ step, index }: { step: FlowRunStep; index: number }) {
|
||||
function StepRow({
|
||||
step,
|
||||
index,
|
||||
liveStatus,
|
||||
inputItems,
|
||||
}: {
|
||||
step: FlowRunStep;
|
||||
index: number;
|
||||
liveStatus?: FlowNodeRunStatus;
|
||||
/**
|
||||
* Normalized items of this step's *input* (the upstream step's output) so the
|
||||
* data browser can resolve `paired_item` back to a source input item.
|
||||
* Omitted for the first step, which has no upstream producer here.
|
||||
*/
|
||||
inputItems?: FlowRunItem[];
|
||||
}) {
|
||||
const { t } = useT();
|
||||
const outputText = formatStepOutput(step.output);
|
||||
const items = normalizeItems(step.output);
|
||||
const dotClass = (liveStatus && FLOW_STEP_LIVE_DOT[liveStatus]) ?? 'bg-content-faint';
|
||||
|
||||
return (
|
||||
<li
|
||||
data-testid={`flow-run-step-${index}`}
|
||||
className="rounded-lg border border-line bg-surface-muted p-2.5 text-xs">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="h-1.5 w-1.5 flex-none rounded-full bg-content-faint" aria-hidden />
|
||||
<span
|
||||
data-testid={`flow-run-step-dot-${index}`}
|
||||
className={`h-1.5 w-1.5 flex-none rounded-full ${dotClass}`}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="truncate font-mono font-medium text-content-secondary">
|
||||
{step.node_id}
|
||||
</span>
|
||||
@@ -108,14 +147,18 @@ function StepRow({ step, index }: { step: FlowRunStep; index: number }) {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{outputText.length > 0 && (
|
||||
{items.length > 0 && (
|
||||
<details className="mt-1.5">
|
||||
<summary className="cursor-pointer text-[11px] font-medium text-content-faint hover:text-content-secondary">
|
||||
{t('flowRuns.inspector.output')}
|
||||
</summary>
|
||||
<pre className="mt-1 max-h-60 overflow-auto whitespace-pre-wrap break-words rounded bg-surface px-2 py-1.5 font-mono text-[11px] leading-relaxed text-content-secondary">
|
||||
{outputText}
|
||||
</pre>
|
||||
<div className="mt-1.5">
|
||||
<RunItemDataBrowser
|
||||
items={items}
|
||||
inputItems={inputItems}
|
||||
testIdPrefix={`flow-run-step-${index}`}
|
||||
/>
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</li>
|
||||
@@ -126,6 +169,12 @@ interface Props {
|
||||
/** Run id (== thread_id) to inspect. Renders `null` (nothing) when absent. */
|
||||
runId: string | null;
|
||||
onClose: () => void;
|
||||
/**
|
||||
* "Fix with agent" (Phase 5c) — when provided and the run failed, a repair
|
||||
* action surfaces that hands the run context up so the host can open the
|
||||
* canvas copilot preloaded. Omitted where there's no copilot to route to.
|
||||
*/
|
||||
onFixWithAgent?: (request: FlowRepairRequest) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -133,9 +182,32 @@ interface Props {
|
||||
* unconditionally and just flip `runId` (same convention as
|
||||
* `SubagentDrawer`).
|
||||
*/
|
||||
export function FlowRunInspectorDrawer({ runId, onClose }: Props) {
|
||||
export function FlowRunInspectorDrawer({ runId, onClose, onFixWithAgent }: Props) {
|
||||
const { t } = useT();
|
||||
const { run, loading, error } = useFlowRunPoller(runId);
|
||||
// Live per-node status overlay (Phase 3e): the socket feed makes the poller's
|
||||
// durable step list feel live without replacing it as the source of truth.
|
||||
const liveStatuses = useFlowRunProgress(runId);
|
||||
|
||||
const handleFixWithAgent = () => {
|
||||
if (!run || !onFixWithAgent) return;
|
||||
// Best-effort failing-node hints from the live status feed (error/failed).
|
||||
const failingNodeIds = Object.entries(liveStatuses)
|
||||
.filter(([, status]) => status === 'error' || status === 'failed')
|
||||
.map(([nodeId]) => nodeId);
|
||||
log(
|
||||
'fix-with-agent: flow=%s run=%s failing=%d',
|
||||
run.flow_id,
|
||||
run.thread_id,
|
||||
failingNodeIds.length
|
||||
);
|
||||
onFixWithAgent({
|
||||
flowId: run.flow_id,
|
||||
runId: run.thread_id,
|
||||
error: run.error,
|
||||
failingNodeIds: failingNodeIds.length > 0 ? failingNodeIds : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
useEscapeKey(() => {
|
||||
log('escape: closing runId=%s', runId);
|
||||
@@ -242,6 +314,21 @@ export function FlowRunInspectorDrawer({ runId, onClose }: Props) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Repair entry point (Phase 5c): open the canvas copilot preloaded
|
||||
with this failed run so the workflow builder can propose a fix. */}
|
||||
{run.status === 'failed' && onFixWithAgent && (
|
||||
<div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
data-testid="flow-run-fix-with-agent"
|
||||
onClick={handleFixWithAgent}>
|
||||
{t('flowRuns.inspector.fixWithAgent')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pending approvals banner */}
|
||||
{run.status === 'pending_approval' && pendingCount > 0 && (
|
||||
<div
|
||||
@@ -266,7 +353,13 @@ export function FlowRunInspectorDrawer({ runId, onClose }: Props) {
|
||||
) : (
|
||||
<ol className="space-y-2" data-testid="flow-run-steps">
|
||||
{run.steps.map((step, idx) => (
|
||||
<StepRow key={`${step.node_id}-${idx}`} step={step} index={idx} />
|
||||
<StepRow
|
||||
key={`${step.node_id}-${idx}`}
|
||||
step={step}
|
||||
index={idx}
|
||||
liveStatus={liveStatuses[step.node_id]}
|
||||
inputItems={idx > 0 ? normalizeItems(run.steps[idx - 1].output) : undefined}
|
||||
/>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
FLOW_RUN_STATUS_ACCENT,
|
||||
FLOW_RUN_STATUS_DOT,
|
||||
FLOW_RUN_STATUS_KEY,
|
||||
type FlowRepairRequest,
|
||||
FlowRunInspectorDrawer,
|
||||
} from './FlowRunInspectorDrawer';
|
||||
|
||||
@@ -56,6 +57,8 @@ interface Props {
|
||||
/** Flow name for the drawer title, when known. */
|
||||
flowName?: string;
|
||||
onClose: () => void;
|
||||
/** "Fix with agent" (Phase 5c) — forwarded to the inspector for failed runs. */
|
||||
onFixWithAgent?: (request: FlowRepairRequest) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -63,7 +66,7 @@ interface Props {
|
||||
* unconditionally and just flip `flowId` (same convention as
|
||||
* `FlowRunInspectorDrawer`/`SubagentDrawer`).
|
||||
*/
|
||||
export function FlowRunsDrawer({ flowId, flowName, onClose }: Props) {
|
||||
export function FlowRunsDrawer({ flowId, flowName, onClose, onFixWithAgent }: Props) {
|
||||
const { t } = useT();
|
||||
const [runs, setRuns] = useState<FlowRun[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -209,7 +212,11 @@ export function FlowRunsDrawer({ flowId, flowName, onClose }: Props) {
|
||||
</div>
|
||||
|
||||
{selectedRunId && (
|
||||
<FlowRunInspectorDrawer runId={selectedRunId} onClose={() => setSelectedRunId(null)} />
|
||||
<FlowRunInspectorDrawer
|
||||
runId={selectedRunId}
|
||||
onClose={() => setSelectedRunId(null)}
|
||||
onFixWithAgent={onFixWithAgent}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* FlowTemplateGallery (Phase 4c) — the curated-template picker. Presentational
|
||||
* only: it renders the bundled `FLOW_TEMPLATES` as selectable cards and calls
|
||||
* `onSelect` with the chosen template. The *create* side effect
|
||||
* (`flows_create` + navigate into the canvas) lives in the caller
|
||||
* (`NewWorkflowModal` / `FlowsPage`), so this component is trivially testable
|
||||
* and reusable both inside the new-workflow modal and inline on the Workflows
|
||||
* empty state.
|
||||
*
|
||||
* Display strings are i18n'd via the `templateNameKey`/`templateDescriptionKey`/
|
||||
* `templateCategoryKey` helpers — no template English is hardcoded here.
|
||||
*/
|
||||
import createDebug from 'debug';
|
||||
|
||||
import {
|
||||
FLOW_TEMPLATES,
|
||||
type FlowTemplate,
|
||||
templateCategoryKey,
|
||||
templateDescriptionKey,
|
||||
templateNameKey,
|
||||
} from '../../lib/flows/templates';
|
||||
import { useT } from '../../lib/i18n/I18nContext';
|
||||
|
||||
const log = createDebug('app:flows:templates');
|
||||
|
||||
interface FlowTemplateGalleryProps {
|
||||
/** Called with the picked template; the caller performs the create + navigate. */
|
||||
onSelect: (template: FlowTemplate) => void;
|
||||
/** Template id currently being created (shows a spinner label, disables the grid). */
|
||||
busyId?: string | null;
|
||||
}
|
||||
|
||||
export default function FlowTemplateGallery({ onSelect, busyId }: FlowTemplateGalleryProps) {
|
||||
const { t } = useT();
|
||||
|
||||
if (FLOW_TEMPLATES.length === 0) {
|
||||
return (
|
||||
<p className="py-6 text-center text-sm text-content-muted" data-testid="flow-templates-empty">
|
||||
{t('flows.templates.empty')}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="flow-template-gallery"
|
||||
className="grid grid-cols-1 gap-3 sm:grid-cols-2"
|
||||
role="list">
|
||||
{FLOW_TEMPLATES.map(template => {
|
||||
const busy = busyId === template.id;
|
||||
const anyBusy = Boolean(busyId);
|
||||
return (
|
||||
<button
|
||||
key={template.id}
|
||||
type="button"
|
||||
role="listitem"
|
||||
data-testid={`flow-template-${template.id}`}
|
||||
disabled={anyBusy}
|
||||
onClick={() => {
|
||||
log('template selected: id=%s', template.id);
|
||||
onSelect(template);
|
||||
}}
|
||||
className="flex flex-col items-start gap-1.5 rounded-2xl border border-line bg-surface p-4 text-left transition-colors hover:border-primary-300 hover:bg-primary-50/40 disabled:cursor-not-allowed disabled:opacity-60 dark:hover:bg-primary-500/10">
|
||||
<span className="inline-flex items-center rounded-full bg-primary-50 px-2 py-0.5 text-[11px] font-medium text-primary-600 dark:bg-primary-500/10 dark:text-primary-400">
|
||||
{t(templateCategoryKey(template.category))}
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-content">
|
||||
{t(templateNameKey(template.id))}
|
||||
</span>
|
||||
<span className="text-xs leading-relaxed text-content-muted">
|
||||
{t(templateDescriptionKey(template.id))}
|
||||
</span>
|
||||
<span className="mt-1 text-xs font-medium text-primary-600 dark:text-primary-400">
|
||||
{busy ? t('flows.chooser.creating') : t('flows.templates.use')}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* NewWorkflowModal (Phase 4a) behavior tests.
|
||||
*
|
||||
* Covers the three chooser paths:
|
||||
* - "Start from scratch" creates a flow whose graph has a single `manual`
|
||||
* 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
|
||||
* suite asserts only this component's orchestration.
|
||||
*/
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { FLOW_TEMPLATES } from '../../lib/flows/templates';
|
||||
import NewWorkflowModal from './NewWorkflowModal';
|
||||
|
||||
const navigate = vi.hoisted(() => vi.fn());
|
||||
vi.mock('react-router-dom', async orig => ({
|
||||
...(await orig<typeof import('react-router-dom')>()),
|
||||
useNavigate: () => navigate,
|
||||
}));
|
||||
|
||||
const createFlow = vi.hoisted(() => vi.fn());
|
||||
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 };
|
||||
}
|
||||
|
||||
describe('NewWorkflowModal', () => {
|
||||
beforeEach(() => {
|
||||
navigate.mockReset();
|
||||
createFlow.mockReset();
|
||||
});
|
||||
|
||||
it('start from scratch creates a manual-trigger flow and opens its canvas', async () => {
|
||||
createFlow.mockResolvedValue({ id: 'flow-new' });
|
||||
renderModal();
|
||||
|
||||
fireEvent.click(screen.getByTestId('new-workflow-scratch'));
|
||||
|
||||
await waitFor(() => expect(createFlow).toHaveBeenCalledTimes(1));
|
||||
const [, graph] = createFlow.mock.calls[0];
|
||||
expect(graph.nodes).toHaveLength(1);
|
||||
expect(graph.nodes[0].kind).toBe('trigger');
|
||||
expect(graph.nodes[0].config.trigger_kind).toBe('manual');
|
||||
expect(graph.edges).toEqual([]);
|
||||
await waitFor(() => expect(navigate).toHaveBeenCalledWith('/flows/flow-new'));
|
||||
});
|
||||
|
||||
it('creating from a template calls flows_create with that template graph', async () => {
|
||||
createFlow.mockResolvedValue({ id: 'flow-tpl' });
|
||||
renderModal();
|
||||
|
||||
// Open the gallery.
|
||||
fireEvent.click(screen.getByTestId('new-workflow-template'));
|
||||
expect(screen.getByTestId('flow-template-gallery')).toBeTruthy();
|
||||
|
||||
const template = FLOW_TEMPLATES[0];
|
||||
fireEvent.click(screen.getByTestId(`flow-template-${template.id}`));
|
||||
|
||||
await waitFor(() => expect(createFlow).toHaveBeenCalledTimes(1));
|
||||
const [, graph] = createFlow.mock.calls[0];
|
||||
expect(graph).toBe(template.graph);
|
||||
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('can navigate from the gallery back to the chooser', () => {
|
||||
renderModal();
|
||||
fireEvent.click(screen.getByTestId('new-workflow-template'));
|
||||
expect(screen.getByTestId('flow-template-gallery')).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByTestId('new-workflow-gallery-back'));
|
||||
expect(screen.getByTestId('new-workflow-scratch')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('surfaces an error banner when flows_create rejects', async () => {
|
||||
createFlow.mockRejectedValue(new Error('boom'));
|
||||
renderModal();
|
||||
|
||||
fireEvent.click(screen.getByTestId('new-workflow-scratch'));
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('new-workflow-error')).toBeTruthy());
|
||||
expect(navigate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* NewWorkflowModal (Phase 4a) — the "New workflow" chooser. Replaces the old
|
||||
* FlowsPage `/chat` TODO with three ways to start:
|
||||
*
|
||||
* - **Start from scratch** — `flows_create` a blank graph carrying a single
|
||||
* `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.
|
||||
*
|
||||
* Create + navigate is delegated to {@link useCreateFlow}; this component only
|
||||
* owns which view is showing and assembles the name/graph for each path.
|
||||
*/
|
||||
import createDebug from 'debug';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { createBlankWorkflowGraph } from '../../lib/flows/newFlow';
|
||||
import { type FlowTemplate, templateNameKey } from '../../lib/flows/templates';
|
||||
import { useT } from '../../lib/i18n/I18nContext';
|
||||
import { ModalShell } from '../ui/ModalShell';
|
||||
import FlowTemplateGallery from './FlowTemplateGallery';
|
||||
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';
|
||||
|
||||
/** One big tap-target row in the chooser view. */
|
||||
function ChooserOption({
|
||||
testId,
|
||||
title,
|
||||
description,
|
||||
disabled,
|
||||
onClick,
|
||||
}: {
|
||||
testId: string;
|
||||
title: string;
|
||||
description: string;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={testId}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
className="flex w-full flex-col items-start gap-0.5 rounded-2xl border border-line bg-surface p-4 text-left transition-colors hover:border-primary-300 hover:bg-primary-50/40 disabled:cursor-not-allowed disabled:opacity-60 dark:hover:bg-primary-500/10">
|
||||
<span className="text-sm font-semibold text-content">{title}</span>
|
||||
<span className="text-xs leading-relaxed text-content-muted">{description}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const log = createDebug('app:flows:new');
|
||||
|
||||
export default function NewWorkflowModal({ onClose, onDescribe }: NewWorkflowModalProps) {
|
||||
const { t } = useT();
|
||||
const [view, setView] = useState<View>('chooser');
|
||||
const { create, busyKey, error, clearError } = useCreateFlow();
|
||||
|
||||
const startFromScratch = () => {
|
||||
const name = t('flows.page.newWorkflow');
|
||||
const graph = createBlankWorkflowGraph(name, t('flows.nodeKind.trigger'));
|
||||
log('start from scratch');
|
||||
void create(BLANK_FLOW_KEY, name, graph);
|
||||
};
|
||||
|
||||
const startFromTemplate = (template: FlowTemplate) => {
|
||||
const name = t(templateNameKey(template.id));
|
||||
log('start from template: id=%s', template.id);
|
||||
void create(template.id, name, template.graph);
|
||||
};
|
||||
|
||||
const openGallery = () => {
|
||||
clearError();
|
||||
setView('gallery');
|
||||
};
|
||||
|
||||
const backToChooser = () => {
|
||||
clearError();
|
||||
setView('chooser');
|
||||
};
|
||||
|
||||
const busy = Boolean(busyKey);
|
||||
const title = view === 'gallery' ? t('flows.templates.title') : t('flows.chooser.title');
|
||||
const subtitle = view === 'gallery' ? t('flows.templates.subtitle') : t('flows.chooser.subtitle');
|
||||
|
||||
return (
|
||||
<ModalShell
|
||||
onClose={onClose}
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
titleId="new-workflow-modal-title"
|
||||
maxWidthClassName={view === 'gallery' ? 'max-w-2xl' : 'max-w-md'}>
|
||||
<div className="space-y-3" data-testid="new-workflow-modal">
|
||||
{error && (
|
||||
<p
|
||||
role="alert"
|
||||
data-testid="new-workflow-error"
|
||||
className="rounded-xl border border-coral-200 bg-coral-50 px-3 py-2 text-xs text-coral-700 dark:border-coral-500/30 dark:bg-coral-500/10 dark:text-coral-300">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{view === 'chooser' ? (
|
||||
<>
|
||||
<ChooserOption
|
||||
testId="new-workflow-scratch"
|
||||
title={t('flows.chooser.scratchTitle')}
|
||||
description={t('flows.chooser.scratchDescription')}
|
||||
disabled={busy}
|
||||
onClick={startFromScratch}
|
||||
/>
|
||||
<ChooserOption
|
||||
testId="new-workflow-template"
|
||||
title={t('flows.chooser.templateTitle')}
|
||||
description={t('flows.chooser.templateDescription')}
|
||||
disabled={busy}
|
||||
onClick={openGallery}
|
||||
/>
|
||||
<ChooserOption
|
||||
testId="new-workflow-describe"
|
||||
title={t('flows.chooser.describeTitle')}
|
||||
description={t('flows.chooser.describeDescription')}
|
||||
disabled={busy}
|
||||
onClick={onDescribe}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="new-workflow-gallery-back"
|
||||
onClick={backToChooser}
|
||||
className="text-xs font-medium text-primary-600 hover:underline dark:text-primary-400">
|
||||
{t('flows.templates.back')}
|
||||
</button>
|
||||
<FlowTemplateGallery onSelect={startFromTemplate} busyId={busyKey} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ModalShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
/**
|
||||
* RunItemDataBrowser (Phase 6)
|
||||
* ----------------------------
|
||||
*
|
||||
* Per-item data browser for a single run step's output, extracted from
|
||||
* {@link FlowRunInspectorDrawer} to keep both files small. Renders the n8n
|
||||
* signature **table ⟷ JSON** toggle over the step's normalized output items
|
||||
* (see `lib/flows/runItems.ts`):
|
||||
*
|
||||
* - **Table view** — one row per item, columns derived from the union of the
|
||||
* items' `json` keys. Long cell values truncate (full value on hover via
|
||||
* `title`); the whole table scrolls horizontally when wide.
|
||||
* - **JSON view** — the items' `json` payloads pretty-printed, vertically
|
||||
* scrollable.
|
||||
*
|
||||
* Binary attachments are never inlined — they render as placeholder chips
|
||||
* (name / MIME). When an output item carries a resolved `paired_item` and the
|
||||
* caller supplied the step's `inputItems`, a "Source" affordance reveals the
|
||||
* input item that produced it; absent pairing, no affordance is offered.
|
||||
*/
|
||||
import debug from 'debug';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
cellValue,
|
||||
collectColumns,
|
||||
type FlowRunItem,
|
||||
formatCell,
|
||||
formatJson,
|
||||
hasObjectRows,
|
||||
} from '../../lib/flows/runItems';
|
||||
import { useT } from '../../lib/i18n/I18nContext';
|
||||
|
||||
const log = debug('flows:run-item-data-browser');
|
||||
|
||||
/** Cap a single table cell's rendered text so one huge value can't blow out the row. */
|
||||
const MAX_CELL_CHARS = 200;
|
||||
|
||||
type ViewMode = 'table' | 'json';
|
||||
|
||||
function truncate(text: string): string {
|
||||
return text.length > MAX_CELL_CHARS ? `${text.slice(0, MAX_CELL_CHARS)}…` : text;
|
||||
}
|
||||
|
||||
interface BinaryChipsProps {
|
||||
binary: FlowRunItem['binary'];
|
||||
testId: string;
|
||||
}
|
||||
|
||||
/** Placeholder chips for an item's binary attachments — metadata only, no bytes. */
|
||||
function BinaryChips({ binary, testId }: BinaryChipsProps) {
|
||||
const { t } = useT();
|
||||
if (binary.length === 0) return null;
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1" data-testid={testId}>
|
||||
{binary.map(ref => (
|
||||
<span
|
||||
key={ref.key}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-line bg-surface px-1.5 py-0.5 text-[10px] font-medium text-content-muted"
|
||||
title={ref.mimeType ?? undefined}>
|
||||
<span aria-hidden>📎</span>
|
||||
<span className="font-mono">{ref.fileName ?? ref.key}</span>
|
||||
<span className="rounded bg-surface-muted px-1 text-[9px] uppercase text-content-faint">
|
||||
{ref.mimeType ?? t('flowRuns.inspector.binaryLabel')}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/** Normalized output items of the step being inspected. */
|
||||
items: FlowRunItem[];
|
||||
/**
|
||||
* Normalized items of the step's *input* (typically the upstream step's
|
||||
* output). When present, output items carrying a resolved `paired_item` gain
|
||||
* a "Source" affordance that reveals the input item at that index. Omitted →
|
||||
* no pairing affordance is offered.
|
||||
*/
|
||||
inputItems?: FlowRunItem[];
|
||||
/** Stable prefix for `data-testid`s so multiple browsers on one screen don't collide. */
|
||||
testIdPrefix: string;
|
||||
}
|
||||
|
||||
export function RunItemDataBrowser({ items, inputItems, testIdPrefix }: Props) {
|
||||
const { t } = useT();
|
||||
const [view, setView] = useState<ViewMode>('table');
|
||||
// Which output row currently has its paired source input revealed (single-open).
|
||||
const [revealedSource, setRevealedSource] = useState<number | null>(null);
|
||||
|
||||
const columns = useMemo(() => collectColumns(items), [items]);
|
||||
const useColumns = hasObjectRows(items) && columns.length > 0;
|
||||
const showActions = useMemo(
|
||||
() =>
|
||||
items.some(
|
||||
item => item.binary.length > 0 || (item.pairedIndex !== null && inputItems !== undefined)
|
||||
),
|
||||
[items, inputItems]
|
||||
);
|
||||
|
||||
const jsonText = useMemo(() => formatJson(items.map(item => item.json)), [items]);
|
||||
|
||||
const totalColSpan = 1 + (useColumns ? columns.length : 1) + (showActions ? 1 : 0);
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<p className="text-[11px] italic text-content-faint" data-testid={`${testIdPrefix}-no-items`}>
|
||||
{t('flowRuns.inspector.noItems')}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const toggleSource = (index: number, pairedIndex: number) => {
|
||||
const next = revealedSource === index ? null : index;
|
||||
log(
|
||||
'toggleSource: prefix=%s row=%d paired=%d open=%s',
|
||||
testIdPrefix,
|
||||
index,
|
||||
pairedIndex,
|
||||
next !== null
|
||||
);
|
||||
setRevealedSource(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-testid={`${testIdPrefix}-data-browser`}>
|
||||
{/* Header: view toggle + item count. */}
|
||||
<div className="mb-1.5 flex items-center justify-between gap-2">
|
||||
<div
|
||||
className="inline-flex overflow-hidden rounded-md border border-line"
|
||||
role="group"
|
||||
aria-label={t('flowRuns.inspector.dataViewLabel')}>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`${testIdPrefix}-view-table`}
|
||||
aria-pressed={view === 'table'}
|
||||
onClick={() => setView('table')}
|
||||
className={`px-2 py-0.5 text-[11px] font-medium ${
|
||||
view === 'table'
|
||||
? 'bg-ocean-500 text-white'
|
||||
: 'bg-surface text-content-muted hover:bg-surface-hover'
|
||||
}`}>
|
||||
{t('flowRuns.inspector.dataTable')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`${testIdPrefix}-view-json`}
|
||||
aria-pressed={view === 'json'}
|
||||
onClick={() => setView('json')}
|
||||
className={`border-l border-line px-2 py-0.5 text-[11px] font-medium ${
|
||||
view === 'json'
|
||||
? 'bg-ocean-500 text-white'
|
||||
: 'bg-surface text-content-muted hover:bg-surface-hover'
|
||||
}`}>
|
||||
{t('flowRuns.inspector.dataJson')}
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-[10px] text-content-faint" data-testid={`${testIdPrefix}-item-count`}>
|
||||
{t('flowRuns.inspector.itemCount').replace('{count}', String(items.length))}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{view === 'json' ? (
|
||||
<pre
|
||||
data-testid={`${testIdPrefix}-json`}
|
||||
className="max-h-72 overflow-auto whitespace-pre-wrap break-words rounded bg-surface px-2 py-1.5 font-mono text-[11px] leading-relaxed text-content-secondary">
|
||||
{jsonText}
|
||||
</pre>
|
||||
) : (
|
||||
<div
|
||||
className="max-h-72 overflow-auto rounded border border-line"
|
||||
data-testid={`${testIdPrefix}-table-scroll`}>
|
||||
<table
|
||||
className="w-full border-collapse text-left text-[11px]"
|
||||
data-testid={`${testIdPrefix}-table`}>
|
||||
<thead>
|
||||
<tr className="bg-surface-muted text-content-muted">
|
||||
<th className="border-b border-line px-1.5 py-1 font-medium" aria-hidden />
|
||||
{useColumns ? (
|
||||
columns.map(column => (
|
||||
<th
|
||||
key={column}
|
||||
className="border-b border-line px-1.5 py-1 font-mono font-medium"
|
||||
scope="col">
|
||||
{column}
|
||||
</th>
|
||||
))
|
||||
) : (
|
||||
<th className="border-b border-line px-1.5 py-1 font-medium" scope="col">
|
||||
{t('flowRuns.inspector.dataJson')}
|
||||
</th>
|
||||
)}
|
||||
{showActions && <th className="border-b border-line px-1.5 py-1" aria-hidden />}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item, index) => {
|
||||
const canPair = item.pairedIndex !== null && inputItems !== undefined;
|
||||
const sourceItem =
|
||||
canPair && item.pairedIndex !== null ? inputItems?.[item.pairedIndex] : undefined;
|
||||
const isRevealed = revealedSource === index;
|
||||
return (
|
||||
<FragmentRow
|
||||
key={index}
|
||||
item={item}
|
||||
index={index}
|
||||
columns={columns}
|
||||
useColumns={useColumns}
|
||||
showActions={showActions}
|
||||
canPair={canPair}
|
||||
isRevealed={isRevealed}
|
||||
sourceItem={sourceItem}
|
||||
totalColSpan={totalColSpan}
|
||||
testIdPrefix={testIdPrefix}
|
||||
onToggleSource={() =>
|
||||
item.pairedIndex !== null && toggleSource(index, item.pairedIndex)
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface FragmentRowProps {
|
||||
item: FlowRunItem;
|
||||
index: number;
|
||||
columns: string[];
|
||||
useColumns: boolean;
|
||||
showActions: boolean;
|
||||
canPair: boolean;
|
||||
isRevealed: boolean;
|
||||
sourceItem: FlowRunItem | undefined;
|
||||
totalColSpan: number;
|
||||
testIdPrefix: string;
|
||||
onToggleSource: () => void;
|
||||
}
|
||||
|
||||
function FragmentRow({
|
||||
item,
|
||||
index,
|
||||
columns,
|
||||
useColumns,
|
||||
showActions,
|
||||
canPair,
|
||||
isRevealed,
|
||||
sourceItem,
|
||||
totalColSpan,
|
||||
testIdPrefix,
|
||||
onToggleSource,
|
||||
}: FragmentRowProps) {
|
||||
const { t } = useT();
|
||||
return (
|
||||
<>
|
||||
<tr
|
||||
data-testid={`${testIdPrefix}-row-${index}`}
|
||||
className="border-b border-line last:border-b-0 align-top">
|
||||
<th scope="row" className="px-1.5 py-1 text-left font-mono font-normal text-content-faint">
|
||||
{index + 1}
|
||||
</th>
|
||||
{useColumns ? (
|
||||
columns.map(column => {
|
||||
const text = formatCell(cellValue(item, column));
|
||||
return (
|
||||
<td
|
||||
key={column}
|
||||
className="max-w-[16rem] truncate px-1.5 py-1 font-mono text-content-secondary"
|
||||
title={text || undefined}>
|
||||
{truncate(text)}
|
||||
</td>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<td
|
||||
className="max-w-[16rem] truncate px-1.5 py-1 font-mono text-content-secondary"
|
||||
title={formatCell(item.json) || undefined}>
|
||||
{truncate(formatCell(item.json))}
|
||||
</td>
|
||||
)}
|
||||
{showActions && (
|
||||
<td className="whitespace-nowrap px-1.5 py-1">
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
<BinaryChips binary={item.binary} testId={`${testIdPrefix}-binary-${index}`} />
|
||||
{canPair && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`${testIdPrefix}-source-toggle-${index}`}
|
||||
aria-pressed={isRevealed}
|
||||
onClick={onToggleSource}
|
||||
className="rounded border border-line px-1.5 py-0.5 text-[10px] font-medium text-content-muted hover:bg-surface-hover">
|
||||
{isRevealed
|
||||
? t('flowRuns.inspector.hideSource')
|
||||
: t('flowRuns.inspector.showSource')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
{canPair && isRevealed && (
|
||||
<tr data-testid={`${testIdPrefix}-source-${index}`}>
|
||||
<td colSpan={totalColSpan} className="bg-surface-muted px-1.5 py-1.5">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wide text-content-faint">
|
||||
{t('flowRuns.inspector.sourceInputTitle')}
|
||||
</div>
|
||||
<pre className="mt-1 max-h-48 overflow-auto whitespace-pre-wrap break-words rounded bg-surface px-2 py-1.5 font-mono text-[11px] leading-relaxed text-content-secondary">
|
||||
{sourceItem ? formatJson(sourceItem.json) : t('flowRuns.inspector.emptyValue')}
|
||||
</pre>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default RunItemDataBrowser;
|
||||
@@ -0,0 +1,138 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { WorkflowGraph, WorkflowNode } from '../../lib/flows/types';
|
||||
import type { WorkflowProposal } from '../../store/chatRuntimeSlice';
|
||||
import WorkflowCopilotPanel from './WorkflowCopilotPanel';
|
||||
|
||||
vi.mock('../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (key: string) => key }) }));
|
||||
|
||||
const hookState = vi.hoisted(() => ({
|
||||
sending: false,
|
||||
proposal: null as WorkflowProposal | null,
|
||||
error: null as string | null,
|
||||
send: vi.fn(),
|
||||
clearProposal: vi.fn(),
|
||||
}));
|
||||
vi.mock('../../hooks/useWorkflowBuilderChat', () => ({ useWorkflowBuilderChat: () => hookState }));
|
||||
|
||||
function node(id: string): WorkflowNode {
|
||||
return { id, kind: 'agent', name: id, config: {}, ports: [] };
|
||||
}
|
||||
function graph(ids: string[]): WorkflowGraph {
|
||||
return { schema_version: 1, name: 'g', nodes: ids.map(node), edges: [] };
|
||||
}
|
||||
|
||||
function proposalWith(ids: string[]): WorkflowProposal {
|
||||
return {
|
||||
name: 'Revised flow',
|
||||
graph: graph(ids),
|
||||
requireApproval: true,
|
||||
summary: { trigger: 'manual', steps: [] },
|
||||
};
|
||||
}
|
||||
|
||||
const baseGraph = graph(['a', 'b']);
|
||||
|
||||
describe('WorkflowCopilotPanel', () => {
|
||||
beforeEach(() => {
|
||||
hookState.sending = false;
|
||||
hookState.proposal = null;
|
||||
hookState.error = null;
|
||||
hookState.send = vi.fn().mockResolvedValue(undefined);
|
||||
hookState.clearProposal = vi.fn();
|
||||
});
|
||||
|
||||
it('sends a revise turn that injects the current graph', async () => {
|
||||
render(
|
||||
<WorkflowCopilotPanel
|
||||
graph={baseGraph}
|
||||
onProposal={vi.fn()}
|
||||
onAccept={vi.fn()}
|
||||
onReject={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
fireEvent.change(screen.getByTestId('workflow-copilot-input'), {
|
||||
target: { value: 'add a Slack notification on failure' },
|
||||
});
|
||||
fireEvent.click(screen.getByTestId('workflow-copilot-send'));
|
||||
|
||||
expect(hookState.send).toHaveBeenCalledTimes(1);
|
||||
const arg = hookState.send.mock.calls[0][0];
|
||||
expect(arg.displayText).toBe('add a Slack notification on failure');
|
||||
expect(arg.prompt).toContain(JSON.stringify(baseGraph));
|
||||
});
|
||||
|
||||
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].
|
||||
hookState.proposal = proposalWith(['a', 'c']);
|
||||
render(
|
||||
<WorkflowCopilotPanel
|
||||
graph={baseGraph}
|
||||
onProposal={onProposal}
|
||||
onAccept={vi.fn()}
|
||||
onReject={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(onProposal).toHaveBeenCalledWith(hookState.proposal);
|
||||
// Both a single added ("c") and a single removed ("b") badge appear.
|
||||
expect(screen.getByTestId('workflow-copilot-added')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('workflow-copilot-removed')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Accept applies to the draft and clears the proposal (never persists)', () => {
|
||||
const onAccept = vi.fn();
|
||||
hookState.proposal = proposalWith(['a', 'c']);
|
||||
render(
|
||||
<WorkflowCopilotPanel
|
||||
graph={baseGraph}
|
||||
onProposal={vi.fn()}
|
||||
onAccept={onAccept}
|
||||
onReject={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByTestId('workflow-copilot-accept'));
|
||||
expect(onAccept).toHaveBeenCalledWith(hookState.proposal);
|
||||
expect(hookState.clearProposal).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Reject discards the proposal without applying it', () => {
|
||||
const onReject = vi.fn();
|
||||
const onAccept = vi.fn();
|
||||
hookState.proposal = proposalWith(['a', 'c']);
|
||||
render(
|
||||
<WorkflowCopilotPanel
|
||||
graph={baseGraph}
|
||||
onProposal={vi.fn()}
|
||||
onAccept={onAccept}
|
||||
onReject={onReject}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByTestId('workflow-copilot-reject'));
|
||||
expect(onReject).toHaveBeenCalledTimes(1);
|
||||
expect(onAccept).not.toHaveBeenCalled();
|
||||
expect(hookState.clearProposal).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('auto-sends a repair turn once when opened with a repair seed', () => {
|
||||
render(
|
||||
<WorkflowCopilotPanel
|
||||
graph={baseGraph}
|
||||
onProposal={vi.fn()}
|
||||
onAccept={vi.fn()}
|
||||
onReject={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
repairSeed={{ runId: 'run-7', error: 'boom', graph: baseGraph }}
|
||||
/>
|
||||
);
|
||||
expect(hookState.send).toHaveBeenCalledTimes(1);
|
||||
const arg = hookState.send.mock.calls[0][0];
|
||||
expect(arg.prompt).toContain('run-7');
|
||||
expect(arg.prompt).toContain('get_flow_run');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* WorkflowCopilotPanel (Phase 5c) — a side-panel chat bound to the
|
||||
* `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.
|
||||
*
|
||||
* Invariant: the copilot only PROPOSES. Accept applies to the UNSAVED local
|
||||
* draft (no `flows_update`); persistence stays behind the canvas's own Save.
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { useWorkflowBuilderChat } from '../../hooks/useWorkflowBuilderChat';
|
||||
import { diffGraphs } from '../../lib/flows/graphDiff';
|
||||
import type { WorkflowGraph } from '../../lib/flows/types';
|
||||
import {
|
||||
buildRepairPrompt,
|
||||
buildRevisePrompt,
|
||||
type RepairPromptContext,
|
||||
} from '../../lib/flows/workflowBuilderPrompt';
|
||||
import { useT } from '../../lib/i18n/I18nContext';
|
||||
import type { WorkflowProposal } from '../../store/chatRuntimeSlice';
|
||||
import Button from '../ui/Button';
|
||||
|
||||
interface Props {
|
||||
/** The current draft graph, injected as context for each revise turn. */
|
||||
graph: WorkflowGraph;
|
||||
/**
|
||||
* 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
|
||||
* reflects it.
|
||||
*/
|
||||
onProposal: (proposal: WorkflowProposal) => void;
|
||||
/** Accept the pending proposal into the local draft (host commits it). */
|
||||
onAccept: (proposal: WorkflowProposal) => void;
|
||||
/** Reject the pending proposal (host reverts the overlay). */
|
||||
onReject: () => void;
|
||||
/** Close the panel. */
|
||||
onClose: () => void;
|
||||
/**
|
||||
* Optional repair seed (from a failed run's "Fix with agent") — auto-sends a
|
||||
* repair turn once on mount so the copilot opens already diagnosing.
|
||||
*/
|
||||
repairSeed?: RepairPromptContext | null;
|
||||
}
|
||||
|
||||
export default function WorkflowCopilotPanel({
|
||||
graph,
|
||||
onProposal,
|
||||
onAccept,
|
||||
onReject,
|
||||
onClose,
|
||||
repairSeed = null,
|
||||
}: Props) {
|
||||
const { t } = useT();
|
||||
const { sending, proposal, error, send, clearProposal } = useWorkflowBuilderChat();
|
||||
const [text, setText] = useState('');
|
||||
|
||||
// Surface each NEW proposal to the host exactly once (enter preview overlay).
|
||||
const lastSurfacedRef = useRef<WorkflowProposal | null>(null);
|
||||
useEffect(() => {
|
||||
if (proposal && proposal !== lastSurfacedRef.current) {
|
||||
lastSurfacedRef.current = proposal;
|
||||
onProposal(proposal);
|
||||
}
|
||||
}, [proposal, onProposal]);
|
||||
|
||||
// Auto-send the repair turn once when opened from a failed run.
|
||||
const repairSentRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!repairSeed || repairSentRef.current) return;
|
||||
repairSentRef.current = true;
|
||||
void send({
|
||||
displayText: t('flows.copilot.repairDisplay'),
|
||||
prompt: buildRepairPrompt(repairSeed),
|
||||
});
|
||||
}, [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]);
|
||||
|
||||
const onKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
void submit();
|
||||
}
|
||||
},
|
||||
[submit]
|
||||
);
|
||||
|
||||
const accept = useCallback(() => {
|
||||
if (!proposal) return;
|
||||
onAccept(proposal);
|
||||
clearProposal();
|
||||
lastSurfacedRef.current = null;
|
||||
}, [proposal, onAccept, clearProposal]);
|
||||
|
||||
const reject = useCallback(() => {
|
||||
onReject();
|
||||
clearProposal();
|
||||
lastSurfacedRef.current = null;
|
||||
}, [onReject, clearProposal]);
|
||||
|
||||
const diff = proposal ? diffGraphs(graph, proposal.graph as WorkflowGraph) : null;
|
||||
|
||||
return (
|
||||
<aside
|
||||
data-testid="workflow-copilot-panel"
|
||||
className="flex h-full w-full max-w-sm flex-col border-l border-line bg-surface">
|
||||
<header className="flex items-start gap-2 border-b border-line px-3 py-2.5">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-semibold text-content">{t('flows.copilot.title')}</p>
|
||||
<p className="text-[11px] text-content-muted">{t('flows.copilot.subtitle')}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="workflow-copilot-close"
|
||||
aria-label={t('flows.copilot.close')}
|
||||
onClick={onClose}
|
||||
className="shrink-0 rounded-full p-1.5 text-content-faint hover:bg-surface-hover hover:text-content-secondary">
|
||||
✕
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 space-y-3 overflow-y-auto px-3 py-3 text-sm">
|
||||
{!proposal && !sending && (
|
||||
<p className="text-xs text-content-muted" data-testid="workflow-copilot-empty">
|
||||
{t('flows.copilot.emptyState')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{sending && (
|
||||
<p className="text-xs text-content-muted" data-testid="workflow-copilot-thinking">
|
||||
{t('flows.copilot.thinking')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-coral" data-testid="workflow-copilot-error">
|
||||
{error === 'offline' ? t('flows.copilot.offline') : t('flows.copilot.error')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{proposal && diff && (
|
||||
<div
|
||||
data-testid="workflow-copilot-proposal"
|
||||
className="rounded-xl border border-ocean-300 bg-surface p-3 dark:border-ocean-700">
|
||||
<p className="text-xs font-semibold text-ocean-900 dark:text-ocean-100">
|
||||
{proposal.name || t('flows.copilot.proposalTitle')}
|
||||
</p>
|
||||
<p className="mt-1 text-[11px] text-content-muted">{t('flows.copilot.previewHint')}</p>
|
||||
|
||||
<div className="mt-2 flex flex-wrap gap-1.5 text-[11px]">
|
||||
{diff.addedNodeIds.size > 0 && (
|
||||
<span
|
||||
data-testid="workflow-copilot-added"
|
||||
className="rounded-full bg-sage-100 px-2 py-0.5 font-medium text-sage-700 dark:bg-sage-500/15 dark:text-sage-300">
|
||||
{t('flows.copilot.added').replace('{count}', String(diff.addedNodeIds.size))}
|
||||
</span>
|
||||
)}
|
||||
{diff.removedNodeIds.size > 0 && (
|
||||
<span
|
||||
data-testid="workflow-copilot-removed"
|
||||
className="rounded-full bg-coral-100 px-2 py-0.5 font-medium text-coral-700 dark:bg-coral-500/15 dark:text-coral-300">
|
||||
{t('flows.copilot.removed').replace('{count}', String(diff.removedNodeIds.size))}
|
||||
</span>
|
||||
)}
|
||||
{!diff.hasChanges && (
|
||||
<span className="text-content-faint">{t('flows.copilot.noChanges')}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
data-testid="workflow-copilot-accept"
|
||||
onClick={accept}>
|
||||
{t('flows.copilot.accept')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
data-testid="workflow-copilot-reject"
|
||||
onClick={reject}>
|
||||
{t('flows.copilot.reject')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { WorkflowProposal } from '../../store/chatRuntimeSlice';
|
||||
import WorkflowPromptBar from './WorkflowPromptBar';
|
||||
|
||||
// Echo i18n keys.
|
||||
vi.mock('../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (key: string) => key }) }));
|
||||
|
||||
// Stub the proposal card so we only assert it renders with the right props.
|
||||
vi.mock('../chat/WorkflowProposalCard', () => ({
|
||||
default: ({ threadId, proposal }: { threadId: string; proposal: WorkflowProposal }) => (
|
||||
<div data-testid="stub-proposal-card">
|
||||
{threadId}:{proposal.name}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const hookState = vi.hoisted(() => ({
|
||||
threadId: null as string | null,
|
||||
sending: false,
|
||||
proposal: null as WorkflowProposal | null,
|
||||
error: null as string | null,
|
||||
send: vi.fn(),
|
||||
clearProposal: vi.fn(),
|
||||
}));
|
||||
vi.mock('../../hooks/useWorkflowBuilderChat', () => ({ useWorkflowBuilderChat: () => hookState }));
|
||||
|
||||
describe('WorkflowPromptBar', () => {
|
||||
beforeEach(() => {
|
||||
hookState.threadId = null;
|
||||
hookState.sending = false;
|
||||
hookState.proposal = null;
|
||||
hookState.error = null;
|
||||
hookState.send = vi.fn().mockResolvedValue(undefined);
|
||||
hookState.clearProposal = vi.fn();
|
||||
});
|
||||
|
||||
it('submits a builder turn with a delegation prompt containing the description', async () => {
|
||||
render(<WorkflowPromptBar />);
|
||||
fireEvent.change(screen.getByTestId('workflow-prompt-input'), {
|
||||
target: { value: 'digest my Slack every morning' },
|
||||
});
|
||||
fireEvent.click(screen.getByTestId('workflow-prompt-submit'));
|
||||
|
||||
expect(hookState.send).toHaveBeenCalledTimes(1);
|
||||
const arg = hookState.send.mock.calls[0][0];
|
||||
expect(arg.displayText).toBe('digest my Slack every morning');
|
||||
expect(arg.prompt).toContain('digest my Slack every morning');
|
||||
expect(arg.prompt.toLowerCase()).toContain('workflow builder');
|
||||
});
|
||||
|
||||
it('does not submit empty/whitespace input', () => {
|
||||
render(<WorkflowPromptBar />);
|
||||
fireEvent.change(screen.getByTestId('workflow-prompt-input'), { target: { value: ' ' } });
|
||||
fireEvent.click(screen.getByTestId('workflow-prompt-submit'));
|
||||
expect(hookState.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders the resulting proposal inline via WorkflowProposalCard', () => {
|
||||
hookState.threadId = 'builder-thread-1';
|
||||
hookState.proposal = {
|
||||
name: 'Morning digest',
|
||||
graph: { nodes: [], edges: [] },
|
||||
requireApproval: true,
|
||||
summary: { trigger: 'schedule', steps: [] },
|
||||
};
|
||||
render(<WorkflowPromptBar />);
|
||||
const card = screen.getByTestId('stub-proposal-card');
|
||||
expect(card).toHaveTextContent('builder-thread-1:Morning digest');
|
||||
});
|
||||
|
||||
it('shows the offline hint when the hook reports offline', () => {
|
||||
hookState.error = 'offline';
|
||||
render(<WorkflowPromptBar />);
|
||||
expect(screen.getByTestId('workflow-prompt-error')).toHaveTextContent(
|
||||
'flows.promptBar.offline'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* WorkflowPromptBar (Phase 5c) — the prompt-first authoring surface at the top
|
||||
* of the Flows page (and its empty-state hero). The user describes a workflow in
|
||||
* natural language; submitting spawns a `workflow_builder` turn in a DEDICATED
|
||||
* thread (via {@link useWorkflowBuilderChat}) and renders the returned proposal
|
||||
* inline with the existing {@link WorkflowProposalCard} ("Open in canvas" +
|
||||
* "Save & enable").
|
||||
*
|
||||
* Nothing here persists or enables a flow — the composer only asks the agent to
|
||||
* PROPOSE. Saving stays behind the card's explicit "Save & enable" click.
|
||||
*/
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { useWorkflowBuilderChat } from '../../hooks/useWorkflowBuilderChat';
|
||||
import { buildCreatePrompt } from '../../lib/flows/workflowBuilderPrompt';
|
||||
import { useT } from '../../lib/i18n/I18nContext';
|
||||
import WorkflowProposalCard from '../chat/WorkflowProposalCard';
|
||||
import Button from '../ui/Button';
|
||||
|
||||
interface Props {
|
||||
/** Compact (list header) vs. hero (empty-state) presentation. */
|
||||
variant?: 'compact' | 'hero';
|
||||
/** Optional autofocus for the empty-state hero. */
|
||||
autoFocus?: boolean;
|
||||
}
|
||||
|
||||
export default function WorkflowPromptBar({ variant = 'compact', autoFocus = false }: Props) {
|
||||
const { t } = useT();
|
||||
const { threadId, sending, proposal, error, send } = useWorkflowBuilderChat();
|
||||
const [text, setText] = useState('');
|
||||
|
||||
const submit = useCallback(async () => {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed || sending) return;
|
||||
await send({ displayText: trimmed, prompt: buildCreatePrompt(trimmed) });
|
||||
setText('');
|
||||
}, [text, sending, send]);
|
||||
|
||||
const onKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
// Enter submits; Shift+Enter inserts a newline.
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
void submit();
|
||||
}
|
||||
},
|
||||
[submit]
|
||||
);
|
||||
|
||||
const isHero = variant === 'hero';
|
||||
|
||||
return (
|
||||
<section
|
||||
data-testid="workflow-prompt-bar"
|
||||
className={
|
||||
isHero
|
||||
? 'rounded-2xl border border-ocean-200 bg-ocean-50/50 p-4 dark:border-ocean-700/50 dark:bg-ocean-500/5'
|
||||
: 'rounded-xl border border-line bg-surface p-3'
|
||||
}>
|
||||
<label htmlFor="workflow-prompt-input" className="sr-only">
|
||||
{t('flows.promptBar.label')}
|
||||
</label>
|
||||
{isHero && (
|
||||
<div className="mb-2">
|
||||
<h3 className="text-sm font-semibold text-content">{t('flows.promptBar.heroTitle')}</h3>
|
||||
<p className="text-xs text-content-muted">{t('flows.promptBar.heroSubtitle')}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-end gap-2">
|
||||
<textarea
|
||||
id="workflow-prompt-input"
|
||||
data-testid="workflow-prompt-input"
|
||||
value={text}
|
||||
onChange={e => setText(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
rows={isHero ? 2 : 1}
|
||||
autoFocus={autoFocus}
|
||||
disabled={sending}
|
||||
placeholder={t('flows.promptBar.placeholder')}
|
||||
className="min-h-[38px] 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-prompt-submit"
|
||||
disabled={sending || text.trim().length === 0}
|
||||
onClick={() => void submit()}>
|
||||
{sending ? t('flows.promptBar.thinking') : t('flows.promptBar.submit')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="mt-2 text-xs text-coral" data-testid="workflow-prompt-error">
|
||||
{error === 'offline' ? t('flows.promptBar.offline') : t('flows.promptBar.error')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{threadId && proposal && (
|
||||
<div className="mt-3" data-testid="workflow-prompt-proposal">
|
||||
<WorkflowProposalCard threadId={threadId} proposal={proposal} />
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -74,15 +74,23 @@ describe('FlowRunInspectorDrawer', () => {
|
||||
expect(screen.getByTestId('flow-run-step-port-1')).toHaveTextContent('true');
|
||||
});
|
||||
|
||||
it('expands a step to reveal its output', () => {
|
||||
it('expands a step to reveal its output in the per-item data browser', () => {
|
||||
useFlowRunPoller.mockReturnValue({ run: makeRun(), loading: false, error: null });
|
||||
renderDrawer('thread-1', vi.fn());
|
||||
|
||||
const step = screen.getByTestId('flow-run-step-0');
|
||||
expect(step.querySelector('pre')).not.toBeVisible();
|
||||
// Data browser lives inside a collapsed <details> until expanded.
|
||||
expect(screen.queryByTestId('flow-run-step-0-data-browser')).not.toBeVisible();
|
||||
fireEvent.click(screen.getAllByText('Output')[0]);
|
||||
expect(step.querySelector('pre')).toBeVisible();
|
||||
expect(step.querySelector('pre')?.textContent).toContain('"rows": 3');
|
||||
|
||||
// Default table view shows the `rows` column and its value.
|
||||
const browser = screen.getByTestId('flow-run-step-0-data-browser');
|
||||
expect(browser).toBeVisible();
|
||||
expect(screen.getByTestId('flow-run-step-0-table')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('flow-run-step-0-row-0')).toHaveTextContent('3');
|
||||
|
||||
// Toggling to JSON shows the pretty-printed payload.
|
||||
fireEvent.click(screen.getByTestId('flow-run-step-0-view-json'));
|
||||
expect(screen.getByTestId('flow-run-step-0-json').textContent).toContain('"rows": 3');
|
||||
});
|
||||
|
||||
it('shows an error state when the poller reports an error', () => {
|
||||
@@ -140,4 +148,51 @@ describe('FlowRunInspectorDrawer', () => {
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// ── "Fix with agent" repair entry point (Phase 5c) ────────────────────────
|
||||
it('shows "Fix with agent" only for a failed run when the handler is provided', () => {
|
||||
useFlowRunPoller.mockReturnValue({
|
||||
run: makeRun({ status: 'failed', error: 'HTTP 500' }),
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<FlowRunInspectorDrawer runId="thread-1" onClose={vi.fn()} onFixWithAgent={vi.fn()} />
|
||||
</Provider>
|
||||
);
|
||||
expect(screen.getByTestId('flow-run-fix-with-agent')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides "Fix with agent" for a non-failed run', () => {
|
||||
useFlowRunPoller.mockReturnValue({
|
||||
run: makeRun({ status: 'completed' }),
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<FlowRunInspectorDrawer runId="thread-1" onClose={vi.fn()} onFixWithAgent={vi.fn()} />
|
||||
</Provider>
|
||||
);
|
||||
expect(screen.queryByTestId('flow-run-fix-with-agent')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hands the failed run context up when "Fix with agent" is clicked', () => {
|
||||
useFlowRunPoller.mockReturnValue({
|
||||
run: makeRun({ status: 'failed', error: 'HTTP 500', flow_id: 'flow-42', thread_id: 'run-9' }),
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
const onFixWithAgent = vi.fn();
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<FlowRunInspectorDrawer runId="run-9" onClose={vi.fn()} onFixWithAgent={onFixWithAgent} />
|
||||
</Provider>
|
||||
);
|
||||
fireEvent.click(screen.getByTestId('flow-run-fix-with-agent'));
|
||||
expect(onFixWithAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ flowId: 'flow-42', runId: 'run-9', error: 'HTTP 500' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* RunItemDataBrowser (Phase 6) — table ⟷ JSON toggle, binary chips, and the
|
||||
* input↔output pairing affordance.
|
||||
*
|
||||
* `useT` falls back to the English map without a provider (see
|
||||
* `lib/i18n/I18nContext.tsx`), so these render bare.
|
||||
*/
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { normalizeItems } from '../../../lib/flows/runItems';
|
||||
import { RunItemDataBrowser } from '../RunItemDataBrowser';
|
||||
|
||||
const PREFIX = 'browser';
|
||||
|
||||
describe('RunItemDataBrowser', () => {
|
||||
it('renders one table row per item with union columns', () => {
|
||||
const items = normalizeItems([
|
||||
{ json: { a: 'alpha', b: 'ex' } },
|
||||
{ json: { b: 'why', c: true } },
|
||||
]);
|
||||
render(<RunItemDataBrowser items={items} testIdPrefix={PREFIX} />);
|
||||
|
||||
const table = screen.getByTestId(`${PREFIX}-table`);
|
||||
// Union columns a, b, c all present as headers.
|
||||
const headers = within(table)
|
||||
.getAllByRole('columnheader')
|
||||
.map(h => h.textContent);
|
||||
expect(headers).toEqual(expect.arrayContaining(['a', 'b', 'c']));
|
||||
|
||||
expect(screen.getByTestId(`${PREFIX}-row-0`)).toBeInTheDocument();
|
||||
expect(screen.getByTestId(`${PREFIX}-row-1`)).toBeInTheDocument();
|
||||
expect(screen.queryByTestId(`${PREFIX}-row-2`)).not.toBeInTheDocument();
|
||||
|
||||
// Row 0 shows its own values; row 1 shows the disjoint `c` value.
|
||||
const row0 = within(screen.getByTestId(`${PREFIX}-row-0`));
|
||||
expect(row0.getByText('alpha')).toBeInTheDocument();
|
||||
expect(row0.getByText('ex')).toBeInTheDocument();
|
||||
const row1 = within(screen.getByTestId(`${PREFIX}-row-1`));
|
||||
expect(row1.getByText('true')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('toggles to JSON view and shows pretty-printed payloads', () => {
|
||||
const items = normalizeItems([{ json: { rows: 3 } }]);
|
||||
render(<RunItemDataBrowser items={items} testIdPrefix={PREFIX} />);
|
||||
|
||||
// Table is the default view.
|
||||
expect(screen.getByTestId(`${PREFIX}-table`)).toBeInTheDocument();
|
||||
expect(screen.queryByTestId(`${PREFIX}-json`)).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByTestId(`${PREFIX}-view-json`));
|
||||
|
||||
const json = screen.getByTestId(`${PREFIX}-json`);
|
||||
expect(json.textContent).toContain('"rows": 3');
|
||||
expect(screen.queryByTestId(`${PREFIX}-table`)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a binary item as a placeholder chip, not inlined bytes', () => {
|
||||
const items = normalizeItems([
|
||||
{
|
||||
json: { name: 'report' },
|
||||
binary: { file: { fileName: 'report.pdf', mimeType: 'application/pdf' } },
|
||||
},
|
||||
]);
|
||||
render(<RunItemDataBrowser items={items} testIdPrefix={PREFIX} />);
|
||||
|
||||
const chip = screen.getByTestId(`${PREFIX}-binary-0`);
|
||||
expect(chip).toHaveTextContent('report.pdf');
|
||||
expect(chip).toHaveTextContent('application/pdf');
|
||||
});
|
||||
|
||||
it('reveals the source input item when paired_item is present', () => {
|
||||
const items = normalizeItems([{ json: { out: 'derived' }, paired_item: 1 }]);
|
||||
const inputItems = normalizeItems([{ json: { src: 'wrong' } }, { json: { src: 'right' } }]);
|
||||
render(<RunItemDataBrowser items={items} inputItems={inputItems} testIdPrefix={PREFIX} />);
|
||||
|
||||
// Source panel hidden until toggled.
|
||||
expect(screen.queryByTestId(`${PREFIX}-source-0`)).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByTestId(`${PREFIX}-source-toggle-0`));
|
||||
const source = screen.getByTestId(`${PREFIX}-source-0`);
|
||||
// paired_item = 1 → the second input item.
|
||||
expect(source.textContent).toContain('right');
|
||||
expect(source.textContent).not.toContain('wrong');
|
||||
|
||||
// Toggling again hides it.
|
||||
fireEvent.click(screen.getByTestId(`${PREFIX}-source-toggle-0`));
|
||||
expect(screen.queryByTestId(`${PREFIX}-source-0`)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('offers no pairing affordance when paired_item is absent', () => {
|
||||
const items = normalizeItems([{ json: { out: 'x' } }]);
|
||||
const inputItems = normalizeItems([{ json: { src: 'y' } }]);
|
||||
render(<RunItemDataBrowser items={items} inputItems={inputItems} testIdPrefix={PREFIX} />);
|
||||
expect(screen.queryByTestId(`${PREFIX}-source-toggle-0`)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('offers no pairing affordance when inputItems are not supplied', () => {
|
||||
const items = normalizeItems([{ json: { out: 'x' }, paired_item: 0 }]);
|
||||
render(<RunItemDataBrowser items={items} testIdPrefix={PREFIX} />);
|
||||
expect(screen.queryByTestId(`${PREFIX}-source-toggle-0`)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows an empty-state message when there are no items', () => {
|
||||
render(<RunItemDataBrowser items={[]} testIdPrefix={PREFIX} />);
|
||||
expect(screen.getByTestId(`${PREFIX}-no-items`)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,554 @@
|
||||
/**
|
||||
* EditableFlowCanvas — the mutable Workflow Canvas (issue B5b.2 / Phase 3a).
|
||||
* Wraps `@xyflow/react`'s `<ReactFlow>` in *controlled* mode: node/edge state
|
||||
* is lifted into `useNodesState`/`useEdgesState` seeded once from the incoming
|
||||
* graph, so drags, connections, additions, and deletions mutate local state
|
||||
* rather than the read-only viewer's static props.
|
||||
*
|
||||
* What it wires on top of the read-only `FlowCanvas`:
|
||||
* - **drag / move** — `nodesDraggable` on; `onNodesChange` persists positions.
|
||||
* - **connect** — `onConnect` is port-aware: it accepts a new edge only when
|
||||
* {@link isValidFlowConnection} approves it (reusing the canvas's derived
|
||||
* input/output ports), and rejects self-loops, unknown handles, and dupes.
|
||||
* - **delete** — Backspace/Delete removes the selection (React Flow default),
|
||||
* plus an explicit "Delete selected" toolbar button as a discoverable
|
||||
* affordance; deleting a node also drops its incident edges.
|
||||
* - **add** — a {@link NodePalette} inserts any of the 12 node kinds by click
|
||||
* (default cascade position) or drag-drop (under the cursor).
|
||||
* - **save** — a "Save" button serializes the live canvas back to a
|
||||
* `WorkflowGraph` via {@link xyflowToWorkflowGraph} and hands it to `onSave`.
|
||||
* The dirty-guard / persistence call lives one layer up (Phase 3d).
|
||||
*/
|
||||
import {
|
||||
addEdge,
|
||||
Background,
|
||||
BackgroundVariant,
|
||||
type Connection,
|
||||
Controls,
|
||||
MiniMap,
|
||||
ReactFlow,
|
||||
type ReactFlowInstance,
|
||||
useEdgesState,
|
||||
useNodesState,
|
||||
} from '@xyflow/react';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
import createDebug from 'debug';
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { FLOW_RUN_NODE_STATUS_CLASS, useFlowRunProgress } from '../../../hooks/useFlowRunProgress';
|
||||
import { erroredNodeIds } from '../../../lib/flows/flowValidation';
|
||||
import {
|
||||
createFlowNode,
|
||||
FLOW_NODE_TYPE,
|
||||
type FlowEdge,
|
||||
type FlowNode,
|
||||
isValidFlowConnection,
|
||||
type WorkflowGraphMeta,
|
||||
xyflowToWorkflowGraph,
|
||||
} from '../../../lib/flows/graphAdapter';
|
||||
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 './flowCanvasStyles.css';
|
||||
import FlowNodeComponent from './FlowNodeComponent';
|
||||
import FlowValidationBanner from './FlowValidationBanner';
|
||||
import NodeConfigDrawer, { type NodeConfigPatch } from './nodeConfig/NodeConfigDrawer';
|
||||
import NodePalette, { PALETTE_DND_MIME } from './NodePalette';
|
||||
import { useFlowValidation } from './useFlowValidation';
|
||||
|
||||
const log = createDebug('app:flows:canvas:edit');
|
||||
|
||||
const NODE_TYPES = { [FLOW_NODE_TYPE]: FlowNodeComponent };
|
||||
const DELETE_KEYS = ['Backspace', 'Delete'];
|
||||
|
||||
/** Where a click-added palette node lands (canvas coords) before cascade. */
|
||||
const CLICK_ADD_ORIGIN = { x: 80, y: 80 };
|
||||
/** Per-click cascade so repeated palette clicks don't stack on one spot. */
|
||||
const CLICK_ADD_STEP = 32;
|
||||
|
||||
export interface EditableFlowCanvasProps {
|
||||
nodes: FlowNode[];
|
||||
edges: FlowEdge[];
|
||||
/** Graph-level metadata xyflow doesn't carry, needed to re-serialize on save. */
|
||||
meta: WorkflowGraphMeta;
|
||||
/**
|
||||
* Called with the current canvas serialized to a `WorkflowGraph` when the
|
||||
* user clicks Save. The caller owns the `flows_update` RPC (Phase 3d); this
|
||||
* component runs validation and gates Save on hard errors before invoking it.
|
||||
* May return a promise — Save awaits it, and only advances the dirty baseline
|
||||
* (clearing unsaved state) once it resolves. A rejection surfaces inline.
|
||||
*/
|
||||
onSave?: (graph: WorkflowGraph) => void | Promise<void>;
|
||||
/** Fired when a drawn connection is rejected as invalid (for a toast in 3c). */
|
||||
onInvalidConnection?: (connection: Connection) => void;
|
||||
/**
|
||||
* Reports the draft's dirty state (unsaved edits vs the last saved baseline)
|
||||
* so the host page can gate navigation-away (Phase 3d).
|
||||
*/
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
/**
|
||||
* Id of the currently-executing run (== thread_id) to overlay live per-node
|
||||
* status on the canvas (Phase 3e). `null`/absent means no run is in flight,
|
||||
* so no overlay is drawn. The live feed is best-effort — the durable
|
||||
* `flow_runs` row + {@link useFlowRunPoller} remain the source of truth.
|
||||
*/
|
||||
activeRunId?: string | null;
|
||||
/**
|
||||
* Reports the canvas's live graph on every edit (Phase 5c) so the host can
|
||||
* feed the current draft to the copilot as context and diff a proposal
|
||||
* against it. Fires with the same serialization Save uses.
|
||||
*/
|
||||
onGraphChange?: (graph: WorkflowGraph) => void;
|
||||
/**
|
||||
* Node ids the copilot's pending proposal ADDS — ringed sage as a diff
|
||||
* highlight (Phase 5c). Empty/absent when not previewing a proposal.
|
||||
*/
|
||||
addedNodeIds?: ReadonlySet<string>;
|
||||
/**
|
||||
* Node ids the copilot's pending proposal REMOVES — ghosted (Phase 5c). These
|
||||
* nodes are still rendered (carried over by the host) so the removal is
|
||||
* visible before Accept/Reject.
|
||||
*/
|
||||
removedNodeIds?: ReadonlySet<string>;
|
||||
/**
|
||||
* Force-disable Save (Phase 5c) — set while a copilot proposal is under
|
||||
* review so the ghosted preview graph can't be persisted; Accept/Reject in
|
||||
* the copilot panel is the gate instead.
|
||||
*/
|
||||
saveDisabled?: boolean;
|
||||
/**
|
||||
* Seed the dirty flag as already-unsaved at mount (Phase 5c fix). This
|
||||
* component's dirty baseline is seeded from `nodes`/`edges` at mount, so
|
||||
* whenever the host remounts the canvas with a new key (e.g. accepting a
|
||||
* copilot proposal, `FlowCanvasPage`'s `canvasVersion` bump) the freshly
|
||||
* mounted graph would otherwise instantly read as "clean" even though it
|
||||
* was never actually persisted via `onSave` — losing the accepted changes
|
||||
* on back/reload instead of gating them behind Save. The host computes
|
||||
* this by comparing the incoming graph against its own last-persisted
|
||||
* snapshot and passes the result through, independent of any canvas
|
||||
* remount.
|
||||
*/
|
||||
initialDirty?: boolean;
|
||||
}
|
||||
|
||||
const EMPTY_ID_SET: ReadonlySet<string> = new Set();
|
||||
|
||||
function EditableFlowCanvas({
|
||||
nodes: initialNodes,
|
||||
edges: initialEdges,
|
||||
meta,
|
||||
onSave,
|
||||
onInvalidConnection,
|
||||
onDirtyChange,
|
||||
activeRunId = null,
|
||||
onGraphChange,
|
||||
addedNodeIds = EMPTY_ID_SET,
|
||||
removedNodeIds = EMPTY_ID_SET,
|
||||
saveDisabled = false,
|
||||
initialDirty = false,
|
||||
}: EditableFlowCanvasProps) {
|
||||
const { t } = useT();
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState<FlowNode>(initialNodes);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState<FlowEdge>(initialEdges);
|
||||
const rfRef = useRef<ReactFlowInstance<FlowNode, FlowEdge> | null>(null);
|
||||
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);
|
||||
const [connections, setConnections] = useState<FlowConnection[]>([]);
|
||||
|
||||
// ── Draft / dirty state (Phase 3d) ────────────────────────────────────────
|
||||
// The last *saved* snapshot: the graph is "dirty" whenever the live canvas
|
||||
// serializes to something different. Seeded from the incoming graph and
|
||||
// advanced on every successful Save so post-save the canvas reads clean.
|
||||
const [baseline, setBaseline] = useState<{ nodes: FlowNode[]; edges: FlowEdge[] }>(() => ({
|
||||
nodes: initialNodes,
|
||||
edges: initialEdges,
|
||||
}));
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
// Host-computed "already unsaved at mount" override (Phase 5c fix, see
|
||||
// `initialDirty`'s doc comment) — cleared once a real Save/Discard
|
||||
// resolves this instance's baseline, same as the self-computed `dirty`.
|
||||
const [forcedDirty, setForcedDirty] = useState(initialDirty);
|
||||
|
||||
const currentGraph = useMemo(
|
||||
() => xyflowToWorkflowGraph(nodes, edges, meta),
|
||||
[nodes, edges, meta]
|
||||
);
|
||||
const currentKey = useMemo(() => JSON.stringify(currentGraph), [currentGraph]);
|
||||
|
||||
// Report the live graph up (Phase 5c) so the copilot always has the current
|
||||
// draft to build on. Keyed on `currentKey` so it fires once per real change,
|
||||
// not on every render.
|
||||
useEffect(() => {
|
||||
onGraphChange?.(currentGraph);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentKey]);
|
||||
const baselineKey = useMemo(
|
||||
() => JSON.stringify(xyflowToWorkflowGraph(baseline.nodes, baseline.edges, meta)),
|
||||
[baseline, meta]
|
||||
);
|
||||
const dirty = forcedDirty || currentKey !== baselineKey;
|
||||
|
||||
// Notify the host page so it can gate navigation-away while dirty.
|
||||
useEffect(() => {
|
||||
onDirtyChange?.(dirty);
|
||||
}, [dirty, onDirtyChange]);
|
||||
|
||||
// ── Validation (Phase 3c) ─────────────────────────────────────────────────
|
||||
const { validation, validating, validateNow } = useFlowValidation(currentGraph, currentKey);
|
||||
// Only a *present, failed* validation blocks Save — a null result (not yet
|
||||
// run, or the RPC failed) fails open, since the server re-validates on update.
|
||||
const hasErrors = validation ? !validation.valid : false;
|
||||
|
||||
// Ids named by a hard error, so the canvas can ring the offending node(s).
|
||||
const erroredIds = useMemo(
|
||||
() =>
|
||||
erroredNodeIds(
|
||||
validation && !validation.valid ? validation.errors : [],
|
||||
nodes.map(n => n.id)
|
||||
),
|
||||
[validation, nodes]
|
||||
);
|
||||
// ── Live run overlay (Phase 3e) ───────────────────────────────────────────
|
||||
// Subscribe to the core's per-step progress feed for the active run and map
|
||||
// each node id to a live-status ring class. This CLOSES Phase 1's deferred
|
||||
// "frontend consumes FlowRunProgress" follow-up. The 2s poller in
|
||||
// `useFlowRunPoller` stays as the durable fallback; this just makes it live.
|
||||
const runProgress = useFlowRunProgress(activeRunId);
|
||||
|
||||
// Derive the render array (never stored in draft, so it can't dirty the graph):
|
||||
// tag errored nodes with the `flow-node-error` class the canvas CSS rings, and
|
||||
// overlay each node's live run status (`flow-node-running`/`-success`/`-failed`).
|
||||
const hasRunOverlay = Object.keys(runProgress).length > 0;
|
||||
const hasDiffOverlay = addedNodeIds.size > 0 || removedNodeIds.size > 0;
|
||||
const displayNodes = useMemo(() => {
|
||||
if (erroredIds.size === 0 && !hasRunOverlay && !hasDiffOverlay) return nodes;
|
||||
return nodes.map(n => {
|
||||
const extra: string[] = [];
|
||||
if (erroredIds.has(n.id)) extra.push('flow-node-error');
|
||||
const runClass = FLOW_RUN_NODE_STATUS_CLASS[runProgress[n.id]];
|
||||
if (runClass) extra.push(runClass);
|
||||
// Copilot diff overlay (Phase 5c): sage ring on added, ghost on removed.
|
||||
if (addedNodeIds.has(n.id)) extra.push('flow-node-added');
|
||||
if (removedNodeIds.has(n.id)) extra.push('flow-node-removed');
|
||||
if (extra.length === 0) return n;
|
||||
return { ...n, className: `${n.className ?? ''} ${extra.join(' ')}`.trim() };
|
||||
});
|
||||
// `runProgress` is a stable-enough dependency (new object only on a real
|
||||
// status change, see the hook's setState guard).
|
||||
}, [nodes, erroredIds, runProgress, hasRunOverlay, hasDiffOverlay, addedNodeIds, removedNodeIds]);
|
||||
|
||||
// Load the secret-free credential refs once for the node-config credential
|
||||
// picker (http_request / tool_call). Guarded: outside Tauri (or if the RPC
|
||||
// fails) the picker just shows its empty state rather than throwing.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const list = await listFlowConnections();
|
||||
if (cancelled) return;
|
||||
log('connections loaded: count=%d', list.length);
|
||||
setConnections(list);
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
log('connections load failed (non-fatal): %o', err);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const nextNodeId = useCallback((kind: NodeKind): string => {
|
||||
// Prefix keeps palette-added ids from ever colliding with loaded graph ids
|
||||
// (which are arbitrary backend strings); the counter keeps them unique
|
||||
// within a session even for same-kind, same-millisecond clicks.
|
||||
return `new-${kind}-${addCounter.current++}`;
|
||||
}, []);
|
||||
|
||||
const onConnect = useCallback(
|
||||
(connection: Connection) => {
|
||||
if (!isValidFlowConnection(connection, nodes, edges)) {
|
||||
log('onConnect: rejected %o', connection);
|
||||
onInvalidConnection?.(connection);
|
||||
return;
|
||||
}
|
||||
log('onConnect: accepted %o', connection);
|
||||
setEdges(current => addEdge(connection, current));
|
||||
},
|
||||
[nodes, edges, setEdges, onInvalidConnection]
|
||||
);
|
||||
|
||||
// Live drag feedback: React Flow calls this while dragging a new connection
|
||||
// and paints the target handle valid/invalid before the drop commits.
|
||||
const isValidConnection = useCallback(
|
||||
(connection: Connection | FlowEdge) =>
|
||||
isValidFlowConnection(connection as Connection, nodes, edges),
|
||||
[nodes, edges]
|
||||
);
|
||||
|
||||
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]);
|
||||
},
|
||||
[nextNodeId, setNodes, t]
|
||||
);
|
||||
|
||||
const handlePaletteAdd = useCallback(
|
||||
(kind: NodeKind) => {
|
||||
const step = addCounter.current * CLICK_ADD_STEP;
|
||||
addNode(kind, { x: CLICK_ADD_ORIGIN.x + step, y: CLICK_ADD_ORIGIN.y + step });
|
||||
},
|
||||
[addNode]
|
||||
);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(event: React.DragEvent) => {
|
||||
event.preventDefault();
|
||||
const kind = event.dataTransfer.getData(PALETTE_DND_MIME) as NodeKind;
|
||||
if (!kind) return;
|
||||
const instance = rfRef.current;
|
||||
const position = instance
|
||||
? instance.screenToFlowPosition({ x: event.clientX, y: event.clientY })
|
||||
: { ...CLICK_ADD_ORIGIN };
|
||||
addNode(kind, position);
|
||||
},
|
||||
[addNode]
|
||||
);
|
||||
|
||||
const handleDragOver = useCallback((event: React.DragEvent) => {
|
||||
event.preventDefault();
|
||||
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]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
// Hard errors block Save (warnings are allowed through). Belt-and-braces:
|
||||
// the button is also disabled in this state.
|
||||
if (hasErrors) {
|
||||
log('save: blocked — graph has validation errors');
|
||||
return;
|
||||
}
|
||||
const graph = xyflowToWorkflowGraph(nodes, edges, meta);
|
||||
log('save: nodes=%d edges=%d', graph.nodes.length, graph.edges.length);
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
try {
|
||||
await onSave?.(graph);
|
||||
// Advance the dirty baseline to the just-saved snapshot so the canvas
|
||||
// reads clean (and the nav guard stands down) until the next edit.
|
||||
setBaseline({ nodes, edges });
|
||||
setForcedDirty(false);
|
||||
log('save: succeeded — baseline advanced');
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
log('save: failed err=%o', err);
|
||||
setSaveError(message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [hasErrors, nodes, edges, meta, onSave]);
|
||||
|
||||
// Discard all unsaved edits, resetting the canvas to the last saved baseline.
|
||||
const handleDiscard = useCallback(() => {
|
||||
log(
|
||||
'discard: resetting to baseline nodes=%d edges=%d',
|
||||
baseline.nodes.length,
|
||||
baseline.edges.length
|
||||
);
|
||||
setNodes(baseline.nodes);
|
||||
setEdges(baseline.edges);
|
||||
setConfigNodeId(null);
|
||||
setSaveError(null);
|
||||
setForcedDirty(false);
|
||||
}, [baseline, setNodes, setEdges]);
|
||||
|
||||
const handleValidate = useCallback(() => {
|
||||
log('validate: manual trigger');
|
||||
void validateNow();
|
||||
}, [validateNow]);
|
||||
|
||||
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);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// Apply a name/config edit from the drawer to the live node state (controlled).
|
||||
const updateNode = useCallback(
|
||||
(nodeId: string, patch: NodeConfigPatch) => {
|
||||
log(
|
||||
'updateNode: id=%s name=%s config=%s',
|
||||
nodeId,
|
||||
patch.name ?? '(unchanged)',
|
||||
patch.config ? 'present' : '(unchanged)'
|
||||
);
|
||||
setNodes(current =>
|
||||
current.map(n =>
|
||||
n.id === nodeId
|
||||
? {
|
||||
...n,
|
||||
data: {
|
||||
...n.data,
|
||||
...(patch.name !== undefined ? { name: patch.name } : {}),
|
||||
...(patch.config !== undefined ? { config: patch.config } : {}),
|
||||
},
|
||||
}
|
||||
: n
|
||||
)
|
||||
);
|
||||
},
|
||||
[setNodes]
|
||||
);
|
||||
|
||||
// Close the drawer AND clear the selection, so re-clicking the same node
|
||||
// re-fires `onSelectionChange` and reopens it.
|
||||
const handleCloseConfig = useCallback(() => {
|
||||
log('closeConfig: deselecting all nodes');
|
||||
setConfigNodeId(null);
|
||||
setNodes(current =>
|
||||
current.some(n => n.selected) ? current.map(n => ({ ...n, selected: false })) : current
|
||||
);
|
||||
}, [setNodes]);
|
||||
|
||||
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} />
|
||||
|
||||
<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} />
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(EditableFlowCanvas);
|
||||
@@ -1,16 +1,29 @@
|
||||
/**
|
||||
* FlowCanvas — the read-only Workflow Canvas view (issue B5b.1): renders a
|
||||
* saved flow's `WorkflowGraph` (already converted to xyflow's shape by
|
||||
* `graphAdapter.ts`) with a minimap, zoom/pan controls, and a dotted
|
||||
* background. This is the first slice of the visual builder — editing
|
||||
* (dragging nodes, drawing new edges) lands in B5b.2+; here every
|
||||
* interaction that would mutate the graph is disabled.
|
||||
* FlowCanvas — the Workflow Canvas view. Two modes behind one entry point:
|
||||
*
|
||||
* - **read-only** (default, issue B5b.1): renders a saved flow's
|
||||
* `WorkflowGraph` (already converted to xyflow's shape by `graphAdapter.ts`)
|
||||
* with a minimap, zoom/pan controls, and a dotted background. Every
|
||||
* interaction that would mutate the graph is disabled.
|
||||
* - **editable** (`editable`, issue B5b.2 / Phase 3a): delegates to
|
||||
* {@link EditableFlowCanvas}, which lifts nodes/edges into controlled state
|
||||
* and wires drag/connect/add/delete/save on top.
|
||||
*
|
||||
* The `editable` prop defaults to `false` so every existing read-only consumer
|
||||
* (the `/flows/:id` viewer) keeps its exact behavior — only the builder opts in.
|
||||
*/
|
||||
import { Background, BackgroundVariant, Controls, MiniMap, ReactFlow } from '@xyflow/react';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
import { memo, useMemo } from 'react';
|
||||
|
||||
import { FLOW_NODE_TYPE, type FlowEdge, type FlowNode } from '../../../lib/flows/graphAdapter';
|
||||
import {
|
||||
FLOW_NODE_TYPE,
|
||||
type FlowEdge,
|
||||
type FlowNode,
|
||||
type WorkflowGraphMeta,
|
||||
} from '../../../lib/flows/graphAdapter';
|
||||
import type { WorkflowGraph } from '../../../lib/flows/types';
|
||||
import EditableFlowCanvas from './EditableFlowCanvas';
|
||||
import './flowCanvasStyles.css';
|
||||
import FlowNodeComponent from './FlowNodeComponent';
|
||||
|
||||
@@ -18,24 +31,53 @@ export interface FlowCanvasProps {
|
||||
nodes: FlowNode[];
|
||||
edges: FlowEdge[];
|
||||
/**
|
||||
* Whether the canvas allows editing. Defaults to `true` (read-only) since
|
||||
* this slice ships no editing UI at all — B5b.2+ will pass `false` once an
|
||||
* editor exists.
|
||||
* Enable the editable builder (drag/connect/add/delete/save). Defaults to
|
||||
* `false` — the read-only viewer that ships everywhere else stays intact.
|
||||
*/
|
||||
readonly?: boolean;
|
||||
editable?: boolean;
|
||||
/** Graph-level metadata needed to re-serialize on save (editable only). */
|
||||
meta?: WorkflowGraphMeta;
|
||||
/** Save callback: receives the live canvas as a `WorkflowGraph` (editable only). */
|
||||
onSave?: (graph: WorkflowGraph) => void | Promise<void>;
|
||||
/** Reports the editable draft's dirty state so the host can guard navigation (editable only). */
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
/** Active run id (== thread_id) to overlay live per-node status on the canvas (editable only, Phase 3e). */
|
||||
activeRunId?: string | null;
|
||||
/** Reports the live graph on every edit so the copilot has the current draft (editable only, Phase 5c). */
|
||||
onGraphChange?: (graph: WorkflowGraph) => void;
|
||||
/** Node ids the copilot proposal adds — ringed as a diff highlight (editable only, Phase 5c). */
|
||||
addedNodeIds?: ReadonlySet<string>;
|
||||
/** Node ids the copilot proposal removes — ghosted (editable only, Phase 5c). */
|
||||
removedNodeIds?: ReadonlySet<string>;
|
||||
/** Disable Save while a copilot proposal is under review (editable only, Phase 5c). */
|
||||
saveDisabled?: boolean;
|
||||
/**
|
||||
* Seed the editable canvas as already-dirty at mount (editable only, Phase
|
||||
* 5c fix) — see `EditableFlowCanvas`'s `initialDirty` doc comment. The host
|
||||
* computes this by diffing the incoming graph against its own
|
||||
* last-persisted snapshot so a `key`-remount (e.g. accepting a copilot
|
||||
* proposal) doesn't silently clear unsaved state.
|
||||
*/
|
||||
initialDirty?: boolean;
|
||||
}
|
||||
|
||||
const NODE_TYPES = { [FLOW_NODE_TYPE]: FlowNodeComponent };
|
||||
|
||||
// Stable fallback so an omitted `meta` doesn't allocate a new object every
|
||||
// render — `meta ?? { ... }` inline would defeat EditableFlowCanvas's
|
||||
// `useMemo(..., [meta])` dependency (a new referentially-distinct object each
|
||||
// render forces it to re-serialize the whole graph even with no real edit).
|
||||
const DEFAULT_META: WorkflowGraphMeta = { schema_version: 1, name: '' };
|
||||
|
||||
/**
|
||||
* Fills its parent's box (`h-full w-full` — the page decides how tall/wide
|
||||
* that is; `FlowCanvasPage` gives it the full panel body).
|
||||
* Read-only render path — unchanged from B5b.1. Kept a separate component so
|
||||
* its `useMemo` hook stays unconditional and the editable path's controlled
|
||||
* state hooks never run for a plain viewer.
|
||||
*/
|
||||
function FlowCanvas({ nodes, edges, readonly = true }: FlowCanvasProps) {
|
||||
function ReadonlyFlowCanvas({ nodes, edges }: { nodes: FlowNode[]; edges: FlowEdge[] }) {
|
||||
const interactionProps = useMemo(
|
||||
() =>
|
||||
readonly ? { nodesDraggable: false, nodesConnectable: false, elementsSelectable: false } : {},
|
||||
[readonly]
|
||||
() => ({ nodesDraggable: false, nodesConnectable: false, elementsSelectable: false }),
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -56,4 +98,42 @@ function FlowCanvas({ nodes, edges, readonly = true }: FlowCanvasProps) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fills its parent's box (`h-full w-full` — the page decides how tall/wide
|
||||
* that is; `FlowCanvasPage` gives it the full panel body).
|
||||
*/
|
||||
function FlowCanvas({
|
||||
nodes,
|
||||
edges,
|
||||
editable = false,
|
||||
meta,
|
||||
onSave,
|
||||
onDirtyChange,
|
||||
activeRunId,
|
||||
onGraphChange,
|
||||
addedNodeIds,
|
||||
removedNodeIds,
|
||||
saveDisabled,
|
||||
initialDirty,
|
||||
}: FlowCanvasProps) {
|
||||
if (editable) {
|
||||
return (
|
||||
<EditableFlowCanvas
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
meta={meta ?? DEFAULT_META}
|
||||
onSave={onSave}
|
||||
onDirtyChange={onDirtyChange}
|
||||
activeRunId={activeRunId}
|
||||
onGraphChange={onGraphChange}
|
||||
addedNodeIds={addedNodeIds}
|
||||
removedNodeIds={removedNodeIds}
|
||||
saveDisabled={saveDisabled}
|
||||
initialDirty={initialDirty}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <ReadonlyFlowCanvas nodes={nodes} edges={edges} />;
|
||||
}
|
||||
|
||||
export default memo(FlowCanvas);
|
||||
|
||||
@@ -22,72 +22,12 @@ import { Handle, type NodeProps, Position } from '@xyflow/react';
|
||||
import { memo } from 'react';
|
||||
|
||||
import type { FlowNode } from '../../../lib/flows/graphAdapter';
|
||||
import type { NodeKind } from '../../../lib/flows/types';
|
||||
import { COLOR_CLASSES, handleOffsets, nodeKindMeta } from '../../../lib/flows/nodeKindMeta';
|
||||
import { useT } from '../../../lib/i18n/I18nContext';
|
||||
|
||||
type NodeColor = 'sage' | 'primary' | 'amber' | 'coral' | 'neutral';
|
||||
|
||||
/** Per-kind emoji + border/chip color. Colors cycle through the four
|
||||
* CSS-variable-backed semantic ramps (primary/sage/amber/coral) that support
|
||||
* Tailwind's `/opacity` modifiers in this codebase (see `tailwind.config.js`)
|
||||
* so light/dark theming comes for free; with 12 kinds and 4 ramps some kinds
|
||||
* share a color family; the emoji + name remain the primary distinguishers.
|
||||
*
|
||||
* `data.kind` is typed as the 12-entry `NodeKind` union, but a saved graph is
|
||||
* `unknown` on the wire (cast in `FlowCanvasPage.tsx`) — a future 13th
|
||||
* tinyflows kind, or any other value the backend ever emits, can reach this
|
||||
* map at runtime even though TypeScript can't see it. Index lookups below
|
||||
* fall back to {@link DEFAULT_NODE_META} rather than assuming a hit, so an
|
||||
* unrecognized kind renders as a plain neutral node instead of crashing the
|
||||
* whole canvas (there's no error boundary around `<ReactFlow>`).
|
||||
*/
|
||||
const NODE_KIND_META: Record<NodeKind, { emoji: string; color: NodeColor }> = {
|
||||
trigger: { emoji: '⚡', color: 'sage' },
|
||||
agent: { emoji: '🤖', color: 'primary' },
|
||||
tool_call: { emoji: '🔧', color: 'amber' },
|
||||
http_request: { emoji: '🌐', color: 'coral' },
|
||||
code: { emoji: '📝', color: 'sage' },
|
||||
condition: { emoji: '🔀', color: 'primary' },
|
||||
switch: { emoji: '🔁', color: 'amber' },
|
||||
merge: { emoji: '🔗', color: 'coral' },
|
||||
split_out: { emoji: '📤', color: 'sage' },
|
||||
transform: { emoji: '♻️', color: 'primary' },
|
||||
output_parser: { emoji: '📋', color: 'amber' },
|
||||
sub_workflow: { emoji: '🧩', color: 'coral' },
|
||||
};
|
||||
|
||||
/** Fallback for any `kind` outside the map above — see the doc comment on `NODE_KIND_META`. */
|
||||
const DEFAULT_NODE_META: { emoji: string; color: NodeColor } = { emoji: '❔', color: 'neutral' };
|
||||
|
||||
const COLOR_CLASSES: Record<NodeColor, { border: string; chip: string }> = {
|
||||
sage: {
|
||||
border: 'border-sage-400 dark:border-sage-500/60',
|
||||
chip: 'bg-sage-100 dark:bg-sage-500/20',
|
||||
},
|
||||
primary: {
|
||||
border: 'border-primary-400 dark:border-primary-500/60',
|
||||
chip: 'bg-primary-100 dark:bg-primary-500/20',
|
||||
},
|
||||
amber: {
|
||||
border: 'border-amber-400 dark:border-amber-500/60',
|
||||
chip: 'bg-amber-100 dark:bg-amber-500/20',
|
||||
},
|
||||
coral: {
|
||||
border: 'border-coral-400 dark:border-coral-500/60',
|
||||
chip: 'bg-coral-100 dark:bg-coral-500/20',
|
||||
},
|
||||
neutral: { border: 'border-line-strong', chip: 'bg-surface-subtle' },
|
||||
};
|
||||
|
||||
/** Even vertical offsets (in %) for `count` handles along one side of the card. */
|
||||
function handleOffsets(count: number): number[] {
|
||||
if (count <= 1) return [50];
|
||||
return Array.from({ length: count }, (_, i) => ((i + 1) / (count + 1)) * 100);
|
||||
}
|
||||
|
||||
function FlowNodeComponent({ data, selected }: NodeProps<FlowNode>) {
|
||||
const { t } = useT();
|
||||
const meta = NODE_KIND_META[data.kind] ?? DEFAULT_NODE_META;
|
||||
const meta = nodeKindMeta(data.kind);
|
||||
const colors = COLOR_CLASSES[meta.color];
|
||||
const inputOffsets = handleOffsets(data.inputPorts.length);
|
||||
const outputOffsets = handleOffsets(data.outputPorts.length);
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* FlowValidationBanner (Phase 3c) — the inline error/warning surface for the
|
||||
* editable Workflow Canvas. Renders two distinct lists:
|
||||
*
|
||||
* - **errors** (coral) — hard structural problems from `flows_validate`
|
||||
* (`valid === false`): missing/duplicate trigger, cycle, invalid node config,
|
||||
* unknown-node edge. These block Save (gated by the canvas, not here).
|
||||
* - **warnings** (amber) — advisory notes that never block Save (today: an
|
||||
* unfired-trigger-kind warning; see `flows/ops.rs::graph_trigger_warnings`).
|
||||
*
|
||||
* A `saveError` (the `flows_update` RPC itself failing) renders as a third,
|
||||
* separate error row so a transport failure reads differently from a graph
|
||||
* that's structurally invalid. When there's nothing to show the component
|
||||
* renders nothing.
|
||||
*
|
||||
* Presentational only — the canvas owns validation state and Save gating.
|
||||
*/
|
||||
import { memo } from 'react';
|
||||
|
||||
import { useT } from '../../../lib/i18n/I18nContext';
|
||||
import type { FlowValidation } from '../../../services/api/flowsApi';
|
||||
|
||||
export interface FlowValidationBannerProps {
|
||||
validation: FlowValidation | null;
|
||||
/** Message from a failed `flows_update` Save, shown as a distinct error row. */
|
||||
saveError?: string | null;
|
||||
}
|
||||
|
||||
function MessageList({
|
||||
title,
|
||||
messages,
|
||||
tone,
|
||||
testId,
|
||||
}: {
|
||||
title: string;
|
||||
messages: string[];
|
||||
tone: 'error' | 'warning';
|
||||
testId: string;
|
||||
}) {
|
||||
const toneClasses =
|
||||
tone === 'error'
|
||||
? 'border-coral-300/60 bg-coral-50 text-coral-700 dark:border-coral-500/40 dark:bg-coral-500/10 dark:text-coral-300'
|
||||
: 'border-amber-300/60 bg-amber-50 text-amber-700 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-300';
|
||||
return (
|
||||
<div className={`rounded-lg border px-3 py-2 text-xs ${toneClasses}`} data-testid={testId}>
|
||||
<div className="mb-1 font-semibold uppercase tracking-wide">{title}</div>
|
||||
<ul className="space-y-0.5">
|
||||
{messages.map((message, i) => (
|
||||
<li key={`${message}-${i}`} className="leading-snug">
|
||||
{message}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FlowValidationBanner({ validation, saveError }: FlowValidationBannerProps) {
|
||||
const { t } = useT();
|
||||
|
||||
const errors = validation && !validation.valid ? validation.errors : [];
|
||||
const warnings = validation?.warnings ?? [];
|
||||
|
||||
if (errors.length === 0 && warnings.length === 0 && !saveError) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex max-h-40 w-full flex-col gap-2 overflow-y-auto"
|
||||
data-testid="flow-editor-validation">
|
||||
{saveError && (
|
||||
<MessageList
|
||||
title={t('flows.editor.saveFailedTitle')}
|
||||
messages={[saveError]}
|
||||
tone="error"
|
||||
testId="flow-editor-save-error"
|
||||
/>
|
||||
)}
|
||||
{errors.length > 0 && (
|
||||
<MessageList
|
||||
title={t('flows.editor.errorsTitle')}
|
||||
messages={errors}
|
||||
tone="error"
|
||||
testId="flow-editor-errors"
|
||||
/>
|
||||
)}
|
||||
{warnings.length > 0 && (
|
||||
<MessageList
|
||||
title={t('flows.editor.warningsTitle')}
|
||||
messages={warnings}
|
||||
tone="warning"
|
||||
testId="flow-editor-warnings"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(FlowValidationBanner);
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* 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:
|
||||
*
|
||||
* - **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.
|
||||
*/
|
||||
import { memo } from 'react';
|
||||
|
||||
import { COLOR_CLASSES, NODE_KINDS, nodeKindMeta } from '../../../lib/flows/nodeKindMeta';
|
||||
import type { NodeKind } from '../../../lib/flows/types';
|
||||
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;
|
||||
}
|
||||
|
||||
function NodePalette({ onAdd }: NodePaletteProps) {
|
||||
const { t } = useT();
|
||||
|
||||
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"
|
||||
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>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(NodePalette);
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* EditableFlowCanvas — live run overlay (Phase 3e).
|
||||
*
|
||||
* Drives the canvas through the public `FlowCanvas editable` entry point with a
|
||||
* mocked `socketService`, then simulates the core's `flow:run_progress` feed and
|
||||
* asserts the target node's live-status class flips on its React Flow wrapper —
|
||||
* n8n's signature running/success/error interaction. Also proves the overlay is
|
||||
* scoped to the watched run (a different run's event is ignored) and that with
|
||||
* no socket event the node carries no run class (the 2s poller fallback, tested
|
||||
* separately, remains the source of truth).
|
||||
*/
|
||||
import { act, render, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { FlowNode } from '../../../../lib/flows/graphAdapter';
|
||||
import FlowCanvas from '../FlowCanvas';
|
||||
|
||||
const validateFlow = vi.hoisted(() => vi.fn());
|
||||
const listFlowConnections = vi.hoisted(() => vi.fn());
|
||||
vi.mock('../../../../services/api/flowsApi', () => ({ validateFlow, listFlowConnections }));
|
||||
|
||||
// A tiny in-memory socket stand-in: `emit(event, payload)` fans out to every
|
||||
// handler registered via `on`, and `off` removes them (so unmount cleanup is
|
||||
// observable).
|
||||
const socketHandlers = vi.hoisted(() => new Map<string, Set<(data: unknown) => void>>());
|
||||
const socketOn = vi.hoisted(() =>
|
||||
vi.fn((event: string, cb: (data: unknown) => void) => {
|
||||
const set = socketHandlers.get(event) ?? new Set();
|
||||
set.add(cb);
|
||||
socketHandlers.set(event, set);
|
||||
})
|
||||
);
|
||||
const socketOff = vi.hoisted(() =>
|
||||
vi.fn((event: string, cb: (data: unknown) => void) => {
|
||||
socketHandlers.get(event)?.delete(cb);
|
||||
})
|
||||
);
|
||||
vi.mock('../../../../services/socketService', () => ({
|
||||
socketService: { on: socketOn, off: socketOff },
|
||||
}));
|
||||
|
||||
function emitProgress(payload: { run_id: string; node_id: string; status: string }) {
|
||||
act(() => {
|
||||
for (const event of ['flow:run_progress', 'flow_run_progress']) {
|
||||
for (const cb of socketHandlers.get(event) ?? []) cb(payload);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function triggerNode(): FlowNode {
|
||||
return {
|
||||
id: 't',
|
||||
type: 'flowNode',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
kind: 'trigger',
|
||||
name: 'Start',
|
||||
config: {},
|
||||
ports: [],
|
||||
inputPorts: ['main'],
|
||||
outputPorts: ['main'],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const META = { schema_version: 1, id: 'wf_1', name: 'My flow' } as const;
|
||||
|
||||
function renderCanvas(props: Partial<React.ComponentProps<typeof FlowCanvas>> = {}) {
|
||||
return render(
|
||||
<FlowCanvas
|
||||
editable
|
||||
nodes={[triggerNode()]}
|
||||
edges={[]}
|
||||
meta={META}
|
||||
onSave={vi.fn()}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function nodeWrapper(container: HTMLElement): Element | null {
|
||||
return container.querySelector('.react-flow__node[data-id="t"]');
|
||||
}
|
||||
|
||||
describe('EditableFlowCanvas — live run overlay', () => {
|
||||
beforeEach(() => {
|
||||
socketHandlers.clear();
|
||||
socketOn.mockClear();
|
||||
socketOff.mockClear();
|
||||
validateFlow.mockReset();
|
||||
listFlowConnections.mockReset();
|
||||
listFlowConnections.mockResolvedValue([]);
|
||||
validateFlow.mockResolvedValue({ valid: true, errors: [], warnings: [] });
|
||||
});
|
||||
|
||||
it('flips the node run-status class as flow:run_progress events arrive', async () => {
|
||||
const { container } = renderCanvas({ activeRunId: 'run_1' });
|
||||
|
||||
// The canvas subscribed to both event aliases for the active run.
|
||||
await waitFor(() =>
|
||||
expect(socketOn).toHaveBeenCalledWith('flow:run_progress', expect.any(Function))
|
||||
);
|
||||
expect(socketOn).toHaveBeenCalledWith('flow_run_progress', expect.any(Function));
|
||||
|
||||
// No event yet → no run class.
|
||||
expect(nodeWrapper(container)).not.toHaveClass('flow-node-running');
|
||||
|
||||
// running → pulsing ring.
|
||||
emitProgress({ run_id: 'run_1', node_id: 't', status: 'running' });
|
||||
await waitFor(() => expect(nodeWrapper(container)).toHaveClass('flow-node-running'));
|
||||
|
||||
// success → sage ring (and the running class is gone).
|
||||
emitProgress({ run_id: 'run_1', node_id: 't', status: 'success' });
|
||||
await waitFor(() => expect(nodeWrapper(container)).toHaveClass('flow-node-success'));
|
||||
expect(nodeWrapper(container)).not.toHaveClass('flow-node-running');
|
||||
|
||||
// error → coral run-failed ring.
|
||||
emitProgress({ run_id: 'run_1', node_id: 't', status: 'error' });
|
||||
await waitFor(() => expect(nodeWrapper(container)).toHaveClass('flow-node-failed'));
|
||||
});
|
||||
|
||||
it('ignores progress for a different run id', async () => {
|
||||
const { container } = renderCanvas({ activeRunId: 'run_1' });
|
||||
await waitFor(() => expect(socketOn).toHaveBeenCalled());
|
||||
|
||||
emitProgress({ run_id: 'other_run', node_id: 't', status: 'running' });
|
||||
// Give React a chance to (not) re-render.
|
||||
await Promise.resolve();
|
||||
expect(nodeWrapper(container)).not.toHaveClass('flow-node-running');
|
||||
});
|
||||
|
||||
it('does not subscribe when there is no active run (poller-only fallback)', async () => {
|
||||
const { container } = renderCanvas();
|
||||
// No run → no socket subscription, and the node never carries a run class.
|
||||
expect(socketOn).not.toHaveBeenCalledWith('flow:run_progress', expect.any(Function));
|
||||
await waitFor(() => expect(nodeWrapper(container)).toBeInTheDocument());
|
||||
expect(nodeWrapper(container)).not.toHaveClass('flow-node-running');
|
||||
});
|
||||
|
||||
it('unsubscribes on unmount', async () => {
|
||||
const { unmount } = renderCanvas({ activeRunId: 'run_1' });
|
||||
await waitFor(() => expect(socketOn).toHaveBeenCalled());
|
||||
unmount();
|
||||
expect(socketOff).toHaveBeenCalledWith('flow:run_progress', expect.any(Function));
|
||||
expect(socketOff).toHaveBeenCalledWith('flow_run_progress', expect.any(Function));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* EditableFlowCanvas (issue B5b.2 / Phase 3a) — behavior tests for the mutable
|
||||
* Workflow Canvas driven through the public `FlowCanvas editable` entry point.
|
||||
*
|
||||
* `@xyflow/react` mounts for real in jsdom (nodes measure 0x0, but the DOM
|
||||
* tree, palette, toolbar, and `FlowNodeComponent` cards are all assertable), so
|
||||
* these tests drive the *click* affordances (palette add, save) rather than
|
||||
* drag geometry, which jsdom can't produce. Port-aware connection validity is
|
||||
* unit-tested directly against `isValidFlowConnection` in
|
||||
* `lib/flows/graphAdapter.test.ts`.
|
||||
*/
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { FlowEdge, FlowNode } from '../../../../lib/flows/graphAdapter';
|
||||
import type { WorkflowGraph } from '../../../../lib/flows/types';
|
||||
import FlowCanvas from '../FlowCanvas';
|
||||
|
||||
// `FlowNodeComponent` / palette call `useT()`, which falls back to the bundled
|
||||
// English map when no `I18nProvider` (and its Redux dependency) is mounted —
|
||||
// the same no-provider render the read-only `FlowCanvas.test.tsx` relies on.
|
||||
function renderCanvas(ui: React.ReactElement) {
|
||||
return render(ui);
|
||||
}
|
||||
|
||||
function triggerNode(): FlowNode {
|
||||
return {
|
||||
id: 't',
|
||||
type: 'flowNode',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
kind: 'trigger',
|
||||
name: 'Start',
|
||||
config: {},
|
||||
ports: [],
|
||||
inputPorts: ['main'],
|
||||
outputPorts: ['main'],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('FlowCanvas (editable)', () => {
|
||||
it('renders the node palette with all 12 node kinds', () => {
|
||||
renderCanvas(<FlowCanvas editable nodes={[triggerNode()]} edges={[]} />);
|
||||
expect(screen.getByTestId('flow-node-palette')).toBeInTheDocument();
|
||||
// Palette items are keyed by kind via data-testid `flow-palette-item-<kind>`.
|
||||
expect(screen.getByTestId('flow-palette-item-trigger')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('flow-palette-item-agent')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('flow-palette-item-sub_workflow')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does NOT render the palette in read-only mode', () => {
|
||||
renderCanvas(<FlowCanvas nodes={[triggerNode()]} edges={[]} />);
|
||||
expect(screen.queryByTestId('flow-node-palette')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('adds a node to the canvas when a palette item is clicked', () => {
|
||||
renderCanvas(<FlowCanvas editable nodes={[triggerNode()]} edges={[]} />);
|
||||
// One node to start (the trigger).
|
||||
expect(screen.getAllByTestId('flow-node')).toHaveLength(1);
|
||||
|
||||
fireEvent.click(screen.getByTestId('flow-palette-item-agent'));
|
||||
|
||||
const rendered = screen.getAllByTestId('flow-node');
|
||||
expect(rendered).toHaveLength(2);
|
||||
// The newly added node carries data-node-kind="agent".
|
||||
expect(rendered.some(el => el.getAttribute('data-node-kind') === 'agent')).toBe(true);
|
||||
});
|
||||
|
||||
it('serializes the live canvas to a valid WorkflowGraph on Save', () => {
|
||||
const onSave = vi.fn<(graph: WorkflowGraph) => void>();
|
||||
renderCanvas(
|
||||
<FlowCanvas
|
||||
editable
|
||||
nodes={[triggerNode()]}
|
||||
edges={[]}
|
||||
meta={{ schema_version: 1, id: 'wf_1', name: 'My flow' }}
|
||||
onSave={onSave}
|
||||
/>
|
||||
);
|
||||
|
||||
// Add an agent node, then save.
|
||||
fireEvent.click(screen.getByTestId('flow-palette-item-agent'));
|
||||
fireEvent.click(screen.getByTestId('flow-editor-save'));
|
||||
|
||||
expect(onSave).toHaveBeenCalledTimes(1);
|
||||
const graph = onSave.mock.calls[0][0];
|
||||
expect(graph.schema_version).toBe(1);
|
||||
expect(graph.id).toBe('wf_1');
|
||||
expect(graph.name).toBe('My flow');
|
||||
// Original trigger + the palette-added agent.
|
||||
expect(graph.nodes.map(n => n.kind).sort()).toEqual(['agent', 'trigger']);
|
||||
expect(graph.edges).toEqual([]);
|
||||
});
|
||||
|
||||
it('disables the delete button when nothing is selected', () => {
|
||||
renderCanvas(<FlowCanvas editable nodes={[triggerNode()]} edges={[]} />);
|
||||
expect(screen.getByTestId('flow-editor-delete')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('exposes no Save button when onSave is not provided', () => {
|
||||
renderCanvas(<FlowCanvas editable nodes={[triggerNode()]} edges={[] as FlowEdge[]} />);
|
||||
expect(screen.queryByTestId('flow-editor-save')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* EditableFlowCanvas — validation UX (Phase 3c) + draft/dirty state (Phase 3d).
|
||||
*
|
||||
* Drives the canvas through the public `FlowCanvas editable` entry point with a
|
||||
* mocked `flowsApi` so `validateFlow` is deterministic. Covers:
|
||||
* - an invalid graph shows the inline error banner, rings the offending node,
|
||||
* and blocks Save;
|
||||
* - a valid-with-warnings graph surfaces warnings distinctly and allows Save;
|
||||
* - dirty tracking gates Save/Discard, Discard resets to baseline, and a
|
||||
* successful Save clears the dirty flag.
|
||||
*/
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { FlowNode } from '../../../../lib/flows/graphAdapter';
|
||||
import FlowCanvas from '../FlowCanvas';
|
||||
|
||||
const validateFlow = vi.hoisted(() => vi.fn());
|
||||
const listFlowConnections = vi.hoisted(() => vi.fn());
|
||||
vi.mock('../../../../services/api/flowsApi', () => ({ validateFlow, listFlowConnections }));
|
||||
|
||||
function triggerNode(): FlowNode {
|
||||
return {
|
||||
id: 't',
|
||||
type: 'flowNode',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
kind: 'trigger',
|
||||
name: 'Start',
|
||||
config: {},
|
||||
ports: [],
|
||||
inputPorts: ['main'],
|
||||
outputPorts: ['main'],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const META = { schema_version: 1, id: 'wf_1', name: 'My flow' } as const;
|
||||
|
||||
function renderCanvas(props: Partial<React.ComponentProps<typeof FlowCanvas>> = {}) {
|
||||
return render(
|
||||
<FlowCanvas
|
||||
editable
|
||||
nodes={[triggerNode()]}
|
||||
edges={[]}
|
||||
meta={META}
|
||||
onSave={vi.fn().mockResolvedValue(undefined)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe('EditableFlowCanvas — validation + dirty state', () => {
|
||||
beforeEach(() => {
|
||||
validateFlow.mockReset();
|
||||
listFlowConnections.mockReset();
|
||||
listFlowConnections.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it('surfaces hard errors, rings the offending node, and blocks Save', async () => {
|
||||
validateFlow.mockResolvedValue({
|
||||
valid: false,
|
||||
errors: ['invalid config for node t: missing schedule'],
|
||||
warnings: [],
|
||||
});
|
||||
const { container } = renderCanvas();
|
||||
|
||||
// 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'));
|
||||
|
||||
const errors = await screen.findByTestId('flow-editor-errors');
|
||||
expect(errors).toHaveTextContent('invalid config for node t: missing schedule');
|
||||
|
||||
// Save is blocked while there are hard errors, even though the graph is dirty.
|
||||
expect(screen.getByTestId('flow-editor-save')).toBeDisabled();
|
||||
|
||||
// The named node ('t') is ringed with the error class on its RF wrapper.
|
||||
await waitFor(() =>
|
||||
expect(container.querySelector('.react-flow__node[data-id="t"]')).toHaveClass(
|
||||
'flow-node-error'
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it('shows warnings distinctly from errors and allows Save', async () => {
|
||||
validateFlow.mockResolvedValue({
|
||||
valid: true,
|
||||
errors: [],
|
||||
warnings: ['this trigger kind does not fire automatically yet'],
|
||||
});
|
||||
renderCanvas();
|
||||
|
||||
fireEvent.click(screen.getByTestId('flow-palette-item-agent'));
|
||||
fireEvent.click(screen.getByTestId('flow-editor-validate'));
|
||||
|
||||
const warnings = await screen.findByTestId('flow-editor-warnings');
|
||||
expect(warnings).toHaveTextContent('does not fire automatically');
|
||||
// A valid graph never renders the errors list…
|
||||
expect(screen.queryByTestId('flow-editor-errors')).not.toBeInTheDocument();
|
||||
// …and Save is allowed (warnings don't block).
|
||||
expect(screen.getByTestId('flow-editor-save')).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('tracks dirty state: Save/Discard gate on it, Discard resets, Save clears it', async () => {
|
||||
validateFlow.mockResolvedValue({ valid: true, errors: [], warnings: [] });
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
const onDirtyChange = vi.fn();
|
||||
renderCanvas({ onSave, onDirtyChange });
|
||||
|
||||
// Pristine: no dirty badge, Save + Discard disabled.
|
||||
expect(screen.queryByTestId('flow-editor-dirty')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('flow-editor-save')).toBeDisabled();
|
||||
expect(screen.getByTestId('flow-editor-discard')).toBeDisabled();
|
||||
|
||||
// Edit → dirty.
|
||||
fireEvent.click(screen.getByTestId('flow-palette-item-agent'));
|
||||
expect(screen.getByTestId('flow-editor-dirty')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('flow-editor-save')).not.toBeDisabled();
|
||||
expect(screen.getByTestId('flow-editor-discard')).not.toBeDisabled();
|
||||
expect(onDirtyChange).toHaveBeenLastCalledWith(true);
|
||||
expect(screen.getAllByTestId('flow-node')).toHaveLength(2);
|
||||
|
||||
// Discard → back to the single trigger, no longer dirty.
|
||||
fireEvent.click(screen.getByTestId('flow-editor-discard'));
|
||||
expect(screen.getAllByTestId('flow-node')).toHaveLength(1);
|
||||
expect(screen.queryByTestId('flow-editor-dirty')).not.toBeInTheDocument();
|
||||
expect(onDirtyChange).toHaveBeenLastCalledWith(false);
|
||||
|
||||
// Edit again and Save → onSave called, dirty cleared once it resolves.
|
||||
fireEvent.click(screen.getByTestId('flow-palette-item-agent'));
|
||||
fireEvent.click(screen.getByTestId('flow-editor-save'));
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
|
||||
const graph = onSave.mock.calls[0][0];
|
||||
expect(graph.nodes.map((n: { kind: string }) => n.kind).sort()).toEqual(['agent', 'trigger']);
|
||||
await waitFor(() => expect(screen.queryByTestId('flow-editor-dirty')).not.toBeInTheDocument());
|
||||
expect(onDirtyChange).toHaveBeenLastCalledWith(false);
|
||||
});
|
||||
|
||||
it('starts dirty when the host passes initialDirty (a remount carrying unsaved content)', async () => {
|
||||
validateFlow.mockResolvedValue({ valid: true, errors: [], warnings: [] });
|
||||
const onDirtyChange = vi.fn();
|
||||
// Mirrors `FlowCanvasPage` remounting the canvas (`key={canvasVersion}`)
|
||||
// after accepting a copilot proposal: the incoming nodes/edges ARE the
|
||||
// component's "initial" graph, so without `initialDirty` the canvas would
|
||||
// seed its baseline from them and instantly read as clean even though
|
||||
// nothing was persisted (the P1 this regression test guards against).
|
||||
renderCanvas({ onDirtyChange, initialDirty: true });
|
||||
|
||||
expect(screen.getByTestId('flow-editor-dirty')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('flow-editor-save')).not.toBeDisabled();
|
||||
expect(onDirtyChange).toHaveBeenLastCalledWith(true);
|
||||
});
|
||||
|
||||
it('surfaces a Save failure inline and leaves the graph dirty', async () => {
|
||||
validateFlow.mockResolvedValue({ valid: true, errors: [], warnings: [] });
|
||||
const onSave = vi.fn().mockRejectedValue(new Error('core unreachable'));
|
||||
renderCanvas({ onSave });
|
||||
|
||||
fireEvent.click(screen.getByTestId('flow-palette-item-agent'));
|
||||
fireEvent.click(screen.getByTestId('flow-editor-save'));
|
||||
|
||||
const saveError = await screen.findByTestId('flow-editor-save-error');
|
||||
expect(saveError).toHaveTextContent('core unreachable');
|
||||
// Still dirty — nothing persisted.
|
||||
expect(screen.getByTestId('flow-editor-dirty')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -63,3 +63,80 @@
|
||||
background: rgb(var(--surface) / 0.7);
|
||||
color: rgb(var(--content-muted));
|
||||
}
|
||||
|
||||
/*
|
||||
* Validation highlight (Phase 3c): a node named by a hard validation error
|
||||
* (invalid config / cycle / duplicate / unknown-node) gets a coral ring so the
|
||||
* inline banner's message points at a visible culprit on the canvas. `.flow-node-error`
|
||||
* is applied to the outer `.react-flow__node` wrapper via the node's `className`.
|
||||
*/
|
||||
.flow-canvas .react-flow__node.flow-node-error {
|
||||
outline: 2px solid rgb(var(--coral-500));
|
||||
outline-offset: 3px;
|
||||
border-radius: 0.85rem;
|
||||
}
|
||||
|
||||
/*
|
||||
* Live run overlay (Phase 3e): while a run is in flight the canvas rings each
|
||||
* node with its live status from `useFlowRunProgress` — n8n's signature
|
||||
* running/success/error interaction. Classes are applied to the outer
|
||||
* `.react-flow__node` wrapper via the node's `className` (composed alongside the
|
||||
* validation `.flow-node-error` ring). `flow-node-failed` is kept distinct from
|
||||
* the validation `.flow-node-error` so a *runtime* failure reads differently
|
||||
* from a *config* error.
|
||||
*/
|
||||
.flow-canvas .react-flow__node.flow-node-running {
|
||||
outline: 2px solid rgb(var(--primary-500));
|
||||
outline-offset: 3px;
|
||||
border-radius: 0.85rem;
|
||||
animation: flow-node-run-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.flow-canvas .react-flow__node.flow-node-success {
|
||||
outline: 2px solid rgb(var(--sage-500));
|
||||
outline-offset: 3px;
|
||||
border-radius: 0.85rem;
|
||||
}
|
||||
|
||||
.flow-canvas .react-flow__node.flow-node-failed {
|
||||
outline: 2px solid rgb(var(--coral-500));
|
||||
outline-offset: 3px;
|
||||
border-radius: 0.85rem;
|
||||
}
|
||||
|
||||
/*
|
||||
* Copilot diff overlay (Phase 5c): while the user reviews an agent proposal on
|
||||
* the canvas, nodes the proposal ADDS get a sage ring (like a live-success
|
||||
* highlight) and nodes it REMOVES are ghosted (dimmed + dashed coral ring) so
|
||||
* the change reads diff-style before Accept/Reject. Applied via the node's
|
||||
* `className` alongside the validation/run rings.
|
||||
*/
|
||||
.flow-canvas .react-flow__node.flow-node-added {
|
||||
outline: 2px solid rgb(var(--sage-500));
|
||||
outline-offset: 3px;
|
||||
border-radius: 0.85rem;
|
||||
}
|
||||
|
||||
.flow-canvas .react-flow__node.flow-node-removed {
|
||||
outline: 2px dashed rgb(var(--coral-500));
|
||||
outline-offset: 3px;
|
||||
border-radius: 0.85rem;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
@keyframes flow-node-run-pulse {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgb(var(--primary-500) / 0.45);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 6px rgb(var(--primary-500) / 0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Respect reduced-motion: keep the ring, drop the pulse. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.flow-canvas .react-flow__node.flow-node-running {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* NodeConfigDrawer (issue B5b / Phase 3b) — right-hand drawer that opens when a
|
||||
* single node is selected on the editable canvas. Renders the node's per-kind
|
||||
* config form ({@link NODE_CONFIG_FORMS}) with a raw-JSON escape hatch for kinds
|
||||
* without a dedicated form (and an opt-in "Edit as JSON" toggle for every kind).
|
||||
*
|
||||
* Chrome mirrors {@link FlowRunInspectorDrawer}: fixed overlay, backdrop click
|
||||
* and Escape both close. Unlike that drawer it is NOT full-height-modal — it
|
||||
* floats on the right of the canvas so the graph stays visible while editing,
|
||||
* but keeps the same close semantics.
|
||||
*
|
||||
* Controlled: every edit calls `onChange(nodeId, patch)`; the canvas owns node
|
||||
* state and re-renders the drawer with the updated `config`, so the form fields
|
||||
* always reflect the live draft (no local mirror of config that could drift).
|
||||
* The drawer body is keyed by node id so switching nodes cleanly re-seeds the
|
||||
* JSON editor's local text buffer.
|
||||
*/
|
||||
import createDebug from 'debug';
|
||||
import { memo, useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { useEscapeKey } from '../../../../hooks/useEscapeKey';
|
||||
import type { FlowNode } from '../../../../lib/flows/graphAdapter';
|
||||
import { nodeKindMeta } from '../../../../lib/flows/nodeKindMeta';
|
||||
import { useT } from '../../../../lib/i18n/I18nContext';
|
||||
import type { FlowConnection } from '../../../../services/api/flowsApi';
|
||||
import { JsonField } from './nodeConfigFields';
|
||||
import { NODE_CONFIG_FORMS } from './nodeConfigForms';
|
||||
|
||||
const log = createDebug('app:flows:nodeConfig:drawer');
|
||||
|
||||
export interface NodeConfigPatch {
|
||||
name?: string;
|
||||
config?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface NodeConfigDrawerProps {
|
||||
/** The selected node to edit, or `null` when nothing single-node is selected. */
|
||||
node: FlowNode | null;
|
||||
onClose: () => void;
|
||||
/** Apply a name/config patch to the node identified by `nodeId`. */
|
||||
onChange: (nodeId: string, patch: NodeConfigPatch) => void;
|
||||
/** Secret-free credential refs for the picker (loaded once by the canvas). */
|
||||
connections: FlowConnection[];
|
||||
}
|
||||
|
||||
function NodeConfigBody({
|
||||
node,
|
||||
onChange,
|
||||
connections,
|
||||
}: {
|
||||
node: FlowNode;
|
||||
onChange: (nodeId: string, patch: NodeConfigPatch) => void;
|
||||
connections: FlowConnection[];
|
||||
}) {
|
||||
const { t } = useT();
|
||||
const config = useMemo(() => node.data.config ?? {}, [node.data.config]);
|
||||
const Form = NODE_CONFIG_FORMS[node.data.kind];
|
||||
// Kinds with no dedicated form start on the raw editor; kinds with a form
|
||||
// start on the form but can flip to raw via the toggle.
|
||||
const [rawMode, setRawMode] = useState(!Form);
|
||||
|
||||
const mergeConfig = useCallback(
|
||||
(patch: Record<string, unknown>) => {
|
||||
log('mergeConfig: node=%s keys=%o', node.id, Object.keys(patch));
|
||||
onChange(node.id, { config: { ...config, ...patch } });
|
||||
},
|
||||
[node.id, config, onChange]
|
||||
);
|
||||
|
||||
const replaceConfig = useCallback(
|
||||
(value: unknown) => {
|
||||
const next =
|
||||
value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
log('replaceConfig: node=%s keys=%o', node.id, Object.keys(next));
|
||||
onChange(node.id, { config: next });
|
||||
},
|
||||
[node.id, onChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{Form && (
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-line px-2 py-0.5 text-[11px] font-medium text-content-muted hover:bg-surface-hover"
|
||||
data-testid="node-config-raw-toggle"
|
||||
onClick={() => setRawMode(m => !m)}>
|
||||
{rawMode ? t('flows.nodeConfig.editForm') : t('flows.nodeConfig.editJson')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{Form && !rawMode ? (
|
||||
<Form config={config} onChange={mergeConfig} connections={connections} />
|
||||
) : (
|
||||
<JsonField
|
||||
label={t('flows.nodeConfig.rawJsonLabel')}
|
||||
hint={t('flows.nodeConfig.rawJsonHint')}
|
||||
value={config}
|
||||
onChange={replaceConfig}
|
||||
rows={12}
|
||||
testId="node-config-raw-json"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NodeConfigDrawer({ node, onClose, onChange, connections }: NodeConfigDrawerProps) {
|
||||
const { t } = useT();
|
||||
|
||||
useEscapeKey(() => {
|
||||
log('escape: closing');
|
||||
onClose();
|
||||
}, node !== null);
|
||||
|
||||
if (!node) return null;
|
||||
|
||||
const meta = nodeKindMeta(node.data.kind);
|
||||
const kindLabel = t(`flows.nodeKind.${node.data.kind}`, node.data.kind);
|
||||
|
||||
return (
|
||||
// `pointer-events-none` wrapper so the drawer floats over the canvas
|
||||
// without a backdrop — the graph stays fully interactive, and clicking an
|
||||
// empty canvas area deselects the node (closing the drawer) on its own.
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 z-20 flex justify-end"
|
||||
data-testid="node-config-drawer">
|
||||
<aside className="pointer-events-auto relative flex h-full w-full max-w-xs flex-col border-l border-line bg-surface shadow-xl">
|
||||
<header className="flex items-start gap-2 border-b border-line px-3.5 py-3">
|
||||
<span className="text-lg leading-none" aria-hidden="true">
|
||||
{meta.emoji}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<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"
|
||||
value={node.data.name}
|
||||
aria-label={t('flows.nodeConfig.nameLabel')}
|
||||
placeholder={t('flows.nodeConfig.namePlaceholder')}
|
||||
data-testid="node-config-name"
|
||||
onChange={e => onChange(node.id, { name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="node-config-close"
|
||||
onClick={onClose}
|
||||
aria-label={t('flows.nodeConfig.close')}
|
||||
className="shrink-0 rounded-full p-1.5 text-content-faint hover:bg-surface-hover hover:text-content-secondary">
|
||||
✕
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-3.5 py-3.5">
|
||||
{/* 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} />
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(NodeConfigDrawer);
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Behavior tests for NodeConfigDrawer (Phase 3b): renders the selected node's
|
||||
* name + per-kind form, edits flow back through `onChange` (controlled), the
|
||||
* raw-JSON escape hatch toggles/parses, and close fires `onClose`. `useT()`
|
||||
* falls back to the bundled English map with no provider mounted.
|
||||
*/
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { FlowNode } from '../../../../../lib/flows/graphAdapter';
|
||||
import NodeConfigDrawer from '../NodeConfigDrawer';
|
||||
|
||||
function makeNode(overrides: Partial<FlowNode['data']> = {}): FlowNode {
|
||||
return {
|
||||
id: 'n1',
|
||||
type: 'flowNode',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
kind: 'http_request',
|
||||
name: 'Fetch data',
|
||||
config: { method: 'GET', url: 'https://x.test' },
|
||||
ports: [],
|
||||
inputPorts: ['main'],
|
||||
outputPorts: ['main'],
|
||||
...overrides,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('NodeConfigDrawer', () => {
|
||||
it('renders nothing when no node is selected', () => {
|
||||
const { container } = render(
|
||||
<NodeConfigDrawer node={null} onClose={vi.fn()} onChange={vi.fn()} connections={[]} />
|
||||
);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('renders the node name and its per-kind form', () => {
|
||||
render(
|
||||
<NodeConfigDrawer node={makeNode()} onClose={vi.fn()} onChange={vi.fn()} connections={[]} />
|
||||
);
|
||||
expect(screen.getByTestId('node-config-drawer')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('node-config-name')).toHaveValue('Fetch data');
|
||||
// http_request form fields are present.
|
||||
expect(screen.getByTestId('node-config-http-method')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('emits a name patch when the name is edited', () => {
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<NodeConfigDrawer node={makeNode()} onClose={onChange} onChange={onChange} connections={[]} />
|
||||
);
|
||||
fireEvent.change(screen.getByTestId('node-config-name'), { target: { value: 'Renamed' } });
|
||||
expect(onChange).toHaveBeenCalledWith('n1', { name: 'Renamed' });
|
||||
});
|
||||
|
||||
it('merges a form edit into the existing config', () => {
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<NodeConfigDrawer node={makeNode()} onClose={vi.fn()} onChange={onChange} connections={[]} />
|
||||
);
|
||||
fireEvent.change(screen.getByTestId('node-config-http-method'), { target: { value: 'POST' } });
|
||||
// Existing url is preserved; only method changes.
|
||||
expect(onChange).toHaveBeenCalledWith('n1', {
|
||||
config: { method: 'POST', url: 'https://x.test' },
|
||||
});
|
||||
});
|
||||
|
||||
it('toggles to the raw-JSON editor and replaces config on valid JSON', () => {
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<NodeConfigDrawer node={makeNode()} onClose={vi.fn()} onChange={onChange} connections={[]} />
|
||||
);
|
||||
fireEvent.click(screen.getByTestId('node-config-raw-toggle'));
|
||||
const editor = screen.getByTestId('node-config-raw-json');
|
||||
fireEvent.change(editor, { target: { value: '{"method":"DELETE"}' } });
|
||||
expect(onChange).toHaveBeenLastCalledWith('n1', { config: { method: 'DELETE' } });
|
||||
});
|
||||
|
||||
it('uses the raw-JSON editor by default for kinds without a dedicated form', () => {
|
||||
render(
|
||||
<NodeConfigDrawer
|
||||
node={makeNode({ kind: 'merge', name: 'Join', config: {} })}
|
||||
onClose={vi.fn()}
|
||||
onChange={vi.fn()}
|
||||
connections={[]}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId('node-config-raw-json')).toBeInTheDocument();
|
||||
// No form/raw toggle for kinds that only have the raw editor.
|
||||
expect(screen.queryByTestId('node-config-raw-toggle')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('fires onClose from the close button', () => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<NodeConfigDrawer node={makeNode()} onClose={onClose} onChange={vi.fn()} connections={[]} />
|
||||
);
|
||||
fireEvent.click(screen.getByTestId('node-config-close'));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Behavior tests for representative per-kind node-config forms (Phase 3b):
|
||||
* http_request (typed fields + kind-filtered credential picker), transform
|
||||
* (key→value map), and trigger (conditional fields by trigger_kind). Forms are
|
||||
* pulled from the {@link NODE_CONFIG_FORMS} registry and driven through their
|
||||
* public `onChange` patch contract. `useT()` falls back to the bundled English
|
||||
* map with no provider mounted (same as the sibling canvas tests).
|
||||
*/
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { FlowConnection } from '../../../../../services/api/flowsApi';
|
||||
import { NODE_CONFIG_FORMS, type NodeConfigFormProps } from '../nodeConfigForms';
|
||||
|
||||
function renderForm(kind: keyof typeof NODE_CONFIG_FORMS, props: Partial<NodeConfigFormProps>) {
|
||||
const Form = NODE_CONFIG_FORMS[kind]!;
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<Form config={props.config ?? {}} onChange={onChange} connections={props.connections ?? []} />
|
||||
);
|
||||
return { onChange };
|
||||
}
|
||||
|
||||
const HTTP_CRED: FlowConnection = {
|
||||
connection_ref: 'http_cred:stripe',
|
||||
kind: 'http',
|
||||
display: 'Stripe API',
|
||||
scheme: 'bearer',
|
||||
};
|
||||
const COMPOSIO_CRED: FlowConnection = {
|
||||
connection_ref: 'composio:github:conn_1',
|
||||
kind: 'composio',
|
||||
display: 'GitHub',
|
||||
toolkit: 'github',
|
||||
};
|
||||
|
||||
describe('HttpRequestForm', () => {
|
||||
it('emits a method patch when the method changes', () => {
|
||||
const { onChange } = renderForm('http_request', {});
|
||||
fireEvent.change(screen.getByTestId('node-config-http-method'), { target: { value: 'POST' } });
|
||||
expect(onChange).toHaveBeenLastCalledWith({ method: 'POST' });
|
||||
});
|
||||
|
||||
it('emits a url patch as the URL is typed', () => {
|
||||
const { onChange } = renderForm('http_request', {});
|
||||
fireEvent.change(screen.getByTestId('node-config-http-url'), {
|
||||
target: { value: '=item.url' },
|
||||
});
|
||||
expect(onChange).toHaveBeenLastCalledWith({ url: '=item.url' });
|
||||
});
|
||||
|
||||
it('offers only http-kind credentials in the picker and emits connection_ref on select', () => {
|
||||
const { onChange } = renderForm('http_request', { connections: [HTTP_CRED, COMPOSIO_CRED] });
|
||||
const select = screen.getByTestId('node-config-http-credential');
|
||||
// None + the single http credential — the composio one is filtered out.
|
||||
const options = within(select).getAllByRole('option');
|
||||
expect(options).toHaveLength(2);
|
||||
expect(screen.queryByText(/GitHub/)).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.change(select, { target: { value: 'http_cred:stripe' } });
|
||||
expect(onChange).toHaveBeenLastCalledWith({ connection_ref: 'http_cred:stripe' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('TransformForm', () => {
|
||||
it('builds a set map from added key/value rows', () => {
|
||||
const { onChange } = renderForm('transform', {});
|
||||
// Add a row, then fill key + value.
|
||||
fireEvent.click(screen.getByTestId('node-config-transform-set-add'));
|
||||
const container = screen.getByTestId('node-config-transform-set');
|
||||
const inputs = within(container).getAllByRole('textbox');
|
||||
expect(inputs).toHaveLength(2);
|
||||
fireEvent.change(inputs[0], { target: { value: 'greeting' } });
|
||||
fireEvent.change(inputs[1], { target: { value: '=item.name' } });
|
||||
expect(onChange).toHaveBeenLastCalledWith({ set: { greeting: '=item.name' } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('TriggerForm', () => {
|
||||
it('reveals the cron schedule field only for the schedule kind and patches it', () => {
|
||||
const { onChange } = renderForm('trigger', {});
|
||||
// Manual by default — no schedule field.
|
||||
expect(screen.queryByTestId('node-config-trigger-schedule')).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByTestId('node-config-trigger-kind'), {
|
||||
target: { value: 'schedule' },
|
||||
});
|
||||
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' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,430 @@
|
||||
/**
|
||||
* Shared, presentation-only field primitives for the node-config drawer
|
||||
* (issue B5b / Phase 3b). Each per-kind form (`nodeConfigForms.tsx`) composes
|
||||
* these; the drawer owns the controlled config object and passes each field a
|
||||
* `value` + `onChange`, so every field here is a dumb controlled input.
|
||||
*
|
||||
* The primitives call `useT()` only for their *intrinsic* chrome (the
|
||||
* "Expression" affordance, "Add row"/"Remove", "Invalid JSON" message) — the
|
||||
* semantic per-field *labels* are always passed in by the form so the i18n key
|
||||
* lives next to the field's meaning, not buried in a generic input.
|
||||
*
|
||||
* `=`-expression affordance: tinyflows resolves any config string starting with
|
||||
* `=` as an expression evaluated against the node's input (`crate::expr`).
|
||||
* {@link ExpressionField} surfaces that with a monospace input and a small
|
||||
* "Expression" badge + hint so an author knows a value like `=item.url` is live,
|
||||
* not a literal.
|
||||
*/
|
||||
import createDebug from 'debug';
|
||||
import { useCallback, useId, useMemo, useState } from 'react';
|
||||
|
||||
import { useT } from '../../../../lib/i18n/I18nContext';
|
||||
import type { FlowConnection } from '../../../../services/api/flowsApi';
|
||||
|
||||
const log = createDebug('app:flows:nodeConfig:fields');
|
||||
|
||||
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]';
|
||||
|
||||
/** Read a string field off a free-form config object, defaulting to `''`. */
|
||||
export function configString(config: Record<string, unknown>, key: string): string {
|
||||
const value = config[key];
|
||||
return typeof value === 'string' ? value : '';
|
||||
}
|
||||
|
||||
/** Read a `Record<string,string>` map off config (e.g. HTTP headers / transform set). */
|
||||
export function configStringMap(
|
||||
config: Record<string, unknown>,
|
||||
key: string
|
||||
): Record<string, string> {
|
||||
const value = config[key];
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
|
||||
const out: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
||||
out[k] = typeof v === 'string' ? v : JSON.stringify(v);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Label + optional hint wrapper shared by every field. */
|
||||
export function Field({
|
||||
label,
|
||||
hint,
|
||||
htmlFor,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
hint?: string;
|
||||
htmlFor?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<label
|
||||
htmlFor={htmlFor}
|
||||
className="block text-[11px] font-semibold uppercase tracking-wide text-content-muted">
|
||||
{label}
|
||||
</label>
|
||||
{children}
|
||||
{hint && <p className="text-[11px] leading-snug text-content-faint">{hint}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface TextFieldProps {
|
||||
label: string;
|
||||
hint?: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
export function TextField({ label, hint, value, onChange, placeholder, testId }: TextFieldProps) {
|
||||
const id = useId();
|
||||
return (
|
||||
<Field label={label} hint={hint} htmlFor={id}>
|
||||
<input
|
||||
id={id}
|
||||
type="text"
|
||||
className={INPUT_CLASS}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
data-testid={testId}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
export interface TextAreaFieldProps extends Omit<TextFieldProps, 'onChange'> {
|
||||
onChange: (value: string) => void;
|
||||
rows?: number;
|
||||
mono?: boolean;
|
||||
}
|
||||
|
||||
export function TextAreaField({
|
||||
label,
|
||||
hint,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
rows = 4,
|
||||
mono,
|
||||
testId,
|
||||
}: TextAreaFieldProps) {
|
||||
const id = useId();
|
||||
return (
|
||||
<Field label={label} hint={hint} htmlFor={id}>
|
||||
<textarea
|
||||
id={id}
|
||||
rows={rows}
|
||||
className={`${INPUT_CLASS} resize-y ${mono ? MONO_CLASS : ''}`}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
data-testid={testId}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
export interface SelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface SelectFieldProps {
|
||||
label: string;
|
||||
hint?: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
options: SelectOption[];
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
export function SelectField({ label, hint, value, onChange, options, testId }: SelectFieldProps) {
|
||||
const id = useId();
|
||||
return (
|
||||
<Field label={label} hint={hint} htmlFor={id}>
|
||||
<select
|
||||
id={id}
|
||||
className={INPUT_CLASS}
|
||||
value={value}
|
||||
data-testid={testId}
|
||||
onChange={e => onChange(e.target.value)}>
|
||||
{options.map(opt => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A field whose value is commonly a tinyflows `=`-expression. Monospace input
|
||||
* with a leading "Expression" badge + hint so authors recognize `=item.foo`
|
||||
* as a live, input-bound value rather than a literal.
|
||||
*/
|
||||
export function ExpressionField({
|
||||
label,
|
||||
hint,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
testId,
|
||||
}: TextFieldProps) {
|
||||
const { t } = useT();
|
||||
const id = useId();
|
||||
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">
|
||||
<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')}
|
||||
aria-hidden="true">
|
||||
=
|
||||
</span>
|
||||
<input
|
||||
id={id}
|
||||
type="text"
|
||||
className={`w-full bg-transparent px-2.5 py-1.5 ${MONO_CLASS} text-content placeholder-content-faint focus:outline-none`}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
data-testid={testId}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
export interface KeyMapFieldProps {
|
||||
label: string;
|
||||
hint?: string;
|
||||
value: Record<string, string>;
|
||||
onChange: (value: Record<string, string>) => void;
|
||||
monoValues?: boolean;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Edits a flat `Record<string,string>` (HTTP headers, transform `set`) as a
|
||||
* list of key/value rows with add/remove. Rebuilds the whole object on every
|
||||
* keystroke so the parent stays the single controlled source of truth.
|
||||
*/
|
||||
export function KeyMapField({
|
||||
label,
|
||||
hint,
|
||||
value,
|
||||
onChange,
|
||||
monoValues,
|
||||
testId,
|
||||
}: KeyMapFieldProps) {
|
||||
const { t } = useT();
|
||||
// Rows are kept as an ordered array locally so editing a key doesn't reorder
|
||||
// or drop an in-progress empty key. Seeded from `value`; the parent object is
|
||||
// rebuilt from rows on every change.
|
||||
const [rows, setRows] = useState<Array<[string, string]>>(() => Object.entries(value));
|
||||
|
||||
const commit = useCallback(
|
||||
(next: Array<[string, string]>) => {
|
||||
setRows(next);
|
||||
const obj: Record<string, string> = {};
|
||||
for (const [k, v] of next) {
|
||||
if (k.trim() !== '') obj[k] = v;
|
||||
}
|
||||
log('KeyMapField commit: rows=%d keys=%d', next.length, Object.keys(obj).length);
|
||||
onChange(obj);
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<Field label={label} hint={hint}>
|
||||
<div className="space-y-1.5" data-testid={testId}>
|
||||
{rows.map(([k, v], i) => (
|
||||
<div key={i} className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="text"
|
||||
className={`${INPUT_CLASS} flex-1`}
|
||||
value={k}
|
||||
placeholder={t('flows.nodeConfig.keymapKeyPlaceholder')}
|
||||
onChange={e => {
|
||||
const next = rows.slice();
|
||||
next[i] = [e.target.value, v];
|
||||
commit(next);
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className={`${INPUT_CLASS} flex-1 ${monoValues ? MONO_CLASS : ''}`}
|
||||
value={v}
|
||||
placeholder={t('flows.nodeConfig.keymapValuePlaceholder')}
|
||||
onChange={e => {
|
||||
const next = rows.slice();
|
||||
next[i] = [k, e.target.value];
|
||||
commit(next);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 rounded-md px-1.5 py-1 text-content-faint hover:bg-surface-hover hover:text-coral-600"
|
||||
aria-label={t('flows.nodeConfig.keymapRemove')}
|
||||
onClick={() => commit(rows.filter((_, idx) => idx !== i))}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-dashed border-line-strong px-2 py-1 text-xs text-content-muted hover:bg-surface-hover"
|
||||
data-testid={testId ? `${testId}-add` : undefined}
|
||||
onClick={() => commit([...rows, ['', '']])}>
|
||||
+ {t('flows.nodeConfig.keymapAdd')}
|
||||
</button>
|
||||
</div>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
export interface JsonFieldProps {
|
||||
label: string;
|
||||
hint?: string;
|
||||
value: unknown;
|
||||
onChange: (value: unknown) => void;
|
||||
rows?: number;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Edits an arbitrary JSON value (HTTP body, tool_call args, or the raw-config
|
||||
* escape hatch) as pretty-printed text. Keeps a local text buffer so invalid
|
||||
* intermediate states are allowed while typing; only propagates `onChange` when
|
||||
* the buffer parses. Seeded once from `value` — the drawer body is keyed by
|
||||
* node id, so switching nodes remounts and re-seeds.
|
||||
*/
|
||||
export function JsonField({ label, hint, value, onChange, rows = 6, testId }: JsonFieldProps) {
|
||||
const { t } = useT();
|
||||
const id = useId();
|
||||
const initial = useMemo(() => {
|
||||
if (value === undefined || value === null) return '';
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}, [value]);
|
||||
const [text, setText] = useState(initial);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(next: string) => {
|
||||
setText(next);
|
||||
if (next.trim() === '') {
|
||||
setError(false);
|
||||
onChange(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(next);
|
||||
setError(false);
|
||||
log('JsonField parsed ok');
|
||||
onChange(parsed);
|
||||
} catch {
|
||||
setError(true);
|
||||
log('JsonField parse error — buffer held, not propagated');
|
||||
}
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<Field label={label} hint={hint} htmlFor={id}>
|
||||
<textarea
|
||||
id={id}
|
||||
rows={rows}
|
||||
className={`${INPUT_CLASS} resize-y ${MONO_CLASS} ${
|
||||
error ? 'border-coral-400 focus:border-coral-500 focus:ring-coral-500/20' : ''
|
||||
}`}
|
||||
value={text}
|
||||
data-testid={testId}
|
||||
onChange={e => handleChange(e.target.value)}
|
||||
/>
|
||||
{error && (
|
||||
<p className="text-[11px] font-medium text-coral-600 dark:text-coral-400" role="alert">
|
||||
{t('flows.nodeConfig.rawJsonInvalid')}
|
||||
</p>
|
||||
)}
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
export interface CredentialPickerFieldProps {
|
||||
label?: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
connections: FlowConnection[];
|
||||
/** Restrict the offered connections by kind (e.g. only `http` for HTTP nodes). */
|
||||
kinds?: Array<FlowConnection['kind']>;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Credential selector for `http_request` / `tool_call` nodes, fed by
|
||||
* `flows_list_connections` (secret-free). Writes the chosen `connection_ref`
|
||||
* onto config; the empty option clears it. Shows only `display`/`kind` — never
|
||||
* a secret. Renders a muted note when no credentials are connected.
|
||||
*/
|
||||
export function CredentialPickerField({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
connections,
|
||||
kinds,
|
||||
testId,
|
||||
}: CredentialPickerFieldProps) {
|
||||
const { t } = useT();
|
||||
const id = useId();
|
||||
const options = useMemo(
|
||||
() => (kinds ? connections.filter(c => kinds.includes(c.kind)) : connections),
|
||||
[connections, kinds]
|
||||
);
|
||||
|
||||
const resolvedLabel = label ?? t('flows.nodeConfig.credentialLabel');
|
||||
|
||||
if (options.length === 0) {
|
||||
return (
|
||||
<Field label={resolvedLabel} hint={t('flows.nodeConfig.credentialHint')}>
|
||||
<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.credentialEmpty')}
|
||||
</p>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Field label={resolvedLabel} hint={t('flows.nodeConfig.credentialHint')} htmlFor={id}>
|
||||
<select
|
||||
id={id}
|
||||
className={INPUT_CLASS}
|
||||
value={value}
|
||||
data-testid={testId}
|
||||
onChange={e => onChange(e.target.value)}>
|
||||
<option value="">{t('flows.nodeConfig.credentialNone')}</option>
|
||||
{options.map(conn => (
|
||||
<option key={conn.connection_ref} value={conn.connection_ref}>
|
||||
{conn.display} · {conn.kind}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
/**
|
||||
* Per-kind config forms for the node-config drawer (issue B5b / Phase 3b).
|
||||
* Each form renders a small set of typed fields for a high-traffic `NodeKind`,
|
||||
* reading from the controlled `config` object and merging edits back via
|
||||
* `onChange` (a shallow-merge patch). Kinds without a dedicated form fall back
|
||||
* to the drawer's raw-JSON editor.
|
||||
*
|
||||
* Field keys mirror what `vendor/tinyflows` actually reads at runtime:
|
||||
* - `http_request` → `method` / `url` / `connection_ref` / `headers` / `body`
|
||||
* - `agent` → `prompt` / `model` / `connection_ref`
|
||||
* - `tool_call` → `slug` / `args` / `connection_ref`
|
||||
* - `code` → `language` (`javascript`|`python`) / `source`
|
||||
* - `condition` → `field` (truthiness of a key on the input item)
|
||||
* - `switch` → `expression` (=-expr, precedence) / `field` (fallback)
|
||||
* - `transform` → `set` (key → =-expression map)
|
||||
* - `trigger` → `trigger_kind` + kind-specific (`schedule`, `toolkit`/`trigger_slug`)
|
||||
*/
|
||||
import type { NodeKind } from '../../../../lib/flows/types';
|
||||
import { useT } from '../../../../lib/i18n/I18nContext';
|
||||
import type { FlowConnection } from '../../../../services/api/flowsApi';
|
||||
import {
|
||||
configString,
|
||||
configStringMap,
|
||||
CredentialPickerField,
|
||||
ExpressionField,
|
||||
JsonField,
|
||||
KeyMapField,
|
||||
SelectField,
|
||||
TextAreaField,
|
||||
TextField,
|
||||
} from './nodeConfigFields';
|
||||
|
||||
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[];
|
||||
}
|
||||
|
||||
export type NodeConfigForm = (props: NodeConfigFormProps) => React.ReactElement;
|
||||
|
||||
// ── trigger ────────────────────────────────────────────────────────────────
|
||||
|
||||
const TRIGGER_KINDS = ['manual', 'schedule', 'webhook', 'app_event'] as const;
|
||||
|
||||
function TriggerForm({ config, onChange }: NodeConfigFormProps) {
|
||||
const { t } = useT();
|
||||
const kind = configString(config, 'trigger_kind') || 'manual';
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<SelectField
|
||||
label={t('flows.nodeConfig.trigger.kindLabel')}
|
||||
value={kind}
|
||||
onChange={v => onChange({ trigger_kind: v })}
|
||||
testId="node-config-trigger-kind"
|
||||
options={TRIGGER_KINDS.map(k => ({
|
||||
value: k,
|
||||
label: t(`flows.nodeConfig.trigger.kind_${k}`),
|
||||
}))}
|
||||
/>
|
||||
{kind === 'schedule' && (
|
||||
<TextField
|
||||
label={t('flows.nodeConfig.trigger.scheduleLabel')}
|
||||
hint={t('flows.nodeConfig.trigger.scheduleHint')}
|
||||
value={configString(config, 'schedule')}
|
||||
onChange={v => onChange({ schedule: v })}
|
||||
placeholder="0 9 * * 1"
|
||||
testId="node-config-trigger-schedule"
|
||||
/>
|
||||
)}
|
||||
{kind === 'app_event' && (
|
||||
<>
|
||||
<TextField
|
||||
label={t('flows.nodeConfig.trigger.toolkitLabel')}
|
||||
value={configString(config, 'toolkit')}
|
||||
onChange={v => onChange({ toolkit: v })}
|
||||
placeholder="github"
|
||||
/>
|
||||
<TextField
|
||||
label={t('flows.nodeConfig.trigger.triggerSlugLabel')}
|
||||
value={configString(config, 'trigger_slug')}
|
||||
onChange={v => onChange({ trigger_slug: v })}
|
||||
placeholder="GITHUB_STAR_ADDED"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{kind === 'webhook' && (
|
||||
<p className="rounded-lg border border-dashed border-amber-300 bg-amber-50 px-2.5 py-1.5 text-[11px] text-amber-700 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-300">
|
||||
{t('flows.nodeConfig.trigger.webhookHint')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── http_request ─────────────────────────────────────────────────────────────
|
||||
|
||||
const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
|
||||
|
||||
function HttpRequestForm({ config, onChange, connections }: NodeConfigFormProps) {
|
||||
const { t } = useT();
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<SelectField
|
||||
label={t('flows.nodeConfig.http.methodLabel')}
|
||||
value={configString(config, 'method') || 'GET'}
|
||||
onChange={v => onChange({ method: v })}
|
||||
testId="node-config-http-method"
|
||||
options={HTTP_METHODS.map(m => ({ value: m, label: m }))}
|
||||
/>
|
||||
<ExpressionField
|
||||
label={t('flows.nodeConfig.http.urlLabel')}
|
||||
value={configString(config, 'url')}
|
||||
onChange={v => onChange({ url: v })}
|
||||
placeholder="https://api.example.com/v1/resource"
|
||||
testId="node-config-http-url"
|
||||
/>
|
||||
<CredentialPickerField
|
||||
value={configString(config, 'connection_ref')}
|
||||
onChange={v => onChange({ connection_ref: v })}
|
||||
connections={connections}
|
||||
kinds={['http']}
|
||||
testId="node-config-http-credential"
|
||||
/>
|
||||
<KeyMapField
|
||||
label={t('flows.nodeConfig.http.headersLabel')}
|
||||
value={configStringMap(config, 'headers')}
|
||||
onChange={v => onChange({ headers: v })}
|
||||
monoValues
|
||||
testId="node-config-http-headers"
|
||||
/>
|
||||
<JsonField
|
||||
label={t('flows.nodeConfig.http.bodyLabel')}
|
||||
value={config.body ?? null}
|
||||
onChange={v => onChange({ body: v })}
|
||||
testId="node-config-http-body"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── agent ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function AgentForm({ config, onChange, connections }: NodeConfigFormProps) {
|
||||
const { t } = useT();
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<TextAreaField
|
||||
label={t('flows.nodeConfig.agent.promptLabel')}
|
||||
value={configString(config, 'prompt')}
|
||||
onChange={v => onChange({ prompt: v })}
|
||||
placeholder={t('flows.nodeConfig.agent.promptPlaceholder')}
|
||||
rows={5}
|
||||
testId="node-config-agent-prompt"
|
||||
/>
|
||||
<TextField
|
||||
label={t('flows.nodeConfig.agent.modelLabel')}
|
||||
value={configString(config, 'model')}
|
||||
onChange={v => onChange({ model: v })}
|
||||
placeholder="gpt-4o-mini"
|
||||
testId="node-config-agent-model"
|
||||
/>
|
||||
<CredentialPickerField
|
||||
value={configString(config, 'connection_ref')}
|
||||
onChange={v => onChange({ connection_ref: v })}
|
||||
connections={connections}
|
||||
testId="node-config-agent-credential"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── tool_call ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function ToolCallForm({ config, onChange, connections }: NodeConfigFormProps) {
|
||||
const { t } = useT();
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<TextField
|
||||
label={t('flows.nodeConfig.tool.slugLabel')}
|
||||
value={configString(config, 'slug')}
|
||||
onChange={v => onChange({ slug: v })}
|
||||
placeholder="GITHUB_CREATE_ISSUE"
|
||||
testId="node-config-tool-slug"
|
||||
/>
|
||||
<CredentialPickerField
|
||||
value={configString(config, 'connection_ref')}
|
||||
onChange={v => onChange({ connection_ref: v })}
|
||||
connections={connections}
|
||||
testId="node-config-tool-credential"
|
||||
/>
|
||||
<JsonField
|
||||
label={t('flows.nodeConfig.tool.argsLabel')}
|
||||
value={config.args ?? null}
|
||||
onChange={v => onChange({ args: v })}
|
||||
testId="node-config-tool-args"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── condition ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function ConditionForm({ config, onChange }: NodeConfigFormProps) {
|
||||
const { t } = useT();
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<TextField
|
||||
label={t('flows.nodeConfig.condition.fieldLabel')}
|
||||
hint={t('flows.nodeConfig.condition.fieldHint')}
|
||||
value={configString(config, 'field')}
|
||||
onChange={v => onChange({ field: v })}
|
||||
placeholder="status"
|
||||
testId="node-config-condition-field"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── switch ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function SwitchForm({ config, onChange }: NodeConfigFormProps) {
|
||||
const { t } = useT();
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<ExpressionField
|
||||
label={t('flows.nodeConfig.switch.expressionLabel')}
|
||||
hint={t('flows.nodeConfig.switch.hint')}
|
||||
value={configString(config, 'expression')}
|
||||
onChange={v => onChange({ expression: v })}
|
||||
placeholder="item.type"
|
||||
testId="node-config-switch-expression"
|
||||
/>
|
||||
<TextField
|
||||
label={t('flows.nodeConfig.switch.fieldLabel')}
|
||||
value={configString(config, 'field')}
|
||||
onChange={v => onChange({ field: v })}
|
||||
placeholder="type"
|
||||
testId="node-config-switch-field"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── transform ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function TransformForm({ config, onChange }: NodeConfigFormProps) {
|
||||
const { t } = useT();
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<KeyMapField
|
||||
label={t('flows.nodeConfig.transform.setLabel')}
|
||||
hint={t('flows.nodeConfig.transform.setHint')}
|
||||
value={configStringMap(config, 'set')}
|
||||
onChange={v => onChange({ set: v })}
|
||||
monoValues
|
||||
testId="node-config-transform-set"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── code ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
function CodeForm({ config, onChange }: NodeConfigFormProps) {
|
||||
const { t } = useT();
|
||||
const language = configString(config, 'language') || 'javascript';
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<SelectField
|
||||
label={t('flows.nodeConfig.code.languageLabel')}
|
||||
value={language}
|
||||
onChange={v => onChange({ language: v })}
|
||||
testId="node-config-code-language"
|
||||
options={[
|
||||
{ value: 'javascript', label: t('flows.nodeConfig.code.language_javascript') },
|
||||
{ value: 'python', label: t('flows.nodeConfig.code.language_python') },
|
||||
]}
|
||||
/>
|
||||
<TextAreaField
|
||||
label={t('flows.nodeConfig.code.sourceLabel')}
|
||||
value={configString(config, 'source')}
|
||||
onChange={v => onChange({ source: v })}
|
||||
placeholder="return items;"
|
||||
rows={8}
|
||||
mono
|
||||
testId="node-config-code-source"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registry of the kinds that get a dedicated form. Any `NodeKind` absent here
|
||||
* (merge, split_out, output_parser, sub_workflow) falls through to the drawer's
|
||||
* raw-JSON escape hatch.
|
||||
*/
|
||||
export const NODE_CONFIG_FORMS: Partial<Record<NodeKind, NodeConfigForm>> = {
|
||||
trigger: TriggerForm,
|
||||
http_request: HttpRequestForm,
|
||||
agent: AgentForm,
|
||||
tool_call: ToolCallForm,
|
||||
condition: ConditionForm,
|
||||
switch: SwitchForm,
|
||||
transform: TransformForm,
|
||||
code: CodeForm,
|
||||
};
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* useFlowValidation (Phase 3c) — debounced + on-demand validation of the live
|
||||
* editable-canvas draft against `openhuman.flows_validate`.
|
||||
*
|
||||
* The canvas serializes its controlled node/edge state to a `WorkflowGraph` on
|
||||
* every edit; this hook watches that graph (keyed by its serialized form so a
|
||||
* no-op re-render never re-validates) and, `DEBOUNCE_MS` after the last change,
|
||||
* asks the core to validate it. It also exposes {@link FlowValidationState.validateNow}
|
||||
* for the explicit "Validate" button (immediate, bypasses the debounce).
|
||||
*
|
||||
* Failures of the RPC itself (offline, no Tauri bridge, transport error) are
|
||||
* swallowed — validation is advisory client-side (the server re-validates on
|
||||
* `flows_update` before persisting), so a transport hiccup must never wedge the
|
||||
* editor with a stale "invalid" state or an unhandled rejection. On failure the
|
||||
* last successful `validation` is left untouched and `validating` clears.
|
||||
*/
|
||||
import createDebug from 'debug';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import type { WorkflowGraph } from '../../../lib/flows/types';
|
||||
import { type FlowValidation, validateFlow } from '../../../services/api/flowsApi';
|
||||
|
||||
const log = createDebug('app:flows:canvas:validate');
|
||||
|
||||
/** Idle delay after the last edit before auto-validating the draft. */
|
||||
export const VALIDATION_DEBOUNCE_MS = 500;
|
||||
|
||||
export interface FlowValidationState {
|
||||
/** The most recent successful validation result, or `null` before the first. */
|
||||
validation: FlowValidation | null;
|
||||
/** True while a validation RPC is in flight (debounced or manual). */
|
||||
validating: boolean;
|
||||
/** Validate the current graph immediately, resolving with the result (or `null` on error). */
|
||||
validateNow: () => Promise<FlowValidation | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param graph the live draft graph (read via a ref, so its identity changing
|
||||
* every render doesn't itself trigger re-validation).
|
||||
* @param graphKey a stable serialization of `graph`; the debounced effect keys
|
||||
* off this so validation only re-runs when the graph truly changes.
|
||||
* @param enabled gate the auto-validate effect (e.g. off in read-only hosts).
|
||||
*/
|
||||
export function useFlowValidation(
|
||||
graph: WorkflowGraph,
|
||||
graphKey: string,
|
||||
enabled = true
|
||||
): FlowValidationState {
|
||||
const [validation, setValidation] = useState<FlowValidation | null>(null);
|
||||
const [validating, setValidating] = useState(false);
|
||||
|
||||
// Latest graph, read lazily so the debounced effect / manual trigger always
|
||||
// validate the current draft without listing `graph` (new object each render)
|
||||
// as a dependency.
|
||||
const graphRef = useRef(graph);
|
||||
graphRef.current = graph;
|
||||
|
||||
const mountedRef = useRef(true);
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Monotonic request token: guards against an out-of-order completion (a
|
||||
// debounced auto-validate and a manual `validateNow()`, or two rapid
|
||||
// `validateNow()` calls, resolving out of issue order) applying a stale
|
||||
// result over a fresher one — which could wrongly unblock or re-block Save
|
||||
// after the user has made further edits. Also doubles as the correlation
|
||||
// field for the debug log.
|
||||
const requestIdRef = useRef(0);
|
||||
|
||||
const run = useCallback(async (): Promise<FlowValidation | null> => {
|
||||
const requestId = ++requestIdRef.current;
|
||||
setValidating(true);
|
||||
try {
|
||||
const result = await validateFlow(graphRef.current);
|
||||
if (!mountedRef.current || requestId !== requestIdRef.current) return result;
|
||||
log(
|
||||
'validated (req=%d): valid=%s errors=%d warnings=%d',
|
||||
requestId,
|
||||
result.valid,
|
||||
result.errors.length,
|
||||
result.warnings.length
|
||||
);
|
||||
setValidation(result);
|
||||
return result;
|
||||
} catch (err) {
|
||||
log('validation failed (non-fatal): %o', err);
|
||||
return null;
|
||||
} finally {
|
||||
if (mountedRef.current && requestId === requestIdRef.current) setValidating(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Debounced auto-validate: re-runs only when the serialized graph changes.
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const timer = setTimeout(() => {
|
||||
void run();
|
||||
}, VALIDATION_DEBOUNCE_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [graphKey, enabled, run]);
|
||||
|
||||
return { validation, validating, validateNow: run };
|
||||
}
|
||||
|
||||
export default useFlowValidation;
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* `useCreateFlow` (Phase 4a/4c) — shared create-and-open logic for the
|
||||
* new-workflow chooser, the template gallery, and the Workflows empty state.
|
||||
* Persists a candidate `WorkflowGraph` via `flows_create` and, on success,
|
||||
* navigates into the editable canvas at `/flows/:id`. Single-flight: a second
|
||||
* call while one is in flight is ignored, so a double-click can't create two
|
||||
* flows.
|
||||
*
|
||||
* `busyKey` identifies which affordance is mid-create (a template id, or
|
||||
* `'blank'` for start-from-scratch) so a caller can show the spinner on just
|
||||
* that card/button. On failure the key clears and `error` is set to the
|
||||
* localized `flows.chooser.createError` message, leaving the surface open to
|
||||
* retry.
|
||||
*/
|
||||
import createDebug from 'debug';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import type { WorkflowGraph } from '../../lib/flows/types';
|
||||
import { useT } from '../../lib/i18n/I18nContext';
|
||||
import { createFlow } from '../../services/api/flowsApi';
|
||||
|
||||
const log = createDebug('app:flows:create');
|
||||
|
||||
/** Sentinel `busyKey` for the "start from scratch" path (not a template id). */
|
||||
export const BLANK_FLOW_KEY = 'blank';
|
||||
|
||||
export interface UseCreateFlow {
|
||||
/** Persist `graph` under `name`, then navigate into its canvas. `key` tags the busy affordance. */
|
||||
create: (key: string, name: string, graph: WorkflowGraph) => Promise<void>;
|
||||
/** The `key` of the create currently in flight, or `null`. */
|
||||
busyKey: string | null;
|
||||
/** Localized create-failure message, or `null`. */
|
||||
error: string | null;
|
||||
/** Clear the error banner (e.g. when the user switches views). */
|
||||
clearError: () => void;
|
||||
}
|
||||
|
||||
export function useCreateFlow(): UseCreateFlow {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useT();
|
||||
const [busyKey, setBusyKey] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const create = useCallback(
|
||||
async (key: string, name: string, graph: WorkflowGraph) => {
|
||||
if (busyKey) {
|
||||
log('create: ignored — already creating key=%s', busyKey);
|
||||
return;
|
||||
}
|
||||
log('create: key=%s name=%s nodes=%d', key, name, graph.nodes.length);
|
||||
setBusyKey(key);
|
||||
setError(null);
|
||||
try {
|
||||
const flow = await createFlow(name, graph);
|
||||
log('create: created id=%s — navigating to canvas', flow.id);
|
||||
navigate(`/flows/${flow.id}`);
|
||||
} catch (err) {
|
||||
log('create: failed key=%s err=%o', key, err);
|
||||
setError(t('flows.chooser.createError'));
|
||||
setBusyKey(null);
|
||||
}
|
||||
},
|
||||
[busyKey, navigate, t]
|
||||
);
|
||||
|
||||
const clearError = useCallback(() => setError(null), []);
|
||||
|
||||
return { create, busyKey, error, clearError };
|
||||
}
|
||||
@@ -4,8 +4,8 @@
|
||||
*
|
||||
* The Intelligence page's "Workflows" tab — the single home for installed
|
||||
* workflows (the unified primitive: a goal + the procedure to reach it,
|
||||
* authored as SKILL.md bundles and served by the `workflows_*` JSON-RPC via
|
||||
* `workflowsApi`).
|
||||
* authored as SKILL.md bundles and served by the `skills_*` JSON-RPC via
|
||||
* `skillsApi`).
|
||||
*
|
||||
* Owns the full workflow surface that used to live on the Connections page:
|
||||
* - lists discovered workflows as cards,
|
||||
@@ -21,7 +21,7 @@ import { useCallback, useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { useT } from '../../lib/i18n/I18nContext';
|
||||
import { workflowsApi, type WorkflowSummary } from '../../services/api/workflowsApi';
|
||||
import { skillsApi, type WorkflowSummary } from '../../services/api/skillsApi';
|
||||
import type { ToastNotification } from '../../types/intelligence';
|
||||
import SettingsPanel from '../settings/layout/SettingsPanel';
|
||||
import CreateSkillModal from '../skills/CreateSkillModal';
|
||||
@@ -63,7 +63,7 @@ export default function WorkflowsTab({ asSettingsPanel = false }: WorkflowsTabPr
|
||||
|
||||
const refresh = useCallback(async (): Promise<WorkflowSummary[]> => {
|
||||
try {
|
||||
const list = await workflowsApi.listWorkflows();
|
||||
const list = await skillsApi.listWorkflows();
|
||||
log('listWorkflows ok count=%d', list.length);
|
||||
setLoadError(null);
|
||||
setWorkflows(list);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { WorkflowSummary } from '../../../services/api/workflowsApi';
|
||||
import type { WorkflowSummary } from '../../../services/api/skillsApi';
|
||||
import WorkflowsTab from '../WorkflowsTab';
|
||||
|
||||
vi.mock('../../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string) => k }) }));
|
||||
@@ -83,14 +83,11 @@ const seeded = (overrides: Partial<WorkflowSummary>): WorkflowSummary => ({
|
||||
});
|
||||
|
||||
const listWorkflows = vi.fn();
|
||||
vi.mock('../../../services/api/workflowsApi', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../../services/api/workflowsApi')>(
|
||||
'../../../services/api/workflowsApi'
|
||||
vi.mock('../../../services/api/skillsApi', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../../services/api/skillsApi')>(
|
||||
'../../../services/api/skillsApi'
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
workflowsApi: { ...actual.workflowsApi, listWorkflows: () => listWorkflows() },
|
||||
};
|
||||
return { ...actual, skillsApi: { ...actual.skillsApi, listWorkflows: () => listWorkflows() } };
|
||||
});
|
||||
|
||||
describe('WorkflowsTab', () => {
|
||||
@@ -99,7 +96,7 @@ describe('WorkflowsTab', () => {
|
||||
listWorkflows.mockReset();
|
||||
});
|
||||
|
||||
it('lists workflows from workflowsApi with the create entry point', async () => {
|
||||
it('lists workflows from skillsApi with the create entry point', async () => {
|
||||
listWorkflows.mockResolvedValue([
|
||||
seeded({ id: 'user-wf', name: 'User WF', scope: 'user' }),
|
||||
seeded({ id: 'project-wf', name: 'Project WF', scope: 'project' }),
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* ----------------
|
||||
*
|
||||
* Centered white modal that scaffolds a new SKILL.md skill via the
|
||||
* `openhuman.workflows_create` JSON-RPC method. Matches the settings-modal
|
||||
* `openhuman.skills_create` JSON-RPC method. Matches the settings-modal
|
||||
* design rules (clean white, 520px desktop, 16px radius, backdrop + blur,
|
||||
* Escape/click-out to close, focus capture) — see
|
||||
* `.claude/rules/15-settings-modal-system.md`.
|
||||
@@ -20,7 +20,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { useT } from '../../lib/i18n/I18nContext';
|
||||
import { type WorkflowSummary } from '../../services/api/workflowsApi';
|
||||
import { type WorkflowSummary } from '../../services/api/skillsApi';
|
||||
import Button from '../ui/Button';
|
||||
import CreateWorkflowForm from './CreateWorkflowForm';
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* - All form fields (name, description, scope, license, author,
|
||||
* tags, allowed-tools).
|
||||
* - Slug preview + validation (name and description required).
|
||||
* - Submit handler that calls `workflowsApi.createWorkflow` and surfaces
|
||||
* - Submit handler that calls `skillsApi.createWorkflow` and surfaces
|
||||
* the result via `onCreated(skill)` / error string via inline
|
||||
* `<div role="alert">`.
|
||||
*
|
||||
@@ -43,10 +43,10 @@ import { useT } from '../../lib/i18n/I18nContext';
|
||||
import {
|
||||
type CreateWorkflowInput,
|
||||
type CreateWorkflowInputDef,
|
||||
workflowsApi,
|
||||
skillsApi,
|
||||
type WorkflowScope,
|
||||
type WorkflowSummary,
|
||||
} from '../../services/api/workflowsApi';
|
||||
} from '../../services/api/skillsApi';
|
||||
import Button from '../ui/Button';
|
||||
|
||||
/** Mirrors `SkillCreateInputDef` shape used as wire payload, with one
|
||||
@@ -81,7 +81,7 @@ const log = debug('skills:create-form');
|
||||
export interface CreateSkillFormHandle {
|
||||
/** True iff name+description are present and no submit is in flight. */
|
||||
isValid: () => boolean;
|
||||
/** True while workflowsApi.createWorkflow is in flight. */
|
||||
/** True while skillsApi.createWorkflow is in flight. */
|
||||
isSubmitting: () => boolean;
|
||||
/** Imperatively trigger submit. Resolves once the round-trip finishes. */
|
||||
submit: () => Promise<void>;
|
||||
@@ -107,7 +107,7 @@ export interface CreateSkillFormProps {
|
||||
/**
|
||||
* When set, the form is in EDIT mode for this workflow: fields are
|
||||
* pre-filled (name read-only — the slug is identity), and submit calls
|
||||
* `workflows_update` instead of `workflows_create`. Tags / author /
|
||||
* `skills_update` instead of `skills_create`. Tags / author /
|
||||
* allowed-tools (not exposed as editable fields) are carried through so
|
||||
* they're preserved on save.
|
||||
*/
|
||||
@@ -143,7 +143,7 @@ const CreateWorkflowForm = forwardRef<CreateSkillFormHandle, CreateSkillFormProp
|
||||
const { t } = useT();
|
||||
const isEdit = !!editing;
|
||||
// Fields the form doesn't expose but must preserve across an edit
|
||||
// (otherwise workflows_update would regenerate frontmatter without them).
|
||||
// (otherwise skills_update would regenerate frontmatter without them).
|
||||
const preservedRef = useRef<{ tags?: string[]; author?: string; allowedTools?: string[] }>({});
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
@@ -218,7 +218,7 @@ const CreateWorkflowForm = forwardRef<CreateSkillFormHandle, CreateSkillFormProp
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const desc = await workflowsApi.describeWorkflow(editing.id);
|
||||
const desc = await skillsApi.describeWorkflow(editing.id);
|
||||
if (cancelled) return;
|
||||
if (desc.when_to_use) setWhenToUse(desc.when_to_use);
|
||||
setInputs(
|
||||
@@ -280,8 +280,8 @@ const CreateWorkflowForm = forwardRef<CreateSkillFormHandle, CreateSkillFormProp
|
||||
setError(null);
|
||||
try {
|
||||
const saved = editing
|
||||
? await workflowsApi.updateWorkflow(payload)
|
||||
: await workflowsApi.createWorkflow(payload);
|
||||
? await skillsApi.updateWorkflow(payload)
|
||||
: await skillsApi.createWorkflow(payload);
|
||||
log('submit-ok id=%s edit=%s', saved.id, isEdit);
|
||||
onCreated(saved);
|
||||
} catch (err) {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* ------------------
|
||||
*
|
||||
* Centered white modal that installs a skill via
|
||||
* `openhuman.workflows_install_from_url`. The Rust side fetches a single
|
||||
* `openhuman.skills_install_from_url`. The Rust side fetches a single
|
||||
* `SKILL.md` file over HTTPS and writes it into
|
||||
* `<workspace>/.openhuman/skills/<slug>/SKILL.md`. URLs are allow-listed
|
||||
* (https only, no private/loopback/link-local/multicast/cloud-metadata
|
||||
@@ -36,9 +36,9 @@ import { useT } from '../../lib/i18n/I18nContext';
|
||||
import { trackEvent } from '../../services/analytics';
|
||||
import {
|
||||
type InstallWorkflowFromUrlResult,
|
||||
workflowsApi,
|
||||
skillsApi,
|
||||
type WorkflowSummary,
|
||||
} from '../../services/api/workflowsApi';
|
||||
} from '../../services/api/skillsApi';
|
||||
import Button from '../ui/Button';
|
||||
|
||||
const log = debug('skills:install-dialog');
|
||||
@@ -202,7 +202,7 @@ export default function InstallSkillDialog({ onClose, onInstalled }: Props) {
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const installed = await workflowsApi.installWorkflowFromUrl(payload);
|
||||
const installed = await skillsApi.installWorkflowFromUrl(payload);
|
||||
log(
|
||||
'submit-ok new=%d stdout=%d stderr=%d',
|
||||
installed.newWorkflows.length,
|
||||
|
||||
@@ -5,9 +5,9 @@ import { useT } from '../../lib/i18n/I18nContext';
|
||||
import { type CatalogEntry, skillRegistryApi } from '../../services/api/skillRegistryApi';
|
||||
import {
|
||||
type InstallWorkflowFromUrlResult,
|
||||
workflowsApi,
|
||||
skillsApi,
|
||||
type WorkflowSummary,
|
||||
} from '../../services/api/workflowsApi';
|
||||
} from '../../services/api/skillsApi';
|
||||
import EmptyStateCard from '../EmptyStateCard';
|
||||
import ChipTabs from '../layout/ChipTabs';
|
||||
import Button from '../ui/Button';
|
||||
@@ -574,7 +574,7 @@ export default function SkillsExplorerTab({ onToast }: SkillsExplorerTabProps) {
|
||||
try {
|
||||
// Include `skills/`-root installs (registry installs land there) so they
|
||||
// appear in the Installed tab and flip the catalog Install button.
|
||||
const result = await workflowsApi.listWorkflows({ includeSkills: true });
|
||||
const result = await skillsApi.listWorkflows({ includeSkills: true });
|
||||
log('fetchSkills: count=%d', result.length);
|
||||
setSkills(result);
|
||||
} catch (err) {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* ---------------------------
|
||||
*
|
||||
* Small centered confirm modal for destructive uninstall of a user-scope
|
||||
* SKILL.md skill. Wraps `workflowsApi.uninstallWorkflow` which calls
|
||||
* SKILL.md skill. Wraps `skillsApi.uninstallWorkflow` which calls
|
||||
* `openhuman.skill_registry_uninstall` on the Rust side — that RPC only accepts
|
||||
* user-scope installs (`~/.openhuman/skills/<name>/`) and refuses project
|
||||
* and legacy scopes. The card that opens this dialog is responsible for
|
||||
@@ -31,9 +31,9 @@ import { useT } from '../../lib/i18n/I18nContext';
|
||||
import { trackEvent } from '../../services/analytics';
|
||||
import {
|
||||
type UninstallWorkflowResult,
|
||||
workflowsApi,
|
||||
skillsApi,
|
||||
type WorkflowSummary,
|
||||
} from '../../services/api/workflowsApi';
|
||||
} from '../../services/api/skillsApi';
|
||||
import Button from '../ui/Button';
|
||||
|
||||
const log = debug('skills:uninstall-dialog');
|
||||
@@ -83,7 +83,7 @@ export default function UninstallSkillConfirmDialog({ skill, onClose, onUninstal
|
||||
// `skill.id` is the on-disk slug (directory under ~/.openhuman/skills/).
|
||||
// `skill.name` is the frontmatter display name and may diverge from the
|
||||
// slug — the backend resolves by slug, so pass `id`.
|
||||
const result = await workflowsApi.uninstallWorkflow(skill.id);
|
||||
const result = await skillsApi.uninstallWorkflow(skill.id);
|
||||
log('confirm: done removedPath=%s', result.removedPath);
|
||||
trackEvent('skill_uninstall', { skill_id: skill.id });
|
||||
onUninstalled(result);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// Generalises across every bundled skill (`github-issue-crusher`,
|
||||
// `pr-review-shepherd`, `dev-workflow`, plus anything the user installs
|
||||
// later) — pick one from the dropdown, fill the dynamically-rendered
|
||||
// inputs (loaded from `openhuman.workflows_describe`), Run Now to
|
||||
// inputs (loaded from `openhuman.skills_describe`), Run Now to
|
||||
// fire-and-forget a background autonomous run, or Save as a recurring
|
||||
// cron schedule. Recent runs are listed below with an inline log
|
||||
// viewer (click-to-expand, auto-tail for in-flight runs).
|
||||
@@ -22,9 +22,9 @@ import {
|
||||
type ScannedRun,
|
||||
type WorkflowDescription,
|
||||
type WorkflowRunStarted,
|
||||
workflowsApi,
|
||||
skillsApi,
|
||||
type WorkflowSummary,
|
||||
} from '../../services/api/workflowsApi';
|
||||
} from '../../services/api/skillsApi';
|
||||
import {
|
||||
type CoreCronJob,
|
||||
type CoreCronRun,
|
||||
@@ -454,12 +454,12 @@ export const WorkflowRunnerBody = ({ headerText, className }: SkillsRunnerBodyPr
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchParams, selectedSkillId]);
|
||||
|
||||
// ── Initial load: workflows_list ──────────────────────────────────────
|
||||
// ── Initial load: skills_list ──────────────────────────────────────
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setSkillsLoading(true);
|
||||
setSkillsError(null);
|
||||
workflowsApi
|
||||
skillsApi
|
||||
// Include `skills/`-root installs so registry-installed skills are runnable here.
|
||||
.listWorkflows({ includeSkills: true })
|
||||
.then(list => {
|
||||
@@ -483,7 +483,7 @@ export const WorkflowRunnerBody = ({ headerText, className }: SkillsRunnerBodyPr
|
||||
};
|
||||
}, []);
|
||||
|
||||
// ── On selection: workflows_describe ──────────────────────────────────
|
||||
// ── On selection: skills_describe ──────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!selectedSkillId) {
|
||||
setDescription(null);
|
||||
@@ -494,7 +494,7 @@ export const WorkflowRunnerBody = ({ headerText, className }: SkillsRunnerBodyPr
|
||||
setDescLoading(true);
|
||||
setDescError(null);
|
||||
setRun({ status: 'idle' });
|
||||
workflowsApi
|
||||
skillsApi
|
||||
.describeWorkflow(selectedSkillId)
|
||||
.then(desc => {
|
||||
if (cancelled) return;
|
||||
@@ -546,7 +546,7 @@ export const WorkflowRunnerBody = ({ headerText, className }: SkillsRunnerBodyPr
|
||||
const handleStopRun = useCallback(async (runId: string) => {
|
||||
log('stop run runId=%s', runId);
|
||||
try {
|
||||
await workflowsApi.cancelRun(runId);
|
||||
await skillsApi.cancelRun(runId);
|
||||
} catch (err) {
|
||||
log('cancelRun error: %s', err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
@@ -557,7 +557,7 @@ export const WorkflowRunnerBody = ({ headerText, className }: SkillsRunnerBodyPr
|
||||
const runtimeRequirement = inferRuntimeRequirement(selectedWorkflow);
|
||||
if (!runtimeRequirement) return;
|
||||
|
||||
const resolved = await workflowsApi.resolveRuntimes(runtimeRequirement);
|
||||
const resolved = await skillsApi.resolveRuntimes(runtimeRequirement);
|
||||
const unavailable = resolved.runtimes.filter(runtime => !runtime.available);
|
||||
if (unavailable.length === 0) return;
|
||||
|
||||
@@ -592,7 +592,7 @@ export const WorkflowRunnerBody = ({ headerText, className }: SkillsRunnerBodyPr
|
||||
const inputs = buildInputsPayload(description, formValues);
|
||||
log('runWorkflow %s inputs=%o', description.id, inputs);
|
||||
await ensureRuntimeAvailability();
|
||||
const result = await workflowsApi.runWorkflow(description.id, inputs);
|
||||
const result = await skillsApi.runWorkflow(description.id, inputs);
|
||||
setRun({ status: 'started', result });
|
||||
// Surface the new run in "Recent runs" without a manual refresh, and
|
||||
// hold the guard through a short cooldown so a second click can't spawn
|
||||
@@ -619,7 +619,7 @@ export const WorkflowRunnerBody = ({ headerText, className }: SkillsRunnerBodyPr
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setRecentRunsLoading(true);
|
||||
workflowsApi
|
||||
skillsApi
|
||||
.recentRuns(selectedSkillId || undefined, 10)
|
||||
.then(list => {
|
||||
if (cancelled) return;
|
||||
@@ -735,7 +735,7 @@ export const WorkflowRunnerBody = ({ headerText, className }: SkillsRunnerBodyPr
|
||||
error: null,
|
||||
},
|
||||
}));
|
||||
const slice: RunLogSlice = await workflowsApi.readRunLog(runId, fromOffset);
|
||||
const slice: RunLogSlice = await skillsApi.readRunLog(runId, fromOffset);
|
||||
if (cancelled) return;
|
||||
setViewer(prev => {
|
||||
const prior = prev[runId]?.content ?? '';
|
||||
@@ -818,7 +818,7 @@ export const WorkflowRunnerBody = ({ headerText, className }: SkillsRunnerBodyPr
|
||||
try {
|
||||
log('runJobNow: running %s directly with %o', selectedSkillId, inputs);
|
||||
await ensureRuntimeAvailability();
|
||||
await workflowsApi.runWorkflow(selectedSkillId, inputs);
|
||||
await skillsApi.runWorkflow(selectedSkillId, inputs);
|
||||
scheduleRecentRunsRefresh();
|
||||
releaseRunGuard(2500);
|
||||
} catch (err: unknown) {
|
||||
@@ -1586,11 +1586,11 @@ export const WorkflowRunnerBody = ({ headerText, className }: SkillsRunnerBodyPr
|
||||
onClose={() => setEditOpen(false)}
|
||||
onCreated={() => {
|
||||
setEditOpen(false);
|
||||
void workflowsApi
|
||||
void skillsApi
|
||||
.listWorkflows({ includeSkills: true })
|
||||
.then(list => setSkills(list.filter(s => s.id !== 'codegraph-smoke')))
|
||||
.catch(() => {});
|
||||
void workflowsApi
|
||||
void skillsApi
|
||||
.describeWorkflow(selectedSkillId)
|
||||
.then(setDescription)
|
||||
.catch(() => {});
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* - Escape key closes (but not while submitting).
|
||||
* - Backdrop click closes (but not while submitting).
|
||||
* - Submit is disabled when name or description is empty.
|
||||
* - Submit rekeys `allowedTools` → `'allowed-tools'` via workflowsApi.createWorkflow.
|
||||
* - Submit rekeys `allowedTools` → `'allowed-tools'` via skillsApi.createWorkflow.
|
||||
* - Submit calls `onCreated` with the returned skill.
|
||||
* - Submit failure surfaces an error banner and re-enables the button.
|
||||
* - Slug preview updates as the name changes.
|
||||
@@ -14,11 +14,11 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { WorkflowSummary } from '../../../services/api/workflowsApi';
|
||||
import type { WorkflowSummary } from '../../../services/api/skillsApi';
|
||||
import CreateSkillModal from '../CreateSkillModal';
|
||||
|
||||
vi.mock('../../../services/api/workflowsApi', () => ({
|
||||
workflowsApi: {
|
||||
vi.mock('../../../services/api/skillsApi', () => ({
|
||||
skillsApi: {
|
||||
createWorkflow: vi.fn(),
|
||||
updateWorkflow: vi.fn(),
|
||||
describeWorkflow: vi
|
||||
@@ -51,8 +51,8 @@ function builtSkill(overrides: Partial<WorkflowSummary> = {}): WorkflowSummary {
|
||||
|
||||
describe('CreateSkillModal', () => {
|
||||
beforeEach(async () => {
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
vi.mocked(workflowsApi.createWorkflow).mockReset();
|
||||
const { skillsApi } = await import('../../../services/api/skillsApi');
|
||||
vi.mocked(skillsApi.createWorkflow).mockReset();
|
||||
});
|
||||
|
||||
it('renders title and required fields', () => {
|
||||
@@ -96,13 +96,13 @@ describe('CreateSkillModal', () => {
|
||||
// form is now Name + Description + the `[[inputs]]` editor only — see
|
||||
// ScheduledCronCard / CreateWorkflowForm.tsx), so the inputs are no longer
|
||||
// collectable from the modal UI. The rekey itself still happens in
|
||||
// `workflowsApi.createWorkflow` (services/api/workflowsApi.ts → params build) and
|
||||
// is covered by the workflowsApi unit tests; this test now just guards the
|
||||
// `skillsApi.createWorkflow` (services/api/skillsApi.ts → params build) and
|
||||
// is covered by the skillsApi unit tests; this test now just guards the
|
||||
// modal's submit-pipeline shape: name + description → createWorkflow →
|
||||
// onCreated.
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
const { skillsApi } = await import('../../../services/api/skillsApi');
|
||||
const created = builtSkill();
|
||||
vi.mocked(workflowsApi.createWorkflow).mockResolvedValueOnce(created);
|
||||
vi.mocked(skillsApi.createWorkflow).mockResolvedValueOnce(created);
|
||||
|
||||
const onCreated = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
@@ -116,15 +116,15 @@ describe('CreateSkillModal', () => {
|
||||
fireEvent.click(submit);
|
||||
});
|
||||
|
||||
expect(vi.mocked(workflowsApi.createWorkflow)).toHaveBeenCalledWith(
|
||||
expect(vi.mocked(skillsApi.createWorkflow)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: 'My Skill', description: 'does stuff', scope: 'user' })
|
||||
);
|
||||
expect(onCreated).toHaveBeenCalledWith(created);
|
||||
});
|
||||
|
||||
it('surfaces error and re-enables submit on failure', async () => {
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
vi.mocked(workflowsApi.createWorkflow).mockRejectedValueOnce(new Error('slug already exists'));
|
||||
const { skillsApi } = await import('../../../services/api/skillsApi');
|
||||
vi.mocked(skillsApi.createWorkflow).mockRejectedValueOnce(new Error('slug already exists'));
|
||||
|
||||
render(<CreateSkillModal onClose={vi.fn()} onCreated={vi.fn()} />);
|
||||
fireEvent.change(screen.getByLabelText(/Name/), { target: { value: 'dup' } });
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* and the /skills/new page can rely on it.
|
||||
*
|
||||
* Covers:
|
||||
* - submit calls workflowsApi.createWorkflow with the trimmed/normalised
|
||||
* - submit calls skillsApi.createWorkflow with the trimmed/normalised
|
||||
* payload (CSVs split, optional fields omitted when empty).
|
||||
* - onStateChange is called with validity + submitting flags so
|
||||
* wrappers can sync their submit button's disabled state.
|
||||
@@ -27,8 +27,8 @@ const hoisted = vi.hoisted(() => ({
|
||||
describeWorkflow: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../services/api/workflowsApi', () => ({
|
||||
workflowsApi: {
|
||||
vi.mock('../../../services/api/skillsApi', () => ({
|
||||
skillsApi: {
|
||||
createWorkflow: hoisted.createWorkflow,
|
||||
updateWorkflow: hoisted.updateWorkflow,
|
||||
describeWorkflow: hoisted.describeWorkflow,
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* - Submit disabled until a well-formed https URL is entered.
|
||||
* - Shows inline error for non-https URLs.
|
||||
* - Rejects timeout outside 1–600.
|
||||
* - Submit forwards timeoutSecs to workflowsApi.installWorkflowFromUrl.
|
||||
* - Submit forwards timeoutSecs to skillsApi.installWorkflowFromUrl.
|
||||
* - Success panel renders newWorkflows list + calls onInstalled.
|
||||
* - Error panel categorizes known prefixes and shows the raw error in
|
||||
* a details expander; unknown errors fall back to a generic title.
|
||||
@@ -16,14 +16,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import InstallSkillDialog from '../InstallSkillDialog';
|
||||
|
||||
vi.mock('../../../services/api/workflowsApi', () => ({
|
||||
workflowsApi: { installWorkflowFromUrl: vi.fn() },
|
||||
vi.mock('../../../services/api/skillsApi', () => ({
|
||||
skillsApi: { installWorkflowFromUrl: vi.fn() },
|
||||
}));
|
||||
|
||||
describe('InstallSkillDialog', () => {
|
||||
beforeEach(async () => {
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
vi.mocked(workflowsApi.installWorkflowFromUrl).mockReset();
|
||||
const { skillsApi } = await import('../../../services/api/skillsApi');
|
||||
vi.mocked(skillsApi.installWorkflowFromUrl).mockReset();
|
||||
});
|
||||
|
||||
it('renders title and URL input', () => {
|
||||
@@ -70,9 +70,9 @@ describe('InstallSkillDialog', () => {
|
||||
expect(submit.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('forwards timeoutSecs to workflowsApi and fires onInstalled on success', async () => {
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
vi.mocked(workflowsApi.installWorkflowFromUrl).mockResolvedValueOnce({
|
||||
it('forwards timeoutSecs to skillsApi and fires onInstalled on success', async () => {
|
||||
const { skillsApi } = await import('../../../services/api/skillsApi');
|
||||
vi.mocked(skillsApi.installWorkflowFromUrl).mockResolvedValueOnce({
|
||||
url: 'https://raw.githubusercontent.com/owner/repo/main/SKILL.md',
|
||||
stdout: 'added my-skill',
|
||||
stderr: '',
|
||||
@@ -91,7 +91,7 @@ describe('InstallSkillDialog', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /Install/ }));
|
||||
});
|
||||
|
||||
expect(vi.mocked(workflowsApi.installWorkflowFromUrl)).toHaveBeenCalledWith({
|
||||
expect(vi.mocked(skillsApi.installWorkflowFromUrl)).toHaveBeenCalledWith({
|
||||
url: 'https://raw.githubusercontent.com/owner/repo/main/SKILL.md',
|
||||
timeoutSecs: 120,
|
||||
});
|
||||
@@ -105,8 +105,8 @@ describe('InstallSkillDialog', () => {
|
||||
});
|
||||
|
||||
it('omits timeoutSecs when field is blank', async () => {
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
vi.mocked(workflowsApi.installWorkflowFromUrl).mockResolvedValueOnce({
|
||||
const { skillsApi } = await import('../../../services/api/skillsApi');
|
||||
vi.mocked(skillsApi.installWorkflowFromUrl).mockResolvedValueOnce({
|
||||
url: 'https://raw.githubusercontent.com/owner/repo/main/SKILL.md',
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
@@ -122,14 +122,14 @@ describe('InstallSkillDialog', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /Install/ }));
|
||||
});
|
||||
|
||||
expect(vi.mocked(workflowsApi.installWorkflowFromUrl)).toHaveBeenCalledWith({
|
||||
expect(vi.mocked(skillsApi.installWorkflowFromUrl)).toHaveBeenCalledWith({
|
||||
url: 'https://raw.githubusercontent.com/owner/repo/main/SKILL.md',
|
||||
});
|
||||
});
|
||||
|
||||
it('shows generic title with raw error text on unknown error and re-enables submit', async () => {
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
vi.mocked(workflowsApi.installWorkflowFromUrl).mockRejectedValueOnce(
|
||||
const { skillsApi } = await import('../../../services/api/skillsApi');
|
||||
vi.mocked(skillsApi.installWorkflowFromUrl).mockRejectedValueOnce(
|
||||
new Error('unexpected: something weird happened')
|
||||
);
|
||||
|
||||
@@ -151,8 +151,8 @@ describe('InstallSkillDialog', () => {
|
||||
});
|
||||
|
||||
it('categorizes "invalid SKILL.md:" errors with a friendly title and hint', async () => {
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
vi.mocked(workflowsApi.installWorkflowFromUrl).mockRejectedValueOnce(
|
||||
const { skillsApi } = await import('../../../services/api/skillsApi');
|
||||
vi.mocked(skillsApi.installWorkflowFromUrl).mockRejectedValueOnce(
|
||||
new Error('invalid SKILL.md: missing required field `description`')
|
||||
);
|
||||
|
||||
@@ -173,8 +173,8 @@ describe('InstallSkillDialog', () => {
|
||||
});
|
||||
|
||||
it('categorizes "unsupported url form:" errors', async () => {
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
vi.mocked(workflowsApi.installWorkflowFromUrl).mockRejectedValueOnce(
|
||||
const { skillsApi } = await import('../../../services/api/skillsApi');
|
||||
vi.mocked(skillsApi.installWorkflowFromUrl).mockRejectedValueOnce(
|
||||
new Error('unsupported url form: path must end in .md, got "https://example.com/foo"')
|
||||
);
|
||||
|
||||
|
||||
@@ -2,11 +2,11 @@ import { act, fireEvent, render, screen, waitFor, within } from '@testing-librar
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { CatalogEntry } from '../../../services/api/skillRegistryApi';
|
||||
import type { WorkflowSummary } from '../../../services/api/workflowsApi';
|
||||
import type { WorkflowSummary } from '../../../services/api/skillsApi';
|
||||
import SkillsExplorerTab from '../SkillsExplorerTab';
|
||||
|
||||
vi.mock('../../../services/api/workflowsApi', () => ({
|
||||
workflowsApi: {
|
||||
vi.mock('../../../services/api/skillsApi', () => ({
|
||||
skillsApi: {
|
||||
listWorkflows: vi.fn(),
|
||||
installWorkflowFromUrl: vi.fn(),
|
||||
uninstallWorkflow: vi.fn(),
|
||||
@@ -107,10 +107,10 @@ const MOCK_CATALOG_ENTRY_WITH_META: CatalogEntry = {
|
||||
|
||||
describe('SkillsExplorerTab', () => {
|
||||
beforeEach(async () => {
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
const { skillsApi } = await import('../../../services/api/skillsApi');
|
||||
const { skillRegistryApi } = await import('../../../services/api/skillRegistryApi');
|
||||
vi.mocked(workflowsApi.listWorkflows).mockReset();
|
||||
vi.mocked(workflowsApi.uninstallWorkflow).mockReset();
|
||||
vi.mocked(skillsApi.listWorkflows).mockReset();
|
||||
vi.mocked(skillsApi.uninstallWorkflow).mockReset();
|
||||
vi.mocked(skillRegistryApi.browse).mockReset();
|
||||
vi.mocked(skillRegistryApi.search).mockReset();
|
||||
vi.mocked(skillRegistryApi.install).mockReset();
|
||||
@@ -121,9 +121,9 @@ describe('SkillsExplorerTab', () => {
|
||||
});
|
||||
|
||||
it('defaults to registry view and shows catalog entries', async () => {
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
const { skillsApi } = await import('../../../services/api/skillsApi');
|
||||
const { skillRegistryApi } = await import('../../../services/api/skillRegistryApi');
|
||||
vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([]);
|
||||
vi.mocked(skillsApi.listWorkflows).mockResolvedValue([]);
|
||||
vi.mocked(skillRegistryApi.browse).mockResolvedValue([MOCK_CATALOG_ENTRY]);
|
||||
|
||||
render(<SkillsExplorerTab />);
|
||||
@@ -135,9 +135,9 @@ describe('SkillsExplorerTab', () => {
|
||||
});
|
||||
|
||||
it('paginates the registry catalog via the Show more control', async () => {
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
const { skillsApi } = await import('../../../services/api/skillsApi');
|
||||
const { skillRegistryApi } = await import('../../../services/api/skillRegistryApi');
|
||||
vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([]);
|
||||
vi.mocked(skillsApi.listWorkflows).mockResolvedValue([]);
|
||||
const entries: CatalogEntry[] = Array.from({ length: 130 }, (_, i) => ({
|
||||
...MOCK_CATALOG_ENTRY,
|
||||
id: `paged-skill-${i}`,
|
||||
@@ -171,9 +171,9 @@ describe('SkillsExplorerTab', () => {
|
||||
});
|
||||
|
||||
it('searches catalog via RPC when typing in search box', async () => {
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
const { skillsApi } = await import('../../../services/api/skillsApi');
|
||||
const { skillRegistryApi } = await import('../../../services/api/skillRegistryApi');
|
||||
vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([]);
|
||||
vi.mocked(skillsApi.listWorkflows).mockResolvedValue([]);
|
||||
vi.mocked(skillRegistryApi.browse).mockResolvedValue([MOCK_CATALOG_ENTRY]);
|
||||
vi.mocked(skillRegistryApi.search).mockResolvedValue([MOCK_DOCKER_ENTRY]);
|
||||
|
||||
@@ -202,8 +202,8 @@ describe('SkillsExplorerTab', () => {
|
||||
});
|
||||
|
||||
it('shows installed skills when switching to installed tab', async () => {
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([MOCK_SKILL, MOCK_PROJECT_SKILL]);
|
||||
const { skillsApi } = await import('../../../services/api/skillsApi');
|
||||
vi.mocked(skillsApi.listWorkflows).mockResolvedValue([MOCK_SKILL, MOCK_PROJECT_SKILL]);
|
||||
|
||||
render(<SkillsExplorerTab />);
|
||||
|
||||
@@ -220,8 +220,8 @@ describe('SkillsExplorerTab', () => {
|
||||
});
|
||||
|
||||
it('shows empty state when no installed skills', async () => {
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([]);
|
||||
const { skillsApi } = await import('../../../services/api/skillsApi');
|
||||
vi.mocked(skillsApi.listWorkflows).mockResolvedValue([]);
|
||||
|
||||
render(<SkillsExplorerTab />);
|
||||
await switchToInstalled();
|
||||
@@ -233,8 +233,8 @@ describe('SkillsExplorerTab', () => {
|
||||
|
||||
it('shows error state on registry fetch failure', async () => {
|
||||
const { skillRegistryApi } = await import('../../../services/api/skillRegistryApi');
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([]);
|
||||
const { skillsApi } = await import('../../../services/api/skillsApi');
|
||||
vi.mocked(skillsApi.listWorkflows).mockResolvedValue([]);
|
||||
vi.mocked(skillRegistryApi.browse).mockRejectedValue(new Error('Network error'));
|
||||
|
||||
render(<SkillsExplorerTab />);
|
||||
@@ -246,8 +246,8 @@ describe('SkillsExplorerTab', () => {
|
||||
});
|
||||
|
||||
it('filters installed skills by search query', async () => {
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([MOCK_SKILL, MOCK_PROJECT_SKILL]);
|
||||
const { skillsApi } = await import('../../../services/api/skillsApi');
|
||||
vi.mocked(skillsApi.listWorkflows).mockResolvedValue([MOCK_SKILL, MOCK_PROJECT_SKILL]);
|
||||
|
||||
render(<SkillsExplorerTab />);
|
||||
await switchToInstalled();
|
||||
@@ -264,8 +264,8 @@ describe('SkillsExplorerTab', () => {
|
||||
});
|
||||
|
||||
it('shows install from URL button', async () => {
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([]);
|
||||
const { skillsApi } = await import('../../../services/api/skillsApi');
|
||||
vi.mocked(skillsApi.listWorkflows).mockResolvedValue([]);
|
||||
|
||||
render(<SkillsExplorerTab />);
|
||||
|
||||
@@ -275,8 +275,8 @@ describe('SkillsExplorerTab', () => {
|
||||
});
|
||||
|
||||
it('shows uninstall button only for user-scope skills', async () => {
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([MOCK_SKILL, MOCK_PROJECT_SKILL]);
|
||||
const { skillsApi } = await import('../../../services/api/skillsApi');
|
||||
vi.mocked(skillsApi.listWorkflows).mockResolvedValue([MOCK_SKILL, MOCK_PROJECT_SKILL]);
|
||||
|
||||
render(<SkillsExplorerTab />);
|
||||
await switchToInstalled();
|
||||
@@ -290,8 +290,8 @@ describe('SkillsExplorerTab', () => {
|
||||
});
|
||||
|
||||
it('displays version and tags in installed view', async () => {
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([MOCK_SKILL]);
|
||||
const { skillsApi } = await import('../../../services/api/skillsApi');
|
||||
vi.mocked(skillsApi.listWorkflows).mockResolvedValue([MOCK_SKILL]);
|
||||
|
||||
render(<SkillsExplorerTab />);
|
||||
await switchToInstalled();
|
||||
@@ -304,8 +304,8 @@ describe('SkillsExplorerTab', () => {
|
||||
});
|
||||
|
||||
it('displays scope badges', async () => {
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([MOCK_SKILL, MOCK_PROJECT_SKILL]);
|
||||
const { skillsApi } = await import('../../../services/api/skillsApi');
|
||||
vi.mocked(skillsApi.listWorkflows).mockResolvedValue([MOCK_SKILL, MOCK_PROJECT_SKILL]);
|
||||
|
||||
render(<SkillsExplorerTab />);
|
||||
await switchToInstalled();
|
||||
@@ -318,9 +318,9 @@ describe('SkillsExplorerTab', () => {
|
||||
});
|
||||
|
||||
it('shows skill warnings when present', async () => {
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
const { skillsApi } = await import('../../../services/api/skillsApi');
|
||||
const skillWithWarning = { ...MOCK_SKILL, warnings: ['Missing required field: author'] };
|
||||
vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([skillWithWarning]);
|
||||
vi.mocked(skillsApi.listWorkflows).mockResolvedValue([skillWithWarning]);
|
||||
|
||||
render(<SkillsExplorerTab />);
|
||||
await switchToInstalled();
|
||||
@@ -331,7 +331,7 @@ describe('SkillsExplorerTab', () => {
|
||||
});
|
||||
|
||||
it('shows "Installed" badge for already-installed catalog entries', async () => {
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
const { skillsApi } = await import('../../../services/api/skillsApi');
|
||||
const { skillRegistryApi } = await import('../../../services/api/skillRegistryApi');
|
||||
const catalogEntry = {
|
||||
...MOCK_CATALOG_ENTRY,
|
||||
@@ -345,7 +345,7 @@ describe('SkillsExplorerTab', () => {
|
||||
name: 'Apple Notes',
|
||||
location: '/Users/test/.openhuman/skills/apple-notes/SKILL.md',
|
||||
};
|
||||
vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([installedSkill]);
|
||||
vi.mocked(skillsApi.listWorkflows).mockResolvedValue([installedSkill]);
|
||||
vi.mocked(skillRegistryApi.browse).mockResolvedValue([catalogEntry]);
|
||||
|
||||
render(<SkillsExplorerTab />);
|
||||
@@ -370,7 +370,7 @@ describe('SkillsExplorerTab', () => {
|
||||
});
|
||||
|
||||
it('does not mark catalog entries installed by display name alone', async () => {
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
const { skillsApi } = await import('../../../services/api/skillsApi');
|
||||
const { skillRegistryApi } = await import('../../../services/api/skillRegistryApi');
|
||||
const catalogEntry = {
|
||||
...MOCK_CATALOG_ENTRY,
|
||||
@@ -384,7 +384,7 @@ describe('SkillsExplorerTab', () => {
|
||||
name: 'Apple Notes',
|
||||
location: '/Users/test/.openhuman/skills/apple-notes-copy/SKILL.md',
|
||||
};
|
||||
vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([unrelatedInstalledSkill]);
|
||||
vi.mocked(skillsApi.listWorkflows).mockResolvedValue([unrelatedInstalledSkill]);
|
||||
vi.mocked(skillRegistryApi.browse).mockResolvedValue([catalogEntry]);
|
||||
|
||||
render(<SkillsExplorerTab />);
|
||||
@@ -399,7 +399,7 @@ describe('SkillsExplorerTab', () => {
|
||||
// install-key heuristic — otherwise the card reverted to "Install" and the
|
||||
// only signal of success was a fleeting toast.
|
||||
it('marks a catalog entry installed on success even when the refetched list does not map back', async () => {
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
const { skillsApi } = await import('../../../services/api/skillsApi');
|
||||
const { skillRegistryApi } = await import('../../../services/api/skillRegistryApi');
|
||||
const catalogEntry = {
|
||||
...MOCK_CATALOG_ENTRY,
|
||||
@@ -409,7 +409,7 @@ describe('SkillsExplorerTab', () => {
|
||||
};
|
||||
// The installed list never resolves to anything that maps back to the entry
|
||||
// (simulates a post-install id/location the heuristic can't match).
|
||||
vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([]);
|
||||
vi.mocked(skillsApi.listWorkflows).mockResolvedValue([]);
|
||||
vi.mocked(skillRegistryApi.browse).mockResolvedValue([catalogEntry]);
|
||||
vi.mocked(skillRegistryApi.install).mockResolvedValue({
|
||||
url: '',
|
||||
@@ -436,8 +436,8 @@ describe('SkillsExplorerTab', () => {
|
||||
});
|
||||
|
||||
it('has an install from URL button', async () => {
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([]);
|
||||
const { skillsApi } = await import('../../../services/api/skillsApi');
|
||||
vi.mocked(skillsApi.listWorkflows).mockResolvedValue([]);
|
||||
|
||||
render(<SkillsExplorerTab />);
|
||||
|
||||
@@ -448,8 +448,8 @@ describe('SkillsExplorerTab', () => {
|
||||
});
|
||||
|
||||
it('shows "no results" when installed skills exist but search has no matches', async () => {
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([MOCK_SKILL]);
|
||||
const { skillsApi } = await import('../../../services/api/skillsApi');
|
||||
vi.mocked(skillsApi.listWorkflows).mockResolvedValue([MOCK_SKILL]);
|
||||
|
||||
render(<SkillsExplorerTab />);
|
||||
await switchToInstalled();
|
||||
@@ -467,8 +467,8 @@ describe('SkillsExplorerTab', () => {
|
||||
});
|
||||
|
||||
it('opens skill detail dialog when a skill tile is clicked', async () => {
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([MOCK_SKILL]);
|
||||
const { skillsApi } = await import('../../../services/api/skillsApi');
|
||||
vi.mocked(skillsApi.listWorkflows).mockResolvedValue([MOCK_SKILL]);
|
||||
|
||||
render(<SkillsExplorerTab />);
|
||||
await switchToInstalled();
|
||||
@@ -488,8 +488,8 @@ describe('SkillsExplorerTab', () => {
|
||||
});
|
||||
|
||||
it('activates skill tile on Enter key and Space key', async () => {
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([MOCK_SKILL]);
|
||||
const { skillsApi } = await import('../../../services/api/skillsApi');
|
||||
vi.mocked(skillsApi.listWorkflows).mockResolvedValue([MOCK_SKILL]);
|
||||
|
||||
render(<SkillsExplorerTab />);
|
||||
await switchToInstalled();
|
||||
@@ -506,9 +506,9 @@ describe('SkillsExplorerTab', () => {
|
||||
});
|
||||
|
||||
it('opens catalog entry detail dialog when a registry tile is clicked', async () => {
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
const { skillsApi } = await import('../../../services/api/skillsApi');
|
||||
const { skillRegistryApi } = await import('../../../services/api/skillRegistryApi');
|
||||
vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([]);
|
||||
vi.mocked(skillsApi.listWorkflows).mockResolvedValue([]);
|
||||
vi.mocked(skillRegistryApi.browse).mockResolvedValue([MOCK_CATALOG_ENTRY_WITH_META]);
|
||||
|
||||
render(<SkillsExplorerTab />);
|
||||
@@ -534,8 +534,8 @@ describe('SkillsExplorerTab', () => {
|
||||
});
|
||||
|
||||
it('closes skill detail dialog when overlay is clicked', async () => {
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([MOCK_SKILL]);
|
||||
const { skillsApi } = await import('../../../services/api/skillsApi');
|
||||
vi.mocked(skillsApi.listWorkflows).mockResolvedValue([MOCK_SKILL]);
|
||||
|
||||
render(<SkillsExplorerTab />);
|
||||
await switchToInstalled();
|
||||
@@ -565,9 +565,9 @@ describe('SkillsExplorerTab', () => {
|
||||
});
|
||||
|
||||
it('shows install button in detail dialog footer for non-installed registry entry', async () => {
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
const { skillsApi } = await import('../../../services/api/skillsApi');
|
||||
const { skillRegistryApi } = await import('../../../services/api/skillRegistryApi');
|
||||
vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([]);
|
||||
vi.mocked(skillsApi.listWorkflows).mockResolvedValue([]);
|
||||
vi.mocked(skillRegistryApi.browse).mockResolvedValue([MOCK_CATALOG_ENTRY]);
|
||||
|
||||
render(<SkillsExplorerTab />);
|
||||
@@ -588,10 +588,10 @@ describe('SkillsExplorerTab', () => {
|
||||
});
|
||||
|
||||
it('calls skillRegistryApi.install when the install button in registry tile is clicked', async () => {
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
const { skillsApi } = await import('../../../services/api/skillsApi');
|
||||
const { skillRegistryApi } = await import('../../../services/api/skillRegistryApi');
|
||||
const onToast = vi.fn();
|
||||
vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([]);
|
||||
vi.mocked(skillsApi.listWorkflows).mockResolvedValue([]);
|
||||
vi.mocked(skillRegistryApi.browse).mockResolvedValue([MOCK_CATALOG_ENTRY]);
|
||||
vi.mocked(skillRegistryApi.install).mockResolvedValue({
|
||||
url: 'https://example.com/SKILL.md',
|
||||
@@ -619,10 +619,10 @@ describe('SkillsExplorerTab', () => {
|
||||
});
|
||||
|
||||
it('shows error toast when registry install fails', async () => {
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
const { skillsApi } = await import('../../../services/api/skillsApi');
|
||||
const { skillRegistryApi } = await import('../../../services/api/skillRegistryApi');
|
||||
const onToast = vi.fn();
|
||||
vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([]);
|
||||
vi.mocked(skillsApi.listWorkflows).mockResolvedValue([]);
|
||||
vi.mocked(skillRegistryApi.browse).mockResolvedValue([MOCK_CATALOG_ENTRY]);
|
||||
vi.mocked(skillRegistryApi.install).mockRejectedValue(new Error('Install failed'));
|
||||
|
||||
@@ -642,9 +642,9 @@ describe('SkillsExplorerTab', () => {
|
||||
});
|
||||
|
||||
it('shows source toggle buttons when sources are available', async () => {
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
const { skillsApi } = await import('../../../services/api/skillsApi');
|
||||
const { skillRegistryApi } = await import('../../../services/api/skillRegistryApi');
|
||||
vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([]);
|
||||
vi.mocked(skillsApi.listWorkflows).mockResolvedValue([]);
|
||||
vi.mocked(skillRegistryApi.sources).mockResolvedValue(['built-in', 'ClawHub']);
|
||||
// No catalog entries so "built-in" only appears in the toggle buttons
|
||||
vi.mocked(skillRegistryApi.browse).mockResolvedValue([]);
|
||||
@@ -662,9 +662,9 @@ describe('SkillsExplorerTab', () => {
|
||||
});
|
||||
|
||||
it('deselecting a source filter triggers search with single active source', async () => {
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
const { skillsApi } = await import('../../../services/api/skillsApi');
|
||||
const { skillRegistryApi } = await import('../../../services/api/skillRegistryApi');
|
||||
vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([]);
|
||||
vi.mocked(skillsApi.listWorkflows).mockResolvedValue([]);
|
||||
vi.mocked(skillRegistryApi.sources).mockResolvedValue(['built-in', 'ClawHub']);
|
||||
vi.mocked(skillRegistryApi.browse).mockResolvedValue([]);
|
||||
vi.mocked(skillRegistryApi.search).mockResolvedValue([]);
|
||||
@@ -689,9 +689,9 @@ describe('SkillsExplorerTab', () => {
|
||||
});
|
||||
|
||||
it('shows catalog count in Registry tab badge when entries exist', async () => {
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
const { skillsApi } = await import('../../../services/api/skillsApi');
|
||||
const { skillRegistryApi } = await import('../../../services/api/skillRegistryApi');
|
||||
vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([]);
|
||||
vi.mocked(skillsApi.listWorkflows).mockResolvedValue([]);
|
||||
vi.mocked(skillRegistryApi.browse).mockResolvedValue([MOCK_CATALOG_ENTRY, MOCK_DOCKER_ENTRY]);
|
||||
|
||||
render(<SkillsExplorerTab />);
|
||||
@@ -703,8 +703,8 @@ describe('SkillsExplorerTab', () => {
|
||||
});
|
||||
|
||||
it('shows installed skill count in Installed tab badge', async () => {
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([MOCK_SKILL, MOCK_PROJECT_SKILL]);
|
||||
const { skillsApi } = await import('../../../services/api/skillsApi');
|
||||
vi.mocked(skillsApi.listWorkflows).mockResolvedValue([MOCK_SKILL, MOCK_PROJECT_SKILL]);
|
||||
|
||||
render(<SkillsExplorerTab />);
|
||||
|
||||
@@ -715,8 +715,8 @@ describe('SkillsExplorerTab', () => {
|
||||
});
|
||||
|
||||
it('shows legacy scope badge for legacy skills', async () => {
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([MOCK_LEGACY_SKILL]);
|
||||
const { skillsApi } = await import('../../../services/api/skillsApi');
|
||||
vi.mocked(skillsApi.listWorkflows).mockResolvedValue([MOCK_LEGACY_SKILL]);
|
||||
|
||||
render(<SkillsExplorerTab />);
|
||||
await switchToInstalled();
|
||||
@@ -729,14 +729,14 @@ describe('SkillsExplorerTab', () => {
|
||||
});
|
||||
|
||||
it('displays SkillFormatBadge with fallback label for unknown format', async () => {
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
const { skillsApi } = await import('../../../services/api/skillsApi');
|
||||
const unknownFormatSkill = {
|
||||
...MOCK_SKILL,
|
||||
id: 'unk-skill',
|
||||
name: 'Unknown Format Skill',
|
||||
sourceFormat: 'unknown-format',
|
||||
};
|
||||
vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([unknownFormatSkill]);
|
||||
vi.mocked(skillsApi.listWorkflows).mockResolvedValue([unknownFormatSkill]);
|
||||
|
||||
render(<SkillsExplorerTab />);
|
||||
await switchToInstalled();
|
||||
@@ -749,9 +749,9 @@ describe('SkillsExplorerTab', () => {
|
||||
});
|
||||
|
||||
it('shows empty registry state when catalog returns no results', async () => {
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
const { skillsApi } = await import('../../../services/api/skillsApi');
|
||||
const { skillRegistryApi } = await import('../../../services/api/skillRegistryApi');
|
||||
vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([]);
|
||||
vi.mocked(skillsApi.listWorkflows).mockResolvedValue([]);
|
||||
vi.mocked(skillRegistryApi.browse).mockResolvedValue([]);
|
||||
|
||||
render(<SkillsExplorerTab />);
|
||||
@@ -763,9 +763,9 @@ describe('SkillsExplorerTab', () => {
|
||||
});
|
||||
|
||||
it('retry button on error retriggers catalog fetch', async () => {
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
const { skillsApi } = await import('../../../services/api/skillsApi');
|
||||
const { skillRegistryApi } = await import('../../../services/api/skillRegistryApi');
|
||||
vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([]);
|
||||
vi.mocked(skillsApi.listWorkflows).mockResolvedValue([]);
|
||||
vi.mocked(skillRegistryApi.browse)
|
||||
.mockRejectedValueOnce(new Error('timeout'))
|
||||
.mockResolvedValue([MOCK_CATALOG_ENTRY]);
|
||||
@@ -786,8 +786,8 @@ describe('SkillsExplorerTab', () => {
|
||||
});
|
||||
|
||||
it('retry button on installed view error retriggers skills fetch', async () => {
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
vi.mocked(workflowsApi.listWorkflows)
|
||||
const { skillsApi } = await import('../../../services/api/skillsApi');
|
||||
vi.mocked(skillsApi.listWorkflows)
|
||||
.mockRejectedValueOnce(new Error('skills fetch failed'))
|
||||
.mockResolvedValue([MOCK_SKILL]);
|
||||
|
||||
@@ -808,9 +808,9 @@ describe('SkillsExplorerTab', () => {
|
||||
});
|
||||
|
||||
it('refresh button triggers force-refresh catalog fetch', async () => {
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
const { skillsApi } = await import('../../../services/api/skillsApi');
|
||||
const { skillRegistryApi } = await import('../../../services/api/skillRegistryApi');
|
||||
vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([]);
|
||||
vi.mocked(skillsApi.listWorkflows).mockResolvedValue([]);
|
||||
vi.mocked(skillRegistryApi.browse).mockResolvedValue([MOCK_CATALOG_ENTRY]);
|
||||
|
||||
render(<SkillsExplorerTab />);
|
||||
@@ -836,7 +836,7 @@ describe('SkillsExplorerTab', () => {
|
||||
});
|
||||
|
||||
it('sorts hermes skills before non-hermes in installed view', async () => {
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
const { skillsApi } = await import('../../../services/api/skillsApi');
|
||||
const alphaSkill = {
|
||||
...MOCK_SKILL,
|
||||
id: 'alpha',
|
||||
@@ -849,7 +849,7 @@ describe('SkillsExplorerTab', () => {
|
||||
name: 'Hermes Skill',
|
||||
sourceFormat: 'hermes',
|
||||
};
|
||||
vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([alphaSkill, hermesSkill]);
|
||||
vi.mocked(skillsApi.listWorkflows).mockResolvedValue([alphaSkill, hermesSkill]);
|
||||
|
||||
render(<SkillsExplorerTab />);
|
||||
await switchToInstalled();
|
||||
@@ -867,9 +867,9 @@ describe('SkillsExplorerTab', () => {
|
||||
});
|
||||
|
||||
it('activates catalog tile on Enter key', async () => {
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
const { skillsApi } = await import('../../../services/api/skillsApi');
|
||||
const { skillRegistryApi } = await import('../../../services/api/skillRegistryApi');
|
||||
vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([]);
|
||||
vi.mocked(skillsApi.listWorkflows).mockResolvedValue([]);
|
||||
vi.mocked(skillRegistryApi.browse).mockResolvedValue([MOCK_CATALOG_ENTRY]);
|
||||
|
||||
render(<SkillsExplorerTab />);
|
||||
@@ -887,10 +887,10 @@ describe('SkillsExplorerTab', () => {
|
||||
});
|
||||
|
||||
it('detail dialog install button (footer) triggers install', async () => {
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
const { skillsApi } = await import('../../../services/api/skillsApi');
|
||||
const { skillRegistryApi } = await import('../../../services/api/skillRegistryApi');
|
||||
const onToast = vi.fn();
|
||||
vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([]);
|
||||
vi.mocked(skillsApi.listWorkflows).mockResolvedValue([]);
|
||||
// Override the beforeEach mock so browse returns an entry
|
||||
vi.mocked(skillRegistryApi.browse).mockResolvedValue([MOCK_CATALOG_ENTRY]);
|
||||
vi.mocked(skillRegistryApi.install).mockResolvedValue({
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* Verifies:
|
||||
* - Renders skill name + on-disk path + destructive confirm copy.
|
||||
* - Cancel button fires onClose, does NOT hit the RPC.
|
||||
* - Confirm fires `workflowsApi.uninstallWorkflow(name)` and forwards the result
|
||||
* - Confirm fires `skillsApi.uninstallWorkflow(name)` and forwards the result
|
||||
* to `onUninstalled`, then closes.
|
||||
* - RPC error is surfaced inline and the dialog stays open (no onClose).
|
||||
* - While in-flight, both buttons disable and Esc no-ops (handled by
|
||||
@@ -13,11 +13,11 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { WorkflowSummary } from '../../../services/api/workflowsApi';
|
||||
import type { WorkflowSummary } from '../../../services/api/skillsApi';
|
||||
import UninstallSkillConfirmDialog from '../UninstallSkillConfirmDialog';
|
||||
|
||||
vi.mock('../../../services/api/workflowsApi', () => ({
|
||||
workflowsApi: { uninstallWorkflow: vi.fn() },
|
||||
vi.mock('../../../services/api/skillsApi', () => ({
|
||||
skillsApi: { uninstallWorkflow: vi.fn() },
|
||||
}));
|
||||
|
||||
const fixture: WorkflowSummary = {
|
||||
@@ -41,8 +41,8 @@ const fixture: WorkflowSummary = {
|
||||
|
||||
describe('UninstallSkillConfirmDialog', () => {
|
||||
beforeEach(async () => {
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
vi.mocked(workflowsApi.uninstallWorkflow).mockReset();
|
||||
const { skillsApi } = await import('../../../services/api/skillsApi');
|
||||
vi.mocked(skillsApi.uninstallWorkflow).mockReset();
|
||||
});
|
||||
|
||||
it('renders skill name, path (stripped of /SKILL.md), and confirm copy', () => {
|
||||
@@ -62,8 +62,8 @@ describe('UninstallSkillConfirmDialog', () => {
|
||||
// by slug — the UI must pass `skill.id` (the slug).
|
||||
const onClose = vi.fn();
|
||||
const onUninstalled = vi.fn();
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
vi.mocked(workflowsApi.uninstallWorkflow).mockResolvedValueOnce({
|
||||
const { skillsApi } = await import('../../../services/api/skillsApi');
|
||||
vi.mocked(skillsApi.uninstallWorkflow).mockResolvedValueOnce({
|
||||
name: 'weather-helper',
|
||||
removedPath: '/Users/me/.openhuman/skills/weather-helper',
|
||||
scope: 'user',
|
||||
@@ -84,29 +84,29 @@ describe('UninstallSkillConfirmDialog', () => {
|
||||
fireEvent.click(screen.getByTestId('uninstall-skill-confirm'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(vi.mocked(workflowsApi.uninstallWorkflow)).toHaveBeenCalledWith('weather-helper');
|
||||
expect(vi.mocked(skillsApi.uninstallWorkflow)).toHaveBeenCalledWith('weather-helper');
|
||||
});
|
||||
expect(vi.mocked(workflowsApi.uninstallWorkflow)).not.toHaveBeenCalledWith(
|
||||
expect(vi.mocked(skillsApi.uninstallWorkflow)).not.toHaveBeenCalledWith(
|
||||
'Weather Helper (Pro)'
|
||||
);
|
||||
});
|
||||
|
||||
it('Cancel fires onClose without calling the RPC', async () => {
|
||||
const onClose = vi.fn();
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
const { skillsApi } = await import('../../../services/api/skillsApi');
|
||||
render(
|
||||
<UninstallSkillConfirmDialog skill={fixture} onClose={onClose} onUninstalled={vi.fn()} />
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: /Cancel/ }));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
expect(vi.mocked(workflowsApi.uninstallWorkflow)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(skillsApi.uninstallWorkflow)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Confirm calls workflowsApi.uninstallWorkflow and forwards result to onUninstalled', async () => {
|
||||
it('Confirm calls skillsApi.uninstallWorkflow and forwards result to onUninstalled', async () => {
|
||||
const onClose = vi.fn();
|
||||
const onUninstalled = vi.fn();
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
vi.mocked(workflowsApi.uninstallWorkflow).mockResolvedValueOnce({
|
||||
const { skillsApi } = await import('../../../services/api/skillsApi');
|
||||
vi.mocked(skillsApi.uninstallWorkflow).mockResolvedValueOnce({
|
||||
name: 'weather-helper',
|
||||
removedPath: '/Users/me/.openhuman/skills/weather-helper',
|
||||
scope: 'user',
|
||||
@@ -122,12 +122,12 @@ describe('UninstallSkillConfirmDialog', () => {
|
||||
fireEvent.click(screen.getByTestId('uninstall-skill-confirm'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(vi.mocked(workflowsApi.uninstallWorkflow)).toHaveBeenCalledWith('weather-helper');
|
||||
expect(vi.mocked(skillsApi.uninstallWorkflow)).toHaveBeenCalledWith('weather-helper');
|
||||
});
|
||||
// Assert the caller passed the slug (`id`) — not the frontmatter
|
||||
// display name. Regression guard for the #781 fix that swapped
|
||||
// `skill.name` → `skill.id` in the confirm handler.
|
||||
expect(vi.mocked(workflowsApi.uninstallWorkflow)).toHaveBeenCalledWith(fixture.id);
|
||||
expect(vi.mocked(skillsApi.uninstallWorkflow)).toHaveBeenCalledWith(fixture.id);
|
||||
await waitFor(() => {
|
||||
expect(onUninstalled).toHaveBeenCalledWith({
|
||||
name: 'weather-helper',
|
||||
@@ -143,8 +143,8 @@ describe('UninstallSkillConfirmDialog', () => {
|
||||
it('surfaces RPC errors inline and keeps the dialog open', async () => {
|
||||
const onClose = vi.fn();
|
||||
const onUninstalled = vi.fn();
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
vi.mocked(workflowsApi.uninstallWorkflow).mockRejectedValueOnce(
|
||||
const { skillsApi } = await import('../../../services/api/skillsApi');
|
||||
vi.mocked(skillsApi.uninstallWorkflow).mockRejectedValueOnce(
|
||||
new Error("skill 'weather-helper' is not installed")
|
||||
);
|
||||
|
||||
@@ -169,14 +169,14 @@ describe('UninstallSkillConfirmDialog', () => {
|
||||
});
|
||||
|
||||
it('disables buttons while the RPC is in flight', async () => {
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
const { skillsApi } = await import('../../../services/api/skillsApi');
|
||||
type UninstallResolve = (v: {
|
||||
name: string;
|
||||
removedPath: string;
|
||||
scope: WorkflowSummary['scope'];
|
||||
}) => void;
|
||||
const deferred: { resolve?: UninstallResolve } = {};
|
||||
vi.mocked(workflowsApi.uninstallWorkflow).mockReturnValueOnce(
|
||||
vi.mocked(skillsApi.uninstallWorkflow).mockReturnValueOnce(
|
||||
new Promise<{ name: string; removedPath: string; scope: WorkflowSummary['scope'] }>(
|
||||
resolve => {
|
||||
deferred.resolve = resolve;
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
*
|
||||
* Covered here:
|
||||
* - Mount with one saved schedule for the picked skill (mocking
|
||||
* workflows_list, workflows_describe, cron_list, recent_runs).
|
||||
* skills_list, skills_describe, cron_list, recent_runs).
|
||||
* - Toggle flips enabled → false via openhumanCronUpdate(id, { enabled }).
|
||||
* - The list re-loads after toggle (openhumanCronList called again).
|
||||
* - aria-checked reflects the new state once the list refreshes.
|
||||
@@ -51,8 +51,8 @@ vi.mock('../../../utils/tauriCommands/cron', () => ({
|
||||
openhumanCronRuns: hoisted.cronRuns,
|
||||
}));
|
||||
|
||||
vi.mock('../../../services/api/workflowsApi', () => ({
|
||||
workflowsApi: {
|
||||
vi.mock('../../../services/api/skillsApi', () => ({
|
||||
skillsApi: {
|
||||
listWorkflows: hoisted.listWorkflows,
|
||||
describeWorkflow: hoisted.describeWorkflow,
|
||||
runWorkflow: hoisted.runWorkflow,
|
||||
@@ -173,7 +173,7 @@ describe('WorkflowRunnerBody — saved-schedule toggle', () => {
|
||||
const Body = await importBody();
|
||||
renderBody(Body);
|
||||
|
||||
// Wait for workflows_list to resolve and populate the dropdown.
|
||||
// Wait for skills_list to resolve and populate the dropdown.
|
||||
await waitFor(() => expect(hoisted.listWorkflows).toHaveBeenCalled());
|
||||
|
||||
// Pick the skill so the schedule list mounts.
|
||||
@@ -546,7 +546,7 @@ describe('WorkflowRunnerBody — URL ?workflow= preselect', () => {
|
||||
expect(hoisted.describeWorkflow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores ?workflow= when the value is not in the workflows_list (picker stays empty, describeWorkflow called once with empty=never)', async () => {
|
||||
it('ignores ?workflow= when the value is not in the skills_list (picker stays empty, describeWorkflow called once with empty=never)', async () => {
|
||||
// ?workflow=unknown-skill is treated as best-effort: we set the state
|
||||
// but the picker shows "Select a skill" since the option isn't in
|
||||
// the list. The describe call IS attempted (we don't pre-filter
|
||||
|
||||
@@ -37,7 +37,7 @@ export interface WorkflowRunError {
|
||||
|
||||
/**
|
||||
* Parse the message string returned by the `openhuman.skill_runtime_run` RPC
|
||||
* error path (or thrown by `workflowsApi.runWorkflow`). Anything that matches
|
||||
* error path (or thrown by `skillsApi.runWorkflow`). Anything that matches
|
||||
* the `[preflight:<gate>:<tag>]` prefix becomes a structured gate
|
||||
* failure; anything else falls through with `gate: null` so the caller
|
||||
* can render the raw text.
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* useFlowRunProgress (Phase 3e) — unit tests.
|
||||
*
|
||||
* Verifies the hook builds a `node_id -> status` map from the socket
|
||||
* `flow:run_progress` feed, filters to the watched run, resets on a run change,
|
||||
* and unsubscribes on unmount. The socket is a tiny in-memory emitter so a
|
||||
* `flow:run_progress` event can be simulated deterministically.
|
||||
*/
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { useFlowRunProgress } from './useFlowRunProgress';
|
||||
|
||||
const handlers = vi.hoisted(() => new Map<string, Set<(data: unknown) => void>>());
|
||||
const on = vi.hoisted(() =>
|
||||
vi.fn((event: string, cb: (data: unknown) => void) => {
|
||||
const set = handlers.get(event) ?? new Set();
|
||||
set.add(cb);
|
||||
handlers.set(event, set);
|
||||
})
|
||||
);
|
||||
const off = vi.hoisted(() =>
|
||||
vi.fn((event: string, cb: (data: unknown) => void) => {
|
||||
handlers.get(event)?.delete(cb);
|
||||
})
|
||||
);
|
||||
vi.mock('../services/socketService', () => ({ socketService: { on, off } }));
|
||||
|
||||
function emit(payload: unknown) {
|
||||
act(() => {
|
||||
for (const event of ['flow:run_progress', 'flow_run_progress']) {
|
||||
for (const cb of handlers.get(event) ?? []) cb(payload);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
describe('useFlowRunProgress', () => {
|
||||
beforeEach(() => {
|
||||
handlers.clear();
|
||||
on.mockClear();
|
||||
off.mockClear();
|
||||
});
|
||||
|
||||
it('returns an empty map and subscribes to nothing when runId is null', () => {
|
||||
const { result } = renderHook(() => useFlowRunProgress(null));
|
||||
expect(result.current).toEqual({});
|
||||
expect(on).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accumulates node statuses for the watched run', () => {
|
||||
const { result } = renderHook(() => useFlowRunProgress('run_1'));
|
||||
expect(on).toHaveBeenCalledWith('flow:run_progress', expect.any(Function));
|
||||
expect(on).toHaveBeenCalledWith('flow_run_progress', expect.any(Function));
|
||||
|
||||
emit({ run_id: 'run_1', node_id: 'a', status: 'running' });
|
||||
expect(result.current).toEqual({ a: 'running' });
|
||||
|
||||
emit({ run_id: 'run_1', node_id: 'a', status: 'success' });
|
||||
emit({ run_id: 'run_1', node_id: 'b', status: 'error' });
|
||||
expect(result.current).toEqual({ a: 'success', b: 'error' });
|
||||
});
|
||||
|
||||
it('ignores events for other runs and malformed payloads', () => {
|
||||
const { result } = renderHook(() => useFlowRunProgress('run_1'));
|
||||
emit({ run_id: 'other', node_id: 'a', status: 'running' });
|
||||
emit({ node_id: 'a', status: 'running' }); // missing run_id
|
||||
emit(null);
|
||||
emit({ run_id: 'run_1', node_id: 'a' }); // missing status
|
||||
expect(result.current).toEqual({});
|
||||
});
|
||||
|
||||
it('resets the map when the runId changes', () => {
|
||||
const { result, rerender } = renderHook(({ id }) => useFlowRunProgress(id), {
|
||||
initialProps: { id: 'run_1' as string | null },
|
||||
});
|
||||
emit({ run_id: 'run_1', node_id: 'a', status: 'success' });
|
||||
expect(result.current).toEqual({ a: 'success' });
|
||||
|
||||
rerender({ id: 'run_2' });
|
||||
expect(result.current).toEqual({});
|
||||
emit({ run_id: 'run_1', node_id: 'a', status: 'success' }); // stale run ignored
|
||||
expect(result.current).toEqual({});
|
||||
});
|
||||
|
||||
it('unsubscribes both event aliases on unmount', () => {
|
||||
const { unmount } = renderHook(() => useFlowRunProgress('run_1'));
|
||||
unmount();
|
||||
expect(off).toHaveBeenCalledWith('flow:run_progress', expect.any(Function));
|
||||
expect(off).toHaveBeenCalledWith('flow_run_progress', expect.any(Function));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* useFlowRunProgress (Phase 3e — live run overlay)
|
||||
* ------------------------------------------------
|
||||
*
|
||||
* Subscribes to the core's live per-step progress feed for a single durable
|
||||
* `tinyflows` run and yields a `node_id -> status` map so the canvas can animate
|
||||
* nodes as they execute (n8n's signature running/success/error interaction).
|
||||
*
|
||||
* The backend's `FlowRunObserver` publishes `DomainEvent::FlowRunProgress` on
|
||||
* each finished step; the core socket bridge (`src/core/socketio.rs`) re-emits it
|
||||
* to the frontend as **both** `flow:run_progress` and `flow_run_progress`
|
||||
* (colon + underscore aliases, same as every other bridged event) with the
|
||||
* payload `{ run_id, node_id, status }`.
|
||||
*
|
||||
* This is a *live overlay only* — the durable `flow_runs` row remains the source
|
||||
* of truth and {@link useFlowRunPoller} stays as the 2s fallback, so a dropped
|
||||
* broadcast (lag) merely delays the animation, never corrupts run history. The
|
||||
* subscription mirrors {@link useTinyplaceStream} exactly (socketService.on/off
|
||||
* with cleanup on unmount / dependency change).
|
||||
*/
|
||||
import debug from 'debug';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { socketService } from '../services/socketService';
|
||||
|
||||
const log = debug('flows:run-progress');
|
||||
|
||||
/** Socket event aliases the core bridge emits (colon + underscore forms). */
|
||||
const EVENT_COLON = 'flow:run_progress';
|
||||
const EVENT_UNDERSCORE = 'flow_run_progress';
|
||||
|
||||
/**
|
||||
* Node-level live status. The observer today emits only `success`/`error` on
|
||||
* step finish; `running` is included so the hook stays forward-compatible with
|
||||
* a future step-start event (and so callers can optimistically mark a node
|
||||
* active). Any unrecognized status string is passed through verbatim.
|
||||
*/
|
||||
export type FlowNodeRunStatus = 'running' | 'success' | 'error' | (string & {});
|
||||
|
||||
/** node_id → latest live status for the watched run. */
|
||||
export type FlowRunProgressMap = Record<string, FlowNodeRunStatus>;
|
||||
|
||||
/**
|
||||
* Maps a live node status to the canvas CSS class that rings/animates the node.
|
||||
* Kept here (not in the CSS-adjacent component) so the hook, the canvas, and
|
||||
* tests share one source of truth. `error` deliberately uses a run-specific
|
||||
* class distinct from validation's `.flow-node-error` so a *runtime* failure
|
||||
* reads differently from a *config* error.
|
||||
*/
|
||||
export const FLOW_RUN_NODE_STATUS_CLASS: Record<string, string> = {
|
||||
running: 'flow-node-running',
|
||||
success: 'flow-node-success',
|
||||
error: 'flow-node-failed',
|
||||
failed: 'flow-node-failed',
|
||||
};
|
||||
|
||||
interface FlowRunProgressPayload {
|
||||
run_id: string;
|
||||
node_id: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
function parsePayload(data: unknown): FlowRunProgressPayload | null {
|
||||
if (!data || typeof data !== 'object') return null;
|
||||
const obj = data as Record<string, unknown>;
|
||||
if (typeof obj.run_id !== 'string') return null;
|
||||
if (typeof obj.node_id !== 'string') return null;
|
||||
if (typeof obj.status !== 'string') return null;
|
||||
return { run_id: obj.run_id, node_id: obj.node_id, status: obj.status };
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch `runId`'s live progress. Returns a `node_id -> status` map that grows
|
||||
* as steps finish. Yields an empty map (and subscribes to nothing) when `runId`
|
||||
* is `null`. Resets whenever `runId` changes so a stale run's node states never
|
||||
* bleed onto a newly-started one.
|
||||
*/
|
||||
export function useFlowRunProgress(runId: string | null): FlowRunProgressMap {
|
||||
const [statuses, setStatuses] = useState<FlowRunProgressMap>({});
|
||||
|
||||
// Reset during render (not synchronously inside the effect below —
|
||||
// `react-hooks/set-state-in-effect` disallows that) when `runId` changes, so
|
||||
// a stale run's node states never bleed onto a newly-started one.
|
||||
const prevRunIdRef = useRef(runId);
|
||||
if (prevRunIdRef.current !== runId) {
|
||||
prevRunIdRef.current = runId;
|
||||
setStatuses({});
|
||||
}
|
||||
|
||||
const handleProgress = useCallback(
|
||||
(data: unknown) => {
|
||||
if (!runId) return;
|
||||
const payload = parsePayload(data);
|
||||
if (!payload) {
|
||||
log('progress: dropped — invalid payload %o', data);
|
||||
return;
|
||||
}
|
||||
// Filter to the run this hook instance is watching; the bridge broadcasts
|
||||
// every run's progress to all listeners.
|
||||
if (payload.run_id !== runId) return;
|
||||
log('progress: run=%s node=%s status=%s', runId, payload.node_id, payload.status);
|
||||
setStatuses(prev =>
|
||||
prev[payload.node_id] === payload.status
|
||||
? prev
|
||||
: { ...prev, [payload.node_id]: payload.status }
|
||||
);
|
||||
},
|
||||
[runId]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!runId) return;
|
||||
log('subscribe: run=%s', runId);
|
||||
socketService.on(EVENT_COLON, handleProgress);
|
||||
socketService.on(EVENT_UNDERSCORE, handleProgress);
|
||||
return () => {
|
||||
log('unsubscribe: run=%s', runId);
|
||||
socketService.off(EVENT_COLON, handleProgress);
|
||||
socketService.off(EVENT_UNDERSCORE, handleProgress);
|
||||
};
|
||||
}, [runId, handleProgress]);
|
||||
|
||||
return statuses;
|
||||
}
|
||||
|
||||
export default useFlowRunProgress;
|
||||
@@ -0,0 +1,105 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { WorkflowProposal } from '../store/chatRuntimeSlice';
|
||||
import { useWorkflowBuilderChat } from './useWorkflowBuilderChat';
|
||||
|
||||
const chatSend = vi.hoisted(() => vi.fn());
|
||||
vi.mock('../services/chatService', () => ({ chatSend }));
|
||||
|
||||
// Socket is always "connected" for these tests (offline is exercised via the
|
||||
// prompt bar's error rendering).
|
||||
vi.mock('../store/socketSelectors', () => ({ selectSocketStatus: () => 'connected' }));
|
||||
|
||||
const dispatch = vi.hoisted(() => vi.fn());
|
||||
const selectorState = vi.hoisted(() => ({
|
||||
activeThreadIds: {} as Record<string, true>,
|
||||
proposals: {} as Record<string, WorkflowProposal>,
|
||||
}));
|
||||
vi.mock('../store/hooks', () => ({
|
||||
useAppDispatch: () => dispatch,
|
||||
useAppSelector: (sel: (s: unknown) => unknown) =>
|
||||
sel({
|
||||
thread: { activeThreadIds: selectorState.activeThreadIds },
|
||||
chatRuntime: { pendingWorkflowProposalsByThread: selectorState.proposals },
|
||||
}),
|
||||
}));
|
||||
|
||||
// Tag thread/chatRuntime action creators so the dispatch mock can special-case
|
||||
// the two thunks that need `.unwrap()`.
|
||||
vi.mock('../store/threadSlice', () => ({
|
||||
createNewThread: (labels: string[]) => ({ type: 'createNewThread', labels }),
|
||||
addMessageLocal: (p: unknown) => ({ type: 'addMessageLocal', p }),
|
||||
markThreadInferenceActive: (id: string) => ({ type: 'markActive', id }),
|
||||
clearThreadInferenceActive: (id: string) => ({ type: 'clearActive', id }),
|
||||
}));
|
||||
vi.mock('../store/chatRuntimeSlice', () => ({
|
||||
beginInferenceTurn: (p: unknown) => ({ type: 'begin', p }),
|
||||
clearRuntimeForThread: (p: unknown) => ({ type: 'clearRuntime', p }),
|
||||
clearWorkflowProposalForThread: (p: unknown) => ({ type: 'clearProposal', p }),
|
||||
setToolTimelineForThread: (p: unknown) => ({ type: 'timeline', p }),
|
||||
}));
|
||||
|
||||
describe('useWorkflowBuilderChat', () => {
|
||||
beforeEach(() => {
|
||||
chatSend.mockReset().mockResolvedValue(undefined);
|
||||
selectorState.activeThreadIds = {};
|
||||
selectorState.proposals = {};
|
||||
dispatch.mockReset().mockImplementation((action: { type: string }) => {
|
||||
if (action.type === 'createNewThread') {
|
||||
return { unwrap: () => Promise.resolve({ id: 'builder-1' }) };
|
||||
}
|
||||
if (action.type === 'addMessageLocal') {
|
||||
return { unwrap: () => Promise.resolve(undefined) };
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
});
|
||||
|
||||
it('creates a dedicated thread on first send and dispatches the turn there', async () => {
|
||||
const { result } = renderHook(() => useWorkflowBuilderChat());
|
||||
expect(result.current.threadId).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
await result.current.send({ displayText: 'hi', prompt: 'DELEGATE PROMPT' });
|
||||
});
|
||||
|
||||
// A dedicated "workflow-builder" thread was created and the turn sent there.
|
||||
expect(dispatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'createNewThread', labels: ['workflow-builder'] })
|
||||
);
|
||||
expect(chatSend).toHaveBeenCalledWith({ threadId: 'builder-1', message: 'DELEGATE PROMPT' });
|
||||
await waitFor(() => expect(result.current.threadId).toBe('builder-1'));
|
||||
});
|
||||
|
||||
it('surfaces the proposal the runtime parsed onto the dedicated thread', async () => {
|
||||
const proposal: WorkflowProposal = {
|
||||
name: 'Digest',
|
||||
graph: { nodes: [], edges: [] },
|
||||
requireApproval: true,
|
||||
summary: { trigger: 'schedule', steps: [] },
|
||||
};
|
||||
selectorState.proposals = { 'builder-1': proposal };
|
||||
|
||||
const { result } = renderHook(() => useWorkflowBuilderChat());
|
||||
await act(async () => {
|
||||
await result.current.send({ displayText: 'hi', prompt: 'PROMPT' });
|
||||
});
|
||||
await waitFor(() => expect(result.current.proposal).toEqual(proposal));
|
||||
});
|
||||
|
||||
it('reuses the same dedicated thread across sends (creates it once)', async () => {
|
||||
const { result } = renderHook(() => useWorkflowBuilderChat());
|
||||
await act(async () => {
|
||||
await result.current.send({ displayText: 'one', prompt: 'P1' });
|
||||
});
|
||||
await act(async () => {
|
||||
await result.current.send({ displayText: 'two', prompt: 'P2' });
|
||||
});
|
||||
const createCalls = dispatch.mock.calls.filter(
|
||||
([a]) => (a as { type: string }).type === 'createNewThread'
|
||||
);
|
||||
expect(createCalls).toHaveLength(1);
|
||||
expect(chatSend).toHaveBeenLastCalledWith({ threadId: 'builder-1', message: 'P2' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* useWorkflowBuilderChat (Phase 5c) — a thin driver around the existing chat
|
||||
* runtime for the Flows prompt bar and canvas copilot. It owns a DEDICATED
|
||||
* thread (created lazily on first send) so a workflow-authoring conversation
|
||||
* never collides with the user's main chat, sends turns phrased to route to the
|
||||
* `workflow_builder` specialist (see `lib/flows/workflowBuilderPrompt.ts`), and
|
||||
* exposes the resulting `WorkflowProposal` the global `ChatRuntimeProvider`
|
||||
* parses onto this thread.
|
||||
*
|
||||
* It deliberately does NOT reimplement the chat runtime: the same
|
||||
* `addMessageLocal` → `chatSend` path and the same `pendingWorkflowProposalsByThread`
|
||||
* store slice that `Conversations.tsx` uses drive this. The only new concept is
|
||||
* per-surface thread scoping.
|
||||
*
|
||||
* Invariant: nothing here persists or enables a flow. The proposal is
|
||||
* validate-only; saving stays behind the explicit `WorkflowProposalCard`
|
||||
* "Save & enable" click.
|
||||
*/
|
||||
import createDebug from 'debug';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { chatSend } from '../services/chatService';
|
||||
import {
|
||||
beginInferenceTurn,
|
||||
clearRuntimeForThread,
|
||||
clearWorkflowProposalForThread,
|
||||
setToolTimelineForThread,
|
||||
type WorkflowProposal,
|
||||
} from '../store/chatRuntimeSlice';
|
||||
import { useAppDispatch, useAppSelector } from '../store/hooks';
|
||||
import { selectSocketStatus } from '../store/socketSelectors';
|
||||
import {
|
||||
addMessageLocal,
|
||||
clearThreadInferenceActive,
|
||||
createNewThread,
|
||||
markThreadInferenceActive,
|
||||
} from '../store/threadSlice';
|
||||
import type { ThreadMessage } from '../types/thread';
|
||||
|
||||
const log = createDebug('app:flows:builder-chat');
|
||||
|
||||
/** A single builder turn: what the user sees vs. what the agent receives. */
|
||||
export interface WorkflowBuilderSendParams {
|
||||
/** Human-readable text shown as the user's message in the thread transcript. */
|
||||
displayText: string;
|
||||
/** The full delegation prompt actually sent to the core (may inject graph/context). */
|
||||
prompt: string;
|
||||
}
|
||||
|
||||
export interface UseWorkflowBuilderChat {
|
||||
/** The dedicated thread id, or `null` before the first send creates it. */
|
||||
threadId: string | null;
|
||||
/** True while a builder turn is in flight on this thread. */
|
||||
sending: boolean;
|
||||
/** The latest proposal the agent returned on this thread, or `null`. */
|
||||
proposal: WorkflowProposal | null;
|
||||
/** Last send error (thread create / RPC failure), or `null`. */
|
||||
error: string | null;
|
||||
/** Send a builder turn, creating the dedicated thread on first use. */
|
||||
send: (params: WorkflowBuilderSendParams) => Promise<void>;
|
||||
/** Clear the current proposal (e.g. after Accept/Reject) without persisting. */
|
||||
clearProposal: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param seedThreadId Optional existing thread to bind to instead of creating a
|
||||
* fresh one — lets a caller reuse a thread across mounts (unused today; the
|
||||
* prompt bar and copilot each start clean).
|
||||
*/
|
||||
export function useWorkflowBuilderChat(seedThreadId?: string | null): UseWorkflowBuilderChat {
|
||||
const dispatch = useAppDispatch();
|
||||
const socketStatus = useAppSelector(selectSocketStatus);
|
||||
const [threadId, setThreadId] = useState<string | null>(seedThreadId ?? null);
|
||||
const [localSending, setLocalSending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const activeThreadIds = useAppSelector(state => state.thread.activeThreadIds);
|
||||
const proposalsByThread = useAppSelector(
|
||||
state => state.chatRuntime.pendingWorkflowProposalsByThread
|
||||
);
|
||||
|
||||
const proposal = useMemo(
|
||||
() => (threadId ? (proposalsByThread[threadId] ?? null) : null),
|
||||
[threadId, proposalsByThread]
|
||||
);
|
||||
|
||||
// "Sending" = we're mid-dispatch OR the runtime still marks the thread active.
|
||||
const runtimeActive = threadId ? Boolean(activeThreadIds[threadId]) : false;
|
||||
const sending = localSending || runtimeActive;
|
||||
|
||||
const send = useCallback(
|
||||
async ({ displayText, prompt }: WorkflowBuilderSendParams) => {
|
||||
if (localSending) {
|
||||
log('send: ignored — a turn is already dispatching');
|
||||
return;
|
||||
}
|
||||
if (socketStatus !== 'connected') {
|
||||
log('send: blocked — socket not connected (%s)', socketStatus);
|
||||
setError('offline');
|
||||
return;
|
||||
}
|
||||
setLocalSending(true);
|
||||
setError(null);
|
||||
// Declared outside the try so the catch block can see a thread created
|
||||
// during THIS call — `threadId` state doesn't update synchronously
|
||||
// within the same closure invocation, so a failure after creation (but
|
||||
// before this call returns) would otherwise see the stale `null` and
|
||||
// skip cleanup, leaving that new thread's active markers dangling.
|
||||
let targetThreadId = threadId;
|
||||
try {
|
||||
if (!targetThreadId) {
|
||||
log('send: creating dedicated builder thread');
|
||||
const thread = await dispatch(createNewThread(['workflow-builder'])).unwrap();
|
||||
targetThreadId = thread.id;
|
||||
setThreadId(targetThreadId);
|
||||
}
|
||||
// A fresh turn supersedes any prior proposal on this thread.
|
||||
dispatch(clearWorkflowProposalForThread({ threadId: targetThreadId }));
|
||||
|
||||
const userMessage: ThreadMessage = {
|
||||
id: `msg_${globalThis.crypto.randomUUID()}`,
|
||||
content: displayText,
|
||||
type: 'text',
|
||||
extraMetadata: {},
|
||||
sender: 'user',
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
await dispatch(
|
||||
addMessageLocal({ threadId: targetThreadId, message: userMessage })
|
||||
).unwrap();
|
||||
|
||||
dispatch(setToolTimelineForThread({ threadId: targetThreadId, entries: [] }));
|
||||
dispatch(beginInferenceTurn({ threadId: targetThreadId }));
|
||||
dispatch(markThreadInferenceActive(targetThreadId));
|
||||
|
||||
log('send: dispatching builder turn thread=%s', targetThreadId);
|
||||
await chatSend({ threadId: targetThreadId, message: prompt });
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
log('send: failed err=%o', err);
|
||||
setError(msg);
|
||||
// The runtime never got a turn to end, so release the active markers we
|
||||
// optimistically set (guarded: targetThreadId is still null only when
|
||||
// thread creation itself failed, in which case there's nothing to clear).
|
||||
if (targetThreadId) {
|
||||
dispatch(clearRuntimeForThread({ threadId: targetThreadId }));
|
||||
dispatch(clearThreadInferenceActive(targetThreadId));
|
||||
}
|
||||
} finally {
|
||||
setLocalSending(false);
|
||||
}
|
||||
},
|
||||
[dispatch, localSending, socketStatus, threadId]
|
||||
);
|
||||
|
||||
const clearProposal = useCallback(() => {
|
||||
if (threadId) dispatch(clearWorkflowProposalForThread({ threadId }));
|
||||
}, [dispatch, threadId]);
|
||||
|
||||
return { threadId, sending, proposal, error, send, clearProposal };
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Canvas draft handoff (Phase 4e) — carries an *unsaved* candidate
|
||||
* `WorkflowGraph` from the chat `WorkflowProposalCard` "Open in canvas" action
|
||||
* into the editable Workflow Canvas so the user can review/edit it BEFORE the
|
||||
* single persistence gate.
|
||||
*
|
||||
* Critical invariant: opening a draft NEVER persists or enables a flow. The
|
||||
* draft rides in the router's `location.state` (ephemeral, dropped on reload —
|
||||
* exactly what an unsaved draft should be) rather than any store or RPC. The
|
||||
* canvas's own Save button remains the one and only persistence gate; for a
|
||||
* draft it calls `flows_create` (see `FlowCanvasPage`), never on open.
|
||||
*/
|
||||
import type { WorkflowGraph } from './types';
|
||||
|
||||
/**
|
||||
* Dedicated route for an unsaved draft canvas. Placed BEFORE `/flows/:id` so it
|
||||
* matches first — otherwise `:id` would capture `"draft"` and try to
|
||||
* `flows_get('draft')`.
|
||||
*/
|
||||
export const FLOW_CANVAS_DRAFT_ROUTE = '/flows/draft';
|
||||
|
||||
/**
|
||||
* The shape stashed in `location.state` when navigating to
|
||||
* {@link FLOW_CANVAS_DRAFT_ROUTE}. Mirrors the fields `flows_create` needs so
|
||||
* the canvas's Save can persist the reviewed draft as-is.
|
||||
*/
|
||||
export interface FlowCanvasDraftState {
|
||||
/** Proposed flow name — seeds the canvas title and the eventual `flows_create`. */
|
||||
name: string;
|
||||
/** The candidate graph to open as an editable, unsaved draft. */
|
||||
graph: WorkflowGraph;
|
||||
/** "Require approval for outbound actions" toggle to carry into `flows_create`. */
|
||||
requireApproval: boolean;
|
||||
/**
|
||||
* Non-fatal import warnings (Phase 4d) — surfaced as toasts over the draft
|
||||
* canvas when a graph was imported via `flows_import` (unmapped n8n node
|
||||
* types, untranslated expressions, a synthesized/demoted trigger). Absent for
|
||||
* a chat-proposal draft (which carries no import notes).
|
||||
*/
|
||||
importWarnings?: string[];
|
||||
}
|
||||
|
||||
/** Narrow an opaque `location.state` to a {@link FlowCanvasDraftState}. */
|
||||
export function asFlowCanvasDraftState(state: unknown): FlowCanvasDraftState | null {
|
||||
if (!state || typeof state !== 'object') return null;
|
||||
const record = state as Record<string, unknown>;
|
||||
const graph = record.graph;
|
||||
if (
|
||||
typeof record.name !== 'string' ||
|
||||
!graph ||
|
||||
typeof graph !== 'object' ||
|
||||
typeof record.requireApproval !== 'boolean'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const importWarnings = Array.isArray(record.importWarnings)
|
||||
? record.importWarnings.filter((w): w is string => typeof w === 'string')
|
||||
: undefined;
|
||||
return {
|
||||
name: record.name,
|
||||
graph: graph as WorkflowGraph,
|
||||
requireApproval: record.requireApproval,
|
||||
...(importWarnings ? { importWarnings } : {}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* exportFlow (Phase 4d) — pure client-side flow export. Covers the file-name
|
||||
* slug, the pretty-printed serialization, and the DOM download side effect
|
||||
* (anchor click + object-URL lifecycle).
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { downloadFlowGraph, exportFileName, serializeFlowGraph } from './exportFlow';
|
||||
|
||||
describe('exportFileName', () => {
|
||||
it('slugifies the name and appends .flow.json', () => {
|
||||
expect(exportFileName('Daily Digest')).toBe('daily-digest.flow.json');
|
||||
expect(exportFileName(' Fetch & Parse API! ')).toBe('fetch-parse-api.flow.json');
|
||||
});
|
||||
|
||||
it('falls back to "workflow" for an empty/symbol-only name', () => {
|
||||
expect(exportFileName('')).toBe('workflow.flow.json');
|
||||
expect(exportFileName('***')).toBe('workflow.flow.json');
|
||||
});
|
||||
});
|
||||
|
||||
describe('serializeFlowGraph', () => {
|
||||
it('pretty-prints the graph with a trailing newline', () => {
|
||||
const out = serializeFlowGraph({ name: 'x', nodes: [], edges: [] });
|
||||
expect(out).toBe('{\n "name": "x",\n "nodes": [],\n "edges": []\n}\n');
|
||||
});
|
||||
});
|
||||
|
||||
describe('downloadFlowGraph', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('triggers an anchor download and revokes the object URL', () => {
|
||||
const createObjectURL = vi.fn(() => 'blob:mock');
|
||||
const revokeObjectURL = vi.fn();
|
||||
vi.stubGlobal('URL', { createObjectURL, revokeObjectURL });
|
||||
const click = vi.fn();
|
||||
const anchor = { href: '', download: '', click } as unknown as HTMLAnchorElement;
|
||||
vi.spyOn(document, 'createElement').mockReturnValue(anchor);
|
||||
vi.spyOn(document.body, 'appendChild').mockImplementation(node => node);
|
||||
vi.spyOn(document.body, 'removeChild').mockImplementation(node => node);
|
||||
|
||||
const ok = downloadFlowGraph('My Flow', { name: 'My Flow', nodes: [], edges: [] });
|
||||
|
||||
expect(ok).toBe(true);
|
||||
expect(anchor.download).toBe('my-flow.flow.json');
|
||||
expect(click).toHaveBeenCalledTimes(1);
|
||||
expect(createObjectURL).toHaveBeenCalledTimes(1);
|
||||
expect(revokeObjectURL).toHaveBeenCalledWith('blob:mock');
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Workflow export (Phase 4d) — pure client-side serialization of a flow's
|
||||
* `WorkflowGraph` into a downloadable JSON file. No RPC: the frontend already
|
||||
* holds the graph (from `flows_list` / `flows_get`), so exporting is just
|
||||
* serialize + trigger a browser download. The counterpart import path
|
||||
* (`flowsApi.importFlow`) is host-validated because migrate/validate must be
|
||||
* authoritative; export has no such constraint.
|
||||
*/
|
||||
import createDebug from 'debug';
|
||||
|
||||
const log = createDebug('app:flows:export');
|
||||
|
||||
/**
|
||||
* Turns a flow name into a safe, lower-kebab file stem. Non-alphanumeric runs
|
||||
* collapse to a single `-`; empty results fall back to `workflow`.
|
||||
*/
|
||||
export function exportFileName(name: string): string {
|
||||
const stem = name
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
return `${stem || 'workflow'}.flow.json`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes a flow's `graph` to pretty-printed JSON. Kept separate from the
|
||||
* download side effect so it's trivially unit-testable.
|
||||
*/
|
||||
export function serializeFlowGraph(graph: unknown): string {
|
||||
return `${JSON.stringify(graph, null, 2)}\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads a flow's `WorkflowGraph` as a `<name>.flow.json` file via a
|
||||
* transient object-URL anchor. Guarded for non-DOM environments (SSR / tests
|
||||
* without a document) — returns `false` when it can't run, `true` once the
|
||||
* download is triggered.
|
||||
*/
|
||||
export function downloadFlowGraph(name: string, graph: unknown): boolean {
|
||||
if (typeof document === 'undefined' || typeof URL === 'undefined' || !URL.createObjectURL) {
|
||||
log('downloadFlowGraph: no DOM/URL available — skipping');
|
||||
return false;
|
||||
}
|
||||
const fileName = exportFileName(name);
|
||||
log('downloadFlowGraph: name=%s file=%s', name, fileName);
|
||||
const blob = new Blob([serializeFlowGraph(graph)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
try {
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = fileName;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
document.body.removeChild(anchor);
|
||||
} finally {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Unit tests for `erroredNodeIds` (Phase 3c) — mapping graph-level validation
|
||||
* error strings back to the node ids they name, for canvas highlighting.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { erroredNodeIds } from './flowValidation';
|
||||
|
||||
describe('erroredNodeIds', () => {
|
||||
it('flags a node named in an invalid-config error', () => {
|
||||
const flagged = erroredNodeIds(
|
||||
['invalid config for node agent1: missing prompt'],
|
||||
['trigger', 'agent1']
|
||||
);
|
||||
expect([...flagged]).toEqual(['agent1']);
|
||||
});
|
||||
|
||||
it('flags every id listed in a multiple-triggers error', () => {
|
||||
const flagged = erroredNodeIds(
|
||||
['workflow has multiple trigger nodes: ["t1", "t2"]'],
|
||||
['t1', 't2', 'a']
|
||||
);
|
||||
expect(flagged).toEqual(new Set(['t1', 't2']));
|
||||
});
|
||||
|
||||
it('flags nothing for a graph-level error that names no node', () => {
|
||||
const flagged = erroredNodeIds(['workflow has no trigger node'], ['t', 'a']);
|
||||
expect(flagged.size).toBe(0);
|
||||
});
|
||||
|
||||
it('does not partial-match a shorter id embedded in a longer hyphenated id', () => {
|
||||
// "agent" must NOT be flagged by an error naming "new-agent-0".
|
||||
const flagged = erroredNodeIds(
|
||||
['invalid config for node new-agent-0: bad'],
|
||||
['agent', 'new-agent-0']
|
||||
);
|
||||
expect(flagged).toEqual(new Set(['new-agent-0']));
|
||||
});
|
||||
|
||||
it('returns an empty set when there are no errors', () => {
|
||||
expect(erroredNodeIds([], ['t', 'a']).size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Pure helpers for the editable Workflow Canvas's validation UX (Phase 3c).
|
||||
*
|
||||
* The `openhuman.flows_validate` RPC returns a graph-level
|
||||
* {@link FlowValidation} — a `valid` flag plus opaque `errors[]` / `warnings[]`
|
||||
* message strings (see `services/api/flowsApi.ts` and, upstream, the
|
||||
* `tinyflows::error::ValidationError` `Display` impls). Several of those error
|
||||
* strings name the offending node id inline, e.g.:
|
||||
*
|
||||
* - `"invalid config for node <id>: <reason>"`
|
||||
* - `"illegal cycle detected involving node: <id>"`
|
||||
* - `"edge references unknown node id: <id>"`
|
||||
* - `"duplicate node id: <id>"`
|
||||
* - `"workflow has multiple trigger nodes: ["<id>", "<id>"]"`
|
||||
*
|
||||
* To highlight the culprit node(s) on the canvas we can't parse each distinct
|
||||
* message shape robustly (they're free-form host strings), so instead we test
|
||||
* every *known* node id against every error message: a node is flagged when its
|
||||
* id appears as a whole token in any error. Graph-level errors that name no
|
||||
* node (e.g. `"workflow has no trigger node"`) simply flag nothing — they still
|
||||
* surface in the banner and still block Save.
|
||||
*
|
||||
* Kept pure + dependency-free so it's trivially unit-testable and reusable.
|
||||
*/
|
||||
|
||||
/** Escape a string for literal use inside a `RegExp`. */
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* The subset of `nodeIds` that appear as a whole token in at least one of the
|
||||
* `errors` messages. "Whole token" boundaries treat word characters AND `-` as
|
||||
* non-boundaries (node ids are commonly hyphenated, e.g. `new-agent-0`), so a
|
||||
* shorter id embedded in a longer hyphenated id is never a false positive.
|
||||
*/
|
||||
export function erroredNodeIds(errors: string[], nodeIds: string[]): Set<string> {
|
||||
const flagged = new Set<string>();
|
||||
if (errors.length === 0) return flagged;
|
||||
for (const id of nodeIds) {
|
||||
if (!id) continue;
|
||||
const re = new RegExp(`(^|[^\\w-])${escapeRegExp(id)}([^\\w-]|$)`);
|
||||
if (errors.some(message => re.test(message))) {
|
||||
flagged.add(id);
|
||||
}
|
||||
}
|
||||
return flagged;
|
||||
}
|
||||
@@ -2,13 +2,15 @@ import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
autoLayout,
|
||||
createFlowNode,
|
||||
edgeId,
|
||||
type FlowEdge,
|
||||
type FlowNode,
|
||||
isValidFlowConnection,
|
||||
workflowGraphToXyflow,
|
||||
xyflowToWorkflowGraph,
|
||||
} from './graphAdapter';
|
||||
import type { WorkflowEdge, WorkflowGraph, WorkflowNode } from './types';
|
||||
import type { NodeKind, WorkflowEdge, WorkflowGraph, WorkflowNode } from './types';
|
||||
|
||||
function node(overrides: Partial<WorkflowNode> = {}): WorkflowNode {
|
||||
return { id: 'n1', kind: 'agent', name: 'Agent', config: {}, ports: [], ...overrides };
|
||||
@@ -331,4 +333,151 @@ describe('graphAdapter', () => {
|
||||
expect(ids.size).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createFlowNode', () => {
|
||||
it('builds a flowNode with a single default main input/output and empty config/ports', () => {
|
||||
const created = createFlowNode('agent', { x: 12, y: 34 }, 'new-agent-0', 'Agent');
|
||||
expect(created.id).toBe('new-agent-0');
|
||||
expect(created.type).toBe('flowNode');
|
||||
expect(created.position).toEqual({ x: 12, y: 34 });
|
||||
expect(created.data.kind).toBe('agent');
|
||||
expect(created.data.name).toBe('Agent');
|
||||
expect(created.data.config).toEqual({});
|
||||
expect(created.data.ports).toEqual([]);
|
||||
expect(created.data.inputPorts).toEqual(['main']);
|
||||
expect(created.data.outputPorts).toEqual(['main']);
|
||||
});
|
||||
|
||||
it('falls back to the kind as the name when none is given', () => {
|
||||
const created = createFlowNode('http_request', { x: 0, y: 0 }, 'id1');
|
||||
expect(created.data.name).toBe('http_request');
|
||||
});
|
||||
|
||||
it('seeds a condition node with declared true/false output ports (fixed runtime routing)', () => {
|
||||
const created = createFlowNode('condition', { x: 0, y: 0 }, 'cond-0', 'Branch');
|
||||
expect(created.data.ports).toEqual([{ name: 'true' }, { name: 'false' }]);
|
||||
expect(created.data.inputPorts).toEqual(['main']);
|
||||
expect(created.data.outputPorts).toEqual(['true', 'false']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidFlowConnection', () => {
|
||||
// A trigger → agent pair, both with the default single `main` handle, as a
|
||||
// freshly palette-built canvas would produce.
|
||||
const nodes: FlowNode[] = [
|
||||
createFlowNode('trigger', { x: 0, y: 0 }, 't', 'Start'),
|
||||
createFlowNode('agent', { x: 280, y: 0 }, 'a', 'Reply'),
|
||||
];
|
||||
|
||||
it('accepts a main→main connection between two distinct nodes', () => {
|
||||
expect(
|
||||
isValidFlowConnection(
|
||||
{ source: 't', target: 'a', sourceHandle: 'main', targetHandle: 'main' },
|
||||
nodes,
|
||||
[]
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts a connection with null handles (defaults to main)', () => {
|
||||
expect(
|
||||
isValidFlowConnection(
|
||||
{ source: 't', target: 'a', sourceHandle: null, targetHandle: null },
|
||||
nodes,
|
||||
[]
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a self-loop', () => {
|
||||
expect(
|
||||
isValidFlowConnection(
|
||||
{ source: 't', target: 't', sourceHandle: 'main', targetHandle: 'main' },
|
||||
nodes,
|
||||
[]
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a missing endpoint', () => {
|
||||
expect(
|
||||
isValidFlowConnection({ source: 't', target: null, sourceHandle: 'main' }, nodes, [])
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects an endpoint that is not on the canvas', () => {
|
||||
expect(
|
||||
isValidFlowConnection(
|
||||
{ source: 't', target: 'ghost', sourceHandle: 'main', targetHandle: 'main' },
|
||||
nodes,
|
||||
[]
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects an unknown source output port', () => {
|
||||
expect(
|
||||
isValidFlowConnection(
|
||||
{ source: 't', target: 'a', sourceHandle: 'nonexistent', targetHandle: 'main' },
|
||||
nodes,
|
||||
[]
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects an unknown target input port', () => {
|
||||
expect(
|
||||
isValidFlowConnection(
|
||||
{ source: 't', target: 'a', sourceHandle: 'main', targetHandle: 'nonexistent' },
|
||||
nodes,
|
||||
[]
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a duplicate of an edge already present', () => {
|
||||
const existing: FlowEdge[] = [
|
||||
{ id: 'e1', source: 't', target: 'a', sourceHandle: 'main', targetHandle: 'main' },
|
||||
];
|
||||
expect(
|
||||
isValidFlowConnection(
|
||||
{ source: 't', target: 'a', sourceHandle: 'main', targetHandle: 'main' },
|
||||
nodes,
|
||||
existing
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('palette-built graph round-trips through xyflowToWorkflowGraph', () => {
|
||||
it('serializes click-added nodes + a valid connection back into a WorkflowGraph', () => {
|
||||
const kinds: NodeKind[] = ['trigger', 'agent'];
|
||||
const built = kinds.map((kind, i) =>
|
||||
createFlowNode(kind, { x: i * 280, y: 0 }, `new-${kind}-${i}`, kind)
|
||||
);
|
||||
const connection = {
|
||||
source: 'new-trigger-0',
|
||||
target: 'new-agent-1',
|
||||
sourceHandle: 'main',
|
||||
targetHandle: 'main',
|
||||
};
|
||||
expect(isValidFlowConnection(connection, built, [])).toBe(true);
|
||||
|
||||
const edges: FlowEdge[] = [{ id: 'e', ...connection }];
|
||||
const result = xyflowToWorkflowGraph(built, edges, {
|
||||
schema_version: 1,
|
||||
id: 'wf_new',
|
||||
name: 'Fresh flow',
|
||||
});
|
||||
|
||||
expect(result.schema_version).toBe(1);
|
||||
expect(result.id).toBe('wf_new');
|
||||
expect(result.name).toBe('Fresh flow');
|
||||
expect(result.nodes.map(n => n.kind)).toEqual(['trigger', 'agent']);
|
||||
expect(result.nodes.every(n => n.config && Array.isArray(n.ports))).toBe(true);
|
||||
expect(result.edges).toEqual([
|
||||
{ from_node: 'new-trigger-0', from_port: 'main', to_node: 'new-agent-1', to_port: 'main' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -151,6 +151,126 @@ export function workflowGraphToXyflow(graph: WorkflowGraph): {
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
/**
|
||||
* A React-Flow connection candidate (what `onConnect` / `isValidConnection`
|
||||
* receive). `sourceHandle`/`targetHandle` are the `Handle` ids — i.e. the
|
||||
* effective port names `FlowNodeComponent` renders — and may be `null` when a
|
||||
* node exposes a single unnamed handle, in which case they default to `main`.
|
||||
*/
|
||||
export interface FlowConnectionCandidate {
|
||||
source?: string | null;
|
||||
target?: string | null;
|
||||
sourceHandle?: string | null;
|
||||
targetHandle?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Port-aware validity check for a candidate connection on the editable canvas.
|
||||
* Rejects (returns `false`) when:
|
||||
* - either endpoint is missing;
|
||||
* - the connection is a self-loop (`source === target`) — tinyflows graphs
|
||||
* are DAG-ish and a node wiring to itself is never meaningful here;
|
||||
* - either endpoint node isn't in `nodes`;
|
||||
* - the source handle isn't one of the source node's effective *output*
|
||||
* ports, or the target handle isn't one of the target node's effective
|
||||
* *input* ports (reusing the same `inputPorts`/`outputPorts` the canvas
|
||||
* already derived in {@link workflowGraphToXyflow} / {@link createFlowNode});
|
||||
* - an identical edge (same 4-tuple) already exists in `edges` — React Flow
|
||||
* would happily add a duplicate otherwise.
|
||||
*
|
||||
* Pure and dependency-free so both `<ReactFlow isValidConnection>` (live drag
|
||||
* feedback) and `onConnect` (the commit) can share one source of truth, and so
|
||||
* it's trivially unit-testable.
|
||||
*/
|
||||
export function isValidFlowConnection(
|
||||
connection: FlowConnectionCandidate,
|
||||
nodes: FlowNode[],
|
||||
edges: FlowEdge[] = []
|
||||
): boolean {
|
||||
const { source, target } = connection;
|
||||
if (!source || !target) {
|
||||
log('isValidFlowConnection: reject — missing endpoint');
|
||||
return false;
|
||||
}
|
||||
if (source === target) {
|
||||
log('isValidFlowConnection: reject — self-loop on %s', source);
|
||||
return false;
|
||||
}
|
||||
const sourceNode = nodes.find(n => n.id === source);
|
||||
const targetNode = nodes.find(n => n.id === target);
|
||||
if (!sourceNode || !targetNode) {
|
||||
log('isValidFlowConnection: reject — endpoint node not found');
|
||||
return false;
|
||||
}
|
||||
const sourceHandle = connection.sourceHandle || DEFAULT_PORT;
|
||||
const targetHandle = connection.targetHandle || DEFAULT_PORT;
|
||||
if (!sourceNode.data.outputPorts.includes(sourceHandle)) {
|
||||
log('isValidFlowConnection: reject — %s has no output port %s', source, sourceHandle);
|
||||
return false;
|
||||
}
|
||||
if (!targetNode.data.inputPorts.includes(targetHandle)) {
|
||||
log('isValidFlowConnection: reject — %s has no input port %s', target, targetHandle);
|
||||
return false;
|
||||
}
|
||||
const duplicate = edges.some(
|
||||
e =>
|
||||
e.source === source &&
|
||||
e.target === target &&
|
||||
(e.sourceHandle || DEFAULT_PORT) === sourceHandle &&
|
||||
(e.targetHandle || DEFAULT_PORT) === targetHandle
|
||||
);
|
||||
if (duplicate) {
|
||||
log('isValidFlowConnection: reject — duplicate edge');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Declared output `ports` a freshly-added node of `kind` needs at creation
|
||||
* time, for kinds whose runtime routing is fixed and NOT derivable from
|
||||
* config or wired edges. A `condition` node always routes through `true`/
|
||||
* `false` (`vendor/tinyflows/src/nodes/control_flow/condition.rs`), but the
|
||||
* config drawer has no port editor — so unlike `switch` (whose case ports
|
||||
* are config-driven and materialize once the author wires an edge, per
|
||||
* {@link effectiveOutputPorts}'s doc comment), a new `condition` node must be
|
||||
* seeded with both ports up front or its second branch is never wireable
|
||||
* from the canvas.
|
||||
*/
|
||||
function defaultPortsForKind(kind: NodeKind): Port[] {
|
||||
if (kind === 'condition') {
|
||||
return [{ name: 'true' }, { name: 'false' }];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a fresh xyflow node for a palette-added `kind` at `position`. Newly
|
||||
* dropped nodes start with a single default `main` input handle (no wired
|
||||
* edges yet) plus whatever {@link defaultPortsForKind} declares for `kind` —
|
||||
* empty for most kinds, which fall back to the single default `main` output
|
||||
* handle exactly as {@link effectiveInputPorts}/{@link effectiveOutputPorts}
|
||||
* would derive for a node with no edges yet — so it round-trips cleanly
|
||||
* through {@link xyflowToWorkflowGraph} and immediately accepts connections.
|
||||
* `id` must be unique within the canvas; `name` defaults to `kind` when
|
||||
* omitted.
|
||||
*/
|
||||
export function createFlowNode(
|
||||
kind: NodeKind,
|
||||
position: Point,
|
||||
id: string,
|
||||
name?: string
|
||||
): FlowNode {
|
||||
const ports = defaultPortsForKind(kind);
|
||||
const outputPorts = ports.length > 0 ? ports.map(p => p.name) : [DEFAULT_PORT];
|
||||
return {
|
||||
id,
|
||||
type: FLOW_NODE_TYPE,
|
||||
position,
|
||||
data: { kind, name: name ?? kind, config: {}, ports, inputPorts: [DEFAULT_PORT], outputPorts },
|
||||
};
|
||||
}
|
||||
|
||||
/** Metadata not carried by xyflow nodes/edges, needed to reassemble a full `WorkflowGraph`. */
|
||||
export interface WorkflowGraphMeta {
|
||||
schema_version: number;
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildPreviewGraph, diffGraphs } from './graphDiff';
|
||||
import type { WorkflowGraph, WorkflowNode } from './types';
|
||||
|
||||
function node(id: string): WorkflowNode {
|
||||
return { id, kind: 'agent', name: id, config: {}, ports: [] };
|
||||
}
|
||||
|
||||
function graph(ids: string[], edges: WorkflowGraph['edges'] = []): WorkflowGraph {
|
||||
return { schema_version: 1, name: 'g', nodes: ids.map(node), edges };
|
||||
}
|
||||
|
||||
describe('diffGraphs', () => {
|
||||
it('reports added and removed node ids by id', () => {
|
||||
const current = graph(['a', 'b']);
|
||||
const proposed = graph(['b', 'c']);
|
||||
const d = diffGraphs(current, proposed);
|
||||
expect([...d.addedNodeIds]).toEqual(['c']);
|
||||
expect([...d.removedNodeIds]).toEqual(['a']);
|
||||
expect(d.hasChanges).toBe(true);
|
||||
});
|
||||
|
||||
it('reports no changes when the node set is identical', () => {
|
||||
const d = diffGraphs(graph(['a', 'b']), graph(['b', 'a']));
|
||||
expect(d.addedNodeIds.size).toBe(0);
|
||||
expect(d.removedNodeIds.size).toBe(0);
|
||||
expect(d.hasChanges).toBe(false);
|
||||
});
|
||||
|
||||
it('is safe against null/undefined graphs', () => {
|
||||
expect(diffGraphs(null, graph(['a'])).addedNodeIds).toEqual(new Set(['a']));
|
||||
expect(diffGraphs(graph(['a']), null).removedNodeIds).toEqual(new Set(['a']));
|
||||
expect(diffGraphs(null, null).hasChanges).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildPreviewGraph', () => {
|
||||
it('returns the proposed graph unchanged when nothing is removed', () => {
|
||||
const proposed = graph(['a', 'c']);
|
||||
expect(buildPreviewGraph(graph(['a']), proposed, new Set())).toBe(proposed);
|
||||
});
|
||||
|
||||
it('carries removed nodes (and their edges) over as ghosts', () => {
|
||||
const current = graph(
|
||||
['a', 'b'],
|
||||
[{ from_node: 'a', from_port: 'main', to_node: 'b', to_port: 'main' }]
|
||||
);
|
||||
const proposed = graph(['a', 'c']);
|
||||
const preview = buildPreviewGraph(current, proposed, new Set(['b']));
|
||||
expect(preview.nodes.map(n => n.id).sort()).toEqual(['a', 'b', 'c']);
|
||||
// The a→b edge is preserved so the ghosted node still shows its wiring.
|
||||
expect(preview.edges).toContainEqual({
|
||||
from_node: 'a',
|
||||
from_port: 'main',
|
||||
to_node: 'b',
|
||||
to_port: 'main',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* graphDiff (Phase 5c) — a node-level diff between the current canvas draft and
|
||||
* an agent-proposed `WorkflowGraph`, used to paint the copilot's diff overlay:
|
||||
* added nodes are highlighted, removed nodes are ghosted.
|
||||
*
|
||||
* Diff is by node `id`. The `workflow_builder` returns a full revised graph
|
||||
* (`revise_workflow` echoes the whole `WorkflowGraph`), so a node the user asked
|
||||
* to keep retains its id across a revision; genuinely new nodes get fresh ids
|
||||
* and dropped nodes disappear from the proposed set. That makes id-set
|
||||
* membership a faithful added/removed signal without heuristic matching.
|
||||
*/
|
||||
import type { WorkflowGraph } from './types';
|
||||
|
||||
export interface GraphDiff {
|
||||
/** Node ids present in the proposed graph but not the current one. */
|
||||
addedNodeIds: Set<string>;
|
||||
/** Node ids present in the current graph but dropped from the proposed one. */
|
||||
removedNodeIds: Set<string>;
|
||||
/** True when neither set is empty — i.e. the proposal changes the node set. */
|
||||
hasChanges: boolean;
|
||||
}
|
||||
|
||||
function nodeIds(graph: WorkflowGraph | null | undefined): Set<string> {
|
||||
const ids = new Set<string>();
|
||||
for (const node of graph?.nodes ?? []) {
|
||||
if (node && typeof node.id === 'string') ids.add(node.id);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the node-level diff from `current` to `proposed`. Safe against
|
||||
* missing/partial graphs — an absent side contributes no ids.
|
||||
*/
|
||||
export function diffGraphs(
|
||||
current: WorkflowGraph | null | undefined,
|
||||
proposed: WorkflowGraph | null | undefined
|
||||
): GraphDiff {
|
||||
const currentIds = nodeIds(current);
|
||||
const proposedIds = nodeIds(proposed);
|
||||
|
||||
const addedNodeIds = new Set<string>();
|
||||
for (const id of proposedIds) {
|
||||
if (!currentIds.has(id)) addedNodeIds.add(id);
|
||||
}
|
||||
const removedNodeIds = new Set<string>();
|
||||
for (const id of currentIds) {
|
||||
if (!proposedIds.has(id)) removedNodeIds.add(id);
|
||||
}
|
||||
|
||||
return {
|
||||
addedNodeIds,
|
||||
removedNodeIds,
|
||||
hasChanges: addedNodeIds.size > 0 || removedNodeIds.size > 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the graph shown DURING preview: the proposed graph, plus any removed
|
||||
* nodes (and the edges touching them) carried over from `current` so they can
|
||||
* be rendered ghosted instead of vanishing. Accepting the proposal drops these;
|
||||
* rejecting reverts to `current`.
|
||||
*/
|
||||
export function buildPreviewGraph(
|
||||
current: WorkflowGraph,
|
||||
proposed: WorkflowGraph,
|
||||
removedNodeIds: Set<string>
|
||||
): WorkflowGraph {
|
||||
if (removedNodeIds.size === 0) return proposed;
|
||||
const ghostNodes = current.nodes.filter(n => removedNodeIds.has(n.id));
|
||||
const proposedNodeIds = new Set(proposed.nodes.map(n => n.id));
|
||||
const combinedNodeIds = new Set([...proposedNodeIds, ...removedNodeIds]);
|
||||
// Keep current edges that touch a ghosted node and whose other end still
|
||||
// exists in the combined view, so a removed node still shows its wiring.
|
||||
const ghostEdges = current.edges.filter(
|
||||
e =>
|
||||
(removedNodeIds.has(e.from_node) || removedNodeIds.has(e.to_node)) &&
|
||||
combinedNodeIds.has(e.from_node) &&
|
||||
combinedNodeIds.has(e.to_node)
|
||||
);
|
||||
return {
|
||||
...proposed,
|
||||
nodes: [...proposed.nodes, ...ghostNodes],
|
||||
edges: [...proposed.edges, ...ghostEdges],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Unit tests for `createBlankWorkflowGraph` (Phase 4a "start from scratch").
|
||||
* Locks the starter-graph shape the chooser persists: a single `manual`
|
||||
* trigger, no edges, and structural validity (one trigger, unique ids).
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { BLANK_TRIGGER_NODE_ID, createBlankWorkflowGraph } from './newFlow';
|
||||
|
||||
describe('createBlankWorkflowGraph', () => {
|
||||
it('produces a single manual trigger and no edges', () => {
|
||||
const graph = createBlankWorkflowGraph('My flow', 'Trigger');
|
||||
expect(graph.schema_version).toBe(1);
|
||||
expect(graph.name).toBe('My flow');
|
||||
expect(graph.edges).toEqual([]);
|
||||
expect(graph.nodes).toHaveLength(1);
|
||||
|
||||
const [trigger] = graph.nodes;
|
||||
expect(trigger.id).toBe(BLANK_TRIGGER_NODE_ID);
|
||||
expect(trigger.kind).toBe('trigger');
|
||||
expect(trigger.name).toBe('Trigger');
|
||||
expect(trigger.config).toEqual({ trigger_kind: 'manual' });
|
||||
});
|
||||
|
||||
it('is structurally valid (exactly one trigger, unique ids)', () => {
|
||||
const graph = createBlankWorkflowGraph('x', 'y');
|
||||
const ids = graph.nodes.map(n => n.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
expect(graph.nodes.filter(n => n.kind === 'trigger')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Helpers for authoring a *new* flow from the Phase 4a chooser. Pure and
|
||||
* dependency-free so the blank-graph shape is unit-testable and the create
|
||||
* path (`flows_create`) is the only thing the UI has to wire up.
|
||||
*/
|
||||
import type { WorkflowGraph } from './types';
|
||||
|
||||
/** Stable node id of the starter trigger a "start from scratch" flow ships with. */
|
||||
export const BLANK_TRIGGER_NODE_ID = 'trigger';
|
||||
|
||||
/**
|
||||
* A minimal, structurally-valid `WorkflowGraph` for "start from scratch": a
|
||||
* single `manual` trigger node and no edges. Passes the same
|
||||
* `openhuman.flows_validate` rules the templates do (exactly one trigger,
|
||||
* unique ids, no dangling edges), so `flows_create` accepts it directly.
|
||||
*
|
||||
* `name` is used for both the flow name (passed separately to `flows_create`)
|
||||
* and the graph's own `name`; `triggerName` is the human label shown on the
|
||||
* starter node in the canvas.
|
||||
*/
|
||||
export function createBlankWorkflowGraph(name: string, triggerName: string): WorkflowGraph {
|
||||
return {
|
||||
schema_version: 1,
|
||||
name,
|
||||
nodes: [
|
||||
{
|
||||
id: BLANK_TRIGGER_NODE_ID,
|
||||
kind: 'trigger',
|
||||
name: triggerName,
|
||||
config: { trigger_kind: 'manual' },
|
||||
ports: [],
|
||||
position: { x: 0, y: 0 },
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Per-kind visual metadata for the 12 tinyflows `NodeKind`s, shared by the
|
||||
* canvas node renderer (`FlowNodeComponent`) and the editable canvas's node
|
||||
* palette (`NodePalette`). Kept dependency-free (no React) so both a rendered
|
||||
* `<Handle>`-bearing card and a plain palette button can pull the same
|
||||
* emoji/accent from one source of truth instead of drifting apart.
|
||||
*
|
||||
* Colors cycle through the four CSS-variable-backed semantic ramps
|
||||
* (primary/sage/amber/coral) that support Tailwind's `/opacity` modifiers in
|
||||
* this codebase (see `tailwind.config.js`) so light/dark theming comes for
|
||||
* free; with 12 kinds and 4 ramps some kinds share a color family — the emoji
|
||||
* + name remain the primary distinguishers.
|
||||
*/
|
||||
import type { NodeKind } from './types';
|
||||
|
||||
export type NodeColor = 'sage' | 'primary' | 'amber' | 'coral' | 'neutral';
|
||||
|
||||
export interface NodeKindMeta {
|
||||
emoji: string;
|
||||
color: NodeColor;
|
||||
}
|
||||
|
||||
/**
|
||||
* The 12 `NodeKind`s in the order they should appear in the palette. Trigger
|
||||
* leads (every graph needs exactly one); the rest follow the logical grouping
|
||||
* of the `tinyflows::model::NodeKind` enum.
|
||||
*/
|
||||
export const NODE_KINDS: NodeKind[] = [
|
||||
'trigger',
|
||||
'agent',
|
||||
'tool_call',
|
||||
'http_request',
|
||||
'code',
|
||||
'condition',
|
||||
'switch',
|
||||
'merge',
|
||||
'split_out',
|
||||
'transform',
|
||||
'output_parser',
|
||||
'sub_workflow',
|
||||
];
|
||||
|
||||
/** Per-kind emoji + border/chip color. See the module doc for the color model. */
|
||||
export const NODE_KIND_META: Record<NodeKind, NodeKindMeta> = {
|
||||
trigger: { emoji: '⚡', color: 'sage' },
|
||||
agent: { emoji: '🤖', color: 'primary' },
|
||||
tool_call: { emoji: '🔧', color: 'amber' },
|
||||
http_request: { emoji: '🌐', color: 'coral' },
|
||||
code: { emoji: '📝', color: 'sage' },
|
||||
condition: { emoji: '🔀', color: 'primary' },
|
||||
switch: { emoji: '🔁', color: 'amber' },
|
||||
merge: { emoji: '🔗', color: 'coral' },
|
||||
split_out: { emoji: '📤', color: 'sage' },
|
||||
transform: { emoji: '♻️', color: 'primary' },
|
||||
output_parser: { emoji: '📋', color: 'amber' },
|
||||
sub_workflow: { emoji: '🧩', color: 'coral' },
|
||||
};
|
||||
|
||||
/**
|
||||
* Fallback for any `kind` outside {@link NODE_KIND_META} — a saved graph is
|
||||
* `unknown` on the wire (cast in `FlowCanvasPage.tsx`), so a future 13th
|
||||
* tinyflows kind, or any other value the backend ever emits, can reach the
|
||||
* renderer at runtime even though TypeScript can't see it. Lookups fall back
|
||||
* here so an unrecognized kind renders as a plain neutral node instead of
|
||||
* crashing the whole canvas (there's no error boundary around `<ReactFlow>`).
|
||||
*/
|
||||
export const DEFAULT_NODE_META: NodeKindMeta = { emoji: '❔', color: 'neutral' };
|
||||
|
||||
/** Resolve a kind's metadata, falling back to {@link DEFAULT_NODE_META}. */
|
||||
export function nodeKindMeta(kind: NodeKind): NodeKindMeta {
|
||||
return NODE_KIND_META[kind] ?? DEFAULT_NODE_META;
|
||||
}
|
||||
|
||||
export const COLOR_CLASSES: Record<NodeColor, { border: string; chip: string }> = {
|
||||
sage: {
|
||||
border: 'border-sage-400 dark:border-sage-500/60',
|
||||
chip: 'bg-sage-100 dark:bg-sage-500/20',
|
||||
},
|
||||
primary: {
|
||||
border: 'border-primary-400 dark:border-primary-500/60',
|
||||
chip: 'bg-primary-100 dark:bg-primary-500/20',
|
||||
},
|
||||
amber: {
|
||||
border: 'border-amber-400 dark:border-amber-500/60',
|
||||
chip: 'bg-amber-100 dark:bg-amber-500/20',
|
||||
},
|
||||
coral: {
|
||||
border: 'border-coral-400 dark:border-coral-500/60',
|
||||
chip: 'bg-coral-100 dark:bg-coral-500/20',
|
||||
},
|
||||
neutral: { border: 'border-line-strong', chip: 'bg-surface-subtle' },
|
||||
};
|
||||
|
||||
/** Even vertical offsets (in %) for `count` handles along one side of a card. */
|
||||
export function handleOffsets(count: number): number[] {
|
||||
if (count <= 1) return [50];
|
||||
return Array.from({ length: count }, (_, i) => ((i + 1) / (count + 1)) * 100);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* runItems — normalizer contract for the run inspector's per-item data browser.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { collectColumns, formatCell, hasObjectRows, normalizeItems } from './runItems';
|
||||
|
||||
describe('normalizeItems', () => {
|
||||
it('returns [] for null/undefined output', () => {
|
||||
expect(normalizeItems(null)).toEqual([]);
|
||||
expect(normalizeItems(undefined)).toEqual([]);
|
||||
});
|
||||
|
||||
it('wraps a single non-array value as one item', () => {
|
||||
const items = normalizeItems({ name: 'ada' });
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0]).toEqual({ json: { name: 'ada' }, binary: [], pairedIndex: null });
|
||||
});
|
||||
|
||||
it('normalizes an n8n item array with json/binary/paired_item', () => {
|
||||
const items = normalizeItems([
|
||||
{
|
||||
json: { id: 1 },
|
||||
binary: { file: { fileName: 'a.pdf', mimeType: 'application/pdf' } },
|
||||
paired_item: 0,
|
||||
},
|
||||
{ json: { id: 2 }, paired_item: { item: 1 } },
|
||||
]);
|
||||
expect(items[0].json).toEqual({ id: 1 });
|
||||
expect(items[0].binary).toEqual([
|
||||
{ key: 'file', fileName: 'a.pdf', mimeType: 'application/pdf' },
|
||||
]);
|
||||
expect(items[0].pairedIndex).toBe(0);
|
||||
expect(items[1].pairedIndex).toBe(1);
|
||||
});
|
||||
|
||||
it('resolves an array paired_item to the first index and snake_case binary meta', () => {
|
||||
const items = normalizeItems([
|
||||
{
|
||||
json: {},
|
||||
binary: { doc: { file_name: 'b.txt', mime_type: 'text/plain' } },
|
||||
paired_item: [{ item: 3 }, { item: 4 }],
|
||||
},
|
||||
]);
|
||||
expect(items[0].pairedIndex).toBe(3);
|
||||
expect(items[0].binary[0]).toEqual({ key: 'doc', fileName: 'b.txt', mimeType: 'text/plain' });
|
||||
});
|
||||
|
||||
it('treats a bare object without json as the payload itself', () => {
|
||||
const items = normalizeItems([{ id: 7 }]);
|
||||
expect(items[0]).toEqual({ json: { id: 7 }, binary: [], pairedIndex: null });
|
||||
});
|
||||
|
||||
it('leaves pairedIndex null for absent or malformed paired_item', () => {
|
||||
expect(normalizeItems([{ json: {}, paired_item: 'nope' }])[0].pairedIndex).toBeNull();
|
||||
expect(normalizeItems([{ json: {} }])[0].pairedIndex).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('collectColumns / hasObjectRows', () => {
|
||||
it('unions object json keys in first-seen order', () => {
|
||||
const items = normalizeItems([{ json: { a: 1, b: 2 } }, { json: { b: 3, c: 4 } }]);
|
||||
expect(collectColumns(items)).toEqual(['a', 'b', 'c']);
|
||||
expect(hasObjectRows(items)).toBe(true);
|
||||
});
|
||||
|
||||
it('reports no columns for primitive-only items', () => {
|
||||
const items = normalizeItems(['ok']);
|
||||
expect(collectColumns(items)).toEqual([]);
|
||||
expect(hasObjectRows(items)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatCell', () => {
|
||||
it('renders primitives verbatim and objects compactly', () => {
|
||||
expect(formatCell('x')).toBe('x');
|
||||
expect(formatCell(3)).toBe('3');
|
||||
expect(formatCell(true)).toBe('true');
|
||||
expect(formatCell(null)).toBe('null');
|
||||
expect(formatCell(undefined)).toBe('');
|
||||
expect(formatCell({ a: 1 })).toBe('{"a":1}');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* runItems — normalize a `tinyflows` run step's opaque `output` into the
|
||||
* n8n-style item-array shape the run inspector's per-item data browser
|
||||
* (Phase 6) renders.
|
||||
*
|
||||
* `FlowRunStep.output` is `unknown` on the wire (see `services/api/flowsApi.ts`)
|
||||
* because the durable run record stores whatever the node emitted. The n8n data
|
||||
* model each node produces is an array of items, each `{ json, binary?,
|
||||
* paired_item? }`:
|
||||
* - `json` — the item's data payload (usually an object).
|
||||
* - `binary` — a map of named binary attachments (never inlined in the
|
||||
* UI; shown as placeholder chips).
|
||||
* - `paired_item` — links this output item back to the input item it derived
|
||||
* from, so the inspector can reveal the source input.
|
||||
*
|
||||
* Real runs are messier than that ideal, so this normalizer is deliberately
|
||||
* forgiving: a bare object/primitive, a single item, or a full item array all
|
||||
* normalize into `FlowRunItem[]`. Anything it can't interpret as item-shaped is
|
||||
* treated as a single item whose `json` is the raw value — never throws.
|
||||
*/
|
||||
|
||||
/** A single binary attachment reference (metadata only — bytes never inlined). */
|
||||
export interface FlowBinaryRef {
|
||||
/** Property name of this attachment in the item's `binary` map. */
|
||||
key: string;
|
||||
/** Original file name, if the node recorded one. */
|
||||
fileName?: string;
|
||||
/** MIME type, if the node recorded one. */
|
||||
mimeType?: string;
|
||||
}
|
||||
|
||||
/** One normalized output item of a run step. */
|
||||
export interface FlowRunItem {
|
||||
/** The item's `json` data payload (any JSON value; usually an object). */
|
||||
json: unknown;
|
||||
/** Binary attachments declared on the item (metadata only). */
|
||||
binary: FlowBinaryRef[];
|
||||
/**
|
||||
* Zero-based index of the input item this output derived from, resolved from
|
||||
* `paired_item`, or `null` when the item carries no pairing hint.
|
||||
*/
|
||||
pairedIndex: number | null;
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a `paired_item` field to a single source input index. n8n allows a
|
||||
* bare number, a `{ item }` object, or an array of either (fan-in) — we take
|
||||
* the first resolvable index and ignore the rest (the UI reveals one source).
|
||||
*/
|
||||
function resolvePairedIndex(raw: unknown): number | null {
|
||||
if (typeof raw === 'number' && Number.isInteger(raw) && raw >= 0) return raw;
|
||||
if (isPlainObject(raw)) {
|
||||
const item = raw.item;
|
||||
if (typeof item === 'number' && Number.isInteger(item) && item >= 0) return item;
|
||||
return null;
|
||||
}
|
||||
if (Array.isArray(raw)) {
|
||||
for (const entry of raw) {
|
||||
const resolved = resolvePairedIndex(entry);
|
||||
if (resolved !== null) return resolved;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Parse an item's `binary` map into placeholder-chip metadata. */
|
||||
function parseBinary(raw: unknown): FlowBinaryRef[] {
|
||||
if (!isPlainObject(raw)) return [];
|
||||
return Object.entries(raw).map(([key, value]) => {
|
||||
const meta = isPlainObject(value) ? value : {};
|
||||
const fileName = meta.fileName ?? meta.file_name;
|
||||
const mimeType = meta.mimeType ?? meta.mime_type;
|
||||
return {
|
||||
key,
|
||||
fileName: typeof fileName === 'string' ? fileName : undefined,
|
||||
mimeType: typeof mimeType === 'string' ? mimeType : undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Normalize one raw element into a {@link FlowRunItem}. */
|
||||
function toItem(raw: unknown): FlowRunItem {
|
||||
// Item-shaped: `{ json, binary?, paired_item? }`. `json` present as an own key
|
||||
// is the discriminant — a plain data object without it is treated as the
|
||||
// payload itself (see below).
|
||||
if (isPlainObject(raw) && 'json' in raw) {
|
||||
return {
|
||||
json: raw.json,
|
||||
binary: parseBinary(raw.binary),
|
||||
pairedIndex: resolvePairedIndex(raw.paired_item),
|
||||
};
|
||||
}
|
||||
return { json: raw, binary: [], pairedIndex: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a run step's `output` into an array of {@link FlowRunItem}. Returns
|
||||
* `[]` for `null`/`undefined` output; wraps a single value as one item.
|
||||
*/
|
||||
export function normalizeItems(output: unknown): FlowRunItem[] {
|
||||
if (output === null || output === undefined) return [];
|
||||
if (Array.isArray(output)) return output.map(toItem);
|
||||
return [toItem(output)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Union of the `json` object keys across all items, in first-seen order — the
|
||||
* column set for the table view. Items whose `json` is not a plain object
|
||||
* contribute no columns (they render in a synthetic single-value column).
|
||||
*/
|
||||
export function collectColumns(items: FlowRunItem[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const columns: string[] = [];
|
||||
for (const item of items) {
|
||||
if (!isPlainObject(item.json)) continue;
|
||||
for (const key of Object.keys(item.json)) {
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
columns.push(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
return columns;
|
||||
}
|
||||
|
||||
/** True when at least one item's `json` is a plain object (table has columns). */
|
||||
export function hasObjectRows(items: FlowRunItem[]): boolean {
|
||||
return items.some(item => isPlainObject(item.json));
|
||||
}
|
||||
|
||||
/** Read a single column's value from an item's object `json` (undefined if absent). */
|
||||
export function cellValue(item: FlowRunItem, column: string): unknown {
|
||||
return isPlainObject(item.json) ? item.json[column] : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a value for a table cell: primitives verbatim, objects/arrays as
|
||||
* compact JSON, `undefined` as an empty string (missing column for this item).
|
||||
*/
|
||||
export function formatCell(value: unknown): string {
|
||||
if (value === undefined) return '';
|
||||
if (value === null) return 'null';
|
||||
if (typeof value === 'string') return value;
|
||||
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
/** Pretty-print an item's `json` payload for the JSON view / source panel. */
|
||||
export function formatJson(value: unknown): string {
|
||||
if (value === undefined) return '';
|
||||
if (typeof value === 'string') return value;
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"name": "App event to conditional action",
|
||||
"nodes": [
|
||||
{
|
||||
"id": "trigger",
|
||||
"kind": "trigger",
|
||||
"name": "App event",
|
||||
"config": { "trigger_kind": "app_event", "toolkit": "gmail", "trigger_slug": "new_message" },
|
||||
"ports": [],
|
||||
"position": { "x": 0, "y": 0 }
|
||||
},
|
||||
{
|
||||
"id": "gate",
|
||||
"kind": "condition",
|
||||
"name": "Needs a reply?",
|
||||
"config": { "field": "important" },
|
||||
"ports": [{ "name": "true" }, { "name": "false" }],
|
||||
"position": { "x": 0, "y": 160 }
|
||||
},
|
||||
{
|
||||
"id": "act",
|
||||
"kind": "tool_call",
|
||||
"name": "Take action",
|
||||
"config": { "slug": "", "args": {} },
|
||||
"ports": [],
|
||||
"position": { "x": 0, "y": 320 }
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
{ "from_node": "trigger", "from_port": "main", "to_node": "gate", "to_port": "main" },
|
||||
{ "from_node": "gate", "from_port": "true", "to_node": "act", "to_port": "main" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"name": "Ask the agent",
|
||||
"nodes": [
|
||||
{
|
||||
"id": "trigger",
|
||||
"kind": "trigger",
|
||||
"name": "Run manually",
|
||||
"config": { "trigger_kind": "manual" },
|
||||
"ports": [],
|
||||
"position": { "x": 0, "y": 0 }
|
||||
},
|
||||
{
|
||||
"id": "agent",
|
||||
"kind": "agent",
|
||||
"name": "Do the task",
|
||||
"config": {
|
||||
"prompt": "Describe the task you want the agent to carry out each time this workflow runs."
|
||||
},
|
||||
"ports": [],
|
||||
"position": { "x": 0, "y": 160 }
|
||||
}
|
||||
],
|
||||
"edges": [{ "from_node": "trigger", "from_port": "main", "to_node": "agent", "to_port": "main" }]
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"name": "Daily digest to channel",
|
||||
"nodes": [
|
||||
{
|
||||
"id": "trigger",
|
||||
"kind": "trigger",
|
||||
"name": "Every morning",
|
||||
"config": { "trigger_kind": "schedule", "schedule": "0 9 * * *" },
|
||||
"ports": [],
|
||||
"position": { "x": 0, "y": 0 }
|
||||
},
|
||||
{
|
||||
"id": "summarize",
|
||||
"kind": "agent",
|
||||
"name": "Summarize the day",
|
||||
"config": {
|
||||
"prompt": "Write a short daily digest of what happened across my connected accounts. Keep it under 10 bullet points."
|
||||
},
|
||||
"ports": [],
|
||||
"position": { "x": 0, "y": 160 }
|
||||
},
|
||||
{
|
||||
"id": "notify",
|
||||
"kind": "tool_call",
|
||||
"name": "Post to channel",
|
||||
"config": { "slug": "", "args": {} },
|
||||
"ports": [],
|
||||
"position": { "x": 0, "y": 320 }
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
{ "from_node": "trigger", "from_port": "main", "to_node": "summarize", "to_port": "main" },
|
||||
{ "from_node": "summarize", "from_port": "main", "to_node": "notify", "to_port": "main" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"name": "Fetch and parse an API",
|
||||
"nodes": [
|
||||
{
|
||||
"id": "trigger",
|
||||
"kind": "trigger",
|
||||
"name": "Run manually",
|
||||
"config": { "trigger_kind": "manual" },
|
||||
"ports": [],
|
||||
"position": { "x": 0, "y": 0 }
|
||||
},
|
||||
{
|
||||
"id": "fetch",
|
||||
"kind": "http_request",
|
||||
"name": "Call the API",
|
||||
"config": { "method": "GET", "url": "https://api.example.com/v1/resource", "headers": {} },
|
||||
"ports": [],
|
||||
"position": { "x": 0, "y": 160 }
|
||||
},
|
||||
{
|
||||
"id": "parse",
|
||||
"kind": "output_parser",
|
||||
"name": "Parse the response",
|
||||
"config": {},
|
||||
"ports": [],
|
||||
"position": { "x": 0, "y": 320 }
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
{ "from_node": "trigger", "from_port": "main", "to_node": "fetch", "to_port": "main" },
|
||||
{ "from_node": "fetch", "from_port": "main", "to_node": "parse", "to_port": "main" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Curated `WorkflowGraph` templates (Phase 4c) — bundled as static JSON so the
|
||||
* new-workflow chooser and the Workflows empty-state gallery can offer a
|
||||
* one-click "start from a working example" path. Selecting a template calls
|
||||
* `flows_create` with that template's `graph`; the created flow then opens in
|
||||
* the editable canvas exactly like any other flow.
|
||||
*
|
||||
* Every bundled graph is structurally valid against the same rules
|
||||
* `openhuman.flows_validate` enforces (`tinyflows::validate`): unique node ids,
|
||||
* exactly one `trigger` node, and every edge referencing an existing node
|
||||
* (asserted in `templates.test.ts`). Node configs are realistic starting
|
||||
* points, not finished automations — the user is expected to fill in the
|
||||
* blanks (channel/tool slugs, URLs, prompts) in the canvas after creating.
|
||||
*
|
||||
* NO dynamic imports (repo rule): each template is a static `import` of its
|
||||
* `.json` file. Display strings (name / description / category) are NOT stored
|
||||
* here — they are i18n'd via `flows.templates.<id>.name` /
|
||||
* `flows.templates.<id>.description` / `flows.templates.category.<category>`
|
||||
* keys so the gallery never hardcodes English in JSX. This module only carries
|
||||
* the stable `id`, the `category` grouping, and the `graph` itself.
|
||||
*/
|
||||
import type { WorkflowGraph } from '../types';
|
||||
import appEventRoute from './app-event-route.json';
|
||||
import askAgent from './ask-agent.json';
|
||||
import dailyDigest from './daily-digest.json';
|
||||
import httpFetchParse from './http-fetch-parse.json';
|
||||
import scheduledScrape from './scheduled-scrape.json';
|
||||
import webhookTriage from './webhook-triage.json';
|
||||
|
||||
/**
|
||||
* Grouping shown as a section header / badge in the gallery. Each value has a
|
||||
* matching `flows.templates.category.<value>` i18n key.
|
||||
*/
|
||||
export type FlowTemplateCategory = 'scheduled' | 'triggered' | 'onDemand';
|
||||
|
||||
/**
|
||||
* One curated template. `id` is stable (used to derive its i18n display keys
|
||||
* and as a React key); `graph` is the ready-to-create `WorkflowGraph`. The
|
||||
* human-readable name/description are resolved at render time from i18n, so
|
||||
* they intentionally do not live on this object.
|
||||
*/
|
||||
export interface FlowTemplate {
|
||||
id: string;
|
||||
category: FlowTemplateCategory;
|
||||
graph: WorkflowGraph;
|
||||
}
|
||||
|
||||
/**
|
||||
* The curated set, in gallery display order. Casting each imported JSON to
|
||||
* `WorkflowGraph` is safe because `templates.test.ts` asserts every graph is
|
||||
* structurally valid (single trigger, unique ids, edges reference real nodes).
|
||||
*/
|
||||
export const FLOW_TEMPLATES: FlowTemplate[] = [
|
||||
{ id: 'daily-digest', category: 'scheduled', graph: dailyDigest as WorkflowGraph },
|
||||
{ id: 'scheduled-scrape', category: 'scheduled', graph: scheduledScrape as WorkflowGraph },
|
||||
{ id: 'webhook-triage', category: 'triggered', graph: webhookTriage as WorkflowGraph },
|
||||
{ id: 'app-event-route', category: 'triggered', graph: appEventRoute as WorkflowGraph },
|
||||
{ id: 'http-fetch-parse', category: 'onDemand', graph: httpFetchParse as WorkflowGraph },
|
||||
{ id: 'ask-agent', category: 'onDemand', graph: askAgent as WorkflowGraph },
|
||||
];
|
||||
|
||||
/** i18n key for a template's display name (`flows.templates.<id>.name`). */
|
||||
export function templateNameKey(id: string): string {
|
||||
return `flows.templates.${id}.name`;
|
||||
}
|
||||
|
||||
/** i18n key for a template's description (`flows.templates.<id>.description`). */
|
||||
export function templateDescriptionKey(id: string): string {
|
||||
return `flows.templates.${id}.description`;
|
||||
}
|
||||
|
||||
/** i18n key for a category label (`flows.templates.category.<category>`). */
|
||||
export function templateCategoryKey(category: FlowTemplateCategory): string {
|
||||
return `flows.templates.category.${category}`;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"name": "Scheduled scrape to memory",
|
||||
"nodes": [
|
||||
{
|
||||
"id": "trigger",
|
||||
"kind": "trigger",
|
||||
"name": "Every 6 hours",
|
||||
"config": { "trigger_kind": "schedule", "schedule": "0 */6 * * *" },
|
||||
"ports": [],
|
||||
"position": { "x": 0, "y": 0 }
|
||||
},
|
||||
{
|
||||
"id": "fetch",
|
||||
"kind": "http_request",
|
||||
"name": "Fetch source",
|
||||
"config": { "method": "GET", "url": "https://example.com/feed.json", "headers": {} },
|
||||
"ports": [],
|
||||
"position": { "x": 0, "y": 160 }
|
||||
},
|
||||
{
|
||||
"id": "shape",
|
||||
"kind": "transform",
|
||||
"name": "Shape the item",
|
||||
"config": { "set": { "title": "=item.title", "summary": "=item.summary" } },
|
||||
"ports": [],
|
||||
"position": { "x": 0, "y": 320 }
|
||||
},
|
||||
{
|
||||
"id": "remember",
|
||||
"kind": "tool_call",
|
||||
"name": "Store in memory",
|
||||
"config": { "slug": "", "args": {} },
|
||||
"ports": [],
|
||||
"position": { "x": 0, "y": 480 }
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
{ "from_node": "trigger", "from_port": "main", "to_node": "fetch", "to_port": "main" },
|
||||
{ "from_node": "fetch", "from_port": "main", "to_node": "shape", "to_port": "main" },
|
||||
{ "from_node": "shape", "from_port": "main", "to_node": "remember", "to_port": "main" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Structural validity tests for the bundled Phase 4c templates. Mirrors the
|
||||
* exact checks `tinyflows::validate` (and therefore `openhuman.flows_validate`)
|
||||
* enforces — unique node ids, exactly one `trigger` node, every edge endpoint
|
||||
* referencing an existing node — so a template can never ship in a shape the
|
||||
* backend would reject at `flows_create` time. Also asserts each template
|
||||
* carries the i18n keys the gallery renders it with, and that ids are unique.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import en from '../../i18n/en';
|
||||
import type { WorkflowGraph } from '../types';
|
||||
import {
|
||||
FLOW_TEMPLATES,
|
||||
templateCategoryKey,
|
||||
templateDescriptionKey,
|
||||
templateNameKey,
|
||||
} from './index';
|
||||
|
||||
/** The structural subset of `tinyflows::validate` this frontend can assert. */
|
||||
function structuralErrors(graph: WorkflowGraph): string[] {
|
||||
const errors: string[] = [];
|
||||
|
||||
const ids = graph.nodes.map(n => n.id);
|
||||
const seen = new Set<string>();
|
||||
for (const id of ids) {
|
||||
if (seen.has(id)) errors.push(`duplicate node id: ${id}`);
|
||||
seen.add(id);
|
||||
}
|
||||
|
||||
const triggers = graph.nodes.filter(n => n.kind === 'trigger');
|
||||
if (triggers.length === 0) errors.push('missing trigger node');
|
||||
if (triggers.length > 1) errors.push(`multiple trigger nodes: ${triggers.length}`);
|
||||
|
||||
const idSet = new Set(ids);
|
||||
for (const edge of graph.edges) {
|
||||
if (!idSet.has(edge.from_node)) errors.push(`edge from unknown node: ${edge.from_node}`);
|
||||
if (!idSet.has(edge.to_node)) errors.push(`edge to unknown node: ${edge.to_node}`);
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
describe('flow templates', () => {
|
||||
it('ships at least five curated templates', () => {
|
||||
expect(FLOW_TEMPLATES.length).toBeGreaterThanOrEqual(5);
|
||||
});
|
||||
|
||||
it('has a unique id for every template', () => {
|
||||
const ids = FLOW_TEMPLATES.map(t => t.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
|
||||
it.each(FLOW_TEMPLATES.map(t => [t.id, t] as const))(
|
||||
'template %s is a structurally valid WorkflowGraph',
|
||||
(_id, template) => {
|
||||
const graph = template.graph;
|
||||
expect(graph.schema_version).toBe(1);
|
||||
expect(Array.isArray(graph.nodes)).toBe(true);
|
||||
expect(Array.isArray(graph.edges)).toBe(true);
|
||||
// Exactly one trigger, unique ids, edges reference existing nodes.
|
||||
expect(structuralErrors(graph)).toEqual([]);
|
||||
// Precisely one trigger (belt-and-suspenders over the helper above).
|
||||
expect(graph.nodes.filter(n => n.kind === 'trigger')).toHaveLength(1);
|
||||
}
|
||||
);
|
||||
|
||||
it.each(FLOW_TEMPLATES.map(t => [t.id, t] as const))(
|
||||
'template %s has i18n name/description/category keys',
|
||||
(id, template) => {
|
||||
const dict = en as Record<string, string>;
|
||||
expect(dict[templateNameKey(id)]).toBeTruthy();
|
||||
expect(dict[templateDescriptionKey(id)]).toBeTruthy();
|
||||
expect(dict[templateCategoryKey(template.category)]).toBeTruthy();
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"name": "Webhook to agent triage",
|
||||
"nodes": [
|
||||
{
|
||||
"id": "trigger",
|
||||
"kind": "trigger",
|
||||
"name": "Incoming webhook",
|
||||
"config": { "trigger_kind": "webhook" },
|
||||
"ports": [],
|
||||
"position": { "x": 0, "y": 0 }
|
||||
},
|
||||
{
|
||||
"id": "triage",
|
||||
"kind": "agent",
|
||||
"name": "Triage the payload",
|
||||
"config": {
|
||||
"prompt": "Read the incoming webhook payload and decide how urgent it is. Summarize what it is about and what action, if any, is needed."
|
||||
},
|
||||
"ports": [],
|
||||
"position": { "x": 0, "y": 160 }
|
||||
},
|
||||
{
|
||||
"id": "notify",
|
||||
"kind": "tool_call",
|
||||
"name": "Notify me",
|
||||
"config": { "slug": "", "args": {} },
|
||||
"ports": [],
|
||||
"position": { "x": 0, "y": 320 }
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
{ "from_node": "trigger", "from_port": "main", "to_node": "triage", "to_port": "main" },
|
||||
{ "from_node": "triage", "from_port": "main", "to_node": "notify", "to_port": "main" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { WorkflowGraph } from './types';
|
||||
import { buildCreatePrompt, buildRepairPrompt, buildRevisePrompt } from './workflowBuilderPrompt';
|
||||
|
||||
const graph: WorkflowGraph = {
|
||||
schema_version: 1,
|
||||
name: 'g',
|
||||
nodes: [{ id: 'a', kind: 'trigger', name: 'Start', config: {}, ports: [] }],
|
||||
edges: [],
|
||||
};
|
||||
|
||||
describe('buildCreatePrompt', () => {
|
||||
it('includes the description and asks only for a proposal (never persist)', () => {
|
||||
const p = buildCreatePrompt(' email me new Slack messages ');
|
||||
expect(p).toContain('email me new Slack messages');
|
||||
expect(p.toLowerCase()).toContain('workflow builder');
|
||||
expect(p.toLowerCase()).toContain('do not save');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildRevisePrompt', () => {
|
||||
it('injects the current graph JSON and the instruction', () => {
|
||||
const p = buildRevisePrompt('add a Slack notification on failure', graph);
|
||||
expect(p).toContain('add a Slack notification on failure');
|
||||
expect(p).toContain(JSON.stringify(graph));
|
||||
expect(p.toLowerCase()).toContain('revise');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildRepairPrompt', () => {
|
||||
it('references the run, error, and failing nodes and injects the graph', () => {
|
||||
const p = buildRepairPrompt({
|
||||
runId: 'run-9',
|
||||
error: 'HTTP 500 from webhook',
|
||||
failingNodeIds: ['n2'],
|
||||
graph,
|
||||
});
|
||||
expect(p).toContain('run-9');
|
||||
expect(p).toContain('get_flow_run');
|
||||
expect(p).toContain('HTTP 500 from webhook');
|
||||
expect(p).toContain('n2');
|
||||
expect(p).toContain(JSON.stringify(graph));
|
||||
});
|
||||
|
||||
it('omits the error/nodes lines when absent', () => {
|
||||
const p = buildRepairPrompt({ runId: 'run-9', graph });
|
||||
expect(p).toContain('run-9');
|
||||
expect(p).not.toContain('Run error:');
|
||||
expect(p).not.toContain('Failing step node');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* workflowBuilderPrompt (Phase 5c) — builds the natural-language turn text that
|
||||
* routes a chat turn to the `workflow_builder` specialist agent.
|
||||
*
|
||||
* There is no UI affordance to target a named agent for a turn: `chatSend`
|
||||
* carries only a thread + optional model/behaviour `profile_id`, and the core
|
||||
* always runs the turn through the orchestrator. The orchestrator's
|
||||
* `build_workflow` delegation edge routes any "build/automate/when-X-do-Y"
|
||||
* request to `workflow_builder` (see its `when_to_use` in
|
||||
* `agent_registry/agents/workflow_builder/agent.toml`). So instead of routing
|
||||
* directly, we phrase the turn so that delegation fires deterministically and
|
||||
* the specialist ends its turn by calling `propose_workflow` / `revise_workflow`
|
||||
* — the runtime then surfaces the returned proposal as a `WorkflowProposalCard`.
|
||||
*
|
||||
* Every builder here keeps the "propose, never persist" invariant: the prompts
|
||||
* ask for a PROPOSAL only. Saving/enabling stays behind the explicit
|
||||
* `WorkflowProposalCard` "Save & enable" click; nothing here can reach
|
||||
* `flows_create`/`flows_update`/`set_enabled`.
|
||||
*/
|
||||
import type { WorkflowGraph } from './types';
|
||||
|
||||
/** A leading directive that reliably trips the `build_workflow` delegation. */
|
||||
const DELEGATE_DIRECTIVE =
|
||||
'Use the workflow builder to design a tinyflows automation and return a workflow proposal for me to review. Do not save, enable, or run anything.';
|
||||
|
||||
/** Serialize a graph compactly for injection as agent context. */
|
||||
function serializeGraph(graph: WorkflowGraph): string {
|
||||
try {
|
||||
return JSON.stringify(graph);
|
||||
} catch {
|
||||
return '{}';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* First-draft prompt for the Flows prompt bar. `description` is the user's
|
||||
* free-text ask ("email me a digest of new Slack messages every morning").
|
||||
*/
|
||||
export function buildCreatePrompt(description: string): string {
|
||||
const trimmed = description.trim();
|
||||
return `${DELEGATE_DIRECTIVE}\n\nBuild a workflow that does this:\n${trimmed}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterative-refine prompt for the canvas copilot. Injects the CURRENT draft
|
||||
* graph so the specialist revises it in place (via `revise_workflow`) rather
|
||||
* than starting over. `instruction` is the user's change request ("add a Slack
|
||||
* notification on failure", "make the schedule weekdays only").
|
||||
*/
|
||||
export function buildRevisePrompt(instruction: string, graph: WorkflowGraph): string {
|
||||
const trimmed = instruction.trim();
|
||||
return [
|
||||
DELEGATE_DIRECTIVE,
|
||||
'',
|
||||
'Here is the current workflow draft (tinyflows WorkflowGraph JSON):',
|
||||
'```json',
|
||||
serializeGraph(graph),
|
||||
'```',
|
||||
'',
|
||||
'Revise it as follows and return the full revised proposal:',
|
||||
trimmed,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/** Context for a repair turn opened from a failed run's inspector. */
|
||||
export interface RepairPromptContext {
|
||||
/** The failed run id (== thread_id) so the agent can `get_flow_run` it. */
|
||||
runId: string;
|
||||
/** The run-level error message, if any. */
|
||||
error?: string | null;
|
||||
/** Node ids that failed / are implicated, if known. */
|
||||
failingNodeIds?: string[];
|
||||
/** The flow's current graph, injected so the fix builds on the real draft. */
|
||||
graph: WorkflowGraph;
|
||||
}
|
||||
|
||||
/**
|
||||
* Repair prompt for "Fix with agent". Preloads the failing run + step context
|
||||
* so the specialist reads the run (`get_flow_run`), diagnoses the failure, and
|
||||
* proposes a corrected graph.
|
||||
*/
|
||||
export function buildRepairPrompt(ctx: RepairPromptContext): string {
|
||||
const parts = [
|
||||
DELEGATE_DIRECTIVE,
|
||||
'',
|
||||
`A run of this workflow failed (run id: ${ctx.runId}). Read the run with get_flow_run, diagnose why it failed, and propose a fix.`,
|
||||
];
|
||||
if (ctx.error && ctx.error.trim().length > 0) {
|
||||
parts.push('', `Run error: ${ctx.error.trim()}`);
|
||||
}
|
||||
if (ctx.failingNodeIds && ctx.failingNodeIds.length > 0) {
|
||||
parts.push('', `Failing step node id(s): ${ctx.failingNodeIds.join(', ')}`);
|
||||
}
|
||||
parts.push(
|
||||
'',
|
||||
'Here is the current workflow draft (tinyflows WorkflowGraph JSON):',
|
||||
'```json',
|
||||
serializeGraph(ctx.graph),
|
||||
'```',
|
||||
'',
|
||||
'Return the full corrected proposal.'
|
||||
);
|
||||
return parts.join('\n');
|
||||
}
|
||||
@@ -3053,6 +3053,7 @@ const messages: TranslationMap = {
|
||||
'chat.flowProposal.requireApprovalHint': 'سيتطلب كل إجراء صادر موافقتك.',
|
||||
'chat.flowProposal.save': 'حفظ وتفعيل',
|
||||
'chat.flowProposal.saving': 'جارٍ الحفظ…',
|
||||
'chat.flowProposal.openInCanvas': 'فتح في اللوحة',
|
||||
'chat.flowProposal.dismiss': 'إغلاق',
|
||||
'chat.flowProposal.error': 'تعذّر حفظ سير العمل. حاول مرة أخرى.',
|
||||
'channels.authMode.managed_dm': 'قم بتسجيل الدخول باستخدام OpenHuman',
|
||||
@@ -3598,6 +3599,17 @@ const messages: TranslationMap = {
|
||||
'flowRuns.inspector.port': 'المنفذ',
|
||||
'flowRuns.inspector.loading': 'جارٍ تحميل التشغيل…',
|
||||
'flowRuns.inspector.loadError': 'تعذّر تحميل هذا التشغيل',
|
||||
'flowRuns.inspector.fixWithAgent': 'إصلاح بمساعدة الوكيل',
|
||||
'flowRuns.inspector.dataTable': 'جدول',
|
||||
'flowRuns.inspector.dataJson': 'JSON',
|
||||
'flowRuns.inspector.dataViewLabel': 'عرض المخرجات',
|
||||
'flowRuns.inspector.itemCount': '{count} عنصر',
|
||||
'flowRuns.inspector.noItems': 'لا توجد عناصر مخرجات',
|
||||
'flowRuns.inspector.emptyValue': '(فارغ)',
|
||||
'flowRuns.inspector.binaryLabel': 'ثنائي',
|
||||
'flowRuns.inspector.showSource': 'المصدر',
|
||||
'flowRuns.inspector.hideSource': 'إخفاء المصدر',
|
||||
'flowRuns.inspector.sourceInputTitle': 'عنصر الإدخال المصدر',
|
||||
'flowRuns.status.running': 'قيد التشغيل',
|
||||
'flowRuns.status.completed': 'مكتمل',
|
||||
'flowRuns.status.pending_approval': 'بانتظار الموافقة',
|
||||
@@ -3629,11 +3641,44 @@ const messages: TranslationMap = {
|
||||
'flows.runs.loading': 'جارٍ تحميل عمليات التشغيل…',
|
||||
'flows.runs.loadError': 'تعذّر تحميل عمليات التشغيل',
|
||||
'flows.runs.empty': 'لا توجد عمليات تشغيل بعد',
|
||||
'flows.promptBar.label': 'صف سير عمل',
|
||||
'flows.promptBar.placeholder': 'صف سير عمل…',
|
||||
'flows.promptBar.submit': 'إنشاء',
|
||||
'flows.promptBar.thinking': 'جارٍ الإنشاء…',
|
||||
'flows.promptBar.heroTitle': 'صف سير عمل',
|
||||
'flows.promptBar.heroSubtitle': 'أخبر المُنشئ بما يجب أتمتته وراجع اقتراحه.',
|
||||
'flows.promptBar.error': 'تعذّر الوصول إلى مُنشئ سير العمل. يرجى المحاولة مرة أخرى.',
|
||||
'flows.promptBar.offline': 'أنت غير متصل. أعد الاتصال لإنشاء سير عمل.',
|
||||
'flows.copilot.open': 'المساعد',
|
||||
'flows.copilot.title': 'مساعد سير العمل',
|
||||
'flows.copilot.subtitle': 'اطلب تغييرات وراجع كل اقتراح قبل تطبيقه.',
|
||||
'flows.copilot.close': 'إغلاق المساعد',
|
||||
'flows.copilot.placeholder': 'اطلب تغييرًا…',
|
||||
'flows.copilot.send': 'إرسال',
|
||||
'flows.copilot.thinking': 'يفكر…',
|
||||
'flows.copilot.error': 'تعذّر الوصول إلى مُنشئ سير العمل. يرجى المحاولة مرة أخرى.',
|
||||
'flows.copilot.offline': 'أنت غير متصل. أعد الاتصال لاستخدام المساعد.',
|
||||
'flows.copilot.emptyState': 'صف تغييرًا على سير العمل هذا وسيقترح المُنشئ تحديثًا.',
|
||||
'flows.copilot.proposalTitle': 'التغييرات المقترحة',
|
||||
'flows.copilot.added': 'أُضيف {count}',
|
||||
'flows.copilot.removed': 'أُزيل {count}',
|
||||
'flows.copilot.noChanges': 'هذا الاقتراح لا يغيّر أي عقدة.',
|
||||
'flows.copilot.accept': 'تطبيق على المسودة',
|
||||
'flows.copilot.reject': 'تجاهل',
|
||||
'flows.copilot.previewHint': 'جارٍ مراجعة مسودة مقترحة — لم يُحفظ شيء بعد.',
|
||||
'flows.copilot.repairDisplay': 'فشل تشغيل؛ راجعه واقترح إصلاحًا.',
|
||||
'flows.list.view': 'عرض سير العمل',
|
||||
'flows.list.export': 'تصدير',
|
||||
'flows.list.exported': 'تم تصدير سير العمل',
|
||||
'flows.page.import': 'استيراد',
|
||||
'flows.import.invalidFile': 'هذا الملف ليس ملف JSON صالحًا لسير العمل.',
|
||||
'flows.import.error': 'تعذّر استيراد سير العمل هذا. تحقق من الملف وحاول مرة أخرى.',
|
||||
'flows.import.warningTitle': 'تحذير الاستيراد',
|
||||
'flows.canvas.title': 'سير العمل',
|
||||
'flows.canvas.loading': 'جارٍ تحميل سير العمل…',
|
||||
'flows.canvas.loadError': 'تعذّر تحميل سير العمل هذا. يرجى المحاولة مرة أخرى.',
|
||||
'flows.canvas.notFound': 'تعذّر العثور على سير العمل هذا.',
|
||||
'flows.canvas.draftMissing': 'لا توجد مسودة سير عمل لفتحها. اقترح واحدة من الدردشة أولاً.',
|
||||
'flows.canvas.backToList': 'العودة إلى قائمة سير العمل',
|
||||
'flows.nodeKind.trigger': 'المُشغِّل',
|
||||
'flows.nodeKind.agent': 'الوكيل',
|
||||
@@ -3647,6 +3692,114 @@ const messages: TranslationMap = {
|
||||
'flows.nodeKind.transform': 'التحويل',
|
||||
'flows.nodeKind.output_parser': 'محلل المخرجات',
|
||||
'flows.nodeKind.sub_workflow': 'سير عمل فرعي',
|
||||
'flows.palette.title': 'العقد',
|
||||
'flows.palette.addNode': 'إضافة عقدة {kind}',
|
||||
'flows.editor.save': 'حفظ',
|
||||
'flows.editor.deleteSelected': 'حذف المحدد',
|
||||
// ── Validation UX + draft/dirty state (issue B5b / Phase 3c–3d)
|
||||
'flows.editor.saving': 'جارٍ الحفظ…',
|
||||
'flows.editor.run': 'تشغيل',
|
||||
'flows.editor.running': 'جارٍ التشغيل…',
|
||||
'flows.editor.runFailed': 'تعذّر بدء التشغيل',
|
||||
'flows.editor.validate': 'تحقّق',
|
||||
'flows.editor.validating': 'جارٍ التحقّق…',
|
||||
'flows.editor.discard': 'تجاهل التغييرات',
|
||||
'flows.editor.unsaved': 'تغييرات غير محفوظة',
|
||||
'flows.editor.saveBlocked': 'أصلِح الأخطاء أدناه قبل الحفظ.',
|
||||
'flows.editor.errorsTitle': 'أخطاء',
|
||||
'flows.editor.warningsTitle': 'تحذيرات',
|
||||
'flows.editor.saveFailedTitle': 'تعذّر الحفظ',
|
||||
'flows.editor.leaveTitle': 'المغادرة دون حفظ؟',
|
||||
'flows.editor.leaveBody': 'لديك تغييرات غير محفوظة على سير العمل هذا. إذا غادرت الآن، فستُفقد.',
|
||||
'flows.editor.leaveStay': 'ابقَ',
|
||||
'flows.editor.leaveDiscard': 'غادر',
|
||||
// ── Node config drawer (issue B5b / Phase 3b) — per-kind config forms
|
||||
'flows.nodeConfig.close': 'إغلاق الإعدادات',
|
||||
'flows.nodeConfig.nameLabel': 'الاسم',
|
||||
'flows.nodeConfig.namePlaceholder': 'اسم العقدة',
|
||||
'flows.nodeConfig.editForm': 'تحرير كنموذج',
|
||||
'flows.nodeConfig.editJson': 'تحرير كـ JSON',
|
||||
'flows.nodeConfig.rawJsonLabel': 'الإعدادات (JSON)',
|
||||
'flows.nodeConfig.rawJsonHint': 'إعدادات حرة لهذه العقدة.',
|
||||
'flows.nodeConfig.rawJsonInvalid': 'JSON غير صالح — لا تُطبَّق التغييرات حتى يتم تحليله بنجاح.',
|
||||
'flows.nodeConfig.expressionHint': 'ابدأ بـ = لحساب القيمة من مدخل العقدة، مثل =item.url',
|
||||
'flows.nodeConfig.expressionBadge': 'تعبير',
|
||||
'flows.nodeConfig.keymapKeyPlaceholder': 'المفتاح',
|
||||
'flows.nodeConfig.keymapValuePlaceholder': 'القيمة',
|
||||
'flows.nodeConfig.keymapRemove': 'إزالة الصف',
|
||||
'flows.nodeConfig.keymapAdd': 'إضافة صف',
|
||||
'flows.nodeConfig.credentialLabel': 'بيانات الاعتماد',
|
||||
'flows.nodeConfig.credentialHint': 'اختر حسابًا متصلاً أو بيانات اعتماد لهذه العقدة.',
|
||||
'flows.nodeConfig.credentialEmpty': 'لا توجد بيانات اعتماد متصلة متاحة.',
|
||||
'flows.nodeConfig.credentialNone': 'لا شيء',
|
||||
'flows.nodeConfig.trigger.kindLabel': 'نوع المشغِّل',
|
||||
'flows.nodeConfig.trigger.kind_manual': 'يدوي',
|
||||
'flows.nodeConfig.trigger.kind_schedule': 'جدولة',
|
||||
'flows.nodeConfig.trigger.kind_webhook': 'ويب هوك',
|
||||
'flows.nodeConfig.trigger.kind_app_event': 'حدث التطبيق',
|
||||
'flows.nodeConfig.trigger.scheduleLabel': 'جدولة cron',
|
||||
'flows.nodeConfig.trigger.scheduleHint': 'تعبير cron: الدقيقة الساعة اليوم الشهر يوم الأسبوع.',
|
||||
'flows.nodeConfig.trigger.toolkitLabel': 'مجموعة الأدوات',
|
||||
'flows.nodeConfig.trigger.triggerSlugLabel': 'معرّف المشغِّل',
|
||||
'flows.nodeConfig.trigger.webhookHint': 'تُحفَظ مشغِّلات الويب هوك لكنها لا تعمل تلقائيًا بعد.',
|
||||
'flows.nodeConfig.http.methodLabel': 'الطريقة',
|
||||
'flows.nodeConfig.http.urlLabel': 'URL',
|
||||
'flows.nodeConfig.http.headersLabel': 'الترويسات',
|
||||
'flows.nodeConfig.http.bodyLabel': 'المحتوى (JSON)',
|
||||
'flows.nodeConfig.agent.promptLabel': 'المُوجِّه',
|
||||
'flows.nodeConfig.agent.promptPlaceholder': 'تعليمات للوكيل…',
|
||||
'flows.nodeConfig.agent.modelLabel': 'النموذج',
|
||||
'flows.nodeConfig.tool.slugLabel': 'معرّف الأداة',
|
||||
'flows.nodeConfig.tool.argsLabel': 'الوسائط (JSON)',
|
||||
'flows.nodeConfig.condition.fieldLabel': 'الحقل',
|
||||
'flows.nodeConfig.condition.fieldHint':
|
||||
'مفتاح عنصر المدخل لاختبار صحته. يوجِّه إلى true أو false.',
|
||||
'flows.nodeConfig.switch.expressionLabel': 'تعبير',
|
||||
'flows.nodeConfig.switch.hint':
|
||||
'تختار القيمة الناتجة منفذ الإخراج المطابق؛ يذهب null إلى default.',
|
||||
'flows.nodeConfig.switch.fieldLabel': 'الحقل (احتياطي)',
|
||||
'flows.nodeConfig.transform.setLabel': 'تعيين الحقول',
|
||||
'flows.nodeConfig.transform.setHint': 'كل قيمة تعبير يُقيَّم لكل عنصر، مثل =item.name',
|
||||
'flows.nodeConfig.code.languageLabel': 'اللغة',
|
||||
'flows.nodeConfig.code.language_javascript': 'JavaScript',
|
||||
'flows.nodeConfig.code.language_python': 'Python',
|
||||
'flows.nodeConfig.code.sourceLabel': 'الكود المصدري',
|
||||
|
||||
'flows.chooser.title': 'إنشاء سير عمل',
|
||||
'flows.chooser.subtitle': 'اختر كيف تريد أن تبدأ.',
|
||||
'flows.chooser.scratchTitle': 'ابدأ من الصفر',
|
||||
'flows.chooser.scratchDescription': 'ابدأ بلوحة فارغة ومشغّل يدوي واحد.',
|
||||
'flows.chooser.templateTitle': 'من قالب',
|
||||
'flows.chooser.templateDescription': 'ابدأ من مثال جاهز وخصّصه.',
|
||||
'flows.chooser.describeTitle': 'صِفه',
|
||||
'flows.chooser.describeDescription': 'أخبر المساعد بما تريد ودعه يصيغ سير العمل.',
|
||||
'flows.chooser.creating': 'جارٍ إنشاء سير العمل…',
|
||||
'flows.chooser.createError': 'تعذّر إنشاء سير العمل. يرجى المحاولة مرة أخرى.',
|
||||
'flows.templates.title': 'ابدأ من قالب',
|
||||
'flows.templates.subtitle': 'اختر نقطة بداية وخصّصها في المحرّر.',
|
||||
'flows.templates.use': 'استخدام القالب',
|
||||
'flows.templates.back': 'رجوع',
|
||||
'flows.templates.empty': 'لا توجد قوالب متاحة.',
|
||||
'flows.templates.category.scheduled': 'مجدول',
|
||||
'flows.templates.category.triggered': 'مُشغَّل',
|
||||
'flows.templates.category.onDemand': 'عند الطلب',
|
||||
'flows.templates.daily-digest.name': 'ملخص يومي إلى قناة',
|
||||
'flows.templates.daily-digest.description':
|
||||
'وفق جدول زمني، يكتب وكيل ملخصًا قصيرًا وينشره في قناة.',
|
||||
'flows.templates.scheduled-scrape.name': 'استخراج مجدول إلى الذاكرة',
|
||||
'flows.templates.scheduled-scrape.description':
|
||||
'اجلب مصدرًا وفق جدول زمني، وأعد تشكيل النتائج، واحفظها في الذاكرة.',
|
||||
'flows.templates.webhook-triage.name': 'فرز الويب هوك والإشعار',
|
||||
'flows.templates.webhook-triage.description':
|
||||
'يُفرز الويب هوك الوارد بواسطة وكيل، ثم يصلك إشعار.',
|
||||
'flows.templates.app-event-route.name': 'حدث تطبيق إلى إجراء شرطي',
|
||||
'flows.templates.app-event-route.description':
|
||||
'يشغّل حدث من تطبيق متصل فحصًا، ثم ينفّذ إجراءً عند التطابق.',
|
||||
'flows.templates.http-fetch-parse.name': 'جلب واجهة API وتحليلها',
|
||||
'flows.templates.http-fetch-parse.description':
|
||||
'استدعِ نقطة نهاية HTTP عند الطلب وحلّل الاستجابة إلى شكل قابل للاستخدام.',
|
||||
'flows.templates.ask-agent.name': 'اسأل الوكيل',
|
||||
'flows.templates.ask-agent.description': 'مشغّل يدوي بسيط يسلّم مهمة إلى وكيل.',
|
||||
|
||||
'oauth.button.connecting': 'جارٍ الاتصال...',
|
||||
'oauth.button.loopbackTimeout':
|
||||
|
||||
@@ -3120,6 +3120,7 @@ const messages: TranslationMap = {
|
||||
'প্রতিটি বহির্গামী কাজের জন্য আপনার অনুমোদন প্রয়োজন হবে।',
|
||||
'chat.flowProposal.save': 'সংরক্ষণ ও সক্রিয় করুন',
|
||||
'chat.flowProposal.saving': 'সংরক্ষণ করা হচ্ছে…',
|
||||
'chat.flowProposal.openInCanvas': 'ক্যানভাসে খুলুন',
|
||||
'chat.flowProposal.dismiss': 'খারিজ করুন',
|
||||
'chat.flowProposal.error': 'ওয়ার্কফ্লো সংরক্ষণ করা যায়নি। আবার চেষ্টা করুন।',
|
||||
'channels.authMode.managed_dm': 'OpenHuman দিয়ে লগইন করুন',
|
||||
@@ -3681,6 +3682,17 @@ const messages: TranslationMap = {
|
||||
'flowRuns.inspector.port': 'পোর্ট',
|
||||
'flowRuns.inspector.loading': 'রান লোড হচ্ছে…',
|
||||
'flowRuns.inspector.loadError': 'এই রানটি লোড করা যায়নি',
|
||||
'flowRuns.inspector.fixWithAgent': 'এজেন্ট দিয়ে ঠিক করুন',
|
||||
'flowRuns.inspector.dataTable': 'টেবিল',
|
||||
'flowRuns.inspector.dataJson': 'JSON',
|
||||
'flowRuns.inspector.dataViewLabel': 'আউটপুট ভিউ',
|
||||
'flowRuns.inspector.itemCount': '{count}টি আইটেম',
|
||||
'flowRuns.inspector.noItems': 'কোনো আউটপুট আইটেম নেই',
|
||||
'flowRuns.inspector.emptyValue': '(খালি)',
|
||||
'flowRuns.inspector.binaryLabel': 'বাইনারি',
|
||||
'flowRuns.inspector.showSource': 'উৎস',
|
||||
'flowRuns.inspector.hideSource': 'উৎস লুকান',
|
||||
'flowRuns.inspector.sourceInputTitle': 'উৎস ইনপুট আইটেম',
|
||||
'flowRuns.status.running': 'চলছে',
|
||||
'flowRuns.status.completed': 'সম্পন্ন',
|
||||
'flowRuns.status.pending_approval': 'অনুমোদনের অপেক্ষায়',
|
||||
@@ -3712,11 +3724,48 @@ const messages: TranslationMap = {
|
||||
'flows.runs.loading': 'রান লোড হচ্ছে…',
|
||||
'flows.runs.loadError': 'রান লোড করা যায়নি',
|
||||
'flows.runs.empty': 'এখনো কোনো রান নেই',
|
||||
'flows.promptBar.label': 'একটি ওয়ার্কফ্লো বর্ণনা করুন',
|
||||
'flows.promptBar.placeholder': 'একটি ওয়ার্কফ্লো বর্ণনা করুন…',
|
||||
'flows.promptBar.submit': 'তৈরি করুন',
|
||||
'flows.promptBar.thinking': 'তৈরি হচ্ছে…',
|
||||
'flows.promptBar.heroTitle': 'একটি ওয়ার্কফ্লো বর্ণনা করুন',
|
||||
'flows.promptBar.heroSubtitle':
|
||||
'বিল্ডারকে বলুন কী স্বয়ংক্রিয় করতে হবে এবং তার প্রস্তাব পর্যালোচনা করুন।',
|
||||
'flows.promptBar.error': 'ওয়ার্কফ্লো বিল্ডারে পৌঁছানো যায়নি। আবার চেষ্টা করুন।',
|
||||
'flows.promptBar.offline': 'আপনি অফলাইন। একটি ওয়ার্কফ্লো তৈরি করতে আবার সংযোগ করুন।',
|
||||
'flows.copilot.open': 'কো-পাইলট',
|
||||
'flows.copilot.title': 'ওয়ার্কফ্লো কো-পাইলট',
|
||||
'flows.copilot.subtitle': 'পরিবর্তন চান এবং প্রয়োগ করার আগে প্রতিটি প্রস্তাব পর্যালোচনা করুন।',
|
||||
'flows.copilot.close': 'কো-পাইলট বন্ধ করুন',
|
||||
'flows.copilot.placeholder': 'একটি পরিবর্তন চান…',
|
||||
'flows.copilot.send': 'পাঠান',
|
||||
'flows.copilot.thinking': 'ভাবছে…',
|
||||
'flows.copilot.error': 'ওয়ার্কফ্লো বিল্ডারে পৌঁছানো যায়নি। আবার চেষ্টা করুন।',
|
||||
'flows.copilot.offline': 'আপনি অফলাইন। কো-পাইলট ব্যবহার করতে আবার সংযোগ করুন।',
|
||||
'flows.copilot.emptyState':
|
||||
'এই ওয়ার্কফ্লোতে একটি পরিবর্তন বর্ণনা করুন এবং বিল্ডার একটি আপডেট প্রস্তাব করবে।',
|
||||
'flows.copilot.proposalTitle': 'প্রস্তাবিত পরিবর্তন',
|
||||
'flows.copilot.added': '{count}টি যোগ হয়েছে',
|
||||
'flows.copilot.removed': '{count}টি সরানো হয়েছে',
|
||||
'flows.copilot.noChanges': 'এই প্রস্তাব কোনো নোড পরিবর্তন করে না।',
|
||||
'flows.copilot.accept': 'খসড়ায় প্রয়োগ করুন',
|
||||
'flows.copilot.reject': 'বাতিল করুন',
|
||||
'flows.copilot.previewHint':
|
||||
'একটি প্রস্তাবিত খসড়া পর্যালোচনা হচ্ছে — এখনও কিছু সংরক্ষণ করা হয়নি।',
|
||||
'flows.copilot.repairDisplay': 'একটি রান ব্যর্থ হয়েছে; এটি দেখুন এবং একটি সমাধান প্রস্তাব করুন।',
|
||||
'flows.list.view': 'ওয়ার্কফ্লো দেখুন',
|
||||
'flows.list.export': 'রপ্তানি',
|
||||
'flows.list.exported': 'ওয়ার্কফ্লো রপ্তানি হয়েছে',
|
||||
'flows.page.import': 'আমদানি',
|
||||
'flows.import.invalidFile': 'এই ফাইলটি বৈধ ওয়ার্কফ্লো JSON নয়।',
|
||||
'flows.import.error': 'এই ওয়ার্কফ্লোটি আমদানি করা যায়নি। ফাইলটি পরীক্ষা করে আবার চেষ্টা করুন।',
|
||||
'flows.import.warningTitle': 'আমদানি সতর্কতা',
|
||||
'flows.canvas.title': 'ওয়ার্কফ্লো',
|
||||
'flows.canvas.loading': 'ওয়ার্কফ্লো লোড হচ্ছে…',
|
||||
'flows.canvas.loadError': 'এই ওয়ার্কফ্লোটি লোড করা যায়নি। আবার চেষ্টা করুন।',
|
||||
'flows.canvas.notFound': 'এই ওয়ার্কফ্লোটি পাওয়া যায়নি।',
|
||||
'flows.canvas.draftMissing':
|
||||
'খোলার জন্য কোনো ওয়ার্কফ্লো খসড়া নেই। প্রথমে চ্যাট থেকে একটি প্রস্তাব করুন।',
|
||||
'flows.canvas.backToList': 'ওয়ার্কফ্লো তালিকায় ফিরে যান',
|
||||
'flows.nodeKind.trigger': 'ট্রিগার',
|
||||
'flows.nodeKind.agent': 'এজেন্ট',
|
||||
@@ -3730,6 +3779,122 @@ const messages: TranslationMap = {
|
||||
'flows.nodeKind.transform': 'রূপান্তর',
|
||||
'flows.nodeKind.output_parser': 'আউটপুট পার্সার',
|
||||
'flows.nodeKind.sub_workflow': 'সাব-ওয়ার্কফ্লো',
|
||||
'flows.palette.title': 'নোড',
|
||||
'flows.palette.addNode': '{kind} নোড যোগ করুন',
|
||||
'flows.editor.save': 'সংরক্ষণ করুন',
|
||||
'flows.editor.deleteSelected': 'নির্বাচিত মুছুন',
|
||||
// ── Validation UX + draft/dirty state (issue B5b / Phase 3c–3d)
|
||||
'flows.editor.saving': 'সংরক্ষণ করা হচ্ছে…',
|
||||
'flows.editor.run': 'চালান',
|
||||
'flows.editor.running': 'চলছে…',
|
||||
'flows.editor.runFailed': 'রান শুরু করা যায়নি',
|
||||
'flows.editor.validate': 'যাচাই করুন',
|
||||
'flows.editor.validating': 'যাচাই করা হচ্ছে…',
|
||||
'flows.editor.discard': 'পরিবর্তন বাতিল করুন',
|
||||
'flows.editor.unsaved': 'অসংরক্ষিত পরিবর্তন',
|
||||
'flows.editor.saveBlocked': 'সংরক্ষণের আগে নিচের ত্রুটিগুলো ঠিক করুন।',
|
||||
'flows.editor.errorsTitle': 'ত্রুটি',
|
||||
'flows.editor.warningsTitle': 'সতর্কতা',
|
||||
'flows.editor.saveFailedTitle': 'সংরক্ষণ করা যায়নি',
|
||||
'flows.editor.leaveTitle': 'সংরক্ষণ না করে চলে যাবেন?',
|
||||
'flows.editor.leaveBody':
|
||||
'এই ওয়ার্কফ্লোতে আপনার অসংরক্ষিত পরিবর্তন রয়েছে। এখন চলে গেলে সেগুলো হারিয়ে যাবে।',
|
||||
'flows.editor.leaveStay': 'থাকুন',
|
||||
'flows.editor.leaveDiscard': 'চলে যান',
|
||||
// ── Node config drawer (issue B5b / Phase 3b) — per-kind config forms
|
||||
'flows.nodeConfig.close': 'সেটিংস বন্ধ করুন',
|
||||
'flows.nodeConfig.nameLabel': 'নাম',
|
||||
'flows.nodeConfig.namePlaceholder': 'নোডের নাম',
|
||||
'flows.nodeConfig.editForm': 'ফর্ম হিসেবে সম্পাদনা করুন',
|
||||
'flows.nodeConfig.editJson': 'JSON হিসেবে সম্পাদনা করুন',
|
||||
'flows.nodeConfig.rawJsonLabel': 'কনফিগারেশন (JSON)',
|
||||
'flows.nodeConfig.rawJsonHint': 'এই নোডের জন্য মুক্ত কনফিগারেশন।',
|
||||
'flows.nodeConfig.rawJsonInvalid': 'অবৈধ JSON — পার্স না হওয়া পর্যন্ত পরিবর্তন প্রয়োগ হয় না।',
|
||||
'flows.nodeConfig.expressionHint':
|
||||
'নোড ইনপুট থেকে মান গণনা করতে = দিয়ে শুরু করুন, যেমন =item.url',
|
||||
'flows.nodeConfig.expressionBadge': 'এক্সপ্রেশন',
|
||||
'flows.nodeConfig.keymapKeyPlaceholder': 'কী',
|
||||
'flows.nodeConfig.keymapValuePlaceholder': 'মান',
|
||||
'flows.nodeConfig.keymapRemove': 'সারি সরান',
|
||||
'flows.nodeConfig.keymapAdd': 'সারি যোগ করুন',
|
||||
'flows.nodeConfig.credentialLabel': 'শংসাপত্র',
|
||||
'flows.nodeConfig.credentialHint': 'এই নোডের জন্য একটি সংযুক্ত অ্যাকাউন্ট বা শংসাপত্র বেছে নিন।',
|
||||
'flows.nodeConfig.credentialEmpty': 'কোনো সংযুক্ত শংসাপত্র উপলব্ধ নেই।',
|
||||
'flows.nodeConfig.credentialNone': 'কোনোটি নয়',
|
||||
'flows.nodeConfig.trigger.kindLabel': 'ট্রিগারের ধরন',
|
||||
'flows.nodeConfig.trigger.kind_manual': 'ম্যানুয়াল',
|
||||
'flows.nodeConfig.trigger.kind_schedule': 'সময়সূচি',
|
||||
'flows.nodeConfig.trigger.kind_webhook': 'ওয়েবহুক',
|
||||
'flows.nodeConfig.trigger.kind_app_event': 'অ্যাপ ইভেন্ট',
|
||||
'flows.nodeConfig.trigger.scheduleLabel': 'ক্রন সময়সূচি',
|
||||
'flows.nodeConfig.trigger.scheduleHint': 'ক্রন এক্সপ্রেশন: মিনিট ঘণ্টা দিন মাস সপ্তাহের দিন।',
|
||||
'flows.nodeConfig.trigger.toolkitLabel': 'টুলকিট',
|
||||
'flows.nodeConfig.trigger.triggerSlugLabel': 'ট্রিগার স্লাগ',
|
||||
'flows.nodeConfig.trigger.webhookHint':
|
||||
'ওয়েবহুক ট্রিগার সংরক্ষিত হয় তবে এখনও স্বয়ংক্রিয়ভাবে চলে না।',
|
||||
'flows.nodeConfig.http.methodLabel': 'পদ্ধতি',
|
||||
'flows.nodeConfig.http.urlLabel': 'URL',
|
||||
'flows.nodeConfig.http.headersLabel': 'হেডার',
|
||||
'flows.nodeConfig.http.bodyLabel': 'বডি (JSON)',
|
||||
'flows.nodeConfig.agent.promptLabel': 'প্রম্পট',
|
||||
'flows.nodeConfig.agent.promptPlaceholder': 'এজেন্টের জন্য নির্দেশনা…',
|
||||
'flows.nodeConfig.agent.modelLabel': 'মডেল',
|
||||
'flows.nodeConfig.tool.slugLabel': 'টুল স্লাগ',
|
||||
'flows.nodeConfig.tool.argsLabel': 'আর্গুমেন্ট (JSON)',
|
||||
'flows.nodeConfig.condition.fieldLabel': 'ক্ষেত্র',
|
||||
'flows.nodeConfig.condition.fieldHint':
|
||||
'সত্যতা যাচাইয়ের জন্য ইনপুট আইটেমের কী। true বা false এ রুট করে।',
|
||||
'flows.nodeConfig.switch.expressionLabel': 'এক্সপ্রেশন',
|
||||
'flows.nodeConfig.switch.hint':
|
||||
'ফলাফল মান মিলে যাওয়া আউটপুট পোর্ট নির্বাচন করে; null default এ যায়।',
|
||||
'flows.nodeConfig.switch.fieldLabel': 'ক্ষেত্র (বিকল্প)',
|
||||
'flows.nodeConfig.transform.setLabel': 'ক্ষেত্র সেট করুন',
|
||||
'flows.nodeConfig.transform.setHint':
|
||||
'প্রতিটি মান প্রতি আইটেমে মূল্যায়িত একটি এক্সপ্রেশন, যেমন =item.name',
|
||||
'flows.nodeConfig.code.languageLabel': 'ভাষা',
|
||||
'flows.nodeConfig.code.language_javascript': 'JavaScript',
|
||||
'flows.nodeConfig.code.language_python': 'Python',
|
||||
'flows.nodeConfig.code.sourceLabel': 'সোর্স কোড',
|
||||
|
||||
'flows.chooser.title': 'ওয়ার্কফ্লো তৈরি করুন',
|
||||
'flows.chooser.subtitle': 'আপনি কীভাবে শুরু করতে চান তা বেছে নিন।',
|
||||
'flows.chooser.scratchTitle': 'একদম শুরু থেকে',
|
||||
'flows.chooser.scratchDescription':
|
||||
'একটি খালি ক্যানভাস এবং একটি একক ম্যানুয়াল ট্রিগার দিয়ে শুরু করুন।',
|
||||
'flows.chooser.templateTitle': 'টেমপ্লেট থেকে',
|
||||
'flows.chooser.templateDescription':
|
||||
'একটি প্রস্তুত উদাহরণ থেকে শুরু করুন এবং এটি কাস্টমাইজ করুন।',
|
||||
'flows.chooser.describeTitle': 'বর্ণনা করুন',
|
||||
'flows.chooser.describeDescription':
|
||||
'সহকারীকে বলুন আপনি কী চান এবং তাকে ওয়ার্কফ্লোর খসড়া তৈরি করতে দিন।',
|
||||
'flows.chooser.creating': 'ওয়ার্কফ্লো তৈরি হচ্ছে…',
|
||||
'flows.chooser.createError': 'ওয়ার্কফ্লো তৈরি করা যায়নি। অনুগ্রহ করে আবার চেষ্টা করুন।',
|
||||
'flows.templates.title': 'টেমপ্লেট থেকে শুরু করুন',
|
||||
'flows.templates.subtitle': 'একটি শুরুর বিন্দু বেছে নিন এবং সম্পাদকে এটি কাস্টমাইজ করুন।',
|
||||
'flows.templates.use': 'টেমপ্লেট ব্যবহার করুন',
|
||||
'flows.templates.back': 'পিছনে',
|
||||
'flows.templates.empty': 'কোনো টেমপ্লেট উপলব্ধ নেই।',
|
||||
'flows.templates.category.scheduled': 'নির্ধারিত',
|
||||
'flows.templates.category.triggered': 'ট্রিগারড',
|
||||
'flows.templates.category.onDemand': 'চাহিদা অনুযায়ী',
|
||||
'flows.templates.daily-digest.name': 'চ্যানেলে দৈনিক সারসংক্ষেপ',
|
||||
'flows.templates.daily-digest.description':
|
||||
'একটি সময়সূচি অনুযায়ী, একটি এজেন্ট একটি সংক্ষিপ্ত সারসংক্ষেপ লেখে এবং তা চ্যানেলে পোস্ট করে।',
|
||||
'flows.templates.scheduled-scrape.name': 'নির্ধারিত স্ক্র্যাপ মেমরিতে',
|
||||
'flows.templates.scheduled-scrape.description':
|
||||
'সময়সূচি অনুযায়ী একটি উৎস আনুন, ফলাফল পুনর্গঠন করুন এবং মেমরিতে সংরক্ষণ করুন।',
|
||||
'flows.templates.webhook-triage.name': 'ওয়েবহুক ট্রায়াজ এবং বিজ্ঞপ্তি',
|
||||
'flows.templates.webhook-triage.description':
|
||||
'একটি আগত ওয়েবহুক একটি এজেন্ট দ্বারা ট্রায়াজ করা হয়, তারপর আপনাকে জানানো হয়।',
|
||||
'flows.templates.app-event-route.name': 'অ্যাপ ইভেন্ট থেকে শর্তসাপেক্ষ ক্রিয়া',
|
||||
'flows.templates.app-event-route.description':
|
||||
'একটি সংযুক্ত অ্যাপের ইভেন্ট একটি পরীক্ষা চালায়, তারপর মিললে ক্রিয়া সম্পাদন করে।',
|
||||
'flows.templates.http-fetch-parse.name': 'API আনুন এবং পার্স করুন',
|
||||
'flows.templates.http-fetch-parse.description':
|
||||
'চাহিদা অনুযায়ী একটি HTTP এন্ডপয়েন্ট কল করুন এবং প্রতিক্রিয়া ব্যবহারযোগ্য রূপে পার্স করুন।',
|
||||
'flows.templates.ask-agent.name': 'এজেন্টকে জিজ্ঞাসা করুন',
|
||||
'flows.templates.ask-agent.description':
|
||||
'একটি সরল ম্যানুয়াল ট্রিগার যা একটি কাজ এজেন্টকে হস্তান্তর করে।',
|
||||
|
||||
'oauth.button.connecting': 'সংযোগ হচ্ছে...',
|
||||
'oauth.button.loopbackTimeout':
|
||||
|
||||
@@ -3197,6 +3197,7 @@ const messages: TranslationMap = {
|
||||
'chat.flowProposal.requireApprovalHint': 'Jede ausgehende Aktion benötigt Ihre Genehmigung.',
|
||||
'chat.flowProposal.save': 'Speichern & aktivieren',
|
||||
'chat.flowProposal.saving': 'Wird gespeichert…',
|
||||
'chat.flowProposal.openInCanvas': 'In der Leinwand öffnen',
|
||||
'chat.flowProposal.dismiss': 'Verwerfen',
|
||||
'chat.flowProposal.error':
|
||||
'Der Workflow konnte nicht gespeichert werden. Bitte versuchen Sie es erneut.',
|
||||
@@ -3771,6 +3772,17 @@ const messages: TranslationMap = {
|
||||
'flowRuns.inspector.port': 'Port',
|
||||
'flowRuns.inspector.loading': 'Lauf wird geladen…',
|
||||
'flowRuns.inspector.loadError': 'Dieser Lauf konnte nicht geladen werden',
|
||||
'flowRuns.inspector.fixWithAgent': 'Mit Agent beheben',
|
||||
'flowRuns.inspector.dataTable': 'Tabelle',
|
||||
'flowRuns.inspector.dataJson': 'JSON',
|
||||
'flowRuns.inspector.dataViewLabel': 'Ausgabeansicht',
|
||||
'flowRuns.inspector.itemCount': '{count} Element(e)',
|
||||
'flowRuns.inspector.noItems': 'Keine Ausgabeelemente',
|
||||
'flowRuns.inspector.emptyValue': '(leer)',
|
||||
'flowRuns.inspector.binaryLabel': 'Binär',
|
||||
'flowRuns.inspector.showSource': 'Quelle',
|
||||
'flowRuns.inspector.hideSource': 'Quelle ausblenden',
|
||||
'flowRuns.inspector.sourceInputTitle': 'Quell-Eingabeelement',
|
||||
'flowRuns.status.running': 'Läuft',
|
||||
'flowRuns.status.completed': 'Abgeschlossen',
|
||||
'flowRuns.status.pending_approval': 'Wartet auf Genehmigung',
|
||||
@@ -3803,12 +3815,53 @@ const messages: TranslationMap = {
|
||||
'flows.runs.loading': 'Ausführungen werden geladen…',
|
||||
'flows.runs.loadError': 'Ausführungen konnten nicht geladen werden',
|
||||
'flows.runs.empty': 'Noch keine Ausführungen',
|
||||
'flows.promptBar.label': 'Beschreibe einen Workflow',
|
||||
'flows.promptBar.placeholder': 'Beschreibe einen Workflow…',
|
||||
'flows.promptBar.submit': 'Erstellen',
|
||||
'flows.promptBar.thinking': 'Wird erstellt…',
|
||||
'flows.promptBar.heroTitle': 'Beschreibe einen Workflow',
|
||||
'flows.promptBar.heroSubtitle':
|
||||
'Sag dem Generator, was automatisiert werden soll, und prüfe seinen Vorschlag.',
|
||||
'flows.promptBar.error': 'Der Workflow-Generator ist nicht erreichbar. Bitte erneut versuchen.',
|
||||
'flows.promptBar.offline':
|
||||
'Du bist offline. Verbinde dich erneut, um einen Workflow zu erstellen.',
|
||||
'flows.copilot.open': 'Assistent',
|
||||
'flows.copilot.title': 'Workflow-Assistent',
|
||||
'flows.copilot.subtitle':
|
||||
'Bitte um Änderungen und prüfe jeden Vorschlag, bevor du ihn übernimmst.',
|
||||
'flows.copilot.close': 'Assistent schließen',
|
||||
'flows.copilot.placeholder': 'Bitte um eine Änderung…',
|
||||
'flows.copilot.send': 'Senden',
|
||||
'flows.copilot.thinking': 'Denkt nach…',
|
||||
'flows.copilot.error': 'Der Workflow-Generator ist nicht erreichbar. Bitte erneut versuchen.',
|
||||
'flows.copilot.offline': 'Du bist offline. Verbinde dich erneut, um den Assistenten zu nutzen.',
|
||||
'flows.copilot.emptyState':
|
||||
'Beschreibe eine Änderung an diesem Workflow, und der Generator schlägt eine Aktualisierung vor.',
|
||||
'flows.copilot.proposalTitle': 'Vorgeschlagene Änderungen',
|
||||
'flows.copilot.added': '{count} hinzugefügt',
|
||||
'flows.copilot.removed': '{count} entfernt',
|
||||
'flows.copilot.noChanges': 'Dieser Vorschlag ändert keine Knoten.',
|
||||
'flows.copilot.accept': 'Auf Entwurf anwenden',
|
||||
'flows.copilot.reject': 'Verwerfen',
|
||||
'flows.copilot.previewHint':
|
||||
'Ein vorgeschlagener Entwurf wird geprüft — es wurde noch nichts gespeichert.',
|
||||
'flows.copilot.repairDisplay':
|
||||
'Eine Ausführung ist fehlgeschlagen; sieh sie dir an und schlage eine Lösung vor.',
|
||||
'flows.list.view': 'Workflow anzeigen',
|
||||
'flows.list.export': 'Exportieren',
|
||||
'flows.list.exported': 'Workflow exportiert',
|
||||
'flows.page.import': 'Importieren',
|
||||
'flows.import.invalidFile': 'Diese Datei ist kein gültiges Workflow-JSON.',
|
||||
'flows.import.error':
|
||||
'Dieser Workflow konnte nicht importiert werden. Überprüfe die Datei und versuche es erneut.',
|
||||
'flows.import.warningTitle': 'Importwarnung',
|
||||
'flows.canvas.title': 'Workflow',
|
||||
'flows.canvas.loading': 'Workflow wird geladen…',
|
||||
'flows.canvas.loadError':
|
||||
'Dieser Workflow konnte nicht geladen werden. Bitte versuche es erneut.',
|
||||
'flows.canvas.notFound': 'Dieser Workflow wurde nicht gefunden.',
|
||||
'flows.canvas.draftMissing':
|
||||
'Kein Workflow-Entwurf zum Öffnen. Schlage zuerst einen im Chat vor.',
|
||||
'flows.canvas.backToList': 'Zurück zu den Workflows',
|
||||
'flows.nodeKind.trigger': 'Auslöser',
|
||||
'flows.nodeKind.agent': 'Agent',
|
||||
@@ -3822,6 +3875,123 @@ const messages: TranslationMap = {
|
||||
'flows.nodeKind.transform': 'Transformation',
|
||||
'flows.nodeKind.output_parser': 'Ausgabe-Parser',
|
||||
'flows.nodeKind.sub_workflow': 'Unter-Workflow',
|
||||
'flows.palette.title': 'Knoten',
|
||||
'flows.palette.addNode': '{kind}-Knoten hinzufügen',
|
||||
'flows.editor.save': 'Speichern',
|
||||
'flows.editor.deleteSelected': 'Auswahl löschen',
|
||||
// ── Validation UX + draft/dirty state (issue B5b / Phase 3c–3d)
|
||||
'flows.editor.saving': 'Wird gespeichert…',
|
||||
'flows.editor.run': 'Ausführen',
|
||||
'flows.editor.running': 'Läuft…',
|
||||
'flows.editor.runFailed': 'Ausführung konnte nicht gestartet werden',
|
||||
'flows.editor.validate': 'Prüfen',
|
||||
'flows.editor.validating': 'Wird geprüft…',
|
||||
'flows.editor.discard': 'Änderungen verwerfen',
|
||||
'flows.editor.unsaved': 'Nicht gespeicherte Änderungen',
|
||||
'flows.editor.saveBlocked': 'Beheben Sie die Fehler unten vor dem Speichern.',
|
||||
'flows.editor.errorsTitle': 'Fehler',
|
||||
'flows.editor.warningsTitle': 'Warnungen',
|
||||
'flows.editor.saveFailedTitle': 'Speichern fehlgeschlagen',
|
||||
'flows.editor.leaveTitle': 'Ohne Speichern verlassen?',
|
||||
'flows.editor.leaveBody':
|
||||
'Sie haben nicht gespeicherte Änderungen an diesem Workflow. Wenn Sie jetzt gehen, gehen sie verloren.',
|
||||
'flows.editor.leaveStay': 'Bleiben',
|
||||
'flows.editor.leaveDiscard': 'Verlassen',
|
||||
// ── Node config drawer (issue B5b / Phase 3b) — per-kind config forms
|
||||
'flows.nodeConfig.close': 'Einstellungen schließen',
|
||||
'flows.nodeConfig.nameLabel': 'Name',
|
||||
'flows.nodeConfig.namePlaceholder': 'Knotenname',
|
||||
'flows.nodeConfig.editForm': 'Als Formular bearbeiten',
|
||||
'flows.nodeConfig.editJson': 'Als JSON bearbeiten',
|
||||
'flows.nodeConfig.rawJsonLabel': 'Konfiguration (JSON)',
|
||||
'flows.nodeConfig.rawJsonHint': 'Freie Konfiguration für diesen Knoten.',
|
||||
'flows.nodeConfig.rawJsonInvalid':
|
||||
'Ungültiges JSON – Änderungen werden erst übernommen, wenn es gültig ist.',
|
||||
'flows.nodeConfig.expressionHint':
|
||||
'Mit = beginnen, um den Wert aus der Knoteneingabe zu berechnen, z. B. =item.url',
|
||||
'flows.nodeConfig.expressionBadge': 'Ausdruck',
|
||||
'flows.nodeConfig.keymapKeyPlaceholder': 'Schlüssel',
|
||||
'flows.nodeConfig.keymapValuePlaceholder': 'Wert',
|
||||
'flows.nodeConfig.keymapRemove': 'Zeile entfernen',
|
||||
'flows.nodeConfig.keymapAdd': 'Zeile hinzufügen',
|
||||
'flows.nodeConfig.credentialLabel': 'Anmeldedaten',
|
||||
'flows.nodeConfig.credentialHint':
|
||||
'Wähle ein verbundenes Konto oder Anmeldedaten für diesen Knoten.',
|
||||
'flows.nodeConfig.credentialEmpty': 'Keine verbundenen Anmeldedaten verfügbar.',
|
||||
'flows.nodeConfig.credentialNone': 'Keine',
|
||||
'flows.nodeConfig.trigger.kindLabel': 'Auslösertyp',
|
||||
'flows.nodeConfig.trigger.kind_manual': 'Manuell',
|
||||
'flows.nodeConfig.trigger.kind_schedule': 'Zeitplan',
|
||||
'flows.nodeConfig.trigger.kind_webhook': 'Webhook',
|
||||
'flows.nodeConfig.trigger.kind_app_event': 'App-Ereignis',
|
||||
'flows.nodeConfig.trigger.scheduleLabel': 'Cron-Zeitplan',
|
||||
'flows.nodeConfig.trigger.scheduleHint': 'Cron-Ausdruck: Minute Stunde Tag Monat Wochentag.',
|
||||
'flows.nodeConfig.trigger.toolkitLabel': 'Toolkit',
|
||||
'flows.nodeConfig.trigger.triggerSlugLabel': 'Auslöser-Slug',
|
||||
'flows.nodeConfig.trigger.webhookHint':
|
||||
'Webhook-Auslöser werden gespeichert, aber noch nicht automatisch ausgelöst.',
|
||||
'flows.nodeConfig.http.methodLabel': 'Methode',
|
||||
'flows.nodeConfig.http.urlLabel': 'URL',
|
||||
'flows.nodeConfig.http.headersLabel': 'Header',
|
||||
'flows.nodeConfig.http.bodyLabel': 'Text (JSON)',
|
||||
'flows.nodeConfig.agent.promptLabel': 'Prompt',
|
||||
'flows.nodeConfig.agent.promptPlaceholder': 'Anweisungen für den Agenten…',
|
||||
'flows.nodeConfig.agent.modelLabel': 'Modell',
|
||||
'flows.nodeConfig.tool.slugLabel': 'Tool-Slug',
|
||||
'flows.nodeConfig.tool.argsLabel': 'Argumente (JSON)',
|
||||
'flows.nodeConfig.condition.fieldLabel': 'Feld',
|
||||
'flows.nodeConfig.condition.fieldHint':
|
||||
'Schlüssel des Eingabeelements, der auf Wahrheit geprüft wird. Leitet zu true oder false.',
|
||||
'flows.nodeConfig.switch.expressionLabel': 'Ausdruck',
|
||||
'flows.nodeConfig.switch.hint':
|
||||
'Der Ergebniswert wählt den passenden Ausgangsport; null geht an default.',
|
||||
'flows.nodeConfig.switch.fieldLabel': 'Feld (Ausweichoption)',
|
||||
'flows.nodeConfig.transform.setLabel': 'Felder setzen',
|
||||
'flows.nodeConfig.transform.setHint':
|
||||
'Jeder Wert ist ein Ausdruck, der pro Element ausgewertet wird, z. B. =item.name',
|
||||
'flows.nodeConfig.code.languageLabel': 'Sprache',
|
||||
'flows.nodeConfig.code.language_javascript': 'JavaScript',
|
||||
'flows.nodeConfig.code.language_python': 'Python',
|
||||
'flows.nodeConfig.code.sourceLabel': 'Quellcode',
|
||||
|
||||
'flows.chooser.title': 'Workflow erstellen',
|
||||
'flows.chooser.subtitle': 'Wählen Sie, wie Sie beginnen möchten.',
|
||||
'flows.chooser.scratchTitle': 'Bei null anfangen',
|
||||
'flows.chooser.scratchDescription':
|
||||
'Mit einer leeren Arbeitsfläche und einem einzelnen manuellen Auslöser starten.',
|
||||
'flows.chooser.templateTitle': 'Aus einer Vorlage',
|
||||
'flows.chooser.templateDescription': 'Mit einem fertigen Beispiel starten und anpassen.',
|
||||
'flows.chooser.describeTitle': 'Beschreiben',
|
||||
'flows.chooser.describeDescription':
|
||||
'Sagen Sie dem Assistenten, was Sie möchten, und lassen Sie ihn den Workflow entwerfen.',
|
||||
'flows.chooser.creating': 'Workflow wird erstellt…',
|
||||
'flows.chooser.createError': 'Der Workflow konnte nicht erstellt werden. Bitte erneut versuchen.',
|
||||
'flows.templates.title': 'Mit einer Vorlage beginnen',
|
||||
'flows.templates.subtitle': 'Wählen Sie einen Ausgangspunkt und passen Sie ihn im Editor an.',
|
||||
'flows.templates.use': 'Vorlage verwenden',
|
||||
'flows.templates.back': 'Zurück',
|
||||
'flows.templates.empty': 'Keine Vorlagen verfügbar.',
|
||||
'flows.templates.category.scheduled': 'Geplant',
|
||||
'flows.templates.category.triggered': 'Ausgelöst',
|
||||
'flows.templates.category.onDemand': 'Bei Bedarf',
|
||||
'flows.templates.daily-digest.name': 'Tägliche Zusammenfassung an Kanal',
|
||||
'flows.templates.daily-digest.description':
|
||||
'Nach Zeitplan schreibt ein Agent eine kurze Zusammenfassung und postet sie in einen Kanal.',
|
||||
'flows.templates.scheduled-scrape.name': 'Geplantes Scraping ins Gedächtnis',
|
||||
'flows.templates.scheduled-scrape.description':
|
||||
'Eine Quelle nach Zeitplan abrufen, die Ergebnisse umformen und im Gedächtnis speichern.',
|
||||
'flows.templates.webhook-triage.name': 'Webhook-Triage und Benachrichtigung',
|
||||
'flows.templates.webhook-triage.description':
|
||||
'Ein eingehender Webhook wird von einem Agenten sortiert, dann werden Sie benachrichtigt.',
|
||||
'flows.templates.app-event-route.name': 'App-Ereignis zu bedingter Aktion',
|
||||
'flows.templates.app-event-route.description':
|
||||
'Ein Ereignis einer verbundenen App führt eine Prüfung aus und handelt bei Übereinstimmung.',
|
||||
'flows.templates.http-fetch-parse.name': 'API abrufen und auswerten',
|
||||
'flows.templates.http-fetch-parse.description':
|
||||
'Einen HTTP-Endpunkt bei Bedarf aufrufen und die Antwort in eine nutzbare Form bringen.',
|
||||
'flows.templates.ask-agent.name': 'Den Agenten fragen',
|
||||
'flows.templates.ask-agent.description':
|
||||
'Ein einfacher manueller Auslöser, der einem Agenten eine Aufgabe übergibt.',
|
||||
|
||||
'oauth.button.connecting': 'Verbinden...',
|
||||
'oauth.button.loopbackTimeout':
|
||||
|
||||
@@ -3568,6 +3568,7 @@ const en: TranslationMap = {
|
||||
'chat.flowProposal.requireApprovalHint': 'Every outbound action will need your approval.',
|
||||
'chat.flowProposal.save': 'Save & enable',
|
||||
'chat.flowProposal.saving': 'Saving…',
|
||||
'chat.flowProposal.openInCanvas': 'Open in canvas',
|
||||
'chat.flowProposal.dismiss': 'Dismiss',
|
||||
'chat.flowProposal.error': 'Could not save the workflow. Please try again.',
|
||||
|
||||
@@ -4319,6 +4320,21 @@ const en: TranslationMap = {
|
||||
'flowRuns.inspector.port': 'Port',
|
||||
'flowRuns.inspector.loading': 'Loading run…',
|
||||
'flowRuns.inspector.loadError': 'Could not load this run',
|
||||
// Phase 5c — "Fix with agent" opens the canvas copilot preloaded with this
|
||||
// failed run's context so the workflow builder can propose a fix.
|
||||
'flowRuns.inspector.fixWithAgent': 'Fix with agent',
|
||||
// Phase 6 — per-item data browser (n8n-style table ⟷ JSON toggle) for a
|
||||
// step's output items, plus the input↔output pairing affordance.
|
||||
'flowRuns.inspector.dataTable': 'Table',
|
||||
'flowRuns.inspector.dataJson': 'JSON',
|
||||
'flowRuns.inspector.dataViewLabel': 'Output view',
|
||||
'flowRuns.inspector.itemCount': '{count} item(s)',
|
||||
'flowRuns.inspector.noItems': 'No output items',
|
||||
'flowRuns.inspector.emptyValue': '(empty)',
|
||||
'flowRuns.inspector.binaryLabel': 'Binary',
|
||||
'flowRuns.inspector.showSource': 'Source',
|
||||
'flowRuns.inspector.hideSource': 'Hide source',
|
||||
'flowRuns.inspector.sourceInputTitle': 'Source input item',
|
||||
'flowRuns.status.running': 'Running',
|
||||
'flowRuns.status.completed': 'Completed',
|
||||
'flowRuns.status.pending_approval': 'Awaiting approval',
|
||||
@@ -4351,12 +4367,50 @@ const en: TranslationMap = {
|
||||
'flows.list.paused': 'Paused',
|
||||
'flows.list.runStarted': 'Workflow started',
|
||||
'flows.list.view': 'View workflow',
|
||||
'flows.list.export': 'Export',
|
||||
'flows.list.exported': 'Workflow exported',
|
||||
'flows.page.import': 'Import',
|
||||
'flows.import.invalidFile': 'That file is not valid workflow JSON.',
|
||||
'flows.import.error': 'Could not import this workflow. Check the file and try again.',
|
||||
'flows.import.warningTitle': 'Import warning',
|
||||
'flows.runs.title': 'Runs for {name}',
|
||||
'flows.runs.titleFallback': 'Workflow runs',
|
||||
'flows.runs.loading': 'Loading runs…',
|
||||
'flows.runs.loadError': 'Could not load runs',
|
||||
'flows.runs.empty': 'No runs yet',
|
||||
|
||||
// ── Phase 5c: prompt-first authoring + canvas copilot ────────────────────
|
||||
// The Flows prompt bar (describe a workflow → builder agent proposes it) and
|
||||
// the canvas copilot (iterate on a draft with a diff overlay). Everything
|
||||
// here only PROPOSES — saving/enabling stays behind explicit clicks.
|
||||
'flows.promptBar.label': 'Describe a workflow',
|
||||
'flows.promptBar.placeholder': 'Describe a workflow…',
|
||||
'flows.promptBar.submit': 'Build',
|
||||
'flows.promptBar.thinking': 'Building…',
|
||||
'flows.promptBar.heroTitle': 'Describe a workflow',
|
||||
'flows.promptBar.heroSubtitle': 'Tell the builder what to automate and review its proposal.',
|
||||
'flows.promptBar.error': 'Could not reach the workflow builder. Please try again.',
|
||||
'flows.promptBar.offline': 'You are offline. Reconnect to build a workflow.',
|
||||
'flows.copilot.open': 'Copilot',
|
||||
'flows.copilot.title': 'Workflow copilot',
|
||||
'flows.copilot.subtitle': 'Ask for changes and review each proposal before applying it.',
|
||||
'flows.copilot.close': 'Close copilot',
|
||||
'flows.copilot.placeholder': 'Ask for a change…',
|
||||
'flows.copilot.send': 'Send',
|
||||
'flows.copilot.thinking': 'Thinking…',
|
||||
'flows.copilot.error': 'Could not reach the workflow builder. Please try again.',
|
||||
'flows.copilot.offline': 'You are offline. Reconnect to use the copilot.',
|
||||
'flows.copilot.emptyState':
|
||||
'Describe a change to this workflow and the builder will propose an update.',
|
||||
'flows.copilot.proposalTitle': 'Proposed changes',
|
||||
'flows.copilot.added': '{count} added',
|
||||
'flows.copilot.removed': '{count} removed',
|
||||
'flows.copilot.noChanges': 'No node changes in this proposal.',
|
||||
'flows.copilot.accept': 'Apply to draft',
|
||||
'flows.copilot.reject': 'Discard',
|
||||
'flows.copilot.previewHint': 'Reviewing a proposed draft — nothing is saved yet.',
|
||||
'flows.copilot.repairDisplay': 'A run failed — please look at it and propose a fix.',
|
||||
|
||||
// ── Workflow Canvas (issue B5b.1) — the read-only graph view of a saved
|
||||
// flow at /flows/:id. `flows.nodeKind.*` labels the 12 tinyflows node
|
||||
// kinds (`tinyflows::model::NodeKind`) shown in each canvas node card.
|
||||
@@ -4364,6 +4418,7 @@ const en: TranslationMap = {
|
||||
'flows.canvas.loading': 'Loading workflow…',
|
||||
'flows.canvas.loadError': 'Could not load this workflow. Please try again.',
|
||||
'flows.canvas.notFound': 'This workflow could not be found.',
|
||||
'flows.canvas.draftMissing': 'No workflow draft to open. Propose one from chat first.',
|
||||
'flows.canvas.backToList': 'Back to workflows',
|
||||
'flows.nodeKind.trigger': 'Trigger',
|
||||
'flows.nodeKind.agent': 'Agent',
|
||||
@@ -4378,6 +4433,125 @@ const en: TranslationMap = {
|
||||
'flows.nodeKind.output_parser': 'Output parser',
|
||||
'flows.nodeKind.sub_workflow': 'Sub-workflow',
|
||||
|
||||
// ── Editable Workflow Canvas (issue B5b.2 / Phase 3a) — the node palette
|
||||
// and editor toolbar layered on top of the read-only canvas above.
|
||||
'flows.palette.title': 'Nodes',
|
||||
'flows.palette.addNode': 'Add {kind} node',
|
||||
'flows.editor.save': 'Save',
|
||||
'flows.editor.saving': 'Saving…',
|
||||
'flows.editor.run': 'Run',
|
||||
'flows.editor.running': 'Running…',
|
||||
'flows.editor.runFailed': 'Could not start run',
|
||||
'flows.editor.deleteSelected': 'Delete selected',
|
||||
// ── Validation UX + draft/dirty state (issue B5b / Phase 3c–3d)
|
||||
'flows.editor.validate': 'Validate',
|
||||
'flows.editor.validating': 'Validating…',
|
||||
'flows.editor.discard': 'Discard changes',
|
||||
'flows.editor.unsaved': 'Unsaved changes',
|
||||
'flows.editor.saveBlocked': 'Fix the errors below before saving.',
|
||||
'flows.editor.errorsTitle': 'Errors',
|
||||
'flows.editor.warningsTitle': 'Warnings',
|
||||
'flows.editor.saveFailedTitle': 'Could not save',
|
||||
'flows.editor.leaveTitle': 'Leave without saving?',
|
||||
'flows.editor.leaveBody':
|
||||
'You have unsaved changes to this workflow. If you leave now, they will be lost.',
|
||||
'flows.editor.leaveStay': 'Stay',
|
||||
'flows.editor.leaveDiscard': 'Leave',
|
||||
// ── Node config drawer (issue B5b / Phase 3b) — per-kind config forms
|
||||
'flows.nodeConfig.close': 'Close settings',
|
||||
'flows.nodeConfig.nameLabel': 'Name',
|
||||
'flows.nodeConfig.namePlaceholder': 'Node name',
|
||||
'flows.nodeConfig.editForm': 'Edit as form',
|
||||
'flows.nodeConfig.editJson': 'Edit as JSON',
|
||||
'flows.nodeConfig.rawJsonLabel': 'Configuration (JSON)',
|
||||
'flows.nodeConfig.rawJsonHint': 'Free-form configuration for this node.',
|
||||
'flows.nodeConfig.rawJsonInvalid': 'Invalid JSON — changes are not applied until it parses.',
|
||||
'flows.nodeConfig.expressionHint':
|
||||
'Start with = to compute the value from the node input, e.g. =item.url',
|
||||
'flows.nodeConfig.expressionBadge': 'Expression',
|
||||
'flows.nodeConfig.keymapKeyPlaceholder': 'Key',
|
||||
'flows.nodeConfig.keymapValuePlaceholder': 'Value',
|
||||
'flows.nodeConfig.keymapRemove': 'Remove row',
|
||||
'flows.nodeConfig.keymapAdd': 'Add row',
|
||||
'flows.nodeConfig.credentialLabel': 'Credential',
|
||||
'flows.nodeConfig.credentialHint': 'Choose a connected account or credential for this node.',
|
||||
'flows.nodeConfig.credentialEmpty': 'No connected credentials available.',
|
||||
'flows.nodeConfig.credentialNone': 'None',
|
||||
'flows.nodeConfig.trigger.kindLabel': 'Trigger type',
|
||||
'flows.nodeConfig.trigger.kind_manual': 'Manual',
|
||||
'flows.nodeConfig.trigger.kind_schedule': 'Schedule',
|
||||
'flows.nodeConfig.trigger.kind_webhook': 'Webhook',
|
||||
'flows.nodeConfig.trigger.kind_app_event': 'App event',
|
||||
'flows.nodeConfig.trigger.scheduleLabel': 'Cron schedule',
|
||||
'flows.nodeConfig.trigger.scheduleHint': 'Cron expression: minute hour day month weekday.',
|
||||
'flows.nodeConfig.trigger.toolkitLabel': 'Toolkit',
|
||||
'flows.nodeConfig.trigger.triggerSlugLabel': 'Trigger slug',
|
||||
'flows.nodeConfig.trigger.webhookHint':
|
||||
'Webhook triggers are saved but not dispatched automatically yet.',
|
||||
'flows.nodeConfig.http.methodLabel': 'Method',
|
||||
'flows.nodeConfig.http.urlLabel': 'URL',
|
||||
'flows.nodeConfig.http.headersLabel': 'Headers',
|
||||
'flows.nodeConfig.http.bodyLabel': 'Body (JSON)',
|
||||
'flows.nodeConfig.agent.promptLabel': 'Prompt',
|
||||
'flows.nodeConfig.agent.promptPlaceholder': 'Instructions for the agent…',
|
||||
'flows.nodeConfig.agent.modelLabel': 'Model',
|
||||
'flows.nodeConfig.tool.slugLabel': 'Tool slug',
|
||||
'flows.nodeConfig.tool.argsLabel': 'Arguments (JSON)',
|
||||
'flows.nodeConfig.condition.fieldLabel': 'Field',
|
||||
'flows.nodeConfig.condition.fieldHint':
|
||||
'Key on the input item to test for truthiness. Routes to true or false.',
|
||||
'flows.nodeConfig.switch.expressionLabel': 'Expression',
|
||||
'flows.nodeConfig.switch.hint':
|
||||
'The result value selects the matching output port; null routes to default.',
|
||||
'flows.nodeConfig.switch.fieldLabel': 'Field (fallback)',
|
||||
'flows.nodeConfig.transform.setLabel': 'Set fields',
|
||||
'flows.nodeConfig.transform.setHint':
|
||||
'Each value is an expression evaluated per item, e.g. =item.name',
|
||||
'flows.nodeConfig.code.languageLabel': 'Language',
|
||||
'flows.nodeConfig.code.language_javascript': 'JavaScript',
|
||||
'flows.nodeConfig.code.language_python': 'Python',
|
||||
'flows.nodeConfig.code.sourceLabel': 'Source',
|
||||
|
||||
// Phase 4a "New workflow" chooser + Phase 4c templates gallery. The chooser
|
||||
// offers scratch / template / describe; the gallery lists the curated
|
||||
// `WorkflowGraph` templates bundled under `lib/flows/templates/`.
|
||||
'flows.chooser.title': 'Create a workflow',
|
||||
'flows.chooser.subtitle': 'Choose how you want to start.',
|
||||
'flows.chooser.scratchTitle': 'Start from scratch',
|
||||
'flows.chooser.scratchDescription': 'Begin with a blank canvas and a single manual trigger.',
|
||||
'flows.chooser.templateTitle': 'From a template',
|
||||
'flows.chooser.templateDescription': 'Start from a ready-made example and customize it.',
|
||||
'flows.chooser.describeTitle': 'Describe it',
|
||||
'flows.chooser.describeDescription':
|
||||
'Tell the assistant what you want and let it draft the workflow.',
|
||||
'flows.chooser.creating': 'Creating workflow…',
|
||||
'flows.chooser.createError': 'Could not create the workflow. Please try again.',
|
||||
'flows.templates.title': 'Start from a template',
|
||||
'flows.templates.subtitle': 'Pick a starting point and customize it in the editor.',
|
||||
'flows.templates.use': 'Use template',
|
||||
'flows.templates.back': 'Back',
|
||||
'flows.templates.empty': 'No templates available.',
|
||||
'flows.templates.category.scheduled': 'Scheduled',
|
||||
'flows.templates.category.triggered': 'Triggered',
|
||||
'flows.templates.category.onDemand': 'On demand',
|
||||
'flows.templates.daily-digest.name': 'Daily digest to channel',
|
||||
'flows.templates.daily-digest.description':
|
||||
'On a schedule, an agent writes a short summary and posts it to a channel.',
|
||||
'flows.templates.scheduled-scrape.name': 'Scheduled scrape to memory',
|
||||
'flows.templates.scheduled-scrape.description':
|
||||
'Fetch a source on a schedule, reshape the results, and store them in memory.',
|
||||
'flows.templates.webhook-triage.name': 'Webhook triage and notify',
|
||||
'flows.templates.webhook-triage.description':
|
||||
'An incoming webhook is triaged by an agent, then you get notified.',
|
||||
'flows.templates.app-event-route.name': 'App event to conditional action',
|
||||
'flows.templates.app-event-route.description':
|
||||
'A connected-app event runs a check, then takes an action when it matches.',
|
||||
'flows.templates.http-fetch-parse.name': 'Fetch and parse an API',
|
||||
'flows.templates.http-fetch-parse.description':
|
||||
'Call an HTTP endpoint on demand and parse the response into a usable shape.',
|
||||
'flows.templates.ask-agent.name': 'Ask the agent',
|
||||
'flows.templates.ask-agent.description': 'A simple manual trigger that hands a task to an agent.',
|
||||
|
||||
'oauth.button.connecting': 'Connecting...',
|
||||
'oauth.button.loopbackTimeout':
|
||||
'Sign-in timed out — the browser did not complete the OAuth redirect. Please try again.',
|
||||
|
||||
@@ -3175,6 +3175,7 @@ const messages: TranslationMap = {
|
||||
'chat.flowProposal.requireApprovalHint': 'Cada acción saliente necesitará tu aprobación.',
|
||||
'chat.flowProposal.save': 'Guardar y activar',
|
||||
'chat.flowProposal.saving': 'Guardando…',
|
||||
'chat.flowProposal.openInCanvas': 'Abrir en el lienzo',
|
||||
'chat.flowProposal.dismiss': 'Descartar',
|
||||
'chat.flowProposal.error': 'No se pudo guardar el flujo de trabajo. Inténtalo de nuevo.',
|
||||
'channels.authMode.managed_dm': 'Iniciar sesión con OpenHuman',
|
||||
@@ -3744,6 +3745,17 @@ const messages: TranslationMap = {
|
||||
'flowRuns.inspector.port': 'Puerto',
|
||||
'flowRuns.inspector.loading': 'Cargando ejecución…',
|
||||
'flowRuns.inspector.loadError': 'No se pudo cargar esta ejecución',
|
||||
'flowRuns.inspector.fixWithAgent': 'Corregir con el agente',
|
||||
'flowRuns.inspector.dataTable': 'Tabla',
|
||||
'flowRuns.inspector.dataJson': 'JSON',
|
||||
'flowRuns.inspector.dataViewLabel': 'Vista de salida',
|
||||
'flowRuns.inspector.itemCount': '{count} elemento(s)',
|
||||
'flowRuns.inspector.noItems': 'Sin elementos de salida',
|
||||
'flowRuns.inspector.emptyValue': '(vacío)',
|
||||
'flowRuns.inspector.binaryLabel': 'Binario',
|
||||
'flowRuns.inspector.showSource': 'Origen',
|
||||
'flowRuns.inspector.hideSource': 'Ocultar origen',
|
||||
'flowRuns.inspector.sourceInputTitle': 'Elemento de entrada de origen',
|
||||
'flowRuns.status.running': 'En ejecución',
|
||||
'flowRuns.status.completed': 'Completado',
|
||||
'flowRuns.status.pending_approval': 'Esperando aprobación',
|
||||
@@ -3776,11 +3788,47 @@ const messages: TranslationMap = {
|
||||
'flows.runs.loading': 'Cargando ejecuciones…',
|
||||
'flows.runs.loadError': 'No se pudieron cargar las ejecuciones',
|
||||
'flows.runs.empty': 'Aún no hay ejecuciones',
|
||||
'flows.promptBar.label': 'Describe un flujo de trabajo',
|
||||
'flows.promptBar.placeholder': 'Describe un flujo de trabajo…',
|
||||
'flows.promptBar.submit': 'Crear',
|
||||
'flows.promptBar.thinking': 'Creando…',
|
||||
'flows.promptBar.heroTitle': 'Describe un flujo de trabajo',
|
||||
'flows.promptBar.heroSubtitle': 'Indica al generador qué automatizar y revisa su propuesta.',
|
||||
'flows.promptBar.error': 'No se pudo contactar con el generador de flujos. Inténtalo de nuevo.',
|
||||
'flows.promptBar.offline': 'Estás sin conexión. Reconéctate para crear un flujo.',
|
||||
'flows.copilot.open': 'Copiloto',
|
||||
'flows.copilot.title': 'Copiloto de flujos',
|
||||
'flows.copilot.subtitle': 'Pide cambios y revisa cada propuesta antes de aplicarla.',
|
||||
'flows.copilot.close': 'Cerrar copiloto',
|
||||
'flows.copilot.placeholder': 'Pide un cambio…',
|
||||
'flows.copilot.send': 'Enviar',
|
||||
'flows.copilot.thinking': 'Pensando…',
|
||||
'flows.copilot.error': 'No se pudo contactar con el generador de flujos. Inténtalo de nuevo.',
|
||||
'flows.copilot.offline': 'Estás sin conexión. Reconéctate para usar el copiloto.',
|
||||
'flows.copilot.emptyState':
|
||||
'Describe un cambio en este flujo y el generador propondrá una actualización.',
|
||||
'flows.copilot.proposalTitle': 'Cambios propuestos',
|
||||
'flows.copilot.added': '{count} añadidos',
|
||||
'flows.copilot.removed': '{count} eliminados',
|
||||
'flows.copilot.noChanges': 'Esta propuesta no cambia ningún nodo.',
|
||||
'flows.copilot.accept': 'Aplicar al borrador',
|
||||
'flows.copilot.reject': 'Descartar',
|
||||
'flows.copilot.previewHint': 'Revisando un borrador propuesto: aún no se ha guardado nada.',
|
||||
'flows.copilot.repairDisplay': 'Falló una ejecución; revísala y propón una solución.',
|
||||
'flows.list.view': 'Ver flujo de trabajo',
|
||||
'flows.list.export': 'Exportar',
|
||||
'flows.list.exported': 'Flujo de trabajo exportado',
|
||||
'flows.page.import': 'Importar',
|
||||
'flows.import.invalidFile': 'Ese archivo no es un JSON de flujo de trabajo válido.',
|
||||
'flows.import.error':
|
||||
'No se pudo importar este flujo de trabajo. Comprueba el archivo e inténtalo de nuevo.',
|
||||
'flows.import.warningTitle': 'Advertencia de importación',
|
||||
'flows.canvas.title': 'Flujo de trabajo',
|
||||
'flows.canvas.loading': 'Cargando flujo de trabajo…',
|
||||
'flows.canvas.loadError': 'No se pudo cargar este flujo de trabajo. Inténtalo de nuevo.',
|
||||
'flows.canvas.notFound': 'No se pudo encontrar este flujo de trabajo.',
|
||||
'flows.canvas.draftMissing':
|
||||
'No hay ningún borrador de flujo para abrir. Propón uno desde el chat primero.',
|
||||
'flows.canvas.backToList': 'Volver a los flujos de trabajo',
|
||||
'flows.nodeKind.trigger': 'Disparador',
|
||||
'flows.nodeKind.agent': 'Agente',
|
||||
@@ -3794,6 +3842,122 @@ const messages: TranslationMap = {
|
||||
'flows.nodeKind.transform': 'Transformar',
|
||||
'flows.nodeKind.output_parser': 'Analizador de salida',
|
||||
'flows.nodeKind.sub_workflow': 'Subflujo de trabajo',
|
||||
'flows.palette.title': 'Nodos',
|
||||
'flows.palette.addNode': 'Añadir nodo {kind}',
|
||||
'flows.editor.save': 'Guardar',
|
||||
'flows.editor.deleteSelected': 'Eliminar selección',
|
||||
// ── Validation UX + draft/dirty state (issue B5b / Phase 3c–3d)
|
||||
'flows.editor.saving': 'Guardando…',
|
||||
'flows.editor.run': 'Ejecutar',
|
||||
'flows.editor.running': 'Ejecutando…',
|
||||
'flows.editor.runFailed': 'No se pudo iniciar la ejecución',
|
||||
'flows.editor.validate': 'Validar',
|
||||
'flows.editor.validating': 'Validando…',
|
||||
'flows.editor.discard': 'Descartar cambios',
|
||||
'flows.editor.unsaved': 'Cambios sin guardar',
|
||||
'flows.editor.saveBlocked': 'Corrige los errores de abajo antes de guardar.',
|
||||
'flows.editor.errorsTitle': 'Errores',
|
||||
'flows.editor.warningsTitle': 'Advertencias',
|
||||
'flows.editor.saveFailedTitle': 'No se pudo guardar',
|
||||
'flows.editor.leaveTitle': '¿Salir sin guardar?',
|
||||
'flows.editor.leaveBody':
|
||||
'Tienes cambios sin guardar en este flujo de trabajo. Si sales ahora, se perderán.',
|
||||
'flows.editor.leaveStay': 'Quedarme',
|
||||
'flows.editor.leaveDiscard': 'Salir',
|
||||
// ── Node config drawer (issue B5b / Phase 3b) — per-kind config forms
|
||||
'flows.nodeConfig.close': 'Cerrar ajustes',
|
||||
'flows.nodeConfig.nameLabel': 'Nombre',
|
||||
'flows.nodeConfig.namePlaceholder': 'Nombre del nodo',
|
||||
'flows.nodeConfig.editForm': 'Editar como formulario',
|
||||
'flows.nodeConfig.editJson': 'Editar como JSON',
|
||||
'flows.nodeConfig.rawJsonLabel': 'Configuración (JSON)',
|
||||
'flows.nodeConfig.rawJsonHint': 'Configuración libre para este nodo.',
|
||||
'flows.nodeConfig.rawJsonInvalid':
|
||||
'JSON no válido: los cambios no se aplican hasta que sea válido.',
|
||||
'flows.nodeConfig.expressionHint':
|
||||
'Empieza con = para calcular el valor a partir de la entrada del nodo, p. ej. =item.url',
|
||||
'flows.nodeConfig.expressionBadge': 'Expresión',
|
||||
'flows.nodeConfig.keymapKeyPlaceholder': 'Clave',
|
||||
'flows.nodeConfig.keymapValuePlaceholder': 'Valor',
|
||||
'flows.nodeConfig.keymapRemove': 'Quitar fila',
|
||||
'flows.nodeConfig.keymapAdd': 'Añadir fila',
|
||||
'flows.nodeConfig.credentialLabel': 'Credencial',
|
||||
'flows.nodeConfig.credentialHint': 'Elige una cuenta o credencial conectada para este nodo.',
|
||||
'flows.nodeConfig.credentialEmpty': 'No hay credenciales conectadas disponibles.',
|
||||
'flows.nodeConfig.credentialNone': 'Ninguna',
|
||||
'flows.nodeConfig.trigger.kindLabel': 'Tipo de activador',
|
||||
'flows.nodeConfig.trigger.kind_manual': 'Manual',
|
||||
'flows.nodeConfig.trigger.kind_schedule': 'Programación',
|
||||
'flows.nodeConfig.trigger.kind_webhook': 'Webhook',
|
||||
'flows.nodeConfig.trigger.kind_app_event': 'Evento de app',
|
||||
'flows.nodeConfig.trigger.scheduleLabel': 'Programación cron',
|
||||
'flows.nodeConfig.trigger.scheduleHint': 'Expresión cron: minuto hora día mes día de la semana.',
|
||||
'flows.nodeConfig.trigger.toolkitLabel': 'Kit de herramientas',
|
||||
'flows.nodeConfig.trigger.triggerSlugLabel': 'Slug del activador',
|
||||
'flows.nodeConfig.trigger.webhookHint':
|
||||
'Los activadores de webhook se guardan pero todavía no se ejecutan automáticamente.',
|
||||
'flows.nodeConfig.http.methodLabel': 'Método',
|
||||
'flows.nodeConfig.http.urlLabel': 'URL',
|
||||
'flows.nodeConfig.http.headersLabel': 'Encabezados',
|
||||
'flows.nodeConfig.http.bodyLabel': 'Cuerpo (JSON)',
|
||||
'flows.nodeConfig.agent.promptLabel': 'Instrucción',
|
||||
'flows.nodeConfig.agent.promptPlaceholder': 'Instrucciones para el agente…',
|
||||
'flows.nodeConfig.agent.modelLabel': 'Modelo',
|
||||
'flows.nodeConfig.tool.slugLabel': 'Slug de la herramienta',
|
||||
'flows.nodeConfig.tool.argsLabel': 'Argumentos (JSON)',
|
||||
'flows.nodeConfig.condition.fieldLabel': 'Campo',
|
||||
'flows.nodeConfig.condition.fieldHint':
|
||||
'Clave del elemento de entrada cuya veracidad se evalúa. Enruta a verdadero o falso.',
|
||||
'flows.nodeConfig.switch.expressionLabel': 'Expresión',
|
||||
'flows.nodeConfig.switch.hint':
|
||||
'El valor resultante selecciona el puerto de salida correspondiente; null va a default.',
|
||||
'flows.nodeConfig.switch.fieldLabel': 'Campo (alternativo)',
|
||||
'flows.nodeConfig.transform.setLabel': 'Definir campos',
|
||||
'flows.nodeConfig.transform.setHint':
|
||||
'Cada valor es una expresión evaluada por elemento, p. ej. =item.name',
|
||||
'flows.nodeConfig.code.languageLabel': 'Lenguaje',
|
||||
'flows.nodeConfig.code.language_javascript': 'JavaScript',
|
||||
'flows.nodeConfig.code.language_python': 'Python',
|
||||
'flows.nodeConfig.code.sourceLabel': 'Código fuente',
|
||||
|
||||
'flows.chooser.title': 'Crear un flujo de trabajo',
|
||||
'flows.chooser.subtitle': 'Elige cómo quieres empezar.',
|
||||
'flows.chooser.scratchTitle': 'Empezar desde cero',
|
||||
'flows.chooser.scratchDescription':
|
||||
'Comienza con un lienzo en blanco y un único disparador manual.',
|
||||
'flows.chooser.templateTitle': 'Desde una plantilla',
|
||||
'flows.chooser.templateDescription': 'Empieza con un ejemplo listo y personalízalo.',
|
||||
'flows.chooser.describeTitle': 'Descríbelo',
|
||||
'flows.chooser.describeDescription':
|
||||
'Dile al asistente lo que quieres y deja que redacte el flujo de trabajo.',
|
||||
'flows.chooser.creating': 'Creando el flujo de trabajo…',
|
||||
'flows.chooser.createError': 'No se pudo crear el flujo de trabajo. Inténtalo de nuevo.',
|
||||
'flows.templates.title': 'Empezar desde una plantilla',
|
||||
'flows.templates.subtitle': 'Elige un punto de partida y personalízalo en el editor.',
|
||||
'flows.templates.use': 'Usar plantilla',
|
||||
'flows.templates.back': 'Atrás',
|
||||
'flows.templates.empty': 'No hay plantillas disponibles.',
|
||||
'flows.templates.category.scheduled': 'Programado',
|
||||
'flows.templates.category.triggered': 'Activado',
|
||||
'flows.templates.category.onDemand': 'Bajo demanda',
|
||||
'flows.templates.daily-digest.name': 'Resumen diario al canal',
|
||||
'flows.templates.daily-digest.description':
|
||||
'Según una programación, un agente escribe un breve resumen y lo publica en un canal.',
|
||||
'flows.templates.scheduled-scrape.name': 'Extracción programada a la memoria',
|
||||
'flows.templates.scheduled-scrape.description':
|
||||
'Obtén una fuente según una programación, transforma los resultados y guárdalos en la memoria.',
|
||||
'flows.templates.webhook-triage.name': 'Clasificación de webhook y aviso',
|
||||
'flows.templates.webhook-triage.description':
|
||||
'Un agente clasifica un webhook entrante y luego recibes un aviso.',
|
||||
'flows.templates.app-event-route.name': 'Evento de app a acción condicional',
|
||||
'flows.templates.app-event-route.description':
|
||||
'Un evento de una app conectada ejecuta una comprobación y actúa cuando coincide.',
|
||||
'flows.templates.http-fetch-parse.name': 'Obtener y analizar una API',
|
||||
'flows.templates.http-fetch-parse.description':
|
||||
'Llama a un extremo HTTP bajo demanda y analiza la respuesta a una forma utilizable.',
|
||||
'flows.templates.ask-agent.name': 'Preguntar al agente',
|
||||
'flows.templates.ask-agent.description':
|
||||
'Un disparador manual sencillo que entrega una tarea a un agente.',
|
||||
|
||||
'oauth.button.connecting': 'Conectando...',
|
||||
'oauth.button.loopbackTimeout':
|
||||
|
||||
@@ -3189,6 +3189,7 @@ const messages: TranslationMap = {
|
||||
'chat.flowProposal.requireApprovalHint': 'Chaque action sortante nécessitera votre approbation.',
|
||||
'chat.flowProposal.save': 'Enregistrer et activer',
|
||||
'chat.flowProposal.saving': 'Enregistrement…',
|
||||
'chat.flowProposal.openInCanvas': 'Ouvrir dans le canevas',
|
||||
'chat.flowProposal.dismiss': 'Ignorer',
|
||||
'chat.flowProposal.error': "Impossible d'enregistrer le workflow. Veuillez réessayer.",
|
||||
'channels.authMode.managed_dm': 'Connectez-vous avec OpenHuman',
|
||||
@@ -3759,6 +3760,17 @@ const messages: TranslationMap = {
|
||||
'flowRuns.inspector.port': 'Port',
|
||||
'flowRuns.inspector.loading': "Chargement de l'exécution…",
|
||||
'flowRuns.inspector.loadError': 'Impossible de charger cette exécution',
|
||||
'flowRuns.inspector.fixWithAgent': "Corriger avec l'agent",
|
||||
'flowRuns.inspector.dataTable': 'Tableau',
|
||||
'flowRuns.inspector.dataJson': 'JSON',
|
||||
'flowRuns.inspector.dataViewLabel': 'Vue de sortie',
|
||||
'flowRuns.inspector.itemCount': '{count} élément(s)',
|
||||
'flowRuns.inspector.noItems': 'Aucun élément de sortie',
|
||||
'flowRuns.inspector.emptyValue': '(vide)',
|
||||
'flowRuns.inspector.binaryLabel': 'Binaire',
|
||||
'flowRuns.inspector.showSource': 'Source',
|
||||
'flowRuns.inspector.hideSource': 'Masquer la source',
|
||||
'flowRuns.inspector.sourceInputTitle': "Élément d'entrée source",
|
||||
'flowRuns.status.running': 'En cours',
|
||||
'flowRuns.status.completed': 'Terminé',
|
||||
'flowRuns.status.pending_approval': "En attente d'approbation",
|
||||
@@ -3791,11 +3803,48 @@ const messages: TranslationMap = {
|
||||
'flows.runs.loading': 'Chargement des exécutions…',
|
||||
'flows.runs.loadError': 'Impossible de charger les exécutions',
|
||||
'flows.runs.empty': 'Aucune exécution pour le moment',
|
||||
'flows.promptBar.label': 'Décrivez un flux de travail',
|
||||
'flows.promptBar.placeholder': 'Décrivez un flux de travail…',
|
||||
'flows.promptBar.submit': 'Créer',
|
||||
'flows.promptBar.thinking': 'Création…',
|
||||
'flows.promptBar.heroTitle': 'Décrivez un flux de travail',
|
||||
'flows.promptBar.heroSubtitle':
|
||||
'Indiquez au générateur quoi automatiser et examinez sa proposition.',
|
||||
'flows.promptBar.error': 'Impossible de joindre le générateur de flux. Veuillez réessayer.',
|
||||
'flows.promptBar.offline': 'Vous êtes hors ligne. Reconnectez-vous pour créer un flux.',
|
||||
'flows.copilot.open': 'Copilote',
|
||||
'flows.copilot.title': 'Copilote de flux',
|
||||
'flows.copilot.subtitle':
|
||||
'Demandez des modifications et examinez chaque proposition avant de l’appliquer.',
|
||||
'flows.copilot.close': 'Fermer le copilote',
|
||||
'flows.copilot.placeholder': 'Demandez une modification…',
|
||||
'flows.copilot.send': 'Envoyer',
|
||||
'flows.copilot.thinking': 'Réflexion…',
|
||||
'flows.copilot.error': 'Impossible de joindre le générateur de flux. Veuillez réessayer.',
|
||||
'flows.copilot.offline': 'Vous êtes hors ligne. Reconnectez-vous pour utiliser le copilote.',
|
||||
'flows.copilot.emptyState':
|
||||
'Décrivez une modification de ce flux et le générateur proposera une mise à jour.',
|
||||
'flows.copilot.proposalTitle': 'Modifications proposées',
|
||||
'flows.copilot.added': '{count} ajoutés',
|
||||
'flows.copilot.removed': '{count} supprimés',
|
||||
'flows.copilot.noChanges': 'Cette proposition ne modifie aucun nœud.',
|
||||
'flows.copilot.accept': 'Appliquer au brouillon',
|
||||
'flows.copilot.reject': 'Ignorer',
|
||||
'flows.copilot.previewHint': 'Examen d’un brouillon proposé — rien n’est encore enregistré.',
|
||||
'flows.copilot.repairDisplay': 'Une exécution a échoué ; examinez-la et proposez une correction.',
|
||||
'flows.list.view': 'Voir le workflow',
|
||||
'flows.list.export': 'Exporter',
|
||||
'flows.list.exported': 'Workflow exporté',
|
||||
'flows.page.import': 'Importer',
|
||||
'flows.import.invalidFile': "Ce fichier n'est pas un JSON de workflow valide.",
|
||||
'flows.import.error': "Impossible d'importer ce workflow. Vérifiez le fichier et réessayez.",
|
||||
'flows.import.warningTitle': "Avertissement d'importation",
|
||||
'flows.canvas.title': 'Workflow',
|
||||
'flows.canvas.loading': 'Chargement du workflow…',
|
||||
'flows.canvas.loadError': 'Impossible de charger ce workflow. Veuillez réessayer.',
|
||||
'flows.canvas.notFound': 'Ce workflow est introuvable.',
|
||||
'flows.canvas.draftMissing':
|
||||
'Aucun brouillon de flux à ouvrir. Proposez-en un depuis la discussion d’abord.',
|
||||
'flows.canvas.backToList': 'Retour aux workflows',
|
||||
'flows.nodeKind.trigger': 'Déclencheur',
|
||||
'flows.nodeKind.agent': 'Agent',
|
||||
@@ -3809,6 +3858,124 @@ const messages: TranslationMap = {
|
||||
'flows.nodeKind.transform': 'Transformation',
|
||||
'flows.nodeKind.output_parser': 'Analyseur de sortie',
|
||||
'flows.nodeKind.sub_workflow': 'Sous-workflow',
|
||||
'flows.palette.title': 'Nœuds',
|
||||
'flows.palette.addNode': 'Ajouter un nœud {kind}',
|
||||
'flows.editor.save': 'Enregistrer',
|
||||
'flows.editor.deleteSelected': 'Supprimer la sélection',
|
||||
// ── Validation UX + draft/dirty state (issue B5b / Phase 3c–3d)
|
||||
'flows.editor.saving': 'Enregistrement…',
|
||||
'flows.editor.run': 'Exécuter',
|
||||
'flows.editor.running': 'Exécution…',
|
||||
'flows.editor.runFailed': 'Impossible de démarrer l’exécution',
|
||||
'flows.editor.validate': 'Valider',
|
||||
'flows.editor.validating': 'Validation…',
|
||||
'flows.editor.discard': 'Annuler les modifications',
|
||||
'flows.editor.unsaved': 'Modifications non enregistrées',
|
||||
'flows.editor.saveBlocked': 'Corrigez les erreurs ci-dessous avant d’enregistrer.',
|
||||
'flows.editor.errorsTitle': 'Erreurs',
|
||||
'flows.editor.warningsTitle': 'Avertissements',
|
||||
'flows.editor.saveFailedTitle': 'Échec de l’enregistrement',
|
||||
'flows.editor.leaveTitle': 'Quitter sans enregistrer ?',
|
||||
'flows.editor.leaveBody':
|
||||
'Vous avez des modifications non enregistrées sur ce workflow. Si vous partez maintenant, elles seront perdues.',
|
||||
'flows.editor.leaveStay': 'Rester',
|
||||
'flows.editor.leaveDiscard': 'Quitter',
|
||||
// ── Node config drawer (issue B5b / Phase 3b) — per-kind config forms
|
||||
'flows.nodeConfig.close': 'Fermer les réglages',
|
||||
'flows.nodeConfig.nameLabel': 'Nom',
|
||||
'flows.nodeConfig.namePlaceholder': 'Nom du nœud',
|
||||
'flows.nodeConfig.editForm': 'Modifier en formulaire',
|
||||
'flows.nodeConfig.editJson': 'Modifier en JSON',
|
||||
'flows.nodeConfig.rawJsonLabel': 'Configuration au format JSON',
|
||||
'flows.nodeConfig.rawJsonHint': 'Configuration libre pour ce nœud.',
|
||||
'flows.nodeConfig.rawJsonInvalid':
|
||||
"JSON invalide : les modifications ne sont appliquées qu'une fois valide.",
|
||||
'flows.nodeConfig.expressionHint':
|
||||
"Commencez par = pour calculer la valeur à partir de l'entrée du nœud, ex. =item.url",
|
||||
'flows.nodeConfig.expressionBadge': 'Expression',
|
||||
'flows.nodeConfig.keymapKeyPlaceholder': 'Clé',
|
||||
'flows.nodeConfig.keymapValuePlaceholder': 'Valeur',
|
||||
'flows.nodeConfig.keymapRemove': 'Supprimer la ligne',
|
||||
'flows.nodeConfig.keymapAdd': 'Ajouter une ligne',
|
||||
'flows.nodeConfig.credentialLabel': 'Identifiant',
|
||||
'flows.nodeConfig.credentialHint':
|
||||
'Choisissez un compte ou un identifiant connecté pour ce nœud.',
|
||||
'flows.nodeConfig.credentialEmpty': 'Aucun identifiant connecté disponible.',
|
||||
'flows.nodeConfig.credentialNone': 'Aucun',
|
||||
'flows.nodeConfig.trigger.kindLabel': 'Type de déclencheur',
|
||||
'flows.nodeConfig.trigger.kind_manual': 'Manuel',
|
||||
'flows.nodeConfig.trigger.kind_schedule': 'Planification',
|
||||
'flows.nodeConfig.trigger.kind_webhook': 'Webhook',
|
||||
'flows.nodeConfig.trigger.kind_app_event': "Événement d'app",
|
||||
'flows.nodeConfig.trigger.scheduleLabel': 'Planification cron',
|
||||
'flows.nodeConfig.trigger.scheduleHint':
|
||||
'Expression cron : minute heure jour mois jour de la semaine.',
|
||||
'flows.nodeConfig.trigger.toolkitLabel': 'Boîte à outils',
|
||||
'flows.nodeConfig.trigger.triggerSlugLabel': 'Slug du déclencheur',
|
||||
'flows.nodeConfig.trigger.webhookHint':
|
||||
'Les déclencheurs webhook sont enregistrés mais pas encore déclenchés automatiquement.',
|
||||
'flows.nodeConfig.http.methodLabel': 'Méthode',
|
||||
'flows.nodeConfig.http.urlLabel': 'URL',
|
||||
'flows.nodeConfig.http.headersLabel': 'En-têtes',
|
||||
'flows.nodeConfig.http.bodyLabel': 'Corps (JSON)',
|
||||
'flows.nodeConfig.agent.promptLabel': 'Invite',
|
||||
'flows.nodeConfig.agent.promptPlaceholder': "Instructions pour l'agent…",
|
||||
'flows.nodeConfig.agent.modelLabel': 'Modèle',
|
||||
'flows.nodeConfig.tool.slugLabel': "Slug de l'outil",
|
||||
'flows.nodeConfig.tool.argsLabel': 'Paramètres (JSON)',
|
||||
'flows.nodeConfig.condition.fieldLabel': 'Champ',
|
||||
'flows.nodeConfig.condition.fieldHint':
|
||||
"Clé de l'élément d'entrée à évaluer comme vraie. Achemine vers vrai ou faux.",
|
||||
'flows.nodeConfig.switch.expressionLabel': 'Expression',
|
||||
'flows.nodeConfig.switch.hint':
|
||||
'La valeur obtenue sélectionne le port de sortie correspondant ; null va vers default.',
|
||||
'flows.nodeConfig.switch.fieldLabel': 'Champ (repli)',
|
||||
'flows.nodeConfig.transform.setLabel': 'Définir les champs',
|
||||
'flows.nodeConfig.transform.setHint':
|
||||
'Chaque valeur est une expression évaluée par élément, ex. =item.name',
|
||||
'flows.nodeConfig.code.languageLabel': 'Langage',
|
||||
'flows.nodeConfig.code.language_javascript': 'JavaScript',
|
||||
'flows.nodeConfig.code.language_python': 'Python',
|
||||
'flows.nodeConfig.code.sourceLabel': 'Code source',
|
||||
|
||||
'flows.chooser.title': 'Créer un workflow',
|
||||
'flows.chooser.subtitle': 'Choisissez comment commencer.',
|
||||
'flows.chooser.scratchTitle': 'Partir de zéro',
|
||||
'flows.chooser.scratchDescription':
|
||||
'Commencez avec une toile vierge et un seul déclencheur manuel.',
|
||||
'flows.chooser.templateTitle': "À partir d'un modèle",
|
||||
'flows.chooser.templateDescription': "Partez d'un exemple prêt à l'emploi et personnalisez-le.",
|
||||
'flows.chooser.describeTitle': 'Décrivez-le',
|
||||
'flows.chooser.describeDescription':
|
||||
"Dites à l'assistant ce que vous voulez et laissez-le rédiger le workflow.",
|
||||
'flows.chooser.creating': 'Création du workflow…',
|
||||
'flows.chooser.createError': 'Impossible de créer le workflow. Veuillez réessayer.',
|
||||
'flows.templates.title': "Partir d'un modèle",
|
||||
'flows.templates.subtitle': "Choisissez un point de départ et personnalisez-le dans l'éditeur.",
|
||||
'flows.templates.use': 'Utiliser le modèle',
|
||||
'flows.templates.back': 'Retour',
|
||||
'flows.templates.empty': 'Aucun modèle disponible.',
|
||||
'flows.templates.category.scheduled': 'Planifié',
|
||||
'flows.templates.category.triggered': 'Déclenché',
|
||||
'flows.templates.category.onDemand': 'À la demande',
|
||||
'flows.templates.daily-digest.name': 'Résumé quotidien vers un canal',
|
||||
'flows.templates.daily-digest.description':
|
||||
'Selon une planification, un agent rédige un court résumé et le publie dans un canal.',
|
||||
'flows.templates.scheduled-scrape.name': 'Extraction planifiée vers la mémoire',
|
||||
'flows.templates.scheduled-scrape.description':
|
||||
'Récupère une source selon une planification, remodèle les résultats et les enregistre en mémoire.',
|
||||
'flows.templates.webhook-triage.name': 'Tri du webhook et notification',
|
||||
'flows.templates.webhook-triage.description':
|
||||
'Un webhook entrant est trié par un agent, puis vous êtes notifié.',
|
||||
'flows.templates.app-event-route.name': "Événement d'app vers action conditionnelle",
|
||||
'flows.templates.app-event-route.description':
|
||||
"Un événement d'une app connectée exécute une vérification, puis agit en cas de correspondance.",
|
||||
'flows.templates.http-fetch-parse.name': 'Récupérer et analyser une API',
|
||||
'flows.templates.http-fetch-parse.description':
|
||||
'Appelez un point de terminaison HTTP à la demande et analysez la réponse en une forme exploitable.',
|
||||
'flows.templates.ask-agent.name': "Demander à l'agent",
|
||||
'flows.templates.ask-agent.description':
|
||||
'Un simple déclencheur manuel qui confie une tâche à un agent.',
|
||||
|
||||
'oauth.button.connecting': 'Connexion en cours…',
|
||||
'oauth.button.loopbackTimeout':
|
||||
|
||||
@@ -3120,6 +3120,7 @@ const messages: TranslationMap = {
|
||||
'chat.flowProposal.requireApprovalHint': 'हर बाहरी कार्रवाई के लिए आपकी स्वीकृति आवश्यक होगी।',
|
||||
'chat.flowProposal.save': 'सहेजें और सक्षम करें',
|
||||
'chat.flowProposal.saving': 'सहेजा जा रहा है…',
|
||||
'chat.flowProposal.openInCanvas': 'कैनवास में खोलें',
|
||||
'chat.flowProposal.dismiss': 'खारिज करें',
|
||||
'chat.flowProposal.error': 'वर्कफ़्लो सहेजा नहीं जा सका। कृपया फिर से प्रयास करें।',
|
||||
'channels.authMode.managed_dm': 'OpenHuman से लॉगिन करें',
|
||||
@@ -3682,6 +3683,17 @@ const messages: TranslationMap = {
|
||||
'flowRuns.inspector.port': 'पोर्ट',
|
||||
'flowRuns.inspector.loading': 'रन लोड हो रहा है…',
|
||||
'flowRuns.inspector.loadError': 'यह रन लोड नहीं हो सका',
|
||||
'flowRuns.inspector.fixWithAgent': 'एजेंट से ठीक करें',
|
||||
'flowRuns.inspector.dataTable': 'तालिका',
|
||||
'flowRuns.inspector.dataJson': 'JSON',
|
||||
'flowRuns.inspector.dataViewLabel': 'आउटपुट दृश्य',
|
||||
'flowRuns.inspector.itemCount': '{count} आइटम',
|
||||
'flowRuns.inspector.noItems': 'कोई आउटपुट आइटम नहीं',
|
||||
'flowRuns.inspector.emptyValue': '(खाली)',
|
||||
'flowRuns.inspector.binaryLabel': 'बाइनरी',
|
||||
'flowRuns.inspector.showSource': 'स्रोत',
|
||||
'flowRuns.inspector.hideSource': 'स्रोत छिपाएँ',
|
||||
'flowRuns.inspector.sourceInputTitle': 'स्रोत इनपुट आइटम',
|
||||
'flowRuns.status.running': 'चल रहा है',
|
||||
'flowRuns.status.completed': 'पूर्ण',
|
||||
'flowRuns.status.pending_approval': 'अनुमोदन की प्रतीक्षा में',
|
||||
@@ -3712,11 +3724,47 @@ const messages: TranslationMap = {
|
||||
'flows.runs.loading': 'रन लोड हो रहे हैं…',
|
||||
'flows.runs.loadError': 'रन लोड नहीं हो सके',
|
||||
'flows.runs.empty': 'अभी तक कोई रन नहीं',
|
||||
'flows.promptBar.label': 'एक वर्कफ़्लो का वर्णन करें',
|
||||
'flows.promptBar.placeholder': 'एक वर्कफ़्लो का वर्णन करें…',
|
||||
'flows.promptBar.submit': 'बनाएँ',
|
||||
'flows.promptBar.thinking': 'बनाया जा रहा है…',
|
||||
'flows.promptBar.heroTitle': 'एक वर्कफ़्लो का वर्णन करें',
|
||||
'flows.promptBar.heroSubtitle':
|
||||
'बिल्डर को बताएँ कि क्या स्वचालित करना है और उसके प्रस्ताव की समीक्षा करें।',
|
||||
'flows.promptBar.error': 'वर्कफ़्लो बिल्डर तक नहीं पहुँच सके। कृपया फिर से प्रयास करें।',
|
||||
'flows.promptBar.offline': 'आप ऑफ़लाइन हैं। वर्कफ़्लो बनाने के लिए फिर से कनेक्ट करें।',
|
||||
'flows.copilot.open': 'सहपायलट',
|
||||
'flows.copilot.title': 'वर्कफ़्लो सहपायलट',
|
||||
'flows.copilot.subtitle': 'बदलाव माँगें और लागू करने से पहले हर प्रस्ताव की समीक्षा करें।',
|
||||
'flows.copilot.close': 'सहपायलट बंद करें',
|
||||
'flows.copilot.placeholder': 'एक बदलाव माँगें…',
|
||||
'flows.copilot.send': 'भेजें',
|
||||
'flows.copilot.thinking': 'सोच रहे हैं…',
|
||||
'flows.copilot.error': 'वर्कफ़्लो बिल्डर तक नहीं पहुँच सके। कृपया फिर से प्रयास करें।',
|
||||
'flows.copilot.offline': 'आप ऑफ़लाइन हैं। सहपायलट का उपयोग करने के लिए फिर से कनेक्ट करें।',
|
||||
'flows.copilot.emptyState': 'इस वर्कफ़्लो में बदलाव का वर्णन करें और बिल्डर एक अपडेट सुझाएगा।',
|
||||
'flows.copilot.proposalTitle': 'प्रस्तावित बदलाव',
|
||||
'flows.copilot.added': '{count} जोड़े गए',
|
||||
'flows.copilot.removed': '{count} हटाए गए',
|
||||
'flows.copilot.noChanges': 'यह प्रस्ताव किसी नोड को नहीं बदलता।',
|
||||
'flows.copilot.accept': 'ड्राफ़्ट पर लागू करें',
|
||||
'flows.copilot.reject': 'रद्द करें',
|
||||
'flows.copilot.previewHint':
|
||||
'एक प्रस्तावित ड्राफ़्ट की समीक्षा हो रही है — अभी कुछ सहेजा नहीं गया।',
|
||||
'flows.copilot.repairDisplay': 'एक रन विफल हुआ; उसे देखें और सुधार सुझाएँ।',
|
||||
'flows.list.view': 'वर्कफ़्लो देखें',
|
||||
'flows.list.export': 'निर्यात',
|
||||
'flows.list.exported': 'वर्कफ़्लो निर्यात किया गया',
|
||||
'flows.page.import': 'आयात',
|
||||
'flows.import.invalidFile': 'यह फ़ाइल मान्य वर्कफ़्लो JSON नहीं है।',
|
||||
'flows.import.error': 'इस वर्कफ़्लो को आयात नहीं किया जा सका। फ़ाइल जाँचें और पुनः प्रयास करें।',
|
||||
'flows.import.warningTitle': 'आयात चेतावनी',
|
||||
'flows.canvas.title': 'वर्कफ़्लो',
|
||||
'flows.canvas.loading': 'वर्कफ़्लो लोड हो रहा है…',
|
||||
'flows.canvas.loadError': 'यह वर्कफ़्लो लोड नहीं हो सका। कृपया पुनः प्रयास करें।',
|
||||
'flows.canvas.notFound': 'यह वर्कफ़्लो नहीं मिला।',
|
||||
'flows.canvas.draftMissing':
|
||||
'खोलने के लिए कोई वर्कफ़्लो ड्राफ़्ट नहीं है। पहले चैट से एक प्रस्तावित करें।',
|
||||
'flows.canvas.backToList': 'वर्कफ़्लो सूची पर वापस जाएं',
|
||||
'flows.nodeKind.trigger': 'ट्रिगर',
|
||||
'flows.nodeKind.agent': 'एजेंट',
|
||||
@@ -3730,6 +3778,120 @@ const messages: TranslationMap = {
|
||||
'flows.nodeKind.transform': 'रूपांतरण',
|
||||
'flows.nodeKind.output_parser': 'आउटपुट पार्सर',
|
||||
'flows.nodeKind.sub_workflow': 'सब-वर्कफ़्लो',
|
||||
'flows.palette.title': 'नोड',
|
||||
'flows.palette.addNode': '{kind} नोड जोड़ें',
|
||||
'flows.editor.save': 'सहेजें',
|
||||
'flows.editor.deleteSelected': 'चयनित हटाएं',
|
||||
// ── Validation UX + draft/dirty state (issue B5b / Phase 3c–3d)
|
||||
'flows.editor.saving': 'सहेजा जा रहा है…',
|
||||
'flows.editor.run': 'चलाएं',
|
||||
'flows.editor.running': 'चल रहा है…',
|
||||
'flows.editor.runFailed': 'रन शुरू नहीं हो सका',
|
||||
'flows.editor.validate': 'सत्यापित करें',
|
||||
'flows.editor.validating': 'सत्यापित किया जा रहा है…',
|
||||
'flows.editor.discard': 'परिवर्तन रद्द करें',
|
||||
'flows.editor.unsaved': 'बिना सहेजे परिवर्तन',
|
||||
'flows.editor.saveBlocked': 'सहेजने से पहले नीचे दी गई त्रुटियाँ ठीक करें।',
|
||||
'flows.editor.errorsTitle': 'त्रुटियाँ',
|
||||
'flows.editor.warningsTitle': 'चेतावनियाँ',
|
||||
'flows.editor.saveFailedTitle': 'सहेजा नहीं जा सका',
|
||||
'flows.editor.leaveTitle': 'बिना सहेजे छोड़ें?',
|
||||
'flows.editor.leaveBody':
|
||||
'इस वर्कफ़्लो में आपके बिना सहेजे परिवर्तन हैं। अभी छोड़ने पर वे खो जाएँगे।',
|
||||
'flows.editor.leaveStay': 'रुकें',
|
||||
'flows.editor.leaveDiscard': 'छोड़ें',
|
||||
// ── Node config drawer (issue B5b / Phase 3b) — per-kind config forms
|
||||
'flows.nodeConfig.close': 'सेटिंग बंद करें',
|
||||
'flows.nodeConfig.nameLabel': 'नाम',
|
||||
'flows.nodeConfig.namePlaceholder': 'नोड नाम',
|
||||
'flows.nodeConfig.editForm': 'फ़ॉर्म के रूप में संपादित करें',
|
||||
'flows.nodeConfig.editJson': 'JSON के रूप में संपादित करें',
|
||||
'flows.nodeConfig.rawJsonLabel': 'कॉन्फ़िगरेशन (JSON)',
|
||||
'flows.nodeConfig.rawJsonHint': 'इस नोड के लिए मुक्त कॉन्फ़िगरेशन।',
|
||||
'flows.nodeConfig.rawJsonInvalid': 'अमान्य JSON — पार्स होने तक बदलाव लागू नहीं होते।',
|
||||
'flows.nodeConfig.expressionHint':
|
||||
'नोड इनपुट से मान की गणना के लिए = से शुरू करें, जैसे =item.url',
|
||||
'flows.nodeConfig.expressionBadge': 'अभिव्यक्ति',
|
||||
'flows.nodeConfig.keymapKeyPlaceholder': 'कुंजी',
|
||||
'flows.nodeConfig.keymapValuePlaceholder': 'मान',
|
||||
'flows.nodeConfig.keymapRemove': 'पंक्ति हटाएँ',
|
||||
'flows.nodeConfig.keymapAdd': 'पंक्ति जोड़ें',
|
||||
'flows.nodeConfig.credentialLabel': 'क्रेडेंशियल',
|
||||
'flows.nodeConfig.credentialHint': 'इस नोड के लिए एक कनेक्टेड खाता या क्रेडेंशियल चुनें।',
|
||||
'flows.nodeConfig.credentialEmpty': 'कोई कनेक्टेड क्रेडेंशियल उपलब्ध नहीं।',
|
||||
'flows.nodeConfig.credentialNone': 'कोई नहीं',
|
||||
'flows.nodeConfig.trigger.kindLabel': 'ट्रिगर प्रकार',
|
||||
'flows.nodeConfig.trigger.kind_manual': 'मैनुअल',
|
||||
'flows.nodeConfig.trigger.kind_schedule': 'शेड्यूल',
|
||||
'flows.nodeConfig.trigger.kind_webhook': 'वेबहुक',
|
||||
'flows.nodeConfig.trigger.kind_app_event': 'ऐप इवेंट',
|
||||
'flows.nodeConfig.trigger.scheduleLabel': 'क्रॉन शेड्यूल',
|
||||
'flows.nodeConfig.trigger.scheduleHint': 'क्रॉन एक्सप्रेशन: मिनट घंटा दिन महीना सप्ताह का दिन।',
|
||||
'flows.nodeConfig.trigger.toolkitLabel': 'टूलकिट',
|
||||
'flows.nodeConfig.trigger.triggerSlugLabel': 'ट्रिगर स्लग',
|
||||
'flows.nodeConfig.trigger.webhookHint':
|
||||
'वेबहुक ट्रिगर सहेजे जाते हैं पर अभी स्वचालित रूप से नहीं चलते।',
|
||||
'flows.nodeConfig.http.methodLabel': 'विधि',
|
||||
'flows.nodeConfig.http.urlLabel': 'URL',
|
||||
'flows.nodeConfig.http.headersLabel': 'हेडर',
|
||||
'flows.nodeConfig.http.bodyLabel': 'बॉडी (JSON)',
|
||||
'flows.nodeConfig.agent.promptLabel': 'प्रॉम्प्ट',
|
||||
'flows.nodeConfig.agent.promptPlaceholder': 'एजेंट के लिए निर्देश…',
|
||||
'flows.nodeConfig.agent.modelLabel': 'मॉडल',
|
||||
'flows.nodeConfig.tool.slugLabel': 'टूल स्लग',
|
||||
'flows.nodeConfig.tool.argsLabel': 'तर्क (JSON)',
|
||||
'flows.nodeConfig.condition.fieldLabel': 'फ़ील्ड',
|
||||
'flows.nodeConfig.condition.fieldHint':
|
||||
'सत्यता जाँचने के लिए इनपुट आइटम की कुंजी। true या false की ओर रूट करता है।',
|
||||
'flows.nodeConfig.switch.expressionLabel': 'अभिव्यक्ति',
|
||||
'flows.nodeConfig.switch.hint':
|
||||
'परिणामी मान मिलान वाला आउटपुट पोर्ट चुनता है; null default पर जाता है।',
|
||||
'flows.nodeConfig.switch.fieldLabel': 'फ़ील्ड (वैकल्पिक)',
|
||||
'flows.nodeConfig.transform.setLabel': 'फ़ील्ड सेट करें',
|
||||
'flows.nodeConfig.transform.setHint':
|
||||
'प्रत्येक मान प्रति आइटम मूल्यांकित एक अभिव्यक्ति है, जैसे =item.name',
|
||||
'flows.nodeConfig.code.languageLabel': 'भाषा',
|
||||
'flows.nodeConfig.code.language_javascript': 'JavaScript',
|
||||
'flows.nodeConfig.code.language_python': 'Python',
|
||||
'flows.nodeConfig.code.sourceLabel': 'सोर्स कोड',
|
||||
|
||||
'flows.chooser.title': 'वर्कफ़्लो बनाएँ',
|
||||
'flows.chooser.subtitle': 'चुनें कि आप कैसे शुरू करना चाहते हैं।',
|
||||
'flows.chooser.scratchTitle': 'शुरू से शुरू करें',
|
||||
'flows.chooser.scratchDescription': 'एक खाली कैनवास और एकल मैन्युअल ट्रिगर के साथ शुरू करें।',
|
||||
'flows.chooser.templateTitle': 'टेम्पलेट से',
|
||||
'flows.chooser.templateDescription': 'तैयार उदाहरण से शुरू करें और उसे अनुकूलित करें।',
|
||||
'flows.chooser.describeTitle': 'इसका वर्णन करें',
|
||||
'flows.chooser.describeDescription':
|
||||
'सहायक को बताएँ कि आप क्या चाहते हैं और उसे वर्कफ़्लो का मसौदा बनाने दें।',
|
||||
'flows.chooser.creating': 'वर्कफ़्लो बनाया जा रहा है…',
|
||||
'flows.chooser.createError': 'वर्कफ़्लो नहीं बनाया जा सका। कृपया पुनः प्रयास करें।',
|
||||
'flows.templates.title': 'टेम्पलेट से शुरू करें',
|
||||
'flows.templates.subtitle': 'एक प्रारंभिक बिंदु चुनें और संपादक में उसे अनुकूलित करें।',
|
||||
'flows.templates.use': 'टेम्पलेट उपयोग करें',
|
||||
'flows.templates.back': 'वापस',
|
||||
'flows.templates.empty': 'कोई टेम्पलेट उपलब्ध नहीं।',
|
||||
'flows.templates.category.scheduled': 'निर्धारित',
|
||||
'flows.templates.category.triggered': 'ट्रिगर किया गया',
|
||||
'flows.templates.category.onDemand': 'माँग पर',
|
||||
'flows.templates.daily-digest.name': 'चैनल पर दैनिक सारांश',
|
||||
'flows.templates.daily-digest.description':
|
||||
'एक शेड्यूल पर, एजेंट एक छोटा सारांश लिखता है और उसे चैनल पर पोस्ट करता है।',
|
||||
'flows.templates.scheduled-scrape.name': 'निर्धारित स्क्रैप मेमोरी में',
|
||||
'flows.templates.scheduled-scrape.description':
|
||||
'शेड्यूल पर एक स्रोत प्राप्त करें, परिणामों को पुनः आकार दें, और उन्हें मेमोरी में संग्रहीत करें।',
|
||||
'flows.templates.webhook-triage.name': 'वेबहुक ट्राइएज और सूचना',
|
||||
'flows.templates.webhook-triage.description':
|
||||
'आने वाले वेबहुक को एक एजेंट द्वारा छाँटा जाता है, फिर आपको सूचित किया जाता है।',
|
||||
'flows.templates.app-event-route.name': 'ऐप इवेंट से सशर्त क्रिया',
|
||||
'flows.templates.app-event-route.description':
|
||||
'किसी जुड़े ऐप की घटना एक जाँच चलाती है, फिर मिलान होने पर कार्रवाई करती है।',
|
||||
'flows.templates.http-fetch-parse.name': 'API प्राप्त करें और पार्स करें',
|
||||
'flows.templates.http-fetch-parse.description':
|
||||
'माँग पर एक HTTP एंडपॉइंट को कॉल करें और प्रतिक्रिया को उपयोगी रूप में पार्स करें।',
|
||||
'flows.templates.ask-agent.name': 'एजेंट से पूछें',
|
||||
'flows.templates.ask-agent.description':
|
||||
'एक सरल मैन्युअल ट्रिगर जो किसी कार्य को एजेंट को सौंपता है।',
|
||||
|
||||
'oauth.button.connecting': 'कनेक्ट हो रहा है...',
|
||||
'oauth.button.loopbackTimeout':
|
||||
|
||||
@@ -3131,6 +3131,7 @@ const messages: TranslationMap = {
|
||||
'Setiap tindakan keluar akan memerlukan persetujuan Anda.',
|
||||
'chat.flowProposal.save': 'Simpan & aktifkan',
|
||||
'chat.flowProposal.saving': 'Menyimpan…',
|
||||
'chat.flowProposal.openInCanvas': 'Buka di kanvas',
|
||||
'chat.flowProposal.dismiss': 'Abaikan',
|
||||
'chat.flowProposal.error': 'Alur kerja tidak dapat disimpan. Silakan coba lagi.',
|
||||
'channels.authMode.managed_dm': 'Masuk dengan OpenHuman',
|
||||
@@ -3690,6 +3691,17 @@ const messages: TranslationMap = {
|
||||
'flowRuns.inspector.port': 'Port',
|
||||
'flowRuns.inspector.loading': 'Memuat proses…',
|
||||
'flowRuns.inspector.loadError': 'Tidak dapat memuat proses ini',
|
||||
'flowRuns.inspector.fixWithAgent': 'Perbaiki dengan agen',
|
||||
'flowRuns.inspector.dataTable': 'Tabel',
|
||||
'flowRuns.inspector.dataJson': 'JSON',
|
||||
'flowRuns.inspector.dataViewLabel': 'Tampilan keluaran',
|
||||
'flowRuns.inspector.itemCount': '{count} item',
|
||||
'flowRuns.inspector.noItems': 'Tidak ada item keluaran',
|
||||
'flowRuns.inspector.emptyValue': '(kosong)',
|
||||
'flowRuns.inspector.binaryLabel': 'Biner',
|
||||
'flowRuns.inspector.showSource': 'Sumber',
|
||||
'flowRuns.inspector.hideSource': 'Sembunyikan sumber',
|
||||
'flowRuns.inspector.sourceInputTitle': 'Item masukan sumber',
|
||||
'flowRuns.status.running': 'Berjalan',
|
||||
'flowRuns.status.completed': 'Selesai',
|
||||
'flowRuns.status.pending_approval': 'Menunggu persetujuan',
|
||||
@@ -3721,11 +3733,47 @@ const messages: TranslationMap = {
|
||||
'flows.runs.loading': 'Memuat proses…',
|
||||
'flows.runs.loadError': 'Tidak dapat memuat proses',
|
||||
'flows.runs.empty': 'Belum ada proses',
|
||||
'flows.promptBar.label': 'Jelaskan sebuah alur kerja',
|
||||
'flows.promptBar.placeholder': 'Jelaskan sebuah alur kerja…',
|
||||
'flows.promptBar.submit': 'Buat',
|
||||
'flows.promptBar.thinking': 'Membuat…',
|
||||
'flows.promptBar.heroTitle': 'Jelaskan sebuah alur kerja',
|
||||
'flows.promptBar.heroSubtitle':
|
||||
'Beri tahu pembuat apa yang harus diotomatiskan lalu tinjau usulannya.',
|
||||
'flows.promptBar.error': 'Tidak dapat menghubungi pembuat alur kerja. Silakan coba lagi.',
|
||||
'flows.promptBar.offline': 'Anda sedang luring. Sambungkan kembali untuk membuat alur kerja.',
|
||||
'flows.copilot.open': 'Kopilot',
|
||||
'flows.copilot.title': 'Kopilot alur kerja',
|
||||
'flows.copilot.subtitle': 'Minta perubahan dan tinjau setiap usulan sebelum menerapkannya.',
|
||||
'flows.copilot.close': 'Tutup kopilot',
|
||||
'flows.copilot.placeholder': 'Minta sebuah perubahan…',
|
||||
'flows.copilot.send': 'Kirim',
|
||||
'flows.copilot.thinking': 'Sedang berpikir…',
|
||||
'flows.copilot.error': 'Tidak dapat menghubungi pembuat alur kerja. Silakan coba lagi.',
|
||||
'flows.copilot.offline': 'Anda sedang luring. Sambungkan kembali untuk memakai kopilot.',
|
||||
'flows.copilot.emptyState':
|
||||
'Jelaskan perubahan pada alur kerja ini dan pembuat akan mengusulkan pembaruan.',
|
||||
'flows.copilot.proposalTitle': 'Usulan perubahan',
|
||||
'flows.copilot.added': '{count} ditambahkan',
|
||||
'flows.copilot.removed': '{count} dihapus',
|
||||
'flows.copilot.noChanges': 'Usulan ini tidak mengubah simpul apa pun.',
|
||||
'flows.copilot.accept': 'Terapkan ke draf',
|
||||
'flows.copilot.reject': 'Buang',
|
||||
'flows.copilot.previewHint': 'Meninjau draf yang diusulkan — belum ada yang disimpan.',
|
||||
'flows.copilot.repairDisplay': 'Sebuah eksekusi gagal; periksa dan usulkan perbaikan.',
|
||||
'flows.list.view': 'Lihat alur kerja',
|
||||
'flows.list.export': 'Ekspor',
|
||||
'flows.list.exported': 'Alur kerja diekspor',
|
||||
'flows.page.import': 'Impor',
|
||||
'flows.import.invalidFile': 'File itu bukan JSON alur kerja yang valid.',
|
||||
'flows.import.error': 'Tidak dapat mengimpor alur kerja ini. Periksa file dan coba lagi.',
|
||||
'flows.import.warningTitle': 'Peringatan impor',
|
||||
'flows.canvas.title': 'Alur kerja',
|
||||
'flows.canvas.loading': 'Memuat alur kerja…',
|
||||
'flows.canvas.loadError': 'Alur kerja ini tidak dapat dimuat. Silakan coba lagi.',
|
||||
'flows.canvas.notFound': 'Alur kerja ini tidak ditemukan.',
|
||||
'flows.canvas.draftMissing':
|
||||
'Tidak ada draf alur kerja untuk dibuka. Ajukan satu dari obrolan dahulu.',
|
||||
'flows.canvas.backToList': 'Kembali ke daftar alur kerja',
|
||||
'flows.nodeKind.trigger': 'Pemicu',
|
||||
'flows.nodeKind.agent': 'Agen',
|
||||
@@ -3739,6 +3787,121 @@ const messages: TranslationMap = {
|
||||
'flows.nodeKind.transform': 'Transformasi',
|
||||
'flows.nodeKind.output_parser': 'Pengurai keluaran',
|
||||
'flows.nodeKind.sub_workflow': 'Sub-alur kerja',
|
||||
'flows.palette.title': 'Simpul',
|
||||
'flows.palette.addNode': 'Tambah simpul {kind}',
|
||||
'flows.editor.save': 'Simpan',
|
||||
'flows.editor.deleteSelected': 'Hapus yang dipilih',
|
||||
// ── Validation UX + draft/dirty state (issue B5b / Phase 3c–3d)
|
||||
'flows.editor.saving': 'Menyimpan…',
|
||||
'flows.editor.run': 'Jalankan',
|
||||
'flows.editor.running': 'Sedang berjalan…',
|
||||
'flows.editor.runFailed': 'Tidak dapat memulai proses',
|
||||
'flows.editor.validate': 'Validasi',
|
||||
'flows.editor.validating': 'Memvalidasi…',
|
||||
'flows.editor.discard': 'Buang perubahan',
|
||||
'flows.editor.unsaved': 'Perubahan belum disimpan',
|
||||
'flows.editor.saveBlocked': 'Perbaiki kesalahan di bawah sebelum menyimpan.',
|
||||
'flows.editor.errorsTitle': 'Kesalahan',
|
||||
'flows.editor.warningsTitle': 'Peringatan',
|
||||
'flows.editor.saveFailedTitle': 'Gagal menyimpan',
|
||||
'flows.editor.leaveTitle': 'Keluar tanpa menyimpan?',
|
||||
'flows.editor.leaveBody':
|
||||
'Anda memiliki perubahan yang belum disimpan pada alur kerja ini. Jika keluar sekarang, perubahan akan hilang.',
|
||||
'flows.editor.leaveStay': 'Tetap di sini',
|
||||
'flows.editor.leaveDiscard': 'Keluar',
|
||||
// ── Node config drawer (issue B5b / Phase 3b) — per-kind config forms
|
||||
'flows.nodeConfig.close': 'Tutup pengaturan',
|
||||
'flows.nodeConfig.nameLabel': 'Nama',
|
||||
'flows.nodeConfig.namePlaceholder': 'Nama node',
|
||||
'flows.nodeConfig.editForm': 'Edit sebagai formulir',
|
||||
'flows.nodeConfig.editJson': 'Edit sebagai JSON',
|
||||
'flows.nodeConfig.rawJsonLabel': 'Konfigurasi (JSON)',
|
||||
'flows.nodeConfig.rawJsonHint': 'Konfigurasi bebas untuk node ini.',
|
||||
'flows.nodeConfig.rawJsonInvalid': 'JSON tidak valid — perubahan tidak diterapkan sampai valid.',
|
||||
'flows.nodeConfig.expressionHint':
|
||||
'Mulai dengan = untuk menghitung nilai dari input node, mis. =item.url',
|
||||
'flows.nodeConfig.expressionBadge': 'Ekspresi',
|
||||
'flows.nodeConfig.keymapKeyPlaceholder': 'Kunci',
|
||||
'flows.nodeConfig.keymapValuePlaceholder': 'Nilai',
|
||||
'flows.nodeConfig.keymapRemove': 'Hapus baris',
|
||||
'flows.nodeConfig.keymapAdd': 'Tambah baris',
|
||||
'flows.nodeConfig.credentialLabel': 'Kredensial',
|
||||
'flows.nodeConfig.credentialHint': 'Pilih akun atau kredensial yang terhubung untuk node ini.',
|
||||
'flows.nodeConfig.credentialEmpty': 'Tidak ada kredensial terhubung yang tersedia.',
|
||||
'flows.nodeConfig.credentialNone': 'Tidak ada',
|
||||
'flows.nodeConfig.trigger.kindLabel': 'Jenis pemicu',
|
||||
'flows.nodeConfig.trigger.kind_manual': 'Manual',
|
||||
'flows.nodeConfig.trigger.kind_schedule': 'Jadwal',
|
||||
'flows.nodeConfig.trigger.kind_webhook': 'Webhook',
|
||||
'flows.nodeConfig.trigger.kind_app_event': 'Peristiwa aplikasi',
|
||||
'flows.nodeConfig.trigger.scheduleLabel': 'Jadwal cron',
|
||||
'flows.nodeConfig.trigger.scheduleHint':
|
||||
'Ekspresi cron: menit jam hari bulan hari dalam seminggu.',
|
||||
'flows.nodeConfig.trigger.toolkitLabel': 'Toolkit',
|
||||
'flows.nodeConfig.trigger.triggerSlugLabel': 'Slug pemicu',
|
||||
'flows.nodeConfig.trigger.webhookHint':
|
||||
'Pemicu webhook disimpan tetapi belum dipicu secara otomatis.',
|
||||
'flows.nodeConfig.http.methodLabel': 'Metode',
|
||||
'flows.nodeConfig.http.urlLabel': 'URL',
|
||||
'flows.nodeConfig.http.headersLabel': 'Header',
|
||||
'flows.nodeConfig.http.bodyLabel': 'Isi (JSON)',
|
||||
'flows.nodeConfig.agent.promptLabel': 'Prompt',
|
||||
'flows.nodeConfig.agent.promptPlaceholder': 'Instruksi untuk agen…',
|
||||
'flows.nodeConfig.agent.modelLabel': 'Model',
|
||||
'flows.nodeConfig.tool.slugLabel': 'Slug alat',
|
||||
'flows.nodeConfig.tool.argsLabel': 'Argumen (JSON)',
|
||||
'flows.nodeConfig.condition.fieldLabel': 'Bidang',
|
||||
'flows.nodeConfig.condition.fieldHint':
|
||||
'Kunci pada item input yang diuji kebenarannya. Mengarahkan ke true atau false.',
|
||||
'flows.nodeConfig.switch.expressionLabel': 'Ekspresi',
|
||||
'flows.nodeConfig.switch.hint':
|
||||
'Nilai hasil memilih port keluaran yang cocok; null mengarah ke default.',
|
||||
'flows.nodeConfig.switch.fieldLabel': 'Bidang (cadangan)',
|
||||
'flows.nodeConfig.transform.setLabel': 'Atur bidang',
|
||||
'flows.nodeConfig.transform.setHint':
|
||||
'Setiap nilai adalah ekspresi yang dievaluasi per item, mis. =item.name',
|
||||
'flows.nodeConfig.code.languageLabel': 'Bahasa',
|
||||
'flows.nodeConfig.code.language_javascript': 'JavaScript',
|
||||
'flows.nodeConfig.code.language_python': 'Python',
|
||||
'flows.nodeConfig.code.sourceLabel': 'Kode sumber',
|
||||
|
||||
'flows.chooser.title': 'Buat alur kerja',
|
||||
'flows.chooser.subtitle': 'Pilih cara Anda ingin memulai.',
|
||||
'flows.chooser.scratchTitle': 'Mulai dari awal',
|
||||
'flows.chooser.scratchDescription': 'Mulai dengan kanvas kosong dan satu pemicu manual.',
|
||||
'flows.chooser.templateTitle': 'Dari templat',
|
||||
'flows.chooser.templateDescription': 'Mulai dari contoh siap pakai dan sesuaikan.',
|
||||
'flows.chooser.describeTitle': 'Jelaskan',
|
||||
'flows.chooser.describeDescription':
|
||||
'Beri tahu asisten apa yang Anda inginkan dan biarkan ia menyusun alur kerja.',
|
||||
'flows.chooser.creating': 'Membuat alur kerja…',
|
||||
'flows.chooser.createError': 'Tidak dapat membuat alur kerja. Silakan coba lagi.',
|
||||
'flows.templates.title': 'Mulai dari templat',
|
||||
'flows.templates.subtitle': 'Pilih titik awal dan sesuaikan di editor.',
|
||||
'flows.templates.use': 'Gunakan templat',
|
||||
'flows.templates.back': 'Kembali',
|
||||
'flows.templates.empty': 'Tidak ada templat tersedia.',
|
||||
'flows.templates.category.scheduled': 'Terjadwal',
|
||||
'flows.templates.category.triggered': 'Terpicu',
|
||||
'flows.templates.category.onDemand': 'Sesuai permintaan',
|
||||
'flows.templates.daily-digest.name': 'Ringkasan harian ke saluran',
|
||||
'flows.templates.daily-digest.description':
|
||||
'Sesuai jadwal, agen menulis ringkasan singkat dan mengirimkannya ke saluran.',
|
||||
'flows.templates.scheduled-scrape.name': 'Pengambilan terjadwal ke memori',
|
||||
'flows.templates.scheduled-scrape.description':
|
||||
'Ambil sumber sesuai jadwal, ubah bentuk hasilnya, dan simpan di memori.',
|
||||
'flows.templates.webhook-triage.name': 'Triase webhook dan pemberitahuan',
|
||||
'flows.templates.webhook-triage.description':
|
||||
'Webhook masuk ditriase oleh agen, lalu Anda diberi tahu.',
|
||||
'flows.templates.app-event-route.name': 'Peristiwa aplikasi ke tindakan bersyarat',
|
||||
'flows.templates.app-event-route.description':
|
||||
'Peristiwa dari aplikasi terhubung menjalankan pemeriksaan, lalu bertindak saat cocok.',
|
||||
'flows.templates.http-fetch-parse.name': 'Ambil dan urai API',
|
||||
'flows.templates.http-fetch-parse.description':
|
||||
'Panggil endpoint HTTP sesuai permintaan dan urai respons menjadi bentuk yang dapat digunakan.',
|
||||
'flows.templates.ask-agent.name': 'Tanya agen',
|
||||
'flows.templates.ask-agent.description':
|
||||
'Pemicu manual sederhana yang menyerahkan tugas ke agen.',
|
||||
|
||||
'oauth.button.connecting': 'Menghubungkan...',
|
||||
'oauth.button.loopbackTimeout':
|
||||
|
||||
@@ -3171,6 +3171,7 @@ const messages: TranslationMap = {
|
||||
'chat.flowProposal.requireApprovalHint': 'Ogni azione in uscita richiederà la tua approvazione.',
|
||||
'chat.flowProposal.save': 'Salva e attiva',
|
||||
'chat.flowProposal.saving': 'Salvataggio…',
|
||||
'chat.flowProposal.openInCanvas': 'Apri nel canvas',
|
||||
'chat.flowProposal.dismiss': 'Ignora',
|
||||
'chat.flowProposal.error': 'Impossibile salvare il workflow. Riprova.',
|
||||
'channels.authMode.managed_dm': 'Accedi con OpenHuman',
|
||||
@@ -3739,6 +3740,17 @@ const messages: TranslationMap = {
|
||||
'flowRuns.inspector.port': 'Porta',
|
||||
'flowRuns.inspector.loading': 'Caricamento esecuzione…',
|
||||
'flowRuns.inspector.loadError': 'Impossibile caricare questa esecuzione',
|
||||
'flowRuns.inspector.fixWithAgent': "Correggi con l'agente",
|
||||
'flowRuns.inspector.dataTable': 'Tabella',
|
||||
'flowRuns.inspector.dataJson': 'JSON',
|
||||
'flowRuns.inspector.dataViewLabel': 'Vista di output',
|
||||
'flowRuns.inspector.itemCount': '{count} elemento/i',
|
||||
'flowRuns.inspector.noItems': 'Nessun elemento di output',
|
||||
'flowRuns.inspector.emptyValue': '(vuoto)',
|
||||
'flowRuns.inspector.binaryLabel': 'Binario',
|
||||
'flowRuns.inspector.showSource': 'Origine',
|
||||
'flowRuns.inspector.hideSource': 'Nascondi origine',
|
||||
'flowRuns.inspector.sourceInputTitle': 'Elemento di input di origine',
|
||||
'flowRuns.status.running': 'In esecuzione',
|
||||
'flowRuns.status.completed': 'Completato',
|
||||
'flowRuns.status.pending_approval': 'In attesa di approvazione',
|
||||
@@ -3770,11 +3782,47 @@ const messages: TranslationMap = {
|
||||
'flows.runs.loading': 'Caricamento esecuzioni…',
|
||||
'flows.runs.loadError': 'Impossibile caricare le esecuzioni',
|
||||
'flows.runs.empty': 'Ancora nessuna esecuzione',
|
||||
'flows.promptBar.label': 'Descrivi un flusso di lavoro',
|
||||
'flows.promptBar.placeholder': 'Descrivi un flusso di lavoro…',
|
||||
'flows.promptBar.submit': 'Crea',
|
||||
'flows.promptBar.thinking': 'Creazione…',
|
||||
'flows.promptBar.heroTitle': 'Descrivi un flusso di lavoro',
|
||||
'flows.promptBar.heroSubtitle':
|
||||
'Indica al generatore cosa automatizzare ed esamina la sua proposta.',
|
||||
'flows.promptBar.error': 'Impossibile raggiungere il generatore di flussi. Riprova.',
|
||||
'flows.promptBar.offline': 'Sei offline. Riconnettiti per creare un flusso.',
|
||||
'flows.copilot.open': 'Copilota',
|
||||
'flows.copilot.title': 'Copilota dei flussi',
|
||||
'flows.copilot.subtitle': 'Richiedi modifiche ed esamina ogni proposta prima di applicarla.',
|
||||
'flows.copilot.close': 'Chiudi copilota',
|
||||
'flows.copilot.placeholder': 'Richiedi una modifica…',
|
||||
'flows.copilot.send': 'Invia',
|
||||
'flows.copilot.thinking': 'Sto pensando…',
|
||||
'flows.copilot.error': 'Impossibile raggiungere il generatore di flussi. Riprova.',
|
||||
'flows.copilot.offline': 'Sei offline. Riconnettiti per usare il copilota.',
|
||||
'flows.copilot.emptyState':
|
||||
'Descrivi una modifica a questo flusso e il generatore proporrà un aggiornamento.',
|
||||
'flows.copilot.proposalTitle': 'Modifiche proposte',
|
||||
'flows.copilot.added': '{count} aggiunti',
|
||||
'flows.copilot.removed': '{count} rimossi',
|
||||
'flows.copilot.noChanges': 'Questa proposta non modifica alcun nodo.',
|
||||
'flows.copilot.accept': 'Applica alla bozza',
|
||||
'flows.copilot.reject': 'Ignora',
|
||||
'flows.copilot.previewHint': 'Revisione di una bozza proposta: non è stato ancora salvato nulla.',
|
||||
'flows.copilot.repairDisplay': 'Un’esecuzione è fallita; esaminala e proponi una correzione.',
|
||||
'flows.list.view': 'Visualizza flusso di lavoro',
|
||||
'flows.list.export': 'Esporta',
|
||||
'flows.list.exported': 'Flusso di lavoro esportato',
|
||||
'flows.page.import': 'Importa',
|
||||
'flows.import.invalidFile': 'Questo file non è un JSON di flusso di lavoro valido.',
|
||||
'flows.import.error':
|
||||
'Impossibile importare questo flusso di lavoro. Controlla il file e riprova.',
|
||||
'flows.import.warningTitle': 'Avviso di importazione',
|
||||
'flows.canvas.title': 'Flusso di lavoro',
|
||||
'flows.canvas.loading': 'Caricamento flusso di lavoro…',
|
||||
'flows.canvas.loadError': 'Impossibile caricare questo flusso di lavoro. Riprova.',
|
||||
'flows.canvas.notFound': 'Flusso di lavoro non trovato.',
|
||||
'flows.canvas.draftMissing': 'Nessuna bozza di flusso da aprire. Proponine una dalla chat prima.',
|
||||
'flows.canvas.backToList': 'Torna ai flussi di lavoro',
|
||||
'flows.nodeKind.trigger': 'Trigger',
|
||||
'flows.nodeKind.agent': 'Agente',
|
||||
@@ -3788,6 +3836,124 @@ const messages: TranslationMap = {
|
||||
'flows.nodeKind.transform': 'Trasformazione',
|
||||
'flows.nodeKind.output_parser': 'Analizzatore di output',
|
||||
'flows.nodeKind.sub_workflow': 'Sotto-flusso di lavoro',
|
||||
'flows.palette.title': 'Nodi',
|
||||
'flows.palette.addNode': 'Aggiungi nodo {kind}',
|
||||
'flows.editor.save': 'Salva',
|
||||
'flows.editor.deleteSelected': 'Elimina selezione',
|
||||
// ── Validation UX + draft/dirty state (issue B5b / Phase 3c–3d)
|
||||
'flows.editor.saving': 'Salvataggio…',
|
||||
'flows.editor.run': 'Esegui',
|
||||
'flows.editor.running': 'In esecuzione…',
|
||||
'flows.editor.runFailed': 'Impossibile avviare l’esecuzione',
|
||||
'flows.editor.validate': 'Convalida',
|
||||
'flows.editor.validating': 'Convalida in corso…',
|
||||
'flows.editor.discard': 'Annulla modifiche',
|
||||
'flows.editor.unsaved': 'Modifiche non salvate',
|
||||
'flows.editor.saveBlocked': 'Correggi gli errori qui sotto prima di salvare.',
|
||||
'flows.editor.errorsTitle': 'Errori',
|
||||
'flows.editor.warningsTitle': 'Avvisi',
|
||||
'flows.editor.saveFailedTitle': 'Impossibile salvare',
|
||||
'flows.editor.leaveTitle': 'Uscire senza salvare?',
|
||||
'flows.editor.leaveBody':
|
||||
'Hai modifiche non salvate in questo flusso di lavoro. Se esci ora, andranno perse.',
|
||||
'flows.editor.leaveStay': 'Resta',
|
||||
'flows.editor.leaveDiscard': 'Esci',
|
||||
// ── Node config drawer (issue B5b / Phase 3b) — per-kind config forms
|
||||
'flows.nodeConfig.close': 'Chiudi impostazioni',
|
||||
'flows.nodeConfig.nameLabel': 'Nome',
|
||||
'flows.nodeConfig.namePlaceholder': 'Nome del nodo',
|
||||
'flows.nodeConfig.editForm': 'Modifica come modulo',
|
||||
'flows.nodeConfig.editJson': 'Modifica come JSON',
|
||||
'flows.nodeConfig.rawJsonLabel': 'Configurazione (JSON)',
|
||||
'flows.nodeConfig.rawJsonHint': 'Configurazione libera per questo nodo.',
|
||||
'flows.nodeConfig.rawJsonInvalid':
|
||||
'JSON non valido: le modifiche non vengono applicate finché non è valido.',
|
||||
'flows.nodeConfig.expressionHint':
|
||||
"Inizia con = per calcolare il valore dall'input del nodo, es. =item.url",
|
||||
'flows.nodeConfig.expressionBadge': 'Espressione',
|
||||
'flows.nodeConfig.keymapKeyPlaceholder': 'Chiave',
|
||||
'flows.nodeConfig.keymapValuePlaceholder': 'Valore',
|
||||
'flows.nodeConfig.keymapRemove': 'Rimuovi riga',
|
||||
'flows.nodeConfig.keymapAdd': 'Aggiungi riga',
|
||||
'flows.nodeConfig.credentialLabel': 'Credenziale',
|
||||
'flows.nodeConfig.credentialHint':
|
||||
'Scegli un account o una credenziale collegata per questo nodo.',
|
||||
'flows.nodeConfig.credentialEmpty': 'Nessuna credenziale collegata disponibile.',
|
||||
'flows.nodeConfig.credentialNone': 'Nessuna',
|
||||
'flows.nodeConfig.trigger.kindLabel': 'Tipo di trigger',
|
||||
'flows.nodeConfig.trigger.kind_manual': 'Manuale',
|
||||
'flows.nodeConfig.trigger.kind_schedule': 'Pianificazione',
|
||||
'flows.nodeConfig.trigger.kind_webhook': 'Webhook',
|
||||
'flows.nodeConfig.trigger.kind_app_event': 'Evento app',
|
||||
'flows.nodeConfig.trigger.scheduleLabel': 'Pianificazione cron',
|
||||
'flows.nodeConfig.trigger.scheduleHint':
|
||||
'Espressione cron: minuto ora giorno mese giorno della settimana.',
|
||||
'flows.nodeConfig.trigger.toolkitLabel': 'Toolkit',
|
||||
'flows.nodeConfig.trigger.triggerSlugLabel': 'Slug del trigger',
|
||||
'flows.nodeConfig.trigger.webhookHint':
|
||||
'I trigger webhook vengono salvati ma non ancora attivati automaticamente.',
|
||||
'flows.nodeConfig.http.methodLabel': 'Metodo',
|
||||
'flows.nodeConfig.http.urlLabel': 'URL',
|
||||
'flows.nodeConfig.http.headersLabel': 'Intestazioni',
|
||||
'flows.nodeConfig.http.bodyLabel': 'Corpo (JSON)',
|
||||
'flows.nodeConfig.agent.promptLabel': 'Prompt',
|
||||
'flows.nodeConfig.agent.promptPlaceholder': "Istruzioni per l'agente…",
|
||||
'flows.nodeConfig.agent.modelLabel': 'Modello',
|
||||
'flows.nodeConfig.tool.slugLabel': 'Slug dello strumento',
|
||||
'flows.nodeConfig.tool.argsLabel': 'Argomenti (JSON)',
|
||||
'flows.nodeConfig.condition.fieldLabel': 'Campo',
|
||||
'flows.nodeConfig.condition.fieldHint':
|
||||
"Chiave dell'elemento di input da valutare come vera. Instrada verso vero o falso.",
|
||||
'flows.nodeConfig.switch.expressionLabel': 'Espressione',
|
||||
'flows.nodeConfig.switch.hint':
|
||||
'Il valore risultante seleziona la porta di uscita corrispondente; null va a default.',
|
||||
'flows.nodeConfig.switch.fieldLabel': 'Campo (ripiego)',
|
||||
'flows.nodeConfig.transform.setLabel': 'Imposta campi',
|
||||
'flows.nodeConfig.transform.setHint':
|
||||
"Ogni valore è un'espressione valutata per elemento, es. =item.name",
|
||||
'flows.nodeConfig.code.languageLabel': 'Linguaggio',
|
||||
'flows.nodeConfig.code.language_javascript': 'JavaScript',
|
||||
'flows.nodeConfig.code.language_python': 'Python',
|
||||
'flows.nodeConfig.code.sourceLabel': 'Codice sorgente',
|
||||
|
||||
'flows.chooser.title': 'Crea un flusso di lavoro',
|
||||
'flows.chooser.subtitle': 'Scegli come iniziare.',
|
||||
'flows.chooser.scratchTitle': 'Parti da zero',
|
||||
'flows.chooser.scratchDescription':
|
||||
"Inizia con un'area di lavoro vuota e un singolo trigger manuale.",
|
||||
'flows.chooser.templateTitle': 'Da un modello',
|
||||
'flows.chooser.templateDescription': 'Parti da un esempio pronto e personalizzalo.',
|
||||
'flows.chooser.describeTitle': 'Descrivilo',
|
||||
'flows.chooser.describeDescription':
|
||||
"Di' all'assistente cosa vuoi e lascia che rediga il flusso di lavoro.",
|
||||
'flows.chooser.creating': 'Creazione del flusso di lavoro…',
|
||||
'flows.chooser.createError': 'Impossibile creare il flusso di lavoro. Riprova.',
|
||||
'flows.templates.title': 'Parti da un modello',
|
||||
'flows.templates.subtitle': "Scegli un punto di partenza e personalizzalo nell'editor.",
|
||||
'flows.templates.use': 'Usa modello',
|
||||
'flows.templates.back': 'Indietro',
|
||||
'flows.templates.empty': 'Nessun modello disponibile.',
|
||||
'flows.templates.category.scheduled': 'Pianificato',
|
||||
'flows.templates.category.triggered': 'Attivato',
|
||||
'flows.templates.category.onDemand': 'Su richiesta',
|
||||
'flows.templates.daily-digest.name': 'Riepilogo giornaliero al canale',
|
||||
'flows.templates.daily-digest.description':
|
||||
'In base a una pianificazione, un agente scrive un breve riepilogo e lo pubblica in un canale.',
|
||||
'flows.templates.scheduled-scrape.name': 'Scraping pianificato in memoria',
|
||||
'flows.templates.scheduled-scrape.description':
|
||||
'Recupera una fonte in base a una pianificazione, rimodella i risultati e salvali in memoria.',
|
||||
'flows.templates.webhook-triage.name': 'Smistamento webhook e notifica',
|
||||
'flows.templates.webhook-triage.description':
|
||||
'Un webhook in arrivo viene smistato da un agente, poi ricevi una notifica.',
|
||||
'flows.templates.app-event-route.name': 'Evento app verso azione condizionale',
|
||||
'flows.templates.app-event-route.description':
|
||||
"Un evento di un'app collegata esegue un controllo, poi agisce quando corrisponde.",
|
||||
'flows.templates.http-fetch-parse.name': "Recupera e analizza un'API",
|
||||
'flows.templates.http-fetch-parse.description':
|
||||
'Chiama un endpoint HTTP su richiesta e analizza la risposta in una forma utilizzabile.',
|
||||
'flows.templates.ask-agent.name': "Chiedi all'agente",
|
||||
'flows.templates.ask-agent.description':
|
||||
'Un semplice trigger manuale che affida un compito a un agente.',
|
||||
|
||||
'oauth.button.connecting': 'Connessione...',
|
||||
'oauth.button.loopbackTimeout':
|
||||
|
||||
@@ -3089,6 +3089,7 @@ const messages: TranslationMap = {
|
||||
'chat.flowProposal.requireApprovalHint': '모든 외부 작업에는 승인이 필요합니다.',
|
||||
'chat.flowProposal.save': '저장 및 활성화',
|
||||
'chat.flowProposal.saving': '저장 중…',
|
||||
'chat.flowProposal.openInCanvas': '캔버스에서 열기',
|
||||
'chat.flowProposal.dismiss': '닫기',
|
||||
'chat.flowProposal.error': '워크플로를 저장할 수 없습니다. 다시 시도하세요.',
|
||||
'channels.authMode.managed_dm': 'OpenHuman로 로그인',
|
||||
@@ -3645,6 +3646,17 @@ const messages: TranslationMap = {
|
||||
'flowRuns.inspector.port': '포트',
|
||||
'flowRuns.inspector.loading': '실행 로드 중…',
|
||||
'flowRuns.inspector.loadError': '이 실행을 로드할 수 없습니다',
|
||||
'flowRuns.inspector.fixWithAgent': '에이전트로 수정',
|
||||
'flowRuns.inspector.dataTable': '테이블',
|
||||
'flowRuns.inspector.dataJson': 'JSON',
|
||||
'flowRuns.inspector.dataViewLabel': '출력 보기',
|
||||
'flowRuns.inspector.itemCount': '{count}개 항목',
|
||||
'flowRuns.inspector.noItems': '출력 항목 없음',
|
||||
'flowRuns.inspector.emptyValue': '(비어 있음)',
|
||||
'flowRuns.inspector.binaryLabel': '바이너리',
|
||||
'flowRuns.inspector.showSource': '소스',
|
||||
'flowRuns.inspector.hideSource': '소스 숨기기',
|
||||
'flowRuns.inspector.sourceInputTitle': '소스 입력 항목',
|
||||
'flowRuns.status.running': '실행 중',
|
||||
'flowRuns.status.completed': '완료됨',
|
||||
'flowRuns.status.pending_approval': '승인 대기 중',
|
||||
@@ -3675,11 +3687,44 @@ const messages: TranslationMap = {
|
||||
'flows.runs.loading': '실행 기록을 불러오는 중…',
|
||||
'flows.runs.loadError': '실행 기록을 불러올 수 없습니다',
|
||||
'flows.runs.empty': '아직 실행 기록이 없습니다',
|
||||
'flows.promptBar.label': '워크플로를 설명하세요',
|
||||
'flows.promptBar.placeholder': '워크플로를 설명하세요…',
|
||||
'flows.promptBar.submit': '만들기',
|
||||
'flows.promptBar.thinking': '만드는 중…',
|
||||
'flows.promptBar.heroTitle': '워크플로를 설명하세요',
|
||||
'flows.promptBar.heroSubtitle': '무엇을 자동화할지 빌더에게 알려주고 제안을 검토하세요.',
|
||||
'flows.promptBar.error': '워크플로 빌더에 연결할 수 없습니다. 다시 시도하세요.',
|
||||
'flows.promptBar.offline': '오프라인 상태입니다. 다시 연결하여 워크플로를 만드세요.',
|
||||
'flows.copilot.open': '코파일럿',
|
||||
'flows.copilot.title': '워크플로 코파일럿',
|
||||
'flows.copilot.subtitle': '변경을 요청하고 각 제안을 적용하기 전에 검토하세요.',
|
||||
'flows.copilot.close': '코파일럿 닫기',
|
||||
'flows.copilot.placeholder': '변경을 요청하세요…',
|
||||
'flows.copilot.send': '보내기',
|
||||
'flows.copilot.thinking': '생각 중…',
|
||||
'flows.copilot.error': '워크플로 빌더에 연결할 수 없습니다. 다시 시도하세요.',
|
||||
'flows.copilot.offline': '오프라인 상태입니다. 다시 연결하여 코파일럿을 사용하세요.',
|
||||
'flows.copilot.emptyState': '이 워크플로에 대한 변경을 설명하면 빌더가 업데이트를 제안합니다.',
|
||||
'flows.copilot.proposalTitle': '제안된 변경',
|
||||
'flows.copilot.added': '{count}개 추가',
|
||||
'flows.copilot.removed': '{count}개 제거',
|
||||
'flows.copilot.noChanges': '이 제안은 노드를 변경하지 않습니다.',
|
||||
'flows.copilot.accept': '초안에 적용',
|
||||
'flows.copilot.reject': '버리기',
|
||||
'flows.copilot.previewHint': '제안된 초안을 검토 중입니다 — 아직 저장되지 않았습니다.',
|
||||
'flows.copilot.repairDisplay': '실행이 실패했습니다. 확인하고 수정을 제안하세요.',
|
||||
'flows.list.view': '워크플로 보기',
|
||||
'flows.list.export': '내보내기',
|
||||
'flows.list.exported': '워크플로를 내보냈습니다',
|
||||
'flows.page.import': '가져오기',
|
||||
'flows.import.invalidFile': '이 파일은 유효한 워크플로 JSON이 아닙니다.',
|
||||
'flows.import.error': '이 워크플로를 가져올 수 없습니다. 파일을 확인하고 다시 시도하세요.',
|
||||
'flows.import.warningTitle': '가져오기 경고',
|
||||
'flows.canvas.title': '워크플로',
|
||||
'flows.canvas.loading': '워크플로 로드 중…',
|
||||
'flows.canvas.loadError': '이 워크플로를 불러올 수 없습니다. 다시 시도해 주세요.',
|
||||
'flows.canvas.notFound': '이 워크플로를 찾을 수 없습니다.',
|
||||
'flows.canvas.draftMissing': '열 워크플로 초안이 없습니다. 먼저 채팅에서 제안하세요.',
|
||||
'flows.canvas.backToList': '워크플로 목록으로 돌아가기',
|
||||
'flows.nodeKind.trigger': '트리거',
|
||||
'flows.nodeKind.agent': '에이전트',
|
||||
@@ -3693,6 +3738,118 @@ const messages: TranslationMap = {
|
||||
'flows.nodeKind.transform': '변환',
|
||||
'flows.nodeKind.output_parser': '출력 파서',
|
||||
'flows.nodeKind.sub_workflow': '하위 워크플로',
|
||||
'flows.palette.title': '노드',
|
||||
'flows.palette.addNode': '{kind} 노드 추가',
|
||||
'flows.editor.save': '저장',
|
||||
'flows.editor.deleteSelected': '선택 항목 삭제',
|
||||
// ── Validation UX + draft/dirty state (issue B5b / Phase 3c–3d)
|
||||
'flows.editor.saving': '저장 중…',
|
||||
'flows.editor.run': '실행',
|
||||
'flows.editor.running': '실행 중…',
|
||||
'flows.editor.runFailed': '실행을 시작할 수 없습니다',
|
||||
'flows.editor.validate': '검증',
|
||||
'flows.editor.validating': '검증 중…',
|
||||
'flows.editor.discard': '변경 사항 취소',
|
||||
'flows.editor.unsaved': '저장되지 않은 변경 사항',
|
||||
'flows.editor.saveBlocked': '저장하기 전에 아래 오류를 수정하세요.',
|
||||
'flows.editor.errorsTitle': '오류',
|
||||
'flows.editor.warningsTitle': '경고',
|
||||
'flows.editor.saveFailedTitle': '저장할 수 없음',
|
||||
'flows.editor.leaveTitle': '저장하지 않고 나가시겠습니까?',
|
||||
'flows.editor.leaveBody':
|
||||
'이 워크플로에 저장되지 않은 변경 사항이 있습니다. 지금 나가면 변경 사항이 사라집니다.',
|
||||
'flows.editor.leaveStay': '머무르기',
|
||||
'flows.editor.leaveDiscard': '나가기',
|
||||
// ── Node config drawer (issue B5b / Phase 3b) — per-kind config forms
|
||||
'flows.nodeConfig.close': '설정 닫기',
|
||||
'flows.nodeConfig.nameLabel': '이름',
|
||||
'flows.nodeConfig.namePlaceholder': '노드 이름',
|
||||
'flows.nodeConfig.editForm': '폼으로 편집',
|
||||
'flows.nodeConfig.editJson': 'JSON으로 편집',
|
||||
'flows.nodeConfig.rawJsonLabel': '구성 (JSON)',
|
||||
'flows.nodeConfig.rawJsonHint': '이 노드의 자유 구성입니다.',
|
||||
'flows.nodeConfig.rawJsonInvalid':
|
||||
'잘못된 JSON — 구문 분석에 성공할 때까지 변경 사항이 적용되지 않습니다.',
|
||||
'flows.nodeConfig.expressionHint': '노드 입력에서 값을 계산하려면 =로 시작하세요. 예: =item.url',
|
||||
'flows.nodeConfig.expressionBadge': '표현식',
|
||||
'flows.nodeConfig.keymapKeyPlaceholder': '키',
|
||||
'flows.nodeConfig.keymapValuePlaceholder': '값',
|
||||
'flows.nodeConfig.keymapRemove': '행 삭제',
|
||||
'flows.nodeConfig.keymapAdd': '행 추가',
|
||||
'flows.nodeConfig.credentialLabel': '자격 증명',
|
||||
'flows.nodeConfig.credentialHint': '이 노드에 사용할 연결된 계정 또는 자격 증명을 선택하세요.',
|
||||
'flows.nodeConfig.credentialEmpty': '사용 가능한 연결된 자격 증명이 없습니다.',
|
||||
'flows.nodeConfig.credentialNone': '없음',
|
||||
'flows.nodeConfig.trigger.kindLabel': '트리거 유형',
|
||||
'flows.nodeConfig.trigger.kind_manual': '수동',
|
||||
'flows.nodeConfig.trigger.kind_schedule': '예약',
|
||||
'flows.nodeConfig.trigger.kind_webhook': '웹훅',
|
||||
'flows.nodeConfig.trigger.kind_app_event': '앱 이벤트',
|
||||
'flows.nodeConfig.trigger.scheduleLabel': 'Cron 예약',
|
||||
'flows.nodeConfig.trigger.scheduleHint': 'Cron 표현식: 분 시 일 월 요일.',
|
||||
'flows.nodeConfig.trigger.toolkitLabel': '툴킷',
|
||||
'flows.nodeConfig.trigger.triggerSlugLabel': '트리거 슬러그',
|
||||
'flows.nodeConfig.trigger.webhookHint':
|
||||
'웹훅 트리거는 저장되지만 아직 자동으로 실행되지 않습니다.',
|
||||
'flows.nodeConfig.http.methodLabel': '메서드',
|
||||
'flows.nodeConfig.http.urlLabel': 'URL',
|
||||
'flows.nodeConfig.http.headersLabel': '헤더',
|
||||
'flows.nodeConfig.http.bodyLabel': '본문 (JSON)',
|
||||
'flows.nodeConfig.agent.promptLabel': '프롬프트',
|
||||
'flows.nodeConfig.agent.promptPlaceholder': '에이전트를 위한 지침…',
|
||||
'flows.nodeConfig.agent.modelLabel': '모델',
|
||||
'flows.nodeConfig.tool.slugLabel': '도구 슬러그',
|
||||
'flows.nodeConfig.tool.argsLabel': '인수 (JSON)',
|
||||
'flows.nodeConfig.condition.fieldLabel': '필드',
|
||||
'flows.nodeConfig.condition.fieldHint':
|
||||
'참 여부를 검사할 입력 항목의 키입니다. true 또는 false로 라우팅합니다.',
|
||||
'flows.nodeConfig.switch.expressionLabel': '표현식',
|
||||
'flows.nodeConfig.switch.hint':
|
||||
'결과 값이 일치하는 출력 포트를 선택합니다. null은 default로 라우팅됩니다.',
|
||||
'flows.nodeConfig.switch.fieldLabel': '필드 (대체)',
|
||||
'flows.nodeConfig.transform.setLabel': '필드 설정',
|
||||
'flows.nodeConfig.transform.setHint': '각 값은 항목마다 평가되는 표현식입니다. 예: =item.name',
|
||||
'flows.nodeConfig.code.languageLabel': '언어',
|
||||
'flows.nodeConfig.code.language_javascript': 'JavaScript',
|
||||
'flows.nodeConfig.code.language_python': 'Python',
|
||||
'flows.nodeConfig.code.sourceLabel': '소스 코드',
|
||||
|
||||
'flows.chooser.title': '워크플로 만들기',
|
||||
'flows.chooser.subtitle': '시작 방법을 선택하세요.',
|
||||
'flows.chooser.scratchTitle': '처음부터 시작',
|
||||
'flows.chooser.scratchDescription': '빈 캔버스와 단일 수동 트리거로 시작합니다.',
|
||||
'flows.chooser.templateTitle': '템플릿에서 시작',
|
||||
'flows.chooser.templateDescription': '완성된 예제로 시작하여 맞춤 설정하세요.',
|
||||
'flows.chooser.describeTitle': '설명하기',
|
||||
'flows.chooser.describeDescription':
|
||||
'원하는 것을 어시스턴트에게 알려주면 워크플로 초안을 작성합니다.',
|
||||
'flows.chooser.creating': '워크플로 생성 중…',
|
||||
'flows.chooser.createError': '워크플로를 만들 수 없습니다. 다시 시도해 주세요.',
|
||||
'flows.templates.title': '템플릿에서 시작',
|
||||
'flows.templates.subtitle': '시작점을 선택하고 편집기에서 맞춤 설정하세요.',
|
||||
'flows.templates.use': '템플릿 사용',
|
||||
'flows.templates.back': '뒤로',
|
||||
'flows.templates.empty': '사용 가능한 템플릿이 없습니다.',
|
||||
'flows.templates.category.scheduled': '예약됨',
|
||||
'flows.templates.category.triggered': '트리거됨',
|
||||
'flows.templates.category.onDemand': '요청 시',
|
||||
'flows.templates.daily-digest.name': '채널로 일일 요약',
|
||||
'flows.templates.daily-digest.description':
|
||||
'일정에 따라 에이전트가 짧은 요약을 작성해 채널에 게시합니다.',
|
||||
'flows.templates.scheduled-scrape.name': '예약된 스크랩을 메모리로',
|
||||
'flows.templates.scheduled-scrape.description':
|
||||
'일정에 따라 소스를 가져와 결과를 재구성하고 메모리에 저장합니다.',
|
||||
'flows.templates.webhook-triage.name': '웹훅 분류 및 알림',
|
||||
'flows.templates.webhook-triage.description':
|
||||
'수신 웹훅을 에이전트가 분류한 다음 알림을 받습니다.',
|
||||
'flows.templates.app-event-route.name': '앱 이벤트에서 조건부 작업으로',
|
||||
'flows.templates.app-event-route.description':
|
||||
'연결된 앱의 이벤트가 검사를 실행하고 일치하면 작업을 수행합니다.',
|
||||
'flows.templates.http-fetch-parse.name': 'API 가져오기 및 파싱',
|
||||
'flows.templates.http-fetch-parse.description':
|
||||
'요청 시 HTTP 엔드포인트를 호출하고 응답을 사용 가능한 형태로 파싱합니다.',
|
||||
'flows.templates.ask-agent.name': '에이전트에게 묻기',
|
||||
'flows.templates.ask-agent.description': '작업을 에이전트에 넘기는 간단한 수동 트리거입니다.',
|
||||
|
||||
'oauth.button.connecting': '연결 중...',
|
||||
'oauth.button.loopbackTimeout':
|
||||
|
||||
@@ -3155,6 +3155,7 @@ const messages: TranslationMap = {
|
||||
'chat.flowProposal.requireApprovalHint': 'Każda wychodząca akcja będzie wymagać Twojej zgody.',
|
||||
'chat.flowProposal.save': 'Zapisz i włącz',
|
||||
'chat.flowProposal.saving': 'Zapisywanie…',
|
||||
'chat.flowProposal.openInCanvas': 'Otwórz na kanwie',
|
||||
'chat.flowProposal.dismiss': 'Odrzuć',
|
||||
'chat.flowProposal.error': 'Nie udało się zapisać przepływu pracy. Spróbuj ponownie.',
|
||||
'channels.authMode.managed_dm': 'Zaloguj się z OpenHuman',
|
||||
@@ -3725,6 +3726,17 @@ const messages: TranslationMap = {
|
||||
'flowRuns.inspector.port': 'Port',
|
||||
'flowRuns.inspector.loading': 'Ładowanie przebiegu…',
|
||||
'flowRuns.inspector.loadError': 'Nie można załadować tego przebiegu',
|
||||
'flowRuns.inspector.fixWithAgent': 'Napraw z agentem',
|
||||
'flowRuns.inspector.dataTable': 'Tabela',
|
||||
'flowRuns.inspector.dataJson': 'JSON',
|
||||
'flowRuns.inspector.dataViewLabel': 'Widok wyjścia',
|
||||
'flowRuns.inspector.itemCount': '{count} element(y)',
|
||||
'flowRuns.inspector.noItems': 'Brak elementów wyjściowych',
|
||||
'flowRuns.inspector.emptyValue': '(puste)',
|
||||
'flowRuns.inspector.binaryLabel': 'Binarny',
|
||||
'flowRuns.inspector.showSource': 'Źródło',
|
||||
'flowRuns.inspector.hideSource': 'Ukryj źródło',
|
||||
'flowRuns.inspector.sourceInputTitle': 'Źródłowy element wejściowy',
|
||||
'flowRuns.status.running': 'W trakcie',
|
||||
'flowRuns.status.completed': 'Zakończono',
|
||||
'flowRuns.status.pending_approval': 'Oczekuje na zatwierdzenie',
|
||||
@@ -3757,11 +3769,49 @@ const messages: TranslationMap = {
|
||||
'flows.runs.loading': 'Ładowanie przebiegów…',
|
||||
'flows.runs.loadError': 'Nie udało się załadować przebiegów',
|
||||
'flows.runs.empty': 'Brak przebiegów',
|
||||
'flows.promptBar.label': 'Opisz przepływ pracy',
|
||||
'flows.promptBar.placeholder': 'Opisz przepływ pracy…',
|
||||
'flows.promptBar.submit': 'Utwórz',
|
||||
'flows.promptBar.thinking': 'Tworzenie…',
|
||||
'flows.promptBar.heroTitle': 'Opisz przepływ pracy',
|
||||
'flows.promptBar.heroSubtitle':
|
||||
'Powiedz kreatorowi, co zautomatyzować, i sprawdź jego propozycję.',
|
||||
'flows.promptBar.error': 'Nie można połączyć się z kreatorem przepływów. Spróbuj ponownie.',
|
||||
'flows.promptBar.offline': 'Jesteś offline. Połącz się ponownie, aby utworzyć przepływ.',
|
||||
'flows.copilot.open': 'Kopilot',
|
||||
'flows.copilot.title': 'Kopilot przepływów',
|
||||
'flows.copilot.subtitle': 'Poproś o zmiany i sprawdź każdą propozycję przed jej zastosowaniem.',
|
||||
'flows.copilot.close': 'Zamknij kopilota',
|
||||
'flows.copilot.placeholder': 'Poproś o zmianę…',
|
||||
'flows.copilot.send': 'Wyślij',
|
||||
'flows.copilot.thinking': 'Myślę…',
|
||||
'flows.copilot.error': 'Nie można połączyć się z kreatorem przepływów. Spróbuj ponownie.',
|
||||
'flows.copilot.offline': 'Jesteś offline. Połącz się ponownie, aby użyć kopilota.',
|
||||
'flows.copilot.emptyState': 'Opisz zmianę w tym przepływie, a kreator zaproponuje aktualizację.',
|
||||
'flows.copilot.proposalTitle': 'Proponowane zmiany',
|
||||
'flows.copilot.added': 'dodano: {count}',
|
||||
'flows.copilot.removed': 'usunięto: {count}',
|
||||
'flows.copilot.noChanges': 'Ta propozycja nie zmienia żadnego węzła.',
|
||||
'flows.copilot.accept': 'Zastosuj do wersji roboczej',
|
||||
'flows.copilot.reject': 'Odrzuć',
|
||||
'flows.copilot.previewHint':
|
||||
'Przeglądasz proponowaną wersję roboczą — nic nie zostało jeszcze zapisane.',
|
||||
'flows.copilot.repairDisplay':
|
||||
'Uruchomienie nie powiodło się; przejrzyj je i zaproponuj poprawkę.',
|
||||
'flows.list.view': 'Wyświetl przepływ pracy',
|
||||
'flows.list.export': 'Eksportuj',
|
||||
'flows.list.exported': 'Wyeksportowano przepływ pracy',
|
||||
'flows.page.import': 'Importuj',
|
||||
'flows.import.invalidFile': 'Ten plik nie jest prawidłowym plikiem JSON przepływu pracy.',
|
||||
'flows.import.error':
|
||||
'Nie udało się zaimportować tego przepływu pracy. Sprawdź plik i spróbuj ponownie.',
|
||||
'flows.import.warningTitle': 'Ostrzeżenie importu',
|
||||
'flows.canvas.title': 'Przepływ pracy',
|
||||
'flows.canvas.loading': 'Wczytywanie przepływu pracy…',
|
||||
'flows.canvas.loadError': 'Nie można wczytać tego przepływu pracy. Spróbuj ponownie.',
|
||||
'flows.canvas.notFound': 'Nie znaleziono tego przepływu pracy.',
|
||||
'flows.canvas.draftMissing':
|
||||
'Brak wersji roboczej przepływu do otwarcia. Najpierw zaproponuj ją na czacie.',
|
||||
'flows.canvas.backToList': 'Powrót do listy przepływów pracy',
|
||||
'flows.nodeKind.trigger': 'Wyzwalacz',
|
||||
'flows.nodeKind.agent': 'Agent',
|
||||
@@ -3775,6 +3825,123 @@ const messages: TranslationMap = {
|
||||
'flows.nodeKind.transform': 'Transformacja',
|
||||
'flows.nodeKind.output_parser': 'Parser wyjścia',
|
||||
'flows.nodeKind.sub_workflow': 'Podprzepływ pracy',
|
||||
'flows.palette.title': 'Węzły',
|
||||
'flows.palette.addNode': 'Dodaj węzeł {kind}',
|
||||
'flows.editor.save': 'Zapisz',
|
||||
'flows.editor.deleteSelected': 'Usuń zaznaczone',
|
||||
// ── Validation UX + draft/dirty state (issue B5b / Phase 3c–3d)
|
||||
'flows.editor.saving': 'Zapisywanie…',
|
||||
'flows.editor.run': 'Uruchom',
|
||||
'flows.editor.running': 'Uruchamianie…',
|
||||
'flows.editor.runFailed': 'Nie udało się uruchomić przebiegu',
|
||||
'flows.editor.validate': 'Sprawdź',
|
||||
'flows.editor.validating': 'Sprawdzanie…',
|
||||
'flows.editor.discard': 'Odrzuć zmiany',
|
||||
'flows.editor.unsaved': 'Niezapisane zmiany',
|
||||
'flows.editor.saveBlocked': 'Napraw poniższe błędy przed zapisaniem.',
|
||||
'flows.editor.errorsTitle': 'Błędy',
|
||||
'flows.editor.warningsTitle': 'Ostrzeżenia',
|
||||
'flows.editor.saveFailedTitle': 'Nie udało się zapisać',
|
||||
'flows.editor.leaveTitle': 'Wyjść bez zapisywania?',
|
||||
'flows.editor.leaveBody':
|
||||
'Masz niezapisane zmiany w tym przepływie pracy. Jeśli teraz wyjdziesz, zostaną utracone.',
|
||||
'flows.editor.leaveStay': 'Zostań',
|
||||
'flows.editor.leaveDiscard': 'Wyjdź',
|
||||
// ── Node config drawer (issue B5b / Phase 3b) — per-kind config forms
|
||||
'flows.nodeConfig.close': 'Zamknij ustawienia',
|
||||
'flows.nodeConfig.nameLabel': 'Nazwa',
|
||||
'flows.nodeConfig.namePlaceholder': 'Nazwa węzła',
|
||||
'flows.nodeConfig.editForm': 'Edytuj jako formularz',
|
||||
'flows.nodeConfig.editJson': 'Edytuj jako JSON',
|
||||
'flows.nodeConfig.rawJsonLabel': 'Konfiguracja (JSON)',
|
||||
'flows.nodeConfig.rawJsonHint': 'Dowolna konfiguracja dla tego węzła.',
|
||||
'flows.nodeConfig.rawJsonInvalid':
|
||||
'Nieprawidłowy JSON — zmiany nie są stosowane, dopóki nie będzie poprawny.',
|
||||
'flows.nodeConfig.expressionHint':
|
||||
'Zacznij od =, aby obliczyć wartość z danych wejściowych węzła, np. =item.url',
|
||||
'flows.nodeConfig.expressionBadge': 'Wyrażenie',
|
||||
'flows.nodeConfig.keymapKeyPlaceholder': 'Klucz',
|
||||
'flows.nodeConfig.keymapValuePlaceholder': 'Wartość',
|
||||
'flows.nodeConfig.keymapRemove': 'Usuń wiersz',
|
||||
'flows.nodeConfig.keymapAdd': 'Dodaj wiersz',
|
||||
'flows.nodeConfig.credentialLabel': 'Poświadczenie',
|
||||
'flows.nodeConfig.credentialHint': 'Wybierz połączone konto lub poświadczenie dla tego węzła.',
|
||||
'flows.nodeConfig.credentialEmpty': 'Brak dostępnych połączonych poświadczeń.',
|
||||
'flows.nodeConfig.credentialNone': 'Brak',
|
||||
'flows.nodeConfig.trigger.kindLabel': 'Typ wyzwalacza',
|
||||
'flows.nodeConfig.trigger.kind_manual': 'Ręczny',
|
||||
'flows.nodeConfig.trigger.kind_schedule': 'Harmonogram',
|
||||
'flows.nodeConfig.trigger.kind_webhook': 'Webhook',
|
||||
'flows.nodeConfig.trigger.kind_app_event': 'Zdarzenie aplikacji',
|
||||
'flows.nodeConfig.trigger.scheduleLabel': 'Harmonogram cron',
|
||||
'flows.nodeConfig.trigger.scheduleHint':
|
||||
'Wyrażenie cron: minuta godzina dzień miesiąc dzień tygodnia.',
|
||||
'flows.nodeConfig.trigger.toolkitLabel': 'Zestaw narzędzi',
|
||||
'flows.nodeConfig.trigger.triggerSlugLabel': 'Slug wyzwalacza',
|
||||
'flows.nodeConfig.trigger.webhookHint':
|
||||
'Wyzwalacze webhook są zapisywane, ale nie są jeszcze uruchamiane automatycznie.',
|
||||
'flows.nodeConfig.http.methodLabel': 'Metoda',
|
||||
'flows.nodeConfig.http.urlLabel': 'URL',
|
||||
'flows.nodeConfig.http.headersLabel': 'Nagłówki',
|
||||
'flows.nodeConfig.http.bodyLabel': 'Treść (JSON)',
|
||||
'flows.nodeConfig.agent.promptLabel': 'Prompt',
|
||||
'flows.nodeConfig.agent.promptPlaceholder': 'Instrukcje dla agenta…',
|
||||
'flows.nodeConfig.agent.modelLabel': 'Model',
|
||||
'flows.nodeConfig.tool.slugLabel': 'Slug narzędzia',
|
||||
'flows.nodeConfig.tool.argsLabel': 'Argumenty (JSON)',
|
||||
'flows.nodeConfig.condition.fieldLabel': 'Pole',
|
||||
'flows.nodeConfig.condition.fieldHint':
|
||||
'Klucz elementu wejściowego sprawdzany pod kątem prawdziwości. Kieruje do true lub false.',
|
||||
'flows.nodeConfig.switch.expressionLabel': 'Wyrażenie',
|
||||
'flows.nodeConfig.switch.hint':
|
||||
'Wynik wybiera odpowiedni port wyjściowy; null kieruje do default.',
|
||||
'flows.nodeConfig.switch.fieldLabel': 'Pole (zapasowe)',
|
||||
'flows.nodeConfig.transform.setLabel': 'Ustaw pola',
|
||||
'flows.nodeConfig.transform.setHint':
|
||||
'Każda wartość to wyrażenie obliczane dla każdego elementu, np. =item.name',
|
||||
'flows.nodeConfig.code.languageLabel': 'Język',
|
||||
'flows.nodeConfig.code.language_javascript': 'JavaScript',
|
||||
'flows.nodeConfig.code.language_python': 'Python',
|
||||
'flows.nodeConfig.code.sourceLabel': 'Kod źródłowy',
|
||||
|
||||
'flows.chooser.title': 'Utwórz przepływ pracy',
|
||||
'flows.chooser.subtitle': 'Wybierz, jak chcesz zacząć.',
|
||||
'flows.chooser.scratchTitle': 'Zacznij od zera',
|
||||
'flows.chooser.scratchDescription':
|
||||
'Rozpocznij od pustego obszaru i pojedynczego ręcznego wyzwalacza.',
|
||||
'flows.chooser.templateTitle': 'Z szablonu',
|
||||
'flows.chooser.templateDescription': 'Zacznij od gotowego przykładu i dostosuj go.',
|
||||
'flows.chooser.describeTitle': 'Opisz',
|
||||
'flows.chooser.describeDescription':
|
||||
'Powiedz asystentowi, czego chcesz, i pozwól mu naszkicować przepływ pracy.',
|
||||
'flows.chooser.creating': 'Tworzenie przepływu pracy…',
|
||||
'flows.chooser.createError': 'Nie udało się utworzyć przepływu pracy. Spróbuj ponownie.',
|
||||
'flows.templates.title': 'Zacznij od szablonu',
|
||||
'flows.templates.subtitle': 'Wybierz punkt wyjścia i dostosuj go w edytorze.',
|
||||
'flows.templates.use': 'Użyj szablonu',
|
||||
'flows.templates.back': 'Wstecz',
|
||||
'flows.templates.empty': 'Brak dostępnych szablonów.',
|
||||
'flows.templates.category.scheduled': 'Zaplanowane',
|
||||
'flows.templates.category.triggered': 'Wyzwalane',
|
||||
'flows.templates.category.onDemand': 'Na żądanie',
|
||||
'flows.templates.daily-digest.name': 'Codzienne podsumowanie na kanał',
|
||||
'flows.templates.daily-digest.description':
|
||||
'Zgodnie z harmonogramem agent pisze krótkie podsumowanie i publikuje je na kanale.',
|
||||
'flows.templates.scheduled-scrape.name': 'Zaplanowane pobieranie do pamięci',
|
||||
'flows.templates.scheduled-scrape.description':
|
||||
'Pobierz źródło zgodnie z harmonogramem, przekształć wyniki i zapisz je w pamięci.',
|
||||
'flows.templates.webhook-triage.name': 'Segregacja webhooka i powiadomienie',
|
||||
'flows.templates.webhook-triage.description':
|
||||
'Przychodzący webhook jest segregowany przez agenta, a następnie otrzymujesz powiadomienie.',
|
||||
'flows.templates.app-event-route.name': 'Zdarzenie aplikacji do akcji warunkowej',
|
||||
'flows.templates.app-event-route.description':
|
||||
'Zdarzenie z połączonej aplikacji uruchamia sprawdzenie, a następnie działa przy dopasowaniu.',
|
||||
'flows.templates.http-fetch-parse.name': 'Pobierz i przeanalizuj API',
|
||||
'flows.templates.http-fetch-parse.description':
|
||||
'Wywołaj punkt końcowy HTTP na żądanie i przeanalizuj odpowiedź do użytecznej postaci.',
|
||||
'flows.templates.ask-agent.name': 'Zapytaj agenta',
|
||||
'flows.templates.ask-agent.description':
|
||||
'Prosty ręczny wyzwalacz, który przekazuje zadanie agentowi.',
|
||||
|
||||
'oauth.button.connecting': 'Łączenie...',
|
||||
'oauth.button.loopbackTimeout':
|
||||
|
||||
@@ -3173,6 +3173,7 @@ const messages: TranslationMap = {
|
||||
'chat.flowProposal.requireApprovalHint': 'Cada ação de saída exigirá sua aprovação.',
|
||||
'chat.flowProposal.save': 'Salvar e ativar',
|
||||
'chat.flowProposal.saving': 'Salvando…',
|
||||
'chat.flowProposal.openInCanvas': 'Abrir no canvas',
|
||||
'chat.flowProposal.dismiss': 'Dispensar',
|
||||
'chat.flowProposal.error': 'Não foi possível salvar o fluxo de trabalho. Tente novamente.',
|
||||
'channels.authMode.managed_dm': 'Faça login com OpenHuman',
|
||||
@@ -3740,6 +3741,17 @@ const messages: TranslationMap = {
|
||||
'flowRuns.inspector.port': 'Porta',
|
||||
'flowRuns.inspector.loading': 'Carregando execução…',
|
||||
'flowRuns.inspector.loadError': 'Não foi possível carregar esta execução',
|
||||
'flowRuns.inspector.fixWithAgent': 'Corrigir com o agente',
|
||||
'flowRuns.inspector.dataTable': 'Tabela',
|
||||
'flowRuns.inspector.dataJson': 'JSON',
|
||||
'flowRuns.inspector.dataViewLabel': 'Visualização de saída',
|
||||
'flowRuns.inspector.itemCount': '{count} item(ns)',
|
||||
'flowRuns.inspector.noItems': 'Sem itens de saída',
|
||||
'flowRuns.inspector.emptyValue': '(vazio)',
|
||||
'flowRuns.inspector.binaryLabel': 'Binário',
|
||||
'flowRuns.inspector.showSource': 'Origem',
|
||||
'flowRuns.inspector.hideSource': 'Ocultar origem',
|
||||
'flowRuns.inspector.sourceInputTitle': 'Item de entrada de origem',
|
||||
'flowRuns.status.running': 'Em execução',
|
||||
'flowRuns.status.completed': 'Concluído',
|
||||
'flowRuns.status.pending_approval': 'Aguardando aprovação',
|
||||
@@ -3771,11 +3783,47 @@ const messages: TranslationMap = {
|
||||
'flows.runs.loading': 'Carregando execuções…',
|
||||
'flows.runs.loadError': 'Não foi possível carregar as execuções',
|
||||
'flows.runs.empty': 'Ainda não há execuções',
|
||||
'flows.promptBar.label': 'Descreva um fluxo de trabalho',
|
||||
'flows.promptBar.placeholder': 'Descreva um fluxo de trabalho…',
|
||||
'flows.promptBar.submit': 'Criar',
|
||||
'flows.promptBar.thinking': 'Criando…',
|
||||
'flows.promptBar.heroTitle': 'Descreva um fluxo de trabalho',
|
||||
'flows.promptBar.heroSubtitle': 'Diga ao gerador o que automatizar e revise a proposta dele.',
|
||||
'flows.promptBar.error': 'Não foi possível contatar o gerador de fluxos. Tente novamente.',
|
||||
'flows.promptBar.offline': 'Você está offline. Reconecte-se para criar um fluxo.',
|
||||
'flows.copilot.open': 'Copiloto',
|
||||
'flows.copilot.title': 'Copiloto de fluxos',
|
||||
'flows.copilot.subtitle': 'Peça alterações e revise cada proposta antes de aplicá-la.',
|
||||
'flows.copilot.close': 'Fechar copiloto',
|
||||
'flows.copilot.placeholder': 'Peça uma alteração…',
|
||||
'flows.copilot.send': 'Enviar',
|
||||
'flows.copilot.thinking': 'Pensando…',
|
||||
'flows.copilot.error': 'Não foi possível contatar o gerador de fluxos. Tente novamente.',
|
||||
'flows.copilot.offline': 'Você está offline. Reconecte-se para usar o copiloto.',
|
||||
'flows.copilot.emptyState':
|
||||
'Descreva uma alteração neste fluxo e o gerador proporá uma atualização.',
|
||||
'flows.copilot.proposalTitle': 'Alterações propostas',
|
||||
'flows.copilot.added': '{count} adicionados',
|
||||
'flows.copilot.removed': '{count} removidos',
|
||||
'flows.copilot.noChanges': 'Esta proposta não altera nenhum nó.',
|
||||
'flows.copilot.accept': 'Aplicar ao rascunho',
|
||||
'flows.copilot.reject': 'Descartar',
|
||||
'flows.copilot.previewHint': 'Revisando um rascunho proposto — nada foi salvo ainda.',
|
||||
'flows.copilot.repairDisplay': 'Uma execução falhou; analise-a e proponha uma correção.',
|
||||
'flows.list.view': 'Ver fluxo de trabalho',
|
||||
'flows.list.export': 'Exportar',
|
||||
'flows.list.exported': 'Fluxo de trabalho exportado',
|
||||
'flows.page.import': 'Importar',
|
||||
'flows.import.invalidFile': 'Esse arquivo não é um JSON de fluxo de trabalho válido.',
|
||||
'flows.import.error':
|
||||
'Não foi possível importar este fluxo de trabalho. Verifique o arquivo e tente novamente.',
|
||||
'flows.import.warningTitle': 'Aviso de importação',
|
||||
'flows.canvas.title': 'Fluxo de trabalho',
|
||||
'flows.canvas.loading': 'Carregando fluxo de trabalho…',
|
||||
'flows.canvas.loadError': 'Não foi possível carregar este fluxo de trabalho. Tente novamente.',
|
||||
'flows.canvas.notFound': 'Este fluxo de trabalho não foi encontrado.',
|
||||
'flows.canvas.draftMissing':
|
||||
'Nenhum rascunho de fluxo para abrir. Proponha um pelo chat primeiro.',
|
||||
'flows.canvas.backToList': 'Voltar para os fluxos de trabalho',
|
||||
'flows.nodeKind.trigger': 'Gatilho',
|
||||
'flows.nodeKind.agent': 'Agente',
|
||||
@@ -3789,6 +3837,121 @@ const messages: TranslationMap = {
|
||||
'flows.nodeKind.transform': 'Transformação',
|
||||
'flows.nodeKind.output_parser': 'Analisador de saída',
|
||||
'flows.nodeKind.sub_workflow': 'Subfluxo de trabalho',
|
||||
'flows.palette.title': 'Nós',
|
||||
'flows.palette.addNode': 'Adicionar nó {kind}',
|
||||
'flows.editor.save': 'Salvar',
|
||||
'flows.editor.deleteSelected': 'Excluir selecionados',
|
||||
// ── Validation UX + draft/dirty state (issue B5b / Phase 3c–3d)
|
||||
'flows.editor.saving': 'Salvando…',
|
||||
'flows.editor.run': 'Executar',
|
||||
'flows.editor.running': 'Executando…',
|
||||
'flows.editor.runFailed': 'Não foi possível iniciar a execução',
|
||||
'flows.editor.validate': 'Validar',
|
||||
'flows.editor.validating': 'Validando…',
|
||||
'flows.editor.discard': 'Descartar alterações',
|
||||
'flows.editor.unsaved': 'Alterações não salvas',
|
||||
'flows.editor.saveBlocked': 'Corrija os erros abaixo antes de salvar.',
|
||||
'flows.editor.errorsTitle': 'Erros',
|
||||
'flows.editor.warningsTitle': 'Avisos',
|
||||
'flows.editor.saveFailedTitle': 'Não foi possível salvar',
|
||||
'flows.editor.leaveTitle': 'Sair sem salvar?',
|
||||
'flows.editor.leaveBody':
|
||||
'Você tem alterações não salvas neste fluxo de trabalho. Se sair agora, elas serão perdidas.',
|
||||
'flows.editor.leaveStay': 'Ficar',
|
||||
'flows.editor.leaveDiscard': 'Sair',
|
||||
// ── Node config drawer (issue B5b / Phase 3b) — per-kind config forms
|
||||
'flows.nodeConfig.close': 'Fechar configurações',
|
||||
'flows.nodeConfig.nameLabel': 'Nome',
|
||||
'flows.nodeConfig.namePlaceholder': 'Nome do nó',
|
||||
'flows.nodeConfig.editForm': 'Editar como formulário',
|
||||
'flows.nodeConfig.editJson': 'Editar como JSON',
|
||||
'flows.nodeConfig.rawJsonLabel': 'Configuração (JSON)',
|
||||
'flows.nodeConfig.rawJsonHint': 'Configuração livre para este nó.',
|
||||
'flows.nodeConfig.rawJsonInvalid':
|
||||
'JSON inválido — as alterações só são aplicadas quando for válido.',
|
||||
'flows.nodeConfig.expressionHint':
|
||||
'Comece com = para calcular o valor a partir da entrada do nó, ex.: =item.url',
|
||||
'flows.nodeConfig.expressionBadge': 'Expressão',
|
||||
'flows.nodeConfig.keymapKeyPlaceholder': 'Chave',
|
||||
'flows.nodeConfig.keymapValuePlaceholder': 'Valor',
|
||||
'flows.nodeConfig.keymapRemove': 'Remover linha',
|
||||
'flows.nodeConfig.keymapAdd': 'Adicionar linha',
|
||||
'flows.nodeConfig.credentialLabel': 'Credencial',
|
||||
'flows.nodeConfig.credentialHint': 'Escolha uma conta ou credencial conectada para este nó.',
|
||||
'flows.nodeConfig.credentialEmpty': 'Nenhuma credencial conectada disponível.',
|
||||
'flows.nodeConfig.credentialNone': 'Nenhuma',
|
||||
'flows.nodeConfig.trigger.kindLabel': 'Tipo de gatilho',
|
||||
'flows.nodeConfig.trigger.kind_manual': 'Manual',
|
||||
'flows.nodeConfig.trigger.kind_schedule': 'Agendamento',
|
||||
'flows.nodeConfig.trigger.kind_webhook': 'Webhook',
|
||||
'flows.nodeConfig.trigger.kind_app_event': 'Evento de app',
|
||||
'flows.nodeConfig.trigger.scheduleLabel': 'Agendamento cron',
|
||||
'flows.nodeConfig.trigger.scheduleHint': 'Expressão cron: minuto hora dia mês dia da semana.',
|
||||
'flows.nodeConfig.trigger.toolkitLabel': 'Kit de ferramentas',
|
||||
'flows.nodeConfig.trigger.triggerSlugLabel': 'Slug do gatilho',
|
||||
'flows.nodeConfig.trigger.webhookHint':
|
||||
'Os gatilhos de webhook são salvos, mas ainda não são disparados automaticamente.',
|
||||
'flows.nodeConfig.http.methodLabel': 'Método',
|
||||
'flows.nodeConfig.http.urlLabel': 'URL',
|
||||
'flows.nodeConfig.http.headersLabel': 'Cabeçalhos',
|
||||
'flows.nodeConfig.http.bodyLabel': 'Corpo (JSON)',
|
||||
'flows.nodeConfig.agent.promptLabel': 'Prompt',
|
||||
'flows.nodeConfig.agent.promptPlaceholder': 'Instruções para o agente…',
|
||||
'flows.nodeConfig.agent.modelLabel': 'Modelo',
|
||||
'flows.nodeConfig.tool.slugLabel': 'Slug da ferramenta',
|
||||
'flows.nodeConfig.tool.argsLabel': 'Argumentos (JSON)',
|
||||
'flows.nodeConfig.condition.fieldLabel': 'Campo',
|
||||
'flows.nodeConfig.condition.fieldHint':
|
||||
'Chave do item de entrada avaliada como verdadeira. Encaminha para verdadeiro ou falso.',
|
||||
'flows.nodeConfig.switch.expressionLabel': 'Expressão',
|
||||
'flows.nodeConfig.switch.hint':
|
||||
'O valor resultante seleciona a porta de saída correspondente; null vai para default.',
|
||||
'flows.nodeConfig.switch.fieldLabel': 'Campo (alternativa)',
|
||||
'flows.nodeConfig.transform.setLabel': 'Definir campos',
|
||||
'flows.nodeConfig.transform.setHint':
|
||||
'Cada valor é uma expressão avaliada por item, ex.: =item.name',
|
||||
'flows.nodeConfig.code.languageLabel': 'Linguagem',
|
||||
'flows.nodeConfig.code.language_javascript': 'JavaScript',
|
||||
'flows.nodeConfig.code.language_python': 'Python',
|
||||
'flows.nodeConfig.code.sourceLabel': 'Código-fonte',
|
||||
|
||||
'flows.chooser.title': 'Criar um fluxo de trabalho',
|
||||
'flows.chooser.subtitle': 'Escolha como quer começar.',
|
||||
'flows.chooser.scratchTitle': 'Começar do zero',
|
||||
'flows.chooser.scratchDescription': 'Comece com uma tela em branco e um único acionador manual.',
|
||||
'flows.chooser.templateTitle': 'A partir de um modelo',
|
||||
'flows.chooser.templateDescription': 'Comece com um exemplo pronto e personalize-o.',
|
||||
'flows.chooser.describeTitle': 'Descreva',
|
||||
'flows.chooser.describeDescription':
|
||||
'Diga ao assistente o que deseja e deixe-o esboçar o fluxo de trabalho.',
|
||||
'flows.chooser.creating': 'Criando o fluxo de trabalho…',
|
||||
'flows.chooser.createError': 'Não foi possível criar o fluxo de trabalho. Tente novamente.',
|
||||
'flows.templates.title': 'Começar a partir de um modelo',
|
||||
'flows.templates.subtitle': 'Escolha um ponto de partida e personalize-o no editor.',
|
||||
'flows.templates.use': 'Usar modelo',
|
||||
'flows.templates.back': 'Voltar',
|
||||
'flows.templates.empty': 'Nenhum modelo disponível.',
|
||||
'flows.templates.category.scheduled': 'Agendado',
|
||||
'flows.templates.category.triggered': 'Acionado',
|
||||
'flows.templates.category.onDemand': 'Sob demanda',
|
||||
'flows.templates.daily-digest.name': 'Resumo diário para o canal',
|
||||
'flows.templates.daily-digest.description':
|
||||
'Conforme um agendamento, um agente escreve um breve resumo e o publica em um canal.',
|
||||
'flows.templates.scheduled-scrape.name': 'Extração agendada para a memória',
|
||||
'flows.templates.scheduled-scrape.description':
|
||||
'Busque uma fonte conforme um agendamento, remodele os resultados e guarde-os na memória.',
|
||||
'flows.templates.webhook-triage.name': 'Triagem de webhook e aviso',
|
||||
'flows.templates.webhook-triage.description':
|
||||
'Um webhook recebido é triado por um agente e depois você é avisado.',
|
||||
'flows.templates.app-event-route.name': 'Evento de app para ação condicional',
|
||||
'flows.templates.app-event-route.description':
|
||||
'Um evento de um app conectado executa uma verificação e age quando corresponde.',
|
||||
'flows.templates.http-fetch-parse.name': 'Buscar e analisar uma API',
|
||||
'flows.templates.http-fetch-parse.description':
|
||||
'Chame um endpoint HTTP sob demanda e analise a resposta em um formato utilizável.',
|
||||
'flows.templates.ask-agent.name': 'Perguntar ao agente',
|
||||
'flows.templates.ask-agent.description':
|
||||
'Um acionador manual simples que entrega uma tarefa a um agente.',
|
||||
|
||||
'oauth.button.connecting': 'Conectando...',
|
||||
'oauth.button.loopbackTimeout':
|
||||
|
||||
@@ -3146,6 +3146,7 @@ const messages: TranslationMap = {
|
||||
'chat.flowProposal.requireApprovalHint': 'Каждое исходящее действие потребует вашего одобрения.',
|
||||
'chat.flowProposal.save': 'Сохранить и включить',
|
||||
'chat.flowProposal.saving': 'Сохранение…',
|
||||
'chat.flowProposal.openInCanvas': 'Открыть на холсте',
|
||||
'chat.flowProposal.dismiss': 'Скрыть',
|
||||
'chat.flowProposal.error': 'Не удалось сохранить рабочий процесс. Попробуйте еще раз.',
|
||||
'channels.authMode.managed_dm': 'Войдите с помощью OpenHuman',
|
||||
@@ -3714,6 +3715,17 @@ const messages: TranslationMap = {
|
||||
'flowRuns.inspector.port': 'Порт',
|
||||
'flowRuns.inspector.loading': 'Загрузка запуска…',
|
||||
'flowRuns.inspector.loadError': 'Не удалось загрузить этот запуск',
|
||||
'flowRuns.inspector.fixWithAgent': 'Исправить с агентом',
|
||||
'flowRuns.inspector.dataTable': 'Таблица',
|
||||
'flowRuns.inspector.dataJson': 'JSON',
|
||||
'flowRuns.inspector.dataViewLabel': 'Вид вывода',
|
||||
'flowRuns.inspector.itemCount': 'Элементов: {count}',
|
||||
'flowRuns.inspector.noItems': 'Нет выходных элементов',
|
||||
'flowRuns.inspector.emptyValue': '(пусто)',
|
||||
'flowRuns.inspector.binaryLabel': 'Двоичный',
|
||||
'flowRuns.inspector.showSource': 'Источник',
|
||||
'flowRuns.inspector.hideSource': 'Скрыть источник',
|
||||
'flowRuns.inspector.sourceInputTitle': 'Исходный входной элемент',
|
||||
'flowRuns.status.running': 'Выполняется',
|
||||
'flowRuns.status.completed': 'Завершено',
|
||||
'flowRuns.status.pending_approval': 'Ожидает подтверждения',
|
||||
@@ -3746,11 +3758,49 @@ const messages: TranslationMap = {
|
||||
'flows.runs.loading': 'Загрузка запусков…',
|
||||
'flows.runs.loadError': 'Не удалось загрузить запуски',
|
||||
'flows.runs.empty': 'Пока нет запусков',
|
||||
'flows.promptBar.label': 'Опишите рабочий процесс',
|
||||
'flows.promptBar.placeholder': 'Опишите рабочий процесс…',
|
||||
'flows.promptBar.submit': 'Создать',
|
||||
'flows.promptBar.thinking': 'Создание…',
|
||||
'flows.promptBar.heroTitle': 'Опишите рабочий процесс',
|
||||
'flows.promptBar.heroSubtitle':
|
||||
'Скажите конструктору, что автоматизировать, и просмотрите его предложение.',
|
||||
'flows.promptBar.error': 'Не удалось связаться с конструктором процессов. Повторите попытку.',
|
||||
'flows.promptBar.offline': 'Вы не в сети. Переподключитесь, чтобы создать процесс.',
|
||||
'flows.copilot.open': 'Второй пилот',
|
||||
'flows.copilot.title': 'Второй пилот процессов',
|
||||
'flows.copilot.subtitle':
|
||||
'Запрашивайте изменения и проверяйте каждое предложение перед применением.',
|
||||
'flows.copilot.close': 'Закрыть второго пилота',
|
||||
'flows.copilot.placeholder': 'Запросите изменение…',
|
||||
'flows.copilot.send': 'Отправить',
|
||||
'flows.copilot.thinking': 'Думаю…',
|
||||
'flows.copilot.error': 'Не удалось связаться с конструктором процессов. Повторите попытку.',
|
||||
'flows.copilot.offline': 'Вы не в сети. Переподключитесь, чтобы использовать второго пилота.',
|
||||
'flows.copilot.emptyState':
|
||||
'Опишите изменение этого процесса, и конструктор предложит обновление.',
|
||||
'flows.copilot.proposalTitle': 'Предлагаемые изменения',
|
||||
'flows.copilot.added': 'добавлено: {count}',
|
||||
'flows.copilot.removed': 'удалено: {count}',
|
||||
'flows.copilot.noChanges': 'Это предложение не меняет ни одного узла.',
|
||||
'flows.copilot.accept': 'Применить к черновику',
|
||||
'flows.copilot.reject': 'Отклонить',
|
||||
'flows.copilot.previewHint': 'Просмотр предложенного черновика — пока ничего не сохранено.',
|
||||
'flows.copilot.repairDisplay': 'Запуск завершился ошибкой; изучите его и предложите исправление.',
|
||||
'flows.list.view': 'Просмотреть рабочий процесс',
|
||||
'flows.list.export': 'Экспорт',
|
||||
'flows.list.exported': 'Рабочий процесс экспортирован',
|
||||
'flows.page.import': 'Импорт',
|
||||
'flows.import.invalidFile': 'Этот файл не является допустимым JSON рабочего процесса.',
|
||||
'flows.import.error':
|
||||
'Не удалось импортировать этот рабочий процесс. Проверьте файл и попробуйте снова.',
|
||||
'flows.import.warningTitle': 'Предупреждение импорта',
|
||||
'flows.canvas.title': 'Рабочий процесс',
|
||||
'flows.canvas.loading': 'Загрузка рабочего процесса…',
|
||||
'flows.canvas.loadError': 'Не удалось загрузить этот рабочий процесс. Повторите попытку.',
|
||||
'flows.canvas.notFound': 'Этот рабочий процесс не найден.',
|
||||
'flows.canvas.draftMissing':
|
||||
'Нет черновика рабочего процесса для открытия. Сначала предложите его в чате.',
|
||||
'flows.canvas.backToList': 'Назад к рабочим процессам',
|
||||
'flows.nodeKind.trigger': 'Триггер',
|
||||
'flows.nodeKind.agent': 'Агент',
|
||||
@@ -3764,6 +3814,121 @@ const messages: TranslationMap = {
|
||||
'flows.nodeKind.transform': 'Преобразование',
|
||||
'flows.nodeKind.output_parser': 'Парсер вывода',
|
||||
'flows.nodeKind.sub_workflow': 'Подпроцесс',
|
||||
'flows.palette.title': 'Узлы',
|
||||
'flows.palette.addNode': 'Добавить узел {kind}',
|
||||
'flows.editor.save': 'Сохранить',
|
||||
'flows.editor.deleteSelected': 'Удалить выбранное',
|
||||
// ── Validation UX + draft/dirty state (issue B5b / Phase 3c–3d)
|
||||
'flows.editor.saving': 'Сохранение…',
|
||||
'flows.editor.run': 'Запустить',
|
||||
'flows.editor.running': 'Выполняется…',
|
||||
'flows.editor.runFailed': 'Не удалось запустить выполнение',
|
||||
'flows.editor.validate': 'Проверить',
|
||||
'flows.editor.validating': 'Проверка…',
|
||||
'flows.editor.discard': 'Отменить изменения',
|
||||
'flows.editor.unsaved': 'Несохранённые изменения',
|
||||
'flows.editor.saveBlocked': 'Исправьте ошибки ниже перед сохранением.',
|
||||
'flows.editor.errorsTitle': 'Ошибки',
|
||||
'flows.editor.warningsTitle': 'Предупреждения',
|
||||
'flows.editor.saveFailedTitle': 'Не удалось сохранить',
|
||||
'flows.editor.leaveTitle': 'Выйти без сохранения?',
|
||||
'flows.editor.leaveBody':
|
||||
'В этом рабочем процессе есть несохранённые изменения. Если выйти сейчас, они будут потеряны.',
|
||||
'flows.editor.leaveStay': 'Остаться',
|
||||
'flows.editor.leaveDiscard': 'Выйти',
|
||||
// ── Node config drawer (issue B5b / Phase 3b) — per-kind config forms
|
||||
'flows.nodeConfig.close': 'Закрыть настройки',
|
||||
'flows.nodeConfig.nameLabel': 'Имя',
|
||||
'flows.nodeConfig.namePlaceholder': 'Имя узла',
|
||||
'flows.nodeConfig.editForm': 'Редактировать как форму',
|
||||
'flows.nodeConfig.editJson': 'Редактировать как JSON',
|
||||
'flows.nodeConfig.rawJsonLabel': 'Конфигурация (JSON)',
|
||||
'flows.nodeConfig.rawJsonHint': 'Произвольная конфигурация для этого узла.',
|
||||
'flows.nodeConfig.rawJsonInvalid':
|
||||
'Недопустимый JSON — изменения применяются только после успешного разбора.',
|
||||
'flows.nodeConfig.expressionHint':
|
||||
'Начните с =, чтобы вычислить значение из входных данных узла, напр. =item.url',
|
||||
'flows.nodeConfig.expressionBadge': 'Выражение',
|
||||
'flows.nodeConfig.keymapKeyPlaceholder': 'Ключ',
|
||||
'flows.nodeConfig.keymapValuePlaceholder': 'Значение',
|
||||
'flows.nodeConfig.keymapRemove': 'Удалить строку',
|
||||
'flows.nodeConfig.keymapAdd': 'Добавить строку',
|
||||
'flows.nodeConfig.credentialLabel': 'Учётные данные',
|
||||
'flows.nodeConfig.credentialHint':
|
||||
'Выберите подключённый аккаунт или учётные данные для этого узла.',
|
||||
'flows.nodeConfig.credentialEmpty': 'Нет доступных подключённых учётных данных.',
|
||||
'flows.nodeConfig.credentialNone': 'Нет',
|
||||
'flows.nodeConfig.trigger.kindLabel': 'Тип триггера',
|
||||
'flows.nodeConfig.trigger.kind_manual': 'Вручную',
|
||||
'flows.nodeConfig.trigger.kind_schedule': 'Расписание',
|
||||
'flows.nodeConfig.trigger.kind_webhook': 'Вебхук',
|
||||
'flows.nodeConfig.trigger.kind_app_event': 'Событие приложения',
|
||||
'flows.nodeConfig.trigger.scheduleLabel': 'Расписание cron',
|
||||
'flows.nodeConfig.trigger.scheduleHint': 'Выражение cron: минута час день месяц день недели.',
|
||||
'flows.nodeConfig.trigger.toolkitLabel': 'Набор инструментов',
|
||||
'flows.nodeConfig.trigger.triggerSlugLabel': 'Слаг триггера',
|
||||
'flows.nodeConfig.trigger.webhookHint':
|
||||
'Триггеры вебхуков сохраняются, но пока не запускаются автоматически.',
|
||||
'flows.nodeConfig.http.methodLabel': 'Метод',
|
||||
'flows.nodeConfig.http.urlLabel': 'URL',
|
||||
'flows.nodeConfig.http.headersLabel': 'Заголовки',
|
||||
'flows.nodeConfig.http.bodyLabel': 'Тело (JSON)',
|
||||
'flows.nodeConfig.agent.promptLabel': 'Запрос',
|
||||
'flows.nodeConfig.agent.promptPlaceholder': 'Инструкции для агента…',
|
||||
'flows.nodeConfig.agent.modelLabel': 'Модель',
|
||||
'flows.nodeConfig.tool.slugLabel': 'Слаг инструмента',
|
||||
'flows.nodeConfig.tool.argsLabel': 'Аргументы (JSON)',
|
||||
'flows.nodeConfig.condition.fieldLabel': 'Поле',
|
||||
'flows.nodeConfig.condition.fieldHint':
|
||||
'Ключ входного элемента для проверки на истинность. Направляет в true или false.',
|
||||
'flows.nodeConfig.switch.expressionLabel': 'Выражение',
|
||||
'flows.nodeConfig.switch.hint':
|
||||
'Результирующее значение выбирает соответствующий выходной порт; null идёт в default.',
|
||||
'flows.nodeConfig.switch.fieldLabel': 'Поле (запасное)',
|
||||
'flows.nodeConfig.transform.setLabel': 'Задать поля',
|
||||
'flows.nodeConfig.transform.setHint':
|
||||
'Каждое значение — выражение, вычисляемое для каждого элемента, напр. =item.name',
|
||||
'flows.nodeConfig.code.languageLabel': 'Язык',
|
||||
'flows.nodeConfig.code.language_javascript': 'JavaScript',
|
||||
'flows.nodeConfig.code.language_python': 'Python',
|
||||
'flows.nodeConfig.code.sourceLabel': 'Исходный код',
|
||||
|
||||
'flows.chooser.title': 'Создать рабочий процесс',
|
||||
'flows.chooser.subtitle': 'Выберите, с чего начать.',
|
||||
'flows.chooser.scratchTitle': 'Начать с нуля',
|
||||
'flows.chooser.scratchDescription': 'Начните с пустого холста и единственного ручного триггера.',
|
||||
'flows.chooser.templateTitle': 'Из шаблона',
|
||||
'flows.chooser.templateDescription': 'Начните с готового примера и настройте его.',
|
||||
'flows.chooser.describeTitle': 'Описать',
|
||||
'flows.chooser.describeDescription':
|
||||
'Скажите ассистенту, что вам нужно, и позвольте ему составить рабочий процесс.',
|
||||
'flows.chooser.creating': 'Создание рабочего процесса…',
|
||||
'flows.chooser.createError': 'Не удалось создать рабочий процесс. Пожалуйста, попробуйте снова.',
|
||||
'flows.templates.title': 'Начать с шаблона',
|
||||
'flows.templates.subtitle': 'Выберите отправную точку и настройте её в редакторе.',
|
||||
'flows.templates.use': 'Использовать шаблон',
|
||||
'flows.templates.back': 'Назад',
|
||||
'flows.templates.empty': 'Нет доступных шаблонов.',
|
||||
'flows.templates.category.scheduled': 'По расписанию',
|
||||
'flows.templates.category.triggered': 'По событию',
|
||||
'flows.templates.category.onDemand': 'По запросу',
|
||||
'flows.templates.daily-digest.name': 'Ежедневная сводка в канал',
|
||||
'flows.templates.daily-digest.description':
|
||||
'По расписанию агент пишет краткую сводку и публикует её в канале.',
|
||||
'flows.templates.scheduled-scrape.name': 'Плановый сбор в память',
|
||||
'flows.templates.scheduled-scrape.description':
|
||||
'Получайте источник по расписанию, преобразуйте результаты и сохраняйте их в память.',
|
||||
'flows.templates.webhook-triage.name': 'Разбор вебхука и уведомление',
|
||||
'flows.templates.webhook-triage.description':
|
||||
'Входящий вебхук разбирается агентом, затем вы получаете уведомление.',
|
||||
'flows.templates.app-event-route.name': 'Событие приложения к условному действию',
|
||||
'flows.templates.app-event-route.description':
|
||||
'Событие подключённого приложения выполняет проверку и действует при совпадении.',
|
||||
'flows.templates.http-fetch-parse.name': 'Получить и разобрать API',
|
||||
'flows.templates.http-fetch-parse.description':
|
||||
'Вызовите конечную точку HTTP по запросу и разберите ответ в удобную форму.',
|
||||
'flows.templates.ask-agent.name': 'Спросить агента',
|
||||
'flows.templates.ask-agent.description': 'Простой ручной триггер, передающий задачу агенту.',
|
||||
|
||||
'oauth.button.connecting': 'Подключение...',
|
||||
'oauth.button.loopbackTimeout':
|
||||
|
||||
@@ -2957,6 +2957,7 @@ const messages: TranslationMap = {
|
||||
'chat.flowProposal.requireApprovalHint': '每个外发操作都需要你的批准。',
|
||||
'chat.flowProposal.save': '保存并启用',
|
||||
'chat.flowProposal.saving': '保存中…',
|
||||
'chat.flowProposal.openInCanvas': '在画布中打开',
|
||||
'chat.flowProposal.dismiss': '忽略',
|
||||
'chat.flowProposal.error': '无法保存该工作流。请重试。',
|
||||
'channels.authMode.managed_dm': '使用 OpenHuman 登录',
|
||||
@@ -3488,6 +3489,17 @@ const messages: TranslationMap = {
|
||||
'flowRuns.inspector.port': '端口',
|
||||
'flowRuns.inspector.loading': '正在加载运行…',
|
||||
'flowRuns.inspector.loadError': '无法加载此运行',
|
||||
'flowRuns.inspector.fixWithAgent': '让智能体修复',
|
||||
'flowRuns.inspector.dataTable': '表格',
|
||||
'flowRuns.inspector.dataJson': 'JSON',
|
||||
'flowRuns.inspector.dataViewLabel': '输出视图',
|
||||
'flowRuns.inspector.itemCount': '{count} 项',
|
||||
'flowRuns.inspector.noItems': '无输出项',
|
||||
'flowRuns.inspector.emptyValue': '(空)',
|
||||
'flowRuns.inspector.binaryLabel': '二进制',
|
||||
'flowRuns.inspector.showSource': '来源',
|
||||
'flowRuns.inspector.hideSource': '隐藏来源',
|
||||
'flowRuns.inspector.sourceInputTitle': '来源输入项',
|
||||
'flowRuns.status.running': '运行中',
|
||||
'flowRuns.status.completed': '已完成',
|
||||
'flowRuns.status.pending_approval': '等待批准',
|
||||
@@ -3518,11 +3530,44 @@ const messages: TranslationMap = {
|
||||
'flows.runs.loading': '正在加载运行记录…',
|
||||
'flows.runs.loadError': '无法加载运行记录',
|
||||
'flows.runs.empty': '暂无运行记录',
|
||||
'flows.promptBar.label': '描述一个工作流',
|
||||
'flows.promptBar.placeholder': '描述一个工作流…',
|
||||
'flows.promptBar.submit': '生成',
|
||||
'flows.promptBar.thinking': '正在生成…',
|
||||
'flows.promptBar.heroTitle': '描述一个工作流',
|
||||
'flows.promptBar.heroSubtitle': '告诉生成器要自动化什么,然后审阅它的方案。',
|
||||
'flows.promptBar.error': '无法连接工作流生成器,请重试。',
|
||||
'flows.promptBar.offline': '你已离线,请重新连接以生成工作流。',
|
||||
'flows.copilot.open': '副驾驶',
|
||||
'flows.copilot.title': '工作流副驾驶',
|
||||
'flows.copilot.subtitle': '请求修改,并在应用每个方案前先审阅。',
|
||||
'flows.copilot.close': '关闭副驾驶',
|
||||
'flows.copilot.placeholder': '请求一处修改…',
|
||||
'flows.copilot.send': '发送',
|
||||
'flows.copilot.thinking': '思考中…',
|
||||
'flows.copilot.error': '无法连接工作流生成器,请重试。',
|
||||
'flows.copilot.offline': '你已离线,请重新连接以使用副驾驶。',
|
||||
'flows.copilot.emptyState': '描述对该工作流的修改,生成器会提出更新方案。',
|
||||
'flows.copilot.proposalTitle': '建议的修改',
|
||||
'flows.copilot.added': '新增 {count} 个',
|
||||
'flows.copilot.removed': '移除 {count} 个',
|
||||
'flows.copilot.noChanges': '此方案未更改任何节点。',
|
||||
'flows.copilot.accept': '应用到草稿',
|
||||
'flows.copilot.reject': '放弃',
|
||||
'flows.copilot.previewHint': '正在查看建议的草稿——尚未保存任何内容。',
|
||||
'flows.copilot.repairDisplay': '一次运行失败了,请查看并提出修复方案。',
|
||||
'flows.list.view': '查看工作流',
|
||||
'flows.list.export': '导出',
|
||||
'flows.list.exported': '工作流已导出',
|
||||
'flows.page.import': '导入',
|
||||
'flows.import.invalidFile': '该文件不是有效的工作流 JSON。',
|
||||
'flows.import.error': '无法导入此工作流。请检查文件后重试。',
|
||||
'flows.import.warningTitle': '导入警告',
|
||||
'flows.canvas.title': '工作流',
|
||||
'flows.canvas.loading': '正在加载工作流…',
|
||||
'flows.canvas.loadError': '无法加载此工作流。请重试。',
|
||||
'flows.canvas.notFound': '未找到此工作流。',
|
||||
'flows.canvas.draftMissing': '没有可打开的工作流草稿。请先在聊天中提出一个。',
|
||||
'flows.canvas.backToList': '返回工作流列表',
|
||||
'flows.nodeKind.trigger': '触发器',
|
||||
'flows.nodeKind.agent': '智能体',
|
||||
@@ -3536,6 +3581,107 @@ const messages: TranslationMap = {
|
||||
'flows.nodeKind.transform': '转换',
|
||||
'flows.nodeKind.output_parser': '输出解析器',
|
||||
'flows.nodeKind.sub_workflow': '子工作流',
|
||||
'flows.palette.title': '节点',
|
||||
'flows.palette.addNode': '添加{kind}节点',
|
||||
'flows.editor.save': '保存',
|
||||
'flows.editor.deleteSelected': '删除所选',
|
||||
// ── Validation UX + draft/dirty state (issue B5b / Phase 3c–3d)
|
||||
'flows.editor.saving': '正在保存…',
|
||||
'flows.editor.run': '运行',
|
||||
'flows.editor.running': '运行中…',
|
||||
'flows.editor.runFailed': '无法开始运行',
|
||||
'flows.editor.validate': '验证',
|
||||
'flows.editor.validating': '正在验证…',
|
||||
'flows.editor.discard': '放弃更改',
|
||||
'flows.editor.unsaved': '未保存的更改',
|
||||
'flows.editor.saveBlocked': '保存前请修复下方的错误。',
|
||||
'flows.editor.errorsTitle': '错误',
|
||||
'flows.editor.warningsTitle': '警告',
|
||||
'flows.editor.saveFailedTitle': '无法保存',
|
||||
'flows.editor.leaveTitle': '不保存就离开?',
|
||||
'flows.editor.leaveBody': '此工作流有未保存的更改。如果现在离开,更改将会丢失。',
|
||||
'flows.editor.leaveStay': '留下',
|
||||
'flows.editor.leaveDiscard': '离开',
|
||||
// ── Node config drawer (issue B5b / Phase 3b) — per-kind config forms
|
||||
'flows.nodeConfig.close': '关闭设置',
|
||||
'flows.nodeConfig.nameLabel': '名称',
|
||||
'flows.nodeConfig.namePlaceholder': '节点名称',
|
||||
'flows.nodeConfig.editForm': '以表单编辑',
|
||||
'flows.nodeConfig.editJson': '以 JSON 编辑',
|
||||
'flows.nodeConfig.rawJsonLabel': '配置 (JSON)',
|
||||
'flows.nodeConfig.rawJsonHint': '此节点的自由配置。',
|
||||
'flows.nodeConfig.rawJsonInvalid': 'JSON 无效 — 解析成功前更改不会应用。',
|
||||
'flows.nodeConfig.expressionHint': '以 = 开头可根据节点输入计算值,例如 =item.url',
|
||||
'flows.nodeConfig.expressionBadge': '表达式',
|
||||
'flows.nodeConfig.keymapKeyPlaceholder': '键',
|
||||
'flows.nodeConfig.keymapValuePlaceholder': '值',
|
||||
'flows.nodeConfig.keymapRemove': '删除行',
|
||||
'flows.nodeConfig.keymapAdd': '添加行',
|
||||
'flows.nodeConfig.credentialLabel': '凭据',
|
||||
'flows.nodeConfig.credentialHint': '为此节点选择已连接的账户或凭据。',
|
||||
'flows.nodeConfig.credentialEmpty': '没有可用的已连接凭据。',
|
||||
'flows.nodeConfig.credentialNone': '无',
|
||||
'flows.nodeConfig.trigger.kindLabel': '触发器类型',
|
||||
'flows.nodeConfig.trigger.kind_manual': '手动',
|
||||
'flows.nodeConfig.trigger.kind_schedule': '计划',
|
||||
'flows.nodeConfig.trigger.kind_webhook': 'Webhook',
|
||||
'flows.nodeConfig.trigger.kind_app_event': '应用事件',
|
||||
'flows.nodeConfig.trigger.scheduleLabel': 'Cron 计划',
|
||||
'flows.nodeConfig.trigger.scheduleHint': 'Cron 表达式:分 时 日 月 星期。',
|
||||
'flows.nodeConfig.trigger.toolkitLabel': '工具包',
|
||||
'flows.nodeConfig.trigger.triggerSlugLabel': '触发器标识',
|
||||
'flows.nodeConfig.trigger.webhookHint': 'Webhook 触发器会被保存,但尚不会自动触发。',
|
||||
'flows.nodeConfig.http.methodLabel': '方法',
|
||||
'flows.nodeConfig.http.urlLabel': 'URL',
|
||||
'flows.nodeConfig.http.headersLabel': '请求头',
|
||||
'flows.nodeConfig.http.bodyLabel': '正文 (JSON)',
|
||||
'flows.nodeConfig.agent.promptLabel': '提示词',
|
||||
'flows.nodeConfig.agent.promptPlaceholder': '给智能体的指令…',
|
||||
'flows.nodeConfig.agent.modelLabel': '模型',
|
||||
'flows.nodeConfig.tool.slugLabel': '工具标识',
|
||||
'flows.nodeConfig.tool.argsLabel': '参数 (JSON)',
|
||||
'flows.nodeConfig.condition.fieldLabel': '字段',
|
||||
'flows.nodeConfig.condition.fieldHint': '用于判断真值的输入项键。路由到 true 或 false。',
|
||||
'flows.nodeConfig.switch.expressionLabel': '表达式',
|
||||
'flows.nodeConfig.switch.hint': '结果值选择匹配的输出端口;null 路由到 default。',
|
||||
'flows.nodeConfig.switch.fieldLabel': '字段(回退)',
|
||||
'flows.nodeConfig.transform.setLabel': '设置字段',
|
||||
'flows.nodeConfig.transform.setHint': '每个值都是按项求值的表达式,例如 =item.name',
|
||||
'flows.nodeConfig.code.languageLabel': '语言',
|
||||
'flows.nodeConfig.code.language_javascript': 'JavaScript',
|
||||
'flows.nodeConfig.code.language_python': 'Python',
|
||||
'flows.nodeConfig.code.sourceLabel': '源代码',
|
||||
|
||||
'flows.chooser.title': '创建工作流',
|
||||
'flows.chooser.subtitle': '选择你想要的开始方式。',
|
||||
'flows.chooser.scratchTitle': '从头开始',
|
||||
'flows.chooser.scratchDescription': '从空白画布和单个手动触发器开始。',
|
||||
'flows.chooser.templateTitle': '从模板开始',
|
||||
'flows.chooser.templateDescription': '从现成的示例开始并进行自定义。',
|
||||
'flows.chooser.describeTitle': '描述它',
|
||||
'flows.chooser.describeDescription': '告诉助手你想要什么,让它起草工作流。',
|
||||
'flows.chooser.creating': '正在创建工作流…',
|
||||
'flows.chooser.createError': '无法创建工作流。请重试。',
|
||||
'flows.templates.title': '从模板开始',
|
||||
'flows.templates.subtitle': '选择一个起点并在编辑器中自定义。',
|
||||
'flows.templates.use': '使用模板',
|
||||
'flows.templates.back': '返回',
|
||||
'flows.templates.empty': '没有可用的模板。',
|
||||
'flows.templates.category.scheduled': '定时',
|
||||
'flows.templates.category.triggered': '触发',
|
||||
'flows.templates.category.onDemand': '按需',
|
||||
'flows.templates.daily-digest.name': '每日摘要发送到频道',
|
||||
'flows.templates.daily-digest.description': '按计划,智能体编写简短摘要并发布到频道。',
|
||||
'flows.templates.scheduled-scrape.name': '定时抓取到记忆',
|
||||
'flows.templates.scheduled-scrape.description': '按计划获取来源,重塑结果,并存储到记忆中。',
|
||||
'flows.templates.webhook-triage.name': 'Webhook 分流与通知',
|
||||
'flows.templates.webhook-triage.description': '传入的 Webhook 由智能体分流,然后通知你。',
|
||||
'flows.templates.app-event-route.name': '应用事件到条件操作',
|
||||
'flows.templates.app-event-route.description': '来自已连接应用的事件运行检查,匹配时执行操作。',
|
||||
'flows.templates.http-fetch-parse.name': '获取并解析 API',
|
||||
'flows.templates.http-fetch-parse.description': '按需调用 HTTP 端点,并将响应解析为可用的结构。',
|
||||
'flows.templates.ask-agent.name': '询问智能体',
|
||||
'flows.templates.ask-agent.description': '一个简单的手动触发器,将任务交给智能体。',
|
||||
|
||||
'oauth.button.connecting': '连接中...',
|
||||
'oauth.button.loopbackTimeout': '登录超时 — 浏览器未完成 OAuth 跳转。请重试。',
|
||||
|
||||
@@ -1,24 +1,69 @@
|
||||
/**
|
||||
* FlowCanvasPage — the read-only Workflow Canvas view (issue B5b.1) at
|
||||
* FlowCanvasPage (issue B5b / Phase 3) — the Workflow Canvas builder at
|
||||
* `/flows/:id`. Loads one saved flow via `flows_get`, converts its
|
||||
* `WorkflowGraph` (`Flow.graph`, opaque `unknown` on the wire type — see
|
||||
* `services/api/flowsApi.ts`) to xyflow's shape via `graphAdapter.ts`, and
|
||||
* renders it in `FlowCanvas` with editing disabled. This is the first slice
|
||||
* of the visual builder (de-risking the `@xyflow/react` integration) —
|
||||
* dragging nodes / drawing edges lands in B5b.2+.
|
||||
* renders it in the *editable* `FlowCanvas` (drag / connect / add / delete /
|
||||
* config, plus Phase 3c validation UX and Phase 3d draft/dirty state).
|
||||
*
|
||||
* This page owns the two host-level pieces of Phase 3d the canvas can't:
|
||||
* - **Save persistence** — `onSave` runs `flows_update(id, { graph })`. NO
|
||||
* autosave: a saved+enabled flow is live, so an accidental save would fire
|
||||
* real schedules. Save is only ever the explicit button in the canvas.
|
||||
* - **Unsaved-changes guard** — the canvas reports its dirty state up via
|
||||
* `onDirtyChange`; while dirty we (a) warn on a hard tab close/reload via
|
||||
* `beforeunload`, and (b) intercept the in-page Back button with a confirm
|
||||
* dialog. (App-wide route interception would need a data router; this app
|
||||
* mounts a `HashRouter`, so full `useBlocker` interception isn't available —
|
||||
* the Back button is this page's only in-app navigation affordance.)
|
||||
*/
|
||||
import createDebug from 'debug';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||
|
||||
import FlowCanvas from '../components/flows/canvas/FlowCanvas';
|
||||
import WorkflowCopilotPanel from '../components/flows/WorkflowCopilotPanel';
|
||||
import { ToastContainer } from '../components/intelligence/Toast';
|
||||
import PanelPage from '../components/layout/PanelPage';
|
||||
import Button from '../components/ui/Button';
|
||||
import { CenteredLoadingState, ErrorBanner } from '../components/ui/LoadingState';
|
||||
import { asFlowCanvasDraftState } from '../lib/flows/canvasDraft';
|
||||
import { workflowGraphToXyflow } from '../lib/flows/graphAdapter';
|
||||
import { buildPreviewGraph, diffGraphs } from '../lib/flows/graphDiff';
|
||||
import type { WorkflowGraph } from '../lib/flows/types';
|
||||
import { type RepairPromptContext } from '../lib/flows/workflowBuilderPrompt';
|
||||
import { useT } from '../lib/i18n/I18nContext';
|
||||
import { type Flow, getFlow } from '../services/api/flowsApi';
|
||||
import { createFlow, type Flow, getFlow, runFlow, updateFlow } from '../services/api/flowsApi';
|
||||
import type { WorkflowProposal } from '../store/chatRuntimeSlice';
|
||||
import type { ToastNotification } from '../types/intelligence';
|
||||
|
||||
/**
|
||||
* Seed for opening the canvas copilot preloaded from a failed run's "Fix with
|
||||
* agent" action (Phase 5c). Rides in `location.state` (ephemeral). The graph is
|
||||
* supplied by the editor itself, so only the run context travels here.
|
||||
*/
|
||||
export interface CopilotRepairSeed {
|
||||
runId: string;
|
||||
error?: string | null;
|
||||
failingNodeIds?: string[];
|
||||
}
|
||||
|
||||
/** Narrow an opaque `location.state` to a {@link CopilotRepairSeed}. */
|
||||
export function asCopilotRepairSeed(state: unknown): CopilotRepairSeed | null {
|
||||
if (!state || typeof state !== 'object') return null;
|
||||
const record = state as Record<string, unknown>;
|
||||
const seed = record.copilotRepair;
|
||||
if (!seed || typeof seed !== 'object') return null;
|
||||
const s = seed as Record<string, unknown>;
|
||||
if (typeof s.runId !== 'string') return null;
|
||||
return {
|
||||
runId: s.runId,
|
||||
error: typeof s.error === 'string' ? s.error : null,
|
||||
failingNodeIds: Array.isArray(s.failingNodeIds)
|
||||
? s.failingNodeIds.filter((v): v is string => typeof v === 'string')
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const log = createDebug('app:flows:canvas');
|
||||
|
||||
@@ -45,11 +90,358 @@ function BackIcon() {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A flow ready for the editable canvas — either a persisted flow (`flowId` set)
|
||||
* or an unsaved draft handed in from the chat `WorkflowProposalCard` "Open in
|
||||
* canvas" action (`flowId === null`, Phase 4e).
|
||||
*/
|
||||
interface EditorFlow {
|
||||
/** Persisted flow id, or `null` for an unsaved draft. */
|
||||
flowId: string | null;
|
||||
name: string;
|
||||
graph: WorkflowGraph;
|
||||
/** "Require approval" toggle carried into `flows_create` when saving a draft. */
|
||||
requireApproval: boolean;
|
||||
}
|
||||
|
||||
/** The editable canvas body — split out so its hooks only mount once a flow loads. */
|
||||
function FlowEditor({
|
||||
editorFlow,
|
||||
initialCopilotSeed = null,
|
||||
}: {
|
||||
editorFlow: EditorFlow;
|
||||
initialCopilotSeed?: CopilotRepairSeed | null;
|
||||
}) {
|
||||
const { t } = useT();
|
||||
const navigate = useNavigate();
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [leaveConfirm, setLeaveConfirm] = useState(false);
|
||||
// Active run id (== thread_id) driving the canvas's live per-node overlay
|
||||
// (Phase 3e). Set when the user runs the flow; the canvas subscribes to the
|
||||
// `flow:run_progress` feed for it via `useFlowRunProgress`.
|
||||
const [activeRunId, setActiveRunId] = useState<string | null>(null);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [runError, setRunError] = useState<string | null>(null);
|
||||
|
||||
const { flowId, name, graph, requireApproval } = editorFlow;
|
||||
// Draft (unsaved) canvases have no persisted id yet; Save creates the flow
|
||||
// rather than updating one, and there is nothing runnable to run.
|
||||
const isDraft = flowId === null;
|
||||
|
||||
// ── Canvas copilot + draft overlay (Phase 5c) ─────────────────────────────
|
||||
// `draftGraph` is the current ACCEPTED draft (starts as the loaded graph),
|
||||
// kept in sync with manual canvas edits via `onGraphChange`. A copilot
|
||||
// proposal enters `preview`: the canvas re-seeds (bump `canvasVersion`) with
|
||||
// the proposed graph plus ghosted removed nodes, painted diff-style. Accept
|
||||
// commits the proposed graph into `draftGraph`; Reject reverts to the frozen
|
||||
// base. NOTHING here persists — the canvas's own Save is the only gate.
|
||||
const [copilotOpen, setCopilotOpen] = useState(initialCopilotSeed !== null);
|
||||
const [draftGraph, setDraftGraph] = useState<WorkflowGraph>(graph);
|
||||
const [preview, setPreview] = useState<{
|
||||
proposal: WorkflowProposal;
|
||||
base: WorkflowGraph;
|
||||
addedNodeIds: Set<string>;
|
||||
removedNodeIds: Set<string>;
|
||||
} | null>(null);
|
||||
const [canvasVersion, setCanvasVersion] = useState(0);
|
||||
|
||||
// Last-persisted graph, independent of canvas remounts (fixes a P1: the
|
||||
// editable canvas seeds its own dirty baseline from whatever graph it's
|
||||
// mounted with, so bumping `canvasVersion` on Accept — remounting the
|
||||
// canvas with the just-accepted proposal as its "initial" graph — made an
|
||||
// unsaved accepted proposal instantly read as clean; the accepted change
|
||||
// was then lost on back/reload instead of gating behind the required Save.
|
||||
// Only ever updated by a real Save (`handleSave` below), so a diff against
|
||||
// it survives any number of accept/reject/preview remounts.
|
||||
const persistedGraphRef = useRef<WorkflowGraph>(graph);
|
||||
|
||||
const handleGraphChange = useCallback(
|
||||
(next: WorkflowGraph) => {
|
||||
// Freeze the draft while a proposal is under review — the preview graph
|
||||
// (with ghosts) must not overwrite the real draft.
|
||||
if (preview) return;
|
||||
setDraftGraph(next);
|
||||
},
|
||||
[preview]
|
||||
);
|
||||
|
||||
const handleProposal = useCallback(
|
||||
(proposal: WorkflowProposal) => {
|
||||
const proposedGraph = proposal.graph as WorkflowGraph;
|
||||
const d = diffGraphs(draftGraph, proposedGraph);
|
||||
log('copilot proposal: added=%d removed=%d', d.addedNodeIds.size, d.removedNodeIds.size);
|
||||
setPreview({
|
||||
proposal,
|
||||
base: draftGraph,
|
||||
addedNodeIds: d.addedNodeIds,
|
||||
removedNodeIds: d.removedNodeIds,
|
||||
});
|
||||
setCanvasVersion(v => v + 1);
|
||||
},
|
||||
[draftGraph]
|
||||
);
|
||||
|
||||
const handleAcceptProposal = useCallback((proposal: WorkflowProposal) => {
|
||||
log('copilot proposal accepted');
|
||||
setDraftGraph(proposal.graph as WorkflowGraph);
|
||||
setPreview(null);
|
||||
setCanvasVersion(v => v + 1);
|
||||
}, []);
|
||||
|
||||
const handleRejectProposal = useCallback(() => {
|
||||
log('copilot proposal rejected');
|
||||
setPreview(null);
|
||||
setCanvasVersion(v => v + 1);
|
||||
}, []);
|
||||
|
||||
// The graph the canvas renders: the proposed+ghosted preview while reviewing,
|
||||
// else the accepted draft.
|
||||
const editorGraph = useMemo(
|
||||
() =>
|
||||
preview
|
||||
? buildPreviewGraph(
|
||||
preview.base,
|
||||
preview.proposal.graph as WorkflowGraph,
|
||||
preview.removedNodeIds
|
||||
)
|
||||
: draftGraph,
|
||||
[preview, draftGraph]
|
||||
);
|
||||
const { nodes, edges } = useMemo(() => workflowGraphToXyflow(editorGraph), [editorGraph]);
|
||||
const meta = useMemo(
|
||||
() => ({ schema_version: graph.schema_version, id: flowId ?? undefined, name }),
|
||||
[graph.schema_version, flowId, name]
|
||||
);
|
||||
const initialDirty = useMemo(
|
||||
() => JSON.stringify(editorGraph) !== JSON.stringify(persistedGraphRef.current),
|
||||
[editorGraph]
|
||||
);
|
||||
|
||||
// Repair seed for the copilot: bind the run context to the CURRENT draft.
|
||||
const copilotRepairSeed = useMemo<RepairPromptContext | null>(
|
||||
() =>
|
||||
initialCopilotSeed
|
||||
? {
|
||||
runId: initialCopilotSeed.runId,
|
||||
error: initialCopilotSeed.error,
|
||||
failingNodeIds: initialCopilotSeed.failingNodeIds,
|
||||
graph: draftGraph,
|
||||
}
|
||||
: null,
|
||||
// Only seed once (on the initial draft) — a later draft edit must not
|
||||
// re-fire the repair turn.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[initialCopilotSeed]
|
||||
);
|
||||
|
||||
// Persist the live graph. A saved flow updates in place via `flows_update`; a
|
||||
// draft is created via `flows_create` (the single persistence gate — an
|
||||
// agent's `propose_workflow` never reaches this RPC), then we replace into
|
||||
// the new flow's canonical `/flows/:id` canvas so further saves update it.
|
||||
// Rejections propagate so the canvas surfaces the failure inline (and leaves
|
||||
// the draft dirty).
|
||||
const handleSave = useCallback(
|
||||
async (next: WorkflowGraph) => {
|
||||
if (isDraft) {
|
||||
log(
|
||||
'save: creating draft name=%s nodes=%d edges=%d',
|
||||
name,
|
||||
next.nodes.length,
|
||||
next.edges.length
|
||||
);
|
||||
const created = await createFlow(name, next, requireApproval);
|
||||
log('save: draft persisted as flow id=%s', created.id);
|
||||
navigate(`/flows/${created.id}`, { replace: true });
|
||||
return;
|
||||
}
|
||||
log('save: flow id=%s nodes=%d edges=%d', flowId, next.nodes.length, next.edges.length);
|
||||
await updateFlow(flowId, { graph: next });
|
||||
persistedGraphRef.current = next;
|
||||
log('save: flow id=%s persisted', flowId);
|
||||
},
|
||||
[isDraft, flowId, name, requireApproval, navigate]
|
||||
);
|
||||
|
||||
// Warn on hard tab close / reload while there are unsaved edits.
|
||||
useEffect(() => {
|
||||
if (!dirty) return;
|
||||
const handler = (event: BeforeUnloadEvent) => {
|
||||
event.preventDefault();
|
||||
event.returnValue = '';
|
||||
};
|
||||
window.addEventListener('beforeunload', handler);
|
||||
return () => window.removeEventListener('beforeunload', handler);
|
||||
}, [dirty]);
|
||||
|
||||
// Run the *persisted* flow and hand its thread_id to the canvas so it can
|
||||
// overlay live per-node status (Phase 3e). Runs the saved version — not the
|
||||
// (possibly dirty) draft — matching the "Save is explicit, running is live"
|
||||
// model. The durable run row + poller remain the source of truth.
|
||||
const handleRun = useCallback(async () => {
|
||||
if (flowId === null) return; // drafts aren't runnable until saved
|
||||
setRunning(true);
|
||||
setRunError(null);
|
||||
try {
|
||||
log('run: starting flow id=%s', flowId);
|
||||
const result = await runFlow(flowId);
|
||||
log('run: started flow id=%s thread_id=%s', flowId, result.thread_id);
|
||||
setActiveRunId(result.thread_id);
|
||||
} catch (err) {
|
||||
const message = errorMessage(err);
|
||||
log('run: failed id=%s err=%o', flowId, err);
|
||||
setRunError(message);
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
}, [flowId]);
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
if (dirty) {
|
||||
log('back: dirty — prompting for confirmation');
|
||||
setLeaveConfirm(true);
|
||||
return;
|
||||
}
|
||||
navigate('/flows');
|
||||
}, [dirty, navigate]);
|
||||
|
||||
const backButton = (
|
||||
<Button
|
||||
type="button"
|
||||
variant="tertiary"
|
||||
size="xs"
|
||||
iconOnly
|
||||
data-testid="flow-canvas-back"
|
||||
aria-label={t('flows.canvas.backToList')}
|
||||
onClick={handleBack}>
|
||||
<BackIcon />
|
||||
</Button>
|
||||
);
|
||||
|
||||
// A draft has nothing persisted to run yet — the canvas's Save (which creates
|
||||
// the flow) is the only gate, so no Run affordance until it's saved.
|
||||
const runButton = isDraft ? undefined : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
size="xs"
|
||||
data-testid="flow-canvas-run"
|
||||
disabled={running}
|
||||
onClick={() => void handleRun()}>
|
||||
{running ? t('flows.editor.running') : t('flows.editor.run')}
|
||||
</Button>
|
||||
);
|
||||
|
||||
const headerActions = (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={copilotOpen ? 'primary' : 'secondary'}
|
||||
size="xs"
|
||||
data-testid="flow-canvas-copilot-toggle"
|
||||
aria-pressed={copilotOpen}
|
||||
onClick={() => setCopilotOpen(open => !open)}>
|
||||
{t('flows.copilot.open')}
|
||||
</Button>
|
||||
{runButton}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<PanelPage
|
||||
testId="flow-canvas-page"
|
||||
title={name}
|
||||
leading={backButton}
|
||||
action={headerActions}
|
||||
contentClassName="h-full p-0">
|
||||
<div className="flex h-full w-full">
|
||||
<div className="relative h-full flex-1">
|
||||
<FlowCanvas
|
||||
key={`canvas-${canvasVersion}`}
|
||||
editable
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
meta={meta}
|
||||
onSave={handleSave}
|
||||
onDirtyChange={setDirty}
|
||||
activeRunId={activeRunId}
|
||||
onGraphChange={handleGraphChange}
|
||||
addedNodeIds={preview?.addedNodeIds}
|
||||
removedNodeIds={preview?.removedNodeIds}
|
||||
saveDisabled={preview !== null}
|
||||
initialDirty={initialDirty}
|
||||
/>
|
||||
|
||||
{runError && (
|
||||
<div className="pointer-events-none absolute inset-x-3 top-3 z-20 flex justify-center">
|
||||
<div
|
||||
role="alert"
|
||||
data-testid="flow-canvas-run-error"
|
||||
className="pointer-events-auto rounded-xl border border-coral-200 bg-coral-50 px-3 py-2 text-xs text-coral-700 dark:border-coral-500/30 dark:bg-coral-500/10 dark:text-coral-300">
|
||||
{t('flows.editor.runFailed')}: {runError}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{leaveConfirm && (
|
||||
<div
|
||||
className="absolute inset-0 z-30 flex items-center justify-center bg-black/30 p-4"
|
||||
data-testid="flow-leave-confirm">
|
||||
<div className="w-full max-w-sm rounded-xl border border-line bg-surface p-4 shadow-xl">
|
||||
<h2 className="text-sm font-semibold text-content">
|
||||
{t('flows.editor.leaveTitle')}
|
||||
</h2>
|
||||
<p className="mt-1 text-xs text-content-muted">{t('flows.editor.leaveBody')}</p>
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
data-testid="flow-leave-stay"
|
||||
onClick={() => setLeaveConfirm(false)}>
|
||||
{t('flows.editor.leaveStay')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
tone="danger"
|
||||
size="sm"
|
||||
data-testid="flow-leave-discard"
|
||||
onClick={() => {
|
||||
log('back: confirmed leave — discarding unsaved edits');
|
||||
navigate('/flows');
|
||||
}}>
|
||||
{t('flows.editor.leaveDiscard')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{copilotOpen && (
|
||||
<WorkflowCopilotPanel
|
||||
graph={preview?.base ?? draftGraph}
|
||||
onProposal={handleProposal}
|
||||
onAccept={handleAcceptProposal}
|
||||
onReject={handleRejectProposal}
|
||||
onClose={() => setCopilotOpen(false)}
|
||||
repairSeed={copilotRepairSeed}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</PanelPage>
|
||||
);
|
||||
}
|
||||
|
||||
export default function FlowCanvasPage() {
|
||||
const { t } = useT();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [state, setState] = useState<LoadState>({ status: 'loading' });
|
||||
// "Fix with agent" (Phase 5c) navigates here with a repair seed in
|
||||
// `location.state` so the copilot opens preloaded with the failed run.
|
||||
const copilotSeed = useMemo(() => asCopilotRepairSeed(location.state), [location.state]);
|
||||
|
||||
useEffect(() => {
|
||||
// Guards a stale response from clobbering newer state: this effect
|
||||
@@ -95,6 +487,24 @@ export default function FlowCanvasPage() {
|
||||
};
|
||||
}, [id]);
|
||||
|
||||
if (state.status === 'ready') {
|
||||
// Keyed by flow id so switching flows cleanly re-seeds the editable canvas's
|
||||
// controlled node/edge state (which only reads its props at mount).
|
||||
const flow = state.flow;
|
||||
return (
|
||||
<FlowEditor
|
||||
key={flow.id}
|
||||
editorFlow={{
|
||||
flowId: flow.id,
|
||||
name: flow.name,
|
||||
graph: flow.graph as WorkflowGraph,
|
||||
requireApproval: flow.require_approval,
|
||||
}}
|
||||
initialCopilotSeed={copilotSeed}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const backButton = (
|
||||
<Button
|
||||
type="button"
|
||||
@@ -108,12 +518,10 @@ export default function FlowCanvasPage() {
|
||||
</Button>
|
||||
);
|
||||
|
||||
const title = state.status === 'ready' ? state.flow.name : t('flows.canvas.title');
|
||||
|
||||
return (
|
||||
<PanelPage
|
||||
testId="flow-canvas-page"
|
||||
title={title}
|
||||
title={t('flows.canvas.title')}
|
||||
leading={backButton}
|
||||
contentClassName="h-full p-0">
|
||||
{state.status === 'loading' && (
|
||||
@@ -135,13 +543,82 @@ export default function FlowCanvasPage() {
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</PanelPage>
|
||||
);
|
||||
}
|
||||
|
||||
{state.status === 'ready' &&
|
||||
(() => {
|
||||
const graph = state.flow.graph as WorkflowGraph;
|
||||
const { nodes, edges } = workflowGraphToXyflow(graph);
|
||||
return <FlowCanvas nodes={nodes} edges={edges} readonly />;
|
||||
})()}
|
||||
/**
|
||||
* FlowCanvasDraftPage (Phase 4e) — the editable Workflow Canvas hosting an
|
||||
* UNSAVED draft handed in from the chat `WorkflowProposalCard` "Open in canvas"
|
||||
* action, at `/flows/draft`. The candidate graph rides in `location.state`
|
||||
* (ephemeral — see `lib/flows/canvasDraft.ts`); NOTHING is fetched or persisted
|
||||
* on open. The canvas's own Save button remains the single persistence gate
|
||||
* (it calls `flows_create` for a draft), so opening a draft never touches
|
||||
* `flows_create`/`flows_update`. If there's no draft in state (e.g. a hard
|
||||
* reload dropped it, or the route was hit directly), we show an empty state
|
||||
* rather than a broken canvas.
|
||||
*/
|
||||
export function FlowCanvasDraftPage() {
|
||||
const { t } = useT();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const draft = useMemo(() => asFlowCanvasDraftState(location.state), [location.state]);
|
||||
|
||||
// Non-fatal import warnings (Phase 4d) shown as dismissible toasts over the
|
||||
// draft canvas. Seeded once from the draft state so unmapped n8n node types /
|
||||
// untranslated expressions aren't silently lost on the way in.
|
||||
const [toasts, setToasts] = useState<ToastNotification[]>(() =>
|
||||
(draft?.importWarnings ?? []).map((message, i) => ({
|
||||
id: `import-warning-${i}`,
|
||||
type: 'warning',
|
||||
title: t('flows.import.warningTitle'),
|
||||
message,
|
||||
}))
|
||||
);
|
||||
const removeToast = useCallback((id: string) => {
|
||||
setToasts(prev => prev.filter(item => item.id !== id));
|
||||
}, []);
|
||||
|
||||
if (draft) {
|
||||
return (
|
||||
<>
|
||||
<FlowEditor
|
||||
editorFlow={{
|
||||
flowId: null,
|
||||
name: draft.name,
|
||||
graph: draft.graph,
|
||||
requireApproval: draft.requireApproval,
|
||||
}}
|
||||
/>
|
||||
<ToastContainer notifications={toasts} onRemove={removeToast} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const backButton = (
|
||||
<Button
|
||||
type="button"
|
||||
variant="tertiary"
|
||||
size="xs"
|
||||
iconOnly
|
||||
data-testid="flow-canvas-back"
|
||||
aria-label={t('flows.canvas.backToList')}
|
||||
onClick={() => navigate('/flows')}>
|
||||
<BackIcon />
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<PanelPage
|
||||
testId="flow-canvas-page"
|
||||
title={t('flows.canvas.title')}
|
||||
leading={backButton}
|
||||
contentClassName="h-full p-0">
|
||||
<div className="flex h-full items-center justify-center p-4">
|
||||
<p className="text-sm text-content-muted" data-testid="flow-canvas-draft-missing">
|
||||
{t('flows.canvas.draftMissing')}
|
||||
</p>
|
||||
</div>
|
||||
</PanelPage>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,12 +5,14 @@
|
||||
* "Workflow started" toast, and refetches the list, that "View runs" opens
|
||||
* `FlowRunsDrawer` for the clicked flow, that clicking a flow's name
|
||||
* navigates to its read-only Workflow Canvas (`/flows/:id`, issue B5b.1),
|
||||
* and that "New workflow" (header + empty state) navigates to Chat (no
|
||||
* canvas *builder* yet — bridges to B4's agent-proposal flow).
|
||||
* and that "New workflow" (header + empty state) opens the Phase 4a chooser
|
||||
* (start from scratch / template / describe), with the empty state also
|
||||
* surfacing the Phase 4c template gallery inline.
|
||||
*/
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { FLOW_TEMPLATES } from '../lib/flows/templates';
|
||||
import type { Flow } from '../services/api/flowsApi';
|
||||
import { renderWithProviders } from '../test/test-utils';
|
||||
import FlowsPage from './FlowsPage';
|
||||
@@ -19,7 +21,19 @@ const listFlows = vi.hoisted(() => vi.fn());
|
||||
const setFlowEnabled = vi.hoisted(() => vi.fn());
|
||||
const runFlow = vi.hoisted(() => vi.fn());
|
||||
const listFlowRuns = vi.hoisted(() => vi.fn());
|
||||
vi.mock('../services/api/flowsApi', () => ({ listFlows, setFlowEnabled, runFlow, listFlowRuns }));
|
||||
const createFlow = vi.hoisted(() => vi.fn());
|
||||
const importFlow = vi.hoisted(() => vi.fn());
|
||||
vi.mock('../services/api/flowsApi', () => ({
|
||||
listFlows,
|
||||
setFlowEnabled,
|
||||
runFlow,
|
||||
listFlowRuns,
|
||||
createFlow,
|
||||
importFlow,
|
||||
}));
|
||||
|
||||
const downloadFlowGraph = vi.hoisted(() => vi.fn(() => true));
|
||||
vi.mock('../lib/flows/exportFlow', () => ({ downloadFlowGraph }));
|
||||
|
||||
const mockNavigate = vi.hoisted(() => vi.fn());
|
||||
vi.mock('react-router-dom', async importOriginal => {
|
||||
@@ -147,7 +161,7 @@ describe('FlowsPage', () => {
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/flows/flow-1');
|
||||
});
|
||||
|
||||
it('renders a "New workflow" header button and navigates to /chat when clicked', async () => {
|
||||
it('renders a "New workflow" header button that opens the chooser modal', async () => {
|
||||
listFlows.mockResolvedValue([makeFlow()]);
|
||||
renderWithProviders(<FlowsPage />);
|
||||
|
||||
@@ -155,16 +169,94 @@ describe('FlowsPage', () => {
|
||||
expect(newWorkflowButton).toHaveTextContent('New workflow');
|
||||
fireEvent.click(newWorkflowButton);
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/chat');
|
||||
expect(screen.getByTestId('new-workflow-modal')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('new-workflow-scratch')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('navigates to /chat when the empty-state "New workflow" action is clicked', async () => {
|
||||
it('opens the chooser from the empty-state "New workflow" action', async () => {
|
||||
listFlows.mockResolvedValue([]);
|
||||
renderWithProviders(<FlowsPage />);
|
||||
|
||||
const emptyStateButton = await screen.findByTestId('flows-empty-new-workflow');
|
||||
fireEvent.click(emptyStateButton);
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/chat');
|
||||
expect(screen.getByTestId('new-workflow-modal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('"describe it" in the chooser focuses the in-place prompt bar (no Chat hand-off)', async () => {
|
||||
listFlows.mockResolvedValue([makeFlow()]);
|
||||
renderWithProviders(<FlowsPage />);
|
||||
|
||||
fireEvent.click(await screen.findByTestId('flows-new-workflow'));
|
||||
fireEvent.click(screen.getByTestId('new-workflow-describe'));
|
||||
|
||||
// Phase 5c: no more /chat hand-off — the chooser closes and the prompt bar
|
||||
// (already rendered at the top of the page) takes focus for authoring.
|
||||
expect(mockNavigate).not.toHaveBeenCalledWith('/chat');
|
||||
expect(screen.getByTestId('workflow-prompt-bar')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('workflow-prompt-input')).toHaveFocus();
|
||||
});
|
||||
|
||||
it('empty-state template gallery creates a flow and opens its canvas', async () => {
|
||||
listFlows.mockResolvedValue([]);
|
||||
createFlow.mockResolvedValue({ id: 'flow-created' });
|
||||
renderWithProviders(<FlowsPage />);
|
||||
|
||||
await screen.findByTestId('flows-empty-templates');
|
||||
const template = FLOW_TEMPLATES[0];
|
||||
fireEvent.click(screen.getByTestId(`flow-template-${template.id}`));
|
||||
|
||||
await waitFor(() => expect(createFlow).toHaveBeenCalledTimes(1));
|
||||
expect(createFlow.mock.calls[0][1]).toBe(template.graph);
|
||||
await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/flows/flow-created'));
|
||||
});
|
||||
|
||||
it('renders an Import button in the header', async () => {
|
||||
listFlows.mockResolvedValue([makeFlow()]);
|
||||
renderWithProviders(<FlowsPage />);
|
||||
|
||||
const importButton = await screen.findByTestId('flows-import');
|
||||
expect(importButton).toHaveTextContent('Import');
|
||||
});
|
||||
|
||||
it('exports a flow row as JSON via downloadFlowGraph', async () => {
|
||||
listFlows.mockResolvedValue([makeFlow({ graph: { nodes: [], edges: [] } })]);
|
||||
renderWithProviders(<FlowsPage />);
|
||||
|
||||
fireEvent.click(await screen.findByTestId('flow-export-flow-1'));
|
||||
|
||||
expect(downloadFlowGraph).toHaveBeenCalledWith('Daily digest', { nodes: [], edges: [] });
|
||||
});
|
||||
|
||||
it('imports a picked JSON file and opens the result as a draft canvas', async () => {
|
||||
listFlows.mockResolvedValue([]);
|
||||
const graph = { schema_version: 1, name: 'Imported', nodes: [], edges: [] };
|
||||
importFlow.mockResolvedValue({ graph, warnings: ['heads up'] });
|
||||
renderWithProviders(<FlowsPage />);
|
||||
|
||||
const input = await screen.findByTestId('flows-import-input');
|
||||
const file = new File([JSON.stringify({ nodes: [] })], 'wf.json', { type: 'application/json' });
|
||||
fireEvent.change(input, { target: { files: [file] } });
|
||||
|
||||
await waitFor(() => expect(importFlow).toHaveBeenCalledWith({ nodes: [] }, 'auto'));
|
||||
await waitFor(() =>
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/flows/draft', {
|
||||
state: { name: 'Imported', graph, requireApproval: true, importWarnings: ['heads up'] },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('shows an error when the picked file is not valid JSON', async () => {
|
||||
listFlows.mockResolvedValue([]);
|
||||
renderWithProviders(<FlowsPage />);
|
||||
|
||||
const input = await screen.findByTestId('flows-import-input');
|
||||
const file = new File(['not json{'], 'wf.json', { type: 'application/json' });
|
||||
fireEvent.change(input, { target: { files: [file] } });
|
||||
|
||||
expect(await screen.findByTestId('flows-error')).toHaveTextContent(
|
||||
'That file is not valid workflow JSON.'
|
||||
);
|
||||
expect(importFlow).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
+217
-49
@@ -2,24 +2,41 @@
|
||||
* FlowsPage — the Workflows list page (issue B5a).
|
||||
*
|
||||
* The discoverable hub for the `flows::` domain: lists every saved
|
||||
* `Flow` (name, enabled toggle, last-run status, Run button). This is NOT the
|
||||
* canvas (B5b ships flow authoring/editing) — until it lands, "New workflow"
|
||||
* (header + empty-state) bridges to the B4 agent-proposal flow in Chat
|
||||
* instead, since that's the only way to author a flow today.
|
||||
* `Flow` (name, enabled toggle, last-run status, Run button). "New workflow"
|
||||
* (header + empty-state) opens the Phase 4a chooser — start from scratch, pick
|
||||
* a template (Phase 4c), or describe it in Chat — each of which creates a flow
|
||||
* and opens the editable canvas (`/flows/:id`). The empty state also surfaces
|
||||
* the template gallery inline so first-time users have a one-click starting
|
||||
* point.
|
||||
*/
|
||||
import createDebug from 'debug';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import EmptyStateCard from '../components/EmptyStateCard';
|
||||
import FlowListRow, { type FlowListRowBusy } from '../components/flows/FlowListRow';
|
||||
import type { FlowRepairRequest } from '../components/flows/FlowRunInspectorDrawer';
|
||||
import FlowRunsDrawer from '../components/flows/FlowRunsDrawer';
|
||||
import FlowTemplateGallery from '../components/flows/FlowTemplateGallery';
|
||||
import NewWorkflowModal from '../components/flows/NewWorkflowModal';
|
||||
import { useCreateFlow } from '../components/flows/useCreateFlow';
|
||||
import WorkflowPromptBar from '../components/flows/WorkflowPromptBar';
|
||||
import { ToastContainer } from '../components/intelligence/Toast';
|
||||
import PanelPage from '../components/layout/PanelPage';
|
||||
import Button from '../components/ui/Button';
|
||||
import { CenteredLoadingState, ErrorBanner } from '../components/ui/LoadingState';
|
||||
import { FLOW_CANVAS_DRAFT_ROUTE, type FlowCanvasDraftState } from '../lib/flows/canvasDraft';
|
||||
import { downloadFlowGraph } from '../lib/flows/exportFlow';
|
||||
import { type FlowTemplate, templateNameKey } from '../lib/flows/templates';
|
||||
import type { WorkflowGraph } from '../lib/flows/types';
|
||||
import { useT } from '../lib/i18n/I18nContext';
|
||||
import { type Flow, listFlows, runFlow, setFlowEnabled } from '../services/api/flowsApi';
|
||||
import {
|
||||
type Flow,
|
||||
importFlow,
|
||||
listFlows,
|
||||
runFlow,
|
||||
setFlowEnabled,
|
||||
} from '../services/api/flowsApi';
|
||||
import type { ToastNotification } from '../types/intelligence';
|
||||
|
||||
const log = createDebug('app:flows');
|
||||
@@ -43,6 +60,14 @@ export default function FlowsPage() {
|
||||
// then stacks on top of that when a specific run is picked). `null` keeps
|
||||
// the drawer unmounted.
|
||||
const [selectedFlowId, setSelectedFlowId] = useState<string | null>(null);
|
||||
// Whether the Phase 4a "New workflow" chooser modal is open.
|
||||
const [chooserOpen, setChooserOpen] = useState(false);
|
||||
// Bumped by the chooser's "Describe it" action so the prompt bar remounts and
|
||||
// takes focus (Phase 5c). Starts at 0 (no autofocus on initial page load).
|
||||
const [describeNonce, setDescribeNonce] = useState(0);
|
||||
// Create-and-open logic for the empty-state inline template gallery. (The
|
||||
// chooser modal owns its own `useCreateFlow` instance.)
|
||||
const emptyCreate = useCreateFlow();
|
||||
|
||||
const addToast = useCallback((toast: Omit<ToastNotification, 'id'>) => {
|
||||
setToasts(prev => [...prev, { ...toast, id: `toast-${Date.now()}-${Math.random()}` }]);
|
||||
@@ -129,6 +154,29 @@ export default function FlowsPage() {
|
||||
setSelectedFlowId(flow.id);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* "Fix with agent" (Phase 5c) from a failed run's inspector: open the flow's
|
||||
* canvas with a copilot repair seed in `location.state` so the copilot opens
|
||||
* preloaded, diagnosing the failed run. Never persists — the copilot only
|
||||
* proposes.
|
||||
*/
|
||||
const handleFixWithAgent = useCallback(
|
||||
(request: FlowRepairRequest) => {
|
||||
log('fix with agent: flow=%s run=%s', request.flowId, request.runId);
|
||||
setSelectedFlowId(null);
|
||||
navigate(`/flows/${request.flowId}`, {
|
||||
state: {
|
||||
copilotRepair: {
|
||||
runId: request.runId,
|
||||
error: request.error,
|
||||
failingNodeIds: request.failingNodeIds,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
[navigate]
|
||||
);
|
||||
|
||||
/** Opens the read-only Workflow Canvas for this flow (issue B5b.1). */
|
||||
const handleView = useCallback(
|
||||
(flow: Flow) => {
|
||||
@@ -140,24 +188,95 @@ export default function FlowsPage() {
|
||||
|
||||
const selectedFlow = flows.find(f => f.id === selectedFlowId) ?? null;
|
||||
|
||||
/** Downloads a flow's `WorkflowGraph` as a JSON file (Phase 4d export). */
|
||||
const handleExport = useCallback(
|
||||
(flow: Flow) => {
|
||||
log('export: id=%s', flow.id);
|
||||
const ok = downloadFlowGraph(flow.name, flow.graph);
|
||||
if (ok) {
|
||||
addToast({ type: 'success', title: t('flows.list.exported') });
|
||||
}
|
||||
},
|
||||
[addToast, t]
|
||||
);
|
||||
|
||||
// Hidden file input backing the header "Import" action. Clicking the button
|
||||
// opens the OS file picker; the change handler reads + imports the file.
|
||||
const importInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const handleImportClick = useCallback(() => {
|
||||
log('import: opening file picker');
|
||||
importInputRef.current?.click();
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* "New workflow" (there's no canvas builder yet — B5b) bridges to Chat so
|
||||
* the user can kick off B4's agent-proposal flow instead. There's no
|
||||
* existing mechanism to prefill or auto-send an initial composer message
|
||||
* from outside the Chat page — `Conversations.tsx` only reads
|
||||
* `location.state.openThreadId` (to reopen a thread), and the composer's
|
||||
* text is local `useState` with no Redux draft slice. This is the same gap
|
||||
* `ActionItemChecklist.tsx`'s "Run with OpenHuman" button already hit, so
|
||||
* we follow its precedent: navigate to `/chat` with no prefill rather than
|
||||
* build new prefill plumbing from scratch.
|
||||
* Reads the picked JSON file and runs it through `flows_import` (host-side
|
||||
* migrate + validate + best-effort n8n mapping). On success, opens the
|
||||
* normalized graph on the editable canvas as an UNSAVED draft — nothing is
|
||||
* persisted until the user Saves via the canvas's existing gate. Auto-detect
|
||||
* handles native vs n8n, so no format prompt is needed.
|
||||
*/
|
||||
const handleImportFile = useCallback(
|
||||
async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
// Reset the input so re-picking the same file fires `change` again.
|
||||
event.target.value = '';
|
||||
if (!file) return;
|
||||
setError(null);
|
||||
log('import: reading file name=%s size=%d', file.name, file.size);
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(await file.text());
|
||||
} catch (err) {
|
||||
log('import: invalid JSON: %o', err);
|
||||
setError(t('flows.import.invalidFile'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await importFlow(parsed, 'auto');
|
||||
const graph = result.graph as WorkflowGraph;
|
||||
log('import: ok warnings=%d', result.warnings.length);
|
||||
const draft: FlowCanvasDraftState = {
|
||||
name: graph.name || file.name.replace(/\.[^.]+$/, ''),
|
||||
graph,
|
||||
requireApproval: true,
|
||||
importWarnings: result.warnings,
|
||||
};
|
||||
navigate(FLOW_CANVAS_DRAFT_ROUTE, { state: draft });
|
||||
} catch (err) {
|
||||
log('import failed: %o', err);
|
||||
setError(t('flows.import.error'));
|
||||
}
|
||||
},
|
||||
[navigate, t]
|
||||
);
|
||||
|
||||
/** "New workflow" opens the Phase 4a chooser (scratch / template / describe). */
|
||||
const handleNewWorkflow = useCallback(() => {
|
||||
log('new workflow: navigating to chat');
|
||||
// TODO: prefill the chat composer with a workflow-building prompt once a
|
||||
// draft/initial-message API exists (see ActionItemChecklist.tsx's
|
||||
// identical TODO for the same gap).
|
||||
navigate('/chat');
|
||||
}, [navigate]);
|
||||
log('new workflow: opening chooser');
|
||||
setChooserOpen(true);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* "Describe it" hand-off (Phase 5c): rather than punting to Chat, focus the
|
||||
* in-place prompt bar at the top of this page — it spawns a `workflow_builder`
|
||||
* turn in a dedicated thread and renders the proposal inline. Bumping the
|
||||
* nonce remounts the bar so it takes focus even though it's already visible.
|
||||
*/
|
||||
const handleDescribe = useCallback(() => {
|
||||
log('new workflow: describe — focusing the prompt bar');
|
||||
setChooserOpen(false);
|
||||
setDescribeNonce(n => n + 1);
|
||||
}, []);
|
||||
|
||||
/** Create a flow from an empty-state gallery card and open its canvas. */
|
||||
const handleEmptyTemplate = useCallback(
|
||||
(template: FlowTemplate) => {
|
||||
log('empty-state template selected: id=%s', template.id);
|
||||
void emptyCreate.create(template.id, t(templateNameKey(template.id)), template.graph);
|
||||
},
|
||||
[emptyCreate, t]
|
||||
);
|
||||
|
||||
return (
|
||||
<PanelPage
|
||||
@@ -165,16 +284,44 @@ export default function FlowsPage() {
|
||||
title={t('flows.page.title')}
|
||||
description={t('flows.page.description')}
|
||||
action={
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
data-testid="flows-new-workflow"
|
||||
onClick={handleNewWorkflow}>
|
||||
{t('flows.page.newWorkflow')}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
data-testid="flows-import"
|
||||
onClick={handleImportClick}>
|
||||
{t('flows.page.import')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
data-testid="flows-new-workflow"
|
||||
onClick={handleNewWorkflow}>
|
||||
{t('flows.page.newWorkflow')}
|
||||
</Button>
|
||||
</div>
|
||||
}>
|
||||
<input
|
||||
ref={importInputRef}
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
className="hidden"
|
||||
data-testid="flows-import-input"
|
||||
onChange={e => void handleImportFile(e)}
|
||||
/>
|
||||
<div className="mx-auto w-full max-w-3xl space-y-4">
|
||||
{/* Prompt-first authoring (Phase 5c): describe a workflow and let the
|
||||
builder agent propose it. Hero presentation when the list is empty,
|
||||
compact otherwise. Keyed by `describeNonce` so the chooser's
|
||||
"Describe it" action remounts + focuses it. */}
|
||||
<WorkflowPromptBar
|
||||
key={`prompt-bar-${describeNonce}`}
|
||||
variant={!loading && flows.length === 0 ? 'hero' : 'compact'}
|
||||
autoFocus={describeNonce > 0}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div data-testid="flows-error">
|
||||
<ErrorBanner message={error} />
|
||||
@@ -184,26 +331,41 @@ export default function FlowsPage() {
|
||||
{loading && <CenteredLoadingState label={t('flows.page.loading')} />}
|
||||
|
||||
{!loading && flows.length === 0 && !error && (
|
||||
<EmptyStateCard
|
||||
icon={
|
||||
<svg
|
||||
className="h-7 w-7 text-primary-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}>
|
||||
<circle cx="5" cy="6" r="2" />
|
||||
<circle cx="5" cy="18" r="2" />
|
||||
<circle cx="19" cy="12" r="2" />
|
||||
<path strokeLinecap="round" d="M7 6h4a4 4 0 014 4M7 18h4a4 4 0 004-4" />
|
||||
</svg>
|
||||
}
|
||||
title={t('flows.page.emptyTitle')}
|
||||
description={t('flows.page.emptyDescription')}
|
||||
actionLabel={t('flows.page.newWorkflow')}
|
||||
actionTestId="flows-empty-new-workflow"
|
||||
onAction={handleNewWorkflow}
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
<EmptyStateCard
|
||||
icon={
|
||||
<svg
|
||||
className="h-7 w-7 text-primary-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}>
|
||||
<circle cx="5" cy="6" r="2" />
|
||||
<circle cx="5" cy="18" r="2" />
|
||||
<circle cx="19" cy="12" r="2" />
|
||||
<path strokeLinecap="round" d="M7 6h4a4 4 0 014 4M7 18h4a4 4 0 004-4" />
|
||||
</svg>
|
||||
}
|
||||
title={t('flows.page.emptyTitle')}
|
||||
description={t('flows.page.emptyDescription')}
|
||||
actionLabel={t('flows.page.newWorkflow')}
|
||||
actionTestId="flows-empty-new-workflow"
|
||||
onAction={handleNewWorkflow}
|
||||
/>
|
||||
|
||||
<section className="space-y-3" data-testid="flows-empty-templates">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-content">{t('flows.templates.title')}</h3>
|
||||
<p className="text-xs text-content-muted">{t('flows.templates.subtitle')}</p>
|
||||
</div>
|
||||
{emptyCreate.error && (
|
||||
<div data-testid="flows-empty-template-error">
|
||||
<ErrorBanner message={emptyCreate.error} />
|
||||
</div>
|
||||
)}
|
||||
<FlowTemplateGallery onSelect={handleEmptyTemplate} busyId={emptyCreate.busyKey} />
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && flows.length > 0 && (
|
||||
@@ -219,6 +381,7 @@ export default function FlowsPage() {
|
||||
onRun={f => void handleRun(f)}
|
||||
onViewRuns={handleViewRuns}
|
||||
onView={handleView}
|
||||
onExport={handleExport}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -229,8 +392,13 @@ export default function FlowsPage() {
|
||||
flowId={selectedFlowId}
|
||||
flowName={selectedFlow?.name}
|
||||
onClose={() => setSelectedFlowId(null)}
|
||||
onFixWithAgent={handleFixWithAgent}
|
||||
/>
|
||||
|
||||
{chooserOpen && (
|
||||
<NewWorkflowModal onClose={() => setChooserOpen(false)} onDescribe={handleDescribe} />
|
||||
)}
|
||||
|
||||
<ToastContainer notifications={toasts} onRemove={removeToast} />
|
||||
</PanelPage>
|
||||
);
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
/**
|
||||
* WorkflowNew — Phase 6 coverage.
|
||||
*
|
||||
* Covers:
|
||||
* - renders the form (delegates to CreateWorkflowForm) and the header
|
||||
* Cancel/Submit buttons.
|
||||
* - cancel button navigates back to /skills.
|
||||
* - on a successful submit (createWorkflow resolves), the page
|
||||
* navigates to /skills.
|
||||
* - submit button reflects the form's validity (disabled until both
|
||||
* required fields are filled).
|
||||
*/
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import WorkflowNew from './WorkflowNew';
|
||||
|
||||
const stableT = (key: string) => key;
|
||||
vi.mock('../lib/i18n/I18nContext', () => ({ useT: () => ({ t: stableT }) }));
|
||||
|
||||
const hoisted = vi.hoisted(() => ({ createWorkflow: vi.fn() }));
|
||||
|
||||
vi.mock('../services/api/workflowsApi', () => ({
|
||||
workflowsApi: { createWorkflow: hoisted.createWorkflow },
|
||||
}));
|
||||
|
||||
const renderPage = () =>
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/workflows/new']}>
|
||||
<Routes>
|
||||
<Route path="/workflows/new" element={<WorkflowNew />} />
|
||||
<Route path="/connections" element={<div data-testid="dashboard-landed">dashboard</div>} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
describe('WorkflowNew', () => {
|
||||
beforeEach(() => {
|
||||
hoisted.createWorkflow.mockReset();
|
||||
});
|
||||
|
||||
it('renders the form and the header CTAs', () => {
|
||||
renderPage();
|
||||
expect(screen.getByTestId('skill-new-cancel')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('skill-new-submit')).toBeInTheDocument();
|
||||
// CreateWorkflowForm renders the name + description inputs.
|
||||
expect(screen.getByLabelText(/skills.create.name/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/skills.create.description/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('cancel button navigates back to /connections', async () => {
|
||||
renderPage();
|
||||
fireEvent.click(screen.getByTestId('skill-new-cancel'));
|
||||
expect(await screen.findByTestId('dashboard-landed')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('submit is disabled until both required fields are filled', () => {
|
||||
renderPage();
|
||||
const submit = screen.getByTestId('skill-new-submit') as HTMLButtonElement;
|
||||
expect(submit).toBeDisabled();
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/skills.create.name/i), {
|
||||
target: { value: 'New Skill' },
|
||||
});
|
||||
expect(submit).toBeDisabled(); // still missing description
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/skills.create.description/i), {
|
||||
target: { value: 'Does something neat.' },
|
||||
});
|
||||
expect(submit).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('navigates to /connections after a successful submit', async () => {
|
||||
hoisted.createWorkflow.mockResolvedValue({
|
||||
id: 'new-skill',
|
||||
name: 'New Skill',
|
||||
scope: 'user',
|
||||
legacy: false,
|
||||
});
|
||||
renderPage();
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/skills.create.name/i), {
|
||||
target: { value: 'New Skill' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/skills.create.description/i), {
|
||||
target: { value: 'Description.' },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId('skill-new-submit'));
|
||||
await waitFor(() => expect(hoisted.createWorkflow).toHaveBeenCalled());
|
||||
await screen.findByTestId('dashboard-landed');
|
||||
});
|
||||
});
|
||||
@@ -1,97 +0,0 @@
|
||||
/**
|
||||
* /workflows/new — full-page Create-a-Skill authoring view.
|
||||
*
|
||||
* Renders `CreateWorkflowForm` (extracted from CreateSkillModal in
|
||||
* Phase 5) inside page chrome, so the same flow is available as a
|
||||
* standalone route — entry point for the Skills dashboard's [+ Create
|
||||
* a Skill] CTA and bookmark-able for users who routinely scaffold
|
||||
* new SKILL.md drafts.
|
||||
*
|
||||
* Behaviour on submit:
|
||||
* - Success → navigate to /connections so the user lands somewhere
|
||||
* meaningful. We considered /workflows/run?workflow=<new-id>, but
|
||||
* new skills aren't auto-scheduled and the runner picker pre-select
|
||||
* only makes sense once the user has filled in inputs. The
|
||||
* Connections page (defaulting to the Composio tab) provides a clear "here
|
||||
* are your connections" signal. Use ?tab=skills to deep-link to
|
||||
* the Skills tab if needed.
|
||||
* - Cancel → /connections.
|
||||
*/
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import CreateWorkflowForm from '../components/skills/CreateWorkflowForm';
|
||||
import Button from '../components/ui/Button';
|
||||
import { useT } from '../lib/i18n/I18nContext';
|
||||
import { type WorkflowSummary } from '../services/api/workflowsApi';
|
||||
|
||||
const PAGE_FORM_ID = 'create-skill-page-form';
|
||||
|
||||
export default function WorkflowNew() {
|
||||
const { t } = useT();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [formValid, setFormValid] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const handleStateChange = useCallback((state: { valid: boolean; submitting: boolean }) => {
|
||||
setFormValid(state.valid);
|
||||
setSubmitting(state.submitting);
|
||||
}, []);
|
||||
|
||||
const handleCreated = useCallback(
|
||||
(_skill: WorkflowSummary) => {
|
||||
// The dashboard re-fetches the cron list on mount, so any
|
||||
// schedule the user adds for this new skill will appear there
|
||||
// automatically — no need to plumb the new id through state.
|
||||
navigate('/connections');
|
||||
},
|
||||
[navigate]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-full flex flex-col">
|
||||
<div className="flex-1 flex items-start justify-center p-4 pt-6">
|
||||
<div className="w-full max-w-3xl space-y-4">
|
||||
{/* Header: title + Cancel/Submit on the right.
|
||||
The submit button is wired to the form via `form=PAGE_FORM_ID`
|
||||
so it submits the underlying form even though it sits in the
|
||||
header rather than inside the form element. */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-base font-semibold text-content">{t('skills.new.title')}</h1>
|
||||
<p className="mt-0.5 text-xs text-content-muted">{t('skills.create.subtitle')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Button
|
||||
variant="tertiary"
|
||||
data-testid="skill-new-cancel"
|
||||
onClick={() => navigate('/connections')}
|
||||
disabled={submitting}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
form={PAGE_FORM_ID}
|
||||
data-testid="skill-new-submit"
|
||||
disabled={!formValid || submitting}>
|
||||
{submitting ? t('skills.create.creating') : t('skills.create.createBtn')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<div className="rounded-2xl border border-line bg-surface p-6 shadow-soft">
|
||||
<CreateWorkflowForm
|
||||
formId={PAGE_FORM_ID}
|
||||
onCreated={handleCreated}
|
||||
onStateChange={handleStateChange}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +1,29 @@
|
||||
/**
|
||||
* FlowCanvasPage (issue B5b.1) — the read-only Workflow Canvas view at
|
||||
* `/flows/:id`. Asserts the loading → canvas happy path, the not-found state
|
||||
* (mirrors the Rust `flows_get` "not found" error), and the generic error
|
||||
* state for any other failure.
|
||||
* FlowCanvasPage (issue B5b / Phase 3) — the editable Workflow Canvas builder
|
||||
* at `/flows/:id`. Asserts the loading → canvas happy path, the not-found state
|
||||
* (mirrors the Rust `flows_get` "not found" error), the generic error state,
|
||||
* and the Phase 3d host wiring: Save persists via `flows_update`, and the
|
||||
* unsaved-changes guard intercepts the Back button while dirty.
|
||||
*/
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { createMemoryRouter, MemoryRouter, Route, RouterProvider, Routes } from 'react-router-dom';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { Flow } from '../../services/api/flowsApi';
|
||||
import FlowCanvasPage from '../FlowCanvasPage';
|
||||
import FlowCanvasPage, { FlowCanvasDraftPage } from '../FlowCanvasPage';
|
||||
|
||||
const getFlow = vi.hoisted(() => vi.fn());
|
||||
vi.mock('../../services/api/flowsApi', () => ({ getFlow }));
|
||||
const updateFlow = vi.hoisted(() => vi.fn());
|
||||
const createFlow = vi.hoisted(() => vi.fn());
|
||||
const validateFlow = vi.hoisted(() => vi.fn());
|
||||
const listFlowConnections = vi.hoisted(() => vi.fn());
|
||||
vi.mock('../../services/api/flowsApi', () => ({
|
||||
getFlow,
|
||||
updateFlow,
|
||||
createFlow,
|
||||
validateFlow,
|
||||
listFlowConnections,
|
||||
}));
|
||||
|
||||
function makeFlow(overrides: Partial<Flow> = {}): Flow {
|
||||
return {
|
||||
@@ -57,6 +68,14 @@ function renderAtFlowId(id: string) {
|
||||
describe('FlowCanvasPage', () => {
|
||||
beforeEach(() => {
|
||||
getFlow.mockReset();
|
||||
updateFlow.mockReset();
|
||||
createFlow.mockReset();
|
||||
validateFlow.mockReset();
|
||||
listFlowConnections.mockReset();
|
||||
validateFlow.mockResolvedValue({ valid: true, errors: [], warnings: [] });
|
||||
listFlowConnections.mockResolvedValue([]);
|
||||
updateFlow.mockResolvedValue(makeFlow());
|
||||
createFlow.mockResolvedValue(makeFlow({ id: 'created-id', name: 'Daily digest' }));
|
||||
});
|
||||
|
||||
it('shows a loading state while the flow is being fetched', () => {
|
||||
@@ -120,4 +139,126 @@ describe('FlowCanvasPage', () => {
|
||||
expect(screen.getByText('New flow')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Old flow (stale)')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
function renderEditor(id = 'test-id') {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[`/flows/${id}`]}>
|
||||
<Routes>
|
||||
<Route path="/flows/:id" element={<FlowCanvasPage />} />
|
||||
<Route path="/flows" element={<div data-testid="flows-list">Flows list</div>} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
}
|
||||
|
||||
it('persists the live graph via flows_update when Save is clicked', async () => {
|
||||
getFlow.mockResolvedValue(makeFlow());
|
||||
renderEditor();
|
||||
await waitFor(() => expect(screen.getByTestId('flow-canvas')).toBeInTheDocument());
|
||||
|
||||
// Edit the graph (add a node) so it is dirty, then Save.
|
||||
fireEvent.click(screen.getByTestId('flow-palette-item-agent'));
|
||||
fireEvent.click(screen.getByTestId('flow-editor-save'));
|
||||
|
||||
await waitFor(() => expect(updateFlow).toHaveBeenCalledTimes(1));
|
||||
const [calledId, update] = updateFlow.mock.calls[0];
|
||||
expect(calledId).toBe('test-id');
|
||||
expect(update.graph.nodes.map((n: { kind: string }) => n.kind).sort()).toEqual([
|
||||
'agent',
|
||||
'trigger',
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not prompt when navigating Back with no unsaved changes', async () => {
|
||||
getFlow.mockResolvedValue(makeFlow());
|
||||
renderEditor();
|
||||
await waitFor(() => expect(screen.getByTestId('flow-canvas')).toBeInTheDocument());
|
||||
|
||||
fireEvent.click(screen.getByTestId('flow-canvas-back'));
|
||||
// Pristine → straight to the list, no confirmation dialog.
|
||||
await waitFor(() => expect(screen.getByTestId('flows-list')).toBeInTheDocument());
|
||||
expect(screen.queryByTestId('flow-leave-confirm')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('prompts before leaving when dirty, and discards to navigate away', async () => {
|
||||
getFlow.mockResolvedValue(makeFlow());
|
||||
renderEditor();
|
||||
await waitFor(() => expect(screen.getByTestId('flow-canvas')).toBeInTheDocument());
|
||||
|
||||
// Make it dirty, then click Back — a confirmation dialog blocks navigation.
|
||||
fireEvent.click(screen.getByTestId('flow-palette-item-agent'));
|
||||
fireEvent.click(screen.getByTestId('flow-canvas-back'));
|
||||
expect(screen.getByTestId('flow-leave-confirm')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('flows-list')).not.toBeInTheDocument();
|
||||
|
||||
// Staying dismisses the dialog and keeps the editor mounted.
|
||||
fireEvent.click(screen.getByTestId('flow-leave-stay'));
|
||||
expect(screen.queryByTestId('flow-leave-confirm')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('flow-canvas')).toBeInTheDocument();
|
||||
|
||||
// Re-open the prompt and confirm leaving → navigates to the list.
|
||||
fireEvent.click(screen.getByTestId('flow-canvas-back'));
|
||||
fireEvent.click(screen.getByTestId('flow-leave-discard'));
|
||||
await waitFor(() => expect(screen.getByTestId('flows-list')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Draft canvas (Phase 4e) — the chat "Open in canvas" action lands here with
|
||||
// the proposed graph in router state. Opening it must NEVER persist.
|
||||
// -------------------------------------------------------------------------
|
||||
const draftGraph = {
|
||||
schema_version: 1,
|
||||
name: 'Proposed flow',
|
||||
nodes: [
|
||||
{ id: 't', kind: 'trigger', name: 'Start', config: {}, ports: [], position: { x: 0, y: 0 } },
|
||||
],
|
||||
edges: [],
|
||||
};
|
||||
|
||||
function renderDraft(state: unknown) {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[{ pathname: '/flows/draft', state }]}>
|
||||
<Routes>
|
||||
<Route path="/flows/draft" element={<FlowCanvasDraftPage />} />
|
||||
<Route path="/flows/:id" element={<FlowCanvasPage />} />
|
||||
<Route path="/flows" element={<div data-testid="flows-list">Flows list</div>} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
}
|
||||
|
||||
it('renders the draft canvas from router state without fetching or persisting', async () => {
|
||||
renderDraft({ name: 'Proposed flow', graph: draftGraph, requireApproval: true });
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('flow-canvas')).toBeInTheDocument());
|
||||
expect(screen.getByText('Proposed flow')).toBeInTheDocument();
|
||||
// A draft is not fetched, is not runnable, and has persisted nothing.
|
||||
expect(getFlow).not.toHaveBeenCalled();
|
||||
expect(createFlow).not.toHaveBeenCalled();
|
||||
expect(updateFlow).not.toHaveBeenCalled();
|
||||
expect(screen.queryByTestId('flow-canvas-run')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('creates (never updates) the flow when a draft is saved', async () => {
|
||||
renderDraft({ name: 'Proposed flow', graph: draftGraph, requireApproval: true });
|
||||
await waitFor(() => expect(screen.getByTestId('flow-canvas')).toBeInTheDocument());
|
||||
|
||||
// Edit to make it dirty, then Save → the single persistence gate fires
|
||||
// `flows_create` (with the require-approval flag), not `flows_update`.
|
||||
fireEvent.click(screen.getByTestId('flow-palette-item-agent'));
|
||||
fireEvent.click(screen.getByTestId('flow-editor-save'));
|
||||
|
||||
await waitFor(() => expect(createFlow).toHaveBeenCalledTimes(1));
|
||||
const [name, graph, requireApproval] = createFlow.mock.calls[0];
|
||||
expect(name).toBe('Proposed flow');
|
||||
expect(requireApproval).toBe(true);
|
||||
expect(graph.nodes.map((n: { kind: string }) => n.kind).sort()).toEqual(['agent', 'trigger']);
|
||||
expect(updateFlow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows an empty state when the draft route is hit with no draft in state', () => {
|
||||
renderDraft(null);
|
||||
expect(screen.getByTestId('flow-canvas-draft-missing')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('flow-canvas')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user