Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 88 additions & 2 deletions webui/src/pages/Task/QueuedSection.test.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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 }) => <div>session-chat-live:{String(live)}</div>),
}));

vi.mock('react-i18next', () => ({
Expand Down Expand Up @@ -110,7 +111,7 @@ vi.mock('@/components/common/EmptyState', () => ({
}));

vi.mock('@/components/common/SessionChat', () => ({
default: () => <div>session-chat</div>,
default: (props: { live?: boolean }) => mocks.sessionChat(props),
}));

vi.mock('./components', () => ({
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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(<QueuedSection onRefreshGlobal={vi.fn()} />);

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(<QueuedSection onRefreshGlobal={vi.fn()} />);

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);
});
});
48 changes: 35 additions & 13 deletions webui/src/pages/Task/QueuedSection.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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');
Expand 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<string>();
prev.forEach(id => {
if (visibleIds.has(id)) {
Expand All @@ -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') {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
});
Expand All @@ -211,7 +233,7 @@ export default function QueuedSection({ onRefreshGlobal }: { onRefreshGlobal: ()
setSelectedTasks(new Set());
};

if (loading && tasks.length === 0) return <div className="flex justify-center py-12"><LoadingSpinner /></div>;
if (loading && effectiveTasks.length === 0) return <div className="flex justify-center py-12"><LoadingSpinner /></div>;
if (error) return <div className="text-center py-12 text-red-500">{error}</div>;

return (
Expand Down Expand Up @@ -240,7 +262,7 @@ export default function QueuedSection({ onRefreshGlobal }: { onRefreshGlobal: ()
</div>

<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
{tasks.length === 0 ? (
{effectiveTasks.length === 0 ? (
<EmptyState
icon={<ListTodo className="w-8 h-8" />}
title={t('queued.emptyTitle')}
Expand Down Expand Up @@ -268,7 +290,7 @@ export default function QueuedSection({ onRefreshGlobal }: { onRefreshGlobal: ()
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{tasks.map(task => (
{effectiveTasks.map(task => (
<tr
key={task.id}
onClick={() => openDetail(task)}
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -434,7 +456,7 @@ function QueuedDetailPanel({ task, onClose, onAction, onRefresh }: {
) : (
<SessionChat
sessionId={sessionId}
live={isActive}
live={shouldStreamSession}
hideInput
emptyText={emptyText}
className="flex-1 min-h-0"
Expand Down