diff --git a/Cargo.lock b/Cargo.lock index 362c45de2..91e1b86d1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6888,7 +6888,7 @@ dependencies = [ [[package]] name = "tinyflows" -version = "0.3.2" +version = "0.5.0" dependencies = [ "async-trait", "futures-timer", diff --git a/Cargo.toml b/Cargo.toml index 57a785148..b7af30933 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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. diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index 7666dacd8..05746dede 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -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]] diff --git a/app/src/AppRoutes.auth.test.tsx b/app/src/AppRoutes.auth.test.tsx index ef48656b4..5ed73816f 100644 --- a/app/src/AppRoutes.auth.test.tsx +++ b/app/src/AppRoutes.auth.test.tsx @@ -38,7 +38,6 @@ vi.mock('./pages/Rewards', () => ({ default: () =>
})); vi.mock('./pages/Settings', () => ({ default: () =>
})); vi.mock('./pages/Skills', () => ({ default: () =>
})); vi.mock('./pages/Welcome', () => ({ default: () =>
})); -vi.mock('./pages/WorkflowNew', () => ({ default: () =>
})); vi.mock('./pages/WorkflowsRun', () => ({ default: () =>
})); const AppRoutes = (await import('./AppRoutes')).default; diff --git a/app/src/AppRoutes.tsx b/app/src/AppRoutes.tsx index 62a3ba9f5..f8c4559da 100644 --- a/app/src/AppRoutes.tsx +++ b/app/src/AppRoutes.tsx @@ -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 = {}) => { } /> + {/* 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. */} + + + + } + /> { 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. */} - - - - } - /> - + sub-tab. */} + {/* `/workflows/run` is the single-purpose Skill runner page — the live + destination of the Run button in the Automations tab (WorkflowsTab). */} ({ 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 { return { @@ -34,7 +45,9 @@ function proposal(partial: Partial = {}): 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(); + 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(); fireEvent.click(screen.getByText('chat.flowProposal.dismiss')); diff --git a/app/src/components/chat/WorkflowProposalCard.tsx b/app/src/components/chat/WorkflowProposalCard.tsx index 8ca881080..1d8b4ac1d 100644 --- a/app/src/components/chat/WorkflowProposalCard.tsx +++ b/app/src/components/chat/WorkflowProposalCard.tsx @@ -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 = ({ threadId, proposal }) => { const { t } = useT(); const dispatch = useAppDispatch(); + const navigate = useNavigate(); const [saving, setSaving] = useState(false); const [errorMsg, setErrorMsg] = useState(null); @@ -40,6 +44,31 @@ export const WorkflowProposalCard: React.FC = ({ 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 = ({ threadId, proposal }) => disabled={saving}> {saving ? t('chat.flowProposal.saving') : t('chat.flowProposal.save')} + +
); diff --git a/app/src/components/flows/FlowRunInspectorDrawer.tsx b/app/src/components/flows/FlowRunInspectorDrawer.tsx index dd8dcc54f..5f646fed3 100644 --- a/app/src/components/flows/FlowRunInspectorDrawer.tsx +++ b/app/src/components/flows/FlowRunInspectorDrawer.tsx @@ -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 = { + 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 (
  • - + {step.node_id} @@ -108,14 +147,18 @@ function StepRow({ step, index }: { step: FlowRunStep; index: number }) { )}
    - {outputText.length > 0 && ( + {items.length > 0 && (
    {t('flowRuns.inspector.output')} -
    -            {outputText}
    -          
    +
    + +
    )}
  • @@ -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) {
    )} + {/* 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 && ( +
    + +
    + )} + {/* Pending approvals banner */} {run.status === 'pending_approval' && pendingCount > 0 && (
    {run.steps.map((step, idx) => ( - + 0 ? normalizeItems(run.steps[idx - 1].output) : undefined} + /> ))} )} diff --git a/app/src/components/flows/FlowRunsDrawer.tsx b/app/src/components/flows/FlowRunsDrawer.tsx index 35cf3282f..d2d55bd8a 100644 --- a/app/src/components/flows/FlowRunsDrawer.tsx +++ b/app/src/components/flows/FlowRunsDrawer.tsx @@ -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([]); const [loading, setLoading] = useState(false); @@ -209,7 +212,11 @@ export function FlowRunsDrawer({ flowId, flowName, onClose }: Props) {
    {selectedRunId && ( - setSelectedRunId(null)} /> + setSelectedRunId(null)} + onFixWithAgent={onFixWithAgent} + /> )} ); diff --git a/app/src/components/flows/FlowTemplateGallery.tsx b/app/src/components/flows/FlowTemplateGallery.tsx new file mode 100644 index 000000000..9e9553ea3 --- /dev/null +++ b/app/src/components/flows/FlowTemplateGallery.tsx @@ -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 ( +

    + {t('flows.templates.empty')} +

    + ); + } + + return ( +
    + {FLOW_TEMPLATES.map(template => { + const busy = busyId === template.id; + const anyBusy = Boolean(busyId); + return ( + + ); + })} +
    + ); +} diff --git a/app/src/components/flows/NewWorkflowModal.test.tsx b/app/src/components/flows/NewWorkflowModal.test.tsx new file mode 100644 index 000000000..512cb6a36 --- /dev/null +++ b/app/src/components/flows/NewWorkflowModal.test.tsx @@ -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()), + 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(); + 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(); + }); +}); diff --git a/app/src/components/flows/NewWorkflowModal.tsx b/app/src/components/flows/NewWorkflowModal.tsx new file mode 100644 index 000000000..3e78c9ca5 --- /dev/null +++ b/app/src/components/flows/NewWorkflowModal.tsx @@ -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 ( + + ); +} + +const log = createDebug('app:flows:new'); + +export default function NewWorkflowModal({ onClose, onDescribe }: NewWorkflowModalProps) { + const { t } = useT(); + const [view, setView] = useState('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 ( + +
    + {error && ( +

    + {error} +

    + )} + + {view === 'chooser' ? ( + <> + + + + + ) : ( + <> + + + + )} +
    +
    + ); +} diff --git a/app/src/components/flows/RunItemDataBrowser.tsx b/app/src/components/flows/RunItemDataBrowser.tsx new file mode 100644 index 000000000..ac6d9c8c0 --- /dev/null +++ b/app/src/components/flows/RunItemDataBrowser.tsx @@ -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 ( +
    + {binary.map(ref => ( + + 📎 + {ref.fileName ?? ref.key} + + {ref.mimeType ?? t('flowRuns.inspector.binaryLabel')} + + + ))} +
    + ); +} + +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('table'); + // Which output row currently has its paired source input revealed (single-open). + const [revealedSource, setRevealedSource] = useState(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 ( +

    + {t('flowRuns.inspector.noItems')} +

    + ); + } + + 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 ( +
    + {/* Header: view toggle + item count. */} +
    +
    + + +
    + + {t('flowRuns.inspector.itemCount').replace('{count}', String(items.length))} + +
    + + {view === 'json' ? ( +
    +          {jsonText}
    +        
    + ) : ( +
    + + + + + )) + ) : ( + + )} + {showActions && + + + {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 ( + + item.pairedIndex !== null && toggleSource(index, item.pairedIndex) + } + /> + ); + })} + +
    + {useColumns ? ( + columns.map(column => ( + + {column} + + {t('flowRuns.inspector.dataJson')} + } +
    +
    + )} +
    + ); +} + +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 ( + <> + + + {index + 1} + + {useColumns ? ( + columns.map(column => { + const text = formatCell(cellValue(item, column)); + return ( + + {truncate(text)} + + ); + }) + ) : ( + + {truncate(formatCell(item.json))} + + )} + {showActions && ( + +
    + + {canPair && ( + + )} +
    + + )} + + {canPair && isRevealed && ( + + +
    + {t('flowRuns.inspector.sourceInputTitle')} +
    +
    +              {sourceItem ? formatJson(sourceItem.json) : t('flowRuns.inspector.emptyValue')}
    +            
    + + + )} + + ); +} + +export default RunItemDataBrowser; diff --git a/app/src/components/flows/WorkflowCopilotPanel.test.tsx b/app/src/components/flows/WorkflowCopilotPanel.test.tsx new file mode 100644 index 000000000..f9a07b261 --- /dev/null +++ b/app/src/components/flows/WorkflowCopilotPanel.test.tsx @@ -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( + + ); + 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( + + ); + 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( + + ); + 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( + + ); + 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( + + ); + 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'); + }); +}); diff --git a/app/src/components/flows/WorkflowCopilotPanel.tsx b/app/src/components/flows/WorkflowCopilotPanel.tsx new file mode 100644 index 000000000..c6503f80d --- /dev/null +++ b/app/src/components/flows/WorkflowCopilotPanel.tsx @@ -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(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) => { + 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 ( +