diff --git a/apps/hub/src/artifacts/service.ts b/apps/hub/src/artifacts/service.ts index f041d6b6..fbe63921 100644 --- a/apps/hub/src/artifacts/service.ts +++ b/apps/hub/src/artifacts/service.ts @@ -950,8 +950,9 @@ export function createArtifactService( .where( and( eq(approval.tenantId, actor.tenantId), - // An expired ask is one a coordinator upgrade withdrew; its successor asks again. - notInArray(approval.status, ["pending", "expired"]), + // An expired ask is one a coordinator upgrade withdrew; its successor asks again. A + // responded one was questions, whose answers are the person's turn. + notInArray(approval.status, ["pending", "expired", "responded"]), ), ) .orderBy(approval.createdAt); @@ -970,7 +971,7 @@ export function createArtifactService( ); return { approvals: rows.flatMap((row) => - row.status === "pending" + row.status === "pending" || row.status === "responded" ? [] : [ { diff --git a/apps/hub/src/config/project-status.ts b/apps/hub/src/config/project-status.ts index 56ca4f2e..0727c676 100644 --- a/apps/hub/src/config/project-status.ts +++ b/apps/hub/src/config/project-status.ts @@ -20,6 +20,7 @@ const APPROVAL: Record = { instruct_coder: "Approve the change for the app builder", design_app: "Approve the design work", install_packages: "Approve the packages the app builder asked for", + ask_questions: "Answer the coordinator's questions", }; export const projectStatusWording = { diff --git a/apps/hub/src/config/questions.ts b/apps/hub/src/config/questions.ts deleted file mode 100644 index 51cb3742..00000000 --- a/apps/hub/src/config/questions.ts +++ /dev/null @@ -1,3 +0,0 @@ -// The ask_questions result the coordinator reads; the person's answers reach it as their next message. -export const QUESTIONS_ASKED = - "Asked. The questions show on a card below your reply; the person's answers arrive as their next message."; diff --git a/apps/hub/src/config/resume.ts b/apps/hub/src/config/resume.ts index e60a8cf5..542c8c6b 100644 --- a/apps/hub/src/config/resume.ts +++ b/apps/hub/src/config/resume.ts @@ -70,7 +70,7 @@ export const resumeMessage = (context: string | undefined, message: string) => export function approvalDecisionLine({ outcome, message, -}: ApprovalDecision): string { +}: Exclude): string { const decided = outcome === "approved" ? "Approved" : "Rejected"; // A blank note is no note: the line ends at the decision, not at a dangling colon. const note = message?.trim(); diff --git a/apps/hub/src/mail/conversation-turns.ts b/apps/hub/src/mail/conversation-turns.ts index 2518f27f..d5cbaedd 100644 --- a/apps/hub/src/mail/conversation-turns.ts +++ b/apps/hub/src/mail/conversation-turns.ts @@ -21,6 +21,11 @@ import { approvalDecisionLine, withoutResumeContext, } from "../config/resume.ts"; +import { + ASK_QUESTIONS, + QuestionsResponse, + questionsResponseText, +} from "@corbits-builder/schemas/questions"; import { personTurnText } from "../config/specialist-ended.ts"; import { declinedWords } from "../config/decline.ts"; import { readCoordinatorMailThreads } from "./conversation-threads.ts"; @@ -79,7 +84,9 @@ function approvalText( decisions: ApprovalDecisionTurns, ): string | undefined { const decision = ApprovalDecision(parseJson(body)); - if (decision instanceof type.errors) return undefined; + // Answers are the person's turn by the questions call's own result. + if (decision instanceof type.errors || decision.outcome === "responded") + return undefined; const words = declinedWords(decision.message); return decisions === "note" ? words @@ -95,6 +102,41 @@ function personText(body: string): string | undefined { return clippedTurnText(personTurnText(text)); } +const blocksOf = (content: unknown): Record[] => + Array.isArray(content) ? content.filter(isRecord) : []; + +/** The questions calls an assistant turn makes, by id. */ +const questionCalls = (content: unknown): string[] => + blocksOf(content).flatMap((block) => + block["type"] === "tool_call" && + block["name"] === ASK_QUESTIONS && + typeof block["id"] === "string" + ? [block["id"]] + : [], + ); + +/** The person's response a turn returns to a questions call, as their words. */ +const answersOf = ( + content: unknown, + asked: ReadonlySet, +): string | undefined => { + const result = blocksOf(content) + .flatMap((block) => + block["type"] === "tool_result" && + block["isError"] !== true && + typeof block["callId"] === "string" && + asked.has(block["callId"]) + ? [textFromContent(block["content"])] + : [], + ) + .find((text) => text !== undefined); + if (result === undefined) return undefined; + const response = QuestionsResponse(parseJson(result)); + return response instanceof type.errors + ? result + : questionsResponseText(response); +}; + // A missing or corrupt turn list is empty; it does not throw. function mapMirrorConversationTurns( turns: unknown, @@ -102,12 +144,21 @@ function mapMirrorConversationTurns( ): ConversationTextTurn[] { if (!Array.isArray(turns)) return []; const mapped: ConversationTextTurn[] = []; + const asked = new Set(); for (const turn of turns) { if (!isRecord(turn)) continue; const role = mirrorRole(turn["role"]); const at = turn["timestamp"]; if (role === undefined || typeof at !== "number" || !Number.isFinite(at)) continue; + if (role === "assistant") + for (const id of questionCalls(turn["content"])) asked.add(id); + const answers = + role === "inbound" ? answersOf(turn["content"], asked) : undefined; + if (answers !== undefined) { + mapped.push({ role: "person", text: clippedTurnText(answers), at }); + continue; + } const raw = textFromContent(turn["content"]); if (raw === undefined) continue; if (role === "assistant") { diff --git a/apps/hub/src/projects/coordinator-upgrade.ts b/apps/hub/src/projects/coordinator-upgrade.ts index e26baeef..bdb7b797 100644 --- a/apps/hub/src/projects/coordinator-upgrade.ts +++ b/apps/hub/src/projects/coordinator-upgrade.ts @@ -1,4 +1,4 @@ -import { and, desc, eq, inArray, ne } from "drizzle-orm"; +import { and, desc, eq, inArray, isNull, ne, sql } from "drizzle-orm"; import { type } from "arktype"; import { createApprovalStore, type DB } from "@intx/db"; import { @@ -183,12 +183,20 @@ export function createCoordinatorUpgrades(deps: { return asked !== undefined; } - // The coordinator's own undecided approval, which it waits on. + // The coordinator's own undecided approval, which it waits on. A call waiting for the person's + // response, such as open questions, is not one: the person may be answering it, and a successor + // would not know what was asked. async function ownApproval(runId: string) { const [asked] = await db .select({ correlationId: approval.correlationId }) .from(approval) - .where(and(eq(approval.runId, runId), eq(approval.status, "pending"))) + .where( + and( + eq(approval.runId, runId), + eq(approval.status, "pending"), + isNull(sql`${approval.toolDefinition}->>'respondable'`), + ), + ) .limit(1); return asked?.correlationId; } diff --git a/apps/hub/src/projects/deploy-tools.ts b/apps/hub/src/projects/deploy-tools.ts index aab3e421..a83eb58a 100644 --- a/apps/hub/src/projects/deploy-tools.ts +++ b/apps/hub/src/projects/deploy-tools.ts @@ -32,6 +32,7 @@ import { } from "@corbits-builder/schemas/artifact"; import { Preferences } from "@corbits-builder/schemas/preferences"; import { + ASK_QUESTIONS, AskQuestions, askQuestionsIssue, } from "@corbits-builder/schemas/questions"; @@ -54,7 +55,6 @@ import { designConceptAssignment, designLookIdeas, } from "../config/design.ts"; -import { QUESTIONS_ASKED } from "../config/questions.ts"; import { buildPackageAssignment, formatPersonasCatalog, @@ -259,9 +259,9 @@ export function createDeployTools(options?: { inputSchema: { ...RequestApproval.toJsonSchema() }, }, { - name: "ask_questions", + name: ASK_QUESTIONS, description: - "Put up to five questions to the person on one card shown below your reply; your reply introduces them without repeating them. id is a short slug unique within the call. kind single takes one option and multi any number. preselected holds the option values you recommend, at most one for single. allowOther lets the person answer in their own words. Their answers arrive as their next message.", + "Put up to five questions to the person on one card shown below your reply; your reply introduces them without repeating them. id is a short slug unique within the call. kind single takes one option and multi any number. preselected holds the option values you recommend, at most one for single. allowOther lets the person answer in their own words. The call waits for the person, and its result is their response as JSON: answers lists each question with the labels they chose, their own words included, or reply holds their own words when they answered in the conversation instead.", inputSchema: { ...AskQuestions.toJsonSchema() }, }, { @@ -313,7 +313,7 @@ export function createDeployTools(options?: { requires: ["capabilities"], definitions: [ { name: "request_approval", approval: "ask" }, - { name: "ask_questions" }, + { name: ASK_QUESTIONS, approval: "respond" }, { name: "deploy_writer" }, { name: "review_artifact" }, { name: "deploy_coder" }, @@ -323,22 +323,25 @@ export function createDeployTools(options?: { ], factory: (env) => ({ definitions, - run: async (call, signal) => { - if (call.name === "ask_questions") { - try { - const issue = askQuestionsIssue( - validArguments(AskQuestions, call.arguments), - ); - if (issue) throw new Refusal("invalid_questions: " + issue); - return { callId: call.id, content: QUESTIONS_ASKED }; - } catch (error) { - return { - callId: call.id, - content: refusalText(error), - isError: true, - }; - } + // Questions are checked before the call waits on the person, so a malformed set is refused at + // once instead of becoming a card nobody can answer. + check: async (call) => { + if (call.name !== ASK_QUESTIONS) return undefined; + try { + const issue = askQuestionsIssue( + validArguments(AskQuestions, call.arguments), + ); + if (issue) throw new Refusal("invalid_questions: " + issue); + return undefined; + } catch (error) { + return { + callId: call.id, + content: refusalText(error), + isError: true, + }; } + }, + run: async (call, signal) => { if (!options) { try { const scope = await resolveWorkflowProjectScope( diff --git a/apps/web/src/api/ask.ts b/apps/web/src/api/ask.ts index 79f4574d..70e7a051 100644 --- a/apps/web/src/api/ask.ts +++ b/apps/web/src/api/ask.ts @@ -1,5 +1,9 @@ import { type } from "arktype"; import { DesignConcept } from "@corbits-builder/schemas/artifact"; +import { + ASK_QUESTIONS, + AskQuestions, +} from "@corbits-builder/schemas/questions"; import type { Wait } from "./types.ts"; const Names = type("string[]"); @@ -10,7 +14,13 @@ export function askOf( args: Record, ): Pick< Wait, - "toolName" | "packages" | "concepts" | "instruction" | "subject" | "kinds" + | "toolName" + | "packages" + | "concepts" + | "instruction" + | "subject" + | "kinds" + | "questions" > { const packages = args["packages"]; const listed = Names(args["kinds"]); @@ -21,6 +31,9 @@ export function askOf( const concepts = DesignConcept.array()(args["concepts"]); const designConcepts = concepts instanceof type.errors ? undefined : concepts; const installs = Names(packages); + // Questions in the shape the card draws; the hub refuses any other before the call waits. + const questions = + toolName === ASK_QUESTIONS && AskQuestions.allows(args) ? args : undefined; return { ...(toolName === undefined ? {} : { toolName }), ...(installs instanceof type.errors ? {} : { packages: installs }), @@ -28,5 +41,6 @@ export function askOf( ...(typeof instruction === "string" ? { instruction } : {}), ...(typeof subject === "string" ? { subject } : {}), ...(listed instanceof type.errors ? {} : { kinds: listed }), + ...(questions === undefined ? {} : { questions }), }; } diff --git a/apps/web/src/api/decisions.ts b/apps/web/src/api/decisions.ts index 05df3a1a..52eb1a98 100644 --- a/apps/web/src/api/decisions.ts +++ b/apps/web/src/api/decisions.ts @@ -17,6 +17,7 @@ import { wording } from "../config.ts"; import { productClient } from "./transport.ts"; import type { Wait } from "./types.ts"; import { askOf } from "./ask.ts"; +import { ASK_QUESTIONS } from "@corbits-builder/schemas/questions"; import { isInputPark, readConversationEvents } from "./conversation.ts"; import { workspaceTenantId } from "./auth.ts"; @@ -94,8 +95,15 @@ export async function openDecisions(onlyProjectId?: string): Promise { const pending = approvals.filter( (approval) => approval.status === "pending", ); - const projectWaits: Omit[] = pending.map((approval) => - approvalWait(approval, record.projectId, record.tenantId), + // Questions are answered on their card, never approved, so any the card cannot draw are left + // out rather than shown as an approval the hub would refuse. + const projectWaits: Omit[] = pending.flatMap( + (approval) => { + const wait = approvalWait(approval, record.projectId, record.tenantId); + return wait.toolName === ASK_QUESTIONS && wait.questions === undefined + ? [] + : [wait]; + }, ); // Only the latest input park is the person's move; earlier parks that a // later SignalReceived released are not waiting on anything. @@ -113,6 +121,19 @@ export async function openDecisions(onlyProjectId?: string): Promise { return waits; } +/** Responds to an ask whose result is the person's own input; the waiting call returns `result`. */ +export async function respondToApproval(input: { + tenantId: string; + approvalId: string; + result: string; +}): Promise { + await productClient().respondToNativeApproval( + input.tenantId, + input.approvalId, + input.result, + ); +} + export type DecisionChoice = "approve" | "reject"; /** Resolves one native approval; the hub binds nothing the person did not see in the park. */ diff --git a/apps/web/src/api/types.ts b/apps/web/src/api/types.ts index 9a818c2a..6b3876a6 100644 --- a/apps/web/src/api/types.ts +++ b/apps/web/src/api/types.ts @@ -3,6 +3,7 @@ * no hub record, its type says so and it carries an empty value. */ import type { ProjectStage } from "@corbits-builder/schemas/project"; +import type { AskQuestions } from "@corbits-builder/schemas/questions"; export type ApiErrorDetail = { code: string; @@ -58,6 +59,8 @@ export type Wait = { packages?: readonly string[]; /** The design concepts a design_app approval starts. */ concepts?: readonly { readonly label: string; readonly direction: string }[]; + /** The questions an ask_questions call waits on the person to answer. */ + questions?: AskQuestions; /** * The coordinator's open input park: the person's move in the * conversation. `parkProse` on the carrying event is the question to show; diff --git a/apps/web/src/config.ts b/apps/web/src/config.ts index acf8bc9d..1d32c61f 100644 --- a/apps/web/src/config.ts +++ b/apps/web/src/config.ts @@ -1044,11 +1044,9 @@ export const wording = { notSent: "Your answers were not sent. Your choices are kept; try again.", /** The settled card's heading, over each question and its answer. */ answered: "Answered", + /** The questions' answer when the person sends files without words while they are open. */ + sentFiles: (names: readonly string[]) => `Sent ${names.join(", ")}`, answeredInChat: "Answered in the conversation", - replaced: "Replaced by the questions that followed", - /** One list item of the person's reply per question: "- Who signs in? — Staff, Guests". */ - answerLine: (prompt: string, answers: readonly string[]) => - `- ${prompt} — ${answers.join(", ")}`, }, archivedProject: { title: "This project is archived", diff --git a/apps/web/src/pages/workspace/index.tsx b/apps/web/src/pages/workspace/index.tsx index 87f9c593..108e0685 100644 --- a/apps/web/src/pages/workspace/index.tsx +++ b/apps/web/src/pages/workspace/index.tsx @@ -60,6 +60,10 @@ import { earlierDocumentRole } from "./earlier-document.ts"; import { pastedFiles } from "./pasted-files.ts"; import { ArtifactPane, LIVE_TAB } from "./artifact-pane.tsx"; import { ProductApiError, type ProjectArtifact } from "@corbits-builder/client"; +import { + questionsResponseText, + type QuestionsResponse, +} from "@corbits-builder/schemas/questions"; import type { InFlightRun } from "@corbits-builder/schemas/artifact"; import { toast } from "sonner"; import { sendMessage } from "../../api/conversation.ts"; @@ -81,7 +85,7 @@ import type { ChatMessage, Wait } from "../../api/types.ts"; import { deliveredText } from "@corbits-builder/schemas/conversation"; import { keys } from "../../queries/keys.ts"; import { Composer } from "../../composer.tsx"; -import { questionsOpen, RoomThread, turnItemId } from "./transcript.tsx"; +import { RoomThread, turnItemId } from "./transcript.tsx"; import { cardDocuments } from "./collaboration.ts"; import { useFiledVersions } from "./filed-versions.tsx"; import { threadUpdates } from "./specialist-updates.tsx"; @@ -118,7 +122,11 @@ import { withPendingSends, type SentMessage, } from "./pending-sends.ts"; -import { decideApproval, openDecisions } from "../../api/decisions.ts"; +import { + respondToApproval, + decideApproval, + openDecisions, +} from "../../api/decisions.ts"; import type { DeliverableState } from "./deliverable-block.tsx"; import { formatPassageQuote, type PassageQuote } from "./passage-note.tsx"; import { DesignTarget } from "./mockup-tweaks.tsx"; @@ -212,11 +220,29 @@ function documentApprovalsFrom( ...(wait.subject === undefined ? {} : { subject: wait.subject }), ...(wait.packages === undefined ? {} : { packages: wait.packages }), ...(wait.concepts === undefined ? {} : { concepts: wait.concepts }), + ...(wait.questions === undefined ? {} : { questions: wait.questions }), }, ]; }); } +/** The coordinator's open ask a decision or a message settles; questions are answered instead. */ +function coordinatorAsk( + approvals: readonly OpenApproval[] | undefined, +): OpenApproval | undefined { + return approvals?.find( + (approval) => + approval.packages === undefined && approval.questions === undefined, + ); +} + +/** The coordinator's open questions, answered by whatever the person sends while they are open. */ +function questionsAsk( + approvals: readonly OpenApproval[] | undefined, +): OpenApproval | undefined { + return approvals?.find((approval) => approval.questions !== undefined); +} + export { selectedDeliverable }; function openingDraft( @@ -697,11 +723,11 @@ function ReadyRoom({ [...mirror].reverse().find((turn) => turn.role === "assistant")?.text ?? ""; const documentApprovals = waits === undefined ? undefined : documentApprovalsFrom(waits, artifacts); - const { settled: settledApprovals, settle } = useSettledApprovals( - projectId, - documentApprovals, - artifacts, - ); + const { + settled: settledApprovals, + settle, + respond, + } = useSettledApprovals(projectId, documentApprovals, artifacts); // A card decided here is already its settled line; the pending read lags behind it. const pendingFrom = (list: readonly Wait[]): OpenApproval[] => documentApprovalsFrom(list, artifacts).filter( @@ -852,9 +878,8 @@ function ReadyRoom({ setFiles((current) => [...current, ...fitting]); }; - const deliverFiles = async (): Promise => { + const deliverFiles = async (text = value.trim()): Promise => { if (sending) return false; - const text = value.trim(); const attached = files; setSending(true); setSendFailed(false); @@ -1061,18 +1086,40 @@ function ReadyRoom({ at: new Date().toISOString(), }; }; - const openApproval = pendingApprovals?.find( - (approval) => approval.packages === undefined, - ); + // The person's response is the waiting questions' result, as JSON: their answers on the card, + // or the words they sent instead. + const respondWith = + (approval: OpenApproval, answers?: QuestionsResponse) => + async (text: string): Promise => { + const response = answers ?? { reply: text }; + await respondToApproval({ + tenantId: approval.tenantId, + approvalId: approval.approvalId, + result: JSON.stringify(response), + }); + respond(approval, response); + return { + id: `answered:${approval.approvalId}`, + author: "me", + body: text, + at: new Date().toISOString(), + }; + }; + const openApproval = coordinatorAsk(pendingApprovals); + const openQuestions = questionsAsk(pendingApprovals); // Choosing an approach is the person's yes to the version's open ask; the message names the pick. const chooseApproach = async ( message: string, version: number, ): Promise => { const currentApprovals = await readPendingApprovals(); - const currentOpen = currentApprovals?.find( - (approval) => approval.packages === undefined, - ); + // Whatever the person sends while questions are open answers them, as typed words do. + const questions = questionsAsk(currentApprovals); + if (questions !== undefined) + return (await deliverMail(message, false, respondWith(questions))) + ? null + : wording.approaches.notSent; + const currentOpen = coordinatorAsk(currentApprovals); const kind = selected?.kind; const asked = kind === undefined @@ -1095,9 +1142,10 @@ function ReadyRoom({ const deliver = async (body: string, clearComposer = false) => { if (body.trim().length === 0 || sending) return false; const currentApprovals = await readPendingApprovals(); - const currentOpen = currentApprovals?.find( - (approval) => approval.packages === undefined, - ); + const questions = questionsAsk(currentApprovals); + if (questions !== undefined) + return deliverMail(body, clearComposer, respondWith(questions)); + const currentOpen = coordinatorAsk(currentApprovals); return deliverWithOpenApproval({ openApprovalId: currentOpen?.approvalId, decide: onDecideApproval, @@ -1109,9 +1157,20 @@ function ReadyRoom({ const sendFiles = async () => { if (sending) return false; const currentApprovals = await readPendingApprovals(); - const currentOpen = currentApprovals?.find( - (approval) => approval.packages === undefined, - ); + const questions = questionsAsk(currentApprovals); + if (questions !== undefined) { + // The words, or a note naming the files, answer the questions; the files follow on their own. + const text = value.trim(); + const answer = + text.length > 0 + ? text + : wording.questions.sentFiles(files.map((item) => item.name)); + return ( + (await deliverMail(answer, false, respondWith(questions))) && + deliverFiles("") + ); + } + const currentOpen = coordinatorAsk(currentApprovals); return deliverWithOpenApproval({ openApprovalId: currentOpen?.approvalId, decide: onDecideApproval, @@ -1128,7 +1187,10 @@ function ReadyRoom({ if (!(await deliver(message))) restoreToComposer(message); }; const typedYes = useTypedYes({ - pending: pendingApprovals?.map((approval) => approval.approvalId), + pending: + openQuestions === undefined + ? pendingApprovals?.map((approval) => approval.approvalId) + : [], onApprove: (approvalId) => void onDecideApproval({ approvalId, choice: "yes" }), onUndo: (message) => void undoTypedYes(message), @@ -1582,8 +1644,7 @@ function ReadyRoom({ : composerPlaceholder({ approvalOpen: openApproval !== undefined, questionsOpen: - !archived && - questionsOpen(shownTurns, liveRun.results), + !archived && openQuestions !== undefined, status: projectStatus.data, chatFirst, }) @@ -1719,7 +1780,20 @@ function ReadyRoom({ }, })} {...(sentId === undefined ? {} : { sentId })} - onAnswerQuestions={(reply) => deliver(reply)} + onAnswerQuestions={(approvalId, response) => { + const asked = pendingApprovals?.find( + (approval) => + approval.approvalId === approvalId && + approval.questions !== undefined, + ); + return asked === undefined + ? Promise.resolve(false) + : deliverMail( + questionsResponseText(response), + false, + respondWith(asked, response), + ); + }} questionsDisabled={sending || archived} questionsLayout={questionsLayout} > diff --git a/apps/web/src/pages/workspace/questions-card.stories.tsx b/apps/web/src/pages/workspace/questions-card.stories.tsx index c016e7af..f29ba9e8 100644 --- a/apps/web/src/pages/workspace/questions-card.stories.tsx +++ b/apps/web/src/pages/workspace/questions-card.stories.tsx @@ -1,7 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import type { AskQuestions } from "@corbits-builder/schemas/questions"; import { fn } from "storybook/test"; -import { wording } from "../../config.ts"; import { QuestionsCard } from "./questions-card.tsx"; const ask: AskQuestions = { @@ -71,13 +70,20 @@ export const Answered: Story = { args: { state: { kind: "answered", - reply: [ - wording.questions.answerLine("Who is it for?", ["Bakery owners"]), - wording.questions.answerLine("What must the first version do?", [ - "Take orders", - "List the day's orders", - ]), - ].join("\n"), + response: { + answers: [ + { + id: "audience", + question: "Who is it for?", + answer: ["Bakery owners"], + }, + { + id: "first_version", + question: "What must the first version do?", + answer: ["Take orders", "List the day's orders"], + }, + ], + }, }, }, }; @@ -85,8 +91,9 @@ export const Answered: Story = { /** Answered in the person's own words, so the card shows no answers. */ export const AnsweredInChat: Story = { args: { - state: { kind: "answered", reply: "Actually, let's build a game." }, + state: { + kind: "answered", + response: { reply: "Actually, let's build a game." }, + }, }, }; - -export const Replaced: Story = { args: { state: { kind: "replaced" } } }; diff --git a/apps/web/src/pages/workspace/questions-card.test.tsx b/apps/web/src/pages/workspace/questions-card.test.tsx index bf42f200..d2c74386 100644 --- a/apps/web/src/pages/workspace/questions-card.test.tsx +++ b/apps/web/src/pages/workspace/questions-card.test.tsx @@ -79,16 +79,22 @@ describe("questions card", () => { }); test("an answered card settles the same whichever layout asked it", () => { - const reply = [ - text.answerLine("Who is it for?", ["Small teams"]), - text.answerLine("What must the first version do?", ["Track tasks"]), - ].join("\n"); + const response = { + answers: [ + { id: "audience", question: "Who is it for?", answer: ["Small teams"] }, + { + id: "first_version", + question: "What must the first version do?", + answer: ["Track tasks"], + }, + ], + }; const settled = (["steps", "all"] as const).map((layout) => renderToStaticMarkup( , ), @@ -106,7 +112,10 @@ describe("questions card", () => { , ); diff --git a/apps/web/src/pages/workspace/questions-card.tsx b/apps/web/src/pages/workspace/questions-card.tsx index 174f3d8f..d9f41c03 100644 --- a/apps/web/src/pages/workspace/questions-card.tsx +++ b/apps/web/src/pages/workspace/questions-card.tsx @@ -1,7 +1,6 @@ /** - * The coordinator's ask_questions call as a card the person answers. The answers go back as the - * person's next message, the way the composer sends one; the card holds no state of its own past - * that message, so a later person turn is what marks it answered. + * The coordinator's ask_questions call as a card the person answers. The call waits on the person + * as a pending approval, and their answers are what it returns. */ import { useEffect, @@ -10,43 +9,27 @@ import { type KeyboardEvent, type ReactNode, } from "react"; -import { Check, CornerDownRight } from "lucide-react"; +import { Check } from "lucide-react"; import { Button, Checkbox, GateBlock, Input, IntakeForm, - type PartTool, } from "@corbits/react-ui"; -import { +import type { AskQuestions, - askQuestionsIssue, - type Question, + Question, + QuestionsResponse, } from "@corbits-builder/schemas/questions"; import { wording } from "../../config.ts"; import type { QuestionsLayout } from "../../questions-layout-setting.ts"; -import type { CallResult } from "./live-run.ts"; import { clsx } from "clsx"; import { Fault } from "../../components.jsx"; import styles from "./questions-card.module.css"; const text = wording.questions; -export const ASK_QUESTIONS = "ask_questions"; - -/** The questions a finished call asked, or undefined when it is not one the hub accepted. */ -export function askedQuestions( - part: PartTool, - result: CallResult | undefined, -): AskQuestions | undefined { - if (part.name !== ASK_QUESTIONS || part.status !== "output-available") - return undefined; - if (result?.isError === true || !AskQuestions.allows(part.input)) - return undefined; - return askQuestionsIssue(part.input) === undefined ? part.input : undefined; -} - type Answer = { readonly chosen: readonly string[]; readonly other: boolean; @@ -57,10 +40,9 @@ export type QuestionsState = | { readonly kind: "open"; readonly disabled: boolean; - readonly onSend: (reply: string) => Promise; + readonly onSend: (response: QuestionsResponse) => Promise; } - | { readonly kind: "answered"; readonly reply: string } - | { readonly kind: "replaced" }; + | { readonly kind: "answered"; readonly response: QuestionsResponse }; function answerLabels(question: Question, answer: Answer): string[] { const labels = question.options @@ -84,58 +66,31 @@ export function stepName(id: string): string { return words.charAt(0).toUpperCase() + words.slice(1); } -/** Each question with its answer as the person's reply carries it, by the line the card composed. */ -function repliedAnswers( - questions: readonly Question[], - reply: string, -): AnswerRow[] { - const lines = reply.split("\n"); - return questions.flatMap((question) => { - const lead = text.answerLine(question.prompt, []); - const line = lines.find((candidate) => candidate.startsWith(lead)); - return line === undefined - ? [] - : [ - { - id: question.id, - prompt: question.prompt, - answer: line.slice(lead.length), - }, - ]; - }); -} - -type AnswerRow = { id: string; prompt: string; answer: string }; - -function AnswerList({ answers }: { answers: readonly AnswerRow[] }) { +function AnswerList({ + answers, +}: { + answers: Extract["answers"]; +}) { return (
- {answers.map(({ id, prompt, answer }) => ( + {answers.map(({ id, question, answer }) => (
-
{prompt}
-
{answer}
+
{question}
+
{answer.join(", ")}
))}
); } -/** The card once done, the same in either layout: its outcome, and the answers the reply carried. */ -function Settled({ ask, reply }: { ask: AskQuestions; reply?: string }) { - if (reply === undefined) - return ( - } - /> - ); - const answers = repliedAnswers(ask.questions, reply); +/** The card once done, the same in either layout: the answers the person gave on it. */ +function Settled({ response }: { response: QuestionsResponse }) { const mark = ; - if (answers.length === 0) + if ("reply" in response) return ; return ( - + ); } @@ -574,8 +529,8 @@ function StepReview({ ({ id: question.id, - prompt: question.prompt, - answer: answerLabels(question, answerOf(question)).join(", "), + question: question.prompt, + answer: answerLabels(question, answerOf(question)), }))} />