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