fix(flows): show "awaiting approval" state in runs lists when a run is halted at an approval gate (#4932)

This commit is contained in:
Cyrus Gray
2026-07-16 02:24:17 +05:30
committed by GitHub
parent 4135cfc079
commit 1feb87317d
8 changed files with 519 additions and 51 deletions
@@ -23,6 +23,9 @@ import { FlowRunsDrawer } from './FlowRunsDrawer';
const listFlowRuns = vi.hoisted(() => vi.fn());
vi.mock('../../services/api/flowsApi', () => ({ listFlowRuns }));
const fetchPendingApprovals = vi.hoisted(() => vi.fn());
vi.mock('../../services/api/approvalApi', () => ({ fetchPendingApprovals }));
const FlowRunInspectorDrawer = vi.hoisted(() => vi.fn());
vi.mock('./FlowRunInspectorDrawer', () => ({
FLOW_RUN_STATUS_ACCENT: {
@@ -87,6 +90,7 @@ function renderDrawer(flowId: string | null, onClose: () => void, flowName?: str
describe('FlowRunsDrawer', () => {
beforeEach(() => {
vi.clearAllMocks();
fetchPendingApprovals.mockResolvedValue([]);
});
it('renders null when flowId is null', () => {
@@ -123,6 +127,41 @@ describe('FlowRunsDrawer', () => {
expect(row).toHaveTextContent('Completed with warnings');
});
it('shows "Awaiting approval" for a running run halted at a matching flow approval gate', async () => {
listFlowRuns.mockResolvedValue([makeRun({ id: 'run-1', status: 'running' })]);
fetchPendingApprovals.mockResolvedValue([
{
request_id: 'req-1',
tool_name: 'SLACK_SEND_MESSAGE',
action_summary: 'Send Slack message',
args_redacted: {},
session_id: 'session-1',
created_at: '2026-01-01T00:00:00Z',
expires_at: null,
source_context: { kind: 'flow', flow_id: 'flow-1', run_id: 'run-1' },
},
]);
renderDrawer('flow-1', vi.fn());
const row = await screen.findByTestId('flow-run-row-run-1');
await waitFor(() => expect(row).toHaveTextContent('Awaiting approval'));
expect(screen.getByTestId('flow-run-row-dot-run-1').className.includes('dot-pending')).toBe(
true
);
});
it('leaves a running run without a matching flow approval labeled "Running"', async () => {
listFlowRuns.mockResolvedValue([makeRun({ id: 'run-1', status: 'running' })]);
fetchPendingApprovals.mockResolvedValue([]);
renderDrawer('flow-1', vi.fn());
const row = await screen.findByTestId('flow-run-row-run-1');
await waitFor(() => expect(row).toHaveTextContent('Running'));
expect(screen.getByTestId('flow-run-row-dot-run-1').className.includes('dot-running')).toBe(
true
);
});
it('falls back to a generic title when no flowName is given', async () => {
listFlowRuns.mockResolvedValue([]);
renderDrawer('flow-1', vi.fn());
+9 -3
View File
@@ -29,6 +29,10 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { useEscapeKey } from '../../hooks/useEscapeKey';
import { useFlowRunsLiveRefresh } from '../../hooks/useFlowRunsLiveRefresh';
import {
resolveDisplayStatus,
useRunsPendingApprovalSet,
} from '../../hooks/useRunsPendingApprovalSet';
import { useT } from '../../lib/i18n/I18nContext';
import { type FlowRun, listFlowRuns } from '../../services/api/flowsApi';
import {
@@ -142,6 +146,7 @@ export function FlowRunsDrawer({ flowId, flowName, onClose, onFixWithAgent }: Pr
}, [flowId]);
useFlowRunsLiveRefresh(runs, refetch);
const pendingRunIds = useRunsPendingApprovalSet(runs);
useEscapeKey(
() => {
@@ -213,6 +218,7 @@ export function FlowRunsDrawer({ flowId, flowName, onClose, onFixWithAgent }: Pr
<ul className="space-y-2" data-testid="flow-runs-list">
{runs.map(run => {
const startedAt = formatTimestamp(run.started_at);
const displayStatus = resolveDisplayStatus(run, pendingRunIds);
return (
<li key={run.id}>
<button
@@ -222,12 +228,12 @@ export function FlowRunsDrawer({ flowId, flowName, onClose, onFixWithAgent }: Pr
className="flex w-full items-center gap-2 rounded-lg border border-line bg-surface-muted px-3 py-2 text-left text-xs hover:bg-surface-hover">
<span
data-testid={`flow-run-row-dot-${run.id}`}
className={`h-2 w-2 shrink-0 rounded-full ${FLOW_RUN_STATUS_DOT[run.status]}`}
className={`h-2 w-2 shrink-0 rounded-full ${FLOW_RUN_STATUS_DOT[displayStatus]}`}
aria-hidden
/>
<span
className={`inline-flex shrink-0 items-center rounded-full border px-2 py-0.5 font-medium ${FLOW_RUN_STATUS_ACCENT[run.status]}`}>
{t(FLOW_RUN_STATUS_KEY[run.status])}
className={`inline-flex shrink-0 items-center rounded-full border px-2 py-0.5 font-medium ${FLOW_RUN_STATUS_ACCENT[displayStatus]}`}>
{t(FLOW_RUN_STATUS_KEY[displayStatus])}
</span>
{startedAt && (
<span className="truncate text-content-muted">{startedAt}</span>
@@ -21,6 +21,9 @@ import FlowRunsSidebar from './FlowRunsSidebar';
const listFlowRuns = vi.hoisted(() => vi.fn());
vi.mock('../../services/api/flowsApi', () => ({ listFlowRuns }));
const fetchPendingApprovals = vi.hoisted(() => vi.fn());
vi.mock('../../services/api/approvalApi', () => ({ fetchPendingApprovals }));
// Capture the props handed to the drawer so "Fix with agent" can be invoked
// directly without standing up the drawer's own run-polling machinery
// (mirrors `FlowApprovalCard.test.tsx`'s stub pattern).
@@ -97,6 +100,7 @@ describe('FlowRunsSidebar', () => {
beforeEach(() => {
vi.clearAllMocks();
inspectorDrawerProps.current = null;
fetchPendingApprovals.mockResolvedValue([]);
});
it('lists runs and opens the inspector drawer for the clicked run', async () => {
@@ -179,4 +183,33 @@ describe('FlowRunsSidebar', () => {
expect(await screen.findByTestId('flow-runs-sidebar-empty')).toBeInTheDocument();
});
it('shows "Awaiting approval" for a running run halted at an approval gate', async () => {
listFlowRuns.mockResolvedValue([makeRun({ status: 'running' })]);
fetchPendingApprovals.mockResolvedValue([
{
request_id: 'req-1',
tool_name: 'SLACK_SEND_MESSAGE',
action_summary: 'Send Slack message',
args_redacted: {},
session_id: 'session-1',
created_at: '2026-07-13T18:23:00Z',
expires_at: null,
source_context: { kind: 'flow', flow_id: 'flow-1', run_id: 'run-1' },
},
]);
renderSidebar();
const runRow = await screen.findByTestId('flow-runs-sidebar-run-run-1');
await waitFor(() => expect(runRow).toHaveTextContent('Awaiting approval'));
});
it('leaves a running run without a matching approval labeled "Running"', async () => {
listFlowRuns.mockResolvedValue([makeRun({ status: 'running' })]);
fetchPendingApprovals.mockResolvedValue([]);
renderSidebar();
const runRow = await screen.findByTestId('flow-runs-sidebar-run-run-1');
await waitFor(() => expect(runRow).toHaveTextContent('Running'));
});
});
+31 -23
View File
@@ -15,6 +15,10 @@ import { useCallback, useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useFlowRunsLiveRefresh } from '../../hooks/useFlowRunsLiveRefresh';
import {
resolveDisplayStatus,
useRunsPendingApprovalSet,
} from '../../hooks/useRunsPendingApprovalSet';
import { useT } from '../../lib/i18n/I18nContext';
import { type FlowRun, listFlowRuns } from '../../services/api/flowsApi';
import { CenteredLoadingState, ErrorBanner } from '../ui/LoadingState';
@@ -101,6 +105,7 @@ export default function FlowRunsSidebar({ flowId }: FlowRunsSidebarProps) {
}, [load]);
useFlowRunsLiveRefresh(runs, load);
const pendingRunIds = useRunsPendingApprovalSet(runs);
return (
<div className="flex h-full flex-col" data-testid="flow-runs-sidebar">
@@ -150,31 +155,34 @@ export default function FlowRunsSidebar({ flowId }: FlowRunsSidebarProps) {
)}
<ul className="space-y-1">
{runs.map(run => (
<li key={run.id}>
<button
type="button"
data-testid={`flow-runs-sidebar-run-${run.id}`}
onClick={() => setSelectedRunId(run.id)}
className={`flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-surface-hover ${
selectedRunId === run.id ? 'bg-surface-hover' : ''
}`}>
<span
className={`h-2 w-2 shrink-0 rounded-full ${FLOW_RUN_STATUS_DOT[run.status]}`}
aria-hidden="true"
/>
<span className="min-w-0 flex-1">
{runs.map(run => {
const displayStatus = resolveDisplayStatus(run, pendingRunIds);
return (
<li key={run.id}>
<button
type="button"
data-testid={`flow-runs-sidebar-run-${run.id}`}
onClick={() => setSelectedRunId(run.id)}
className={`flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-surface-hover ${
selectedRunId === run.id ? 'bg-surface-hover' : ''
}`}>
<span
className={`inline-flex items-center rounded-full border px-1.5 py-0.5 text-[10px] font-medium ${FLOW_RUN_STATUS_ACCENT[run.status]}`}>
{t(FLOW_RUN_STATUS_KEY[run.status])}
className={`h-2 w-2 shrink-0 rounded-full ${FLOW_RUN_STATUS_DOT[displayStatus]}`}
aria-hidden="true"
/>
<span className="min-w-0 flex-1">
<span
className={`inline-flex items-center rounded-full border px-1.5 py-0.5 text-[10px] font-medium ${FLOW_RUN_STATUS_ACCENT[displayStatus]}`}>
{t(FLOW_RUN_STATUS_KEY[displayStatus])}
</span>
<span className="mt-0.5 block truncate text-[11px] text-content-faint">
{relativeTime(run.started_at, t)}
</span>
</span>
<span className="mt-0.5 block truncate text-[11px] text-content-faint">
{relativeTime(run.started_at, t)}
</span>
</span>
</button>
</li>
))}
</button>
</li>
);
})}
</ul>
</div>
@@ -0,0 +1,214 @@
/**
* useRunsPendingApprovalSet — unit tests.
*
* Verifies: no poll when every run is terminal (or the list is empty); polls
* `approval_list_pending` every 3s while a run is `running`; collects only
* flow-origin (`source_context.kind === 'flow'`) matches into the returned
* Set; a chat-origin approval (no flow `source_context`) is excluded; a
* failed poll keeps the last-known set instead of clearing it; teardown once
* every run settles to terminal; cleanup on unmount.
*/
import { act, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { PendingApproval } from '../../services/api/approvalApi';
import type { FlowRun } from '../../services/api/flowsApi';
import { resolveDisplayStatus, useRunsPendingApprovalSet } from '../useRunsPendingApprovalSet';
const fetchPendingApprovals = vi.hoisted(() => vi.fn());
vi.mock('../../services/api/approvalApi', () => ({ fetchPendingApprovals }));
function makeRun(overrides: Partial<FlowRun> = {}): 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,
};
}
function makeApproval(overrides: Partial<PendingApproval> = {}): PendingApproval {
return {
request_id: 'req-1',
tool_name: 'shell',
action_summary: 'Run `shell`',
args_redacted: {},
session_id: 'session-1',
created_at: '2026-01-01T00:00:00Z',
expires_at: null,
source_context: { kind: 'flow', flow_id: 'flow-1', run_id: 'run-1' },
...overrides,
};
}
describe('useRunsPendingApprovalSet', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('does not poll when every run is terminal', async () => {
fetchPendingApprovals.mockResolvedValue([]);
renderHook(() =>
useRunsPendingApprovalSet([
makeRun({ status: 'completed' }),
makeRun({ id: 'run-2', status: 'failed' }),
])
);
await act(async () => {
await vi.advanceTimersByTimeAsync(20_000);
});
expect(fetchPendingApprovals).not.toHaveBeenCalled();
});
it('does not poll for an empty runs list', async () => {
fetchPendingApprovals.mockResolvedValue([]);
renderHook(() => useRunsPendingApprovalSet([]));
await act(async () => {
await vi.advanceTimersByTimeAsync(10_000);
});
expect(fetchPendingApprovals).not.toHaveBeenCalled();
});
it('polls every 3s while a run is running and includes it when a matching flow approval exists', async () => {
fetchPendingApprovals.mockResolvedValue([makeApproval({ request_id: 'req-a' })]);
const { result } = renderHook(() =>
useRunsPendingApprovalSet([makeRun({ status: 'running' })])
);
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
expect(fetchPendingApprovals).toHaveBeenCalledTimes(1);
expect(result.current.has('run-1')).toBe(true);
await act(async () => {
await vi.advanceTimersByTimeAsync(3000);
});
expect(fetchPendingApprovals).toHaveBeenCalledTimes(2);
});
it('excludes a running run with no matching pending approval', async () => {
fetchPendingApprovals.mockResolvedValue([]);
const { result } = renderHook(() =>
useRunsPendingApprovalSet([makeRun({ status: 'running' })])
);
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
expect(result.current.size).toBe(0);
});
it('excludes a chat-origin approval (no flow source_context)', async () => {
fetchPendingApprovals.mockResolvedValue([
makeApproval({ request_id: 'req-chat', source_context: undefined }),
]);
const { result } = renderHook(() =>
useRunsPendingApprovalSet([makeRun({ status: 'running' })])
);
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
expect(result.current.size).toBe(0);
});
it('keeps the last-known set when a poll fails', async () => {
fetchPendingApprovals.mockResolvedValueOnce([makeApproval({ request_id: 'req-a' })]);
const { result, rerender } = renderHook(
({ runs }: { runs: FlowRun[] }) => useRunsPendingApprovalSet(runs),
{ initialProps: { runs: [makeRun({ status: 'running' })] } }
);
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
expect(result.current.has('run-1')).toBe(true);
fetchPendingApprovals.mockRejectedValueOnce(new Error('network down'));
rerender({ runs: [makeRun({ status: 'running' })] });
await act(async () => {
await vi.advanceTimersByTimeAsync(3000);
});
expect(fetchPendingApprovals).toHaveBeenCalledTimes(2);
expect(result.current.has('run-1')).toBe(true);
// A subsequent successful tick keeps polling on the same cadence.
fetchPendingApprovals.mockResolvedValueOnce([makeApproval({ request_id: 'req-a' })]);
await act(async () => {
await vi.advanceTimersByTimeAsync(3000);
});
expect(fetchPendingApprovals).toHaveBeenCalledTimes(3);
});
it('tears down polling once every run settles to terminal', async () => {
fetchPendingApprovals.mockResolvedValue([makeApproval({ request_id: 'req-a' })]);
const { rerender } = renderHook(
({ runs }: { runs: FlowRun[] }) => useRunsPendingApprovalSet(runs),
{ initialProps: { runs: [makeRun({ status: 'running' })] } }
);
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
expect(fetchPendingApprovals).toHaveBeenCalledTimes(1);
rerender({ runs: [makeRun({ status: 'completed' })] });
fetchPendingApprovals.mockClear();
await act(async () => {
await vi.advanceTimersByTimeAsync(20_000);
});
expect(fetchPendingApprovals).not.toHaveBeenCalled();
});
it('cleans up pending timers on unmount', async () => {
fetchPendingApprovals.mockResolvedValue([]);
const { unmount } = renderHook(() =>
useRunsPendingApprovalSet([makeRun({ status: 'running' })])
);
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
expect(fetchPendingApprovals).toHaveBeenCalledTimes(1);
unmount();
fetchPendingApprovals.mockClear();
await act(async () => {
await vi.advanceTimersByTimeAsync(10_000);
});
expect(fetchPendingApprovals).not.toHaveBeenCalled();
});
});
describe('resolveDisplayStatus', () => {
it('overrides to pending_approval when running and the run id is in the pending set', () => {
const run = makeRun({ status: 'running' });
const pendingRunIds = new Set(['run-1']);
expect(resolveDisplayStatus(run, pendingRunIds)).toBe('pending_approval');
});
it('leaves the status untouched when running but not in the pending set', () => {
const run = makeRun({ status: 'running' });
expect(resolveDisplayStatus(run, new Set())).toBe('running');
});
it('leaves non-running statuses untouched even if the id is (stale) in the pending set', () => {
const run = makeRun({ status: 'completed' });
const pendingRunIds = new Set(['run-1']);
expect(resolveDisplayStatus(run, pendingRunIds)).toBe('completed');
});
});
+118
View File
@@ -0,0 +1,118 @@
/**
* useRunsPendingApprovalSet — cross-references the shared approval queue
* against a runs LIST so surfaces that show many runs at once (sidebar,
* drawer, all-runs page) can distinguish a run merely `running` from one
* that's actually halted at an interactive `ApprovalGate` mid-run.
*
* The core has no separate DB status for "parked at an approval gate" — a
* run stays `status: "running"` in `FlowRun` while it waits; only the shared
* `openhuman.approval_list_pending` queue (see `useFlowPendingApprovals`,
* the single-run analogue used by `FlowRunInspectorDrawer`) knows it's
* parked, via `PendingApproval.source_context = { kind: "flow", run_id }`.
*
* While at least one run in the list is `running`, this hook polls that
* shared queue every {@link POLL_INTERVAL_MS} and returns the Set of
* `run_id`s with a matching flow-origin pending approval. Callers combine
* this with `run.status` via {@link resolveDisplayStatus} to override the
* DISPLAY status only — `run.status` itself, and `useFlowRunsLiveRefresh`
* (which reads the ORIGINAL runs array), are untouched, so `running` stays
* non-terminal there and the list keeps refetching until the run truly
* finishes.
*
* Mirrors the poll-loop + cleanup shape of `useFlowPendingApprovals`
* (cancelled flag + `setTimeout` reschedule + teardown) and the
* gate-on-activity contract of `useFlowRunsLiveRefresh`. Unlike
* `useFlowPendingApprovals`, a failed poll here does NOT surface an error or
* stop polling — this is a best-effort display hint, not a source of truth,
* so a transient failure just keeps showing the last-known set and retries
* on the next tick.
*/
import debug from 'debug';
import { useEffect, useState } from 'react';
import { fetchPendingApprovals } from '../services/api/approvalApi';
import type { FlowRun, FlowRunStatus } from '../services/api/flowsApi';
const log = debug('flows:runs-pending-approval-set');
/** How often to re-poll the shared approval queue while any run is `running`. */
const POLL_INTERVAL_MS = 3000;
/**
* Poll `openhuman.approval_list_pending` every {@link POLL_INTERVAL_MS}ms
* while `runs` contains at least one `status === 'running'` entry, and
* return the Set of `run_id`s with a flow-origin pending approval
* (`source_context.kind === 'flow'`). Stops polling — but does not clear the
* last-known set — once every run has left `running` (completed, failed,
* cancelled, or already reflected as `pending_approval`). Best-effort: a
* failed poll is logged and simply retried next tick, keeping the
* last-known set rather than surfacing an error to the caller.
*/
export function useRunsPendingApprovalSet(runs: FlowRun[]): Set<string> {
const [pendingRunIds, setPendingRunIds] = useState<Set<string>>(() => new Set());
const hasRunning = runs.some(run => run.status === 'running');
useEffect(() => {
if (!hasRunning) {
log('approval polling skipped: no-running-runs');
return;
}
log('approval polling started');
let cancelled = false;
let pollHandle: number | undefined;
const tick = async () => {
if (cancelled) return;
try {
log('approval poll: calling fetchPendingApprovals');
const all = await fetchPendingApprovals();
if (cancelled) return;
const next = new Set<string>();
for (const approval of all) {
if (approval.source_context?.kind === 'flow') {
next.add(approval.source_context.run_id);
}
}
log('approval poll: succeeded total=%d flow-scoped=%d', all.length, next.size);
setPendingRunIds(next);
} catch (err) {
const errorType = err instanceof Error ? err.name : typeof err;
log('approval poll: failed error_type=%s; preserving-last-known-set', errorType);
// Best-effort — leave `pendingRunIds` as-is and retry next tick.
} finally {
if (!cancelled) {
log('approval poll: scheduling retry delay_ms=%d', POLL_INTERVAL_MS);
pollHandle = window.setTimeout(() => void tick(), POLL_INTERVAL_MS);
}
}
};
void tick();
return () => {
cancelled = true;
if (pollHandle !== undefined) window.clearTimeout(pollHandle);
log('approval polling stopped');
};
}, [hasRunning]);
return pendingRunIds;
}
/**
* Overrides a run's DISPLAY status to `pending_approval` when it's currently
* `running` AND the shared approval queue (via {@link useRunsPendingApprovalSet})
* has a flow-origin pending approval for it. Does not mutate `run` or touch
* any other status — callers substitute the result at render sites only
* (status dot / accent / label), leaving `run.status` itself (and anything
* keyed on it, like `useFlowRunsLiveRefresh`'s terminal-status check) intact.
*/
export function resolveDisplayStatus(run: FlowRun, pendingRunIds: Set<string>): FlowRunStatus {
if (run.status === 'running' && pendingRunIds.has(run.id)) {
return 'pending_approval';
}
return run.status;
}
export default useRunsPendingApprovalSet;
+42
View File
@@ -17,6 +17,9 @@ vi.mock('../services/api/flowsApi', () => ({
listFlows: (...a: unknown[]) => listFlows(...a),
}));
const fetchPendingApprovals = vi.hoisted(() => vi.fn());
vi.mock('../services/api/approvalApi', () => ({ fetchPendingApprovals }));
// PanelPage + LoadingState pull i18n/redux we don't need — stub to bare markup.
vi.mock('../components/layout/PanelPage', () => ({
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
@@ -31,6 +34,8 @@ describe('WorkflowRunsPage', () => {
navigateMock.mockReset();
listAllFlowRuns.mockReset();
listFlows.mockReset();
fetchPendingApprovals.mockReset();
fetchPendingApprovals.mockResolvedValue([]);
});
it('renders aggregate runs with their workflow name and status', async () => {
@@ -76,6 +81,43 @@ describe('WorkflowRunsPage', () => {
await waitFor(() => expect(screen.getByText('rpc down')).toBeInTheDocument());
});
it('shows "pending approval" for a running run halted at a matching flow approval gate', async () => {
listAllFlowRuns.mockResolvedValue([
{ id: 'r1', flow_id: 'f1', status: 'running', started_at: '2026-01-01T00:00:00Z' },
]);
listFlows.mockResolvedValue([{ id: 'f1', name: 'Daily digest' }]);
fetchPendingApprovals.mockResolvedValue([
{
request_id: 'req-1',
tool_name: 'SLACK_SEND_MESSAGE',
action_summary: 'Send Slack message',
args_redacted: {},
session_id: 'session-1',
created_at: '2026-01-01T00:00:00Z',
expires_at: null,
source_context: { kind: 'flow', flow_id: 'f1', run_id: 'r1' },
},
]);
render(<WorkflowRunsPage />);
const row = await screen.findByTestId('workflow-run-r1');
await waitFor(() => expect(row).toHaveTextContent('pending approval'));
});
it('leaves a running run without a matching flow approval labeled "running"', async () => {
listAllFlowRuns.mockResolvedValue([
{ id: 'r1', flow_id: 'f1', status: 'running', started_at: '2026-01-01T00:00:00Z' },
]);
listFlows.mockResolvedValue([{ id: 'f1', name: 'Daily digest' }]);
fetchPendingApprovals.mockResolvedValue([]);
render(<WorkflowRunsPage />);
const row = await screen.findByTestId('workflow-run-r1');
await waitFor(() => expect(row).toHaveTextContent('running'));
});
describe('live refresh (useFlowRunsLiveRefresh integration)', () => {
afterEach(() => {
vi.useRealTimers();
+33 -25
View File
@@ -13,6 +13,10 @@ 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 {
resolveDisplayStatus,
useRunsPendingApprovalSet,
} from '../hooks/useRunsPendingApprovalSet';
import { useT } from '../lib/i18n/I18nContext';
import {
type Flow,
@@ -77,6 +81,7 @@ export default function WorkflowRunsPage() {
}, []);
useFlowRunsLiveRefresh(runs, refetchRuns);
const pendingRunIds = useRunsPendingApprovalSet(runs);
const statusLabel = (status: FlowRunStatus) =>
t(`flows.allRuns.status.${status}`, status.replace(/_/g, ' '));
@@ -101,31 +106,34 @@ export default function WorkflowRunsPage() {
<ul
className="divide-y divide-line rounded-xl border border-line"
data-testid="workflow-runs-list">
{runs.map(run => (
<li key={run.id}>
<button
type="button"
data-testid={`workflow-run-${run.id}`}
onClick={() => navigate(`/flows/${run.flow_id}`)}
className="flex w-full items-center gap-3 p-3 text-left hover:bg-surface-hover">
<span
className={`flex-shrink-0 rounded-full px-2 py-0.5 text-[11px] font-medium ${STATUS_CLASS[run.status]}`}>
{statusLabel(run.status)}
</span>
<span className="min-w-0 flex-1 truncate text-sm font-medium text-content">
{flowNames[run.flow_id] ?? t('flows.allRuns.unknownWorkflow')}
</span>
<span className="flex-shrink-0 text-[11px] text-content-faint">
{new Date(run.started_at).toLocaleString()}
</span>
</button>
{run.error && (
<p className="px-3 pb-2 text-[11px] text-coral-600 dark:text-coral-300">
{run.error}
</p>
)}
</li>
))}
{runs.map(run => {
const displayStatus = resolveDisplayStatus(run, pendingRunIds);
return (
<li key={run.id}>
<button
type="button"
data-testid={`workflow-run-${run.id}`}
onClick={() => navigate(`/flows/${run.flow_id}`)}
className="flex w-full items-center gap-3 p-3 text-left hover:bg-surface-hover">
<span
className={`flex-shrink-0 rounded-full px-2 py-0.5 text-[11px] font-medium ${STATUS_CLASS[displayStatus]}`}>
{statusLabel(displayStatus)}
</span>
<span className="min-w-0 flex-1 truncate text-sm font-medium text-content">
{flowNames[run.flow_id] ?? t('flows.allRuns.unknownWorkflow')}
</span>
<span className="flex-shrink-0 text-[11px] text-content-faint">
{new Date(run.started_at).toLocaleString()}
</span>
</button>
{run.error && (
<p className="px-3 pb-2 text-[11px] text-coral-600 dark:text-coral-300">
{run.error}
</p>
)}
</li>
);
})}
</ul>
)}
</div>