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
19 changes: 19 additions & 0 deletions web/app/src/hooks/workspace/useConversationController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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" })]),
Expand Down
11 changes: 8 additions & 3 deletions web/app/src/hooks/workspace/useConversationController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ type WorkingParticipantsByConversationId = Record<string, ConversationWorkingPar

const MESSAGE_WORKING_TIMEOUT_MS = 120_000;
const WORKING_PARTICIPANT_KEY_SEPARATOR = "\u0000";
const PARTICIPANT_ACTIVITY_TURN_PLACEHOLDER = "\u200b";

function clearThreadDraftsForConversation(current: DraftsByThreadKey, conversationID: string): DraftsByThreadKey {
const prefix = `${conversationID}:`;
Expand Down Expand Up @@ -125,6 +126,10 @@ function workingParticipantKey(conversationID: string, participantID: string): s
return `${conversationID}${WORKING_PARTICIPANT_KEY_SEPARATOR}${participantID}`;
}

function isParticipantActivityTurnPlaceholder(message: IMMessage | null | undefined): boolean {
return String(message?.content || "") === PARTICIPANT_ACTIVITY_TURN_PLACEHOLDER;
}

function participantIDFromWorkingKey(conversationID: string, key: string): string {
const prefix = `${conversationID}${WORKING_PARTICIPANT_KEY_SEPARATOR}`;
return key.startsWith(prefix) ? key.slice(prefix.length) : "";
Expand Down Expand Up @@ -208,7 +213,7 @@ function derivedMessageWorkingParticipants(
const repliedTargetIDs = new Set<string>();
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;
}

Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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);
}
}
Expand Down
71 changes: 54 additions & 17 deletions web/app/src/hooks/workspace/useTaskController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -76,14 +76,15 @@ 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,
});
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;
Expand All @@ -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],
Expand Down Expand Up @@ -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),
Expand All @@ -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<WorkspaceScheduledTaskRun[]>(scheduledTaskRunsAllQueryKey(nextTaskIDsKey), nextRuns);
queryClient.setQueryData<WorkspaceScheduledTaskRun[]>(scheduledTaskRunsAllQueryKey(nextRunsCacheKey), nextRuns);
const nextVisibleScheduledTaskID = selectedScheduledTaskID || nextScheduledTasks[0]?.id || "";
if (nextVisibleScheduledTaskID) {
queryClient.setQueryData<WorkspaceScheduledTaskRun[]>(
Expand Down Expand Up @@ -220,7 +228,6 @@ export function useTaskController({
queryClient.setQueryData<WorkspaceTask[]>(TASKS_QUERY_KEY, (current) =>
mergeWorkspaceTaskList(current ?? [], nextTasks),
);
await refreshScheduledTaskState();
} catch {
return;
}
Expand All @@ -235,7 +242,7 @@ export function useTaskController({
window.clearTimeout(timer);
}
};
}, [queryClient, refreshScheduledTaskState, shouldPollTasks]);
}, [queryClient, shouldPollTasks]);

useEffect(() => {
if (taskBoardView !== "scheduled") {
Expand Down Expand Up @@ -394,7 +401,7 @@ export function useTaskController({
mergeScheduledTaskRuns([run], current),
);
queryClient.setQueryData<WorkspaceScheduledTaskRun[]>(
scheduledTaskRunsAllQueryKey(scheduledTaskIDsCacheKey(scheduledTasks)),
scheduledTaskRunsAllQueryKey(scheduledTaskRunsMetadataCacheKey(scheduledTasks)),
(current = []) => mergeScheduledTaskRuns([run], current),
);
await refreshScheduledTaskState();
Expand Down Expand Up @@ -564,7 +571,7 @@ export function useTaskController({
taskEvents,
teams,
scheduledTasks,
scheduledTaskRuns,
scheduledTaskRuns: visibleScheduledTaskRuns,
selectedScheduledTaskID: visibleScheduledTaskID,
activeView: taskBoardView,
createTaskModalView,
Expand Down Expand Up @@ -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";
}
Expand All @@ -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 {
Expand Down
13 changes: 12 additions & 1 deletion web/app/src/pages/TasksPage/components/TasksView/TasksView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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("");
Expand Down Expand Up @@ -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)}
>
<span>
<CalendarClock size={15} aria-hidden="true" />
Expand Down
Loading
Loading