From 1f4845e1a95673808148a8f6e71d5c291ac6912e Mon Sep 17 00:00:00 2001 From: Alberto Perdomo Date: Wed, 2 Sep 2026 14:53:12 +0100 Subject: [PATCH] fix(flows): stay on the flows list after starting a run The workspace Flows section passed `navigateToHistoryOnRun`, so clicking Run on a flow card yanked the user to the run history view. The web list already refreshed in place; the workspace now behaves the same. To make staying informative, the list silently re-fetches every 5s while any visible flow has an active run (`running`/`waiting_for_human`), so badges and run states track progress without navigating away or flashing the loader. Polling tears itself down once no run is active. Co-Authored-By: Claude Code --- .../flows/__tests__/flows-page.test.tsx | 50 +++++++++++++++---- apps/web/src/components/flows/flows-page.tsx | 36 ++++++++----- .../workspace/workspace-flows-view.tsx | 1 - 3 files changed, 64 insertions(+), 23 deletions(-) diff --git a/apps/web/src/components/flows/__tests__/flows-page.test.tsx b/apps/web/src/components/flows/__tests__/flows-page.test.tsx index 99e3a9de..1b981c92 100644 --- a/apps/web/src/components/flows/__tests__/flows-page.test.tsx +++ b/apps/web/src/components/flows/__tests__/flows-page.test.tsx @@ -1,6 +1,6 @@ /** @vitest-environment jsdom */ import { FlowRunStatus, FlowRunTrigger } from '@prisma/client' -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { FlowsPage } from '@/components/flows/flows-page' @@ -175,14 +175,46 @@ describe('FlowsPage', () => { expect(screen.getByText('Running')).toBeTruthy() }) - it('runs flows from the desktop list and navigates to history', async () => { - render() - await waitFor(() => expect(screen.getByText('Weekly Review')).toBeTruthy()) - - fireEvent.click(screen.getByRole('button', { name: 'Run' })) - await waitFor(() => expect(clientMocks.runFlowRequest).toHaveBeenCalledWith('alice', 'flow-1')) - expect(clientMocks.push).toHaveBeenCalledWith('/u/alice/flows/flow-1/runs') - expect(clientMocks.fetchFlowList).toHaveBeenCalledTimes(1) + it('keeps the list in place after a run and refreshes it silently while the run is active', async () => { + const pollCallbacks: Array<() => void> = [] + const setIntervalSpy = vi.spyOn(global, 'setInterval').mockImplementation(((callback: () => void) => { + pollCallbacks.push(callback) + return 0 as unknown as ReturnType + }) as typeof setInterval) + const clearIntervalSpy = vi.spyOn(global, 'clearInterval').mockImplementation(() => undefined) + + try { + clientMocks.fetchFlowList + .mockResolvedValueOnce({ ok: true, data: { flows: [createFlow()] } }) + .mockResolvedValueOnce({ ok: true, data: { flows: [createFlow({ latestRun: createRun(FlowRunStatus.running) })] } }) + .mockResolvedValue({ ok: true, data: { flows: [createFlow({ latestRun: createRun(FlowRunStatus.succeeded) })] } }) + + render() + await waitFor(() => expect(screen.getByText('Weekly Review')).toBeTruthy()) + + fireEvent.click(screen.getByRole('button', { name: 'Run' })) + await waitFor(() => expect(clientMocks.runFlowRequest).toHaveBeenCalledWith('alice', 'flow-1')) + await waitFor(() => expect(clientMocks.fetchFlowList).toHaveBeenCalledTimes(2)) + + // Active run registered a silent poll (StrictMode may register the + // effect twice); firing the latest one refreshes without mounting the + // loader or navigating away. + expect(pollCallbacks.length).toBeGreaterThan(0) + await act(async () => { + pollCallbacks[pollCallbacks.length - 1]!() + }) + await waitFor(() => expect(clientMocks.fetchFlowList).toHaveBeenCalledTimes(3)) + + expect(clientMocks.push).not.toHaveBeenCalled() + expect(screen.getByText('Weekly Review')).toBeTruthy() + expect(screen.queryByText('Loading flows...')).toBeNull() + + // Once no run is active, the poll tears itself down. + expect(clearIntervalSpy).toHaveBeenCalled() + } finally { + setIntervalSpy.mockRestore() + clearIntervalSpy.mockRestore() + } }) it('shows action errors from run and network failures', async () => { diff --git a/apps/web/src/components/flows/flows-page.tsx b/apps/web/src/components/flows/flows-page.tsx index f1c88cd1..06a6ca42 100644 --- a/apps/web/src/components/flows/flows-page.tsx +++ b/apps/web/src/components/flows/flows-page.tsx @@ -2,7 +2,6 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import Link from 'next/link' -import { useRouter } from 'next/navigation' import { ClockCountdown, ClockCounterClockwise, DotsThreeVertical, DownloadSimple, GitBranch, PencilSimple, Play, SpinnerGap, TreeStructure } from '@phosphor-icons/react' @@ -17,12 +16,15 @@ import { getFlowErrorMessage } from '@/lib/flows/errors' import type { FlowListItem } from '@/lib/flows/types' import { cn } from '@/lib/utils' +// While any visible flow has an active run, refresh the list quietly so the +// badges track the run without leaving the page. +const ACTIVE_RUN_REFRESH_INTERVAL_MS = 5000 + type FlowsPageProps = { buildCreateHref?: () => string buildEditHref?: (flowId: string) => string buildHistoryHref?: (flowId: string) => string hideHeader?: boolean - navigateToHistoryOnRun?: boolean slug: string } @@ -44,16 +46,15 @@ function getRunBadgeLabel(flow: FlowListItem): string { return 'Last run failed' } -export function FlowsPage({ buildCreateHref, buildEditHref, buildHistoryHref, hideHeader = false, navigateToHistoryOnRun = false, slug }: FlowsPageProps) { - const router = useRouter() +export function FlowsPage({ buildCreateHref, buildEditHref, buildHistoryHref, hideHeader = false, slug }: FlowsPageProps) { const [flows, setFlows] = useState([]) const [isLoading, setIsLoading] = useState(true) const [loadError, setLoadError] = useState(null) const [actionError, setActionError] = useState(null) const [runningFlowId, setRunningFlowId] = useState(null) - const loadFlows = useCallback(async () => { - setIsLoading(true) + const loadFlows = useCallback(async (options: { silent?: boolean } = {}) => { + if (!options.silent) setIsLoading(true) setLoadError(null) try { const result = await fetchFlowList(slug) @@ -66,7 +67,7 @@ export function FlowsPage({ buildCreateHref, buildEditHref, buildHistoryHref, hi } catch { setLoadError('network_error') } finally { - setIsLoading(false) + if (!options.silent) setIsLoading(false) } }, [slug]) @@ -90,18 +91,13 @@ export function FlowsPage({ buildCreateHref, buildEditHref, buildHistoryHref, hi return } - if (navigateToHistoryOnRun) { - router.push(getHistoryHref(flowId)) - return - } - await loadFlows() } catch { setActionError('network_error') } finally { setRunningFlowId(null) } - }, [getHistoryHref, loadFlows, navigateToHistoryOnRun, router, slug]) + }, [loadFlows, slug]) useEffect(() => { let cancelled = false @@ -138,6 +134,20 @@ export function FlowsPage({ buildCreateHref, buildEditHref, buildHistoryHref, hi const sortedFlows = useMemo(() => [...flows].sort((left, right) => left.name.localeCompare(right.name)), [flows]) const myFlows = useMemo(() => sortedFlows.filter((flow) => flow.permissions.isOwner), [sortedFlows]) const teamFlows = useMemo(() => sortedFlows.filter((flow) => !flow.permissions.isOwner), [sortedFlows]) + const hasActiveRun = useMemo( + () => flows.some((flow) => flow.latestRun?.status === 'running' || flow.latestRun?.status === 'waiting_for_human'), + [flows], + ) + + useEffect(() => { + if (!hasActiveRun) return + + const interval = setInterval(() => { + void loadFlows({ silent: true }) + }, ACTIVE_RUN_REFRESH_INTERVAL_MS) + + return () => clearInterval(interval) + }, [hasActiveRun, loadFlows]) function renderFlowGrid(items: FlowListItem[]) { return ( diff --git a/apps/web/src/components/workspace/workspace-flows-view.tsx b/apps/web/src/components/workspace/workspace-flows-view.tsx index 682e8ea1..cb03b831 100644 --- a/apps/web/src/components/workspace/workspace-flows-view.tsx +++ b/apps/web/src/components/workspace/workspace-flows-view.tsx @@ -80,7 +80,6 @@ export function WorkspaceFlowsView({ buildCreateHref={buildCreateHref} buildEditHref={buildEditHref} buildHistoryHref={buildHistoryHref} - navigateToHistoryOnRun /> )