From 34beaf28a588fec72c7fc04e5b5ff7c1544e880e Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 21 Aug 2026 23:33:08 -0700 Subject: [PATCH] Flip workbench creation to a prompt-first screen (CL-6628) Replace the radiogroup-then-Create picker with a prompt box on top ("What do you want your Workbench to do?") and one-click prefab cards below. Submitting the prompt creates a blank workbench and delivers the typed text as the person's own first message, so Myra responds to their actual intent instead of a kind label; a prefab click still creates immediately through the same template paths as before. Also fixes the ~300ms blank-pane flash on create (CL-6623 finding #3) by rendering the loading treatment with delayMs=0 instead of holding it back for a wait that's already known to be genuine. Rewrites apps/web/test/new-workbench-picker.test.tsx and toast-single-system.test.tsx for the new shape (prompt submit, prefab-card click) in place of the old radiogroup assertions. --- apps/web/src/app.css | 111 ++++---- apps/web/src/instant-agent-create.ts | 13 + apps/web/src/pages/new-workbench-picker.tsx | 180 +++++++------ apps/web/src/workbench-templates.ts | 23 +- apps/web/test/new-workbench-picker.test.tsx | 276 ++++++++++++++------ apps/web/test/toast-single-system.test.tsx | 33 ++- scripts/checks/react-ui-drift-allowlist.ts | 7 +- 7 files changed, 404 insertions(+), 239 deletions(-) diff --git a/apps/web/src/app.css b/apps/web/src/app.css index 0d25058b..9c6fe738 100644 --- a/apps/web/src/app.css +++ b/apps/web/src/app.css @@ -3379,35 +3379,80 @@ tr.insights-row-clickable:hover { margin: 0; } -.new-workbench-pick-list { - border: 1px solid var(--border); +/* The prompt box (CL-6628): the primary act on this screen, so it gets + the heaviest visual weight — a bordered, focus-ringed field with the + send button inline, sized to read as "type here first". */ +.new-workbench-prompt { + display: flex; + align-items: flex-end; + gap: 0.6rem; + border: 1px solid var(--border-strong, var(--border)); + border-radius: 0.6rem; + padding: 0.7rem 0.7rem 0.7rem 1rem; + margin-bottom: 1.5rem; + background: var(--background); } -.new-workbench-pick-row { - width: 100%; +.new-workbench-prompt:has(.new-workbench-prompt-input:focus) { + border-color: var(--primary); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary) 20%, transparent); +} + +.new-workbench-prompt-input { + flex: 1; + min-width: 0; + resize: none; + border: 0; + background: transparent; + font: inherit; + font-size: 0.9375rem; + line-height: 1.4; + padding: 0.2rem 0; +} + +.new-workbench-prompt-input:focus { + outline: none; +} + +.new-workbench-prefab-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.75rem; +} + +@media (max-width: 640px) { + .new-workbench-prefab-grid { + grid-template-columns: 1fr; + } +} + +.new-workbench-prefab-card { display: flex; - align-items: center; - gap: 0.9rem; + flex-direction: column; + align-items: flex-start; + gap: 0.6rem; text-align: left; - border: 0; - border-bottom: 1px solid var(--border); + border: 1px solid var(--border); + border-radius: 0.5rem; background: var(--background); - padding: 0.85rem 0.9rem; + padding: 0.9rem; font: inherit; cursor: pointer; } -.new-workbench-pick-row:last-child { - border-bottom: 0; +.new-workbench-prefab-card:hover:not([aria-disabled="true"]) { + background: var(--muted); + border-color: var(--border-strong, var(--border)); } -.new-workbench-pick-row:hover:not([aria-disabled="true"]) { - background: var(--muted); +.new-workbench-prefab-card[aria-disabled="true"] { + opacity: 0.6; + cursor: not-allowed; } -.new-workbench-pick-row[data-selected="true"] { - background: color-mix(in srgb, var(--primary) 10%, transparent); - box-shadow: inset 2px 0 0 0 var(--shell-accent); +.new-workbench-prefab-card:disabled { + opacity: 0.6; + cursor: not-allowed; } .new-workbench-pick-glyph { @@ -3420,13 +3465,7 @@ tr.insights-row-clickable:hover { color: var(--muted-foreground); } -.new-workbench-pick-row[data-selected="true"] .new-workbench-pick-glyph { - border-color: color-mix(in srgb, var(--primary) 55%, transparent); - color: var(--primary); -} - .new-workbench-pick-text { - flex: 1; min-width: 0; } @@ -3444,34 +3483,6 @@ tr.insights-row-clickable:hover { margin-top: 0.1rem; } -.new-workbench-pick-row[aria-disabled="true"] { - opacity: 0.75; - cursor: not-allowed; -} - -.new-workbench-pick-cta { - flex-shrink: 0; - font-size: 0.75rem; - font-weight: 650; - color: var(--muted-foreground); -} - -.new-workbench-pick-row[data-selected="true"] .new-workbench-pick-cta { - color: var(--primary); -} - -.new-workbench-picker-foot { - margin-top: 1.1rem; - display: flex; - align-items: center; - gap: 0.7rem; -} - -.new-workbench-picker-foot-note { - font-size: 0.75rem; - color: var(--muted-foreground); -} - /* Mission Control (CL-6488/CL-6489) — stat strip, then a main column (Needs you / In flight) beside a 320px rail (Jump back in / This week), the same "stack under 1100px" rule DESIGN.md gives every right rail. */ diff --git a/apps/web/src/instant-agent-create.ts b/apps/web/src/instant-agent-create.ts index 0ff569fa..538880f8 100644 --- a/apps/web/src/instant-agent-create.ts +++ b/apps/web/src/instant-agent-create.ts @@ -19,7 +19,9 @@ import { createWorkbench, getConnectGithubState, inviteAgent, + partsForSend, patchWorkbenchSettings, + sendMessage, startReviewingGithubRepos, workbenchesQueryKeyPrefix, type ConnectGithubRepo, @@ -123,6 +125,12 @@ export type PickGithubRepos = (args: { * Without this, the room this function `navigate`s to can start life * holding a `workbenches` query cached from before the last invite * resolved. + * + * `firstMessage`, when given (CL-6628's prompt box), is sent as the + * signed-in person's own opening message once the room and its + * template participants exist, so it lands after the setup/template + * greeting rather than racing it — Myra reads the room's actual intent + * as the next line, not the first. */ export async function createWorkbenchFromTemplate( tenantId: string, @@ -130,6 +138,7 @@ export async function createWorkbenchFromTemplate( navigate: (to: string) => void, queryClient: QueryClient, pickGithubRepos?: PickGithubRepos, + firstMessage?: string, ): Promise { const definitions = await listAgentDefinitions(tenantId); const setupTemplate = findMyraDefinition(definitions); @@ -235,5 +244,9 @@ export async function createWorkbenchFromTemplate( }); } + if (firstMessage !== undefined && firstMessage.trim() !== "") { + await sendMessage(tenantId, workbench.id, partsForSend(firstMessage, [])); + } + navigate(workbenchPath(workbench.id)); } diff --git a/apps/web/src/pages/new-workbench-picker.tsx b/apps/web/src/pages/new-workbench-picker.tsx index 8d1f8453..608be670 100644 --- a/apps/web/src/pages/new-workbench-picker.tsx +++ b/apps/web/src/pages/new-workbench-picker.tsx @@ -1,26 +1,26 @@ -// Screen 1 of the approved mock (CL-6342): one row list, no card grid, no -// second "or start blank" branch underneath it — "Just start talking" is a -// peer row, not a fallback. A row is always selected on entry (the mock's -// "Code review" default), so the primary button stays enabled the whole -// time. Picking "Code review" instantiates the real template (CL-6344): -// the reviewer roster's agent definitions and the room's own opening -// intro — see `createWorkbenchFromTemplate`'s own doc for exactly what -// that does and doesn't do yet (the GitHub connect card itself is the -// next slice). +// CL-6628: flips the picker's hierarchy from "choose a kind, then create" +// to "say what you want, or choose a shortcut" — a prompt box is the +// primary act, with the prefab rows (still CL-6342/CL-6344's real +// instantiation paths) demoted to one-click shortcuts underneath. Typing +// a goal and hitting Enter creates a blank room and hands that text to +// `createWorkbenchFromTemplate` as `firstMessage`, so Myra's first read of +// the room is the person's actual intent rather than a kind label. A +// prefab click still creates immediately — no radio-then-Create +// second step anywhere on this screen. import { Button, toast } from "@corbits/react-ui"; import { ChatCircle, GitPullRequest, MagnifyingGlass, - Plus, + PaperPlaneRight, } from "@corbits/icons"; import { ChatApiError, describeChatError, WorkbenchLoadingState, } from "@corbits/chat-ui"; -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { getLogger } from "@corbits/client-log"; import { ApiQueryError, describeApiError } from "@corbits/api-query"; @@ -39,7 +39,6 @@ import { fetchAgentReadiness } from "../onboarding"; import { useNavigate } from "../navigation"; import { StageTopBar } from "../shell/stage-top-bar"; import { - COMING_SOON_ROW, WORKBENCH_TEMPLATES, type WorkbenchTemplateId, } from "../workbench-templates"; @@ -79,20 +78,18 @@ type RepoPickerState = { readonly resolve: (repoIds: readonly string[] | null) => void; }; -const ROW_ICON: Record = { +const CARD_ICON: Record = { "code-review": GitPullRequest, "due-diligence": MagnifyingGlass, blank: ChatCircle, }; -function ctaLabel(selected: boolean): string { - return selected ? "Selected" : "Choose"; -} - /** The one kind that needs no manifest: an empty room is always * something this bench can set up. */ const BLANK_TEMPLATE_ID: WorkbenchTemplateId = "blank"; +const PROMPT_PLACEHOLDER = "What do you want your Workbench to do?"; + export function NewWorkbenchPickerRoute() { const navigate = useNavigate(); const queryClient = useQueryClient(); @@ -103,7 +100,7 @@ export function NewWorkbenchPickerRoute() { : `/api/tenants/${selectedTenantId}/library/templates`, TemplateLibraryPage, ); - const [picked, setPicked] = useState(null); + const [prompt, setPrompt] = useState(""); const [creating, setCreating] = useState(false); // Set only when `createWorkbenchFromTemplate` hit the missing-setup-agent // precondition *and* a readiness check confirmed the bench genuinely @@ -113,6 +110,25 @@ export function NewWorkbenchPickerRoute() { // finishes, not a request in flight. const [stillSettingUp, setStillSettingUp] = useState(false); const [repoPicker, setRepoPicker] = useState(null); + const promptRef = useRef(null); + // The last attempted create, so "Try again" (both the still-setting-up + // dead end and a plain toast-and-retry) replays the exact same request + // rather than silently falling back to blank. + const lastAttemptRef = useRef<{ + readonly templateId: WorkbenchTemplateId; + readonly firstMessage: string | undefined; + } | null>(null); + + // The prompt box only exists in the DOM once the library read settles + // and neither dead-end state is showing — an unconditional mount-time + // effect would fire while that branch renders `WorkbenchLoadingState` + // instead, before `promptRef` has anything to focus. Re-running on + // `showingPrompt` catches the moment the textarea actually mounts. + const showingPrompt = + !stillSettingUp && !creating && library.kind !== "loading"; + useEffect(() => { + if (showingPrompt) promptRef.current?.focus(); + }, [showingPrompt]); // What this bench's library can actually serve (CL-6458). A kind whose // manifest the library doesn't hold is shown as not set up rather than @@ -128,8 +144,6 @@ export function NewWorkbenchPickerRoute() { const unavailableTemplates = WORKBENCH_TEMPLATES.filter( (template) => !offeredTemplates.includes(template), ); - const selectedId = - picked ?? offeredTemplates[0]?.id ?? WORKBENCH_TEMPLATES[0]?.id ?? "blank"; const pickGithubRepos: PickGithubRepos = ({ orgName, @@ -140,17 +154,22 @@ export function NewWorkbenchPickerRoute() { setRepoPicker({ orgName, repos, selectedRepoIds, resolve }); }); - async function handleCreate() { + async function handleCreate( + templateId: WorkbenchTemplateId, + firstMessage?: string, + ) { if (selectedTenantId === null || creating) return; + lastAttemptRef.current = { templateId, firstMessage }; setCreating(true); setStillSettingUp(false); try { await createWorkbenchFromTemplate( selectedTenantId, - selectedId, + templateId, navigate, queryClient, pickGithubRepos, + firstMessage, ); } catch (cause) { // The missing-setup-agent precondition reads identically whether @@ -182,6 +201,18 @@ export function NewWorkbenchPickerRoute() { } } + function retryLastAttempt() { + const attempt = lastAttemptRef.current; + if (attempt === null) return; + void handleCreate(attempt.templateId, attempt.firstMessage); + } + + function handlePromptSubmit() { + const trimmed = prompt.trim(); + if (trimmed === "") return; + void handleCreate(BLANK_TEMPLATE_ID, trimmed); + } + return (
-
) : creating ? ( - + // `delayMs={0}`: we already know this is a genuine wait the + // instant the person hits Enter or clicks a card, so the default + // "hold back briefly in case it resolves fast" delay only bought + // a blank pane here (CL-6623 finding #3) — show the loader + // outright instead of leaving a gap before it mounts. + ) : library.kind === "loading" ? ( ) : ( <> -

What should this workbench do?

+

What do you want your Workbench to do?

- Pick one. You can change your mind later — nothing is locked in. + Tell it what you're trying to get done, or pick one below. Takes + about ten seconds.

+
{ + event.preventDefault(); + handlePromptSubmit(); + }} + > +