From e05b3912617183fc4ac48b8425b8859fa9da8ed7 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 1 Nov 2026 22:58:16 -0800 Subject: [PATCH 1/4] feat(web): change the composer placeholder with the project The idle prompt follows project status: pass a note to whoever is working, what to change when the app is running, what to fix after a failure, what you are making before anything is filed, otherwise reply here. An open approval keeps its own line. --- apps/hub/src/projects/next-step.ts | 66 ++++---- apps/web/src/config.ts | 15 ++ .../workspace/composer-placeholder.test.ts | 146 ++++++++++++++++++ .../pages/workspace/composer-placeholder.ts | 41 +++++ apps/web/src/pages/workspace/index.tsx | 15 +- packages/schemas/src/project-status.ts | 3 + 6 files changed, 252 insertions(+), 34 deletions(-) create mode 100644 apps/web/src/pages/workspace/composer-placeholder.test.ts create mode 100644 apps/web/src/pages/workspace/composer-placeholder.ts 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..9cea876f 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) => `I'll pass this to the ${role}…`, + role: { + coordinator: "coordinator", + writer: "writer", + reviewer: "reviewer", + designer: "designer", + coder: "app builder", + }, + change: "What to change…", + fix: "What to fix…", + making: "What are you making…", + reply: "Reply here…", + }, /** 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..9f9f2c9b --- /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("I'll pass this to 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("I'll pass this to the writer…"); + }); + + test("the coder is called the app builder", () => { + expect( + placeholder({ + status: standing({ + working: [{ role: "coder", state: "working" }], + app: { state: "running" }, + }), + }), + ).toBe("I'll pass this to the app builder…"); + }); + + test("a running app beats a failure", () => { + expect( + placeholder({ + chatFirst: true, + status: standing({ + app: { state: "running" }, + failedSpecialist: "writer", + }), + }), + ).toBe("What to change…"); + }); + + test("a failed app beats an empty project", () => { + expect( + placeholder({ + chatFirst: true, + status: standing({ app: { state: "failed" } }), + }), + ).toBe("What to fix…"); + }); + + test("a failed specialist beats an empty project", () => { + expect( + placeholder({ + chatFirst: true, + status: standing({ + app: { state: "stopped" }, + failedSpecialist: "designer", + }), + }), + ).toBe("What to fix…"); + }); + + 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("Reply here…"); + }); + + 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..6f641b02 100644 --- a/apps/web/src/pages/workspace/index.tsx +++ b/apps/web/src/pages/workspace/index.tsx @@ -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. @@ -1491,11 +1494,11 @@ export function StageWorkspace({ )} {mic} 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; From 045d04dcbdc9277d15a35b86fd92c949a3ea5e44 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 1 Nov 2026 23:18:56 -0800 Subject: [PATCH 2/4] fix(web): use plain composer placeholder copy Replace the clipped and first-person lines with short sentences a person can read. --- apps/web/src/config.ts | 8 ++++---- .../pages/workspace/composer-placeholder.test.ts | 14 +++++++------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/apps/web/src/config.ts b/apps/web/src/config.ts index 9cea876f..4828b661 100644 --- a/apps/web/src/config.ts +++ b/apps/web/src/config.ts @@ -315,7 +315,7 @@ export const wording = { }, /** The composer's placeholder when no approval is open. An open approval keeps approval.composerPlaceholder. */ composer: { - passAlong: (role: string) => `I'll pass this to the ${role}…`, + passAlong: (role: string) => `Message for the ${role}…`, role: { coordinator: "coordinator", writer: "writer", @@ -323,10 +323,10 @@ export const wording = { designer: "designer", coder: "app builder", }, - change: "What to change…", - fix: "What to fix…", + change: "What needs to change…", + fix: "What needs fixing…", making: "What are you making…", - reply: "Reply here…", + reply: "What do you want to say…", }, /** What a person's move is waiting on, as the bell and gate name it. */ wait: { diff --git a/apps/web/src/pages/workspace/composer-placeholder.test.ts b/apps/web/src/pages/workspace/composer-placeholder.test.ts index 9f9f2c9b..fdce5a6a 100644 --- a/apps/web/src/pages/workspace/composer-placeholder.test.ts +++ b/apps/web/src/pages/workspace/composer-placeholder.test.ts @@ -45,7 +45,7 @@ describe("composer placeholder", () => { failedSpecialist: "coder", }), }), - ).toBe("I'll pass this to the reviewer…"); + ).toBe("Message for the reviewer…"); }); test("a starting role is named when nobody is working", () => { @@ -60,7 +60,7 @@ describe("composer placeholder", () => { app: { state: "running" }, }), }), - ).toBe("I'll pass this to the writer…"); + ).toBe("Message for the writer…"); }); test("the coder is called the app builder", () => { @@ -71,7 +71,7 @@ describe("composer placeholder", () => { app: { state: "running" }, }), }), - ).toBe("I'll pass this to the app builder…"); + ).toBe("Message for the app builder…"); }); test("a running app beats a failure", () => { @@ -83,7 +83,7 @@ describe("composer placeholder", () => { failedSpecialist: "writer", }), }), - ).toBe("What to change…"); + ).toBe("What needs to change…"); }); test("a failed app beats an empty project", () => { @@ -92,7 +92,7 @@ describe("composer placeholder", () => { chatFirst: true, status: standing({ app: { state: "failed" } }), }), - ).toBe("What to fix…"); + ).toBe("What needs fixing…"); }); test("a failed specialist beats an empty project", () => { @@ -104,7 +104,7 @@ describe("composer placeholder", () => { failedSpecialist: "designer", }), }), - ).toBe("What to fix…"); + ).toBe("What needs fixing…"); }); test("an empty project beats the fallback", () => { @@ -127,7 +127,7 @@ describe("composer placeholder", () => { app: { state: "stopped" }, }), }), - ).toBe("Reply here…"); + ).toBe("What do you want to say…"); }); test("an open approval keeps the approval placeholder", () => { From 7a20d263046c132d2d4d33e1121a4e9e8a616b8e Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 1 Nov 2026 23:25:43 -0800 Subject: [PATCH 3/4] fix(web): say Ask the role in the composer placeholder The working-state line is Ask the writer, with the role swapped in. --- apps/web/src/config.ts | 2 +- apps/web/src/pages/workspace/composer-placeholder.test.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/web/src/config.ts b/apps/web/src/config.ts index 4828b661..893c8d35 100644 --- a/apps/web/src/config.ts +++ b/apps/web/src/config.ts @@ -315,7 +315,7 @@ export const wording = { }, /** The composer's placeholder when no approval is open. An open approval keeps approval.composerPlaceholder. */ composer: { - passAlong: (role: string) => `Message for the ${role}…`, + passAlong: (role: string) => `Ask the ${role}…`, role: { coordinator: "coordinator", writer: "writer", diff --git a/apps/web/src/pages/workspace/composer-placeholder.test.ts b/apps/web/src/pages/workspace/composer-placeholder.test.ts index fdce5a6a..5ce4e8ea 100644 --- a/apps/web/src/pages/workspace/composer-placeholder.test.ts +++ b/apps/web/src/pages/workspace/composer-placeholder.test.ts @@ -45,7 +45,7 @@ describe("composer placeholder", () => { failedSpecialist: "coder", }), }), - ).toBe("Message for the reviewer…"); + ).toBe("Ask the reviewer…"); }); test("a starting role is named when nobody is working", () => { @@ -60,7 +60,7 @@ describe("composer placeholder", () => { app: { state: "running" }, }), }), - ).toBe("Message for the writer…"); + ).toBe("Ask the writer…"); }); test("the coder is called the app builder", () => { @@ -71,7 +71,7 @@ describe("composer placeholder", () => { app: { state: "running" }, }), }), - ).toBe("Message for the app builder…"); + ).toBe("Ask the app builder…"); }); test("a running app beats a failure", () => { From 127de07d7686b528660a5a03fc3833290b41a351 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 2 Nov 2026 01:02:34 -0800 Subject: [PATCH 4/4] fix(web): flatten the workspace dock conditional --- apps/web/src/pages/workspace/index.tsx | 441 +++++++++++++------------ 1 file changed, 222 insertions(+), 219 deletions(-) diff --git a/apps/web/src/pages/workspace/index.tsx b/apps/web/src/pages/workspace/index.tsx index 6f641b02..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 { @@ -1223,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 : (