From b8f9702538cb5a7453d88c9c053474ec641a3a3d Mon Sep 17 00:00:00 2001 From: xiami762 <> Date: Mon, 18 May 2026 15:54:44 +0800 Subject: [PATCH] fix(webui): sync task detail drawer with backend polling Merge the open detail snapshot into the visible list, poll execution by id while the drawer is open, and enable SessionChat live streaming whenever a session id exists so completed/running transitions stay accurate. Co-authored-by: Cursor --- webui/src/pages/Task/QueuedSection.test.tsx | 90 ++++++++++++++++++++- webui/src/pages/Task/QueuedSection.tsx | 48 ++++++++--- 2 files changed, 123 insertions(+), 15 deletions(-) diff --git a/webui/src/pages/Task/QueuedSection.test.tsx b/webui/src/pages/Task/QueuedSection.test.tsx index b468276e6..bdbc6ebf4 100644 --- a/webui/src/pages/Task/QueuedSection.test.tsx +++ b/webui/src/pages/Task/QueuedSection.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { render, screen, waitFor } from '@testing-library/react'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -21,6 +21,7 @@ const mocks = vi.hoisted(() => ({ rerunExecution: vi.fn(), deleteExecution: vi.fn(), copyText: vi.fn().mockResolvedValue(undefined), + sessionChat: vi.fn(({ live }: { live?: boolean }) =>
session-chat-live:{String(live)}
), })); vi.mock('react-i18next', () => ({ @@ -110,7 +111,7 @@ vi.mock('@/components/common/EmptyState', () => ({ })); vi.mock('@/components/common/SessionChat', () => ({ - default: () =>
session-chat
, + default: (props: { live?: boolean }) => mocks.sessionChat(props), })); vi.mock('./components', () => ({ @@ -181,6 +182,7 @@ describe('QueuedSection', () => { beforeEach(() => { vi.clearAllMocks(); + vi.useRealTimers(); mocks.confirm.mockResolvedValue(true); mocks.useTaskExecutions.mockImplementation((params?: TaskListParams) => { const tasks = params?.status === 'completed' ? completedTasks : allTasks; @@ -311,4 +313,88 @@ describe('QueuedSection', () => { expect(mocks.copyText).toHaveBeenCalledWith(expect.stringContaining('"keywords": "flocks agent"')); expect(mocks.toastSuccess).toHaveBeenCalled(); }); + + it('带 session 的任务详情始终以 live 模式挂载 SessionChat', async () => { + const user = userEvent.setup(); + const sessionTask = buildExecution( + 'exec-session-live-1', + '会话任务', + 'completed', + { + sessionID: 'ses-task-1', + }, + ); + + mocks.useTaskExecutions.mockReturnValue({ + tasks: [sessionTask], + total: 1, + loading: false, + error: null, + refetch: mocks.refetch, + }); + mocks.getExecution.mockResolvedValue({ data: sessionTask }); + + render(); + + await user.click(screen.getByText('会话任务')); + + expect(await screen.findByText('session-chat-live:true')).toBeInTheDocument(); + expect(mocks.sessionChat).toHaveBeenLastCalledWith( + expect.objectContaining({ + sessionId: 'ses-task-1', + live: true, + hideInput: true, + }), + ); + }); + + it('详情抽屉打开后会按 execution id 轮询最新状态', async () => { + vi.useFakeTimers(); + const staleCompletedTask = buildExecution( + 'exec-poll-1', + '轮询任务', + 'completed', + { + sessionID: 'ses-task-poll', + }, + ); + const runningTask = { + ...staleCompletedTask, + status: 'running' as const, + completedAt: undefined, + }; + + mocks.useTaskExecutions.mockReturnValue({ + tasks: [staleCompletedTask], + total: 1, + loading: false, + error: null, + refetch: mocks.refetch, + }); + mocks.getExecution + .mockResolvedValueOnce({ data: staleCompletedTask }) + .mockResolvedValueOnce({ data: runningTask }); + + render(); + + fireEvent.click(screen.getByText('轮询任务')); + + await act(async () => { + await Promise.resolve(); + }); + + expect(screen.getAllByText('completed').length).toBeGreaterThan(0); + + await act(async () => { + await vi.advanceTimersByTimeAsync(30000); + }); + + expect(mocks.getExecution).toHaveBeenCalledTimes(2); + + await act(async () => { + await Promise.resolve(); + }); + + expect(screen.getAllByText('running').length).toBeGreaterThan(1); + }); }); diff --git a/webui/src/pages/Task/QueuedSection.tsx b/webui/src/pages/Task/QueuedSection.tsx index ab5c467e2..68a33fc31 100644 --- a/webui/src/pages/Task/QueuedSection.tsx +++ b/webui/src/pages/Task/QueuedSection.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback, useRef } from 'react'; +import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { ListTodo, Play, RotateCcw, XCircle, Trash2, @@ -15,6 +15,8 @@ import { copyText } from '@/utils/clipboard'; import { StatusBadge, PriorityBadge, SourceBadge, ModeBadge, ActionButton } from './components'; import { formatTime, formatDuration, PAGE_SIZE } from './helpers'; +const DETAIL_POLL_INTERVAL_MS = 30000; + export default function QueuedSection({ onRefreshGlobal }: { onRefreshGlobal: () => void }) { const { t } = useTranslation('task'); const [filterKey, setFilterKey] = useState('all'); @@ -35,28 +37,33 @@ export default function QueuedSection({ onRefreshGlobal }: { onRefreshGlobal: () const listParams = { ...currentFilter, offset: page * PAGE_SIZE, limit: PAGE_SIZE }; const { tasks, total, loading, error, refetch } = useTaskExecutions(listParams, { pollInterval: 5000 }); + const effectiveTasks = useMemo(() => { + if (!detailTask) return tasks; + return tasks.map((task) => (task.id === detailTask.id ? detailTask : task)); + }, [tasks, detailTask]); const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); const refresh = useCallback(() => { refetch(); onRefreshGlobal(); }, [refetch, onRefreshGlobal]); - const visibleSelectedIds = tasks + const visibleSelectedIds = effectiveTasks .filter(task => selectedTasks.has(task.id)) .map(task => task.id); const hasVisibleSelection = visibleSelectedIds.length > 0; - const allVisibleSelected = tasks.length > 0 && tasks.every(task => selectedTasks.has(task.id)); + const allVisibleSelected = effectiveTasks.length > 0 && effectiveTasks.every(task => selectedTasks.has(task.id)); // Keep detailTask in sync: update from list data when available, // but never clear it just because the task left the current page. useEffect(() => { if (!selectedId) { setDetailTask(null); return; } + if (detailTask?.id === selectedId) return; const found = tasks.find(t => t.id === selectedId); if (found) setDetailTask(found); - }, [tasks, selectedId]); + }, [tasks, selectedId, detailTask?.id]); // Selection is scoped to the current visible list so users never batch // operate on hidden rows from a previous page or filter. useEffect(() => { setSelectedTasks(prev => { let changed = false; - const visibleIds = new Set(tasks.map(task => task.id)); + const visibleIds = new Set(effectiveTasks.map(task => task.id)); const next = new Set(); prev.forEach(id => { if (visibleIds.has(id)) { @@ -67,7 +74,7 @@ export default function QueuedSection({ onRefreshGlobal }: { onRefreshGlobal: () }); return changed ? next : prev; }); - }, [tasks]); + }, [effectiveTasks]); const markViewedIfNeeded = useCallback(async (task: TaskExecution) => { if (task.status !== 'completed' || task.deliveryStatus !== 'unread') { @@ -101,6 +108,21 @@ export default function QueuedSection({ onRefreshGlobal }: { onRefreshGlobal: () } }, [refresh, selectedId, fetchDetailTask]); + // The execution detail drawer must stay in sync even when the selected row + // disappears from the current list/filter (for example when a stale + // "completed" item becomes running again, or vice versa). Poll by ID while + // the drawer is open so the badge and SessionChat live-state reflect the + // latest backend truth instead of the last list snapshot. + useEffect(() => { + if (!selectedId) return; + + const timerId = window.setInterval(() => { + void fetchDetailTask(selectedId); + }, DETAIL_POLL_INTERVAL_MS); + + return () => window.clearInterval(timerId); + }, [selectedId, fetchDetailTask]); + const closeDetail = useCallback(() => { setSelectedId(null); setDetailTask(null); @@ -191,9 +213,9 @@ export default function QueuedSection({ onRefreshGlobal }: { onRefreshGlobal: () setSelectedTasks(prev => { const next = new Set(prev); if (allVisibleSelected) { - tasks.forEach(task => next.delete(task.id)); + effectiveTasks.forEach(task => next.delete(task.id)); } else { - tasks.forEach(task => next.add(task.id)); + effectiveTasks.forEach(task => next.add(task.id)); } return next; }); @@ -211,7 +233,7 @@ export default function QueuedSection({ onRefreshGlobal }: { onRefreshGlobal: () setSelectedTasks(new Set()); }; - if (loading && tasks.length === 0) return
; + if (loading && effectiveTasks.length === 0) return
; if (error) return
{error}
; return ( @@ -240,7 +262,7 @@ export default function QueuedSection({ onRefreshGlobal }: { onRefreshGlobal: ()
- {tasks.length === 0 ? ( + {effectiveTasks.length === 0 ? ( } title={t('queued.emptyTitle')} @@ -268,7 +290,7 @@ export default function QueuedSection({ onRefreshGlobal }: { onRefreshGlobal: () - {tasks.map(task => ( + {effectiveTasks.map(task => ( openDetail(task)} @@ -333,8 +355,8 @@ function QueuedDetailPanel({ task, onClose, onAction, onRefresh }: { const DRAWER_MAX_WIDTH = 960; const { t } = useTranslation('task'); const sessionId = task.sessionID; - const isActive = ['queued', 'running'].includes(task.status); const isWorkflowExecution = task.executionMode === 'workflow'; + const shouldStreamSession = Boolean(sessionId); const emptyText = ['pending', 'queued'].includes(task.status) ? t('queued.detailWaiting') : t('queued.detailNoRecord'); @@ -434,7 +456,7 @@ function QueuedDetailPanel({ task, onClose, onAction, onRefresh }: { ) : (