diff --git a/apps/hub/src/projects/next-step.ts b/apps/hub/src/projects/next-step.ts index f55e67c0..ec2341e4 100644 --- a/apps/hub/src/projects/next-step.ts +++ b/apps/hub/src/projects/next-step.ts @@ -48,6 +48,18 @@ type ProjectRun = { failureMessage: string | null; }; +// Runs are newest first. A failure stands on the newest run of a role, and the +// first such specialist in that order is the one the next step already names. +function newestFailedSpecialist( + runs: readonly ProjectRun[], +): ProjectRun | undefined { + const newest = new Map(); + for (const run of runs) if (!newest.has(run.role)) newest.set(run.role, run); + return [...newest.values()].find( + (run) => run.role !== "coordinator" && run.status === "failed", + ); +} + /** * Where the project stands and the person's one next step, from native state only: run status, * each live run's latest committed event, pending approval records of live runs, the hub's live @@ -194,6 +206,11 @@ export function createProjectNextStep( async status(actor: Actor, projectId: string): Promise { const { status } = await readableProject(actor, projectId); const all = await projectRuns(actor); + const failed = newestFailedSpecialist(all); + const note = (body: ProjectStatus): ProjectStatus => + failed === undefined + ? body + : { ...body, failedSpecialist: failed.role }; const states = await liveStates(all); const pending = await db .select({ @@ -256,19 +273,19 @@ export function createProjectNextStep( const now = (lead: string) => `${lead} ${appNow}`; if (status === "archived") - return { + return note({ working, now: now(text.archived), app, next: { text: text.next.archived }, - }; + }); const asked = pending[0]; if (asked) { const name = (asked.tool as { name?: unknown } | null)?.name; const tool = typeof name === "string" && name !== "" ? name : "approval"; const kinds = (asked.args as { kinds?: unknown } | null)?.kinds; - return { + return note({ working, now: now(who), waiting: { kind: "approval", approvalId: asked.id, tool }, @@ -281,10 +298,10 @@ export function createProjectNextStep( : [], ), }, - }; + }); } if (!states.some((run) => run.role === "coordinator")) - return { + return note({ working, now: now(text.stopped("coordinator", undefined)), app, @@ -292,64 +309,57 @@ export function createProjectNextStep( text: text.next.resumeCoordinator, action: "resume_coordinator", }, - }; + }); const waitOn = busy[0] ?? starting[0]; if (waitOn !== undefined) - return { + return note({ working, now: now(who), app, next: { text: text.next.wait(waitOn) }, - }; + }); if (unknown) - return { + return note({ working, now: now(who), app, next: { text: text.next.unknown }, - }; - // A failure stands while its run is the newest of its role; runs are newest first. - const newest = new Map(); - for (const run of all) - if (!newest.has(run.role)) newest.set(run.role, run); - const failed = [...newest.values()].find( - (run) => run.role !== "coordinator" && run.status === "failed", - ); + }); if (failed) - return { + return note({ working, now: now(text.stopped(failed.role, await failureReason(failed))), app, next: { text: text.next.retry, action: "reply" }, - }; + }); if (coderRan && app.state === "failed") - return { + return note({ working, now: now(who), app, next: { text: text.next.fixApp, action: "reply" }, - }; + }); if (coderRan && app.state === "running") - return { + return note({ working, now: now(who), app, next: { text: text.next.tryApp, action: "open_app" }, - }; + }); if (coderRan && app.state === "starting") - return { + return note({ working, now: now(who), app, next: { text: text.next.waitApp }, - }; - return { + }); + return note({ working, now: now(who), waiting: { kind: "reply" }, app, next: { text: text.next.reply, action: "reply" }, - }; + }); }, }; } @@ -369,7 +379,7 @@ export function mountProjectNextStepRoutes( tags: ["Projects"], operationId: "readProjectNextStep", description: - "Who is at work in the project, what waits on the person, the app's state and the person's one next step, derived only from native state: run status and each live run's latest committed event (a run parked on input is idle), pending approval records of live runs, the hub's live app and the latest filed app version. Never from model text.", + "Who is at work in the project, what waits on the person, the app's state and the person's one next step, derived only from native state: run status and each live run's latest committed event (a run parked on input is idle), pending approval records of live runs, the hub's live app and the latest filed app version. failedSpecialist is the specialist whose newest run failed, when one has. Never from model text.", security, responses: { 200: { diff --git a/apps/web/src/config.ts b/apps/web/src/config.ts index 29d140b6..893c8d35 100644 --- a/apps/web/src/config.ts +++ b/apps/web/src/config.ts @@ -313,6 +313,21 @@ export const wording = { dropFiles: "Drop files to attach them", noCoordinator: "This project has no running coordinator.", }, + /** The composer's placeholder when no approval is open. An open approval keeps approval.composerPlaceholder. */ + composer: { + passAlong: (role: string) => `Ask the ${role}…`, + role: { + coordinator: "coordinator", + writer: "writer", + reviewer: "reviewer", + designer: "designer", + coder: "app builder", + }, + change: "What needs to change…", + fix: "What needs fixing…", + making: "What are you making…", + reply: "What do you want to say…", + }, /** What a person's move is waiting on, as the bell and gate name it. */ wait: { permission: "The coordinator asks permission to continue", diff --git a/apps/web/src/pages/workspace/composer-placeholder.test.ts b/apps/web/src/pages/workspace/composer-placeholder.test.ts new file mode 100644 index 00000000..5ce4e8ea --- /dev/null +++ b/apps/web/src/pages/workspace/composer-placeholder.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, test } from "bun:test"; +import type { ProjectStatus } from "@corbits-builder/schemas/project-status"; +import { wording } from "../../config.ts"; +import { composerPlaceholder } from "./composer-placeholder.ts"; + +function standing(input: { + working?: ProjectStatus["working"]; + app?: ProjectStatus["app"]; + failedSpecialist?: NonNullable; +}): ProjectStatus { + return { + working: input.working ?? [], + now: "Standing.", + app: input.app ?? { state: "not_started" }, + next: { text: "Reply to the coordinator" }, + ...(input.failedSpecialist === undefined + ? {} + : { failedSpecialist: input.failedSpecialist }), + }; +} + +function placeholder(input: { + approvalOpen?: boolean; + chatFirst?: boolean; + status?: ProjectStatus; +}): string { + return composerPlaceholder({ + approvalOpen: input.approvalOpen ?? false, + chatFirst: input.chatFirst ?? false, + status: input.status, + }); +} + +describe("composer placeholder", () => { + test("a working role beats a running app, and the first working role is named", () => { + expect( + placeholder({ + status: standing({ + working: [ + { role: "writer", state: "starting" }, + { role: "reviewer", state: "working" }, + { role: "designer", state: "working" }, + ], + app: { state: "running" }, + failedSpecialist: "coder", + }), + }), + ).toBe("Ask the reviewer…"); + }); + + test("a starting role is named when nobody is working", () => { + expect( + placeholder({ + status: standing({ + working: [ + { role: "coordinator", state: "unknown" }, + { role: "writer", state: "starting" }, + { role: "designer", state: "starting" }, + ], + app: { state: "running" }, + }), + }), + ).toBe("Ask the writer…"); + }); + + test("the coder is called the app builder", () => { + expect( + placeholder({ + status: standing({ + working: [{ role: "coder", state: "working" }], + app: { state: "running" }, + }), + }), + ).toBe("Ask the app builder…"); + }); + + test("a running app beats a failure", () => { + expect( + placeholder({ + chatFirst: true, + status: standing({ + app: { state: "running" }, + failedSpecialist: "writer", + }), + }), + ).toBe("What needs to change…"); + }); + + test("a failed app beats an empty project", () => { + expect( + placeholder({ + chatFirst: true, + status: standing({ app: { state: "failed" } }), + }), + ).toBe("What needs fixing…"); + }); + + test("a failed specialist beats an empty project", () => { + expect( + placeholder({ + chatFirst: true, + status: standing({ + app: { state: "stopped" }, + failedSpecialist: "designer", + }), + }), + ).toBe("What needs fixing…"); + }); + + test("an empty project beats the fallback", () => { + expect( + placeholder({ + chatFirst: true, + status: standing({ + working: [{ role: "writer", state: "unknown" }], + app: { state: "not_started" }, + }), + }), + ).toBe("What are you making…"); + }); + + test("anything else asks for a reply", () => { + expect( + placeholder({ + status: standing({ + working: [{ role: "coder", state: "unknown" }], + app: { state: "stopped" }, + }), + }), + ).toBe("What do you want to say…"); + }); + + test("an open approval keeps the approval placeholder", () => { + expect( + placeholder({ + approvalOpen: true, + chatFirst: true, + status: standing({ + working: [{ role: "coder", state: "working" }], + app: { state: "failed", reason: "crashed" }, + failedSpecialist: "coder", + }), + }), + ).toBe(wording.approval.composerPlaceholder); + }); +}); diff --git a/apps/web/src/pages/workspace/composer-placeholder.ts b/apps/web/src/pages/workspace/composer-placeholder.ts new file mode 100644 index 00000000..a2b3ad02 --- /dev/null +++ b/apps/web/src/pages/workspace/composer-placeholder.ts @@ -0,0 +1,41 @@ +/** + * The composer's placeholder, from project status rather than the next-step + * sentence. An open approval keeps its own line. + */ +import type { + ProjectStatus, + ProjectWorker, +} from "@corbits-builder/schemas/project-status"; +import type { WorkflowRole } from "@corbits-builder/schemas/installed-workflows"; +import { wording } from "../../config.ts"; + +const copy = wording.composer; +const roleLabel: Record = copy.role; + +function passAlongRole( + working: readonly ProjectWorker[], +): WorkflowRole | undefined { + return ( + working.find((item) => item.state === "working")?.role ?? + working.find((item) => item.state === "starting")?.role + ); +} + +export function composerPlaceholder(input: { + approvalOpen: boolean; + /** Absent until project status has loaded. */ + status: ProjectStatus | undefined; + /** No pane deliverables, design draft, or concepts yet. */ + chatFirst: boolean; +}): string { + if (input.approvalOpen) return wording.approval.composerPlaceholder; + const status = input.status; + if (status === undefined) return copy.reply; + const role = passAlongRole(status.working); + if (role !== undefined) return copy.passAlong(roleLabel[role]); + if (status.app.state === "running") return copy.change; + if (status.app.state === "failed" || status.failedSpecialist !== undefined) + return copy.fix; + if (input.chatFirst) return copy.making; + return copy.reply; +} diff --git a/apps/web/src/pages/workspace/index.tsx b/apps/web/src/pages/workspace/index.tsx index 731f56bd..bbef03d3 100644 --- a/apps/web/src/pages/workspace/index.tsx +++ b/apps/web/src/pages/workspace/index.tsx @@ -11,7 +11,7 @@ * making chat inert during document review. */ import { wording } from "../../config.ts"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Paperclip } from "lucide-react"; import { @@ -118,7 +118,8 @@ import { DESIGN_OPTION, } from "./design-concepts.tsx"; import { useLiveAppPresence } from "./live-app.tsx"; -import { NextStepLine } from "./next-step.tsx"; +import { NextStepLine, useProjectNextStep } from "./next-step.tsx"; +import { composerPlaceholder } from "./composer-placeholder.ts"; import { Dictated } from "../../dictation.jsx"; import { useShellReply } from "../../flourish.tsx"; @@ -404,6 +405,8 @@ export function StageWorkspace({ committedAtRef.current = latestAssistantAt(mirror); const reportedFailure = useRef(null); const rolesAtWork = useRolesAtWork(projectId); + // The line above the conversation is preference-gated; the placeholder is not. + const projectStatus = useProjectNextStep(projectId); const [caution, setCaution] = useState(null); const [gateDone, setGateDone] = useState(0); // The waits that belong to this project, from the open decisions. @@ -1220,6 +1223,226 @@ export function StageWorkspace({ designDraft === null && concepts.length === 0; + let dock: ReactNode; + if (archived) { + dock = ( +
+

{wording.archivedProject.title}

+

{wording.archivedProject.lead}

+ {restoreFault === null ? null : ( +

+ {restoreFault} +

+ )} +
+ void restore()} + > + {restoring + ? wording.archivedProject.restoring + : wording.archivedProject.restore} + +
+
+ ); + } else if (modelNeeded !== null) { + // A coordinator with no model to run on cannot answer, even one still bound from before. + dock = ( +
+

{modelNeeded}

+
+ + {wording.action.openSettings} + + void startCoordinator()} + > + {wording.action.retry} + +
+
+ ); + } else if (unbound) { + // The person does not start the coordinator by typing: an unbound room waits for the + // start that opening it asked for, and a refused start says why and offers it again. + if (startFault === null) { + dock = ( +
+

+ {wording.conversation.startingCoordinator} +

+
+ ); + } else { + dock = ( +
+

{startFault}

+
+ void startCoordinator()} + > + {wording.action.retry} + +
+
+ ); + } + } else { + dock = ( +
+ {attachFault === null ? null : ( +

+ {attachFault} +

+ )} + {sendFailed && sendFault ? ( +

+ {sendFault} +

+ ) : null} + {caution === null ? null : ( +
+

{wording.earlierDocument.warning(participantLabel(caution))}

+
+ + {wording.earlierDocument.sendAnyway} + + { + setCaution(null); + composerRef.current?.focus(); + }} + > + {wording.action.cancel} + +
+
+ )} + {replies.length > 0 ? ( +
+ {replies.map((reply) => ( + void deliver(reply)} + > + {reply} + + ))} +
+ ) : null} + + + {(mic) => ( + 0} + status={sending || approvalBusy ? "submitted" : "ready"} + disabled={sending || approvalBusy} + > + {files.length === 0 ? null : ( + + + {files.map((item) => ( + + + + + setFiles((current) => + current.filter((held) => held.id !== item.id), + ) + } + /> + + ))} + + + )} + + + {mic} + {limits === undefined || + limits.mimeTypes.length === 0 ? null : ( + <> + { + if (event.target.files !== null) + addFiles(event.target.files); + event.target.value = ""; + }} + /> + fileInputRef.current?.click()} + > + + + )} + + + + )} + + +
+ ); + } + return (
- {archived ? ( -
-

- {wording.archivedProject.title} -

-

{wording.archivedProject.lead}

- {restoreFault === null ? null : ( -

- {restoreFault} -

- )} -
- void restore()} - > - {restoring - ? wording.archivedProject.restoring - : wording.archivedProject.restore} - -
-
- ) : modelNeeded !== null ? ( - // A coordinator with no model to run on cannot answer, even one still bound from before. -
-

{modelNeeded}

-
- - {wording.action.openSettings} - - void startCoordinator()} - > - {wording.action.retry} - -
-
- ) : unbound ? ( - // The person does not start the coordinator by typing: an unbound room waits for the - // start that opening it asked for, and a refused start says why and offers it again. - startFault === null ? ( -
-

- {wording.conversation.startingCoordinator} -

-
- ) : ( -
-

{startFault}

-
- void startCoordinator()} - > - {wording.action.retry} - -
-
- ) - ) : ( -
- {attachFault === null ? null : ( -

- {attachFault} -

- )} - {sendFailed && sendFault ? ( -

- {sendFault} -

- ) : null} - {caution === null ? null : ( -
-

- {wording.earlierDocument.warning( - participantLabel(caution), - )} -

-
- - {wording.earlierDocument.sendAnyway} - - { - setCaution(null); - composerRef.current?.focus(); - }} - > - {wording.action.cancel} - -
-
- )} - {replies.length > 0 ? ( -
- {replies.map((reply) => ( - void deliver(reply)} - > - {reply} - - ))} -
- ) : null} - - - {(mic) => ( - 0} - status={ - sending || approvalBusy ? "submitted" : "ready" - } - disabled={sending || approvalBusy} - > - {files.length === 0 ? null : ( - - - {files.map((item) => ( - - - - - setFiles((current) => - current.filter( - (held) => held.id !== item.id, - ), - ) - } - /> - - ))} - - - )} - - - {mic} - {limits === undefined || - limits.mimeTypes.length === 0 ? null : ( - <> - { - if (event.target.files !== null) - addFiles(event.target.files); - event.target.value = ""; - }} - /> - fileInputRef.current?.click()} - > - - - )} - - - - )} - - -
- )} + {dock}
{chatFirst ? null : ( diff --git a/packages/schemas/src/project-status.ts b/packages/schemas/src/project-status.ts index 4d8d3128..2666faea 100644 --- a/packages/schemas/src/project-status.ts +++ b/packages/schemas/src/project-status.ts @@ -58,5 +58,8 @@ export const ProjectStatus = type({ "waiting?": ProjectWaiting, app: ProjectAppStatus, next: { "+": "reject", text: "string > 0", "action?": ProjectNextAction }, + // Which specialist's newest run failed. The workspace must not infer this from + // next-step prose. Absent when no specialist's newest run failed. + "failedSpecialist?": WorkflowRole, }); export type ProjectStatus = typeof ProjectStatus.infer;