diff --git a/apps/web/src/pages/workspace/agent-activity.tsx b/apps/web/src/pages/workspace/agent-activity.tsx index 1ee43c6b..570ab9c5 100644 --- a/apps/web/src/pages/workspace/agent-activity.tsx +++ b/apps/web/src/pages/workspace/agent-activity.tsx @@ -6,12 +6,16 @@ */ import { useQuery } from "@tanstack/react-query"; import type { AgentActivity } from "@corbits-builder/client"; -import { readProjectActivity } from "../../api/activity.ts"; +import { + readProjectActivity, + readProjectRunStates, +} from "../../api/activity.ts"; import { keys } from "../../queries/keys.ts"; import { wording } from "../../config.ts"; const POLL_MS = 10_000; const BUSY_POLL_MS = 3_000; +const RUN_STATE_POLL_MS = 5_000; const text = wording.activity; // The calls that hold until the person answers. const ASKS = new Set(["instruct_coder", "request_approval", "design_app"]); @@ -126,6 +130,34 @@ export function useProjectActivity(projectId: string) { }); } +/** + * Each live run's state by its latest committed event, polled while any run is live. A run that + * parks on an approval raises no room event, so this is how the page learns the person's turn came. + */ +export function useProjectRunStates(projectId: string) { + return useQuery({ + queryKey: keys.projectRunStates.of(projectId), + queryFn: ({ signal }) => readProjectRunStates(projectId, signal), + refetchInterval: (query) => + query.state.data !== undefined && query.state.data.runs.length > 0 + ? RUN_STATE_POLL_MS + : POLL_MS, + refetchOnWindowFocus: false, + }); +} + +/** The runs held on an approval, as one string a React effect can depend on. */ +export function heldOnApproval( + states: { runs: readonly { runId: string; state: string }[] } | undefined, +): string { + if (states === undefined) return ""; + return states.runs + .filter((run) => run.state === "approval") + .map((run) => run.runId) + .sort() + .join(","); +} + /** The roles with a run mid-turn right now, from the hub's native activity read. */ export function useRolesAtWork(projectId: string): ReadonlySet { const agents = useProjectActivity(projectId).data?.agents ?? []; diff --git a/apps/web/src/pages/workspace/index.tsx b/apps/web/src/pages/workspace/index.tsx index 60c5504a..f0d79378 100644 --- a/apps/web/src/pages/workspace/index.tsx +++ b/apps/web/src/pages/workspace/index.tsx @@ -34,7 +34,12 @@ import { PromptInputToolbar, useAttachments, } from "@corbits/react-ui"; -import { useProjectActivity, useRolesAtWork } from "./agent-activity.tsx"; +import { + heldOnApproval, + useProjectActivity, + useProjectRunStates, + useRolesAtWork, +} from "./agent-activity.tsx"; import { dispatchLive, DispatchRoomContext, @@ -510,8 +515,12 @@ export function StageWorkspace({ }; }, [projectId, runId]); + // The runs held on an approval, from the polled run states: a coder's package request raises no + // room event, so the room alone would not say the person's turn came until something else moved. + const awaitingApproval = heldOnApproval(useProjectRunStates(projectId).data); // A room that moved may mean a message reached the coordinator from elsewhere, or a coordinator - // or coder decision opened; read who is at work and the open decisions. + // or coder decision opened; read who is at work and the open decisions. A run parking on an + // approval moves no room, so the open decisions are read again then too. useEffect(() => { void queryClient.invalidateQueries({ queryKey: keys.projectActivity.of(projectId), @@ -519,7 +528,7 @@ export function StageWorkspace({ void queryClient.invalidateQueries({ queryKey: keys.openDecisions.of(projectId), }); - }, [projectId, queryClient, snapshot, gateDone]); + }, [projectId, queryClient, snapshot, gateDone, awaitingApproval]); const settling = awaitingCommit(liveRun, mirror); useEffect(() => {