diff --git a/apps/web/src/pages/workspace/agent-activity.tsx b/apps/web/src/pages/workspace/agent-activity.tsx index 1ee43c6b..de7ea944 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,31 @@ 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?.runs.length ?? 0) > 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 { + 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 3fd7faa5..467d23a3 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, @@ -500,6 +505,9 @@ export function StageWorkspace({ toast.error(message); }, [events, mirror]); + // 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); // The waits that belong to this project, from the open decisions. useEffect(() => { let cancelled = false; @@ -522,9 +530,9 @@ export function StageWorkspace({ return () => { cancelled = true; }; - // A coordinator decision arrives with a room event; a coder's package - // request raises none, so every room reload re-reads the open decisions. - }, [projectId, gateDone, snapshot]); + // A coordinator decision arrives with a room event; a coder's package request raises none, so + // the open decisions are read again on every room reload and whenever a run parks on an approval. + }, [projectId, gateDone, snapshot, awaitingApproval]); for (const [at, parts] of committedSteps(liveRun, mirror)) stepsByAt.current.set(at, parts);