diff --git a/app/src/components/flows/FlowListRow.test.tsx b/app/src/components/flows/FlowListRow.test.tsx index 05e7c2410..6405143cf 100644 --- a/app/src/components/flows/FlowListRow.test.tsx +++ b/app/src/components/flows/FlowListRow.test.tsx @@ -50,13 +50,13 @@ describe('FlowListRow', () => { it('renders the flow name and reflects enabled state on the toggle', () => { renderRow(); expect(screen.getByText('Daily digest')).toBeInTheDocument(); - // The toggle is an icon button; state is conveyed via aria-pressed, not text. - expect(screen.getByTestId('flow-toggle-flow-1')).toHaveAttribute('aria-pressed', 'true'); + // The toggle is a SettingsSwitch (role=switch); state is conveyed via aria-checked. + expect(screen.getByTestId('flow-toggle-flow-1')).toHaveAttribute('aria-checked', 'true'); }); it('reflects paused state on the toggle when disabled', () => { renderRow({ flow: makeFlow({ enabled: false }) }); - expect(screen.getByTestId('flow-toggle-flow-1')).toHaveAttribute('aria-pressed', 'false'); + expect(screen.getByTestId('flow-toggle-flow-1')).toHaveAttribute('aria-checked', 'false'); }); it('shows "Never run" when the flow has no last_run_at', () => { @@ -147,9 +147,16 @@ describe('FlowListRow', () => { expect(onDuplicate).toHaveBeenCalledWith(makeFlow()); }); - it('deletes via the direct Delete icon (not the menu)', () => { + it('routes Delete through the overflow menu', () => { const { onDelete } = renderRow(); - fireEvent.click(screen.getByTestId('flow-delete-flow-1')); + // Delete is a destructive secondary action now — behind the "⋯" menu, not + // a standalone icon button in the flat row. + expect(screen.queryByTestId('flow-delete-flow-1')).not.toBeInTheDocument(); + fireEvent.click(screen.getByTestId('flow-menu-flow-1')); + + const deleteItem = screen.getByTestId('flow-delete-flow-1'); + expect(deleteItem).toHaveTextContent('Delete'); + fireEvent.click(deleteItem); expect(onDelete).toHaveBeenCalledWith(makeFlow()); }); }); diff --git a/app/src/components/flows/FlowListRow.tsx b/app/src/components/flows/FlowListRow.tsx index ea063788a..b1c6e5dcc 100644 --- a/app/src/components/flows/FlowListRow.tsx +++ b/app/src/components/flows/FlowListRow.tsx @@ -3,14 +3,17 @@ * * 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. + * badge header, a line of run metadata, then a row of `Button` actions. Uses + * the canonical `SettingsSwitch` boolean control (`components/settings/controls`) + * for enable/disable, since that's a persistent setting, not a one-off action — + * not an icon `Button`, so its state reads at a glance instead of needing a + * hover/title to disambiguate on vs. off. * * "View runs" (issue B5a.1) opens `FlowRunsDrawer` (mounted by `FlowsPage`) * for this flow's run history — re-added now that B3b's run inspector has - * landed and the drawer has somewhere to send the user. + * landed and the drawer has somewhere to send the user. Delete lives in the + * same overflow menu (destructive actions shouldn't sit in the flat button + * row next to Run/toggle, where a mis-click is one tap away). * * The flow name (issue B5b.1) is itself the "View" affordance for the new * read-only Workflow Canvas: it's rendered as a button that calls `onView`, @@ -22,6 +25,7 @@ */ import { useT } from '../../lib/i18n/I18nContext'; import type { Flow } from '../../services/api/flowsApi'; +import SettingsSwitch from '../settings/controls/SettingsSwitch'; import Button from '../ui/Button'; import FlowRowMenu from './FlowRowMenu'; @@ -33,41 +37,6 @@ function PlayIcon() { ); } -function PowerIcon() { - // On/off — enabled vs. paused (distinct from Run's play triangle). - return ( - - ); -} - -function TrashIcon() { - return ( - - ); -} - /** Which of this row's actions currently has a request in flight, if any. */ export type FlowListRowBusy = 'toggle' | 'run' | null; @@ -153,27 +122,18 @@ const FlowListRow = ({ {/* All controls sit together on the right: the toggle (enabled/paused — - the switch alone communicates state), then Run, Delete, and an overflow - menu with the secondary actions (view runs / export / duplicate). */} + the switch alone communicates state), then Run, and an overflow menu + with the secondary/destructive actions (view runs / export / + duplicate / delete). */}
- + aria-label={t('flows.list.toggleEnabled')} + data-testid={`flow-toggle-${flow.id}`} + /> - onDuplicate(flow), testId: `flow-duplicate-${flow.id}`, }, + { + key: 'delete', + label: t('flows.list.delete'), + onSelect: () => onDelete(flow), + danger: true, + testId: `flow-delete-${flow.id}`, + }, ]} />
diff --git a/app/src/components/flows/FlowRunsDrawer.test.tsx b/app/src/components/flows/FlowRunsDrawer.test.tsx index ad61f6ee9..43cf8096a 100644 --- a/app/src/components/flows/FlowRunsDrawer.test.tsx +++ b/app/src/components/flows/FlowRunsDrawer.test.tsx @@ -12,7 +12,7 @@ * `FlowRunInspectorDrawer.test.tsx`) so this suite only exercises the * run-history list + the nesting contract between the two drawers. */ -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { Provider } from 'react-redux'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -207,4 +207,80 @@ describe('FlowRunsDrawer', () => { fireEvent.keyDown(document, { key: 'Escape' }); expect(onClose).not.toHaveBeenCalled(); }); + + it('discards a stale background refetch after the drawer flips to a different flow (race guard)', async () => { + // Regression test for a codex review finding on this PR: the live-refresh + // `refetch` in FlowRunsDrawer didn't guard against a response landing + // after the drawer had already switched to a different flowId, so a slow + // flow-A refetch could clobber flow-B's already-rendered runs. + vi.useFakeTimers(); + try { + let flowACalls = 0; + let resolveStaleA: ((runs: FlowRun[]) => void) | undefined; + listFlowRuns.mockImplementation((flowId: string) => { + if (flowId === 'flow-a') { + flowACalls += 1; + if (flowACalls === 1) { + // Initial load: one active run so the live-refresh poll subscribes. + return Promise.resolve([ + makeRun({ id: 'run-a', flow_id: 'flow-a', status: 'running' }), + ]); + } + // The poll-triggered refetch — stays pending until resolved below, + // simulating a slow response that outlives the flow switch. + return new Promise(resolve => { + resolveStaleA = resolve; + }); + } + if (flowId === 'flow-b') { + return Promise.resolve([ + makeRun({ id: 'run-b', flow_id: 'flow-b', status: 'completed' }), + ]); + } + return Promise.resolve([]); + }); + + const { rerender } = render( + + + + ); + + // Flush the initial load's already-resolved promise. + await act(async () => { + await Promise.resolve(); + }); + expect(screen.getByTestId('flow-run-row-run-a')).toBeInTheDocument(); + + // Trigger the live-refresh poll fallback — issues the second, hanging + // listFlowRuns('flow-a') call. + await act(async () => { + vi.advanceTimersByTime(5_000); + }); + expect(flowACalls).toBe(2); + + // Flip the drawer to a different flow while that refetch is still in flight. + rerender( + + + + ); + await act(async () => { + await Promise.resolve(); + }); + expect(screen.getByTestId('flow-run-row-run-b')).toBeInTheDocument(); + + // Now let the stale flow-a response land. + await act(async () => { + resolveStaleA?.([makeRun({ id: 'run-a-late', flow_id: 'flow-a', status: 'completed' })]); + await Promise.resolve(); + }); + + // Flow-b's runs must be unaffected by the late flow-a response. + expect(screen.getByTestId('flow-run-row-run-b')).toBeInTheDocument(); + expect(screen.queryByTestId('flow-run-row-run-a-late')).not.toBeInTheDocument(); + } finally { + vi.useRealTimers(); + } + }); }); diff --git a/app/src/components/flows/FlowRunsDrawer.tsx b/app/src/components/flows/FlowRunsDrawer.tsx index d2d55bd8a..8f4484816 100644 --- a/app/src/components/flows/FlowRunsDrawer.tsx +++ b/app/src/components/flows/FlowRunsDrawer.tsx @@ -8,10 +8,11 @@ * 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). + * Data loads via `listFlowRuns` on open, then stays live via + * {@link useFlowRunsLiveRefresh} while any run in the list is still active — + * so a run stuck on "Running" here updates without the user having to close + * and reopen the drawer. The run inspector separately polls a single run's + * live status via `useFlowRunPoller`; that's unrelated to this list refresh. * * Clicking a run sets `selectedRunId` and renders the existing * `FlowRunInspectorDrawer` stacked on top: both are `fixed inset-0 z-50` @@ -24,9 +25,10 @@ * single Escape press closes only the topmost overlay (the inspector) first. */ import debug from 'debug'; -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { useEscapeKey } from '../../hooks/useEscapeKey'; +import { useFlowRunsLiveRefresh } from '../../hooks/useFlowRunsLiveRefresh'; import { useT } from '../../lib/i18n/I18nContext'; import { type FlowRun, listFlowRuns } from '../../services/api/flowsApi'; import { @@ -72,8 +74,15 @@ export function FlowRunsDrawer({ flowId, flowName, onClose, onFixWithAgent }: Pr const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [selectedRunId, setSelectedRunId] = useState(null); + // Tracks the flowId this drawer instance is *currently* showing, so an + // in-flight `refetch()` started for a previous flow (see below) can detect + // it's stale once the drawer flips to a new flowId and bail instead of + // clobbering the new flow's already-loaded runs. + const currentFlowIdRef = useRef(flowId); useEffect(() => { + currentFlowIdRef.current = flowId; + // 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); @@ -109,6 +118,31 @@ export function FlowRunsDrawer({ flowId, flowName, onClose, onFixWithAgent }: Pr }; }, [flowId]); + // Background refresh for the live-update hook below — deliberately doesn't + // touch `loading`/`error` so a poll tick or progress event never flashes + // the loading state or clobbers a real load error with a transient one. + // Guards against a stale response: if the drawer flips from flow A to flow + // B while an A refetch is still in flight, the late A response must not + // overwrite B's already-loaded runs (mirrors the `cancelled` guard on the + // main load effect above). + const refetch = useCallback(() => { + if (!flowId) return; + const requestFlowId = flowId; + listFlowRuns(requestFlowId) + .then(result => { + if (currentFlowIdRef.current !== requestFlowId) return; + setRuns(result); + log('refetched runs: flowId=%s count=%d', requestFlowId, result.length); + }) + .catch(err => { + if (currentFlowIdRef.current !== requestFlowId) return; + const msg = err instanceof Error ? err.message : String(err); + log('refetch failed: flowId=%s err=%s', requestFlowId, msg); + }); + }, [flowId]); + + useFlowRunsLiveRefresh(runs, refetch); + useEscapeKey( () => { log('escape: closing flowId=%s', flowId); diff --git a/app/src/components/flows/FlowRunsSidebar.tsx b/app/src/components/flows/FlowRunsSidebar.tsx index 42edb95c9..5decc46c1 100644 --- a/app/src/components/flows/FlowRunsSidebar.tsx +++ b/app/src/components/flows/FlowRunsSidebar.tsx @@ -3,8 +3,9 @@ * dynamic left sidebar while a flow is open on the canvas (`/flows/:id`). A * compact, scannable run history (status dot + status + relative time); clicking * a run opens the full {@link FlowRunInspectorDrawer} (which polls its live - * status). One-shot fetch via `listFlowRuns` with a manual refresh — the engine - * emits no list-level socket events, so this mirrors `FlowRunsDrawer`'s model. + * status). Fetches via `listFlowRuns`, with a manual refresh button plus + * {@link useFlowRunsLiveRefresh} keeping the list itself live while any run + * shown here is still active (no manual refresh/navigate-away required). * * Rendered by `FlowCanvasPage` inside a `SidebarContent` portal, so it only * appears for a persisted flow (a draft has no runs yet). @@ -13,6 +14,7 @@ import createDebug from 'debug'; import { useCallback, useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; +import { useFlowRunsLiveRefresh } from '../../hooks/useFlowRunsLiveRefresh'; import { useT } from '../../lib/i18n/I18nContext'; import { type FlowRun, listFlowRuns } from '../../services/api/flowsApi'; import { CenteredLoadingState, ErrorBanner } from '../ui/LoadingState'; @@ -98,6 +100,8 @@ export default function FlowRunsSidebar({ flowId }: FlowRunsSidebarProps) { void load(); }, [load]); + useFlowRunsLiveRefresh(runs, load); + return (
diff --git a/app/src/hooks/__tests__/useFlowRunsLiveRefresh.test.ts b/app/src/hooks/__tests__/useFlowRunsLiveRefresh.test.ts new file mode 100644 index 000000000..2ba90066a --- /dev/null +++ b/app/src/hooks/__tests__/useFlowRunsLiveRefresh.test.ts @@ -0,0 +1,145 @@ +/** + * useFlowRunsLiveRefresh — unit tests. + * + * Verifies: no subscription/poll when every run is terminal, subscribe + + * poll when a run is active, a trailing-debounced refetch on a matching + * `flow:run_progress`/`flow_run_progress` event, teardown once the runs + * settle to all-terminal, and cleanup on unmount. + */ +import { act, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { FlowRun } from '../../services/api/flowsApi'; +import { useFlowRunsLiveRefresh } from '../useFlowRunsLiveRefresh'; + +const handlers = vi.hoisted(() => new Map void>>()); +const on = vi.hoisted(() => + vi.fn((event: string, cb: (data: unknown) => void) => { + const set = handlers.get(event) ?? new Set(); + set.add(cb); + handlers.set(event, set); + }) +); +const off = vi.hoisted(() => + vi.fn((event: string, cb: (data: unknown) => void) => { + handlers.get(event)?.delete(cb); + }) +); +vi.mock('../../services/socketService', () => ({ socketService: { on, off } })); + +function emit(event: 'flow:run_progress' | 'flow_run_progress', payload: unknown) { + act(() => { + for (const cb of handlers.get(event) ?? []) cb(payload); + }); +} + +function makeRun(overrides: Partial = {}): FlowRun { + return { + id: 'run-1', + flow_id: 'flow-1', + thread_id: 'run-1', + status: 'running', + started_at: '2026-01-01T00:00:00Z', + steps: [], + pending_approvals: [], + ...overrides, + }; +} + +describe('useFlowRunsLiveRefresh', () => { + beforeEach(() => { + handlers.clear(); + on.mockClear(); + off.mockClear(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('does not subscribe or poll when every run is terminal', () => { + const refetch = vi.fn(); + renderHook(() => + useFlowRunsLiveRefresh( + [makeRun({ status: 'completed' }), makeRun({ id: 'run-2', status: 'failed' })], + refetch + ) + ); + + expect(on).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(20_000); + expect(refetch).not.toHaveBeenCalled(); + }); + + it('subscribes to both event aliases and polls on a fallback interval when a run is active', () => { + const refetch = vi.fn(); + renderHook(() => useFlowRunsLiveRefresh([makeRun({ status: 'running' })], refetch)); + + expect(on).toHaveBeenCalledWith('flow:run_progress', expect.any(Function)); + expect(on).toHaveBeenCalledWith('flow_run_progress', expect.any(Function)); + + vi.advanceTimersByTime(5_000); + expect(refetch).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(5_000); + expect(refetch).toHaveBeenCalledTimes(2); + }); + + it('debounces a burst of flow:run_progress events into a single trailing refetch', () => { + const refetch = vi.fn(); + renderHook(() => useFlowRunsLiveRefresh([makeRun({ status: 'running' })], refetch)); + + // Keep every emit + the final debounce settle comfortably inside the + // first 5s poll tick so the poll fallback doesn't also fire here — that + // interplay is covered separately by the "poll fallback" test above. + emit('flow:run_progress', { run_id: 'run-1', node_id: 'a', status: 'success' }); + vi.advanceTimersByTime(500); + emit('flow:run_progress', { run_id: 'run-1', node_id: 'b', status: 'success' }); + vi.advanceTimersByTime(500); + emit('flow_run_progress', { run_id: 'run-1', node_id: 'c', status: 'success' }); + + // Still within the 3s trailing window from the last event — no refetch yet. + expect(refetch).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(3_000); + expect(refetch).toHaveBeenCalledTimes(1); + }); + + it('tears down the subscription and poll once the runs settle to all-terminal', () => { + const refetch = vi.fn(); + const { rerender } = renderHook(({ runs }) => useFlowRunsLiveRefresh(runs, refetch), { + initialProps: { runs: [makeRun({ status: 'running' })] }, + }); + + expect(on).toHaveBeenCalledTimes(2); + + rerender({ runs: [makeRun({ status: 'completed' })] }); + + expect(off).toHaveBeenCalledWith('flow:run_progress', expect.any(Function)); + expect(off).toHaveBeenCalledWith('flow_run_progress', expect.any(Function)); + + refetch.mockClear(); + vi.advanceTimersByTime(20_000); + expect(refetch).not.toHaveBeenCalled(); + }); + + it('cleans up the subscription, poll, and any pending debounce on unmount', () => { + const refetch = vi.fn(); + const { unmount } = renderHook(() => + useFlowRunsLiveRefresh([makeRun({ status: 'running' })], refetch) + ); + + emit('flow:run_progress', { run_id: 'run-1', node_id: 'a', status: 'success' }); + + unmount(); + + expect(off).toHaveBeenCalledWith('flow:run_progress', expect.any(Function)); + expect(off).toHaveBeenCalledWith('flow_run_progress', expect.any(Function)); + + refetch.mockClear(); + vi.advanceTimersByTime(20_000); + expect(refetch).not.toHaveBeenCalled(); + }); +}); diff --git a/app/src/hooks/useFlowRunsLiveRefresh.ts b/app/src/hooks/useFlowRunsLiveRefresh.ts new file mode 100644 index 000000000..ee96d1668 --- /dev/null +++ b/app/src/hooks/useFlowRunsLiveRefresh.ts @@ -0,0 +1,100 @@ +/** + * useFlowRunsLiveRefresh — keeps a runs LIST fresh while any run in it is + * still active, so "Running" doesn't go stale until the user manually + * refreshes or navigates away. + * + * The backend's `FlowRunObserver` publishes `DomainEvent::FlowRunProgress` on + * each finished step, and the core socket bridge re-emits it to the frontend + * as both `flow:run_progress` and `flow_run_progress` (colon + underscore + * aliases — see `useFlowRunProgress`, whose subscribe/teardown style this + * mirrors). There is, however, no terminal/completion socket event today — + * a run transitioning `running` -> `completed`/`failed`/etc. emits nothing — + * so a step-progress subscription alone can't catch that final refresh. This + * hook therefore layers a 5s poll fallback on top of the socket subscription: + * the socket keeps the refetch snappy while steps are landing, and the poll + * guarantees the terminal transition (which has no event) still gets picked + * up within a few seconds. + * + * This is deliberately dumb about *which* run changed — callers pass the + * list they already fetched and a `refetch` that reloads it; this hook just + * decides *when* to call `refetch` again. `FlowRunsSidebar`, `FlowRunsDrawer`, + * and `WorkflowRunsPage` all wire it onto their existing one-shot fetchers. + */ +import debug from 'debug'; +import { useEffect, useRef } from 'react'; + +import type { FlowRun, FlowRunStatus } from '../services/api/flowsApi'; +import { socketService } from '../services/socketService'; + +const log = debug('flows:runs-live-refresh'); + +/** Socket event aliases the core bridge emits (colon + underscore forms). */ +const EVENT_COLON = 'flow:run_progress'; +const EVENT_UNDERSCORE = 'flow_run_progress'; + +/** Statuses a run never leaves once reached — no further refetch needed for it. */ +const TERMINAL_STATUSES = new Set([ + 'completed', + 'completed_with_warnings', + 'failed', + 'cancelled', +]); + +/** Trailing debounce window for a burst of `flow:run_progress` events. */ +const DEBOUNCE_MS = 3_000; + +/** Poll fallback cadence — catches the terminal transition, which has no socket event. */ +const POLL_INTERVAL_MS = 5_000; + +/** + * Subscribes to live run-progress events (debounced) and polls on a fallback + * interval while `runs` contains at least one non-terminal run, calling + * `refetch` to reload the list. Subscribes to nothing — and tears down any + * existing subscription/poll — once every run has settled or on unmount. + */ +export function useFlowRunsLiveRefresh(runs: FlowRun[], refetch: () => void): void { + // Keep the latest `refetch` available to the effect without retriggering + // subscribe/unsubscribe every time the caller passes a new closure. + const refetchRef = useRef(refetch); + refetchRef.current = refetch; + + const hasActive = runs.some(run => !TERMINAL_STATUSES.has(run.status)); + + useEffect(() => { + if (!hasActive) return; + + let debounceTimer: ReturnType | null = null; + const scheduleRefetch = () => { + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + debounceTimer = null; + log('debounced refetch firing'); + refetchRef.current(); + }, DEBOUNCE_MS); + }; + + const handleProgress = () => { + log('progress event received — scheduling debounced refetch'); + scheduleRefetch(); + }; + + log('subscribing: at least one active run'); + socketService.on(EVENT_COLON, handleProgress); + socketService.on(EVENT_UNDERSCORE, handleProgress); + + const pollId = setInterval(() => { + log('poll fallback refetch'); + refetchRef.current(); + }, POLL_INTERVAL_MS); + + return () => { + log('tearing down: unsubscribing + clearing poll/debounce timers'); + socketService.off(EVENT_COLON, handleProgress); + socketService.off(EVENT_UNDERSCORE, handleProgress); + clearInterval(pollId); + if (debounceTimer) clearTimeout(debounceTimer); + }; + }, [hasActive]); +} + +export default useFlowRunsLiveRefresh; diff --git a/app/src/pages/FlowsPage.test.tsx b/app/src/pages/FlowsPage.test.tsx index 4b0fb94f7..0e1080568 100644 --- a/app/src/pages/FlowsPage.test.tsx +++ b/app/src/pages/FlowsPage.test.tsx @@ -131,9 +131,9 @@ describe('FlowsPage', () => { fireEvent.click(screen.getByTestId('flow-toggle-flow-1')); expect(setFlowEnabled).toHaveBeenCalledWith('flow-1', false); - // The toggle is an icon button now; state is conveyed via aria-pressed. + // The toggle is a SettingsSwitch (role=switch) now; state is conveyed via aria-checked. await waitFor(() => - expect(screen.getByTestId('flow-toggle-flow-1')).toHaveAttribute('aria-pressed', 'false') + expect(screen.getByTestId('flow-toggle-flow-1')).toHaveAttribute('aria-checked', 'false') ); }); @@ -275,6 +275,8 @@ describe('FlowsPage', () => { deleteFlow.mockResolvedValue('flow-1'); renderWithProviders(, { initialEntries: ['/?view=main'] }); + // Delete now lives behind the row's "⋯" overflow menu, alongside + // Export/Duplicate, rather than a standalone icon button. fireEvent.click(await screen.findByTestId('flow-menu-flow-1')); fireEvent.click(await screen.findByTestId('flow-delete-flow-1')); diff --git a/app/src/pages/WorkflowRunsPage.test.tsx b/app/src/pages/WorkflowRunsPage.test.tsx index 686711550..d03d4e66b 100644 --- a/app/src/pages/WorkflowRunsPage.test.tsx +++ b/app/src/pages/WorkflowRunsPage.test.tsx @@ -1,5 +1,5 @@ -import { render, screen, waitFor } from '@testing-library/react'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import WorkflowRunsPage from './WorkflowRunsPage'; @@ -75,4 +75,67 @@ describe('WorkflowRunsPage', () => { await waitFor(() => expect(screen.getByText('rpc down')).toBeInTheDocument()); }); + + describe('live refresh (useFlowRunsLiveRefresh integration)', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('re-fetches just the runs (not listFlows) on the live-refresh poll while a run is active', async () => { + vi.useFakeTimers(); + listAllFlowRuns + .mockResolvedValueOnce([ + { id: 'r1', flow_id: 'f1', status: 'running', started_at: '2026-01-01T00:00:00Z' }, + ]) + .mockResolvedValueOnce([ + { id: 'r1', flow_id: 'f1', status: 'completed', started_at: '2026-01-01T00:00:00Z' }, + ]); + listFlows.mockResolvedValue([{ id: 'f1', name: 'Daily digest' }]); + + render(); + await act(async () => { + await Promise.resolve(); + }); + expect(screen.getByTestId('workflow-runs-list')).toBeInTheDocument(); + expect(listAllFlowRuns).toHaveBeenCalledTimes(1); + expect(listFlows).toHaveBeenCalledTimes(1); + + // The 5s poll fallback fires `refetchRuns` while the one run is still 'running'. + await act(async () => { + vi.advanceTimersByTime(5_000); + await Promise.resolve(); + }); + + // The poll fallback re-fetched just the runs — `listFlows` is not called again. + expect(listAllFlowRuns).toHaveBeenCalledTimes(2); + expect(listFlows).toHaveBeenCalledTimes(1); + }); + + it('does not surface an error banner when a background refetch fails', async () => { + vi.useFakeTimers(); + listAllFlowRuns + .mockResolvedValueOnce([ + { id: 'r1', flow_id: 'f1', status: 'running', started_at: '2026-01-01T00:00:00Z' }, + ]) + .mockRejectedValueOnce(new Error('transient rpc blip')); + listFlows.mockResolvedValue([{ id: 'f1', name: 'Daily digest' }]); + + render(); + await act(async () => { + await Promise.resolve(); + }); + expect(screen.getByTestId('workflow-runs-list')).toBeInTheDocument(); + + await act(async () => { + vi.advanceTimersByTime(5_000); + await Promise.resolve(); + }); + + expect(listAllFlowRuns).toHaveBeenCalledTimes(2); + // The transient background failure is logged only — the list stays as-is, + // no error banner from the (unrelated) `load` error state. + expect(screen.queryByText('transient rpc blip')).not.toBeInTheDocument(); + expect(screen.getByTestId('workflow-runs-list')).toBeInTheDocument(); + }); + }); }); diff --git a/app/src/pages/WorkflowRunsPage.tsx b/app/src/pages/WorkflowRunsPage.tsx index 84b934e5c..874dd0de3 100644 --- a/app/src/pages/WorkflowRunsPage.tsx +++ b/app/src/pages/WorkflowRunsPage.tsx @@ -1,13 +1,18 @@ /** * WorkflowRunsPage — the aggregate "All runs" view: every workflow's runs across * the whole `flows` domain, newest first, backed by the `flows_list_all_runs` - * core RPC. Each row links back to its workflow's canvas. + * core RPC. Each row links back to its workflow's canvas. Stays live via + * {@link useFlowRunsLiveRefresh} while any listed run is still active, via a + * lightweight `refetchRuns` (re-fetches just the runs, not `listFlows()` too) + * so a run doesn't sit on "Running" until the user reloads the page. */ +import debug from 'debug'; import { useCallback, useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import PanelPage from '../components/layout/PanelPage'; import { CenteredLoadingState, ErrorBanner } from '../components/ui/LoadingState'; +import { useFlowRunsLiveRefresh } from '../hooks/useFlowRunsLiveRefresh'; import { useT } from '../lib/i18n/I18nContext'; import { type Flow, @@ -17,6 +22,8 @@ import { listFlows, } from '../services/api/flowsApi'; +const log = debug('app:flows:runs-page'); + const STATUS_CLASS: Record = { running: 'bg-primary-500/15 text-primary-600 dark:text-primary-300', completed: 'bg-sage-500/15 text-sage-700 dark:text-sage-300', @@ -56,6 +63,21 @@ export default function WorkflowRunsPage() { void load(); }, [load]); + // Lighter-weight than `load` — only re-fetches the runs (not `listFlows()` + // too), since flow names rarely change mid-run and re-fetching them on + // every live-refresh tick would be wasted work. + const refetchRuns = useCallback(() => { + listAllFlowRuns() + .then(setRuns) + .catch(err => { + // Best-effort background refresh — a transient failure here shouldn't + // clobber the page's existing error/loading state from `load`. + log('refetchRuns failed: %o', err); + }); + }, []); + + useFlowRunsLiveRefresh(runs, refetchRuns); + const statusLabel = (status: FlowRunStatus) => t(`flows.allRuns.status.${status}`, status.replace(/_/g, ' '));