diff --git a/apps/web/src/config.ts b/apps/web/src/config.ts index 00462016..29d140b6 100644 --- a/apps/web/src/config.ts +++ b/apps/web/src/config.ts @@ -705,15 +705,27 @@ export const wording = { next: (document: string) => `Next on approval: ${document}.`, /** The design concepts a design ask starts, listed by name. */ concepts: "Design concepts to compare", + /** Column headings for those concepts on the design approval card. */ + conceptColumns: { + design: "Design", + description: "Description", + }, /** An ask's own action labels, by its tool, where the defaults read wrong. */ actions: { design_app: { - approve: "Start the design", - deny: "Not yet", + /** Zero concepts, or several. */ + approve: "Start Designs", + /** One concept, by its label: "Start Clear Skies". */ + startNamed: (label: string) => `Start ${label}`, + deny: "Not Yet", }, } as Record< string, - { approve: string; approveAlways?: string; deny: string } + { + approve: string; + deny: string; + startNamed?: (label: string) => string; + } >, /** A document the card lists, as a link: "Weather App Brief v2". */ source: (title: string, version: number) => `${title} v${String(version)}`, diff --git a/apps/web/src/pages/workspace-layout.css b/apps/web/src/pages/workspace-layout.css index db741812..6dc06281 100644 --- a/apps/web/src/pages/workspace-layout.css +++ b/apps/web/src/pages/workspace-layout.css @@ -182,8 +182,8 @@ margin: 0; } -/* The library card always renders Approve always as its second action. A card - that approves exact document versions cannot take a standing grant. */ +/* The library card always renders Approve always as its second action. + Document cards and the design card do not offer that standing grant. */ .document-decision-once [data-slot="tool-approval-card"] > div:last-child @@ -194,12 +194,17 @@ /* Not yet is sm:ms-auto, and every action is shrink-0 nowrap. A long middle label fills the row, Not yet wraps, and the auto margin pins it to the right edge. Keep the actions on one line; the long label wraps inside - its own button. */ -.document-decision [data-slot="tool-approval-card"] > div:last-child { + its own button. The design card sizes its actions on their own. */ +.document-decision:not(.document-decision-design) + [data-slot="tool-approval-card"] + > div:last-child { flex-wrap: nowrap; } -.document-decision [data-slot="tool-approval-card"] > div:last-child > button { +.document-decision:not(.document-decision-design) + [data-slot="tool-approval-card"] + > div:last-child + > button { flex-shrink: 1; min-width: 0; height: auto; @@ -207,7 +212,7 @@ white-space: normal; } -.document-decision +.document-decision:not(.document-decision-design) [data-slot="tool-approval-card"] > div:last-child > button:last-child { @@ -215,6 +220,25 @@ margin-inline-start: 0; } +/* Design approval: the two actions share one line, one width and one + height. Not yet is not pinned to the right edge, and the labels do not wrap. */ +.document-decision-design [data-slot="tool-approval-card"] > div:last-child { + flex-wrap: nowrap; + align-items: stretch; +} + +.document-decision-design + [data-slot="tool-approval-card"] + > div:last-child + > button { + flex: 1 1 0; + min-width: 0; + margin-inline-start: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + /* The library keeps a settled line's words from shrinking, so a long document title ran past the column; it ellipsizes here, and the full line is the card's tooltip. */ .document-decision > p > span { @@ -246,12 +270,26 @@ } .document-decision-concepts { - display: flex; - flex-wrap: wrap; - gap: var(--space-1); + width: 100%; margin: 0; - padding: 0; - list-style: none; + border-collapse: collapse; +} + +.document-decision-concepts th, +.document-decision-concepts td { + padding: var(--space-1) var(--space-3) var(--space-1) 0; + text-align: start; + vertical-align: baseline; + font-weight: 400; +} + +.document-decision-concepts thead th { + font-weight: 600; + color: var(--wb-muted-foreground); +} + +.document-decision-concepts tbody td:first-child { + font-weight: 600; } .design-choice { diff --git a/apps/web/src/pages/workspace/document-decision.tsx b/apps/web/src/pages/workspace/document-decision.tsx index b7408adc..08c5e603 100644 --- a/apps/web/src/pages/workspace/document-decision.tsx +++ b/apps/web/src/pages/workspace/document-decision.tsx @@ -10,7 +10,6 @@ import type { ReactNode } from "react"; import { wording } from "../../config.ts"; import { - Badge, Button as UiButton, ToolApproval, type ToolApprovalDecision, @@ -19,6 +18,24 @@ import { approvalQuestion, shortDocumentName } from "./collaboration.ts"; import { approvalToolPart } from "./conversation-parts.ts"; import type { ApprovalOutcome, DocumentApproval } from "./transcript.tsx"; +/** At most ten words of a design direction, with parenthetical notes removed. */ +function designConceptDescription(direction: string): string { + const words = direction + .replaceAll(/\([^)]*\)/g, "") + .replaceAll(/\s+([,.])/g, "$1") + .replaceAll(/\s+/g, " ") + .trim() + .split(" ") + .filter((word) => word !== ""); + const breakAt = words.findIndex( + (word, index) => index >= 2 && (word.endsWith(",") || word.endsWith(".")), + ); + const count = + breakAt === -1 ? Math.min(words.length, 10) : Math.min(breakAt + 1, 10); + const line = words.slice(0, count).join(" "); + return line.endsWith(",") || line.endsWith(".") ? line.slice(0, -1) : line; +} + const text = wording.approval; const INSTRUCTION_ASKS = new Set(["instruct_coder"]); const FOLDED_INSTRUCTION_ASKS = new Set(["design_app"]); @@ -87,6 +104,27 @@ function declinedLine(approval: DocumentApproval): string { ); } +function actionLabels(approval: DocumentApproval): { + approve: string; + approveAlways?: string; + deny: string; +} { + const preset = + approval.toolName === undefined + ? undefined + : text.actions[approval.toolName]; + if (approval.toolName !== "design_app" || preset === undefined) + return preset ?? { approve: text.approve, deny: text.notYet }; + const label = + approval.concepts?.length === 1 ? approval.concepts[0]?.label : undefined; + const startDesigns = preset.approve; + const approve = + label !== undefined && preset.startNamed !== undefined + ? preset.startNamed(label) + : startDesigns; + return { approve, deny: preset.deny }; +} + function settledLine(approval: DocumentApproval): string { const { document } = approval; if (document !== undefined) @@ -136,14 +174,10 @@ export function DocumentDecision({ const shown = asked !== undefined && shownDocument === asked.kind; const name = shortName(approval); const together = approvedTogether(approval) !== undefined; - const actions = (approval.toolName === undefined - ? undefined - : text.actions[approval.toolName]) ?? { - approve: text.approve, - deny: text.notYet, - }; + const actions = actionLabels(approval); const instruction = approval.instruction; const tool = approval.toolName ?? ""; + const design = tool === "design_app"; const decide = (decision: ToolApprovalDecision) => { if (decision.outcome === "rejected") onNo(); else onYes(); @@ -177,7 +211,13 @@ export function DocumentDecision({ ); return ( name !== "") + .join(" ")} part={approvalToolPart(approval.approvalId, approval.outcome)} onDecide={decide} disabled={disabled} @@ -211,15 +251,25 @@ export function DocumentDecision({ )} {approval.concepts === undefined || approval.concepts.length === 0 ? null : ( - + + + + + + + + + {approval.concepts.map((concept) => ( + + + + + ))} + +
{text.conceptColumns.design}{text.conceptColumns.description}
{concept.label}{designConceptDescription(concept.direction)}
)} {body === undefined ? null : (

{body}

diff --git a/apps/web/src/pages/workspace/transcript.test.tsx b/apps/web/src/pages/workspace/transcript.test.tsx index 8f94e86c..d62c89d2 100644 --- a/apps/web/src/pages/workspace/transcript.test.tsx +++ b/apps/web/src/pages/workspace/transcript.test.tsx @@ -105,6 +105,9 @@ describe("conversation transcript", () => { expect(html).toContain("Happy with the brief?"); expect(html).toContain(">Yes, go ahead<"); expect(html).toContain(">Not yet<"); + expect(html).not.toContain(">Not Yet<"); + expect(html).not.toContain(" { />, ); expect(html).toContain("document-decision-once"); - expect(html).toContain(">Start the design<"); + expect(html).toContain(">Start Designs<"); expect(html).not.toContain("Start designs without asking"); }); @@ -320,4 +323,53 @@ describe("conversation transcript", () => { expect(html).not.toContain(hidden); } }); + + test("a design approval lists each look and starts one by name", () => { + const html = renderToStaticMarkup( + {}} + onAlways={() => {}} + onNo={() => {}} + />, + ); + expect(html).toContain(">Clear Skies<"); + expect(html).toContain(">Crisp cool neutrals<"); + expect(html).not.toContain("generous whitespace"); + expect(html).toContain(">Start Clear Skies<"); + expect(html).toContain(">Not Yet<"); + + const several = renderToStaticMarkup( + {}} + onAlways={() => {}} + onNo={() => {}} + />, + ); + expect(several).toContain(">Start Designs<"); + expect(several).toContain(">Night Dock<"); + expect(several).not.toContain("--radius"); + expect(several).not.toContain(">Start Clear Skies<"); + }); });