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
15 changes: 15 additions & 0 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 FlowsPage from './pages/FlowsPage';
import Invites from './pages/Invites';
import Notifications from './pages/Notifications';
import Onboarding from './pages/onboarding/Onboarding';
Expand Down Expand Up @@ -94,6 +95,20 @@ 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. */}
<Route
path="/flows/*"
element={
<ProtectedRoute requireAuth={true}>
<FlowsPage />
</ProtectedRoute>
}
/>

{/* Back-compat: /activity and /intelligence → settings notifications page. */}
<Route path="/activity" element={<Navigate to="/settings/notifications" replace />} />
<Route path="/intelligence" element={<Navigate to="/settings/notifications" replace />} />
Expand Down
146 changes: 146 additions & 0 deletions app/src/components/flows/FlowListRow.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
/**
* FlowListRow (issue B5a) — 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 controls
* call back with the row's `Flow`. No "View runs" control yet — it was
* pulled until B3b's run inspector lands (see `FlowListRow.tsx`'s module
* doc and the commented integration point in `FlowsPage.tsx`).
*/
import { fireEvent, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';

import type { Flow } from '../../services/api/flowsApi';
import { renderWithProviders } from '../../test/test-utils';
import FlowListRow from './FlowListRow';

function makeFlow(overrides: Partial<Flow> = {}): Flow {
return {
id: 'flow-1',
name: 'Daily digest',
enabled: true,
graph: { nodes: [], edges: [] },
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
last_run_at: null,
last_status: null,
require_approval: false,
...overrides,
};
}

describe('FlowListRow', () => {
it('renders the flow name and an Enabled badge when enabled', () => {
renderWithProviders(<FlowListRow flow={makeFlow()} onToggle={vi.fn()} onRun={vi.fn()} />);

expect(screen.getByText('Daily digest')).toBeInTheDocument();
expect(screen.getByTestId('flow-status-flow-1')).toHaveTextContent('Enabled');
});

it('renders a Paused badge when disabled', () => {
renderWithProviders(
<FlowListRow flow={makeFlow({ enabled: false })} onToggle={vi.fn()} onRun={vi.fn()} />
);

expect(screen.getByTestId('flow-status-flow-1')).toHaveTextContent('Paused');
});

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

expect(screen.getByText('Never run')).toBeInTheDocument();
});

it('shows the capitalized status and "Just now" for a run seconds ago', () => {
renderWithProviders(
<FlowListRow
flow={makeFlow({ last_run_at: new Date().toISOString(), last_status: 'completed' })}
onToggle={vi.fn()}
onRun={vi.fn()}
/>
);

expect(screen.getByText('Completed · Just now')).toBeInTheDocument();
});

it('shows a minutes-ago relative time', () => {
const fiveMinAgo = new Date(Date.now() - 5 * 60_000).toISOString();
renderWithProviders(
<FlowListRow
flow={makeFlow({ last_run_at: fiveMinAgo, last_status: 'completed' })}
onToggle={vi.fn()}
onRun={vi.fn()}
/>
);

expect(screen.getByText('Completed · 5m ago')).toBeInTheDocument();
});

it('shows an hours-ago relative time', () => {
const threeHoursAgo = new Date(Date.now() - 3 * 60 * 60_000).toISOString();
renderWithProviders(
<FlowListRow
flow={makeFlow({ last_run_at: threeHoursAgo, last_status: 'failed' })}
onToggle={vi.fn()}
onRun={vi.fn()}
/>
);

expect(screen.getByText('Failed · 3h ago')).toBeInTheDocument();
});

it('shows a days-ago relative time', () => {
const twoDaysAgo = new Date(Date.now() - 2 * 24 * 60 * 60_000).toISOString();
renderWithProviders(
<FlowListRow
flow={makeFlow({ last_run_at: twoDaysAgo, last_status: 'pending_approval' })}
onToggle={vi.fn()}
onRun={vi.fn()}
/>
);

expect(screen.getByText('Pending_approval · 2d ago')).toBeInTheDocument();
});

it('calls onToggle with the flow when the switch is clicked', () => {
const onToggle = vi.fn();
renderWithProviders(<FlowListRow flow={makeFlow()} onToggle={onToggle} onRun={vi.fn()} />);

fireEvent.click(screen.getByTestId('flow-toggle-flow-1'));

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

it('calls onRun with the flow when the Run button is clicked', () => {
const onRun = vi.fn();
renderWithProviders(<FlowListRow flow={makeFlow()} onToggle={vi.fn()} onRun={onRun} />);

fireEvent.click(screen.getByTestId('flow-run-flow-1'));

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

it('shows the running label and disables Run while busy', () => {
renderWithProviders(
<FlowListRow flow={makeFlow()} onToggle={vi.fn()} onRun={vi.fn()} busy="run" />
);

const runButton = screen.getByTestId('flow-run-flow-1');
expect(runButton).toHaveTextContent('Running…');
expect(runButton).toBeDisabled();
});

it('disables the toggle while busy=toggle', () => {
renderWithProviders(
<FlowListRow flow={makeFlow()} onToggle={vi.fn()} onRun={vi.fn()} busy="toggle" />
);

expect(screen.getByTestId('flow-toggle-flow-1')).toBeDisabled();
});

it('does not render a "View runs" control', () => {
renderWithProviders(<FlowListRow flow={makeFlow()} onToggle={vi.fn()} onRun={vi.fn()} />);

expect(screen.queryByTestId('flow-view-runs-flow-1')).not.toBeInTheDocument();
expect(screen.queryByText('View runs')).not.toBeInTheDocument();
});
});
112 changes: 112 additions & 0 deletions app/src/components/flows/FlowListRow.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/**
* FlowListRow — one saved-flow row on the Workflows list page (issue B5a).
*
* Mirrors the row layout of `CoreJobList`
* (`app/src/components/settings/panels/cron/CoreJobList.tsx`): name + status
* badge header, a line of run metadata, then a row of `Button` actions. Swaps
* the cron "pause/resume" text button for a `SettingsSwitch` toggle (the
* canonical boolean control — see `components/settings/controls`) since
* enable/disable here is a persistent setting, not a one-off action.
*
* No "View runs" action yet: it would only stub-log a `selectedFlowId` with
* nothing to show for it until B3b's run inspector lands (tracked as a
* commented-out integration point in `FlowsPage.tsx`), so it's a dead button
* until then and was pulled rather than shipped as a no-op.
*/
import { useT } from '../../lib/i18n/I18nContext';
import type { Flow } from '../../services/api/flowsApi';
import SettingsSwitch from '../settings/controls/SettingsSwitch';
import Button from '../ui/Button';

/** Which of this row's actions currently has a request in flight, if any. */
export type FlowListRowBusy = 'toggle' | 'run' | null;

/** Matches `useT()`'s `t` signature (`I18nContextValue['t']` isn't exported). */
type TFn = (key: string, fallback?: string) => string;

export interface FlowListRowProps {
flow: Flow;
onToggle: (flow: Flow) => void;
onRun: (flow: Flow) => void;
busy?: FlowListRowBusy;
}

/**
* Formats the "last run" line. `t()` doesn't interpolate, so counts are
* spliced into the translated template in code (`{count}` placeholder) rather
* than templated through raw string concatenation.
*/
function relativeTime(iso: string, t: TFn): string {
const ms = Date.now() - new Date(iso).getTime();
const mins = Math.floor(ms / 60000);
if (mins < 1) return t('flows.list.justNow');
if (mins < 60) return t('flows.list.minutesAgo').replace('{count}', String(mins));
const hrs = Math.floor(mins / 60);
if (hrs < 24) return t('flows.list.hoursAgo').replace('{count}', String(hrs));
const days = Math.floor(hrs / 24);
return t('flows.list.daysAgo').replace('{count}', String(days));
}

/**
* `last_status` is rendered as-is (capitalized) rather than mapped through
* i18n — the same precedent `CoreJobList` follows for `job.last_status` —
* since it's a raw engine-status word, not prose.
*/
function capitalize(value: string): string {
return value.length > 0 ? value.charAt(0).toUpperCase() + value.slice(1) : value;
}

const FlowListRow = ({ flow, onToggle, onRun, busy = null }: FlowListRowProps) => {
const { t } = useT();
const toggleBusy = busy === 'toggle';
const runBusy = busy === 'run';

const lastRunLabel =
flow.last_run_at && flow.last_status
? `${capitalize(flow.last_status)} · ${relativeTime(flow.last_run_at, t)}`
: t('flows.list.neverRun');

return (
<div
data-testid={`flow-row-${flow.id}`}
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>
<div className="mt-0.5 text-[11px] text-content-faint">{lastRunLabel}</div>
</div>
<span
data-testid={`flow-status-${flow.id}`}
className={`flex-shrink-0 rounded-full border px-2 py-1 text-[11px] font-semibold uppercase ${
flow.enabled
? 'border-sage-200 bg-sage-50 text-sage-700 dark:border-sage-500/30 dark:bg-sage-500/10 dark:text-sage-300'
: 'border-line bg-surface-subtle text-content-secondary'
}`}>
{flow.enabled ? t('flows.list.enabled') : t('flows.list.paused')}
</span>
</div>

<div className="flex flex-wrap items-center gap-3">
<SettingsSwitch
id={`flow-toggle-${flow.id}`}
data-testid={`flow-toggle-${flow.id}`}
checked={flow.enabled}
disabled={toggleBusy}
aria-label={t('flows.list.toggleEnabled')}
onCheckedChange={() => onToggle(flow)}
/>
<Button
type="button"
variant="secondary"
size="sm"
data-testid={`flow-run-${flow.id}`}
disabled={runBusy}
onClick={() => onRun(flow)}>
{runBusy ? t('flows.list.running') : t('flows.list.runNow')}
</Button>
</div>
</div>
);
};

export default FlowListRow;
17 changes: 17 additions & 0 deletions app/src/components/layout/shell/CollapsedNavRail.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ describe('CollapsedNavRail', () => {
'nav.chat',
'nav.human',
'nav.brain',
'nav.flows',
'nav.agentWorld',
'nav.connections',
]) {
Expand Down Expand Up @@ -83,6 +84,22 @@ describe('CollapsedNavRail', () => {
);
});

it('marks Workflows active on the /flows list route', () => {
renderWithProviders(<CollapsedNavRail />, { initialEntries: ['/flows'] });
expect(screen.getByRole('button', { name: 'nav.flows' })).toHaveAttribute(
'aria-current',
'page'
);
});

it('marks Workflows active on a nested /flows/* sub-route', () => {
renderWithProviders(<CollapsedNavRail />, { initialEntries: ['/flows/some-flow-id'] });
expect(screen.getByRole('button', { name: 'nav.flows' })).toHaveAttribute(
'aria-current',
'page'
);
});

it('renders a Settings icon that navigates to /settings', () => {
renderWithProviders(<CollapsedNavRail />, { initialEntries: ['/home'] });
const settings = screen.getByRole('button', { name: 'nav.settings' });
Expand Down
1 change: 1 addition & 0 deletions app/src/components/layout/shell/CollapsedNavRail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { useHomeNav } from './useHomeNav';
function matchActive(path: string, pathname: string): boolean {
if (path === '/chat') return pathname.startsWith('/chat');
if (path === '/settings') return pathname === '/settings' || pathname.startsWith('/settings/');
if (path === '/flows') return pathname === '/flows' || pathname.startsWith('/flows/');
if (path === '/home') return pathname === '/home';
return pathname === path;
}
Expand Down
18 changes: 18 additions & 0 deletions app/src/components/layout/shell/SidebarNav.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,24 @@ describe('SidebarNav active matching', () => {
expect(tabButton('Chat')).toHaveAttribute('aria-current', 'page');
});

it('keeps Workflows active on the /flows list route', () => {
renderWithProviders(<SidebarNav />, { initialEntries: ['/flows'] });

expect(tabButton('Workflows')).toHaveAttribute('aria-current', 'page');
});

it('keeps Workflows active on a nested /flows/* sub-route', () => {
renderWithProviders(<SidebarNav />, { initialEntries: ['/flows/some-flow-id'] });

expect(tabButton('Workflows')).toHaveAttribute('aria-current', 'page');
});

it('does not mark Workflows active on an unrelated route', () => {
renderWithProviders(<SidebarNav />, { initialEntries: ['/chat'] });

expect(tabButton('Workflows')).not.toHaveAttribute('aria-current');
});

it('gives the active tab a visible brand-accent fill (not the white sidebar background)', () => {
renderWithProviders(<SidebarNav />, { initialEntries: ['/chat'] });

Expand Down
3 changes: 3 additions & 0 deletions app/src/components/layout/shell/SidebarNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,16 @@ import { NavIcon } from './navIcons';
* - `/agent-world` → the index and every `/agent-world/*` section (it
* redirects to `/agent-world/explore`, so an exact match
* would never light up)
* - `/flows` → the list page and any future `/flows/*` sub-route
* (canvas, run detail, …)
* - `/home` → exact match (so `/` redirects don't light it up)
*/
function matchActive(path: string, pathname: string): boolean {
if (path === '/chat') return pathname.startsWith('/chat');
if (path === '/settings') return pathname === '/settings' || pathname.startsWith('/settings/');
if (path === '/agent-world')
return pathname === '/agent-world' || pathname.startsWith('/agent-world/');
if (path === '/flows') return pathname === '/flows' || pathname.startsWith('/flows/');
if (path === '/home') return pathname === '/home';
return pathname === path;
}
Expand Down
16 changes: 16 additions & 0 deletions app/src/components/layout/shell/navIcons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,22 @@ export function NavIcon({ id, className = 'w-5 h-5' }: NavIconProps) {
/>
</svg>
);
case 'flows':
// Three connected nodes — a saved automation graph, matching the
// Workflows list page's empty-state glyph (FlowsPage.tsx).
return (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<circle cx="5" cy="6" r="2" strokeWidth={1.8} />
<circle cx="5" cy="18" r="2" strokeWidth={1.8} />
<circle cx="19" cy="12" r="2" strokeWidth={1.8} />
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M7 6h4a4 4 0 014 4M7 18h4a4 4 0 004-4"
/>
</svg>
);
case 'agent-world':
// Globe/network glyph — represents the A2A agent social network.
return (
Expand Down
Loading
Loading