From 60d18b87494f9e6e844492f789167278588132a3 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 23 Aug 2026 23:53:59 -0700 Subject: [PATCH 1/3] CL-6980: Drop global Myra home; `/` lands on /w Delete ensureMyraWorkbench/title-match/default-agent-workbench ensure. Talk-to-Myra uses generic open; home is no longer a special Myra slot. --- apps/web/src/agent-chat-launch.ts | 3 +- apps/web/src/command-palette-actions.test.ts | 74 ++++- apps/web/src/command-palette-actions.ts | 13 +- apps/web/src/instant-agent-create.ts | 6 +- apps/web/src/myra-workbench.test.ts | 188 +---------- apps/web/src/myra-workbench.ts | 72 +--- apps/web/src/pages/home-page.tsx | 88 ++--- apps/web/src/shell/routine-panel.tsx | 36 +- apps/web/test/home-page.test.tsx | 196 ++++------- apps/web/test/routine-panel.test.tsx | 14 +- packages/chat-ui/README.md | 4 +- packages/chat-ui/src/api.ts | 8 +- .../src/default-agent-workbench.test.ts | 312 ------------------ .../chat-ui/src/default-agent-workbench.ts | 165 --------- packages/chat-ui/src/direct-workbench.ts | 6 +- packages/chat-ui/src/index.ts | 12 - packages/chat/src/routes.ts | 16 +- packages/chat/test/routes.test.ts | 2 +- scripts/e2e/chat.test.ts | 2 +- 19 files changed, 217 insertions(+), 1000 deletions(-) delete mode 100644 packages/chat-ui/src/default-agent-workbench.test.ts delete mode 100644 packages/chat-ui/src/default-agent-workbench.ts diff --git a/apps/web/src/agent-chat-launch.ts b/apps/web/src/agent-chat-launch.ts index cafb0ebef..9a356f944 100644 --- a/apps/web/src/agent-chat-launch.ts +++ b/apps/web/src/agent-chat-launch.ts @@ -4,8 +4,7 @@ // calls this on success so an explicitly-defined new agent never ends // nowhere, and `instant-agent-create.ts` — THE one creation verb // (CL-6138) — calls it against the account's default setup template. -// Always creates (CL-6089) — never the `reuseExisting` land-hop path, -// which is `default-agent-workbench.ts`'s own call, not this one. +// Always creates (CL-6089) — never `reuseExisting`. import { createWorkbench } from "@corbits/chat-ui"; diff --git a/apps/web/src/command-palette-actions.test.ts b/apps/web/src/command-palette-actions.test.ts index e37aaedc0..1666976ae 100644 --- a/apps/web/src/command-palette-actions.test.ts +++ b/apps/web/src/command-palette-actions.test.ts @@ -35,7 +35,7 @@ function context(overrides: { const ctx = { path: overrides.path, navigate: (to: string) => navigated.push(to), - tenantId: overrides.tenantId ?? "tenant-1", + tenantId: overrides.tenantId === undefined ? "tenant-1" : overrides.tenantId, cycleTheme: () => { themeCycled = true; }, @@ -152,9 +152,81 @@ describe("runActionCommand", () => { }); test("talk-to-myra does nothing without a selected bench", async () => { + let fetches = 0; + globalThis.fetch = (() => { + fetches += 1; + throw new Error("null tenant must not fall back to a default tenant"); + }) as typeof fetch; const { ctx, navigated } = context({ path: "/", tenantId: null }); + expect(ctx.tenantId).toBeNull(); await runActionCommand("talk-to-myra", ctx); expect(navigated).toEqual([]); + expect(fetches).toBe(0); + }); + + test("talk-to-myra opens the generic agent DM, not a land-hop", async () => { + const posts: Array<{ path: string; body: unknown }> = []; + globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { + const path = + typeof input === "string" ? input : new URL(String(input)).pathname; + if (path.includes("/workflows/definitions")) { + return Promise.resolve( + new Response( + JSON.stringify({ + data: [ + { + id: "def-assistant", + tenantId: "tenant-1", + name: "assistant", + currentVersion: "1", + status: "deployed", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + skills: [], + }, + ], + nextCursor: null, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ); + } + if (path.endsWith("/chat/workbenches") && init?.method === "POST") { + posts.push({ + path, + body: JSON.parse(String(init.body)), + }); + return Promise.resolve( + new Response( + JSON.stringify({ + id: "chan-dm-myra", + title: "Myra", + kind: "chat", + pinned: false, + participants: [], + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ); + } + throw new Error(`unexpected fetch: ${init?.method ?? "GET"} ${path}`); + }) as typeof fetch; + + const { ctx, navigated } = context({ path: "/" }); + await runActionCommand("talk-to-myra", ctx); + + expect(posts).toEqual([ + { + path: "/api/tenants/tenant-1/chat/workbenches", + body: { + kind: "chat", + definitionId: "def-assistant", + reuseExisting: true, + }, + }, + ]); + expect(navigated).toEqual(["/w/chan-dm-myra"]); + expect(navigated).not.toContain("/"); }); }); diff --git a/apps/web/src/command-palette-actions.ts b/apps/web/src/command-palette-actions.ts index cd7577c85..79e6214cb 100644 --- a/apps/web/src/command-palette-actions.ts +++ b/apps/web/src/command-palette-actions.ts @@ -28,9 +28,11 @@ // never hops to `/routines` first the way this used to. import { createPendingDialogRequest } from "@corbits/shell-layout"; -import { WORKBENCH_PATH_PREFIX, workbenchPath } from "./workbench-path"; +import { WORKBENCH_PATH_PREFIX } from "./workbench-path"; import { NEW_WORKBENCH_PATH } from "./routes"; -import { ensureMyraWorkbench } from "./myra-workbench"; +import { listAgentDefinitions } from "./agents-api"; +import { openAgentDmChat } from "./agent-dm-launch"; +import { findMyraDefinition } from "./myra-workbench"; import { requestLibraryUpload } from "./library-upload"; import type { RoutinePanelSubject } from "./shell/canvas-availability"; @@ -194,9 +196,10 @@ export async function runActionCommand( } case "talk-to-myra": { if (ctx.tenantId === null) return; - const result = await ensureMyraWorkbench(ctx.tenantId); - if (result.kind === "ready") - ctx.navigate(workbenchPath(result.workbenchId)); + const definitions = await listAgentDefinitions(ctx.tenantId); + const myra = findMyraDefinition(definitions); + if (myra === undefined) return; + await openAgentDmChat(ctx.tenantId, myra.id, ctx.navigate); return; } case "go-workbenches": { diff --git a/apps/web/src/instant-agent-create.ts b/apps/web/src/instant-agent-create.ts index 538880f8f..1d8938b3d 100644 --- a/apps/web/src/instant-agent-create.ts +++ b/apps/web/src/instant-agent-create.ts @@ -4,9 +4,9 @@ // 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 +// seeded `assistant` definition backing Myra, 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 diff --git a/apps/web/src/myra-workbench.test.ts b/apps/web/src/myra-workbench.test.ts index c42f4bdeb..ca2b167cd 100644 --- a/apps/web/src/myra-workbench.test.ts +++ b/apps/web/src/myra-workbench.test.ts @@ -1,69 +1,8 @@ -import { afterEach, describe, expect, test } from "bun:test"; +import { describe, expect, test } from "bun:test"; -import { - ensureMyraWorkbench, - findMyraWorkbench, - findMyraDefinition, - isMyraWorkbenchId, - isMyraWorkbenchTitle, - MYRA_WORKBENCH_TITLE, - resetMyraWorkbenchCache, -} from "./myra-workbench"; -import type { Workbench } from "@corbits/chat-ui"; +import { findMyraDefinition } from "./myra-workbench"; import type { AgentDefinition } from "./agents-api"; -function workbench(partial: { - readonly id: string; - readonly title: string; - readonly kind?: string; -}): Workbench { - return { - id: partial.id, - title: partial.title, - kind: partial.kind ?? "workbench", - pinned: false, - participants: [], - }; -} - -describe("myra-workbench helpers", () => { - afterEach(() => { - resetMyraWorkbenchCache(); - }); - - test("isMyraWorkbenchId is false until a workbench id is cached", () => { - expect(isMyraWorkbenchId("chan-1")).toBe(false); - expect(isMyraWorkbenchId(null)).toBe(false); - }); - - test("MYRA_WORKBENCH_TITLE is Myra", () => { - expect(MYRA_WORKBENCH_TITLE).toBe("Myra"); - }); - - test("isMyraWorkbenchTitle is case-insensitive and trims", () => { - expect(isMyraWorkbenchTitle("Myra")).toBe(true); - expect(isMyraWorkbenchTitle(" myra ")).toBe(true); - expect(isMyraWorkbenchTitle("MYRA")).toBe(true); - expect(isMyraWorkbenchTitle("Myra chat")).toBe(false); - expect(isMyraWorkbenchTitle("Assistant")).toBe(false); - }); - - test("findMyraWorkbench returns the first Myra-titled row", () => { - const items = [ - workbench({ id: "a", title: "general" }), - workbench({ id: "b", title: "myra" }), - workbench({ id: "c", title: "Myra" }), - ]; - expect(findMyraWorkbench(items)?.id).toBe("b"); - }); - - test("findMyraWorkbench returns undefined when none match", () => { - expect( - findMyraWorkbench([workbench({ id: "a", title: "general" })]), - ).toBeUndefined(); - }); -}); - function definition(partial: { readonly id: string; readonly name: string; @@ -94,126 +33,3 @@ describe("findMyraDefinition", () => { ).toBeUndefined(); }); }); - -describe("ensureMyraWorkbench", () => { - const realFetch = globalThis.fetch; - - afterEach(() => { - globalThis.fetch = realFetch; - resetMyraWorkbenchCache(); - }); - - 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" }, - }); - - test("creates a chat with Myra's definitionId when no Myra row exists", async () => { - const calls = stubFetch((path) => { - if (path.endsWith("/chat/workbenches?kind=workbench")) { - return json({ items: [] }); - } - if (path.endsWith("/chat/workbenches?kind=chat")) { - return json({ items: [] }); - } - if (path.includes("/workflows/definitions")) { - return json({ - data: [definition({ id: "def-assistant", name: "assistant" })], - nextCursor: null, - }); - } - if (path.endsWith("/chat/workbenches")) { - return json({ - id: "chat-1", - title: "Myra", - kind: "chat", - pinned: false, - participants: [], - }); - } - throw new Error(`unexpected fetch: ${path}`); - }); - - const result = await ensureMyraWorkbench("tnt_1"); - - expect(result).toEqual({ kind: "ready", workbenchId: "chat-1" }); - const createCall = calls.find((call) => - call.path.endsWith("/chat/workbenches"), - ); - expect(createCall?.init?.method).toBe("POST"); - expect(JSON.parse(String(createCall?.init?.body))).toEqual({ - kind: "chat", - definitionId: "def-assistant", - name: "Myra", - reuseExisting: true, - }); - expect(isMyraWorkbenchId("chat-1")).toBe(true); - }); - - test("converts a legacy workbench-kind Myra row carrying the agent into an auto-responding chat", async () => { - const legacyWire = { - id: "legacy-1", - title: "Myra", - kind: "workbench", - pinned: true, - participants: [{ address: "myra@wf_1.tnt_1", handle: "myra" }], - }; - const calls = stubFetch((path) => { - if (path.endsWith("/chat/workbenches?kind=workbench")) { - return json({ items: [legacyWire] }); - } - if (path.endsWith("/chat/workbenches?kind=chat")) { - return json({ items: [] }); - } - if (path.endsWith("/chat/workbenches/legacy-1/settings")) { - return json({ - ...legacyWire, - kind: "chat", - settings: { "chat/kind": "chat" }, - contextWindow: { value: 50, source: "inherit" }, - }); - } - throw new Error(`unexpected fetch: ${path}`); - }); - - const result = await ensureMyraWorkbench("tnt_1"); - - expect(result).toEqual({ kind: "ready", workbenchId: "legacy-1" }); - expect(isMyraWorkbenchId("legacy-1")).toBe(true); - const patchCall = calls.find((call) => call.init?.method === "PATCH"); - expect(JSON.parse(String(patchCall?.init?.body))).toEqual({ - "chat/kind": "chat", - }); - }); - - test("errors when no Myra definition is deployed for the tenant", async () => { - stubFetch((path) => { - if (path.endsWith("/chat/workbenches?kind=workbench")) - return json({ items: [] }); - if (path.endsWith("/chat/workbenches?kind=chat")) - return json({ items: [] }); - if (path.includes("/workflows/definitions")) { - return json({ data: [], nextCursor: null }); - } - throw new Error(`unexpected fetch: ${path}`); - }); - - const result = await ensureMyraWorkbench("tnt_1"); - - expect(result.kind).toBe("error"); - }); -}); diff --git a/apps/web/src/myra-workbench.ts b/apps/web/src/myra-workbench.ts index cdd17f711..ca64f07c4 100644 --- a/apps/web/src/myra-workbench.ts +++ b/apps/web/src/myra-workbench.ts @@ -1,74 +1,20 @@ -// Default Myra chat: the product land surface. Composition only — the -// find-or-create logic itself is `@corbits/chat-ui`'s generic -// `createDefaultAgentWorkbench`; this file's job is to name Myra as the -// configured agent and wire it to this app's agent-definitions fetch. +// Myra's deployed agent definition, matched by the seeded `assistant` +// asset name — never by display name, which is a UI label, not a wire +// identifier, and never by a workbench title. Opening a chat with her +// is the generic `openAgentDmChat` / POST `{kind:chat, definitionId, +// reuseExisting}` path, the same as any other agent row. -import { - createDefaultAgentWorkbench, - findWorkbenchByTitle, - findDefinitionByAssetName, - isWorkbenchTitleMatch, - type Workbench, -} from "@corbits/chat-ui"; import { WORKFLOW_CATALOG } from "@corbits/workflow-catalog"; -import { listAgentDefinitions, type AgentDefinition } from "./agents-api"; +import type { AgentDefinition } from "./agents-api"; -export const MYRA_WORKBENCH_TITLE = "Myra"; - -/** The seeded workflow asset backing Myra (`packages/hub-client/src/seed.ts` - * deploys it as `assistant`, stamped with catalog displayName "Myra"). A - * chat's `definitionId` names this deployed definition's row id, never the - * asset name itself. */ const MYRA_ASSET_NAME = WORKFLOW_CATALOG.find( - (entry) => entry.displayName === MYRA_WORKBENCH_TITLE, + (entry) => entry.displayName === "Myra", )?.assetName; -export type { EnsureDefaultAgentWorkbenchResult as EnsureMyraWorkbenchResult } from "@corbits/chat-ui"; - -const myraWorkbench = createDefaultAgentWorkbench({ - title: MYRA_WORKBENCH_TITLE, - assetName: MYRA_ASSET_NAME, -}); - -export function isMyraWorkbenchTitle(title: string): boolean { - return isWorkbenchTitleMatch(title, MYRA_WORKBENCH_TITLE); -} - -/** The last workbench id `ensureMyraWorkbench` resolved to, for the shell's - * col2-wide derivation (CL-5936): "Myra is the active surface" reduces to - * "the open workbench is the one Talk-to-Myra last landed us on". */ -export function isMyraWorkbenchId(workbenchId: string | null): boolean { - return myraWorkbench.isCachedWorkbenchId(workbenchId); -} - -/** Test helper — drop the cached id between cases. */ -export function resetMyraWorkbenchCache(): void { - myraWorkbench.resetCache(); -} - -/** Prefer an exact Myra title; first match wins across the given list. */ -export function findMyraWorkbench( - workbenches: readonly Workbench[], -): Workbench | undefined { - return findWorkbenchByTitle(workbenches, MYRA_WORKBENCH_TITLE); -} - -/** Myra's deployed agent definition, matched by the seeded `assistant` - * asset name — never by display name, which is a UI label, not a wire - * identifier. */ export function findMyraDefinition( definitions: readonly AgentDefinition[], ): AgentDefinition | undefined { - return findDefinitionByAssetName(definitions, MYRA_ASSET_NAME); -} - -/** - * List workbench + chat kinds, reuse a Myra-titled row if one exists — a - * legacy workbench-kind Myra from a bench seeded before CL-5985 included, so - * no bench ever ends up with two — otherwise create a 1:1 chat against - * Myra's deployed agent definition. - */ -export function ensureMyraWorkbench(tenantId: string) { - return myraWorkbench.ensure(tenantId, listAgentDefinitions); + if (MYRA_ASSET_NAME === undefined) return undefined; + return definitions.find((definition) => definition.name === MYRA_ASSET_NAME); } diff --git a/apps/web/src/pages/home-page.tsx b/apps/web/src/pages/home-page.tsx index c3782e4cc..a694e1c9e 100644 --- a/apps/web/src/pages/home-page.tsx +++ b/apps/web/src/pages/home-page.tsx @@ -1,20 +1,16 @@ -// 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. +// Default land: `/` hops onto the conversation rail (`/w`). Myra is an +// agent row, opened the same way as any other agent — never find-or-created +// as the home slot. A brand-new bench with zero conversations has nothing +// to land in yet, so this hop waits for the setup agent (and a provider) +// before opening the empty rail, rather than auto-minting a Myra DM. +// Home as a dashboard does not earn its keep — `/` only exists as this hop +// onto `/w`. 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. 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 +// a zero-conversation bench the wait is for Myra's own definition to exist +// at all. 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 @@ -22,10 +18,9 @@ // never to draw a progress number: a seed count is an implementation // detail, and "0 of 5" told a waiting person nothing. // -// CL-6780: that wait is for the agent, never a workbench that does not -// exist yet — so the loader says "Preparing your agent", and a skip with -// no credential stops pretending anything is "getting ready" and offers -// the honest next step (connect a provider) instead of spinning forever. +// CL-6780: a skip with no credential stops pretending anything is "getting +// ready" and offers the honest next step (connect a provider) instead of +// spinning forever. import { Button, EmptyState, PageShell } from "@corbits/react-ui"; import { Clock, WarningCircle } from "@corbits/icons"; @@ -36,24 +31,22 @@ import { describeApiError } from "@corbits/api-query"; import { fetchAgentReadiness, hasActiveCredential } from "../onboarding"; import { useBench } from "../bench-context"; -import { workbenchPath } from "../workbench-path"; -import { ensureMyraWorkbench } from "../myra-workbench"; +import { WORKBENCH_PATH_PREFIX } from "../workbench-path"; import { useNavigate } from "../navigation"; -import { NEW_WORKBENCH_PATH, ONBOARDING_PATH } from "../routes"; +import { ONBOARDING_PATH } from "../routes"; type LandState = /** Working on it: the warm loader, whether we are reading the bench's - * workbenches or waiting for Myra to finish coming online. Both are + * conversations or waiting for Myra to finish coming online. Both are * the same thing to the person waiting. */ | { readonly kind: "opening" } - /** Zero workbenches, credential present, Myra not ready yet — the - * post-connect wait. Headline names the agent, never a workbench that - * does not exist (CL-6780). */ + /** Zero conversations, credential present, Myra not ready yet — the + * post-connect wait. */ | { readonly kind: "waiting-for-agent" } /** Myra has taken long enough that silence would read as a hang. Says * so plainly and offers another go — never a frozen number. */ | { readonly kind: "slow" } - /** Zero workbenches and no active credential: the drain never starts, + /** Zero conversations and no active credential: the drain never starts, * so waiting on "ready" would spin forever. Offer the connect step. */ | { readonly kind: "needs-provider" } | { readonly kind: "error"; readonly message: string }; @@ -61,10 +54,6 @@ type LandState = const LAND_RETRY_MS = 3_000; const LAND_STALL_MS = 45_000; -/** Warm-loader headline for the post-onboarding wait on `/` when no - * workbench exists yet — Myra is coming online, not a workbench. */ -const PREPARING_AGENT_TITLE = "Preparing your agent"; - export function HomeRoute({ retryMs = LAND_RETRY_MS, stallAfterMs = LAND_STALL_MS, @@ -101,34 +90,16 @@ export function HomeRoute({ retryTimer = setTimeout(() => setAttempt((count) => count + 1), retryMs); }; - // A land that failed is either "she isn't up yet" or a real problem, - // and only the bench itself can say which. - const classify = (cause: unknown) => { - void fetchAgentReadiness().then((readiness) => { - if (cancelled) return; - if (readiness.kind === "ready" || readiness.kind === "chat-ready") { - setState({ - kind: "error", - message: describeApiError(cause, "opening Myra"), - }); - return; - } - waitAndRetry(); - }); - }; - - // 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. Without a credential the drain never starts (CL-6780), so a + // Zero conversations: wait for Myra's own definition to exist, then + // send the person to the rail rather than minting anything ourselves. + // Without a credential the drain never starts (CL-6780), so a // not-ready status with no credential is an honest next step, not a // forever spin on "getting ready". const awaitFirstWorkbench = () => { void fetchAgentReadiness().then((readiness) => { if (cancelled) return; if (readiness.kind === "ready" || readiness.kind === "chat-ready") { - navigate(NEW_WORKBENCH_PATH); + navigate(WORKBENCH_PATH_PREFIX); return; } void hasActiveCredential(selectedTenantId).then((hasCredential) => { @@ -152,20 +123,13 @@ export function HomeRoute({ awaitFirstWorkbench(); return; } - void ensureMyraWorkbench(selectedTenantId).then((result) => { - if (cancelled) return; - if (result.kind === "ready") { - navigate(workbenchPath(result.workbenchId)); - return; - } - classify(new Error(result.message)); - }); + navigate(WORKBENCH_PATH_PREFIX); }, (cause: unknown) => { if (cancelled) return; setState({ kind: "error", - message: describeApiError(cause, "opening Myra"), + message: describeApiError(cause, "opening your conversations"), }); }, ); @@ -218,7 +182,7 @@ export function HomeRoute({ } - title="Couldn't open Myra" + title="Couldn't open your conversations" description={state.message} action={