feat(flows): Workflows B5a — list page + nav tab (/flows) (#4471)

This commit is contained in:
Cyrus Gray
2026-07-04 01:51:50 +05:30
committed by GitHub
parent 83d298d56a
commit a5b6a13d5c
29 changed files with 1181 additions and 8 deletions
+15
View File
@@ -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';
@@ -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 />} />
@@ -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
View File
@@ -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;
@@ -28,6 +28,7 @@ describe('CollapsedNavRail', () => {
'nav.chat',
'nav.human',
'nav.brain',
'nav.flows',
'nav.agentWorld',
'nav.connections',
]) {
@@ -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' });
@@ -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;
}
@@ -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'] });
@@ -19,6 +19,8 @@ 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 {
@@ -26,6 +28,7 @@ function matchActive(path: string, pathname: string): boolean {
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;
}
@@ -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 (
+6 -2
View File
@@ -3,8 +3,8 @@ import { describe, expect, it } from 'vitest';
import { AVATAR_MENU_ITEMS, NAV_TABS } from '../navConfig';
describe('NAV_TABS', () => {
it('has exactly 5 entries', () => {
expect(NAV_TABS).toHaveLength(5);
it('has exactly 6 entries', () => {
expect(NAV_TABS).toHaveLength(6);
});
it('has the correct ids in order', () => {
@@ -12,6 +12,7 @@ describe('NAV_TABS', () => {
'chat',
'human',
'brain',
'flows',
'agent-world',
'connections',
]);
@@ -22,6 +23,7 @@ describe('NAV_TABS', () => {
'/chat',
'/human',
'/brain',
'/flows',
'/agent-world',
'/connections',
]);
@@ -32,6 +34,7 @@ describe('NAV_TABS', () => {
'nav.chat',
'nav.human',
'nav.brain',
'nav.flows',
'nav.agentWorld',
'nav.connections',
]);
@@ -42,6 +45,7 @@ describe('NAV_TABS', () => {
'tab-chat',
'tab-human',
'tab-brain',
'tab-flows',
'tab-agent-world',
'tab-connections',
]);
+1
View File
@@ -36,6 +36,7 @@ export const NAV_TABS: NavTab[] = [
{ id: 'chat', labelKey: 'nav.chat', path: '/chat', walkthroughAttr: 'tab-chat' },
{ id: 'human', labelKey: 'nav.human', path: '/human', walkthroughAttr: 'tab-human' },
{ id: 'brain', labelKey: 'nav.brain', path: '/brain', walkthroughAttr: 'tab-brain' },
{ id: 'flows', labelKey: 'nav.flows', path: '/flows', walkthroughAttr: 'tab-flows' },
{
id: 'agent-world',
labelKey: 'nav.agentWorld',
+23
View File
@@ -164,6 +164,7 @@ const messages: TranslationMap = {
'nav.noAgentProfiles': 'لم يتم العثور على ملفات وكلاء',
'nav.activity': 'النشاط',
'nav.brain': 'الدماغ',
'nav.flows': 'سير العمل',
'nav.agentWorld': 'Tiny Place',
'nav.wallet': 'المحفظة',
'agentWorld.description':
@@ -3565,6 +3566,28 @@ const messages: TranslationMap = {
'flowRuns.status.completed': 'مكتمل',
'flowRuns.status.pending_approval': 'بانتظار الموافقة',
'flowRuns.status.failed': 'فشل',
'flows.page.title': 'سير العمل',
'flows.page.description': 'أتمتة محفوظة يمكنك تفعيلها وتشغيلها ومتابعتها.',
'flows.page.emptyTitle': 'لا توجد عمليات سير عمل بعد',
'flows.page.emptyDescription':
'ستظهر عمليات سير العمل المحفوظة هنا بمجرد إنشاء واحدة من لوحة الرسم.',
'flows.page.loading': 'جارٍ تحميل عمليات سير العمل…',
'flows.page.loadError': 'تعذر تحميل عمليات سير العمل. يرجى المحاولة مرة أخرى.',
'flows.list.lastRun': 'آخر تشغيل',
'flows.list.neverRun': 'لم يتم التشغيل بعد',
'flows.list.justNow': 'الآن',
'flows.list.minutesAgo': 'منذ {count} دقيقة',
'flows.list.hoursAgo': 'منذ {count} ساعة',
'flows.list.daysAgo': 'منذ {count} يوم',
'flows.list.runNow': 'تشغيل',
'flows.list.running': 'جارٍ التشغيل…',
'flows.list.viewRuns': 'عرض التشغيلات',
'flows.list.toggleEnabled': 'تفعيل سير العمل',
'flows.list.enabled': 'مفعّل',
'flows.list.paused': 'متوقف مؤقتًا',
'flows.list.runStarted': 'بدأ تشغيل سير العمل',
'oauth.button.connecting': 'جارٍ الاتصال...',
'oauth.button.loopbackTimeout':
'انتهت مهلة تسجيل الدخول — لم يكتمل المتصفح إعادة توجيه OAuth. يرجى المحاولة مرة أخرى.',
+23
View File
@@ -169,6 +169,7 @@ const messages: TranslationMap = {
'nav.noAgentProfiles': 'কোনো এজেন্ট প্রোফাইল পাওয়া যায়নি',
'nav.activity': 'কার্যকলাপ',
'nav.brain': 'ব্রেইন',
'nav.flows': 'ওয়ার্কফ্লো',
'nav.agentWorld': 'Tiny Place',
'nav.wallet': 'ওয়ালেট',
'agentWorld.description':
@@ -3646,6 +3647,28 @@ const messages: TranslationMap = {
'flowRuns.status.completed': 'সম্পন্ন',
'flowRuns.status.pending_approval': 'অনুমোদনের অপেক্ষায়',
'flowRuns.status.failed': 'ব্যর্থ',
'flows.page.title': 'ওয়ার্কফ্লো',
'flows.page.description': 'সংরক্ষিত অটোমেশন যা আপনি সক্ষম, চালাতে এবং পর্যবেক্ষণ করতে পারেন।',
'flows.page.emptyTitle': 'এখনো কোনো ওয়ার্কফ্লো নেই',
'flows.page.emptyDescription':
'ক্যানভাস থেকে একটি তৈরি করলে সংরক্ষিত ওয়ার্কফ্লোগুলো এখানে দেখা যাবে।',
'flows.page.loading': 'ওয়ার্কফ্লো লোড হচ্ছে…',
'flows.page.loadError': 'ওয়ার্কফ্লো লোড করা যায়নি। আবার চেষ্টা করুন।',
'flows.list.lastRun': 'সর্বশেষ চালানো',
'flows.list.neverRun': 'কখনো চালানো হয়নি',
'flows.list.justNow': 'এইমাত্র',
'flows.list.minutesAgo': '{count} মিনিট আগে',
'flows.list.hoursAgo': '{count} ঘণ্টা আগে',
'flows.list.daysAgo': '{count} দিন আগে',
'flows.list.runNow': 'চালান',
'flows.list.running': 'চলছে…',
'flows.list.viewRuns': 'রান দেখুন',
'flows.list.toggleEnabled': 'ওয়ার্কফ্লো সক্ষম করুন',
'flows.list.enabled': 'সক্ষম',
'flows.list.paused': 'বিরতি দেওয়া',
'flows.list.runStarted': 'ওয়ার্কফ্লো শুরু হয়েছে',
'oauth.button.connecting': 'সংযোগ হচ্ছে...',
'oauth.button.loopbackTimeout':
'সাইন-ইন টাইম আউট হয়েছে — ব্রাউজার OAuth পুনর্নির্দেশনা সম্পন্ন করেনি। অনুগ্রহ করে আবার চেষ্টা করুন।',
+24
View File
@@ -174,6 +174,7 @@ const messages: TranslationMap = {
'nav.noAgentProfiles': 'Keine Agentenprofile gefunden',
'nav.activity': 'Aktivität',
'nav.brain': 'Gehirn',
'nav.flows': 'Workflows',
'nav.agentWorld': 'Tiny Place',
'nav.wallet': 'Wallet',
'agentWorld.description':
@@ -3735,6 +3736,29 @@ const messages: TranslationMap = {
'flowRuns.status.completed': 'Abgeschlossen',
'flowRuns.status.pending_approval': 'Wartet auf Genehmigung',
'flowRuns.status.failed': 'Fehlgeschlagen',
'flows.page.title': 'Workflows',
'flows.page.description':
'Gespeicherte Automatisierungen, die du aktivieren, ausführen und überwachen kannst.',
'flows.page.emptyTitle': 'Noch keine Workflows',
'flows.page.emptyDescription':
'Gespeicherte Workflows erscheinen hier, sobald du einen im Canvas erstellst.',
'flows.page.loading': 'Workflows werden geladen…',
'flows.page.loadError': 'Workflows konnten nicht geladen werden. Bitte versuche es erneut.',
'flows.list.lastRun': 'Letzter Lauf',
'flows.list.neverRun': 'Noch nie ausgeführt',
'flows.list.justNow': 'Gerade eben',
'flows.list.minutesAgo': 'vor {count} Min.',
'flows.list.hoursAgo': 'vor {count} Std.',
'flows.list.daysAgo': 'vor {count} Tagen',
'flows.list.runNow': 'Ausführen',
'flows.list.running': 'Läuft…',
'flows.list.viewRuns': 'Läufe anzeigen',
'flows.list.toggleEnabled': 'Workflow aktivieren',
'flows.list.enabled': 'Aktiviert',
'flows.list.paused': 'Pausiert',
'flows.list.runStarted': 'Workflow gestartet',
'oauth.button.connecting': 'Verbinden...',
'oauth.button.loopbackTimeout':
'Anmeldung abgelaufen — der Browser hat die OAuth-Weiterleitung nicht abgeschlossen. Bitte versuche es erneut.',
+28
View File
@@ -25,6 +25,7 @@ const en: TranslationMap = {
'nav.noAgentProfiles': 'No agent profiles found',
'nav.activity': 'Activity',
'nav.brain': 'Brain',
'nav.flows': 'Workflows',
'nav.agentWorld': 'Tiny Place',
'nav.wallet': 'Wallet',
// Agent World section sub-navigation labels
@@ -4285,6 +4286,33 @@ const en: TranslationMap = {
'flowRuns.status.completed': 'Completed',
'flowRuns.status.pending_approval': 'Awaiting approval',
'flowRuns.status.failed': 'Failed',
// ── Workflows list page + nav tab (B5a) — the `flows::` domain's
// discoverable hub at /flows. Distinct from the legacy SKILL.md
// "workflows.*" namespace above and from B3b's "flowRuns.*" / B4's
// "chat.flowProposal.*" namespaces (kept apart here to avoid merge
// conflicts with those in-flight branches).
'flows.page.title': 'Workflows',
'flows.page.description': 'Saved automations you can enable, run, and monitor.',
'flows.page.emptyTitle': 'No workflows yet',
'flows.page.emptyDescription':
'Saved workflows will show up here once you create one from the canvas.',
'flows.page.loading': 'Loading workflows…',
'flows.page.loadError': 'Could not load workflows. Please try again.',
'flows.list.lastRun': 'Last run',
'flows.list.neverRun': 'Never run',
'flows.list.justNow': 'Just now',
'flows.list.minutesAgo': '{count}m ago',
'flows.list.hoursAgo': '{count}h ago',
'flows.list.daysAgo': '{count}d ago',
'flows.list.runNow': 'Run',
'flows.list.running': 'Running…',
'flows.list.viewRuns': 'View runs',
'flows.list.toggleEnabled': 'Enable workflow',
'flows.list.enabled': 'Enabled',
'flows.list.paused': 'Paused',
'flows.list.runStarted': 'Workflow started',
'oauth.button.connecting': 'Connecting...',
'oauth.button.loopbackTimeout':
'Sign-in timed out — the browser did not complete the OAuth redirect. Please try again.',
+24
View File
@@ -171,6 +171,7 @@ const messages: TranslationMap = {
'nav.noAgentProfiles': 'No se encontraron perfiles de agente',
'nav.activity': 'Actividad',
'nav.brain': 'Cerebro',
'nav.flows': 'Flujos de trabajo',
'nav.agentWorld': 'Tiny Place',
'nav.wallet': 'Cartera',
'agentWorld.description':
@@ -3709,6 +3710,29 @@ const messages: TranslationMap = {
'flowRuns.status.completed': 'Completado',
'flowRuns.status.pending_approval': 'Esperando aprobación',
'flowRuns.status.failed': 'Fallido',
'flows.page.title': 'Flujos de trabajo',
'flows.page.description':
'Automatizaciones guardadas que puedes habilitar, ejecutar y supervisar.',
'flows.page.emptyTitle': 'Aún no hay flujos de trabajo',
'flows.page.emptyDescription':
'Los flujos de trabajo guardados aparecerán aquí en cuanto crees uno desde el lienzo.',
'flows.page.loading': 'Cargando flujos de trabajo…',
'flows.page.loadError': 'No se pudieron cargar los flujos de trabajo. Inténtalo de nuevo.',
'flows.list.lastRun': 'Última ejecución',
'flows.list.neverRun': 'Nunca ejecutado',
'flows.list.justNow': 'Justo ahora',
'flows.list.minutesAgo': 'hace {count} min',
'flows.list.hoursAgo': 'hace {count} h',
'flows.list.daysAgo': 'hace {count} d',
'flows.list.runNow': 'Ejecutar',
'flows.list.running': 'Ejecutando…',
'flows.list.viewRuns': 'Ver ejecuciones',
'flows.list.toggleEnabled': 'Habilitar flujo de trabajo',
'flows.list.enabled': 'Habilitado',
'flows.list.paused': 'Pausado',
'flows.list.runStarted': 'Flujo de trabajo iniciado',
'oauth.button.connecting': 'Conectando...',
'oauth.button.loopbackTimeout':
'El inicio de sesión expiró — el navegador no completó la redirección OAuth. Por favor, inténtalo de nuevo.',
+24
View File
@@ -171,6 +171,7 @@ const messages: TranslationMap = {
'nav.noAgentProfiles': "Aucun profil d'agent trouvé",
'nav.activity': 'Activité',
'nav.brain': 'Cerveau',
'nav.flows': 'Workflows',
'nav.agentWorld': 'Tiny Place',
'nav.wallet': 'Portefeuille',
'agentWorld.description':
@@ -3724,6 +3725,29 @@ const messages: TranslationMap = {
'flowRuns.status.completed': 'Terminé',
'flowRuns.status.pending_approval': "En attente d'approbation",
'flowRuns.status.failed': 'Échoué',
'flows.page.title': 'Workflows',
'flows.page.description':
'Automatisations enregistrées que vous pouvez activer, exécuter et surveiller.',
'flows.page.emptyTitle': "Aucun workflow pour l'instant",
'flows.page.emptyDescription':
'Les workflows enregistrés apparaîtront ici dès que vous en créerez un depuis le canevas.',
'flows.page.loading': 'Chargement des workflows…',
'flows.page.loadError': 'Impossible de charger les workflows. Veuillez réessayer.',
'flows.list.lastRun': 'Dernière exécution',
'flows.list.neverRun': 'Jamais exécuté',
'flows.list.justNow': "À l'instant",
'flows.list.minutesAgo': 'il y a {count} min',
'flows.list.hoursAgo': 'il y a {count} h',
'flows.list.daysAgo': 'il y a {count} j',
'flows.list.runNow': 'Exécuter',
'flows.list.running': 'Exécution…',
'flows.list.viewRuns': 'Voir les exécutions',
'flows.list.toggleEnabled': 'Activer le workflow',
'flows.list.enabled': 'Activé',
'flows.list.paused': 'En pause',
'flows.list.runStarted': 'Workflow démarré',
'oauth.button.connecting': 'Connexion en cours…',
'oauth.button.loopbackTimeout':
"La connexion a expiré — le navigateur n'a pas complété la redirection OAuth. Veuillez réessayer.",
+22
View File
@@ -168,6 +168,7 @@ const messages: TranslationMap = {
'nav.noAgentProfiles': 'कोई एजेंट प्रोफाइल नहीं मिला',
'nav.activity': 'गतिविधि',
'nav.brain': 'ब्रेन',
'nav.flows': 'वर्कफ़्लो',
'nav.agentWorld': 'Tiny Place',
'nav.wallet': 'वॉलेट',
'agentWorld.description':
@@ -3647,6 +3648,27 @@ const messages: TranslationMap = {
'flowRuns.status.completed': 'पूर्ण',
'flowRuns.status.pending_approval': 'अनुमोदन की प्रतीक्षा में',
'flowRuns.status.failed': 'विफल',
'flows.page.title': 'वर्कफ़्लो',
'flows.page.description': 'सहेजे गए ऑटोमेशन जिन्हें आप सक्षम, चला और मॉनिटर कर सकते हैं।',
'flows.page.emptyTitle': 'अभी तक कोई वर्कफ़्लो नहीं',
'flows.page.emptyDescription': 'कैनवास से एक बनाने के बाद सहेजे गए वर्कफ़्लो यहां दिखाई देंगे।',
'flows.page.loading': 'वर्कफ़्लो लोड हो रहे हैं…',
'flows.page.loadError': 'वर्कफ़्लो लोड नहीं हो सके। कृपया फिर से प्रयास करें।',
'flows.list.lastRun': 'अंतिम रन',
'flows.list.neverRun': 'कभी नहीं चला',
'flows.list.justNow': 'अभी अभी',
'flows.list.minutesAgo': '{count} मिनट पहले',
'flows.list.hoursAgo': '{count} घंटे पहले',
'flows.list.daysAgo': '{count} दिन पहले',
'flows.list.runNow': 'चलाएं',
'flows.list.running': 'चल रहा है…',
'flows.list.viewRuns': 'रन देखें',
'flows.list.toggleEnabled': 'वर्कफ़्लो सक्षम करें',
'flows.list.enabled': 'सक्षम',
'flows.list.paused': 'रोका गया',
'flows.list.runStarted': 'वर्कफ़्लो शुरू हुआ',
'oauth.button.connecting': 'कनेक्ट हो रहा है...',
'oauth.button.loopbackTimeout':
'साइन-इन का समय समाप्त हो गया — ब्राउज़र ने OAuth पुनर्निर्देशन पूरा नहीं किया। कृपया पुनः प्रयास करें।',
+23
View File
@@ -169,6 +169,7 @@ const messages: TranslationMap = {
'nav.noAgentProfiles': 'Profil agen tidak ditemukan',
'nav.activity': 'Aktivitas',
'nav.brain': 'Otak',
'nav.flows': 'Alur Kerja',
'nav.agentWorld': 'Tiny Place',
'nav.wallet': 'Dompet',
'agentWorld.description':
@@ -3654,6 +3655,28 @@ const messages: TranslationMap = {
'flowRuns.status.completed': 'Selesai',
'flowRuns.status.pending_approval': 'Menunggu persetujuan',
'flowRuns.status.failed': 'Gagal',
'flows.page.title': 'Alur Kerja',
'flows.page.description': 'Otomatisasi tersimpan yang dapat Anda aktifkan, jalankan, dan pantau.',
'flows.page.emptyTitle': 'Belum ada alur kerja',
'flows.page.emptyDescription':
'Alur kerja tersimpan akan muncul di sini setelah Anda membuat satu dari kanvas.',
'flows.page.loading': 'Memuat alur kerja…',
'flows.page.loadError': 'Alur kerja gagal dimuat. Silakan coba lagi.',
'flows.list.lastRun': 'Terakhir dijalankan',
'flows.list.neverRun': 'Belum pernah dijalankan',
'flows.list.justNow': 'Baru saja',
'flows.list.minutesAgo': '{count} menit lalu',
'flows.list.hoursAgo': '{count} jam lalu',
'flows.list.daysAgo': '{count} hari lalu',
'flows.list.runNow': 'Jalankan',
'flows.list.running': 'Sedang berjalan…',
'flows.list.viewRuns': 'Lihat riwayat',
'flows.list.toggleEnabled': 'Aktifkan alur kerja',
'flows.list.enabled': 'Aktif',
'flows.list.paused': 'Dijeda',
'flows.list.runStarted': 'Alur kerja dimulai',
'oauth.button.connecting': 'Menghubungkan...',
'oauth.button.loopbackTimeout':
'Masuk habis waktu — browser tidak menyelesaikan pengalihan OAuth. Silakan coba lagi.',
+23
View File
@@ -171,6 +171,7 @@ const messages: TranslationMap = {
'nav.noAgentProfiles': 'Nessun profilo agente trovato',
'nav.activity': 'Attività',
'nav.brain': 'Cervello',
'nav.flows': 'Flussi di lavoro',
'nav.agentWorld': 'Tiny Place',
'nav.wallet': 'Portafoglio',
'agentWorld.description':
@@ -3704,6 +3705,28 @@ const messages: TranslationMap = {
'flowRuns.status.completed': 'Completato',
'flowRuns.status.pending_approval': 'In attesa di approvazione',
'flowRuns.status.failed': 'Non riuscito',
'flows.page.title': 'Flussi di lavoro',
'flows.page.description': 'Automazioni salvate che puoi abilitare, eseguire e monitorare.',
'flows.page.emptyTitle': 'Ancora nessun flusso di lavoro',
'flows.page.emptyDescription':
'I flussi di lavoro salvati appariranno qui non appena ne crei uno dalla lavagna.',
'flows.page.loading': 'Caricamento dei flussi di lavoro…',
'flows.page.loadError': 'Impossibile caricare i flussi di lavoro. Riprova.',
'flows.list.lastRun': 'Ultima esecuzione',
'flows.list.neverRun': 'Mai eseguito',
'flows.list.justNow': 'Proprio ora',
'flows.list.minutesAgo': '{count} min fa',
'flows.list.hoursAgo': '{count} h fa',
'flows.list.daysAgo': '{count} g fa',
'flows.list.runNow': 'Esegui',
'flows.list.running': 'In esecuzione…',
'flows.list.viewRuns': 'Visualizza esecuzioni',
'flows.list.toggleEnabled': 'Abilita flusso di lavoro',
'flows.list.enabled': 'Abilitato',
'flows.list.paused': 'In pausa',
'flows.list.runStarted': 'Flusso di lavoro avviato',
'oauth.button.connecting': 'Connessione...',
'oauth.button.loopbackTimeout':
'Accesso scaduto — il browser non ha completato il reindirizzamento OAuth. Riprova.',
+22
View File
@@ -165,6 +165,7 @@ const messages: TranslationMap = {
'nav.noAgentProfiles': '에이전트 프로필을 찾을 수 없습니다',
'nav.activity': '활동',
'nav.brain': '브레인',
'nav.flows': '워크플로',
'nav.agentWorld': 'Tiny Place',
'nav.wallet': '지갑',
'agentWorld.description':
@@ -3610,6 +3611,27 @@ const messages: TranslationMap = {
'flowRuns.status.completed': '완료됨',
'flowRuns.status.pending_approval': '승인 대기 중',
'flowRuns.status.failed': '실패',
'flows.page.title': '워크플로',
'flows.page.description': '활성화, 실행, 모니터링할 수 있는 저장된 자동화입니다.',
'flows.page.emptyTitle': '아직 워크플로가 없습니다',
'flows.page.emptyDescription': '캔버스에서 워크플로를 만들면 여기에 표시됩니다.',
'flows.page.loading': '워크플로 로드 중…',
'flows.page.loadError': '워크플로를 불러올 수 없습니다. 다시 시도해 주세요.',
'flows.list.lastRun': '마지막 실행',
'flows.list.neverRun': '실행된 적 없음',
'flows.list.justNow': '방금',
'flows.list.minutesAgo': '{count}분 전',
'flows.list.hoursAgo': '{count}시간 전',
'flows.list.daysAgo': '{count}일 전',
'flows.list.runNow': '실행',
'flows.list.running': '실행 중…',
'flows.list.viewRuns': '실행 내역 보기',
'flows.list.toggleEnabled': '워크플로 활성화',
'flows.list.enabled': '활성화됨',
'flows.list.paused': '일시 중지됨',
'flows.list.runStarted': '워크플로가 시작되었습니다',
'oauth.button.connecting': '연결 중...',
'oauth.button.loopbackTimeout':
'로그인 시간 초과 — 브라우저가 OAuth 리디렉션을 완료하지 못했습니다. 다시 시도해 주세요.',
+24
View File
@@ -173,6 +173,7 @@ const messages: TranslationMap = {
'nav.noAgentProfiles': 'Nie znaleziono profili agentów',
'nav.activity': 'Aktywność',
'nav.brain': 'Mózg',
'nav.flows': 'Przepływy pracy',
'nav.agentWorld': 'Tiny Place',
'nav.wallet': 'Portfel',
'agentWorld.description':
@@ -3690,6 +3691,29 @@ const messages: TranslationMap = {
'flowRuns.status.completed': 'Zakończono',
'flowRuns.status.pending_approval': 'Oczekuje na zatwierdzenie',
'flowRuns.status.failed': 'Niepowodzenie',
'flows.page.title': 'Przepływy pracy',
'flows.page.description':
'Zapisane automatyzacje, które możesz włączyć, uruchomić i monitorować.',
'flows.page.emptyTitle': 'Brak przepływów pracy',
'flows.page.emptyDescription':
'Zapisane przepływy pracy pojawią się tutaj, gdy utworzysz jeden na płótnie.',
'flows.page.loading': 'Ładowanie przepływów pracy…',
'flows.page.loadError': 'Nie udało się załadować przepływów pracy. Spróbuj ponownie.',
'flows.list.lastRun': 'Ostatnie uruchomienie',
'flows.list.neverRun': 'Nigdy nie uruchomiono',
'flows.list.justNow': 'Przed chwilą',
'flows.list.minutesAgo': '{count} min temu',
'flows.list.hoursAgo': '{count} godz. temu',
'flows.list.daysAgo': '{count} dni temu',
'flows.list.runNow': 'Uruchom',
'flows.list.running': 'Uruchamianie…',
'flows.list.viewRuns': 'Zobacz uruchomienia',
'flows.list.toggleEnabled': 'Włącz przepływ pracy',
'flows.list.enabled': 'Włączony',
'flows.list.paused': 'Wstrzymany',
'flows.list.runStarted': 'Przepływ pracy uruchomiony',
'oauth.button.connecting': 'Łączenie...',
'oauth.button.loopbackTimeout':
'Logowanie przekroczyło limit czasu — przeglądarka nie ukończyła przekierowania OAuth. Spróbuj ponownie.',
+23
View File
@@ -170,6 +170,7 @@ const messages: TranslationMap = {
'nav.noAgentProfiles': 'Nenhum perfil de agente encontrado',
'nav.activity': 'Atividade',
'nav.brain': 'Cérebro',
'nav.flows': 'Fluxos de trabalho',
'nav.agentWorld': 'Tiny Place',
'nav.wallet': 'Carteira',
'agentWorld.description':
@@ -3705,6 +3706,28 @@ const messages: TranslationMap = {
'flowRuns.status.completed': 'Concluído',
'flowRuns.status.pending_approval': 'Aguardando aprovação',
'flowRuns.status.failed': 'Falhou',
'flows.page.title': 'Fluxos de trabalho',
'flows.page.description': 'Automações salvas que você pode habilitar, executar e monitorar.',
'flows.page.emptyTitle': 'Ainda não há fluxos de trabalho',
'flows.page.emptyDescription':
'Os fluxos de trabalho salvos aparecerão aqui assim que você criar um a partir do canvas.',
'flows.page.loading': 'Carregando fluxos de trabalho…',
'flows.page.loadError': 'Não foi possível carregar os fluxos de trabalho. Tente novamente.',
'flows.list.lastRun': 'Última execução',
'flows.list.neverRun': 'Nunca executado',
'flows.list.justNow': 'Agora mesmo',
'flows.list.minutesAgo': 'há {count} min',
'flows.list.hoursAgo': 'há {count} h',
'flows.list.daysAgo': 'há {count} d',
'flows.list.runNow': 'Executar',
'flows.list.running': 'Executando…',
'flows.list.viewRuns': 'Ver execuções',
'flows.list.toggleEnabled': 'Habilitar fluxo de trabalho',
'flows.list.enabled': 'Habilitado',
'flows.list.paused': 'Pausado',
'flows.list.runStarted': 'Fluxo de trabalho iniciado',
'oauth.button.connecting': 'Conectando...',
'oauth.button.loopbackTimeout':
'Login expirou — o navegador não concluiu o redirecionamento OAuth. Por favor, tente novamente.',
+24
View File
@@ -173,6 +173,7 @@ const messages: TranslationMap = {
'nav.noAgentProfiles': 'Профили агентов не найдены',
'nav.activity': 'Активность',
'nav.brain': 'Мозг',
'nav.flows': 'Рабочие процессы',
'nav.agentWorld': 'Tiny Place',
'nav.wallet': 'Кошелёк',
'agentWorld.description':
@@ -3679,6 +3680,29 @@ const messages: TranslationMap = {
'flowRuns.status.completed': 'Завершено',
'flowRuns.status.pending_approval': 'Ожидает подтверждения',
'flowRuns.status.failed': 'Не удалось',
'flows.page.title': 'Рабочие процессы',
'flows.page.description':
'Сохранённые автоматизации, которые можно включать, запускать и отслеживать.',
'flows.page.emptyTitle': 'Пока нет рабочих процессов',
'flows.page.emptyDescription':
'Сохранённые рабочие процессы появятся здесь, как только вы создадите один на холсте.',
'flows.page.loading': 'Загрузка рабочих процессов…',
'flows.page.loadError': 'Не удалось загрузить рабочие процессы. Попробуйте снова.',
'flows.list.lastRun': 'Последний запуск',
'flows.list.neverRun': 'Ещё не запускался',
'flows.list.justNow': 'Только что',
'flows.list.minutesAgo': '{count} мин назад',
'flows.list.hoursAgo': '{count} ч назад',
'flows.list.daysAgo': '{count} дн назад',
'flows.list.runNow': 'Запустить',
'flows.list.running': 'Выполняется…',
'flows.list.viewRuns': 'Просмотреть запуски',
'flows.list.toggleEnabled': 'Включить рабочий процесс',
'flows.list.enabled': 'Включён',
'flows.list.paused': 'Приостановлен',
'flows.list.runStarted': 'Рабочий процесс запущен',
'oauth.button.connecting': 'Подключение...',
'oauth.button.loopbackTimeout':
'Время входа истекло — браузер не завершил перенаправление OAuth. Пожалуйста, попробуйте снова.',
+22
View File
@@ -153,6 +153,7 @@ const messages: TranslationMap = {
'nav.noAgentProfiles': '未找到代理档案',
'nav.activity': '动态',
'nav.brain': '大脑',
'nav.flows': '工作流',
'nav.agentWorld': 'Tiny Place',
'nav.wallet': '钱包',
'agentWorld.description':
@@ -3456,6 +3457,27 @@ const messages: TranslationMap = {
'flowRuns.status.completed': '已完成',
'flowRuns.status.pending_approval': '等待批准',
'flowRuns.status.failed': '失败',
'flows.page.title': '工作流',
'flows.page.description': '已保存的自动化流程,可启用、运行并监控。',
'flows.page.emptyTitle': '还没有工作流',
'flows.page.emptyDescription': '在画布中创建工作流后,将显示在此处。',
'flows.page.loading': '正在加载工作流…',
'flows.page.loadError': '无法加载工作流,请重试。',
'flows.list.lastRun': '上次运行',
'flows.list.neverRun': '从未运行',
'flows.list.justNow': '刚刚',
'flows.list.minutesAgo': '{count}分钟前',
'flows.list.hoursAgo': '{count}小时前',
'flows.list.daysAgo': '{count}天前',
'flows.list.runNow': '运行',
'flows.list.running': '运行中…',
'flows.list.viewRuns': '查看运行记录',
'flows.list.toggleEnabled': '启用工作流',
'flows.list.enabled': '已启用',
'flows.list.paused': '已暂停',
'flows.list.runStarted': '工作流已启动',
'oauth.button.connecting': '连接中...',
'oauth.button.loopbackTimeout': '登录超时 — 浏览器未完成 OAuth 跳转。请重试。',
'oauth.login.continueWith': '继续使用',
+111
View File
@@ -0,0 +1,111 @@
/**
* FlowsPage (issue B5a) — the Workflows list page. Asserts the
* loading/empty/error/list states, that toggling a flow calls
* `setFlowEnabled` and refreshes the row, and that Run fires `runFlow`,
* shows a "Workflow started" toast, and refetches the list.
*/
import { fireEvent, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { Flow } from '../services/api/flowsApi';
import { renderWithProviders } from '../test/test-utils';
import FlowsPage from './FlowsPage';
const listFlows = vi.hoisted(() => vi.fn());
const setFlowEnabled = vi.hoisted(() => vi.fn());
const runFlow = vi.hoisted(() => vi.fn());
vi.mock('../services/api/flowsApi', () => ({ listFlows, setFlowEnabled, runFlow }));
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('FlowsPage', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('shows a loading state while flows are being fetched', () => {
listFlows.mockReturnValue(new Promise(() => {})); // never resolves
renderWithProviders(<FlowsPage />);
expect(screen.getByText('Loading workflows…')).toBeInTheDocument();
});
it('shows the empty state when there are no saved flows', async () => {
listFlows.mockResolvedValue([]);
renderWithProviders(<FlowsPage />);
await waitFor(() => expect(screen.getByText('No workflows yet')).toBeInTheDocument());
// The empty state omits a "Create" action (canvas ships in B5b).
expect(screen.queryByRole('button', { name: /create/i })).not.toBeInTheDocument();
});
it('shows an error banner when the fetch fails', async () => {
listFlows.mockRejectedValue(new Error('core unreachable'));
renderWithProviders(<FlowsPage />);
await waitFor(() =>
expect(screen.getByText('Could not load workflows. Please try again.')).toBeInTheDocument()
);
});
it('renders one row per saved flow', async () => {
listFlows.mockResolvedValue([makeFlow(), makeFlow({ id: 'flow-2', name: 'Weekly report' })]);
renderWithProviders(<FlowsPage />);
await waitFor(() => expect(screen.getByText('Daily digest')).toBeInTheDocument());
expect(screen.getByText('Weekly report')).toBeInTheDocument();
});
it('toggles a flow via setFlowEnabled and reflects the updated state', async () => {
listFlows.mockResolvedValue([makeFlow({ enabled: true })]);
setFlowEnabled.mockResolvedValue(makeFlow({ enabled: false }));
renderWithProviders(<FlowsPage />);
await waitFor(() => expect(screen.getByTestId('flow-toggle-flow-1')).toBeInTheDocument());
fireEvent.click(screen.getByTestId('flow-toggle-flow-1'));
expect(setFlowEnabled).toHaveBeenCalledWith('flow-1', false);
await waitFor(() =>
expect(screen.getByTestId('flow-status-flow-1')).toHaveTextContent('Paused')
);
});
it('runs a flow, shows a "Workflow started" toast, and refetches the list', async () => {
listFlows.mockResolvedValue([makeFlow()]);
runFlow.mockResolvedValue({ output: null, pending_approvals: [], thread_id: 't1' });
renderWithProviders(<FlowsPage />);
await waitFor(() => expect(screen.getByTestId('flow-run-flow-1')).toBeInTheDocument());
fireEvent.click(screen.getByTestId('flow-run-flow-1'));
expect(runFlow).toHaveBeenCalledWith('flow-1');
await waitFor(() => expect(screen.getByText('Workflow started')).toBeInTheDocument());
// Loaded once on mount, once more on refetch after the run kicks off.
await waitFor(() => expect(listFlows).toHaveBeenCalledTimes(2));
});
it('shows an error banner (without a toast) when runFlow rejects', async () => {
listFlows.mockResolvedValue([makeFlow()]);
runFlow.mockRejectedValue(new Error('flow disabled'));
renderWithProviders(<FlowsPage />);
await waitFor(() => expect(screen.getByTestId('flow-run-flow-1')).toBeInTheDocument());
fireEvent.click(screen.getByTestId('flow-run-flow-1'));
await waitFor(() => expect(screen.getByText('flow disabled')).toBeInTheDocument());
expect(screen.queryByText('Workflow started')).not.toBeInTheDocument();
});
});
+188
View File
@@ -0,0 +1,188 @@
/**
* 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) and NOT the chat agent-proposal
* surface (B4) — just the top-level `/flows` list, reached via the
* "Workflows" nav tab (see `config/navConfig.ts`).
*/
import createDebug from 'debug';
import { useCallback, useEffect, useState } from 'react';
import EmptyStateCard from '../components/EmptyStateCard';
import FlowListRow, { type FlowListRowBusy } from '../components/flows/FlowListRow';
import { ToastContainer } from '../components/intelligence/Toast';
import PanelPage from '../components/layout/PanelPage';
import { CenteredLoadingState, ErrorBanner } from '../components/ui/LoadingState';
import { useT } from '../lib/i18n/I18nContext';
import { type Flow, listFlows, runFlow, setFlowEnabled } from '../services/api/flowsApi';
import type { ToastNotification } from '../types/intelligence';
const log = createDebug('app:flows');
/** Which single row + action currently has a request in flight, if any. */
type BusyKey = `toggle:${string}` | `run:${string}`;
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
export default function FlowsPage() {
const { t } = useT();
const [flows, setFlows] = useState<Flow[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [busyKey, setBusyKey] = useState<BusyKey | null>(null);
const [toasts, setToasts] = useState<ToastNotification[]>([]);
const addToast = useCallback((toast: Omit<ToastNotification, 'id'>) => {
setToasts(prev => [...prev, { ...toast, id: `toast-${Date.now()}-${Math.random()}` }]);
}, []);
const removeToast = useCallback((id: string) => {
setToasts(prev => prev.filter(item => item.id !== id));
}, []);
const loadFlows = useCallback(async () => {
log('loading flows');
setLoading(true);
setError(null);
try {
const result = await listFlows();
setFlows(result);
log('loaded %d flows', result.length);
} catch (err) {
log('load failed: %o', err);
setError(t('flows.page.loadError'));
} finally {
setLoading(false);
}
}, [t]);
useEffect(() => {
void loadFlows();
}, [loadFlows]);
const handleToggle = useCallback(
async (flow: Flow) => {
if (busyKey) return;
const key: BusyKey = `toggle:${flow.id}`;
setBusyKey(key);
setError(null);
log('toggle: id=%s next=%s', flow.id, !flow.enabled);
try {
const updated = await setFlowEnabled(flow.id, !flow.enabled);
setFlows(prev => prev.map(f => (f.id === updated.id ? updated : f)));
} catch (err) {
log('toggle failed: id=%s err=%o', flow.id, err);
setError(errorMessage(err));
} finally {
setBusyKey(null);
}
},
[busyKey]
);
const handleRun = useCallback(
async (flow: Flow) => {
if (busyKey) return;
const key: BusyKey = `run:${flow.id}`;
setBusyKey(key);
setError(null);
log('run: id=%s', flow.id);
try {
// Fire-and-forget: the caller doesn't wait for the run to finish,
// just that it kicked off. The refetch below picks up the refreshed
// `last_run_at` / `last_status` once the engine settles (or, for a
// still-running flow, on the next manual refresh). Only refetch on
// success — `loadFlows()` clears `error`, which would otherwise wipe
// the failure banner set in the `catch` below.
await runFlow(flow.id);
addToast({ type: 'success', title: t('flows.list.runStarted') });
await loadFlows();
} catch (err) {
log('run failed: id=%s err=%o', flow.id, err);
setError(errorMessage(err));
} finally {
setBusyKey(null);
}
},
[busyKey, addToast, loadFlows, t]
);
const busyFor = (flow: Flow): FlowListRowBusy => {
if (busyKey === `toggle:${flow.id}`) return 'toggle';
if (busyKey === `run:${flow.id}`) return 'run';
return null;
};
return (
<PanelPage
testId="flows-page"
title={t('flows.page.title')}
description={t('flows.page.description')}>
<div className="mx-auto w-full max-w-3xl space-y-4">
{error && (
<div data-testid="flows-error">
<ErrorBanner message={error} />
</div>
)}
{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')}
/>
)}
{!loading && flows.length > 0 && (
<div
data-testid="flows-list"
className="overflow-hidden rounded-2xl border border-line bg-surface">
{flows.map(flow => (
<FlowListRow
key={flow.id}
flow={flow}
busy={busyFor(flow)}
onToggle={f => void handleToggle(f)}
onRun={f => void handleRun(f)}
/>
))}
</div>
)}
{/* === B3b integration (wire after PR #4450 merges) ===
"View runs" was pulled from `FlowListRow` for now — it would only
store a `selectedFlowId` with nothing to show for it until the run
inspector lands, which reads as a dead button. Once #4450 merges,
re-add here as: track `selectedFlowId` state, list the flow's runs
via listFlowRuns(flowId), and open the inspector
(FlowRunInspectorDrawer, keyed by RUN id / thread_id, NOT flowId)
for a chosen run:
{selectedFlowId && (
<FlowRunInspectorRunsForFlow
flowId={selectedFlowId}
onClose={() => setSelectedFlowId(null)}
/>
)} */}
</div>
<ToastContainer notifications={toasts} onRemove={removeToast} />
</PanelPage>
);
}
+121 -1
View File
@@ -1,6 +1,13 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { getFlowRun, listFlowRuns, resumeFlow } from './flowsApi';
import {
getFlowRun,
listFlowRuns,
listFlows,
resumeFlow,
runFlow,
setFlowEnabled,
} from './flowsApi';
const mockCallCoreRpc = vi.fn();
vi.mock('../coreRpcClient', () => ({ callCoreRpc: (...a: unknown[]) => mockCallCoreRpc(...a) }));
@@ -143,4 +150,117 @@ describe('flowsApi', () => {
await expect(getFlowRun('missing')).rejects.toThrow('flow run not found');
});
});
describe('listFlows', () => {
const flow = {
id: 'flow-1',
name: 'Demo flow',
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,
};
it('calls openhuman.flows_list with no params', async () => {
mockCallCoreRpc.mockResolvedValue(cliEnvelope([flow]));
await listFlows();
expect(mockCallCoreRpc).toHaveBeenCalledWith({ method: 'openhuman.flows_list', params: {} });
});
it('unwraps the { result, logs } envelope into the flow array', async () => {
mockCallCoreRpc.mockResolvedValue(cliEnvelope([flow]));
const result = await listFlows();
expect(result).toEqual([flow]);
});
it('propagates rejection from callCoreRpc', async () => {
mockCallCoreRpc.mockRejectedValue(new Error('boom'));
await expect(listFlows()).rejects.toThrow('boom');
});
});
describe('setFlowEnabled', () => {
it('calls openhuman.flows_set_enabled with id and enabled', async () => {
const flow = {
id: 'flow-1',
name: 'Demo flow',
enabled: false,
graph: {},
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
last_run_at: null,
last_status: null,
require_approval: false,
};
mockCallCoreRpc.mockResolvedValue(cliEnvelope(flow));
const result = await setFlowEnabled('flow-1', false);
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.flows_set_enabled',
params: { id: 'flow-1', enabled: false },
});
expect(result).toEqual(flow);
});
it('propagates rejection from callCoreRpc', async () => {
mockCallCoreRpc.mockRejectedValue(new Error('flow not found'));
await expect(setFlowEnabled('missing', true)).rejects.toThrow('flow not found');
});
});
describe('runFlow', () => {
it('calls openhuman.flows_run with id, input, and the extended timeout', async () => {
mockCallCoreRpc.mockResolvedValue(
cliEnvelope({ output: { nodes: {} }, pending_approvals: [], thread_id: 't1' })
);
const result = await runFlow('flow-1');
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.flows_run',
params: { id: 'flow-1', input: null },
timeoutMs: 610_000,
});
expect(result).toEqual({ output: { nodes: {} }, pending_approvals: [], thread_id: 't1' });
});
it('passes a supplied input payload through', async () => {
mockCallCoreRpc.mockResolvedValue(
cliEnvelope({ output: null, pending_approvals: [], thread_id: 't2' })
);
await runFlow('flow-1', { trigger: 'manual' });
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.flows_run',
params: { id: 'flow-1', input: { trigger: 'manual' } },
timeoutMs: 610_000,
});
});
it('unwraps the { result, logs } envelope', async () => {
const payload = { output: null, pending_approvals: ['node-a'], thread_id: 't3' };
mockCallCoreRpc.mockResolvedValue(cliEnvelope(payload));
const result = await runFlow('flow-1');
expect(result).toEqual(payload);
});
it('propagates rejection from callCoreRpc', async () => {
mockCallCoreRpc.mockRejectedValue(new Error('flow disabled'));
await expect(runFlow('flow-1')).rejects.toThrow('flow disabled');
});
});
});
+96 -5
View File
@@ -28,10 +28,11 @@ import { callCoreRpc } from '../coreRpcClient';
const log = debug('flowsApi');
/**
* `openhuman.flows_resume` drives the engine and can run up to ~600s server-side
* (`FLOW_RUN_TIMEOUT_SECS` in `src/openhuman/flows/ops.rs`). Give the client a
* slightly larger budget than the default 30s so a slow resume doesn't fail
* client-side while the engine is still running.
* `openhuman.flows_resume` and `openhuman.flows_run` both drive the tinyflows
* engine and can run up to ~600s server-side (`FLOW_RUN_TIMEOUT_SECS` in
* `src/openhuman/flows/ops.rs`). Give the client a slightly larger budget than
* the default 30s so a slow run/resume doesn't fail client-side while the
* engine is still running.
*/
const FLOW_RESUME_TIMEOUT_MS = 610_000;
@@ -78,6 +79,33 @@ export interface FlowResumeResult {
thread_id: string;
}
/**
* A saved automation workflow (`src/openhuman/flows/types.rs::Flow`) — the
* Workflows list page (B5a) row shape. `graph` is the raw tinyflows
* `WorkflowGraph`; the list page doesn't need to interpret it, only the
* canvas (B5b) does, so it's kept as `unknown` here.
*/
export interface Flow {
/** Stable identifier (UUID) for this flow. */
id: string;
/** Human-readable name shown in the Workflows UI. */
name: string;
/** Whether this flow may currently be triggered/run. */
enabled: boolean;
/** The validated, migrated workflow graph — opaque to this client. */
graph: unknown;
/** RFC3339 creation timestamp. */
created_at: string;
/** RFC3339 last-update timestamp. */
updated_at: string;
/** RFC3339 timestamp of the most recent run, if any. */
last_run_at: string | null;
/** Outcome of the most recent run: `"completed"` | `"pending_approval"` | `"failed"`. */
last_status: string | null;
/** "Require approval for outbound actions" toggle (issue B2). */
require_approval: boolean;
}
// ---------------------------------------------------------------------------
// CLI-compatible envelope unwrapping.
// ---------------------------------------------------------------------------
@@ -166,6 +194,69 @@ export async function getFlowRun(runId: string): Promise<FlowRun> {
return run;
}
export const flowsApi = { resumeFlow, listFlowRuns, getFlowRun };
/**
* List all saved flows via `openhuman.flows_list` (the Workflows list page,
* B5a). No params. Unlike the run-surface calls above, the payload IS the
* `Flow[]` array directly — there is no outer `{ flows: [...] }` wrapper (see
* `src/openhuman/flows/ops.rs::flows_list`, which returns `Vec<Flow>`
* straight through `RpcOutcome::single_log`).
*/
export async function listFlows(): Promise<Flow[]> {
log('listFlows: request');
const response = await callCoreRpc<unknown>({ method: 'openhuman.flows_list', params: {} });
const flows = unwrapCliEnvelope<Flow[]>(response);
log('listFlows: response count=%d', flows.length);
return flows;
}
/**
* Enable or disable a saved flow via `openhuman.flows_set_enabled`. Returns
* the updated `Flow` row directly (same no-wrapper shape as `flows_list`'s
* elements).
*/
export async function setFlowEnabled(id: string, enabled: boolean): Promise<Flow> {
log('setFlowEnabled: request id=%s enabled=%s', id, enabled);
const response = await callCoreRpc<unknown>({
method: 'openhuman.flows_set_enabled',
params: { id, enabled },
});
const flow = unwrapCliEnvelope<Flow>(response);
log('setFlowEnabled: response id=%s enabled=%s', flow.id, flow.enabled);
return flow;
}
/**
* Run a saved flow to completion (or until it pauses on a human-approval
* gate) via `openhuman.flows_run`. This is the call that actually drives the
* tinyflows engine, so it shares `flows_resume`'s ~600s server-side budget
* (see {@link FLOW_RESUME_TIMEOUT_MS}). The Workflows list page's Run button
* uses this fire-and-forget: it awaits the call just long enough to know the
* run kicked off, shows a toast, and refetches `listFlows()` to pick up the
* refreshed `last_run_at`/`last_status`.
*/
export async function runFlow(id: string, input?: unknown): Promise<FlowResumeResult> {
log('runFlow: request id=%s', id);
const response = await callCoreRpc<unknown>({
method: 'openhuman.flows_run',
params: { id, input: input ?? null },
timeoutMs: FLOW_RESUME_TIMEOUT_MS,
});
const result = unwrapCliEnvelope<FlowResumeResult>(response);
log(
'runFlow: response threadId=%s pendingApprovals=%d',
result.thread_id,
result.pending_approvals?.length ?? 0
);
return result;
}
export const flowsApi = {
resumeFlow,
listFlowRuns,
getFlowRun,
listFlows,
setFlowEnabled,
runFlow,
};
export default flowsApi;
+1
View File
@@ -44,6 +44,7 @@ const ROUTES: Route[] = [
{ hash: '/rewards' },
{ hash: '/settings' },
{ hash: '/agent-world' },
{ hash: '/flows' },
];
async function rootTextLength(): Promise<number> {