From f06f3f3becf7bfa4ea2dcf03c4a8e99b305a1d57 Mon Sep 17 00:00:00 2001 From: "cyrus@tinyhumans.ai" Date: Sat, 4 Jul 2026 01:22:26 +0530 Subject: [PATCH 1/2] =?UTF-8?q?feat(flows):=20B5a=20=E2=80=94=20Workflows?= =?UTF-8?q?=20list=20page=20+=20nav=20tab=20(/flows)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The discoverable hub: a top-level /flows page listing saved workflows (name, enabled toggle, last run + status, Run button) reachable from a new nav tab. Uses existing RPCs only — no backend change. - pages/FlowsPage.tsx (+test): PanelPage shell; listFlows on mount; loading/ error/empty states (empty has no CTA until the B5b canvas); toggle → setFlowEnabled (optimistic), Run → fire-and-forget runFlow + toast + refetch (refetch only on success so a run error keeps its banner). - components/flows/FlowListRow.tsx (+test): CoreJobList-style row — name, Enabled/Paused badge, last-run line, SettingsSwitch toggle, Run + View-runs. - flowsApi.ts: appended listFlows / setFlowEnabled / runFlow (+ Flow type); B3b (#4450) doesn't touch this file, so no conflict. - Nav wired in lockstep: navConfig (flows tab, 4th), navIcons, SidebarNav + CollapsedNavRail matchActive, AppRoutes (/flows/*), and the E2E navigation.spec ROUTES + nav-config/shell tests (the CI-gated bits). The /workflows/* Skill routes are untouched. - The run inspector (B3b) is a clearly-marked stub, wired after #4450 merges. - i18n: flows.* + nav.flows across all 14 locales (distinct namespace). typecheck/lint/prettier clean; 69 Vitest tests pass; i18n parity clean. --- app/src/AppRoutes.tsx | 15 ++ app/src/components/flows/FlowListRow.test.tsx | 134 ++++++++++++ app/src/components/flows/FlowListRow.tsx | 110 ++++++++++ .../layout/shell/CollapsedNavRail.test.tsx | 17 ++ .../layout/shell/CollapsedNavRail.tsx | 1 + .../layout/shell/SidebarNav.test.tsx | 18 ++ .../components/layout/shell/SidebarNav.tsx | 3 + app/src/components/layout/shell/navIcons.tsx | 16 ++ app/src/config/__tests__/navConfig.test.ts | 8 +- app/src/config/navConfig.ts | 1 + app/src/lib/i18n/ar.ts | 19 ++ app/src/lib/i18n/bn.ts | 19 ++ app/src/lib/i18n/de.ts | 20 ++ app/src/lib/i18n/en.ts | 24 ++ app/src/lib/i18n/es.ts | 20 ++ app/src/lib/i18n/fr.ts | 20 ++ app/src/lib/i18n/hi.ts | 18 ++ app/src/lib/i18n/id.ts | 19 ++ app/src/lib/i18n/it.ts | 19 ++ app/src/lib/i18n/ko.ts | 18 ++ app/src/lib/i18n/pl.ts | 20 ++ app/src/lib/i18n/pt.ts | 19 ++ app/src/lib/i18n/ru.ts | 20 ++ app/src/lib/i18n/zh-CN.ts | 18 ++ app/src/pages/FlowsPage.test.tsx | 111 ++++++++++ app/src/pages/FlowsPage.tsx | 207 ++++++++++++++++++ app/src/services/api/flowsApi.test.ts | 122 ++++++++++- app/src/services/api/flowsApi.ts | 101 ++++++++- app/test/e2e/specs/navigation.spec.ts | 1 + 29 files changed, 1130 insertions(+), 8 deletions(-) create mode 100644 app/src/components/flows/FlowListRow.test.tsx create mode 100644 app/src/components/flows/FlowListRow.tsx create mode 100644 app/src/pages/FlowsPage.test.tsx create mode 100644 app/src/pages/FlowsPage.tsx diff --git a/app/src/AppRoutes.tsx b/app/src/AppRoutes.tsx index db11a97c8f..d3d19a7557 100644 --- a/app/src/AppRoutes.tsx +++ b/app/src/AppRoutes.tsx @@ -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. */} + + + + } + /> + {/* Back-compat: /activity and /intelligence → settings notifications page. */} } /> } /> diff --git a/app/src/components/flows/FlowListRow.test.tsx b/app/src/components/flows/FlowListRow.test.tsx new file mode 100644 index 0000000000..22ef0e828b --- /dev/null +++ b/app/src/components/flows/FlowListRow.test.tsx @@ -0,0 +1,134 @@ +/** + * FlowListRow (issue B5a) — one saved-flow row on the Workflows list page. + * Asserts the name/status rendering, the last-run/never-run text, and that + * each control (toggle, Run, View runs) calls back with the row's `Flow`. + */ +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 { + 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( + + ); + + expect(screen.getByText('Daily digest')).toBeInTheDocument(); + expect(screen.getByTestId('flow-status-flow-1')).toHaveTextContent('Enabled'); + }); + + it('renders a Paused badge when disabled', () => { + renderWithProviders( + + ); + + expect(screen.getByTestId('flow-status-flow-1')).toHaveTextContent('Paused'); + }); + + it('shows "Never run" when the flow has no last_run_at', () => { + renderWithProviders( + + ); + + expect(screen.getByText('Never run')).toBeInTheDocument(); + }); + + it('shows the capitalized status and a relative time when the flow has run', () => { + renderWithProviders( + + ); + + expect(screen.getByText(/Completed/)).toBeInTheDocument(); + }); + + it('calls onToggle with the flow when the switch is clicked', () => { + const onToggle = vi.fn(); + renderWithProviders( + + ); + + 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( + + ); + + fireEvent.click(screen.getByTestId('flow-run-flow-1')); + + expect(onRun).toHaveBeenCalledWith(makeFlow()); + }); + + it('shows the running label and disables Run while busy', () => { + renderWithProviders( + + ); + + const runButton = screen.getByTestId('flow-run-flow-1'); + expect(runButton).toHaveTextContent('Running…'); + expect(runButton).toBeDisabled(); + }); + + it('disables the toggle while busy=toggle', () => { + renderWithProviders( + + ); + + expect(screen.getByTestId('flow-toggle-flow-1')).toBeDisabled(); + }); + + it('calls onViewRuns with the flow when "View runs" is clicked', () => { + const onViewRuns = vi.fn(); + renderWithProviders( + + ); + + fireEvent.click(screen.getByTestId('flow-view-runs-flow-1')); + + expect(onViewRuns).toHaveBeenCalledWith(makeFlow()); + }); +}); diff --git a/app/src/components/flows/FlowListRow.tsx b/app/src/components/flows/FlowListRow.tsx new file mode 100644 index 0000000000..fa8facdbeb --- /dev/null +++ b/app/src/components/flows/FlowListRow.tsx @@ -0,0 +1,110 @@ +/** + * 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. + */ +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; + +export interface FlowListRowProps { + flow: Flow; + onToggle: (flow: Flow) => void; + onRun: (flow: Flow) => void; + onViewRuns: (flow: Flow) => void; + busy?: FlowListRowBusy; +} + +/** + * Formats the "last run" line. `t()` doesn't interpolate, so the relative + * time is computed here rather than templated through an i18n key (per the + * B5a spec). `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 relativeTime(iso: string): string { + const ms = Date.now() - new Date(iso).getTime(); + const mins = Math.floor(ms / 60000); + if (mins < 1) return 'just now'; + if (mins < 60) return `${mins}m ago`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return `${hrs}h ago`; + const days = Math.floor(hrs / 24); + return `${days}d ago`; +} + +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 { 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('flows.list.neverRun'); + + return ( +
+
+
+
{flow.name}
+
{lastRunLabel}
+
+ + {flow.enabled ? t('flows.list.enabled') : t('flows.list.paused')} + +
+ +
+ onToggle(flow)} + /> + + +
+
+ ); +}; + +export default FlowListRow; diff --git a/app/src/components/layout/shell/CollapsedNavRail.test.tsx b/app/src/components/layout/shell/CollapsedNavRail.test.tsx index fec8daaa54..b9d10b6120 100644 --- a/app/src/components/layout/shell/CollapsedNavRail.test.tsx +++ b/app/src/components/layout/shell/CollapsedNavRail.test.tsx @@ -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(, { initialEntries: ['/flows'] }); + expect(screen.getByRole('button', { name: 'nav.flows' })).toHaveAttribute( + 'aria-current', + 'page' + ); + }); + + it('marks Workflows active on a nested /flows/* sub-route', () => { + renderWithProviders(, { 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(, { initialEntries: ['/home'] }); const settings = screen.getByRole('button', { name: 'nav.settings' }); diff --git a/app/src/components/layout/shell/CollapsedNavRail.tsx b/app/src/components/layout/shell/CollapsedNavRail.tsx index d4ae75cce2..2f045f8ee3 100644 --- a/app/src/components/layout/shell/CollapsedNavRail.tsx +++ b/app/src/components/layout/shell/CollapsedNavRail.tsx @@ -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; } diff --git a/app/src/components/layout/shell/SidebarNav.test.tsx b/app/src/components/layout/shell/SidebarNav.test.tsx index cab802c0fa..790e8289a3 100644 --- a/app/src/components/layout/shell/SidebarNav.test.tsx +++ b/app/src/components/layout/shell/SidebarNav.test.tsx @@ -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(, { initialEntries: ['/flows'] }); + + expect(tabButton('Workflows')).toHaveAttribute('aria-current', 'page'); + }); + + it('keeps Workflows active on a nested /flows/* sub-route', () => { + renderWithProviders(, { initialEntries: ['/flows/some-flow-id'] }); + + expect(tabButton('Workflows')).toHaveAttribute('aria-current', 'page'); + }); + + it('does not mark Workflows active on an unrelated route', () => { + renderWithProviders(, { 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(, { initialEntries: ['/chat'] }); diff --git a/app/src/components/layout/shell/SidebarNav.tsx b/app/src/components/layout/shell/SidebarNav.tsx index a9e0fcd06f..89d8ec26bd 100644 --- a/app/src/components/layout/shell/SidebarNav.tsx +++ b/app/src/components/layout/shell/SidebarNav.tsx @@ -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; } diff --git a/app/src/components/layout/shell/navIcons.tsx b/app/src/components/layout/shell/navIcons.tsx index ef8fd25a0c..565b5d9206 100644 --- a/app/src/components/layout/shell/navIcons.tsx +++ b/app/src/components/layout/shell/navIcons.tsx @@ -102,6 +102,22 @@ export function NavIcon({ id, className = 'w-5 h-5' }: NavIconProps) { /> ); + case 'flows': + // Three connected nodes — a saved automation graph, matching the + // Workflows list page's empty-state glyph (FlowsPage.tsx). + return ( + + + + + + + ); case 'agent-world': // Globe/network glyph — represents the A2A agent social network. return ( diff --git a/app/src/config/__tests__/navConfig.test.ts b/app/src/config/__tests__/navConfig.test.ts index 8c7783b7da..0a20263b51 100644 --- a/app/src/config/__tests__/navConfig.test.ts +++ b/app/src/config/__tests__/navConfig.test.ts @@ -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', ]); diff --git a/app/src/config/navConfig.ts b/app/src/config/navConfig.ts index 843308926e..ff47ca08e7 100644 --- a/app/src/config/navConfig.ts +++ b/app/src/config/navConfig.ts @@ -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', diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index bd4b321da8..620c2a1bcc 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -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,24 @@ 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.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. يرجى المحاولة مرة أخرى.', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 900a333367..865c693360 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -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,24 @@ 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.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 পুনর্নির্দেশনা সম্পন্ন করেনি। অনুগ্রহ করে আবার চেষ্টা করুন।', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 2511104912..1185d85d2c 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -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,25 @@ 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.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.', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 67cc477814..226795e5ad 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -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,29 @@ 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.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.', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 9932d82e76..03252aacf3 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -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,25 @@ 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.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.', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index 0cf916c152..3fb12572f4 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -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,25 @@ 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.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.", diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index 2e1eea58a5..0c65b0109a 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -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,23 @@ 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.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 पुनर्निर्देशन पूरा नहीं किया। कृपया पुनः प्रयास करें।', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 52d3648c58..af8b5e8210 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -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,24 @@ 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.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.', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index cc2db3fd7b..b251f77c95 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -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,24 @@ 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.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.', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index bac9448dcf..2f4b59183f 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -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,23 @@ 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.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 리디렉션을 완료하지 못했습니다. 다시 시도해 주세요.', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index 7dec11c446..c06c52e00b 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -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,25 @@ 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.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.', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 5851ee2191..c93cf297ea 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -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,24 @@ 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.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.', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index e122c6b4db..64a0d40400 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -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,25 @@ 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.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. Пожалуйста, попробуйте снова.', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index 2048e579ec..96effda9e5 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -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,23 @@ 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.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': '继续使用', diff --git a/app/src/pages/FlowsPage.test.tsx b/app/src/pages/FlowsPage.test.tsx new file mode 100644 index 0000000000..806bcf0d5a --- /dev/null +++ b/app/src/pages/FlowsPage.test.tsx @@ -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 { + 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(); + + expect(screen.getByText('Loading workflows…')).toBeInTheDocument(); + }); + + it('shows the empty state when there are no saved flows', async () => { + listFlows.mockResolvedValue([]); + renderWithProviders(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + }); +}); diff --git a/app/src/pages/FlowsPage.tsx b/app/src/pages/FlowsPage.tsx new file mode 100644 index 0000000000..a2ccde2c95 --- /dev/null +++ b/app/src/pages/FlowsPage.tsx @@ -0,0 +1,207 @@ +/** + * 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([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [busyKey, setBusyKey] = useState(null); + const [toasts, setToasts] = useState([]); + + // === B3b integration (wire after PR #4450 merges) === + // The run inspector (FlowRunInspectorDrawer) is keyed by RUN id + // (runId === thread_id), NOT flowId — so this wires as: list the flow's + // runs via listFlowRuns(flowId) → open the inspector for a chosen run. + const [selectedFlowId, setSelectedFlowId] = useState(null); + + const addToast = useCallback((toast: Omit) => { + 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 handleViewRuns = useCallback((flow: Flow) => { + log('view runs: id=%s', flow.id); + setSelectedFlowId(flow.id); + }, []); + + // `selectedFlowId` has no UI consumer yet — the B3b inspector wiring below + // is intentionally left commented out until PR #4450 merges. Log it in the + // meantime so "View runs" is observably doing *something* and the state + // isn't flagged as dead by `noUnusedLocals`. + useEffect(() => { + if (selectedFlowId) { + log('selected flow pending B3b run-inspector wiring: id=%s', selectedFlowId); + } + }, [selectedFlowId]); + + const busyFor = (flow: Flow): FlowListRowBusy => { + if (busyKey === `toggle:${flow.id}`) return 'toggle'; + if (busyKey === `run:${flow.id}`) return 'run'; + return null; + }; + + return ( + +
+ {error && ( +
+ +
+ )} + + {loading && } + + {!loading && flows.length === 0 && !error && ( + + + + + + + } + title={t('flows.page.emptyTitle')} + description={t('flows.page.emptyDescription')} + /> + )} + + {!loading && flows.length > 0 && ( +
+ {flows.map(flow => ( + void handleToggle(f)} + onRun={f => void handleRun(f)} + onViewRuns={handleViewRuns} + /> + ))} +
+ )} + + {/* === B3b integration (wire after PR #4450 merges) === + The run inspector (FlowRunInspectorDrawer) is keyed by RUN id + (runId === thread_id), NOT flowId — so this wires as: list the + flow's runs via listFlowRuns(flowId) → open the inspector for a + chosen run. + {selectedFlowId && ( + setSelectedFlowId(null)} + /> + )} */} +
+ + +
+ ); +} diff --git a/app/src/services/api/flowsApi.test.ts b/app/src/services/api/flowsApi.test.ts index fc856b4aca..73a16d4bed 100644 --- a/app/src/services/api/flowsApi.test.ts +++ b/app/src/services/api/flowsApi.test.ts @@ -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'); + }); + }); }); diff --git a/app/src/services/api/flowsApi.ts b/app/src/services/api/flowsApi.ts index 57338206c2..58b13fa5d0 100644 --- a/app/src/services/api/flowsApi.ts +++ b/app/src/services/api/flowsApi.ts @@ -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 { 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` + * straight through `RpcOutcome::single_log`). + */ +export async function listFlows(): Promise { + log('listFlows: request'); + const response = await callCoreRpc({ method: 'openhuman.flows_list', params: {} }); + const flows = unwrapCliEnvelope(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 { + log('setFlowEnabled: request id=%s enabled=%s', id, enabled); + const response = await callCoreRpc({ + method: 'openhuman.flows_set_enabled', + params: { id, enabled }, + }); + const flow = unwrapCliEnvelope(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 { + log('runFlow: request id=%s', id); + const response = await callCoreRpc({ + method: 'openhuman.flows_run', + params: { id, input: input ?? null }, + timeoutMs: FLOW_RESUME_TIMEOUT_MS, + }); + const result = unwrapCliEnvelope(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; diff --git a/app/test/e2e/specs/navigation.spec.ts b/app/test/e2e/specs/navigation.spec.ts index 27f20827cf..5448f6c8b3 100644 --- a/app/test/e2e/specs/navigation.spec.ts +++ b/app/test/e2e/specs/navigation.spec.ts @@ -44,6 +44,7 @@ const ROUTES: Route[] = [ { hash: '/rewards' }, { hash: '/settings' }, { hash: '/agent-world' }, + { hash: '/flows' }, ]; async function rootTextLength(): Promise { From 8d7fcfd8cfe7127f135bd462dd70a35c5aae2009 Mon Sep 17 00:00:00 2001 From: "cyrus@tinyhumans.ai" Date: Sat, 4 Jul 2026 01:42:57 +0530 Subject: [PATCH 2/2] =?UTF-8?q?fix(flows):=20B5a=20review=20=E2=80=94=20lo?= =?UTF-8?q?calize=20relative=20time,=20drop=20dead=20View-runs=20button?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FlowListRow relativeTime() now routes through useT() with flows.list.{justNow, minutesAgo,hoursAgo,daysAgo} (real translations x14, {count} interpolation via .replace) instead of hardcoded English. [Codex + CodeRabbit] - Removed the 'View runs' button + onViewRuns wiring — it was a dead action with no consumer until B3b's inspector; the B3b integration note is preserved for the post-#4450 rebase. [Codex] - prettier: FlowApprovalCard.test.tsx (pre-existing dirty on main). typecheck/lint/prettier(repo-wide) clean; 46+8 tests pass; i18n parity clean. --- app/src/components/flows/FlowListRow.test.tsx | 98 +++++++++++-------- app/src/components/flows/FlowListRow.tsx | 44 +++++---- app/src/lib/i18n/ar.ts | 4 + app/src/lib/i18n/bn.ts | 4 + app/src/lib/i18n/de.ts | 4 + app/src/lib/i18n/en.ts | 4 + app/src/lib/i18n/es.ts | 4 + app/src/lib/i18n/fr.ts | 4 + app/src/lib/i18n/hi.ts | 4 + app/src/lib/i18n/id.ts | 4 + app/src/lib/i18n/it.ts | 4 + app/src/lib/i18n/ko.ts | 4 + app/src/lib/i18n/pl.ts | 4 + app/src/lib/i18n/pt.ts | 4 + app/src/lib/i18n/ru.ts | 4 + app/src/lib/i18n/zh-CN.ts | 4 + app/src/pages/FlowsPage.tsx | 33 ++----- 17 files changed, 141 insertions(+), 90 deletions(-) diff --git a/app/src/components/flows/FlowListRow.test.tsx b/app/src/components/flows/FlowListRow.test.tsx index 22ef0e828b..31451b4261 100644 --- a/app/src/components/flows/FlowListRow.test.tsx +++ b/app/src/components/flows/FlowListRow.test.tsx @@ -1,7 +1,10 @@ /** * FlowListRow (issue B5a) — one saved-flow row on the Workflows list page. - * Asserts the name/status rendering, the last-run/never-run text, and that - * each control (toggle, Run, View runs) calls back with the row's `Flow`. + * 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'; @@ -27,54 +30,81 @@ function makeFlow(overrides: Partial = {}): Flow { describe('FlowListRow', () => { it('renders the flow name and an Enabled badge when enabled', () => { - renderWithProviders( - - ); + renderWithProviders(); expect(screen.getByText('Daily digest')).toBeInTheDocument(); expect(screen.getByTestId('flow-status-flow-1')).toHaveTextContent('Enabled'); }); it('renders a Paused badge when disabled', () => { + renderWithProviders( + + ); + + expect(screen.getByTestId('flow-status-flow-1')).toHaveTextContent('Paused'); + }); + + it('shows "Never run" when the flow has no last_run_at', () => { + renderWithProviders(); + + expect(screen.getByText('Never run')).toBeInTheDocument(); + }); + + it('shows the capitalized status and "Just now" for a run seconds ago', () => { renderWithProviders( ); - expect(screen.getByTestId('flow-status-flow-1')).toHaveTextContent('Paused'); + expect(screen.getByText('Completed · Just now')).toBeInTheDocument(); }); - it('shows "Never run" when the flow has no last_run_at', () => { + it('shows a minutes-ago relative time', () => { + const fiveMinAgo = new Date(Date.now() - 5 * 60_000).toISOString(); renderWithProviders( - + ); - expect(screen.getByText('Never run')).toBeInTheDocument(); + expect(screen.getByText('Completed · 5m ago')).toBeInTheDocument(); }); - it('shows the capitalized status and a relative time when the flow has run', () => { + it('shows an hours-ago relative time', () => { + const threeHoursAgo = new Date(Date.now() - 3 * 60 * 60_000).toISOString(); renderWithProviders( ); - expect(screen.getByText(/Completed/)).toBeInTheDocument(); + expect(screen.getByText('Failed · 3h ago')).toBeInTheDocument(); }); - it('calls onToggle with the flow when the switch is clicked', () => { - const onToggle = vi.fn(); + it('shows a days-ago relative time', () => { + const twoDaysAgo = new Date(Date.now() - 2 * 24 * 60 * 60_000).toISOString(); renderWithProviders( - + ); + expect(screen.getByText('Pending_approval · 2d ago')).toBeInTheDocument(); + }); + + it('calls onToggle with the flow when the switch is clicked', () => { + const onToggle = vi.fn(); + renderWithProviders(); + fireEvent.click(screen.getByTestId('flow-toggle-flow-1')); expect(onToggle).toHaveBeenCalledWith(makeFlow()); @@ -82,9 +112,7 @@ describe('FlowListRow', () => { it('calls onRun with the flow when the Run button is clicked', () => { const onRun = vi.fn(); - renderWithProviders( - - ); + renderWithProviders(); fireEvent.click(screen.getByTestId('flow-run-flow-1')); @@ -93,13 +121,7 @@ describe('FlowListRow', () => { it('shows the running label and disables Run while busy', () => { renderWithProviders( - + ); const runButton = screen.getByTestId('flow-run-flow-1'); @@ -109,26 +131,16 @@ describe('FlowListRow', () => { it('disables the toggle while busy=toggle', () => { renderWithProviders( - + ); expect(screen.getByTestId('flow-toggle-flow-1')).toBeDisabled(); }); - it('calls onViewRuns with the flow when "View runs" is clicked', () => { - const onViewRuns = vi.fn(); - renderWithProviders( - - ); - - fireEvent.click(screen.getByTestId('flow-view-runs-flow-1')); + it('does not render a "View runs" control', () => { + renderWithProviders(); - expect(onViewRuns).toHaveBeenCalledWith(makeFlow()); + expect(screen.queryByTestId('flow-view-runs-flow-1')).not.toBeInTheDocument(); + expect(screen.queryByText('View runs')).not.toBeInTheDocument(); }); }); diff --git a/app/src/components/flows/FlowListRow.tsx b/app/src/components/flows/FlowListRow.tsx index fa8facdbeb..691ff962ff 100644 --- a/app/src/components/flows/FlowListRow.tsx +++ b/app/src/components/flows/FlowListRow.tsx @@ -7,6 +7,11 @@ * 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'; @@ -16,44 +21,49 @@ 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; - onViewRuns: (flow: Flow) => void; busy?: FlowListRowBusy; } /** - * Formats the "last run" line. `t()` doesn't interpolate, so the relative - * time is computed here rather than templated through an i18n key (per the - * B5a spec). `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. + * 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): string { +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 'just now'; - if (mins < 60) return `${mins}m ago`; + 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 `${hrs}h ago`; + if (hrs < 24) return t('flows.list.hoursAgo').replace('{count}', String(hrs)); const days = Math.floor(hrs / 24); - return `${days}d ago`; + 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, onViewRuns, busy = null }: FlowListRowProps) => { +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)}` + ? `${capitalize(flow.last_status)} · ${relativeTime(flow.last_run_at, t)}` : t('flows.list.neverRun'); return ( @@ -94,14 +104,6 @@ const FlowListRow = ({ flow, onToggle, onRun, onViewRuns, busy = null }: FlowLis onClick={() => onRun(flow)}> {runBusy ? t('flows.list.running') : t('flows.list.runNow')} - ); diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 620c2a1bcc..afca73c856 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -3576,6 +3576,10 @@ const messages: TranslationMap = { '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': 'عرض التشغيلات', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 865c693360..c460c7c548 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -3657,6 +3657,10 @@ const messages: TranslationMap = { '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': 'রান দেখুন', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 1185d85d2c..c485e58f1d 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -3747,6 +3747,10 @@ const messages: TranslationMap = { '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', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 226795e5ad..d4e044a417 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -4301,6 +4301,10 @@ const en: TranslationMap = { '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', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 03252aacf3..0396f8e324 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -3721,6 +3721,10 @@ const messages: TranslationMap = { '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', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index 3fb12572f4..7ca9c7e80a 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -3736,6 +3736,10 @@ const messages: TranslationMap = { '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', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index 0c65b0109a..415b1ecc8f 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -3657,6 +3657,10 @@ const messages: TranslationMap = { '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': 'रन देखें', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index af8b5e8210..f04622da49 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -3665,6 +3665,10 @@ const messages: TranslationMap = { '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', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index b251f77c95..9399512b57 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -3715,6 +3715,10 @@ const messages: TranslationMap = { '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', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 2f4b59183f..56c4d427a1 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -3620,6 +3620,10 @@ const messages: TranslationMap = { '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': '실행 내역 보기', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index c06c52e00b..6a3d2993dc 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -3702,6 +3702,10 @@ const messages: TranslationMap = { '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', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index c93cf297ea..c573eb224d 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -3716,6 +3716,10 @@ const messages: TranslationMap = { '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', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 64a0d40400..0cbd614f20 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -3691,6 +3691,10 @@ const messages: TranslationMap = { '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': 'Просмотреть запуски', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index 96effda9e5..9ae8cad14c 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -3466,6 +3466,10 @@ const messages: TranslationMap = { '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': '查看运行记录', diff --git a/app/src/pages/FlowsPage.tsx b/app/src/pages/FlowsPage.tsx index a2ccde2c95..609065e981 100644 --- a/app/src/pages/FlowsPage.tsx +++ b/app/src/pages/FlowsPage.tsx @@ -36,12 +36,6 @@ export default function FlowsPage() { const [busyKey, setBusyKey] = useState(null); const [toasts, setToasts] = useState([]); - // === B3b integration (wire after PR #4450 merges) === - // The run inspector (FlowRunInspectorDrawer) is keyed by RUN id - // (runId === thread_id), NOT flowId — so this wires as: list the flow's - // runs via listFlowRuns(flowId) → open the inspector for a chosen run. - const [selectedFlowId, setSelectedFlowId] = useState(null); - const addToast = useCallback((toast: Omit) => { setToasts(prev => [...prev, { ...toast, id: `toast-${Date.now()}-${Math.random()}` }]); }, []); @@ -116,21 +110,6 @@ export default function FlowsPage() { [busyKey, addToast, loadFlows, t] ); - const handleViewRuns = useCallback((flow: Flow) => { - log('view runs: id=%s', flow.id); - setSelectedFlowId(flow.id); - }, []); - - // `selectedFlowId` has no UI consumer yet — the B3b inspector wiring below - // is intentionally left commented out until PR #4450 merges. Log it in the - // meantime so "View runs" is observably doing *something* and the state - // isn't flagged as dead by `noUnusedLocals`. - useEffect(() => { - if (selectedFlowId) { - log('selected flow pending B3b run-inspector wiring: id=%s', selectedFlowId); - } - }, [selectedFlowId]); - const busyFor = (flow: Flow): FlowListRowBusy => { if (busyKey === `toggle:${flow.id}`) return 'toggle'; if (busyKey === `run:${flow.id}`) return 'run'; @@ -182,17 +161,19 @@ export default function FlowsPage() { busy={busyFor(flow)} onToggle={f => void handleToggle(f)} onRun={f => void handleRun(f)} - onViewRuns={handleViewRuns} /> ))} )} {/* === B3b integration (wire after PR #4450 merges) === - The run inspector (FlowRunInspectorDrawer) is keyed by RUN id - (runId === thread_id), NOT flowId — so this wires as: list the - flow's runs via listFlowRuns(flowId) → open the inspector for a - chosen run. + "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 && (