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, ' '));