Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-os": "^2.3.2",
"@types/three": "^0.183.1",
"@xyflow/react": "^12.11.1",
"buffer": "^6.0.3",
"cmdk": "^1.1.1",
"d3-force": "^3.0.0",
Expand Down
23 changes: 18 additions & 5 deletions app/src/AppRoutes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +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 FlowsPage from './pages/FlowsPage';
import Invites from './pages/Invites';
import Notifications from './pages/Notifications';
Expand Down Expand Up @@ -96,18 +97,30 @@ const AppRoutes = ({ location }: AppRoutesProps = {}) => {
/>

{/* Workflows — the `flows::` domain's discoverable list hub (issue
B5a). Distinct from the legacy SKILL.md `/workflows/*` Skill routes
below (create/run) and their `/workflows` → `/settings/automations`
back-compat redirect, which stay untouched. The canvas (B5b) and
agent-proposal surface (B4) are separate, later work. */}
B5a) plus the read-only Workflow Canvas (issue B5b.1) at
`/flows/:id`. Distinct from the legacy SKILL.md `/workflows/*`
Skill routes below (create/run) and their `/workflows` →
`/settings/automations` back-compat redirect, which stay untouched.
Not a tab-level route (unlike `/flows` itself, `/flows/:id` isn't
reached from the BottomTabBar), so `navigation.spec.ts`'s ROUTES
table needs no change. Full editing (B5b.2+) and the agent-proposal
surface (B4) are separate, later work. */}
<Route
path="/flows/*"
path="/flows"
element={
<ProtectedRoute requireAuth={true}>
<FlowsPage />
</ProtectedRoute>
}
/>
<Route
path="/flows/:id"
element={
<ProtectedRoute requireAuth={true}>
<FlowCanvasPage />
</ProtectedRoute>
}
/>

{/* Back-compat: /activity and /intelligence → settings notifications page. */}
<Route path="/activity" element={<Navigate to="/settings/notifications" replace />} />
Expand Down
76 changes: 67 additions & 9 deletions app/src/components/flows/FlowListRow.test.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
/**
* FlowListRow (issue B5a / B5a.1) — one saved-flow row on the Workflows list
* page. Asserts the name/status rendering, the last-run/never-run text
* (including the localized relative-time strings), and that the
* toggle/Run/View runs controls call back with the row's `Flow`.
* FlowListRow (issue B5a / B5a.1 / B5b.1) — one saved-flow row on the
* Workflows list page. Asserts the name/status rendering, the
* last-run/never-run text (including the localized relative-time strings),
* that the toggle/Run/View runs controls call back with the row's `Flow`,
* and that the flow name itself is the "View" affordance that opens the
* read-only Workflow Canvas (issue B5b.1).
*/
import { fireEvent, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
Expand All @@ -29,7 +31,13 @@ function makeFlow(overrides: Partial<Flow> = {}): Flow {
describe('FlowListRow', () => {
it('renders the flow name and an Enabled badge when enabled', () => {
renderWithProviders(
<FlowListRow flow={makeFlow()} onToggle={vi.fn()} onRun={vi.fn()} onViewRuns={vi.fn()} />
<FlowListRow
flow={makeFlow()}
onToggle={vi.fn()}
onRun={vi.fn()}
onViewRuns={vi.fn()}
onView={vi.fn()}
/>
);

expect(screen.getByText('Daily digest')).toBeInTheDocument();
Expand All @@ -43,6 +51,7 @@ describe('FlowListRow', () => {
onToggle={vi.fn()}
onRun={vi.fn()}
onViewRuns={vi.fn()}
onView={vi.fn()}
/>
);

Expand All @@ -51,7 +60,13 @@ describe('FlowListRow', () => {

it('shows "Never run" when the flow has no last_run_at', () => {
renderWithProviders(
<FlowListRow flow={makeFlow()} onToggle={vi.fn()} onRun={vi.fn()} onViewRuns={vi.fn()} />
<FlowListRow
flow={makeFlow()}
onToggle={vi.fn()}
onRun={vi.fn()}
onViewRuns={vi.fn()}
onView={vi.fn()}
/>
);

expect(screen.getByText('Never run')).toBeInTheDocument();
Expand All @@ -64,6 +79,7 @@ describe('FlowListRow', () => {
onToggle={vi.fn()}
onRun={vi.fn()}
onViewRuns={vi.fn()}
onView={vi.fn()}
/>
);

Expand All @@ -78,6 +94,7 @@ describe('FlowListRow', () => {
onToggle={vi.fn()}
onRun={vi.fn()}
onViewRuns={vi.fn()}
onView={vi.fn()}
/>
);

Expand All @@ -92,6 +109,7 @@ describe('FlowListRow', () => {
onToggle={vi.fn()}
onRun={vi.fn()}
onViewRuns={vi.fn()}
onView={vi.fn()}
/>
);

Expand All @@ -106,6 +124,7 @@ describe('FlowListRow', () => {
onToggle={vi.fn()}
onRun={vi.fn()}
onViewRuns={vi.fn()}
onView={vi.fn()}
/>
);

Expand All @@ -115,7 +134,13 @@ describe('FlowListRow', () => {
it('calls onToggle with the flow when the switch is clicked', () => {
const onToggle = vi.fn();
renderWithProviders(
<FlowListRow flow={makeFlow()} onToggle={onToggle} onRun={vi.fn()} onViewRuns={vi.fn()} />
<FlowListRow
flow={makeFlow()}
onToggle={onToggle}
onRun={vi.fn()}
onViewRuns={vi.fn()}
onView={vi.fn()}
/>
);

fireEvent.click(screen.getByTestId('flow-toggle-flow-1'));
Expand All @@ -126,7 +151,13 @@ describe('FlowListRow', () => {
it('calls onRun with the flow when the Run button is clicked', () => {
const onRun = vi.fn();
renderWithProviders(
<FlowListRow flow={makeFlow()} onToggle={vi.fn()} onRun={onRun} onViewRuns={vi.fn()} />
<FlowListRow
flow={makeFlow()}
onToggle={vi.fn()}
onRun={onRun}
onViewRuns={vi.fn()}
onView={vi.fn()}
/>
);

fireEvent.click(screen.getByTestId('flow-run-flow-1'));
Expand All @@ -137,7 +168,13 @@ describe('FlowListRow', () => {
it('renders a "View runs" control and calls onViewRuns with the flow when clicked', () => {
const onViewRuns = vi.fn();
renderWithProviders(
<FlowListRow flow={makeFlow()} onToggle={vi.fn()} onRun={vi.fn()} onViewRuns={onViewRuns} />
<FlowListRow
flow={makeFlow()}
onToggle={vi.fn()}
onRun={vi.fn()}
onViewRuns={onViewRuns}
onView={vi.fn()}
/>
);

const viewRunsButton = screen.getByTestId('flow-view-runs-flow-1');
Expand All @@ -147,13 +184,33 @@ describe('FlowListRow', () => {
expect(onViewRuns).toHaveBeenCalledWith(makeFlow());
});

it('renders the flow name as a "View" affordance and calls onView with the flow when clicked', () => {
const onView = vi.fn();
renderWithProviders(
<FlowListRow
flow={makeFlow()}
onToggle={vi.fn()}
onRun={vi.fn()}
onViewRuns={vi.fn()}
onView={onView}
/>
);

const viewButton = screen.getByTestId('flow-view-flow-1');
expect(viewButton).toHaveTextContent('Daily digest');
fireEvent.click(viewButton);

expect(onView).toHaveBeenCalledWith(makeFlow());
});

it('shows the running label and disables Run while busy', () => {
renderWithProviders(
<FlowListRow
flow={makeFlow()}
onToggle={vi.fn()}
onRun={vi.fn()}
onViewRuns={vi.fn()}
onView={vi.fn()}
busy="run"
/>
);
Expand All @@ -170,6 +227,7 @@ describe('FlowListRow', () => {
onToggle={vi.fn()}
onRun={vi.fn()}
onViewRuns={vi.fn()}
onView={vi.fn()}
busy="toggle"
/>
);
Expand Down
29 changes: 27 additions & 2 deletions app/src/components/flows/FlowListRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@
* "View runs" (issue B5a.1) opens `FlowRunsDrawer` (mounted by `FlowsPage`)
* for this flow's run history — re-added now that B3b's run inspector has
* landed and the drawer has somewhere to send the user.
*
* The flow name (issue B5b.1) is itself the "View" affordance for the new
* read-only Workflow Canvas: it's rendered as a button that calls `onView`,
* which `FlowsPage` wires to `navigate('/flows/' + flow.id)`. Kept as the
* name itself (not a separate icon button) since it's the row's most
* prominent, discoverable element and "view this flow's graph" is the most
* natural action to hang off it — "View runs" and "Run" stay distinct
* actions in the button row below.
*/
import { useT } from '../../lib/i18n/I18nContext';
import type { Flow } from '../../services/api/flowsApi';
Expand All @@ -28,6 +36,8 @@ export interface FlowListRowProps {
onToggle: (flow: Flow) => void;
onRun: (flow: Flow) => void;
onViewRuns: (flow: Flow) => void;
/** Opens the read-only Workflow Canvas for this flow (issue B5b.1). */
onView: (flow: Flow) => void;
busy?: FlowListRowBusy;
}

Expand Down Expand Up @@ -56,7 +66,14 @@ function capitalize(value: string): string {
return value.length > 0 ? value.charAt(0).toUpperCase() + value.slice(1) : value;
}

const FlowListRow = ({ flow, onToggle, onRun, onViewRuns, busy = null }: FlowListRowProps) => {
const FlowListRow = ({
flow,
onToggle,
onRun,
onViewRuns,
onView,
busy = null,
}: FlowListRowProps) => {
const { t } = useT();
const toggleBusy = busy === 'toggle';
const runBusy = busy === 'run';
Expand All @@ -72,7 +89,15 @@ const FlowListRow = ({ flow, onToggle, onRun, onViewRuns, busy = null }: FlowLis
className="space-y-3 border-t border-line p-4 first:border-t-0">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-content">{flow.name}</div>
<button
type="button"
data-testid={`flow-view-${flow.id}`}
title={t('flows.list.view')}
aria-label={`${t('flows.list.view')}: ${flow.name}`}
onClick={() => onView(flow)}
className="max-w-full truncate text-left text-sm font-semibold text-content hover:text-primary-600 hover:underline dark:hover:text-primary-400">
{flow.name}
</button>
<div className="mt-0.5 text-[11px] text-content-faint">{lastRunLabel}</div>
</div>
<span
Expand Down
59 changes: 59 additions & 0 deletions app/src/components/flows/canvas/FlowCanvas.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/**
* 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.
*/
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 './flowCanvasStyles.css';
import FlowNodeComponent from './FlowNodeComponent';

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.
*/
readonly?: boolean;
}

const NODE_TYPES = { [FLOW_NODE_TYPE]: FlowNodeComponent };

/**
* 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, readonly = true }: FlowCanvasProps) {
const interactionProps = useMemo(
() =>
readonly ? { nodesDraggable: false, nodesConnectable: false, elementsSelectable: false } : {},
[readonly]
);

return (
<div className="flow-canvas h-full w-full" data-testid="flow-canvas">
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={NODE_TYPES}
fitView
panOnScroll
zoomOnScroll
{...interactionProps}>
<Background variant={BackgroundVariant.Dots} gap={16} size={1} />
<MiniMap pannable zoomable />
<Controls showInteractive={false} />
</ReactFlow>
</div>
);
}

export default memo(FlowCanvas);
Loading
Loading