feat(flows): Workflows B5a.1 — View runs → inspector + New workflow button (#4474)

This commit is contained in:
Cyrus Gray
2026-07-04 02:28:27 +05:30
committed by GitHub
parent 1de7506972
commit 6faeeaa486
22 changed files with 692 additions and 56 deletions
+4
View File
@@ -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 ? (
<button
type="button"
data-testid={actionTestId}
onClick={onAction}
className="mt-4 inline-flex items-center gap-1.5 rounded-full bg-primary-50 dark:bg-primary-500/10 px-3 py-1.5 text-xs font-medium text-primary-600 dark:text-primary-400 border border-primary-100 dark:border-primary-800/50 transition-colors hover:bg-primary-100 dark:hover:bg-primary-500/20">
<span>{actionLabel}</span>
+53 -20
View File
@@ -1,10 +1,8 @@
/**
* FlowListRow (issue B5a) — one saved-flow row on the Workflows list page.
* Asserts the name/status rendering, the last-run/never-run text (including
* the localized relative-time strings), and that the toggle/Run controls
* call back with the row's `Flow`. No "View runs" control yet — it was
* pulled until B3b's run inspector lands (see `FlowListRow.tsx`'s module
* doc and the commented integration point in `FlowsPage.tsx`).
* FlowListRow (issue B5a / B5a.1) — one saved-flow row on the Workflows list
* page. Asserts the name/status rendering, the last-run/never-run text
* (including the localized relative-time strings), and that the
* toggle/Run/View runs controls call back with the row's `Flow`.
*/
import { fireEvent, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
@@ -30,7 +28,9 @@ function makeFlow(overrides: Partial<Flow> = {}): Flow {
describe('FlowListRow', () => {
it('renders the flow name and an Enabled badge when enabled', () => {
renderWithProviders(<FlowListRow flow={makeFlow()} onToggle={vi.fn()} onRun={vi.fn()} />);
renderWithProviders(
<FlowListRow flow={makeFlow()} onToggle={vi.fn()} onRun={vi.fn()} onViewRuns={vi.fn()} />
);
expect(screen.getByText('Daily digest')).toBeInTheDocument();
expect(screen.getByTestId('flow-status-flow-1')).toHaveTextContent('Enabled');
@@ -38,14 +38,21 @@ describe('FlowListRow', () => {
it('renders a Paused badge when disabled', () => {
renderWithProviders(
<FlowListRow flow={makeFlow({ enabled: false })} onToggle={vi.fn()} onRun={vi.fn()} />
<FlowListRow
flow={makeFlow({ enabled: false })}
onToggle={vi.fn()}
onRun={vi.fn()}
onViewRuns={vi.fn()}
/>
);
expect(screen.getByTestId('flow-status-flow-1')).toHaveTextContent('Paused');
});
it('shows "Never run" when the flow has no last_run_at', () => {
renderWithProviders(<FlowListRow flow={makeFlow()} onToggle={vi.fn()} onRun={vi.fn()} />);
renderWithProviders(
<FlowListRow flow={makeFlow()} onToggle={vi.fn()} onRun={vi.fn()} onViewRuns={vi.fn()} />
);
expect(screen.getByText('Never run')).toBeInTheDocument();
});
@@ -56,6 +63,7 @@ describe('FlowListRow', () => {
flow={makeFlow({ last_run_at: new Date().toISOString(), last_status: 'completed' })}
onToggle={vi.fn()}
onRun={vi.fn()}
onViewRuns={vi.fn()}
/>
);
@@ -69,6 +77,7 @@ describe('FlowListRow', () => {
flow={makeFlow({ last_run_at: fiveMinAgo, last_status: 'completed' })}
onToggle={vi.fn()}
onRun={vi.fn()}
onViewRuns={vi.fn()}
/>
);
@@ -82,6 +91,7 @@ describe('FlowListRow', () => {
flow={makeFlow({ last_run_at: threeHoursAgo, last_status: 'failed' })}
onToggle={vi.fn()}
onRun={vi.fn()}
onViewRuns={vi.fn()}
/>
);
@@ -95,6 +105,7 @@ describe('FlowListRow', () => {
flow={makeFlow({ last_run_at: twoDaysAgo, last_status: 'pending_approval' })}
onToggle={vi.fn()}
onRun={vi.fn()}
onViewRuns={vi.fn()}
/>
);
@@ -103,7 +114,9 @@ describe('FlowListRow', () => {
it('calls onToggle with the flow when the switch is clicked', () => {
const onToggle = vi.fn();
renderWithProviders(<FlowListRow flow={makeFlow()} onToggle={onToggle} onRun={vi.fn()} />);
renderWithProviders(
<FlowListRow flow={makeFlow()} onToggle={onToggle} onRun={vi.fn()} onViewRuns={vi.fn()} />
);
fireEvent.click(screen.getByTestId('flow-toggle-flow-1'));
@@ -112,16 +125,37 @@ describe('FlowListRow', () => {
it('calls onRun with the flow when the Run button is clicked', () => {
const onRun = vi.fn();
renderWithProviders(<FlowListRow flow={makeFlow()} onToggle={vi.fn()} onRun={onRun} />);
renderWithProviders(
<FlowListRow flow={makeFlow()} onToggle={vi.fn()} onRun={onRun} onViewRuns={vi.fn()} />
);
fireEvent.click(screen.getByTestId('flow-run-flow-1'));
expect(onRun).toHaveBeenCalledWith(makeFlow());
});
it('renders a "View runs" control and calls onViewRuns with the flow when clicked', () => {
const onViewRuns = vi.fn();
renderWithProviders(
<FlowListRow flow={makeFlow()} onToggle={vi.fn()} onRun={vi.fn()} onViewRuns={onViewRuns} />
);
const viewRunsButton = screen.getByTestId('flow-view-runs-flow-1');
expect(viewRunsButton).toHaveTextContent('View runs');
fireEvent.click(viewRunsButton);
expect(onViewRuns).toHaveBeenCalledWith(makeFlow());
});
it('shows the running label and disables Run while busy', () => {
renderWithProviders(
<FlowListRow flow={makeFlow()} onToggle={vi.fn()} onRun={vi.fn()} busy="run" />
<FlowListRow
flow={makeFlow()}
onToggle={vi.fn()}
onRun={vi.fn()}
onViewRuns={vi.fn()}
busy="run"
/>
);
const runButton = screen.getByTestId('flow-run-flow-1');
@@ -131,16 +165,15 @@ describe('FlowListRow', () => {
it('disables the toggle while busy=toggle', () => {
renderWithProviders(
<FlowListRow flow={makeFlow()} onToggle={vi.fn()} onRun={vi.fn()} busy="toggle" />
<FlowListRow
flow={makeFlow()}
onToggle={vi.fn()}
onRun={vi.fn()}
onViewRuns={vi.fn()}
busy="toggle"
/>
);
expect(screen.getByTestId('flow-toggle-flow-1')).toBeDisabled();
});
it('does not render a "View runs" control', () => {
renderWithProviders(<FlowListRow flow={makeFlow()} onToggle={vi.fn()} onRun={vi.fn()} />);
expect(screen.queryByTestId('flow-view-runs-flow-1')).not.toBeInTheDocument();
expect(screen.queryByText('View runs')).not.toBeInTheDocument();
});
});
+13 -5
View File
@@ -8,10 +8,9 @@
* canonical boolean control — see `components/settings/controls`) since
* enable/disable here is a persistent setting, not a one-off action.
*
* No "View runs" action yet: it would only stub-log a `selectedFlowId` with
* nothing to show for it until B3b's run inspector lands (tracked as a
* commented-out integration point in `FlowsPage.tsx`), so it's a dead button
* until then and was pulled rather than shipped as a no-op.
* "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.
*/
import { useT } from '../../lib/i18n/I18nContext';
import type { Flow } from '../../services/api/flowsApi';
@@ -28,6 +27,7 @@ export interface FlowListRowProps {
flow: Flow;
onToggle: (flow: Flow) => void;
onRun: (flow: Flow) => void;
onViewRuns: (flow: Flow) => void;
busy?: FlowListRowBusy;
}
@@ -56,7 +56,7 @@ function capitalize(value: string): string {
return value.length > 0 ? value.charAt(0).toUpperCase() + value.slice(1) : value;
}
const FlowListRow = ({ flow, onToggle, onRun, busy = null }: FlowListRowProps) => {
const FlowListRow = ({ flow, onToggle, onRun, onViewRuns, busy = null }: FlowListRowProps) => {
const { t } = useT();
const toggleBusy = busy === 'toggle';
const runBusy = busy === 'run';
@@ -95,6 +95,14 @@ const FlowListRow = ({ flow, onToggle, onRun, busy = null }: FlowListRowProps) =
aria-label={t('flows.list.toggleEnabled')}
onCheckedChange={() => onToggle(flow)}
/>
<Button
type="button"
variant="secondary"
size="sm"
data-testid={`flow-view-runs-${flow.id}`}
onClick={() => onViewRuns(flow)}>
{t('flows.list.viewRuns')}
</Button>
<Button
type="button"
variant="secondary"
@@ -30,8 +30,13 @@ import type { FlowRunStatus, FlowRunStep } from '../../services/api/flowsApi';
const log = debug('flows:run-inspector-drawer');
/** Accent classes per run status (semantic palette from tailwind.config.js). */
const FLOW_RUN_STATUS_ACCENT: Record<FlowRunStatus, string> = {
/**
* Accent classes per run status (semantic palette from tailwind.config.js).
* Exported so {@link FlowRunsDrawer} (issue B5a.1) can reuse the same
* status-pill visual language for its run-history rows instead of
* duplicating the mapping.
*/
export const FLOW_RUN_STATUS_ACCENT: Record<FlowRunStatus, string> = {
running:
'border-ocean-200 bg-ocean-50 text-ocean-700 dark:border-ocean-500/30 dark:bg-ocean-500/10 dark:text-ocean-300',
completed:
@@ -42,15 +47,16 @@ const FLOW_RUN_STATUS_ACCENT: Record<FlowRunStatus, string> = {
'border-coral-200 bg-coral-50 text-coral-700 dark:border-coral-500/30 dark:bg-coral-500/10 dark:text-coral-300',
};
/** Header status dot per run status — mirrors `PHASE_STATUS_DOT`. */
const FLOW_RUN_STATUS_DOT: Record<FlowRunStatus, string> = {
/** Header status dot per run status — mirrors `PHASE_STATUS_DOT`. Exported, see above. */
export const FLOW_RUN_STATUS_DOT: Record<FlowRunStatus, string> = {
running: 'bg-ocean-500 animate-pulse',
completed: 'bg-sage-500',
pending_approval: 'bg-amber-500 animate-pulse',
failed: 'bg-coral-500',
};
const FLOW_RUN_STATUS_KEY: Record<FlowRunStatus, string> = {
/** i18n key per run status. Exported, see above. */
export const FLOW_RUN_STATUS_KEY: Record<FlowRunStatus, string> = {
running: 'flowRuns.status.running',
completed: 'flowRuns.status.completed',
pending_approval: 'flowRuns.status.pending_approval',
@@ -0,0 +1,196 @@
/**
* FlowRunsDrawer (issue B5a.1) — rendering contract.
*
* Asserts: renders null when `flowId` is null; loading state; renders fetched
* runs (status pill, started-at, truncated id); empty state; error state;
* clicking a run opens `FlowRunInspectorDrawer` for it, stacked on top;
* closing the inspector returns to the runs list; Escape/backdrop/✕ close the
* runs drawer itself.
*
* Mocks `flowsApi.listFlowRuns` (the one-shot fetch this drawer owns) and
* `FlowRunInspectorDrawer` (its own behavior is covered by
* `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 { Provider } from 'react-redux';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { FlowRun } from '../../services/api/flowsApi';
import { store } from '../../store';
import { FlowRunsDrawer } from './FlowRunsDrawer';
const listFlowRuns = vi.hoisted(() => vi.fn());
vi.mock('../../services/api/flowsApi', () => ({ listFlowRuns }));
const FlowRunInspectorDrawer = vi.hoisted(() => vi.fn());
vi.mock('./FlowRunInspectorDrawer', () => ({
FLOW_RUN_STATUS_ACCENT: {
running: 'accent-running',
completed: 'accent-completed',
pending_approval: 'accent-pending',
failed: 'accent-failed',
},
FLOW_RUN_STATUS_DOT: {
running: 'dot-running',
completed: 'dot-completed',
pending_approval: 'dot-pending',
failed: 'dot-failed',
},
FLOW_RUN_STATUS_KEY: {
running: 'flowRuns.status.running',
completed: 'flowRuns.status.completed',
pending_approval: 'flowRuns.status.pending_approval',
failed: 'flowRuns.status.failed',
},
FlowRunInspectorDrawer: (props: { runId: string | null; onClose: () => void }) => {
FlowRunInspectorDrawer(props);
if (!props.runId) return null;
return (
<div data-testid="mock-inspector">
<span>{props.runId}</span>
<button type="button" data-testid="mock-inspector-close" onClick={props.onClose}>
close inspector
</button>
</div>
);
},
}));
function makeRun(overrides: Partial<FlowRun> = {}): 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(
<Provider store={store}>
<FlowRunsDrawer flowId={flowId} flowName={flowName} onClose={onClose} />
</Provider>
);
}
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();
});
});
+218
View File
@@ -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<FlowRun[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedRunId, setSelectedRunId] = useState<string | null>(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 (
<>
<div className="fixed inset-0 z-50 flex justify-end" data-testid="flow-runs-drawer">
{/* Backdrop */}
<button
type="button"
aria-label={t('conversations.subagent.close')}
data-testid="flow-runs-backdrop"
className="absolute inset-0 bg-stone-900/30 dark:bg-black/50"
onClick={onClose}
/>
<aside className="relative flex h-full w-full max-w-md flex-col bg-surface shadow-xl">
{/* Header */}
<header className="flex items-center gap-2.5 border-b border-line px-4 py-3">
<span className="min-w-0 flex-1 truncate font-semibold text-content">{title}</span>
<button
type="button"
data-testid="flow-runs-close"
onClick={onClose}
aria-label={t('conversations.subagent.close')}
className="shrink-0 rounded-full p-1.5 text-content-faint hover:bg-surface-hover hover:text-content-secondary">
</button>
</header>
<div className="flex-1 overflow-y-auto px-4 py-4">
{loading && (
<div
className="flex items-center gap-2 py-8 text-content-faint"
data-testid="flow-runs-loading">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-ocean-500 border-t-transparent" />
<span className="text-sm">{t('flows.runs.loading')}</span>
</div>
)}
{error && (
<div
role="alert"
data-testid="flow-runs-error"
className="rounded-xl border border-coral-200 bg-coral-50 px-3 py-2 text-xs text-coral-700 dark:border-coral-500/30 dark:bg-coral-500/10 dark:text-coral-300">
{t('flows.runs.loadError')}: {error}
</div>
)}
{!loading && !error && runs.length === 0 && (
<p
className="py-8 text-center text-xs italic text-content-faint"
data-testid="flow-runs-empty">
{t('flows.runs.empty')}
</p>
)}
{!loading && !error && runs.length > 0 && (
<ul className="space-y-2" data-testid="flow-runs-list">
{runs.map(run => {
const startedAt = formatTimestamp(run.started_at);
return (
<li key={run.id}>
<button
type="button"
data-testid={`flow-run-row-${run.id}`}
onClick={() => setSelectedRunId(run.id)}
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]}`}
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])}
</span>
{startedAt && (
<span className="truncate text-content-muted">{startedAt}</span>
)}
<span className="ml-auto truncate font-mono text-[10px] text-content-faint">
{run.id.slice(0, 8)}
</span>
</button>
</li>
);
})}
</ul>
)}
</div>
</aside>
</div>
{selectedRunId && (
<FlowRunInspectorDrawer runId={selectedRunId} onClose={() => setSelectedRunId(null)} />
)}
</>
);
}
export default FlowRunsDrawer;
+6
View File
@@ -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':
+6
View File
@@ -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':
+6
View File
@@ -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':
+6
View File
@@ -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':
+6
View File
@@ -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':
+6
View File
@@ -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':
+6
View File
@@ -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':
+6
View File
@@ -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':
+6
View File
@@ -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':
+6
View File
@@ -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':
+6
View File
@@ -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':
+6
View File
@@ -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':
+6
View File
@@ -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':
+6
View File
@@ -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 跳转。请重试。',
+55 -7
View File
@@ -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<typeof import('react-router-dom')>();
return { ...actual, useNavigate: () => mockNavigate };
});
function makeFlow(overrides: Partial<Flow> = {}): 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(<FlowsPage />);
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(<FlowsPage />);
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(<FlowsPage />);
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(<FlowsPage />);
const emptyStateButton = await screen.findByTestId('flows-empty-new-workflow');
fireEvent.click(emptyStateButton);
expect(mockNavigate).toHaveBeenCalledWith('/chat');
});
});
+58 -19
View File
@@ -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<Flow[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [busyKey, setBusyKey] = useState<BusyKey | null>(null);
const [toasts, setToasts] = useState<ToastNotification[]>([]);
// 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<string | null>(null);
const addToast = useCallback((toast: Omit<ToastNotification, 'id'>) => {
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 (
<PanelPage
testId="flows-page"
title={t('flows.page.title')}
description={t('flows.page.description')}>
description={t('flows.page.description')}
action={
<Button
type="button"
variant="primary"
size="sm"
data-testid="flows-new-workflow"
onClick={handleNewWorkflow}>
{t('flows.page.newWorkflow')}
</Button>
}>
<div className="mx-auto w-full max-w-3xl space-y-4">
{error && (
<div data-testid="flows-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}
/>
))}
</div>
)}
{/* === 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 && (
<FlowRunInspectorRunsForFlow
flowId={selectedFlowId}
onClose={() => setSelectedFlowId(null)}
/>
)} */}
</div>
<FlowRunsDrawer
flowId={selectedFlowId}
flowName={selectedFlow?.name}
onClose={() => setSelectedFlowId(null)}
/>
<ToastContainer notifications={toasts} onRemove={removeToast} />
</PanelPage>
);