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..39a34a9a 100644 --- a/web/app/src/hooks/workspace/useTaskController.ts +++ b/web/app/src/hooks/workspace/useTaskController.ts @@ -21,7 +21,7 @@ import { import { WorkspacePaneTypes } from "@/models/routing"; import { errorMessage, type ApiError } from "@/api/client"; import { rootTaskForTask, type WorkspaceTask, rootTasks, shouldPollTransitionalTasks } from "@/models/tasks"; -import type { WorkspaceScheduledTaskRun } from "@/models/scheduledTasks"; +import type { WorkspaceScheduledTask, WorkspaceScheduledTaskRun } from "@/models/scheduledTasks"; import { workspaceQueryKeys } from "./workspaceQueries"; import type { AgentLike } from "@/models/agents"; import type { IMConversation, TranslateFn } from "@/models/conversations"; @@ -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; @@ -95,27 +96,33 @@ export function useTaskController({ const tasks = useMemo(() => tasksQuery.data ?? [], [tasksQuery.data]); const scheduledTasks = useMemo(() => scheduledTasksQuery.data ?? [], [scheduledTasksQuery.data]); const visibleScheduledTaskID = selectedScheduledTaskID || scheduledTasks[0]?.id || ""; - const scheduledTaskIDsKey = useMemo(() => scheduledTaskIDsCacheKey(scheduledTasks), [scheduledTasks]); + const scheduledTaskRunsCacheKey = useMemo(() => scheduledTaskRunsMetadataCacheKey(scheduledTasks), [scheduledTasks]); 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({ - queryKey: scheduledTaskRunsAllQueryKey(scheduledTaskIDsKey), + queryKey: scheduledTaskRunsAllQueryKey(scheduledTaskRunsCacheKey), queryFn: async () => { 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 ?? []), [scheduledTaskRuns, scheduledTaskRunQueries.data], ); + const visibleScheduledTaskRuns = useMemo( + () => allScheduledTaskRuns.filter((run) => run.scheduled_task_id === visibleScheduledTaskID), + [allScheduledTaskRuns, visibleScheduledTaskID], + ); const scheduledGeneratedTaskIDs = useMemo( () => new Set(allScheduledTaskRuns.map((run) => run.task_id).filter(Boolean)), [allScheduledTaskRuns], @@ -149,12 +156,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), @@ -170,11 +178,11 @@ export function useTaskController({ const refreshScheduledTaskState = useCallback(async () => { const taskResult = await refetchScheduledTasks(); const nextScheduledTasks = taskResult.data ?? scheduledTasks; - const nextTaskIDsKey = scheduledTaskIDsCacheKey(nextScheduledTasks); + const nextRunsCacheKey = scheduledTaskRunsMetadataCacheKey(nextScheduledTasks); const nextRuns = nextScheduledTasks.length ? (await Promise.all(nextScheduledTasks.map((item) => fetchScheduledTaskRuns(item.id)))).flat() : []; - queryClient.setQueryData(scheduledTaskRunsAllQueryKey(nextTaskIDsKey), nextRuns); + queryClient.setQueryData(scheduledTaskRunsAllQueryKey(nextRunsCacheKey), nextRuns); const nextVisibleScheduledTaskID = selectedScheduledTaskID || nextScheduledTasks[0]?.id || ""; if (nextVisibleScheduledTaskID) { queryClient.setQueryData( @@ -220,7 +228,6 @@ export function useTaskController({ queryClient.setQueryData(TASKS_QUERY_KEY, (current) => mergeWorkspaceTaskList(current ?? [], nextTasks), ); - await refreshScheduledTaskState(); } catch { return; } @@ -235,7 +242,7 @@ export function useTaskController({ window.clearTimeout(timer); } }; - }, [queryClient, refreshScheduledTaskState, shouldPollTasks]); + }, [queryClient, shouldPollTasks]); useEffect(() => { if (taskBoardView !== "scheduled") { @@ -394,7 +401,7 @@ export function useTaskController({ mergeScheduledTaskRuns([run], current), ); queryClient.setQueryData( - scheduledTaskRunsAllQueryKey(scheduledTaskIDsCacheKey(scheduledTasks)), + scheduledTaskRunsAllQueryKey(scheduledTaskRunsMetadataCacheKey(scheduledTasks)), (current = []) => mergeScheduledTaskRuns([run], current), ); await refreshScheduledTaskState(); @@ -564,7 +571,7 @@ export function useTaskController({ taskEvents, teams, scheduledTasks, - scheduledTaskRuns, + scheduledTaskRuns: visibleScheduledTaskRuns, selectedScheduledTaskID: visibleScheduledTaskID, activeView: taskBoardView, createTaskModalView, @@ -669,6 +676,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"; } @@ -694,8 +729,10 @@ function mergeScheduledTaskRuns( return Array.from(byID.values()).sort((left, right) => right.triggered_at.localeCompare(left.triggered_at)); } -function scheduledTaskIDsCacheKey(tasks: readonly { id: string }[]): string { - return tasks.map((item) => item.id).join("|"); +function scheduledTaskRunsMetadataCacheKey(tasks: readonly WorkspaceScheduledTask[]): string { + return tasks + .map((item) => [item.id, item.last_run_at, item.updated_at, item.next_run_at, item.enabled ? "1" : "0"].join(":")) + .join("|"); } function workspaceTasksEqual(left: WorkspaceTask, right: WorkspaceTask): boolean { 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)} >