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
/>
)