Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions apps/web/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)}`,
Expand Down
60 changes: 49 additions & 11 deletions apps/web/src/pages/workspace-layout.css
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -194,27 +194,51 @@
/* 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;
min-height: 2rem;
white-space: normal;
}

.document-decision
.document-decision:not(.document-decision-design)
[data-slot="tool-approval-card"]
> div:last-child
> button:last-child {
flex-shrink: 0;
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 {
Expand Down Expand Up @@ -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 {
Expand Down
84 changes: 67 additions & 17 deletions apps/web/src/pages/workspace/document-decision.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
import type { ReactNode } from "react";
import { wording } from "../../config.ts";
import {
Badge,
Button as UiButton,
ToolApproval,
type ToolApprovalDecision,
Expand All @@ -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"]);
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -177,7 +211,13 @@ export function DocumentDecision({
);
return (
<ToolApproval
className="document-decision document-decision-once"
className={[
"document-decision",
"document-decision-once",
design ? "document-decision-design" : "",
]
.filter((name) => name !== "")
.join(" ")}
part={approvalToolPart(approval.approvalId, approval.outcome)}
onDecide={decide}
disabled={disabled}
Expand Down Expand Up @@ -211,15 +251,25 @@ export function DocumentDecision({
)}
{approval.concepts === undefined ||
approval.concepts.length === 0 ? null : (
<ul className="document-decision-concepts" aria-label={text.concepts}>
{approval.concepts.map((concept) => (
<li key={concept.label}>
<Badge tone="neutral" title={concept.direction}>
{concept.label}
</Badge>
</li>
))}
</ul>
<table
className="document-decision-concepts"
aria-label={text.concepts}
>
<thead>
<tr>
<th scope="col">{text.conceptColumns.design}</th>
<th scope="col">{text.conceptColumns.description}</th>
</tr>
</thead>
<tbody>
{approval.concepts.map((concept) => (
<tr key={concept.label}>
<td>{concept.label}</td>
<td>{designConceptDescription(concept.direction)}</td>
</tr>
))}
</tbody>
</table>
)}
{body === undefined ? null : (
<p className="document-decision-next">{body}</p>
Expand Down
54 changes: 53 additions & 1 deletion apps/web/src/pages/workspace/transcript.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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("<table");
expect(html).not.toContain("Start Designs");
expect(html).not.toContain("request_approval");
expect(html).not.toContain("appr-1");
expect(html).not.toContain(briefId);
Expand All @@ -122,7 +125,7 @@ describe("conversation transcript", () => {
/>,
);
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");
});

Expand Down Expand Up @@ -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(
<DocumentDecision
approval={{
approvalId: "appr-design",
toolName: "design_app",
concepts: [
{
label: "Clear Skies",
direction:
"Crisp cool neutrals, one cobalt accent, generous whitespace.",
},
],
}}
onYes={() => {}}
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(
<DocumentDecision
approval={{
approvalId: "appr-several",
toolName: "design_app",
concepts: [
{ label: "Clear Skies", direction: "Crisp cool neutrals." },
{
label: "Night Dock",
direction: "Square corners (--radius 0), a deep navy primary.",
},
],
}}
onYes={() => {}}
onAlways={() => {}}
onNo={() => {}}
/>,
);
expect(several).toContain(">Start Designs<");
expect(several).toContain(">Night Dock<");
expect(several).not.toContain("--radius");
expect(several).not.toContain(">Start Clear Skies<");
});
});