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 }: { ) : (