From 9c787fc3e1f3efb9a574c724c0ba6a93ed315082 Mon Sep 17 00:00:00 2001 From: wanghj Date: Thu, 9 Jul 2026 14:04:31 +0800 Subject: [PATCH 1/2] fix(web): tighten task activity polling - keep agent working indicators visible while turn placeholder messages are present - limit scheduled task run polling to the active scheduled task view and active generated runs - clear stale task conversation errors when switching task views or details - cover polling and placeholder edge cases with focused regression tests --- .../useConversationController.test.ts | 19 ++ .../workspace/useConversationController.ts | 11 +- .../src/hooks/workspace/useTaskController.ts | 47 +++- .../components/TasksView/TasksView.tsx | 13 +- .../tests/hooks/useTaskController.test.tsx | 254 +++++++++++++++--- 5 files changed, 300 insertions(+), 44 deletions(-) diff --git a/web/app/src/hooks/workspace/useConversationController.test.ts b/web/app/src/hooks/workspace/useConversationController.test.ts index 172ad24d..e8d1098e 100644 --- a/web/app/src/hooks/workspace/useConversationController.test.ts +++ b/web/app/src/hooks/workspace/useConversationController.test.ts @@ -42,6 +42,14 @@ function agentTextMessage(content: string): IMMessage { }; } +function agentPlaceholderMessage(): IMMessage { + return { + id: "agent-placeholder", + sender_id: "pt-dev", + content: "\u200b", + }; +} + function conversationWithMessages(messages: IMMessage[]): IMConversation { return { id: "room-1", @@ -97,6 +105,17 @@ describe("activityWorkingParticipantsForConversation", () => { expect(participants).toEqual([]); }); + it("keeps a structured tool active after the agent turn placeholder arrives", () => { + const participants = activityWorkingParticipantsForConversation( + conversationWithMessages([toolActivityMessage("running"), agentPlaceholderMessage()]), + "user-admin", + agents, + usersById, + ); + + expect(participants).toEqual([{ id: "pt-dev", name: "dev" }]); + }); + it("keeps an agent working after a PicoClaw legacy tool message without status", () => { const participants = activityWorkingParticipantsForConversation( conversationWithMessages([legacyToolMessage({ command: "run node inline script" })]), diff --git a/web/app/src/hooks/workspace/useConversationController.ts b/web/app/src/hooks/workspace/useConversationController.ts index 00c5d92c..f0d10373 100644 --- a/web/app/src/hooks/workspace/useConversationController.ts +++ b/web/app/src/hooks/workspace/useConversationController.ts @@ -95,6 +95,7 @@ type WorkingParticipantsByConversationId = Record(); for (let index = conversation.messages.length - 1; index >= 0; index -= 1) { const message = conversation.messages[index]; - if (isThreadReply(message) || isToolCallMessage(message)) { + if (isThreadReply(message) || isToolCallMessage(message) || isParticipantActivityTurnPlaceholder(message)) { continue; } @@ -302,7 +307,7 @@ export function activityWorkingParticipantsForConversation( return; } - if (target && !isToolCallMessage(message)) { + if (target && !isToolCallMessage(message) && !isParticipantActivityTurnPlaceholder(message)) { activeToolKeysByParticipantID.delete(target.id); activeLegacyCommandCountsByParticipantID.delete(target.id); } @@ -734,7 +739,7 @@ export function useConversationController({ if (threadMessageKey(payload.room_id, payload.message) === activeThreadKeyRef.current) { setActiveThreadView((current) => appendReplyToThreadView(current, payload.message) ?? null); } - if (!isToolCallMessage(payload.message)) { + if (!isToolCallMessage(payload.message) && !isParticipantActivityTurnPlaceholder(payload.message)) { clearMessageWorkingParticipants(payload.room_id, payload.message.sender_id); } } diff --git a/web/app/src/hooks/workspace/useTaskController.ts b/web/app/src/hooks/workspace/useTaskController.ts index 5f444d97..668b4b08 100644 --- a/web/app/src/hooks/workspace/useTaskController.ts +++ b/web/app/src/hooks/workspace/useTaskController.ts @@ -76,6 +76,7 @@ export function useTaskController({ const [taskActionError, setTaskActionError] = useState(""); const [parentDetailTaskID, setParentDetailTaskID] = useState(""); const lastTaskTabRevalidateAttemptAt = useRef(0); + const scheduledTaskViewActive = activePane.type === WorkspacePaneTypes.task && taskBoardView === "scheduled"; const tasksQuery = useQuery({ queryKey: TASKS_QUERY_KEY, queryFn: fetchGlobalTasks, @@ -83,7 +84,7 @@ export function useTaskController({ const scheduledTasksQuery = useQuery({ queryKey: SCHEDULED_TASKS_QUERY_KEY, queryFn: fetchScheduledTasks, - refetchInterval: taskBoardView === "scheduled" ? TASK_BOARD_POLL_DELAY_MS : false, + refetchInterval: scheduledTaskViewActive ? TASK_BOARD_POLL_DELAY_MS : false, }); const { refetch: refetchScheduledTasks } = scheduledTasksQuery; const { dataUpdatedAt: tasksDataUpdatedAt, isFetching: tasksFetching, refetch: refetchTasks } = tasksQuery; @@ -99,8 +100,7 @@ export function useTaskController({ const scheduledTaskRunsQuery = useQuery({ queryKey: scheduledTaskRunsQueryKey(visibleScheduledTaskID), queryFn: () => fetchScheduledTaskRuns(visibleScheduledTaskID), - enabled: Boolean(visibleScheduledTaskID), - refetchInterval: taskBoardView === "scheduled" && visibleScheduledTaskID ? TASK_BOARD_POLL_DELAY_MS : false, + enabled: scheduledTaskViewActive && Boolean(visibleScheduledTaskID), }); const scheduledTaskRuns = useMemo(() => scheduledTaskRunsQuery.data ?? [], [scheduledTaskRunsQuery.data]); const scheduledTaskRunQueries = useQuery({ @@ -109,8 +109,11 @@ export function useTaskController({ const runs = await Promise.all(scheduledTasks.map((item) => fetchScheduledTaskRuns(item.id))); return runs.flat(); }, - enabled: scheduledTasks.length > 0, - refetchInterval: taskBoardView === "scheduled" && scheduledTasks.length > 0 ? TASK_BOARD_POLL_DELAY_MS : false, + enabled: scheduledTaskViewActive && scheduledTasks.length > 0, + refetchInterval: (query) => + shouldPollScheduledTaskRuns(scheduledTaskViewActive, query.state.data ?? [], tasks) + ? TASK_BOARD_POLL_DELAY_MS + : false, }); const allScheduledTaskRuns = useMemo( () => mergeScheduledTaskRuns(scheduledTaskRuns, scheduledTaskRunQueries.data ?? []), @@ -149,12 +152,13 @@ export function useTaskController({ const shouldPollActiveTaskBoard = useMemo(() => shouldPollTaskBoard(tasks, activeRootTask), [activeRootTask, tasks]); const shouldPollScheduledGeneratedTasks = useMemo( () => + scheduledTaskViewActive && Array.from(scheduledGeneratedTaskIDs).some((taskID) => { const task = tasks.find((item) => item.id === taskID) ?? null; const root = rootTaskForTask(tasks, task); return shouldPollTaskBoard(tasks, root); }), - [scheduledGeneratedTaskIDs, tasks], + [scheduledGeneratedTaskIDs, scheduledTaskViewActive, tasks], ); const shouldPollTasks = useMemo( () => shouldPollActiveTaskBoard || shouldPollScheduledGeneratedTasks || shouldPollTransitionalTasks(tasks), @@ -220,7 +224,6 @@ export function useTaskController({ queryClient.setQueryData(TASKS_QUERY_KEY, (current) => mergeWorkspaceTaskList(current ?? [], nextTasks), ); - await refreshScheduledTaskState(); } catch { return; } @@ -235,7 +238,7 @@ export function useTaskController({ window.clearTimeout(timer); } }; - }, [queryClient, refreshScheduledTaskState, shouldPollTasks]); + }, [queryClient, shouldPollTasks]); useEffect(() => { if (taskBoardView !== "scheduled") { @@ -669,6 +672,34 @@ function shouldPollTaskBoard(tasks: readonly WorkspaceTask[], root: WorkspaceTas return tasks.some((task) => task.parent_id === root.id && TASK_BOARD_POLL_STATUSES.has(task.status)); } +function shouldPollScheduledTaskRuns( + scheduledTaskViewActive: boolean, + runs: readonly WorkspaceScheduledTaskRun[], + tasks: readonly WorkspaceTask[], +): boolean { + return scheduledTaskViewActive && runs.some((run) => scheduledTaskRunNeedsPolling(run, tasks)); +} + +function scheduledTaskRunNeedsPolling(run: WorkspaceScheduledTaskRun, tasks: readonly WorkspaceTask[]): boolean { + const status = String(run.status || "") + .trim() + .toLowerCase(); + if (["failed", "completed", "done", "canceled", "cancelled"].includes(status)) { + return false; + } + + const taskID = String(run.task_id || "").trim(); + if (!taskID) { + return true; + } + const task = tasks.find((item) => item.id === taskID) ?? null; + if (!task) { + return true; + } + const root = rootTaskForTask(tasks, task); + return shouldPollTaskBoard(tasks, root); +} + function isSchedulerGeneratedTask(task: WorkspaceTask): boolean { return String(task.created_by || "").trim() === "scheduler"; } diff --git a/web/app/src/pages/TasksPage/components/TasksView/TasksView.tsx b/web/app/src/pages/TasksPage/components/TasksView/TasksView.tsx index b3313b2d..f8b56b97 100644 --- a/web/app/src/pages/TasksPage/components/TasksView/TasksView.tsx +++ b/web/app/src/pages/TasksPage/components/TasksView/TasksView.tsx @@ -462,6 +462,10 @@ export function TasksView({ const [conversationOpenError, setConversationOpenError] = useState(""); const availableRoomIDs = useMemo(() => (rooms ? new Set(rooms.map((room) => room.id)) : null), [rooms]); + useEffect(() => { + setConversationOpenError(""); + }, [activeView, selectedScheduledTask?.id]); + useEffect(() => { if (!showCreateTaskModal) { return; @@ -639,14 +643,21 @@ export function TasksView({ function openRootTaskDetail(task: WorkspaceTask) { setParentDialogTaskID(task.id); + setConversationOpenError(""); } function closeRootTaskDetail() { setParentDialogTaskID(""); + setConversationOpenError(""); onCloseParentTaskDetail?.(); void onCloseTaskDetails?.(); } + function selectScheduledTask(taskID: string) { + setConversationOpenError(""); + onSelectScheduledTask(taskID); + } + function openRunTask(taskID: string) { setSelectedGeneratedTaskID(taskID); setConversationOpenError(""); @@ -766,7 +777,7 @@ export function TasksView({ type="button" className={styles.scheduledTaskRow} data-active={selectedScheduledTask?.id === item.id ? true : undefined} - onClick={() => onSelectScheduledTask(item.id)} + onClick={() => selectScheduledTask(item.id)} >