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:
Steven Enamakel
2026-07-05 11:56:33 -07:00
committed by GitHub
parent f24220ee68
commit 44614a7354
194 changed files with 13699 additions and 939 deletions
Generated
+1 -1
View File
@@ -6888,7 +6888,7 @@ dependencies = [
[[package]]
name = "tinyflows"
version = "0.3.2"
version = "0.5.0"
dependencies = [
"async-trait",
"futures-timer",
+6 -1
View File
@@ -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.
+5 -5
View File
@@ -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]]
-1
View File
@@ -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
View File
@@ -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());
});
});
+11
View File
@@ -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>
)}
+9 -2
View File
@@ -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);
+97 -17
View File
@@ -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;
+70
View File
@@ -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 1600.
* - 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
+1 -1
View File
@@ -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.
+91
View File
@@ -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));
});
});
+126
View File
@@ -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' });
});
});
+161
View File
@@ -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 };
}
+65
View File
@@ -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 } : {}),
};
}
+54
View File
@@ -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();
});
});
+60
View File
@@ -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;
}
+43
View File
@@ -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);
});
});
+48
View File
@@ -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;
}
+150 -1
View File
@@ -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' },
]);
});
});
});
+120
View File
@@ -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;
+60
View File
@@ -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',
});
});
});
+86
View File
@@ -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],
};
}
+31
View File
@@ -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);
});
});
+37
View File
@@ -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: [],
};
}
+98
View File
@@ -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);
}
+83
View File
@@ -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}');
});
});
+165
View File
@@ -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" }
]
}
+75
View File
@@ -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');
});
});
+104
View File
@@ -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');
}
+153
View File
@@ -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 3c3d)
'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':
+165
View File
@@ -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 3c3d)
'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':
+170
View File
@@ -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 3c3d)
'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':
+174
View File
@@ -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 3c3d)
'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.',
+164
View File
@@ -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 3c3d)
'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':
+167
View File
@@ -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 lappliquer.',
'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 dun brouillon proposé — rien nest 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 dabord.',
'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 3c3d)
'flows.editor.saving': 'Enregistrement…',
'flows.editor.run': 'Exécuter',
'flows.editor.running': 'Exécution…',
'flows.editor.runFailed': 'Impossible de démarrer lexé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 denregistrer.',
'flows.editor.errorsTitle': 'Erreurs',
'flows.editor.warningsTitle': 'Avertissements',
'flows.editor.saveFailedTitle': 'Échec de lenregistrement',
'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':
+162
View File
@@ -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 3c3d)
'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':
+163
View File
@@ -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 3c3d)
'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':
+166
View File
@@ -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': 'Unesecuzione è 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 3c3d)
'flows.editor.saving': 'Salvataggio…',
'flows.editor.run': 'Esegui',
'flows.editor.running': 'In esecuzione…',
'flows.editor.runFailed': 'Impossibile avviare lesecuzione',
'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':
+157
View File
@@ -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 3c3d)
'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':
+167
View File
@@ -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 3c3d)
'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':
+163
View File
@@ -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 3c3d)
'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':
+165
View File
@@ -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 3c3d)
'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':
+146
View File
@@ -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 3c3d)
'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 跳转。请重试。',
+493 -16
View File
@@ -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>
);
}
+99 -7
View File
@@ -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
View File
@@ -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>
);
-94
View File
@@ -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');
});
});
-97
View File
@@ -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>
);
}
+148 -7
View File
@@ -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