From 922f52fe59a8de5bdf1aa3e55ce7a0c2cdf03a4d Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 21 Aug 2026 15:27:10 -0700 Subject: [PATCH 1/2] Add tests for the zero-workbench land redirecting to the picker A brand-new account should land on the guided workbench picker, not get auto-minted into an unlabeled "New Workbench" it never asked for. Pins the redirect (and keeps the existing Myra-readiness wait/retry/ slow states) so HomeRoute's implementation can be changed under it. --- apps/web/test/home-page.test.tsx | 106 ++++++++----------------------- 1 file changed, 28 insertions(+), 78 deletions(-) diff --git a/apps/web/test/home-page.test.tsx b/apps/web/test/home-page.test.tsx index f5ac6201..1b9eeab4 100644 --- a/apps/web/test/home-page.test.tsx +++ b/apps/web/test/home-page.test.tsx @@ -2,17 +2,17 @@ // resolves to one of two places depending on whether the bench has any // workbenches yet. A bench with one or more ensures Myra's workbench exists // and opens it — the same land-hop CL-6081 wired up. A brand-new bench -// with zero workbenches auto-mints its first Myra workbench through the -// exact same one-creation-verb path every "+ New workbench" control uses -// (CL-6138, superseding CL-6104's guided describe screen) and lands -// straight in it — no separate first-run form, no second creation path. -// All three entries CL-6081 asks for (a direct visit to `/`, `main.tsx`'s -// post-login `navigate("/")`, and the onboarding wizard's -// post-credential hand-off) resolve through this exact hop, so proving -// HomeRoute itself lands correctly in both cases proves the direct-`/` -// case fully; the other two are proven by the narrower source assertions -// below, which pin the exact call each entry point makes onto this same -// route. +// with zero workbenches waits for Myra's own definition to exist, then +// sends the person to the guided picker (`/new`, CL-6486) instead of +// auto-minting an unlabeled workbench and landing straight in it — no +// separate first-run form, no second creation path, just the same picker +// every other "+ New workbench" control already opens. All three entries +// CL-6081 asks for (a direct visit to `/`, `main.tsx`'s post-login +// `navigate("/")`, and the onboarding wizard's post-credential hand-off) +// resolve through this exact hop, so proving HomeRoute itself lands +// correctly in both cases proves the direct-`/` case fully; the other two +// are proven by the narrower source assertions below, which pin the exact +// call each entry point makes onto this same route. import { afterEach, describe, expect, test } from "bun:test"; import { act } from "react"; @@ -157,42 +157,19 @@ describe("HomeRoute (the `/` land hop every entry point funnels through)", () => expect(navigated).toEqual(["/w/chan_myra"]); }); - test("a brand-new bench with zero workbenches auto-mints its first Myra workbench and lands in it", async () => { + test("a brand-new bench with zero workbenches sends the person to the guided picker, not an auto-minted workbench", async () => { stubFetch((path, method) => { if (path === "/api/me/principals") { return json(PRINCIPALS_RESPONSE); } if (path.endsWith("/chat/workbenches") && method === "GET") { // listAllWorkbenches finds nothing — this bench has no workbenches - // yet, so HomeRoute mints one via the default setup template - // rather than calling ensureMyraWorkbench (which only ever finds - // or reuses an existing one). + // yet, so HomeRoute waits for Myra's readiness and redirects to + // the picker rather than minting anything itself. return json({ items: [] }); } - if (path.includes("/workflows/definitions")) { - return json({ - data: [ - { - id: "wfd_assistant", - tenantId: "tnt_1", - name: "assistant", - currentVersion: "1", - status: "deployed", - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - }, - ], - nextCursor: null, - }); - } - if (path.endsWith("/chat/workbenches") && method === "POST") { - return json({ - id: "chan_new", - title: "New Workbench", - kind: "chat", - pinned: false, - participants: [], - }); + if (path === "/api/onboarding/provisioning-status") { + return json({ kind: "ready", setupAgentReady: true }); } throw new Error(`unexpected fetch: ${method} ${path}`); }); @@ -217,7 +194,7 @@ describe("HomeRoute (the `/` land hop every entry point funnels through)", () => if (navigated.length > 0) break; } - expect(navigated).toEqual(["/w/chan_new"]); + expect(navigated).toEqual(["/new"]); }); }); @@ -227,55 +204,28 @@ describe("HomeRoute (the `/` land hop every entry point funnels through)", () => // that happens the moment Myra herself can answer, and an honest way out // if she never does. describe("the wait right after connecting a provider", () => { - /** A bench with no workbenches yet whose agent definitions arrive only - * after `readyAfter` reads — everything before that is the window the - * person spends waiting. */ - function benchWhereMyraArrivesAfter(readyAfter: number, provisioning = true) { - let definitionReads = 0; + /** A bench with no workbenches yet whose setup agent (Myra) reports + * ready only after `readyAfter` reads — everything before that is the + * window the person spends waiting. */ + function benchWhereMyraArrivesAfter(readyAfter: number) { + let statusReads = 0; const state = { statusCalls: 0 }; stubFetch((path, method) => { if (path === "/api/me/principals") return json(PRINCIPALS_RESPONSE); if (path.endsWith("/chat/workbenches") && method === "GET") { return json({ items: [] }); } - if (path.includes("/workflows/definitions")) { - definitionReads += 1; - return json({ - data: - definitionReads > readyAfter - ? [ - { - id: "wfd_assistant", - tenantId: "tnt_1", - name: "assistant", - currentVersion: "1", - status: "deployed", - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - }, - ] - : [], - nextCursor: null, - }); - } if (path === "/api/onboarding/provisioning-status") { + statusReads += 1; state.statusCalls += 1; + const setupAgentReady = statusReads > readyAfter; return json({ - kind: provisioning ? "provisioning" : "ready", - setupAgentReady: !provisioning, + kind: "provisioning", + setupAgentReady, deployed: [], pending: ["assistant"], }); } - if (path.endsWith("/chat/workbenches") && method === "POST") { - return json({ - id: "chan_new", - title: "New Workbench", - kind: "chat", - pinned: false, - participants: [], - }); - } throw new Error(`unexpected fetch: ${method} ${path}`); }); return state; @@ -333,7 +283,7 @@ describe("the wait right after connecting a provider", () => { if (navigated.length > 0) break; } - expect(navigated).toEqual(["/w/chan_new"]); + expect(navigated).toEqual(["/new"]); expect(state.statusCalls).toBeGreaterThan(0); }); @@ -375,7 +325,7 @@ describe("the wait right after connecting a provider", () => { if (navigated.length > 0) break; } - expect(navigated).toEqual(["/w/chan_new"]); + expect(navigated).toEqual(["/new"]); }); }); From 7e30d99f24e78e12169e3383b45491e2f5b50ca6 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 21 Aug 2026 15:27:18 -0700 Subject: [PATCH 2/2] HomeRoute: guide a fresh account to the workbench picker instead of auto-minting an empty one Landing a brand-new account straight into a silently auto-minted, unlabeled "New Workbench" (CL-6138) is exactly the confusing empty-bench experience reported: nothing marks what the room is for, and it skips the guided create flow every other "+ New workbench" control already uses. The zero-workbench branch now waits for Myra's own definition to exist (unchanged wait/slow/retry UX) and then sends the person to NewWorkbenchPickerRoute (`/new`) to create their first real workbench there, template or blank. Drops the now-dead createAgentAndLaunch, the one-off auto-mint verb this replaces, along with its tests. --- apps/web/src/instant-agent-create.test.ts | 86 ----------------------- apps/web/src/instant-agent-create.ts | 59 +++++----------- apps/web/src/pages/home-page.tsx | 57 ++++++++++----- 3 files changed, 55 insertions(+), 147 deletions(-) diff --git a/apps/web/src/instant-agent-create.test.ts b/apps/web/src/instant-agent-create.test.ts index da7e10e7..d6c26357 100644 --- a/apps/web/src/instant-agent-create.test.ts +++ b/apps/web/src/instant-agent-create.test.ts @@ -1,96 +1,10 @@ import { afterEach, describe, expect, test } from "bun:test"; import { - createAgentAndLaunch, createWorkbenchFromTemplate, NEW_WORKBENCH_TITLE, } from "./instant-agent-create"; -describe("createAgentAndLaunch", () => { - const realFetch = globalThis.fetch; - - afterEach(() => { - globalThis.fetch = realFetch; - }); - - type RecordedCall = { readonly path: string; readonly init?: RequestInit }; - - function stubFetch(respond: (path: string) => Response): RecordedCall[] { - const calls: RecordedCall[] = []; - globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { - const path = - typeof input === "string" ? input : new URL(String(input)).pathname; - calls.push(init === undefined ? { path } : { path, init }); - return Promise.resolve(respond(path)); - }) as typeof fetch; - return calls; - } - - const json = (body: unknown, status = 200) => - new Response(JSON.stringify(body), { - status, - headers: { "content-type": "application/json" }, - }); - - const assistantDefinitionWire = { - id: "def-assistant", - tenantId: "tnt_1", - name: "assistant", - currentVersion: "1", - status: "deployed", - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - skills: [] as readonly string[], - }; - - test("launches a New Workbench chat against the default setup template, no definition drafted", async () => { - const navigated: string[] = []; - const calls = stubFetch((path) => { - if (path.includes("/workflows/definitions")) { - return json({ data: [assistantDefinitionWire], nextCursor: null }); - } - if (path.endsWith("/chat/workbenches")) { - return json({ - id: "chan-1", - title: NEW_WORKBENCH_TITLE, - kind: "chat", - pinned: false, - participants: [], - }); - } - throw new Error(`unexpected fetch: ${path}`); - }); - - await createAgentAndLaunch("tnt_1", (to) => navigated.push(to)); - - const createCall = calls.find((call) => - call.path.endsWith("/chat/workbenches"), - ); - expect(JSON.parse(String(createCall?.init?.body))).toEqual({ - kind: "chat", - definitionId: "def-assistant", - name: NEW_WORKBENCH_TITLE, - }); - expect(calls.some((call) => call.path.includes("/agent-definitions"))).toBe( - false, - ); - expect(navigated).toEqual(["/w/chan-1"]); - }); - - test("throws when the tenant has no deployed setup template", async () => { - stubFetch((path) => { - if (path.includes("/workflows/definitions")) { - return json({ data: [], nextCursor: null }); - } - throw new Error(`unexpected fetch: ${path}`); - }); - - await expect(createAgentAndLaunch("tnt_1", () => {})).rejects.toThrow( - "No default setup agent found for this workbench.", - ); - }); -}); - describe("createWorkbenchFromTemplate (CL-6387)", () => { const realFetch = globalThis.fetch; diff --git a/apps/web/src/instant-agent-create.ts b/apps/web/src/instant-agent-create.ts index 3160ca20..4f7e0b41 100644 --- a/apps/web/src/instant-agent-create.ts +++ b/apps/web/src/instant-agent-create.ts @@ -1,21 +1,17 @@ -// THE one creation verb (CL-6089, retargeted CL-6138) for the bench's -// zero-workbench land-hop (`home-page.tsx`): it mints a fresh workbench -// titled "New Workbench" against the account's default setup template (the -// same seeded `assistant` definition backing the home Myra workbench, -// which already opens with the setup greeting: "what do you want me -// around for?"). The conversation itself is what specializes the agent -// into whatever the person wants; the drafting and capability machinery -// already listens for that in-chat, so no definition is drafted or -// created up front here. Explicitly defining a brand-new agent template, -// with its own name/purpose/model/skills chosen up front, stays -// `CreateAgentPanel`'s job (Settings → Agents), unchanged. -// -// Every other "create a workbench" affordance — the sidebar's "+", the -// command palette's "New workbench" — opens the template picker -// (`pages/new-workbench-picker.tsx`) instead (CL-6342, superseding -// CL-6138's direct mint for those entry points): `createWorkbenchFromTemplate` -// below is what the picker's "Create workbench" button calls once a row is -// chosen. +// Every "create a workbench" affordance — the sidebar's "+", the command +// palette's "New workbench", and the zero-workbench land-hop on `/` +// (CL-6486, superseding CL-6138's silent auto-mint) — opens the template +// picker (`pages/new-workbench-picker.tsx`, CL-6342) and calls +// `createWorkbenchFromTemplate` below once a row is chosen. It mints a +// fresh workbench against the account's default setup template (the same +// seeded `assistant` definition backing the home Myra workbench, which +// already opens with the setup greeting: "what do you want me around +// for?"). The conversation itself is what specializes the agent into +// whatever the person wants; the drafting and capability machinery already +// listens for that in-chat, so no definition is drafted or created up +// front here. Explicitly defining a brand-new agent template, with its own +// name/purpose/model/skills chosen up front, stays `CreateAgentPanel`'s job +// (Settings → Agents), unchanged. import { getLogger } from "@corbits/client-log"; import { @@ -36,7 +32,6 @@ import { fetchWorkbenchTemplateManifest, } from "./workbench-templates-api"; -import { launchAgentChat } from "./agent-chat-launch"; import { createAgentDefinition, listAgentDefinitions } from "./agents-api"; import { findMyraDefinition } from "./myra-workbench"; import { workbenchPath } from "./workbench-path"; @@ -71,31 +66,11 @@ export type PickGithubRepos = (args: { readonly selectedRepoIds: readonly string[]; }) => Promise; -/** - * Finds the account's default setup template (the seeded `assistant` - * definition) and launches a brand-new "New Workbench" chat against it. - * Throws if the tenant has no deployed setup template — a bench without - * one predates seeding and needs an operator, not a client-side retry. - */ -export async function createAgentAndLaunch( - tenantId: string, - navigate: (to: string) => void, -): Promise { - const definitions = await listAgentDefinitions(tenantId); - const template = findMyraDefinition(definitions); - if (template === undefined) { - throw new WorkbenchPreconditionError( - "No default setup agent found for this workbench.", - ); - } - await launchAgentChat(tenantId, template.id, navigate, NEW_WORKBENCH_TITLE); -} - /** * The template picker's "Create workbench" action (CL-6344): mints a - * fresh "New Workbench" chat against the same default setup template - * `createAgentAndLaunch` uses, passing the picked row's id through as - * `templateId` so the room opens with that template's own intro + * fresh "New Workbench" chat against the account's default setup template + * (the seeded `assistant`/Myra definition), passing the picked row's id + * through as `templateId` so the room opens with that template's own intro * (`packages/chat/src/routes.ts`'s `POST /workbenches` resolves it into * the canned greeting). When the id names a real manifest * (`workbenchTemplate`), this also creates its participant agent diff --git a/apps/web/src/pages/home-page.tsx b/apps/web/src/pages/home-page.tsx index e1f6e015..7f93b630 100644 --- a/apps/web/src/pages/home-page.tsx +++ b/apps/web/src/pages/home-page.tsx @@ -1,23 +1,26 @@ -// Default land: a brand-new bench with zero workbenches auto-mints its -// first Myra workbench and lands straight in it (CL-6138, superseding the -// CL-6104 describe-screen step) — the same one-creation-verb mint -// `instant-agent-create.ts` gives every other "+ New workbench" control, so -// a fresh bench's very first workbench comes from the exact same path as -// every one after it. A bench that already has one or more lands in (or -// creates) the Myra workbench in the main stage, unchanged. Home as a -// dashboard does not earn its keep — `/` only exists as this hop onto -// `/w/:workbenchId`. Deep links to other pages are unchanged. +// Default land: a bench that already has one or more workbenches lands in +// (or creates) the Myra workbench in the main stage. A brand-new bench with +// zero workbenches has nothing to land in yet, so this hop sends it to the +// guided create surface (`NewWorkbenchPickerRoute`, CL-6342) instead of +// auto-minting an unlabeled "New Workbench" and dropping the person straight +// into it — that auto-mint (CL-6138) is exactly the confusing empty-bench +// landing this hop used to produce. Home as a dashboard does not earn its +// keep — `/` only exists as this hop onto `/w/:workbenchId` or `/new`. Deep +// links to other pages are unchanged. // // Right after a provider connect this hop is also the wait (CL-6457's // deploys run in the background, so landing here can beat them). CL-6462 -// settled what that wait looks like: one warm loader and nothing else. -// The land is simply attempted again every few seconds, because launching -// Myra IS the test of whether the person can start — she is deployed -// first (`SETUP_AGENT_ASSET_NAME` leads `DEFAULT_WORKFLOWS`), so the -// moment she answers we go, with every other seeded workflow still -// converging behind us. Readiness is read only to tell a wait from a -// genuine failure, never to draw a progress number: a seed count is an -// implementation detail, and "0 of 5" told a waiting person nothing. +// settled what that wait looks like: one warm loader and nothing else. For +// a zero-workbench bench the wait is for Myra's own definition to exist at +// all — the picker's "Create workbench" needs it too, so checking here +// first means the picker never opens onto a create button that would just +// throw. The check is simply retried every few seconds, because Myra's +// readiness IS the test of whether the person can start — she is deployed +// first (`SETUP_AGENT_ASSET_NAME` leads `DEFAULT_WORKFLOWS`), so the moment +// she's ready we go, with every other seeded workflow still converging +// behind us. Readiness is read only to tell a wait from a genuine failure, +// never to draw a progress number: a seed count is an implementation +// detail, and "0 of 5" told a waiting person nothing. import { Button, EmptyState, PageShell } from "@corbits/react-ui"; import { Clock, WarningCircle } from "@corbits/icons"; @@ -29,9 +32,9 @@ import { describeApiError } from "@corbits/api-query"; import { fetchAgentReadiness } from "../onboarding"; import { useBench } from "../bench-context"; import { workbenchPath } from "../workbench-path"; -import { createAgentAndLaunch } from "../instant-agent-create"; import { ensureMyraWorkbench } from "../myra-workbench"; import { useNavigate } from "../navigation"; +import { NEW_WORKBENCH_PATH } from "../routes"; type LandState = /** Working on it: the warm loader, whether we are reading the bench's @@ -98,11 +101,27 @@ export function HomeRoute({ }); }; + // Zero workbenches: wait for Myra's own definition to exist, then send + // the person to the picker rather than minting anything ourselves — + // "she can't start yet" and "here, go create your first workbench" + // are different messages, and only the readiness check tells them + // apart. + const awaitFirstWorkbench = () => { + void fetchAgentReadiness().then((readiness) => { + if (cancelled) return; + if (readiness.kind === "ready" || readiness.kind === "chat-ready") { + navigate(NEW_WORKBENCH_PATH); + return; + } + waitAndRetry(); + }); + }; + void listAllWorkbenches(selectedTenantId).then( (workbenches) => { if (cancelled) return; if (workbenches.length === 0) { - createAgentAndLaunch(selectedTenantId, navigate).catch(classify); + awaitFirstWorkbench(); return; } void ensureMyraWorkbench(selectedTenantId).then((result) => {