diff --git a/app/src/components/EmptyStateCard.tsx b/app/src/components/EmptyStateCard.tsx index 28bd71e36..121cac0aa 100644 --- a/app/src/components/EmptyStateCard.tsx +++ b/app/src/components/EmptyStateCard.tsx @@ -6,6 +6,8 @@ interface EmptyStateCardProps { description: string; actionLabel?: string; onAction?: () => void; + /** `data-testid` for the action button, so callers can target it distinctly from other same-labeled buttons on the page. */ + actionTestId?: string; footer?: ReactNode; className?: string; } @@ -16,6 +18,7 @@ const EmptyStateCard = ({ description, actionLabel, onAction, + actionTestId, footer, className = '', }: EmptyStateCardProps) => { @@ -30,6 +33,7 @@ const EmptyStateCard = ({ {actionLabel && onAction ? ( + + ); + }, +})); + +function makeRun(overrides: Partial = {}): FlowRun { + return { + id: 'run-1', + flow_id: 'flow-1', + thread_id: 'run-1', + status: 'completed', + started_at: '2026-01-01T00:00:00Z', + steps: [], + pending_approvals: [], + ...overrides, + }; +} + +function renderDrawer(flowId: string | null, onClose: () => void, flowName?: string) { + return render( + + + + ); +} + +describe('FlowRunsDrawer', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders null when flowId is null', () => { + const { container } = renderDrawer(null, vi.fn()); + expect(container).toBeEmptyDOMElement(); + expect(listFlowRuns).not.toHaveBeenCalled(); + }); + + it('shows a loading state before the fetch resolves', () => { + listFlowRuns.mockReturnValue(new Promise(() => {})); // never resolves + renderDrawer('flow-1', vi.fn()); + expect(screen.getByTestId('flow-runs-loading')).toBeInTheDocument(); + }); + + it('fetches and lists runs for the flow', async () => { + listFlowRuns.mockResolvedValue([ + makeRun({ id: 'run-1', status: 'completed' }), + makeRun({ id: 'run-2', status: 'failed' }), + ]); + renderDrawer('flow-1', vi.fn(), 'Daily digest'); + + expect(await screen.findByTestId('flow-runs-list')).toBeInTheDocument(); + expect(listFlowRuns).toHaveBeenCalledWith('flow-1'); + expect(screen.getByTestId('flow-run-row-run-1')).toBeInTheDocument(); + expect(screen.getByTestId('flow-run-row-run-2')).toBeInTheDocument(); + expect(screen.getByText('Runs for Daily digest')).toBeInTheDocument(); + }); + + it('falls back to a generic title when no flowName is given', async () => { + listFlowRuns.mockResolvedValue([]); + renderDrawer('flow-1', vi.fn()); + await waitFor(() => expect(screen.getByTestId('flow-runs-empty')).toBeInTheDocument()); + expect(screen.getByText('Workflow runs')).toBeInTheDocument(); + }); + + it('shows an empty state when there are no runs', async () => { + listFlowRuns.mockResolvedValue([]); + renderDrawer('flow-1', vi.fn()); + expect(await screen.findByTestId('flow-runs-empty')).toHaveTextContent('No runs yet'); + }); + + it('shows an error state when the fetch fails', async () => { + listFlowRuns.mockRejectedValue(new Error('core unreachable')); + renderDrawer('flow-1', vi.fn()); + expect(await screen.findByTestId('flow-runs-error')).toHaveTextContent('core unreachable'); + }); + + it('opens the run inspector on top when a run row is clicked', async () => { + listFlowRuns.mockResolvedValue([makeRun({ id: 'run-1' })]); + renderDrawer('flow-1', vi.fn()); + + const row = await screen.findByTestId('flow-run-row-run-1'); + fireEvent.click(row); + + expect(await screen.findByTestId('mock-inspector')).toHaveTextContent('run-1'); + // The runs list stays mounted underneath. + expect(screen.getByTestId('flow-runs-list')).toBeInTheDocument(); + }); + + it('returns to the run list when the inspector closes', async () => { + listFlowRuns.mockResolvedValue([makeRun({ id: 'run-1' })]); + renderDrawer('flow-1', vi.fn()); + + fireEvent.click(await screen.findByTestId('flow-run-row-run-1')); + expect(await screen.findByTestId('mock-inspector')).toBeInTheDocument(); + + fireEvent.click(screen.getByTestId('mock-inspector-close')); + expect(screen.queryByTestId('mock-inspector')).not.toBeInTheDocument(); + expect(screen.getByTestId('flow-runs-list')).toBeInTheDocument(); + }); + + it('calls onClose when the close button is clicked', async () => { + listFlowRuns.mockResolvedValue([]); + const onClose = vi.fn(); + renderDrawer('flow-1', onClose); + await waitFor(() => expect(screen.getByTestId('flow-runs-empty')).toBeInTheDocument()); + + fireEvent.click(screen.getByTestId('flow-runs-close')); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('calls onClose when the backdrop is clicked', async () => { + listFlowRuns.mockResolvedValue([]); + const onClose = vi.fn(); + renderDrawer('flow-1', onClose); + await waitFor(() => expect(screen.getByTestId('flow-runs-empty')).toBeInTheDocument()); + + fireEvent.click(screen.getByTestId('flow-runs-backdrop')); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('calls onClose when Escape is pressed and no run is selected', async () => { + listFlowRuns.mockResolvedValue([]); + const onClose = vi.fn(); + renderDrawer('flow-1', onClose); + await waitFor(() => expect(screen.getByTestId('flow-runs-empty')).toBeInTheDocument()); + + fireEvent.keyDown(document, { key: 'Escape' }); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('does not close the runs drawer on Escape while the inspector is open', async () => { + listFlowRuns.mockResolvedValue([makeRun({ id: 'run-1' })]); + const onClose = vi.fn(); + renderDrawer('flow-1', onClose); + + fireEvent.click(await screen.findByTestId('flow-run-row-run-1')); + expect(await screen.findByTestId('mock-inspector')).toBeInTheDocument(); + + fireEvent.keyDown(document, { key: 'Escape' }); + expect(onClose).not.toHaveBeenCalled(); + }); +}); diff --git a/app/src/components/flows/FlowRunsDrawer.tsx b/app/src/components/flows/FlowRunsDrawer.tsx new file mode 100644 index 000000000..35cf3282f --- /dev/null +++ b/app/src/components/flows/FlowRunsDrawer.tsx @@ -0,0 +1,218 @@ +/** + * FlowRunsDrawer (issue B5a.1) + * ---------------------------- + * + * Right-side drawer listing a flow's run history, opened from the + * "View runs" action on {@link FlowListRow}. Drawer chrome mirrors + * `FlowRunInspectorDrawer`/`SubagentDrawer` (fixed overlay + backdrop-click- + * to-close + Escape-to-close via `useEscapeKey`) so it renders as a fixed + * overlay regardless of where the parent mounts it. + * + * Data is a one-shot fetch via `listFlowRuns` — no polling here. The run + * inspector already polls a single run's live status via `useFlowRunPoller`; + * polling the whole list here would duplicate that logic for no benefit + * (the list only needs to be fresh when the drawer opens). + * + * Clicking a run sets `selectedRunId` and renders the existing + * `FlowRunInspectorDrawer` stacked on top: both are `fixed inset-0 z-50` + * overlays, and the inspector is rendered *after* this drawer's own overlay + * in the JSX, so it paints above it (same stacking context, later DOM wins) + * and its backdrop naturally intercepts clicks meant for the runs list. + * Closing the inspector clears `selectedRunId` and returns to the run list; + * closing this drawer (✕ / backdrop / Escape) calls `onClose`. While the + * inspector is open, this drawer's own Escape handler is disabled so a + * single Escape press closes only the topmost overlay (the inspector) first. + */ +import debug from 'debug'; +import { useEffect, useState } from 'react'; + +import { useEscapeKey } from '../../hooks/useEscapeKey'; +import { useT } from '../../lib/i18n/I18nContext'; +import { type FlowRun, listFlowRuns } from '../../services/api/flowsApi'; +import { + FLOW_RUN_STATUS_ACCENT, + FLOW_RUN_STATUS_DOT, + FLOW_RUN_STATUS_KEY, + FlowRunInspectorDrawer, +} from './FlowRunInspectorDrawer'; + +const log = debug('flows:runs-drawer'); + +function formatTimestamp(value: string | null | undefined): string | null { + if (!value) return null; + const parsed = Date.parse(value); + if (!Number.isFinite(parsed)) return null; + return new Intl.DateTimeFormat(undefined, { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }).format(new Date(parsed)); +} + +interface Props { + /** Flow to list runs for. Renders `null` (nothing) when absent. */ + flowId: string | null; + /** Flow name for the drawer title, when known. */ + flowName?: string; + onClose: () => void; +} + +/** + * Renders `null` when `flowId` is `null` so the parent can mount this + * unconditionally and just flip `flowId` (same convention as + * `FlowRunInspectorDrawer`/`SubagentDrawer`). + */ +export function FlowRunsDrawer({ flowId, flowName, onClose }: Props) { + const { t } = useT(); + const [runs, setRuns] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [selectedRunId, setSelectedRunId] = useState(null); + + useEffect(() => { + // Reset for the new target so a previous flow's runs/error can't linger + // under a different flowId while the new fetch is in flight. + setSelectedRunId(null); + setError(null); + + if (!flowId) { + setRuns([]); + setLoading(false); + return; + } + + let cancelled = false; + setLoading(true); + log('loading runs: flowId=%s', flowId); + listFlowRuns(flowId) + .then(result => { + if (cancelled) return; + setRuns(result); + log('loaded runs: flowId=%s count=%d', flowId, result.length); + }) + .catch(err => { + if (cancelled) return; + const msg = err instanceof Error ? err.message : String(err); + log('load failed: flowId=%s err=%s', flowId, msg); + setError(msg); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [flowId]); + + useEscapeKey( + () => { + log('escape: closing flowId=%s', flowId); + onClose(); + }, + flowId !== null && selectedRunId === null + ); + + if (!flowId) return null; + + const title = flowName + ? t('flows.runs.title').replace('{name}', flowName) + : t('flows.runs.titleFallback'); + + return ( + <> +
+ {/* Backdrop */} + + + +
+ {loading && ( +
+
+ {t('flows.runs.loading')} +
+ )} + + {error && ( +
+ {t('flows.runs.loadError')}: {error} +
+ )} + + {!loading && !error && runs.length === 0 && ( +

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

+ )} + + {!loading && !error && runs.length > 0 && ( +
    + {runs.map(run => { + const startedAt = formatTimestamp(run.started_at); + return ( +
  • + +
  • + ); + })} +
+ )} +
+ +
+ + {selectedRunId && ( + setSelectedRunId(null)} /> + )} + + ); +} + +export default FlowRunsDrawer; diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 0337659fd..29c55d5ee 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -3584,6 +3584,7 @@ const messages: TranslationMap = { 'ستظهر عمليات سير العمل المحفوظة هنا بمجرد إنشاء واحدة من لوحة الرسم.', 'flows.page.loading': 'جارٍ تحميل عمليات سير العمل…', 'flows.page.loadError': 'تعذر تحميل عمليات سير العمل. يرجى المحاولة مرة أخرى.', + 'flows.page.newWorkflow': 'سير عمل جديد', 'flows.list.lastRun': 'آخر تشغيل', 'flows.list.neverRun': 'لم يتم التشغيل بعد', 'flows.list.justNow': 'الآن', @@ -3597,6 +3598,11 @@ const messages: TranslationMap = { 'flows.list.enabled': 'مفعّل', 'flows.list.paused': 'متوقف مؤقتًا', 'flows.list.runStarted': 'بدأ تشغيل سير العمل', + 'flows.runs.title': 'عمليات التشغيل لـ {name}', + 'flows.runs.titleFallback': 'عمليات تشغيل سير العمل', + 'flows.runs.loading': 'جارٍ تحميل عمليات التشغيل…', + 'flows.runs.loadError': 'تعذّر تحميل عمليات التشغيل', + 'flows.runs.empty': 'لا توجد عمليات تشغيل بعد', 'oauth.button.connecting': 'جارٍ الاتصال...', 'oauth.button.loopbackTimeout': diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 4b6ec406c..0748e483e 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -3666,6 +3666,7 @@ const messages: TranslationMap = { 'ক্যানভাস থেকে একটি তৈরি করলে সংরক্ষিত ওয়ার্কফ্লোগুলো এখানে দেখা যাবে।', 'flows.page.loading': 'ওয়ার্কফ্লো লোড হচ্ছে…', 'flows.page.loadError': 'ওয়ার্কফ্লো লোড করা যায়নি। আবার চেষ্টা করুন।', + 'flows.page.newWorkflow': 'নতুন ওয়ার্কফ্লো', 'flows.list.lastRun': 'সর্বশেষ চালানো', 'flows.list.neverRun': 'কখনো চালানো হয়নি', 'flows.list.justNow': 'এইমাত্র', @@ -3679,6 +3680,11 @@ const messages: TranslationMap = { 'flows.list.enabled': 'সক্ষম', 'flows.list.paused': 'বিরতি দেওয়া', 'flows.list.runStarted': 'ওয়ার্কফ্লো শুরু হয়েছে', + 'flows.runs.title': '{name}-এর জন্য রান', + 'flows.runs.titleFallback': 'ওয়ার্কফ্লো রান', + 'flows.runs.loading': 'রান লোড হচ্ছে…', + 'flows.runs.loadError': 'রান লোড করা যায়নি', + 'flows.runs.empty': 'এখনো কোনো রান নেই', 'oauth.button.connecting': 'সংযোগ হচ্ছে...', 'oauth.button.loopbackTimeout': diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index d113fe50d..aa04f3e27 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -3756,6 +3756,7 @@ const messages: TranslationMap = { '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.page.newWorkflow': 'Neuer Workflow', 'flows.list.lastRun': 'Letzter Lauf', 'flows.list.neverRun': 'Noch nie ausgeführt', 'flows.list.justNow': 'Gerade eben', @@ -3769,6 +3770,11 @@ const messages: TranslationMap = { 'flows.list.enabled': 'Aktiviert', 'flows.list.paused': 'Pausiert', 'flows.list.runStarted': 'Workflow gestartet', + 'flows.runs.title': 'Ausführungen für {name}', + 'flows.runs.titleFallback': 'Workflow-Ausführungen', + 'flows.runs.loading': 'Ausführungen werden geladen…', + 'flows.runs.loadError': 'Ausführungen konnten nicht geladen werden', + 'flows.runs.empty': 'Noch keine Ausführungen', 'oauth.button.connecting': 'Verbinden...', 'oauth.button.loopbackTimeout': diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 982dc686e..e91e41315 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -4309,6 +4309,7 @@ const en: TranslationMap = { '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.page.newWorkflow': 'New workflow', 'flows.list.lastRun': 'Last run', 'flows.list.neverRun': 'Never run', 'flows.list.justNow': 'Just now', @@ -4322,6 +4323,11 @@ const en: TranslationMap = { 'flows.list.enabled': 'Enabled', 'flows.list.paused': 'Paused', 'flows.list.runStarted': 'Workflow started', + 'flows.runs.title': 'Runs for {name}', + 'flows.runs.titleFallback': 'Workflow runs', + 'flows.runs.loading': 'Loading runs…', + 'flows.runs.loadError': 'Could not load runs', + 'flows.runs.empty': 'No runs yet', 'oauth.button.connecting': 'Connecting...', 'oauth.button.loopbackTimeout': diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 001bb6212..300c9f0c0 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -3729,6 +3729,7 @@ const messages: TranslationMap = { '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.page.newWorkflow': 'Nuevo flujo de trabajo', 'flows.list.lastRun': 'Última ejecución', 'flows.list.neverRun': 'Nunca ejecutado', 'flows.list.justNow': 'Justo ahora', @@ -3742,6 +3743,11 @@ const messages: TranslationMap = { 'flows.list.enabled': 'Habilitado', 'flows.list.paused': 'Pausado', 'flows.list.runStarted': 'Flujo de trabajo iniciado', + 'flows.runs.title': 'Ejecuciones de {name}', + 'flows.runs.titleFallback': 'Ejecuciones del flujo de trabajo', + 'flows.runs.loading': 'Cargando ejecuciones…', + 'flows.runs.loadError': 'No se pudieron cargar las ejecuciones', + 'flows.runs.empty': 'Aún no hay ejecuciones', 'oauth.button.connecting': 'Conectando...', 'oauth.button.loopbackTimeout': diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index 088ffb856..58e067cee 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -3744,6 +3744,7 @@ const messages: TranslationMap = { '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.page.newWorkflow': 'Nouveau workflow', 'flows.list.lastRun': 'Dernière exécution', 'flows.list.neverRun': 'Jamais exécuté', 'flows.list.justNow': "À l'instant", @@ -3757,6 +3758,11 @@ const messages: TranslationMap = { 'flows.list.enabled': 'Activé', 'flows.list.paused': 'En pause', 'flows.list.runStarted': 'Workflow démarré', + 'flows.runs.title': 'Exécutions de {name}', + 'flows.runs.titleFallback': 'Exécutions du workflow', + 'flows.runs.loading': 'Chargement des exécutions…', + 'flows.runs.loadError': 'Impossible de charger les exécutions', + 'flows.runs.empty': 'Aucune exécution pour le moment', 'oauth.button.connecting': 'Connexion en cours…', 'oauth.button.loopbackTimeout': diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index bbab4ff65..8d264b383 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -3665,6 +3665,7 @@ const messages: TranslationMap = { 'flows.page.emptyDescription': 'कैनवास से एक बनाने के बाद सहेजे गए वर्कफ़्लो यहां दिखाई देंगे।', 'flows.page.loading': 'वर्कफ़्लो लोड हो रहे हैं…', 'flows.page.loadError': 'वर्कफ़्लो लोड नहीं हो सके। कृपया फिर से प्रयास करें।', + 'flows.page.newWorkflow': 'नया वर्कफ़्लो', 'flows.list.lastRun': 'अंतिम रन', 'flows.list.neverRun': 'कभी नहीं चला', 'flows.list.justNow': 'अभी अभी', @@ -3678,6 +3679,11 @@ const messages: TranslationMap = { 'flows.list.enabled': 'सक्षम', 'flows.list.paused': 'रोका गया', 'flows.list.runStarted': 'वर्कफ़्लो शुरू हुआ', + 'flows.runs.title': '{name} के लिए रन', + 'flows.runs.titleFallback': 'वर्कफ़्लो रन', + 'flows.runs.loading': 'रन लोड हो रहे हैं…', + 'flows.runs.loadError': 'रन लोड नहीं हो सके', + 'flows.runs.empty': 'अभी तक कोई रन नहीं', 'oauth.button.connecting': 'कनेक्ट हो रहा है...', 'oauth.button.loopbackTimeout': diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 3cffce023..767198b51 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -3674,6 +3674,7 @@ const messages: TranslationMap = { '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.page.newWorkflow': 'Alur Kerja Baru', 'flows.list.lastRun': 'Terakhir dijalankan', 'flows.list.neverRun': 'Belum pernah dijalankan', 'flows.list.justNow': 'Baru saja', @@ -3687,6 +3688,11 @@ const messages: TranslationMap = { 'flows.list.enabled': 'Aktif', 'flows.list.paused': 'Dijeda', 'flows.list.runStarted': 'Alur kerja dimulai', + 'flows.runs.title': 'Proses untuk {name}', + 'flows.runs.titleFallback': 'Proses alur kerja', + 'flows.runs.loading': 'Memuat proses…', + 'flows.runs.loadError': 'Tidak dapat memuat proses', + 'flows.runs.empty': 'Belum ada proses', 'oauth.button.connecting': 'Menghubungkan...', 'oauth.button.loopbackTimeout': diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 91d7b02f8..cfb3869ab 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -3723,6 +3723,7 @@ const messages: TranslationMap = { '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.page.newWorkflow': 'Nuovo flusso di lavoro', 'flows.list.lastRun': 'Ultima esecuzione', 'flows.list.neverRun': 'Mai eseguito', 'flows.list.justNow': 'Proprio ora', @@ -3736,6 +3737,11 @@ const messages: TranslationMap = { 'flows.list.enabled': 'Abilitato', 'flows.list.paused': 'In pausa', 'flows.list.runStarted': 'Flusso di lavoro avviato', + 'flows.runs.title': 'Esecuzioni di {name}', + 'flows.runs.titleFallback': 'Esecuzioni del flusso di lavoro', + 'flows.runs.loading': 'Caricamento esecuzioni…', + 'flows.runs.loadError': 'Impossibile caricare le esecuzioni', + 'flows.runs.empty': 'Ancora nessuna esecuzione', 'oauth.button.connecting': 'Connessione...', 'oauth.button.loopbackTimeout': diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index bef2c9d2a..d428898ce 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -3628,6 +3628,7 @@ const messages: TranslationMap = { 'flows.page.emptyDescription': '캔버스에서 워크플로를 만들면 여기에 표시됩니다.', 'flows.page.loading': '워크플로 로드 중…', 'flows.page.loadError': '워크플로를 불러올 수 없습니다. 다시 시도해 주세요.', + 'flows.page.newWorkflow': '새 워크플로', 'flows.list.lastRun': '마지막 실행', 'flows.list.neverRun': '실행된 적 없음', 'flows.list.justNow': '방금', @@ -3641,6 +3642,11 @@ const messages: TranslationMap = { 'flows.list.enabled': '활성화됨', 'flows.list.paused': '일시 중지됨', 'flows.list.runStarted': '워크플로가 시작되었습니다', + 'flows.runs.title': '{name}의 실행 기록', + 'flows.runs.titleFallback': '워크플로 실행 기록', + 'flows.runs.loading': '실행 기록을 불러오는 중…', + 'flows.runs.loadError': '실행 기록을 불러올 수 없습니다', + 'flows.runs.empty': '아직 실행 기록이 없습니다', 'oauth.button.connecting': '연결 중...', 'oauth.button.loopbackTimeout': diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index d8b8dcd54..a1e606f7f 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -3710,6 +3710,7 @@ const messages: TranslationMap = { '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.page.newWorkflow': 'Nowy przepływ pracy', 'flows.list.lastRun': 'Ostatnie uruchomienie', 'flows.list.neverRun': 'Nigdy nie uruchomiono', 'flows.list.justNow': 'Przed chwilą', @@ -3723,6 +3724,11 @@ const messages: TranslationMap = { 'flows.list.enabled': 'Włączony', 'flows.list.paused': 'Wstrzymany', 'flows.list.runStarted': 'Przepływ pracy uruchomiony', + 'flows.runs.title': 'Przebiegi dla {name}', + 'flows.runs.titleFallback': 'Przebiegi przepływu pracy', + 'flows.runs.loading': 'Ładowanie przebiegów…', + 'flows.runs.loadError': 'Nie udało się załadować przebiegów', + 'flows.runs.empty': 'Brak przebiegów', 'oauth.button.connecting': 'Łączenie...', 'oauth.button.loopbackTimeout': diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 7cba653fa..2dbe353d8 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -3724,6 +3724,7 @@ const messages: TranslationMap = { '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.page.newWorkflow': 'Novo fluxo de trabalho', 'flows.list.lastRun': 'Última execução', 'flows.list.neverRun': 'Nunca executado', 'flows.list.justNow': 'Agora mesmo', @@ -3737,6 +3738,11 @@ const messages: TranslationMap = { 'flows.list.enabled': 'Habilitado', 'flows.list.paused': 'Pausado', 'flows.list.runStarted': 'Fluxo de trabalho iniciado', + 'flows.runs.title': 'Execuções de {name}', + 'flows.runs.titleFallback': 'Execuções do fluxo de trabalho', + 'flows.runs.loading': 'Carregando execuções…', + 'flows.runs.loadError': 'Não foi possível carregar as execuções', + 'flows.runs.empty': 'Ainda não há execuções', 'oauth.button.connecting': 'Conectando...', 'oauth.button.loopbackTimeout': diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 7f23da2d0..aca00920f 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -3699,6 +3699,7 @@ const messages: TranslationMap = { 'Сохранённые рабочие процессы появятся здесь, как только вы создадите один на холсте.', 'flows.page.loading': 'Загрузка рабочих процессов…', 'flows.page.loadError': 'Не удалось загрузить рабочие процессы. Попробуйте снова.', + 'flows.page.newWorkflow': 'Новый рабочий процесс', 'flows.list.lastRun': 'Последний запуск', 'flows.list.neverRun': 'Ещё не запускался', 'flows.list.justNow': 'Только что', @@ -3712,6 +3713,11 @@ const messages: TranslationMap = { 'flows.list.enabled': 'Включён', 'flows.list.paused': 'Приостановлен', 'flows.list.runStarted': 'Рабочий процесс запущен', + 'flows.runs.title': 'Запуски для {name}', + 'flows.runs.titleFallback': 'Запуски рабочего процесса', + 'flows.runs.loading': 'Загрузка запусков…', + 'flows.runs.loadError': 'Не удалось загрузить запуски', + 'flows.runs.empty': 'Пока нет запусков', 'oauth.button.connecting': 'Подключение...', 'oauth.button.loopbackTimeout': diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index 748316b1a..701567c35 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -3474,6 +3474,7 @@ const messages: TranslationMap = { 'flows.page.emptyDescription': '在画布中创建工作流后,将显示在此处。', 'flows.page.loading': '正在加载工作流…', 'flows.page.loadError': '无法加载工作流,请重试。', + 'flows.page.newWorkflow': '新建工作流', 'flows.list.lastRun': '上次运行', 'flows.list.neverRun': '从未运行', 'flows.list.justNow': '刚刚', @@ -3487,6 +3488,11 @@ const messages: TranslationMap = { 'flows.list.enabled': '已启用', 'flows.list.paused': '已暂停', 'flows.list.runStarted': '工作流已启动', + 'flows.runs.title': '{name} 的运行记录', + 'flows.runs.titleFallback': '工作流运行记录', + 'flows.runs.loading': '正在加载运行记录…', + 'flows.runs.loadError': '无法加载运行记录', + 'flows.runs.empty': '暂无运行记录', 'oauth.button.connecting': '连接中...', 'oauth.button.loopbackTimeout': '登录超时 — 浏览器未完成 OAuth 跳转。请重试。', diff --git a/app/src/pages/FlowsPage.test.tsx b/app/src/pages/FlowsPage.test.tsx index 806bcf0d5..ba8378367 100644 --- a/app/src/pages/FlowsPage.test.tsx +++ b/app/src/pages/FlowsPage.test.tsx @@ -1,8 +1,11 @@ /** - * FlowsPage (issue B5a) — the Workflows list page. Asserts the + * FlowsPage (issue B5a / B5a.1) — 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. + * `setFlowEnabled` and refreshes the row, that Run fires `runFlow`, shows a + * "Workflow started" toast, and refetches the list, that "View runs" opens + * `FlowRunsDrawer` for the clicked flow, and that "New workflow" (header + + * empty state) navigates to Chat (no canvas builder yet — bridges to B4's + * agent-proposal flow). */ import { fireEvent, screen, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -14,7 +17,14 @@ 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 })); +const listFlowRuns = vi.hoisted(() => vi.fn()); +vi.mock('../services/api/flowsApi', () => ({ listFlows, setFlowEnabled, runFlow, listFlowRuns })); + +const mockNavigate = vi.hoisted(() => vi.fn()); +vi.mock('react-router-dom', async importOriginal => { + const actual = await importOriginal(); + return { ...actual, useNavigate: () => mockNavigate }; +}); function makeFlow(overrides: Partial = {}): Flow { return { @@ -43,13 +53,14 @@ describe('FlowsPage', () => { expect(screen.getByText('Loading workflows…')).toBeInTheDocument(); }); - it('shows the empty state when there are no saved flows', async () => { + it('shows the empty state when there are no saved flows, with a "New workflow" action', 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(); + // There's no canvas builder yet (B5b) — the empty state's action bridges + // to Chat/B4 instead, same as the header button. + expect(screen.getByTestId('flows-empty-new-workflow')).toHaveTextContent('New workflow'); }); it('shows an error banner when the fetch fails', async () => { @@ -108,4 +119,41 @@ describe('FlowsPage', () => { await waitFor(() => expect(screen.getByText('flow disabled')).toBeInTheDocument()); expect(screen.queryByText('Workflow started')).not.toBeInTheDocument(); }); + + it('opens the run-history drawer for the clicked flow when "View runs" is clicked', async () => { + listFlows.mockResolvedValue([makeFlow()]); + listFlowRuns.mockResolvedValue([]); + renderWithProviders(); + + await waitFor(() => expect(screen.getByTestId('flow-view-runs-flow-1')).toBeInTheDocument()); + fireEvent.click(screen.getByTestId('flow-view-runs-flow-1')); + + expect(await screen.findByTestId('flow-runs-drawer')).toBeInTheDocument(); + expect(screen.getByText('Runs for Daily digest')).toBeInTheDocument(); + expect(listFlowRuns).toHaveBeenCalledWith('flow-1'); + + fireEvent.click(screen.getByTestId('flow-runs-close')); + expect(screen.queryByTestId('flow-runs-drawer')).not.toBeInTheDocument(); + }); + + it('renders a "New workflow" header button and navigates to /chat when clicked', async () => { + listFlows.mockResolvedValue([makeFlow()]); + renderWithProviders(); + + const newWorkflowButton = await screen.findByTestId('flows-new-workflow'); + expect(newWorkflowButton).toHaveTextContent('New workflow'); + fireEvent.click(newWorkflowButton); + + expect(mockNavigate).toHaveBeenCalledWith('/chat'); + }); + + it('navigates to /chat when the empty-state "New workflow" action is clicked', async () => { + listFlows.mockResolvedValue([]); + renderWithProviders(); + + const emptyStateButton = await screen.findByTestId('flows-empty-new-workflow'); + fireEvent.click(emptyStateButton); + + expect(mockNavigate).toHaveBeenCalledWith('/chat'); + }); }); diff --git a/app/src/pages/FlowsPage.tsx b/app/src/pages/FlowsPage.tsx index 609065e98..013794ecd 100644 --- a/app/src/pages/FlowsPage.tsx +++ b/app/src/pages/FlowsPage.tsx @@ -3,17 +3,20 @@ * * 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`). + * canvas (B5b ships flow authoring/editing) — until it lands, "New workflow" + * (header + empty-state) bridges to the B4 agent-proposal flow in Chat + * instead, since that's the only way to author a flow today. */ import createDebug from 'debug'; import { useCallback, useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; import EmptyStateCard from '../components/EmptyStateCard'; import FlowListRow, { type FlowListRowBusy } from '../components/flows/FlowListRow'; +import FlowRunsDrawer from '../components/flows/FlowRunsDrawer'; import { ToastContainer } from '../components/intelligence/Toast'; import PanelPage from '../components/layout/PanelPage'; +import Button from '../components/ui/Button'; import { CenteredLoadingState, ErrorBanner } from '../components/ui/LoadingState'; import { useT } from '../lib/i18n/I18nContext'; import { type Flow, listFlows, runFlow, setFlowEnabled } from '../services/api/flowsApi'; @@ -30,11 +33,16 @@ function errorMessage(err: unknown): string { export default function FlowsPage() { const { t } = useT(); + const navigate = useNavigate(); const [flows, setFlows] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [busyKey, setBusyKey] = useState(null); const [toasts, setToasts] = useState([]); + // Flow whose run history is open in `FlowRunsDrawer` (B3b's run inspector + // then stacks on top of that when a specific run is picked). `null` keeps + // the drawer unmounted. + const [selectedFlowId, setSelectedFlowId] = useState(null); const addToast = useCallback((toast: Omit) => { setToasts(prev => [...prev, { ...toast, id: `toast-${Date.now()}-${Math.random()}` }]); @@ -116,11 +124,47 @@ export default function FlowsPage() { return null; }; + const handleViewRuns = useCallback((flow: Flow) => { + log('view runs: id=%s', flow.id); + setSelectedFlowId(flow.id); + }, []); + + const selectedFlow = flows.find(f => f.id === selectedFlowId) ?? null; + + /** + * "New workflow" (there's no canvas builder yet — B5b) bridges to Chat so + * the user can kick off B4's agent-proposal flow instead. There's no + * existing mechanism to prefill or auto-send an initial composer message + * from outside the Chat page — `Conversations.tsx` only reads + * `location.state.openThreadId` (to reopen a thread), and the composer's + * text is local `useState` with no Redux draft slice. This is the same gap + * `ActionItemChecklist.tsx`'s "Run with OpenHuman" button already hit, so + * we follow its precedent: navigate to `/chat` with no prefill rather than + * build new prefill plumbing from scratch. + */ + const handleNewWorkflow = useCallback(() => { + log('new workflow: navigating to chat'); + // TODO: prefill the chat composer with a workflow-building prompt once a + // draft/initial-message API exists (see ActionItemChecklist.tsx's + // identical TODO for the same gap). + navigate('/chat'); + }, [navigate]); + return ( + description={t('flows.page.description')} + action={ + + }>
{error && (
@@ -147,6 +191,9 @@ export default function FlowsPage() { } title={t('flows.page.emptyTitle')} description={t('flows.page.emptyDescription')} + actionLabel={t('flows.page.newWorkflow')} + actionTestId="flows-empty-new-workflow" + onAction={handleNewWorkflow} /> )} @@ -161,27 +208,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) === - "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 && ( - setSelectedFlowId(null)} - /> - )} */}
+ setSelectedFlowId(null)} + /> +
);