diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 4840629d..99ec337c 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -728,16 +728,16 @@ export function App() { ? opening.text : undefined; + const reportRefusal = async (cause: ProductApiError) => { + const code = await responseCode(cause); + const refused = + code === undefined ? undefined : wording.library.refused[code]; + toast.error(refused ?? humanFault(cause, wording.library.actionFailed)); + void refresh(); + }; const reportProjectError = (cause: unknown) => { if (cause instanceof ApiFailure) toast.error(cause.detail.message); - else if (cause instanceof ProductApiError) - void responseCode(cause).then((code) => { - toast.error( - wording.library.refused[code ?? ""] ?? - humanFault(cause, wording.library.actionFailed), - ); - void refresh(); - }); + else if (cause instanceof ProductApiError) void reportRefusal(cause); else void refresh(); }; diff --git a/apps/web/src/config.ts b/apps/web/src/config.ts index 6b8b2982..a23a3c1f 100644 --- a/apps/web/src/config.ts +++ b/apps/web/src/config.ts @@ -423,6 +423,7 @@ export const wording = { }, activity: { label: "Who is working", + notRead: "Who is working could not be read.", role: { coordinator: "Coordinator", writer: "Writer", @@ -811,6 +812,10 @@ export const wording = { instruct_coder: () => "Send this change to the app builder?", } as Record string>, document: (document: string) => `Happy with the ${document}?`, + /** An ask that names neither a document nor a tool. */ + unnamed: "Happy for this to go ahead?", + /** The settled line of an ask that names neither a document nor a tool. */ + settledUnnamed: "the request", /** A request_approval ask that covers several documents, by their short names. */ documents: (names: readonly string[]) => `Happy with the ${names.slice(0, -1).join(", ")} and ${names.at(-1) ?? ""}?`, @@ -974,6 +979,7 @@ export const wording = { coordinatorStopped: "The coordinator stopped before it replied.", coordinatorNotStarted: "The coordinator for this project did not start.", projectNotOpened: "The project could not be opened.", + projectLoading: "Loading this project…", projectTooShort: "At least ten characters to start a project.", stepFailed: "That step could not finish.", }, diff --git a/apps/web/src/pages/collaborators.tsx b/apps/web/src/pages/collaborators.tsx index e0dd83ee..097499c2 100644 --- a/apps/web/src/pages/collaborators.tsx +++ b/apps/web/src/pages/collaborators.tsx @@ -42,11 +42,12 @@ function expiresInLabel(expiresAt: string): string { async function refusal(cause: unknown): Promise { if (!(cause instanceof ProductApiError)) return cause instanceof Error ? cause.message : String(cause); - const code = (await responseCode(cause)) ?? ""; - return ( - wording.settings.collaborators.refused[code] ?? - wording.settings.collaborators.failed - ); + const code = await responseCode(cause); + const refused = + code === undefined + ? undefined + : wording.settings.collaborators.refused[code]; + return refused ?? wording.settings.collaborators.failed; } export function Collaborators({ user }: { user: HubUser | null }) { diff --git a/apps/web/src/pages/workspace/agent-activity.tsx b/apps/web/src/pages/workspace/agent-activity.tsx index f2688353..d3736137 100644 --- a/apps/web/src/pages/workspace/agent-activity.tsx +++ b/apps/web/src/pages/workspace/agent-activity.tsx @@ -4,7 +4,7 @@ * carried. A phrase is a fixed reading of the latest call, never a guess at * progress. */ -import { useQuery } from "@tanstack/react-query"; +import { queryOptions, useQuery } from "@tanstack/react-query"; import type { AgentActivity } from "@corbits-builder/client"; import { readProjectActivity, @@ -135,8 +135,8 @@ export function activityPhrase(agent: AgentActivity): { }; } -export function useProjectActivity(projectId: string) { - return useQuery({ +export function projectActivityQuery(projectId: string) { + return queryOptions({ queryKey: keys.projectActivity.of(projectId), queryFn: ({ signal }) => readProjectActivity(projectId, signal), // While anyone is mid-turn, a hand-off and each step read sooner. @@ -146,12 +146,16 @@ export function useProjectActivity(projectId: string) { }); } +export function useProjectActivity(projectId: string) { + return useQuery(projectActivityQuery(projectId)); +} + /** * 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({ +export function projectRunStatesQuery(projectId: string) { + return queryOptions({ queryKey: keys.projectRunStates.of(projectId), queryFn: ({ signal }) => readProjectRunStates(projectId, signal), refetchInterval: (query) => @@ -162,6 +166,10 @@ export function useProjectRunStates(projectId: string) { }); } +export function useProjectRunStates(projectId: string) { + return useQuery(projectRunStatesQuery(projectId)); +} + /** 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, @@ -174,8 +182,12 @@ export function heldOnApproval( .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 ?? []; - return new Set(agents.map((agent) => agent.role)); +/** The roles with a run mid-turn right now, from the hub's native activity read; undefined until it is read. */ +export function useRolesAtWork( + projectId: string, +): ReadonlySet | undefined { + const agents = useProjectActivity(projectId).data?.agents; + return agents === undefined + ? undefined + : new Set(agents.map((agent) => agent.role)); } diff --git a/apps/web/src/pages/workspace/artifact-pane.test.tsx b/apps/web/src/pages/workspace/artifact-pane.test.tsx index 1b25798a..dc4f41e3 100644 --- a/apps/web/src/pages/workspace/artifact-pane.test.tsx +++ b/apps/web/src/pages/workspace/artifact-pane.test.tsx @@ -34,6 +34,7 @@ const document: DeliverableState = { content: "Previous content", filedAt: "2026-10-30T11:00:00.000Z", }, + versions: [], }, }; function render( diff --git a/apps/web/src/pages/workspace/collaboration.ts b/apps/web/src/pages/workspace/collaboration.ts index 178ccde3..35588672 100644 --- a/apps/web/src/pages/workspace/collaboration.ts +++ b/apps/web/src/pages/workspace/collaboration.ts @@ -132,14 +132,14 @@ export function shortDocumentName(kind: string, title: string): string { */ export function approvalQuestion( toolName: string | undefined, - document: string, + document: string | undefined, subject?: string, ): string { const asked = toolName === undefined ? undefined : wording.approval.question[toolName]; if (asked !== undefined) return asked(subject); - if (document !== "" || toolName === undefined) - return wording.approval.document(document); + if (document !== undefined) return wording.approval.document(document); + if (toolName === undefined) return wording.approval.unnamed; const tool = toolName .slice(toolName.lastIndexOf(".") + 1) .replaceAll("_", " "); diff --git a/apps/web/src/pages/workspace/deliverable-block.tsx b/apps/web/src/pages/workspace/deliverable-block.tsx index d23a0138..40353377 100644 --- a/apps/web/src/pages/workspace/deliverable-block.tsx +++ b/apps/web/src/pages/workspace/deliverable-block.tsx @@ -56,7 +56,7 @@ export type LoadedDeliverable = ReadVersion & { sha256: string; before: (ReadVersion & { version: number }) | null; historyError?: string; - versions?: readonly RetainedVersion[]; + versions: readonly RetainedVersion[]; }; export type DeliverableState = @@ -238,7 +238,7 @@ function ReadyDocument({ const compareWith = changesOpen ? (baseline ?? predecessor) : null; const afterText = textFor(viewing); const beforeText = compareWith === null ? null : textFor(compareWith); - const viewed = document.versions?.find((item) => item.version === viewing); + const viewed = document.versions.find((item) => item.version === viewing); const mediaType = viewed?.mediaType ?? document.mediaType; const title = viewed?.title ?? document.title; const kind = mediaKind(mediaType, title); @@ -381,7 +381,7 @@ function ReadyDocument({ const barGroups = [ approvalGroup, versionGroup({ - versions: document.versions ?? [], + versions: document.versions, viewing, pending: pendingVersion !== null, canCompare: @@ -526,7 +526,7 @@ function ReadyDocument({ item.version)} + packageVersions={document.versions.map((item) => item.version)} onReadPackageVersion={fetchVersion} shareOpen={shareOpen} onShareOpenChange={setShareOpen} diff --git a/apps/web/src/pages/workspace/dispatch-card.tsx b/apps/web/src/pages/workspace/dispatch-card.tsx index 942b30f1..4ea887a2 100644 --- a/apps/web/src/pages/workspace/dispatch-card.tsx +++ b/apps/web/src/pages/workspace/dispatch-card.tsx @@ -77,9 +77,10 @@ export function dispatchesOf( const role = ROLE_OF[part.name]; if (role === undefined) return []; const pending = part.status !== "output-available"; - const args = record(part.input) ?? {}; - const instruction = str(args["instruction"]); - const kind = str(args["kind"]); + // A call still streaming its arguments has none to read yet. + const args = record(part.input); + const instruction = str(args?.["instruction"]); + const kind = str(args?.["kind"]); const base = { call: part.toolCallId, tool: part.name, @@ -97,8 +98,9 @@ export function dispatchesOf( refused: str(result.content) ?? text.refusedUnknown, }, ]; - const concepts = Array.isArray(args["concepts"]) - ? args["concepts"].flatMap((item) => { + const named = args?.["concepts"]; + const concepts = Array.isArray(named) + ? named.flatMap((item) => { const label = str(record(item)?.["label"]); return label === undefined ? [] : [label]; }) @@ -162,8 +164,8 @@ export type DispatchRoom = { >; /** The native approvals, pending or as their records settled them. */ readonly approvals: readonly DocumentApproval[]; - /** The runs mid-turn, with the tool calls and words their own inference events carried. */ - readonly activity: readonly AgentActivity[]; + /** The runs mid-turn, with the tool calls and words their own inference events carried; undefined until read. */ + readonly activity: readonly AgentActivity[] | undefined; readonly onOpenDocument?: (kind: string) => void; }; diff --git a/apps/web/src/pages/workspace/document-decision.tsx b/apps/web/src/pages/workspace/document-decision.tsx index 08c5e603..8c3925e5 100644 --- a/apps/web/src/pages/workspace/document-decision.tsx +++ b/apps/web/src/pages/workspace/document-decision.tsx @@ -42,9 +42,10 @@ const FOLDED_INSTRUCTION_ASKS = new Set(["design_app"]); function shortName(approval: DocumentApproval): string | undefined { const { document } = approval; - return document === undefined - ? undefined - : shortDocumentName(document.kind, document.title); + if (document === undefined) return undefined; + const name = shortDocumentName(document.kind, document.title); + // A blank title has no name to ask about, so the card falls back to the tool's wording. + return name === "" ? undefined : name; } // The documents a request_approval ask approves together, when it covers more than one. @@ -62,7 +63,7 @@ function decisionTitle(approval: DocumentApproval, shown: boolean): string { ); const question = approvalQuestion( approval.toolName, - shortName(approval) ?? "", + shortName(approval), approval.subject, ); return shown ? `${question} ${text.openOnRight}` : question; @@ -136,12 +137,11 @@ function settledLine(approval: DocumentApproval): string { .map(({ title, version }) => `${title} v${String(version)}`) .join(", "), ); - const settled = - approval.toolName === undefined - ? undefined - : text.settled[approval.toolName]; + if (approval.toolName === undefined) + return text.approved(text.settledUnnamed); + const settled = text.settled[approval.toolName]; return text.approved( - settled ?? approvalQuestion(approval.toolName, "", approval.subject), + settled ?? approvalQuestion(approval.toolName, undefined, approval.subject), ); } diff --git a/apps/web/src/pages/workspace/empty-project.test.tsx b/apps/web/src/pages/workspace/empty-project.test.tsx index 4d64a05e..31a21cd7 100644 --- a/apps/web/src/pages/workspace/empty-project.test.tsx +++ b/apps/web/src/pages/workspace/empty-project.test.tsx @@ -26,6 +26,7 @@ function ready(title: string, content: string): DeliverableState { version: 1, sha256: sha, before: null, + versions: [], }, }; } diff --git a/apps/web/src/pages/workspace/filed-versions.tsx b/apps/web/src/pages/workspace/filed-versions.tsx index 1208a9fe..4fe141a9 100644 --- a/apps/web/src/pages/workspace/filed-versions.tsx +++ b/apps/web/src/pages/workspace/filed-versions.tsx @@ -13,11 +13,10 @@ export type FiledVersion = { export function useFiledVersions(input: { readonly projectId: string; - /** The room has its first read; versions before it are history, not news. */ - readonly ready: boolean; + /** From the room's first read on; versions in that read are history, not news. */ readonly documents: readonly FiledVersion[]; }): readonly FiledVersion[] { - const { projectId, ready, documents } = input; + const { projectId, documents } = input; const seen = useRef | null>(null); const [filed, setFiled] = useState([]); @@ -27,7 +26,6 @@ export function useFiledVersions(input: { }, [projectId]); useEffect(() => { - if (!ready) return; const known = seen.current; seen.current = new Map(documents.map((item) => [item.kind, item.version])); if (known === null) return; @@ -35,7 +33,7 @@ export function useFiledVersions(input: { .filter((item) => item.version > (known.get(item.kind) ?? 0)) .map(({ kind, title, version }) => ({ kind, title, version })); if (fresh.length > 0) setFiled((current) => [...current, ...fresh]); - }, [ready, documents]); + }, [documents]); return filed; } diff --git a/apps/web/src/pages/workspace/index.tsx b/apps/web/src/pages/workspace/index.tsx index 7e320634..3dd009c0 100644 --- a/apps/web/src/pages/workspace/index.tsx +++ b/apps/web/src/pages/workspace/index.tsx @@ -12,7 +12,13 @@ */ import { wording } from "../../config.ts"; import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { + queryOptions, + skipToken, + usePrefetchQuery, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; import { Paperclip, Sparkles } from "lucide-react"; import { Attachment, @@ -36,6 +42,8 @@ import { } from "@corbits/react-ui"; import { heldOnApproval, + projectActivityQuery, + projectRunStatesQuery, useProjectActivity, useProjectRunStates, useRolesAtWork, @@ -121,6 +129,7 @@ import { reloadProjectDocument, roomDocumentKey, roomSnapshotReady, + type RoomSnapshot, } from "./load-room.ts"; import { RoomFault, RoomSkeleton } from "./room-skeleton.tsx"; import { designDraftFiled, useDesignDraft } from "./design-preview.tsx"; @@ -135,7 +144,11 @@ import { } from "./design-concepts.tsx"; import { useLiveAppPresence } from "./live-app.tsx"; import { useQuestionsLayout } from "../../questions-layout-setting.ts"; -import { NextStepLine, useProjectNextStep } from "./next-step.tsx"; +import { + NextStepLine, + projectNextStepQuery, + useProjectNextStep, +} from "./next-step.tsx"; import { composerPlaceholder } from "./composer-placeholder.ts"; import { Dictated } from "../../dictation.jsx"; import { useShellReply } from "../../flourish.tsx"; @@ -147,7 +160,26 @@ import { useModelCatalog } from "../providers.tsx"; const FALLBACK_RELOAD_MS = 30_000; // After a run closes, the mirror's commit can trail its last frame. const SETTLE_RELOAD_MS = [1_000, 3_000, 7_000, 15_000]; -const NO_WAITS: readonly Wait[] = []; + +function acceptedMaterialsQuery(projectId: string) { + return queryOptions({ + queryKey: keys.acceptedMaterials.of(projectId), + retry: false, + refetchOnWindowFocus: false, + queryFn: () => acceptedMaterials(projectId), + }); +} + +// Only an active project has a coordinator whose decisions can be open. +function openDecisionsQuery(projectId: string, active: boolean) { + return queryOptions({ + queryKey: keys.openDecisions.of(projectId), + retry: false, + staleTime: 0, + refetchOnWindowFocus: false, + queryFn: active ? () => openDecisions(projectId) : skipToken, + }); +} function documentApprovalsFrom( waits: readonly Wait[], @@ -269,14 +301,9 @@ async function modelRefusal( : undefined; } -export function StageWorkspace({ - projectId, - projectTitle, - onChanged, - onOpenSettings, - openingMessage, - listedArchived, -}: { +type ModelNeeded = { code: string; message: string }; + +type StageWorkspaceProps = { /** The open project's id. */ projectId: string; projectTitle: string; @@ -288,11 +315,14 @@ export function StageWorkspace({ * artifact but the first conversation message, sent here once the * coordinator binds. */ openingMessage?: string; -}) { - // The conversation record comes from the room query. Null events means no - // coordinator is bound yet (the hub's 409). Empty arrays are not a stand-in - // for a read that has not happened. - const queryClient = useQueryClient(); +}; + +/** + * Reads the project and its room, and starts its coordinator, while the room shows its loading + * or fault state; the ready room mounts only with a loaded snapshot. + */ +export function StageWorkspace(props: StageWorkspaceProps) { + const { projectId, listedArchived } = props; // An archived project has no coordinator or conversation to read, so the room waits on its lifecycle. const projectRead = useQuery({ queryKey: keys.project.of(projectId), @@ -323,17 +353,117 @@ export function StageWorkspace({ refetchOnWindowFocus: false, queryFn: ({ client }) => loadProjectRoom(projectId, client, !archived), }); - const snapshot = status === undefined ? undefined : room.data; - const events = snapshot?.conversation.events ?? null; - const mirror = snapshot?.conversation.turns ?? []; - const runId = archived ? null : (snapshot?.conversation.runId ?? null); - // Each project is its own tenant beneath the workspace, and its runs live there. - const runTenantId = project?.tenantId; + const [startFault, setStartFault] = useState(null); + // Why the coordinator has no model to run on, which only the person can settle. + const [modelNeeded, setModelNeeded] = useState(null); + // Only a missing provider is settled by connecting one; a profile choice needs Settings. + const providerMissing = modelNeeded?.code === "model_provider_missing"; + const startCoordinator = async () => { + setStartFault(null); + setModelNeeded(null); + try { + await startProjectCoordinator(projectId); + await room.refetch(); + } catch (cause) { + const needed = await modelRefusal(cause); + if (needed !== undefined) return setModelNeeded(needed); + setStartFault( + await humanFaultWithCode(cause, wording.message.coordinatorNotStarted), + ); + } + }; + // Opening an active project starts its coordinator when it is not running; a running one is + // left alone. A project archived while open drops the conversation it can no longer read. + useEffect(() => { + if (status === "active") void startCoordinator(); + else if (status === "archived" && room.data !== undefined) + void room.refetch(); + }, [projectId, status]); + const catalog = useModelCatalog(); + const providerConnected = + catalog.data !== undefined && !needsModelSetup(catalog.data.providers); + // However the provider gets connected (Enable AI, Settings, another tab, the server), the + // shared catalog read sees it and the refused start is tried again. Only on that change: a hub + // that still refuses would otherwise retry on every refusal. + useEffect(() => { + if (providerConnected && providerMissing) void startCoordinator(); + }, [providerConnected]); + // The room's other reads start with it, so the ready room mounts with them in hand. + usePrefetchQuery(projectActivityQuery(projectId)); + usePrefetchQuery(projectRunStatesQuery(projectId)); + usePrefetchQuery(projectNextStepQuery(projectId)); + usePrefetchQuery(acceptedMaterialsQuery(projectId)); + // A prefetch cannot wait on the project's status, so this read observes the decisions instead. + useQuery(openDecisionsQuery(projectId, status === "active")); + useLiveAppPresence(projectId); + + if (status === undefined) { + if (projectRead.isError) + return void projectRead.refetch()} />; + return ; + } + const snapshot = room.data; + if (!roomSnapshotReady(projectId, snapshot)) { + if (room.isError) return void room.refetch()} />; + return ; + } + return ( + + ); +} + +/** The room with its first read in hand: everything here works on a loaded snapshot. */ +function ReadyRoom({ + projectId, + projectTitle, + onChanged, + onOpenSettings, + openingMessage, + status, + runTenantId, + snapshot, + start, + refetchProject, +}: StageWorkspaceProps & { + status: string; + runTenantId: string | undefined; + /** Null events means no coordinator is bound yet (the hub's 409). */ + snapshot: RoomSnapshot; + start: { + fault: string | null; + modelNeeded: ModelNeeded | null; + providerMissing: boolean; + retry: () => Promise; + }; + refetchProject: () => Promise; +}) { + const queryClient = useQueryClient(); + const archived = status === "archived"; + const startFault = start.fault; + const modelNeeded = start.modelNeeded; + const startCoordinator = start.retry; + const providerMissing = start.providerMissing; + const events = snapshot.conversation.events; + const mirror = snapshot.conversation.turns; + const runId = archived ? null : snapshot.conversation.runId; // The coder's filed app source is the resume point for a later coder, not a document to read. - const artifacts = (snapshot?.artifacts ?? []) + const artifacts = snapshot.artifacts .filter((item) => item.kind !== "app") .map(paneArtifact); - const documents = snapshot?.documents ?? {}; + const documents = snapshot.documents; const [liveRun, setLiveRun] = useState(emptyLiveRun); const questionsLayout = useQuestionsLayout(); const [approvalBusy, setApprovalBusy] = useState(false); @@ -373,21 +503,8 @@ export function StageWorkspace({ const [sendFault, setSendFault] = useState(null); // When this session last sent the coordinator a message, until a run ends after it or it fails. const [awaitingSince, setAwaitingSince] = useState(null); - const [startFault, setStartFault] = useState(null); - // Why the coordinator has no model to run on, which only the person can settle. - const [modelNeeded, setModelNeeded] = useState<{ - code: string; - message: string; - } | null>(null); - // Only a missing provider is settled by connecting one; a profile choice needs Settings. - const providerMissing = modelNeeded?.code === "model_provider_missing"; const [enablingAi, setEnablingAi] = useState(false); - const accepted = useQuery({ - queryKey: keys.acceptedMaterials.of(projectId), - retry: false, - refetchOnWindowFocus: false, - queryFn: () => acceptedMaterials(projectId), - }); + const accepted = useQuery(acceptedMaterialsQuery(projectId)); const limits = accepted.data; const [files, setFiles] = useState([]); const [attachFault, setAttachFault] = useState(null); @@ -408,28 +525,31 @@ export function StageWorkspace({ const [caution, setCaution] = useState(null); const [gateDone, setGateDone] = useState(0); // The waits that belong to this project, from the open decisions. - const decisions = useQuery({ - queryKey: keys.openDecisions.of(projectId), - enabled: status === "active", - retry: false, - staleTime: 0, - refetchOnWindowFocus: false, - queryFn: () => openDecisions(projectId), - }); + const decisions = useQuery( + openDecisionsQuery(projectId, status === "active"), + ); + // Once per outage: each failed poll is a new error, and a dismissed toast stays dismissed. useEffect(() => { - if (decisions.error === null) return; + if (!decisions.isError) return; toast.error( humanFault(decisions.error, "Open decisions could not be read."), { id: "waits-read", }, ); - }, [decisions.error]); - const waits = decisions.data ?? NO_WAITS; + }, [decisions.isError]); + // Undefined until read: an unread list is not one with nothing open. + const waits = decisions.data; + const activity = useProjectActivity(projectId); + useEffect(() => { + if (!activity.isError) return; + toast.error(humanFault(activity.error, wording.activity.notRead), { + id: "activity-read", + }); + }, [activity.isError]); const reloadRef = useRef<() => void>(() => {}); useEffect(() => { - if (status === undefined) return; const reloader = createRoomReloader(() => loadProjectRoom(projectId, queryClient, status === "active"), ); @@ -562,14 +682,15 @@ export function StageWorkspace({ // The steps map only grows as the live run commits, which re-renders with a new mirror. [mirror, sent], ); - const mailThreads = snapshot?.conversation.threads; + const mailThreads = snapshot.conversation.threads; const specialistUpdates = useMemo( - () => threadUpdates(mailThreads ?? []), + () => threadUpdates(mailThreads), [mailThreads], ); const latestAssistant = [...mirror].reverse().find((turn) => turn.role === "assistant")?.text ?? ""; - const documentApprovals = documentApprovalsFrom(waits, artifacts); + const documentApprovals = + waits === undefined ? undefined : documentApprovalsFrom(waits, artifacts); const { settled: settledApprovals, settle } = useSettledApprovals( projectId, documentApprovals, @@ -583,16 +704,19 @@ export function StageWorkspace({ (item) => item.approvalId === approval.approvalId, ), ); - const pendingApprovals = pendingFrom(waits); + const pendingApprovals = waits === undefined ? undefined : pendingFrom(waits); + const shownApprovals = + pendingApprovals === undefined + ? settledApprovals + : [...settledApprovals, ...pendingApprovals]; const openApprovalIds = new Set( - pendingApprovals.map((approval) => approval.approvalId), + pendingApprovals?.map((approval) => approval.approvalId), ); + // Until the open decisions are read, no approval is known to hold the coordinator. + const parked = + waits !== undefined && parkedOnApproval(waits, openApprovalIds, runId); const liveTurn = pendingLiveTurn(liveRun, mirror); - const streaming = liveTurnStreaming( - liveRun, - mirror, - parkedOnApproval(waits, openApprovalIds, runId), - ); + const streaming = liveTurnStreaming(liveRun, mirror, parked); const liveText = liveTurn?.text ?? ""; const shownTurns = liveTurn === null @@ -614,8 +738,8 @@ export function StageWorkspace({ : []; // Mid-turn by native state, whoever sent the message: this stream saw the message run start and // not end, or the hub's activity read saw one start after the last end this stream saw. - const agents = useProjectActivity(projectId).data?.agents ?? []; - const coordinatorTurnSince = agents.find( + const agents = activity.data?.agents; + const coordinatorTurnSince = agents?.find( (agent) => agent.role === "coordinator", )?.since; const working = @@ -783,14 +907,15 @@ export function StageWorkspace({ // A park created moments after `inference.done` can already stand on the hub before the next // room reload lands; read once on demand when the cached list shows no open ask. - const readPendingApprovals = async (): Promise => { - if (pendingApprovals.length > 0) return pendingApprovals; + const readPendingApprovals = async (): Promise< + readonly OpenApproval[] | undefined + > => { + if (pendingApprovals !== undefined && pendingApprovals.length > 0) + return pendingApprovals; try { - const fresh = await queryClient.fetchQuery({ - queryKey: keys.openDecisions.of(projectId), - staleTime: 0, - queryFn: () => openDecisions(projectId), - }); + const fresh = await queryClient.fetchQuery( + openDecisionsQuery(projectId, true), + ); return pendingFrom(fresh); } catch (cause) { // The cached list is still the best answer; the send that follows reports a hub that is down. @@ -835,13 +960,12 @@ export function StageWorkspace({ choice: "yes" | "always" | "no"; }): Promise => { const notSaved = "That decision was not saved."; - const cachedWaits = - queryClient.getQueryData( - keys.openDecisions.of(projectId), - ) ?? NO_WAITS; + const cachedWaits = queryClient.getQueryData( + keys.openDecisions.of(projectId), + ); const wait = - waits.find((item) => item.approvalId === decision.approvalId) ?? - cachedWaits.find((item) => item.approvalId === decision.approvalId); + waits?.find((item) => item.approvalId === decision.approvalId) ?? + cachedWaits?.find((item) => item.approvalId === decision.approvalId); const approvalId = wait?.approvalId; const tenantId = wait?.tenantId; if ( @@ -854,10 +978,12 @@ export function StageWorkspace({ setApprovalBusy(true); setApprovalError(null); const decided = - documentApprovals.find((item) => item.approvalId === approvalId) ?? - documentApprovalsFrom(cachedWaits, artifacts).find( - (item) => item.approvalId === approvalId, - ); + documentApprovals?.find((item) => item.approvalId === approvalId) ?? + (cachedWaits === undefined + ? undefined + : documentApprovalsFrom(cachedWaits, artifacts).find( + (item) => item.approvalId === approvalId, + )); try { await decideApproval({ tenantId, @@ -893,7 +1019,7 @@ export function StageWorkspace({ setApprovalBusy(false); } }; - const openApproval = pendingApprovals.find( + const openApproval = pendingApprovals?.find( (approval) => approval.packages === undefined, ); // Choosing an approach is the person's yes to the version's open ask; the message names the pick. @@ -902,14 +1028,14 @@ export function StageWorkspace({ version: number, ): Promise => { const currentApprovals = await readPendingApprovals(); - const currentOpen = currentApprovals.find( + const currentOpen = currentApprovals?.find( (approval) => approval.packages === undefined, ); const kind = selected?.kind; const asked = kind === undefined ? undefined - : currentApprovals.find( + : currentApprovals?.find( (approval) => approval.packages === undefined && asksAbout(approval, kind, version), @@ -925,7 +1051,7 @@ export function StageWorkspace({ const deliver = async (body: string, clearComposer = false) => { if (body.trim().length === 0 || sending) return false; const currentApprovals = await readPendingApprovals(); - const currentOpen = currentApprovals.find( + const currentOpen = currentApprovals?.find( (approval) => approval.packages === undefined, ); return deliverWithOpenApproval({ @@ -937,7 +1063,7 @@ export function StageWorkspace({ const sendFiles = async () => { if (sending) return false; const currentApprovals = await readPendingApprovals(); - const currentOpen = currentApprovals.find( + const currentOpen = currentApprovals?.find( (approval) => approval.packages === undefined, ); return deliverWithOpenApproval({ @@ -956,7 +1082,7 @@ export function StageWorkspace({ if (!(await deliver(message))) restoreToComposer(message); }; const typedYes = useTypedYes({ - pending: pendingApprovals.map((approval) => approval.approvalId), + pending: pendingApprovals?.map((approval) => approval.approvalId), onApprove: (approvalId) => void onDecideApproval({ approvalId, choice: "yes" }), onUndo: (message) => void undoTypedYes(message), @@ -968,7 +1094,11 @@ export function StageWorkspace({ if (files.length > 0) return void sendFiles(); if (shell.answers(value)) return setValue(""); if (typedYes.hold(value)) return setValue(""); - const role = earlierDocumentRole(value, artifacts, rolesAtWork); + // Without a read of who is at work there is no one to warn about. + const role = + rolesAtWork === undefined + ? undefined + : earlierDocumentRole(value, artifacts, rolesAtWork); if (role === undefined || caution === role) return sendNow(); setCaution(role); }; @@ -977,37 +1107,7 @@ export function StageWorkspace({ composerRef.current?.focus(); }; - const unbound = snapshot !== undefined && events === null; - const startCoordinator = async () => { - setStartFault(null); - setModelNeeded(null); - try { - await startProjectCoordinator(projectId); - await room.refetch(); - } catch (cause) { - const needed = await modelRefusal(cause); - if (needed !== undefined) return setModelNeeded(needed); - setStartFault( - await humanFaultWithCode(cause, wording.message.coordinatorNotStarted), - ); - } - }; - // Opening an active project starts its coordinator when it is not running; a running one is - // left alone. A project archived while open drops the conversation it can no longer read. - useEffect(() => { - if (status === "active") void startCoordinator(); - else if (status === "archived" && room.data !== undefined) - void room.refetch(); - }, [projectId, status]); - const catalog = useModelCatalog(); - const providerConnected = - catalog.data !== undefined && !needsModelSetup(catalog.data.providers); - // However the provider gets connected (Enable AI, Settings, another tab, the server), the - // shared catalog read sees it and the refused start is tried again. Only on that change: a hub - // that still refuses would otherwise retry on every refusal. - useEffect(() => { - if (providerConnected && providerMissing) void startCoordinator(); - }, [providerConnected]); + const unbound = events === null; const [restoring, setRestoring] = useState(false); const [restoreFault, setRestoreFault] = useState(null); const restore = async () => { @@ -1015,7 +1115,7 @@ export function StageWorkspace({ setRestoreFault(null); try { await restoreProject(projectId); - await projectRead.refetch(); + await refetchProject(); onChanged(); } catch (cause) { setRestoreFault( @@ -1025,14 +1125,11 @@ export function StageWorkspace({ setRestoring(false); } }; - const held = waitingOnPerson(waits, openApprovalIds, runId, liveRun.running); + const held = + waits !== undefined && + waitingOnPerson(waits, openApprovalIds, runId, liveRun.running); const awaiting = - awaitingReply( - awaitingSince, - turns.at(-1)?.role, - liveRun.endedAt, - parkedOnApproval(waits, openApprovalIds, runId), - ) && + awaitingReply(awaitingSince, turns.at(-1)?.role, liveRun.endedAt, parked) && !archived && startFault === null && modelNeeded === null; @@ -1049,16 +1146,16 @@ export function StageWorkspace({ projectId, designDraftFiled( artifacts.some((item) => item.kind === "design"), - snapshot?.inFlight, + snapshot.inFlight, ), ); - const inFlight = snapshot?.inFlight ?? []; + const inFlight = snapshot.inFlight; const dispatchRoom: DispatchRoom = { - artifacts: snapshot?.artifacts ?? [], + artifacts: snapshot.artifacts, inFlight, - failed: snapshot?.failed ?? [], + failed: snapshot.failed, seen: dispatchSeen.current, - approvals: [...settledApprovals, ...pendingApprovals], + approvals: shownApprovals, activity: agents, onOpenDocument: (kind) => openDocument( @@ -1073,16 +1170,19 @@ export function StageWorkspace({ for (const dispatch of dispatches) noteDispatch(dispatchRoom, dispatch); // The document a writer or reviewer run works on, from the coordinator's own hand-off call as // the hub's activity read saw it start: the latest such call made before the run started. - for (const tool of agents.find((agent) => agent.role === "coordinator") - ?.tools ?? []) { - const role = HANDOFF_ROLE[tool.name]; - if (role !== undefined && tool.subject !== undefined) - handoffCalls.current.set(`${tool.at}:${tool.name}`, { - role, - kind: tool.subject, - at: Date.parse(tool.at), - }); - } + const coordinatorTools = agents?.find( + (agent) => agent.role === "coordinator", + )?.tools; + if (coordinatorTools !== undefined) + for (const tool of coordinatorTools) { + const role = HANDOFF_ROLE[tool.name]; + if (role !== undefined && tool.subject !== undefined) + handoffCalls.current.set(`${tool.at}:${tool.name}`, { + role, + kind: tool.subject, + at: Date.parse(tool.at), + }); + } const handoffKind = (run: InFlightRun): string | undefined => { const known = runKinds.current.get(run.runId); if (known !== undefined) return known; @@ -1124,8 +1224,9 @@ export function StageWorkspace({ : designConcepts( options, inFlight, - [...settledApprovals, ...pendingApprovals].flatMap( - (approval) => approval.concepts ?? [], + // An ask that names no concepts adds none. + shownApprovals.flatMap((approval) => + approval.concepts === undefined ? [] : approval.concepts, ), (option) => { const state = documentState(option); @@ -1170,10 +1271,7 @@ export function StageWorkspace({ ) || options.length > 0 || inFlight.some((run) => run.concept !== undefined); - useLiveAppPresence(projectId); - const appFiled = (snapshot?.artifacts ?? []).some( - (item) => item.kind === "app", - ); + const appFiled = snapshot.artifacts.some((item) => item.kind === "app"); const firstBuilder = appFiled ? undefined : inFlight.find((run) => run.role === "coder"); @@ -1184,15 +1282,14 @@ export function StageWorkspace({ const userManual = useUserManual( projectId, liveApp !== null, - rolesAtWork.has("coder"), + rolesAtWork?.has("coder") === true, ); // While an approval is open the pane follows the document it approves. const approvalDeliverableId = - pendingApprovals.find((approval) => approval.document !== undefined) + pendingApprovals?.find((approval) => approval.document !== undefined) ?.document?.kind ?? null; const filed = useFiledVersions({ projectId, - ready: roomSnapshotReady(projectId, snapshot), documents: artifacts.map((item) => item.kind === DESIGN_OPTION ? { @@ -1225,13 +1322,6 @@ export function StageWorkspace({ paneDeliverables.find((item) => item.kind === shownId) ?? null; const selectedState = selected === null ? undefined : documentState(selected); - if (projectRead.isError && status === undefined) - return void projectRead.refetch()} />; - if (!roomSnapshotReady(projectId, snapshot)) { - if (room.isError) return void room.refetch()} />; - return ; - } - // Until the first document is filed there is nothing to read beside the conversation, so it // takes the whole width; the document pane arrives with that document. const chatFirst = @@ -1552,7 +1642,7 @@ export function StageWorkspace({ }))} onOpenDocument={openDocument} shownDocument={chatFirst ? null : (selected?.kind ?? null)} - approvals={[...settledApprovals, ...pendingApprovals]} + approvals={shownApprovals} results={liveRun.results} runs={unseenRuns} {...(shownChoice === undefined || archived diff --git a/apps/web/src/pages/workspace/next-step.tsx b/apps/web/src/pages/workspace/next-step.tsx index d8fcc6e0..7a2d93e9 100644 --- a/apps/web/src/pages/workspace/next-step.tsx +++ b/apps/web/src/pages/workspace/next-step.tsx @@ -2,7 +2,7 @@ * Where the project stands and the person's one next step, as the hub derives them from native * state. The line is pinned above the conversation when the person's preference turns it on. */ -import { useQuery } from "@tanstack/react-query"; +import { queryOptions, useQuery } from "@tanstack/react-query"; import { Button, StatusDot } from "@corbits/react-ui"; import type { ProjectNextAction, @@ -19,16 +19,19 @@ const POLL_MS = 5_000; const NEXT_STEP_LINE = "next_step_line"; const text = wording.nextStep; -export function useProjectNextStep(projectId: string, enabled = true) { - return useQuery({ +export function projectNextStepQuery(projectId: string) { + return queryOptions({ queryKey: keys.projectNextStep.of(projectId), - enabled, queryFn: ({ signal }) => readProjectNextStep(projectId, signal), refetchInterval: POLL_MS, refetchOnWindowFocus: false, }); } +export function useProjectNextStep(projectId: string, enabled = true) { + return useQuery({ ...projectNextStepQuery(projectId), enabled }); +} + function useNextStepLineOn() { const preferences = useQuery({ queryKey: keys.preferences, diff --git a/apps/web/src/pages/workspace/ready-room.test.tsx b/apps/web/src/pages/workspace/ready-room.test.tsx new file mode 100644 index 00000000..ede9172d --- /dev/null +++ b/apps/web/src/pages/workspace/ready-room.test.tsx @@ -0,0 +1,84 @@ +import { describe, expect, test } from "bun:test"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { wording } from "../../config.ts"; +import { keys } from "../../queries/keys.ts"; +import { useRolesAtWork } from "./agent-activity.tsx"; +import { StageWorkspace } from "./index.tsx"; +import type { RoomSnapshot } from "./load-room.ts"; + +const projectId = "project-1"; + +function client(): QueryClient { + return new QueryClient({ defaultOptions: { queries: { retry: false } } }); +} + +function room(queryClient: QueryClient): string { + return renderToStaticMarkup( + + {}} + onOpenSettings={() => {}} + /> + , + ); +} + +const emptyRoom: RoomSnapshot = { + conversation: { events: null, turns: [], threads: [], runId: null }, + artifacts: [], + inFlight: [], + failed: [], + documents: {}, +}; + +describe("project room", () => { + test("an unread room is loading, with the room's other reads already under way", () => { + const queryClient = client(); + expect(room(queryClient)).toContain(wording.message.projectLoading); + for (const key of [ + keys.projectActivity.of(projectId), + keys.projectRunStates.of(projectId), + keys.projectNextStep.of(projectId), + keys.acceptedMaterials.of(projectId), + ]) + expect(queryClient.getQueryState(key)?.fetchStatus).toBe("fetching"); + + const loaded = client(); + loaded.setQueryData(keys.projectRoom.of(projectId), emptyRoom); + const read = room(loaded); + expect(read).not.toContain(wording.message.projectLoading); + expect(read).toContain(wording.archivedProject.conversationTitle); + }); + + test("a failed activity read is not nobody at work", async () => { + function Roles() { + const roles = useRolesAtWork(projectId); + return ( +

+ {roles === undefined ? "unread" : `at work: ${[...roles].join(",")}`} +

+ ); + } + const render = (queryClient: QueryClient) => + renderToStaticMarkup( + + + , + ); + + const failed = client(); + await failed.prefetchQuery({ + queryKey: keys.projectActivity.of(projectId), + queryFn: () => Promise.reject(new Error("The hub is down.")), + }); + expect(render(failed)).toContain("unread"); + + const idle = client(); + idle.setQueryData(keys.projectActivity.of(projectId), { agents: [] }); + expect(render(idle)).toContain("at work:

"); + }); +}); diff --git a/apps/web/src/pages/workspace/room-skeleton.tsx b/apps/web/src/pages/workspace/room-skeleton.tsx index d5f58823..998ff836 100644 --- a/apps/web/src/pages/workspace/room-skeleton.tsx +++ b/apps/web/src/pages/workspace/room-skeleton.tsx @@ -12,7 +12,7 @@ export function RoomSkeleton() { return (

- Loading this project… + {wording.message.projectLoading}

diff --git a/apps/web/src/pages/workspace/settled-approvals.ts b/apps/web/src/pages/workspace/settled-approvals.ts index 7d6a8420..0ff1db56 100644 --- a/apps/web/src/pages/workspace/settled-approvals.ts +++ b/apps/web/src/pages/workspace/settled-approvals.ts @@ -49,7 +49,8 @@ function settledCard( export function useSettledApprovals( projectId: string, - open: readonly OpenApproval[], + /** Undefined until the open decisions are read. */ + open: readonly OpenApproval[] | undefined, artifacts: readonly ProjectArtifact[], ): { settled: readonly DocumentApproval[]; @@ -70,8 +71,9 @@ export function useSettledApprovals( }, [projectId]); // An ask that leaves the pending list was decided, here or elsewhere. - const openKey = open.map((approval) => approval.approvalId).join("\n"); + const openKey = open?.map((approval) => approval.approvalId).join("\n"); useEffect(() => { + if (openKey === undefined) return; void queryClient.invalidateQueries({ queryKey }); }, [projectId, openKey]); diff --git a/apps/web/src/pages/workspace/transcript.test.tsx b/apps/web/src/pages/workspace/transcript.test.tsx index 233bf286..48550793 100644 --- a/apps/web/src/pages/workspace/transcript.test.tsx +++ b/apps/web/src/pages/workspace/transcript.test.tsx @@ -393,6 +393,23 @@ describe("conversation transcript", () => { } }); + test("a document with a blank name is asked about by its tool", () => { + const html = renderToStaticMarkup( + {}} + onAlways={() => {}} + onNo={() => {}} + />, + ); + expect(html).toContain("browser navigate"); + expect(html).not.toContain(wording.approval.document("")); + }); + test("a design approval lists each look and starts one by name", () => { const html = renderToStaticMarkup( void; onUndo: (message: string) => void; /** The card was answered some other way during the countdown. */ @@ -70,7 +70,7 @@ export function useTypedYes({ }; }, [held]); - const open = held !== null && pending.includes(held.approvalId); + const open = held !== null && pending?.includes(held.approvalId) === true; useEffect(() => { if (held === null || open) return; setHeld(null); @@ -79,6 +79,7 @@ export function useTypedYes({ /** Holds the message when it answers the one open card; false means send it as usual. */ const hold = (message: string): boolean => { + if (pending === undefined) return false; const [only] = pending; if ( held !== null || diff --git a/apps/web/src/pages/workspace/work-unit-view.tsx b/apps/web/src/pages/workspace/work-unit-view.tsx index 8d226f2c..19e92a16 100644 --- a/apps/web/src/pages/workspace/work-unit-view.tsx +++ b/apps/web/src/pages/workspace/work-unit-view.tsx @@ -60,9 +60,9 @@ function Elapsed({ since }: { since: string }) { function liveLine( dispatch: Dispatch, subject: string, - activity: readonly AgentActivity[], + activity: readonly AgentActivity[] | undefined, ): string { - const agent = activity.find((item) => + const agent = activity?.find((item) => dispatch.runId === undefined ? item.role === dispatch.role : item.runId === dispatch.runId, @@ -84,7 +84,7 @@ function WorkLine({ status: DispatchStatus; subject: string; steps: number; - activity: readonly AgentActivity[]; + activity: readonly AgentActivity[] | undefined; }): ReactNode { switch (status.state) { case "waiting": @@ -217,7 +217,7 @@ export function WorkUnitView({ status={status} subject={subject} steps={steps.length} - activity={room?.activity ?? []} + activity={room?.activity} /> } labels={wording.activity.trigger}