From 4ebc58f6d366cde0d352a796afa144e35c9b6212 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 24 Aug 2026 08:53:55 -0700 Subject: [PATCH 01/11] Always reopen the agent DM for kind=chat creates POST /workbenches with kind=chat and a definitionId now always find-or-reopens that agent's one conversation. reuseExisting is accepted and ignored; it is no longer a Myra-only opt-in. --- packages/chat/src/routes.ts | 34 +++++++++++++------------------ packages/chat/test/routes.test.ts | 34 ++++++++++++++----------------- 2 files changed, 29 insertions(+), 39 deletions(-) diff --git a/packages/chat/src/routes.ts b/packages/chat/src/routes.ts index 559cb2007..d95a3db10 100644 --- a/packages/chat/src/routes.ts +++ b/packages/chat/src/routes.ts @@ -322,6 +322,12 @@ const CreateWorkbenchBody = type({ "participants?": "string[]", "definitionId?": "string", "principalId?": "string", + /** + * Accepted and ignored. `kind: "chat"` + `definitionId` always + * find-or-reopens via `findExistingAgentChat` (CL-6981); this flag is + * no longer an opt-in. Callers that still send it (Myra land-hop, + * `openAgentDm`) are not 400'd. + */ "reuseExisting?": "boolean", /** * The picked template's own promise line @@ -879,18 +885,10 @@ const MoveWorkbenchBody = type({ }); /** - * Finds an existing chat with the given agent, for the one caller that - * deliberately wants find-or-create semantics: the home-workbench - * land-hop (`ensureMyraWorkbench`, via `default-agent-workbench.ts`), which - * passes `reuseExisting: true` so returning to "Myra" always reopens the - * same conversation rather than minting a fresh one on every visit. - * - * Every other caller — "+ New Workbench" picking an agent as a - * template, or a freshly drafted agent's own launch — always creates - * (CL-6089): the same agent picked twice from the picker is two - * independent workbenches, each with its own workbench tenant and its own - * launched agent instance, not the same conversation reopened. `POST - * /workbenches` only calls this lookup when `reuseExisting` is set. + * Finds an existing chat with the given agent. `POST /workbenches` with + * `kind: "chat"` + `definitionId` always find-or-reopens this way + * (CL-6981): a DM is the one 1:1 tenant with that agent. Uniqueness is + * per (bench, definitionId). Product reopens; it does not clone. * * Matches forward, by the `chat/definitionId` every agent chat has * carried in its settings since this landed, and falls back to @@ -1060,17 +1058,13 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { const tenant = c.get("tenant"); const principal = c.get("principal"); - // "+ New Workbench" always creates (CL-6089): picking an agent in - // the picker uses it as a template, minting a fresh workbench - // every time, not reopening a prior conversation. The one - // exception is the deliberate land-hop to the account's home - // workbench (`ensureMyraWorkbench`), which opts in with - // `reuseExisting: true` so landing on "Myra" always finds the - // same conversation instead of forking a new one on every visit. + // kind: chat + definitionId always find-or-reopens (CL-6981): a DM + // is the one 1:1 tenant with that agent. `reuseExisting` is accepted + // and ignored — omitted and `false` reopen the same as `true`. // Checked before anything is minted, and before the (cheaper, // in-memory) principal-self-chat validation below, since a found // match short-circuits the whole handler. - if (isChatWithDefinition(body) && body.reuseExisting === true) { + if (isChatWithDefinition(body)) { const existing = await findExistingAgentChat( deps, tenant.id, diff --git a/packages/chat/test/routes.test.ts b/packages/chat/test/routes.test.ts index 61a481037..55358d0dc 100644 --- a/packages/chat/test/routes.test.ts +++ b/packages/chat/test/routes.test.ts @@ -573,7 +573,7 @@ describe("POST /workbenches", () => { }); }); -describe("POST /workbenches — reuseExisting: true reopens the land-hop's chat, not create (CL-6089)", () => { +describe("POST /workbenches — kind: chat + definitionId always find-or-reopens (CL-6981)", () => { test("creating a chat with the same agent twice, reuseExisting: true both times, reuses the first chat instead of forking a duplicate", async () => { const deps = buildDeps({ platform: fakePlatform({ invitable: [{ id: "wfd_echo", name: "Echo" }] }), @@ -782,8 +782,8 @@ describe("POST /workbenches — reuseExisting: true reopens the land-hop's chat, }); }); -describe("POST /workbenches — agent chat always creates by default (CL-6089)", () => { - test("creating a chat with the same agent twice, reuseExisting omitted both times, mints two independent workbenches", async () => { +describe("POST /workbenches — reuseExisting no longer opts out of find-or-reopen (CL-6981)", () => { + test("creating a chat with the same agent twice, reuseExisting omitted both times, reopens the first chat", async () => { const deps = buildDeps({ platform: fakePlatform({ invitable: [{ id: "wfd_echo", name: "Echo" }] }), }); @@ -800,28 +800,24 @@ describe("POST /workbenches — agent chat always creates by default (CL-6089)", definitionId: "wfd_echo", }); - expect(second.response.status).toBe(201); - expect(second.body.id).not.toBe(first.body.id); + expect(second.response.status).toBe(200); + expect(second.body.id).toBe(first.body.id); expect(second.body.kind).toBe("chat"); const platform = deps.platform as ReturnType; - expect(platform.launchInviteCalls).toHaveLength(2); + expect(platform.launchInviteCalls).toHaveLength(1); const chats = await deps.store.listWorkbenchSettings(TENANT.id, "chat"); - expect(chats).toHaveLength(2); + expect(chats).toHaveLength(1); - // CL-6387: "mints two independent workbenches" must mean two genuinely - // distinct child tenants — not two workbench rows sharing one tenant - // (which would alias participants/grants across both chats). const firstTenancy = await deps.tenancy.getWorkbenchTenancy(first.body.id); const secondTenancy = await deps.tenancy.getWorkbenchTenancy( second.body.id, ); expect(firstTenancy?.tenantId).toBeDefined(); - expect(secondTenancy?.tenantId).toBeDefined(); - expect(secondTenancy?.tenantId).not.toBe(firstTenancy?.tenantId); + expect(secondTenancy?.tenantId).toBe(firstTenancy?.tenantId); }); - test("creating a chat with the same agent twice, reuseExisting: false explicitly, still mints two workbenches", async () => { + test("creating a chat with the same agent twice, reuseExisting: false explicitly, still reopens the first chat", async () => { const deps = buildDeps({ platform: fakePlatform({ invitable: [{ id: "wfd_echo", name: "Echo" }] }), }); @@ -839,11 +835,11 @@ describe("POST /workbenches — agent chat always creates by default (CL-6089)", }); expect(first.response.status).toBe(201); - expect(second.response.status).toBe(201); - expect(second.body.id).not.toBe(first.body.id); + expect(second.response.status).toBe(200); + expect(second.body.id).toBe(first.body.id); }); - test("a pre-existing chat for the same agent (from an earlier find-or-create call) doesn't stop a later always-create call from minting its own", async () => { + test("a pre-existing chat for the same agent is reopened even when reuseExisting is omitted", async () => { const deps = buildDeps({ platform: fakePlatform({ invitable: [{ id: "wfd_echo", name: "Echo" }] }), }); @@ -861,10 +857,10 @@ describe("POST /workbenches — agent chat always creates by default (CL-6089)", definitionId: "wfd_echo", }); - expect(picked.response.status).toBe(201); - expect(picked.body.id).not.toBe(landHop.body.id); + expect(picked.response.status).toBe(200); + expect(picked.body.id).toBe(landHop.body.id); const chats = await deps.store.listWorkbenchSettings(TENANT.id, "chat"); - expect(chats).toHaveLength(2); + expect(chats).toHaveLength(1); }); }); From beeaafaaf35abbf7f7e74919813c9a21d552a6bd Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 24 Aug 2026 10:19:06 -0700 Subject: [PATCH 02/11] Add tests for blank channel create without a host --- apps/web/src/command-palette-actions.test.ts | 4 +- apps/web/src/instant-agent-create.test.ts | 51 +++++++++++++++++-- apps/web/test/new-workbench-picker.test.tsx | 22 ++++---- .../workflow-catalog/test/instantiate.test.ts | 9 ++-- 4 files changed, 66 insertions(+), 20 deletions(-) diff --git a/apps/web/src/command-palette-actions.test.ts b/apps/web/src/command-palette-actions.test.ts index e37aaedc0..ffffe125a 100644 --- a/apps/web/src/command-palette-actions.test.ts +++ b/apps/web/src/command-palette-actions.test.ts @@ -73,8 +73,8 @@ describe("ACTION_COMMANDS", () => { test("new-workbench and new-agent speak consumer language, not mint", () => { const workbench = ACTION_COMMANDS.find((c) => c.id === "new-workbench"); const agent = ACTION_COMMANDS.find((c) => c.id === "new-agent"); - expect(workbench?.subtitle).toBe("Start a new workbench with Myra"); - expect(agent?.subtitle).toBe("Start a new workbench with Myra"); + expect(workbench?.subtitle).toBe("Start a new workbench"); + expect(agent?.subtitle).toBe("Start a new workbench"); expect(workbench?.subtitle.toLowerCase()).not.toContain("mint"); expect(agent?.subtitle.toLowerCase()).not.toContain("mint"); }); diff --git a/apps/web/src/instant-agent-create.test.ts b/apps/web/src/instant-agent-create.test.ts index a6616da04..bcdc39d75 100644 --- a/apps/web/src/instant-agent-create.test.ts +++ b/apps/web/src/instant-agent-create.test.ts @@ -69,7 +69,7 @@ describe("createWorkbenchFromTemplate (CL-6387)", () => { return json({ id: `chan-${nextId}`, title: NEW_WORKBENCH_TITLE, - kind: "chat", + kind: "workbench", pinned: false, participants: [], }); @@ -104,7 +104,48 @@ describe("createWorkbenchFromTemplate (CL-6387)", () => { expect(body.name).toBe(NEW_WORKBENCH_TITLE); }); - // CL-6387 follow-up: picking a named template threw its own name away + // CL-6982: the + / picker create path mints a `kind: "workbench"` + // channel, never a Myra DM clone (`kind: "chat"` + `definitionId`). + test("blank create POSTs kind=workbench with no definitionId and invites nobody (CL-6982)", async () => { + 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: "workbench", + pinned: true, + participants: [], + }); + } + throw new Error(`unexpected fetch: ${path}`); + }); + + await createWorkbenchFromTemplate( + "tnt_1", + "blank", + () => undefined, + newQueryClient(), + ); + + const createCall = calls.find((call) => + call.path.endsWith("/chat/workbenches"), + ); + const body = JSON.parse(String(createCall?.init?.body)) as { + readonly kind?: string; + readonly definitionId?: string; + readonly reuseExisting?: boolean; + readonly name?: string; + }; + expect(body.kind).toBe("workbench"); + expect(body.definitionId).toBeUndefined(); + expect(body.reuseExisting).toBeUndefined(); + expect(body.name).toBe(NEW_WORKBENCH_TITLE); + expect(calls.some((call) => call.path.includes("/invite"))).toBe(false); + }); + // and left the reviewer roster its greeting promises out of the room // (every bench looked like every other "New Workbench", and Myra's // "Three reviewers read every pull request" greeting described a team @@ -126,7 +167,7 @@ describe("createWorkbenchFromTemplate (CL-6387)", () => { return json({ id: "chan-1", title: "Code review", - kind: "chat", + kind: "workbench", pinned: false, participants: [], }); @@ -148,7 +189,7 @@ describe("createWorkbenchFromTemplate (CL-6387)", () => { return json({ id: "chan-1", title: "Code review", - kind: "chat", + kind: "workbench", pinned: false, participants: [], settings: {}, @@ -195,7 +236,7 @@ describe("createWorkbenchFromTemplate (CL-6387)", () => { const createdIds = createAgentCalls.map( (_, index) => `def-reviewer-${index + 1}`, ); - expect(invitedIds.sort()).toEqual(createdIds.sort()); + expect(invitedIds.sort()).toEqual(["def-assistant", ...createdIds].sort()); expect(navigated).toEqual(["/w/chan-1"]); // CL-6594: a room this function navigates to must never carry a diff --git a/apps/web/test/new-workbench-picker.test.tsx b/apps/web/test/new-workbench-picker.test.tsx index 7d9c00910..c75ba7d87 100644 --- a/apps/web/test/new-workbench-picker.test.tsx +++ b/apps/web/test/new-workbench-picker.test.tsx @@ -189,7 +189,7 @@ function stubBlankCreate( return json({ id: "chan_new", title: "New Workbench", - kind: "chat", + kind: "workbench", pinned: false, participants: [], }); @@ -346,9 +346,11 @@ describe("NewWorkbenchPickerRoute", () => { call.path.endsWith("/chat/workbenches") && call.init?.method === "POST", ); expect(JSON.parse(String(createWorkbenchCall?.init?.body))).toMatchObject({ - kind: "chat", - definitionId: "wfd_assistant", + kind: "workbench", }); + expect( + JSON.parse(String(createWorkbenchCall?.init?.body)).definitionId, + ).toBeUndefined(); const sendMessageCall = calls.find((call) => call.path.endsWith("/chat/workbenches/chan_new/messages"), @@ -460,7 +462,7 @@ describe("NewWorkbenchPickerRoute", () => { return json({ id: "chan_new", title: "New Workbench", - kind: "chat", + kind: "workbench", pinned: false, participants: [], }); @@ -501,7 +503,7 @@ describe("NewWorkbenchPickerRoute", () => { return json({ id: "chan_new", title: "New Workbench", - kind: "chat", + kind: "workbench", pinned: false, participants: [], settings: { @@ -543,11 +545,13 @@ describe("NewWorkbenchPickerRoute", () => { call.path.endsWith("/chat/workbenches") && call.init?.method === "POST", ); expect(JSON.parse(String(createWorkbenchCall?.init?.body))).toMatchObject({ - kind: "chat", - definitionId: "wfd_assistant", + kind: "workbench", templatePromise: "Three reviewers read every pull request and post what they'd change.", }); + expect( + JSON.parse(String(createWorkbenchCall?.init?.body)).definitionId, + ).toBeUndefined(); expect(createdAgentHandles).toEqual([ "correctness-reviewer", @@ -595,7 +599,7 @@ describe("NewWorkbenchPickerRoute", () => { return json({ id: "chan_new", title: "New Workbench", - kind: "chat", + kind: "workbench", pinned: false, participants: [], }); @@ -635,7 +639,7 @@ describe("NewWorkbenchPickerRoute", () => { return json({ id: "chan_new", title: "New Workbench", - kind: "chat", + kind: "workbench", pinned: false, participants: [], settings: { diff --git a/packages/workflow-catalog/test/instantiate.test.ts b/packages/workflow-catalog/test/instantiate.test.ts index e1cf21282..f608f66f7 100644 --- a/packages/workflow-catalog/test/instantiate.test.ts +++ b/packages/workflow-catalog/test/instantiate.test.ts @@ -112,11 +112,12 @@ test("instantiating the code-review template invites every reviewer into the roo expect(ports.invited).toHaveLength(3); }); -test("instantiating the code-review template never invites Myra — she is already the room's own agent", async () => { - const ports = fakePorts(); +test("instantiating the code-review template invites existing Myra, never creates her", async () => { + const ports = fakePorts(["assistant"]); await instantiateWorkbenchTemplate(CODE_REVIEW_TEMPLATE, ports); - expect(ports.invited).not.toContain("def-myra"); - expect(ports.invited).toHaveLength(CODE_REVIEW_REVIEWERS.length); + expect(ports.invited).toContain("def-assistant"); + expect(ports.created).not.toContain("myra"); + expect(ports.created).not.toContain("assistant"); }); test("instantiating the code-review template names an honest pending note for its not-yet-scoped webhook trigger", async () => { From 2e84aca710b117001bd150c7b2181f4c05ed201d Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 24 Aug 2026 10:19:09 -0700 Subject: [PATCH 03/11] Mint empty channels from +; invite existing Myra after --- apps/web/src/command-palette-actions.ts | 4 +- apps/web/src/instant-agent-create.ts | 58 +++++++++----------- packages/chat-ui/src/api.ts | 25 +++++++-- packages/workflow-catalog/src/instantiate.ts | 26 ++++++--- 4 files changed, 65 insertions(+), 48 deletions(-) diff --git a/apps/web/src/command-palette-actions.ts b/apps/web/src/command-palette-actions.ts index cd7577c85..4f43e233f 100644 --- a/apps/web/src/command-palette-actions.ts +++ b/apps/web/src/command-palette-actions.ts @@ -96,12 +96,12 @@ export const ACTION_COMMANDS: readonly ActionCommand[] = [ { id: "new-workbench", title: "New workbench", - subtitle: "Start a new workbench with Myra", + subtitle: "Start a new workbench", }, { id: "new-agent", title: "New workbench", - subtitle: "Start a new workbench with Myra", + subtitle: "Start a new workbench", }, { id: "new-routine", diff --git a/apps/web/src/instant-agent-create.ts b/apps/web/src/instant-agent-create.ts index 538880f8f..bda7317c1 100644 --- a/apps/web/src/instant-agent-create.ts +++ b/apps/web/src/instant-agent-create.ts @@ -2,16 +2,13 @@ // 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. +// `createWorkbenchFromTemplate` below once a row is chosen. Blank `+` +// mints an empty `kind: "workbench"` channel (no host, no definitionId). +// Named templates mint that same empty channel, then invite existing +// principals — including Myra — so the room is a multi-principal +// channel, never a second agent DM. Explicitly defining a brand-new +// agent, with its own name/purpose/model/skills chosen up front, stays +// `CreateAgentPanel`'s job (Settings → Agents), unchanged. import { getLogger } from "@corbits/client-log"; import type { QueryClient } from "@tanstack/react-query"; @@ -98,23 +95,23 @@ export type PickGithubRepos = (args: { }) => Promise; /** - * The template picker's "Create workbench" action (CL-6344): mints a - * fresh chat, named after the picked template, 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 definitions, invites each into the room so the - * roster the greeting promises is the roster actually there (see - * `instantiateWorkbenchTemplate`'s own doc), and records its required - * connections as pending. A template id with no manifest yet (`blank`, - * "Just start talking") mints a plain untagged chat under the generic - * `NEW_WORKBENCH_TITLE`, exactly like before templates existed — there - * is no better name to give it. When `pickGithubRepos` is supplied and - * GitHub is already connected for this tenant, this also drives - * CL-6386's "select on new-workbench" step — see `PickGithubRepos`'s - * own doc. + * The template picker's "Create workbench" action (CL-6344 / CL-6982): + * mints an empty `kind: "workbench"` channel with no host and no + * `definitionId`. A named template (code-review, due-diligence, GTM) + * still mints that empty channel, then instantiates its roster — + * existing principals, including Myra, are invited after mint rather + * than skipped as "already hosted." Talking to an agent is clicking + * that agent (find-or-reopen its one DM). This function is the create + * verb for a room, not a clone of Myra. + * + * Setup still has to have seeded the default assistant definition; if + * it hasn't, we fail with `WorkbenchPreconditionError` rather than + * minting a hostless room that then can't invite anyone. A template id + * with no manifest yet (`blank`, "Just start talking") mints a plain + * untagged channel under the generic `NEW_WORKBENCH_TITLE`. When + * `pickGithubRepos` is supplied and GitHub is already connected for + * this tenant, this also drives CL-6386's "select on new-workbench" + * step — see `PickGithubRepos`'s own doc. * * `queryClient` invalidates the workbenches list once every template * participant has been invited (CL-6594) — `ChatWorkspace`'s own @@ -183,12 +180,11 @@ export async function createWorkbenchFromTemplate( : false; const workbench = await createWorkbench(tenantId, { - kind: "chat", - definitionId: setupTemplate.id, + kind: "workbench", name: manifest?.title ?? NEW_WORKBENCH_TITLE, ...(manifest !== undefined ? { templatePromise: manifest.promise } : {}), - ...(requiresGithub && !githubAlreadyConnected - ? { connectGithubRequiredFor: manifest?.title ?? "" } + ...(requiresGithub && !githubAlreadyConnected && manifest !== undefined + ? { connectGithubRequiredFor: manifest.title } : {}), }); diff --git a/packages/chat-ui/src/api.ts b/packages/chat-ui/src/api.ts index a037bf9ab..52a276d08 100644 --- a/packages/chat-ui/src/api.ts +++ b/packages/chat-ui/src/api.ts @@ -333,13 +333,26 @@ export function listAllWorkbenches( // counterpart attached at creation. See `packages/chat/src/routes.ts` // `POST /workbenches` for the server side of this union. // -// An agent chat always mints a new workbench (CL-6089) — the agent is a -// template, not a conversation being reopened — unless the caller opts -// into `reuseExisting: true`, reserved for the one deliberate -// find-or-create caller: the home-workbench land-hop -// (`default-agent-workbench.ts`'s `ensure`). +// `kind: "chat"` + `definitionId` always find-or-reopens the one DM +// for that agent (CL-6981). `reuseExisting` is still accepted on the +// wire and ignored. `kind: "workbench"` mints an empty channel; named +// templates may send `templatePromise` / `connectGithubRequiredFor` so +// the opener and GitHub card can follow the roster invite. export type CreateWorkbenchInput = - | { readonly kind: "workbench"; readonly name: string } + | { + readonly kind: "workbench"; + readonly name: string; + /** Named-template opener line. The server currently posts the + * canned greeting only on `kind: "chat"` + `definitionId`; this + * field is still sent so a follow-up can honor it on channel + * mint / first invite without dropping the promise from the + * create body. Omitted for a blank channel. */ + readonly templatePromise?: string; + /** Template display name when GitHub must be connected before + * the roster can run. Omitted when the template does not + * require GitHub. */ + readonly connectGithubRequiredFor?: string; + } | { readonly kind: "chat"; readonly definitionId: string; diff --git a/packages/workflow-catalog/src/instantiate.ts b/packages/workflow-catalog/src/instantiate.ts index 68d6d8ca1..e99753785 100644 --- a/packages/workflow-catalog/src/instantiate.ts +++ b/packages/workflow-catalog/src/instantiate.ts @@ -75,9 +75,9 @@ export interface WorkbenchTemplateInstantiationPorts { * workbench's room (`POST /workbenches/:id/invite` — * `@corbits/chat-ui`'s `inviteAgent`), or a fake of it in tests. This * is what makes a template's roster actually present in the room - * rather than merely registered in the agent directory. Never called - * for Myra: she joins the room at workbench creation as its own - * `definitionId`. */ + * rather than merely registered in the agent directory. Called for + * Myra too: she is an existing principal invited after mint, not + * the room's host `definitionId`. */ inviteParticipantAgent(id: string): Promise; /** Persists the room's still-needed connections — the workbench * settings `template/pendingConnections` key today; see @@ -128,11 +128,11 @@ function webhookTriggerTodo( /** * Resolves `manifest` against the bench: creates the participant agent * definitions that don't already exist (Myra is never re-created — she - * is the bench's seeded default setup agent, reused as-is), and - * records the manifest's required connections as still pending. Never - * registers a live webhook trigger — see `webhookTriggerTodos` and - * `./connect-github-setup.ts`, which is what actually creates one, once - * the person has picked repos. + * is the bench's seeded default setup agent, reused as-is and invited + * into the new channel), and records the manifest's required + * connections as still pending. Never registers a live webhook trigger + * — see `webhookTriggerTodos` and `./connect-github-setup.ts`, which is + * what actually creates one, once the person has picked repos. */ export async function instantiateWorkbenchTemplate( manifest: WorkbenchTemplateManifest, @@ -141,6 +141,10 @@ export async function instantiateWorkbenchTemplate( const existingIdsByHandle = new Map( (await ports.listAgentHandles()).map((agent) => [agent.handle, agent.id]), ); + const assistantId = existingIdsByHandle.get("assistant"); + if (assistantId !== undefined && !existingIdsByHandle.has("myra")) { + existingIdsByHandle.set("myra", assistantId); + } const requestsByHandle = new Map( [ ...codeReviewAgentRequests(), @@ -167,12 +171,16 @@ export async function instantiateWorkbenchTemplate( const skippedHandles: string[] = []; const invitedHandles: string[] = []; for (const participant of manifest.participants) { - if (participant.handle === "myra") continue; const existingId = existingIdsByHandle.get(participant.handle); let participantId: string; if (existingId !== undefined) { skippedHandles.push(participant.handle); participantId = existingId; + } else if (participant.handle === "myra") { + // Seeded principal (`name: "assistant"`), never minted from a + // template roster. The create path already gates on + // `findMyraDefinition`; skip rather than throw. + continue; } else { const request = requestsByHandle.get(participant.handle); if (request === undefined) { From 795e8442a8d2ab35559883cdf7c6843e56c7374d Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 24 Aug 2026 10:48:42 -0700 Subject: [PATCH 04/11] Add tests for standing-run reuse on invite --- packages/chat/test/platform-adapter.test.ts | 66 +++++++++++++++++++- packages/chat/test/workbench-service.test.ts | 56 ++++++++++++++--- 2 files changed, 112 insertions(+), 10 deletions(-) diff --git a/packages/chat/test/platform-adapter.test.ts b/packages/chat/test/platform-adapter.test.ts index c65296549..1ec8c601d 100644 --- a/packages/chat/test/platform-adapter.test.ts +++ b/packages/chat/test/platform-adapter.test.ts @@ -87,17 +87,19 @@ mock.module("@intx/db", () => ({ const { createHubChatPlatform } = await import("../src/platform-adapter"); -type SelectChain = { +type SelectChain = PromiseLike & { where(...args: unknown[]): SelectChain; orderBy(...args: unknown[]): SelectChain; limit(n?: number): Promise; }; function selectChain(rows: unknown[]): SelectChain { + const result = Promise.resolve(rows); const chain: SelectChain = { where: () => chain, orderBy: () => chain, - limit: () => Promise.resolve(rows), + limit: () => result, + then: (onFulfilled, onRejected) => result.then(onFulfilled, onRejected), }; return chain; } @@ -1104,6 +1106,66 @@ describe("createHubChatPlatform", () => { }); }); + test("launchInvite reuses the standing workbench_launch for the same definition", async () => { + const db = createFakeDb({ + assetRow: { + tenantId: "ten_1", + creatorPrincipalId: "prin_creator", + name: "workbench-1", + displayName: null, + }, + definitionId: "wfd_workbench1", + workflowDefinitionRow: { + id: "wfd_echo", + tenantId: "ten_1", + status: "deployed", + origin: "authored", + assetId: "asst_echo", + }, + tenantRow: { id: "ten_1", domain: "ten1.workbench.test" }, + wireProjectionsByDefinitionId: { + wfd_echo: inertProjection({ id: "wfd_echo" }), + }, + }); + const platform = createHubChatPlatform({ + toolGrantsForPins: () => [], + db: db as never, + sessionService: createFakeSessionService(), + assetService: createFakeAssetService(), + sidecarRouter: createFakeSidecarRouter({ routableAddresses: [] }), + eventCollectors: createFakeEventCollectors(), + }); + + const first = await platform.launchInvite({ + tenantId: "ten_1", + creatorPrincipalId: "prin_creator", + definitionId: "wfd_echo", + }); + const launchInsertsAfterFirst = db.inserted.filter( + (row) => row.table === workbenchLaunch, + ).length; + const runInsertsAfterFirst = db.inserted.filter( + (row) => row.table === workflowRun, + ).length; + expect(launchInsertsAfterFirst).toBe(1); + expect(runInsertsAfterFirst).toBe(1); + + const second = await platform.launchInvite({ + tenantId: "ten_1", + creatorPrincipalId: "prin_creator", + definitionId: "wfd_echo", + }); + + expect(second.instanceId).toBe(first.instanceId); + expect(second.address).toBe(first.address); + expect(db.inserted.filter((row) => row.table === workbenchLaunch)).toHaveLength( + launchInsertsAfterFirst, + ); + expect(db.inserted.filter((row) => row.table === workflowRun)).toHaveLength( + runInsertsAfterFirst, + ); + }); + // An invited agent's credential secret must be decrypted with the same // real cipher the composition root's credential-write route encrypts it // with. `createHubChatPlatform`'s own `credentialCipher` dep must reach diff --git a/packages/chat/test/workbench-service.test.ts b/packages/chat/test/workbench-service.test.ts index a2069aef4..0cdc6d5dd 100644 --- a/packages/chat/test/workbench-service.test.ts +++ b/packages/chat/test/workbench-service.test.ts @@ -1018,15 +1018,16 @@ describe("POST /workbenches/:id/invite", () => { ]); }); - // The explicit invite affordance deliberately CAN place a second - // instance of one definition in a room — proven live by the CL-6329 - // turn-swap proof and the reason handle de-duplication exists. - // CL-6451's anti-sibling rule lives on the command/mention paths, - // never here. - test("an explicit re-invite of the same definition mints a second instance", async () => { + // One room participant = one standing principal (CL-6978): an + // explicit re-invite of a definition this room already holds returns + // the resident handle rather than minting a sibling or appending a + // second participant row. `addParticipant` does not de-dupe addresses. + test("an explicit re-invite of the same definition does not mint a second instance", async () => { let launches = 0; const platform = fakePlatform({ invitable: [{ id: "wfd_echo", name: "echo" }], + resolveDefinitionIdByAddress: async (address) => + address === "ins_invited1@acme.example" ? "wfd_echo" : undefined, launchInvite: async () => { launches += 1; return { @@ -1050,14 +1051,53 @@ describe("POST /workbenches/:id/invite", () => { expect((await invite()).status).toBe(201); expect((await invite()).status).toBe(201); - expect(platform.launchInviteCalls).toHaveLength(2); + expect(platform.launchInviteCalls).toHaveLength(1); const settingsRow = await deps.store.getWorkbenchSettings( TENANT.id, workbench.id, ); expect(settingsRow?.settings["chat/participants"]).toEqual([ { address: "ins_invited1@acme.example", handle: "echo" }, - { address: "ins_invited2@acme.example", handle: "echo-2" }, + ]); + }); + + test("inviting the same definition into a second workbench reuses the standing address", async () => { + const standing = { + instanceId: "ins_sales", + address: "ins_sales@acme.example", + }; + const platform = fakePlatform({ + invitable: [{ id: "wfd_sales", name: "Sales" }], + launchInvite: async () => standing, + }); + const deps = buildDeps({ platform }); + const app = mountAs(createChatRoutes(deps), "prn_alice"); + const { body: first } = await createWorkbench(app, { kind: "workbench" }); + const { body: second } = await createWorkbench(app, { kind: "workbench" }); + + const invite = (workbenchId: string) => + app.request(`/workbenches/${workbenchId}/invite`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ definitionId: "wfd_sales" }), + }); + expect((await invite(first.id)).status).toBe(201); + expect((await invite(second.id)).status).toBe(201); + + expect(platform.launchInviteCalls).toHaveLength(2); + const firstSettings = await deps.store.getWorkbenchSettings( + TENANT.id, + first.id, + ); + const secondSettings = await deps.store.getWorkbenchSettings( + TENANT.id, + second.id, + ); + expect(firstSettings?.settings["chat/participants"]).toEqual([ + { address: "ins_sales@acme.example", handle: "sales" }, + ]); + expect(secondSettings?.settings["chat/participants"]).toEqual([ + { address: "ins_sales@acme.example", handle: "sales" }, ]); }); From 2a829d0ab537bc554bd7c647b631a3cc338645f4 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 24 Aug 2026 10:48:45 -0700 Subject: [PATCH 05/11] Reuse the standing agent run across rooms --- packages/chat/src/agent-binding.ts | 43 +++++++++++++++++- packages/chat/src/platform-adapter.ts | 24 +++++++++-- packages/chat/src/workbench-service.ts | 60 +++++++++++++++++--------- 3 files changed, 102 insertions(+), 25 deletions(-) diff --git a/packages/chat/src/agent-binding.ts b/packages/chat/src/agent-binding.ts index d646e15f4..d29fdf0de 100644 --- a/packages/chat/src/agent-binding.ts +++ b/packages/chat/src/agent-binding.ts @@ -19,7 +19,7 @@ // erased; it stays readable through the platform's own run routes, // which is the whole audit-trail argument for relaunching rather than // resurrecting. -import { eq } from "drizzle-orm"; +import { asc, eq } from "drizzle-orm"; import { type } from "arktype"; import type { DB } from "@intx/db"; import { workflowRun } from "@intx/db/schema"; @@ -188,6 +188,47 @@ export async function resolveLiveByStableId( return { binding: bindingFrom(row, requireDomain(run.address)), run }; } +/** + * The oldest standing `workbench_launch` in this tenant whose live run + * is the same agent as `definitionId` — row-id match first, else the + * definition's asset (a re-projected authored row over the same asset + * is the same principal, not a sibling). Invite reuses this binding + * instead of minting Sales-2. + */ +export async function findStandingLaunchByDefinition( + db: DB["db"], + input: { + readonly tenantId: string; + readonly definitionId: string; + readonly resolveDefinitionAssetId: ( + definitionId: string, + ) => Promise; + }, +): Promise { + const rows = await db + .select() + .from(workbenchLaunch) + .where(eq(workbenchLaunch.tenantId, input.tenantId)) + .orderBy(asc(workbenchLaunch.createdAt)); + const invitedAssetId = await input.resolveDefinitionAssetId( + input.definitionId, + ); + for (const row of rows) { + const run = await readRun(db, row.currentRunId); + if (run === undefined || run.address === null) continue; + const liveDefinitionId = run.definitionId; + if (liveDefinitionId === input.definitionId) { + return bindingFrom(row, requireDomain(run.address)); + } + if (invitedAssetId === undefined || liveDefinitionId === null) continue; + const liveAssetId = await input.resolveDefinitionAssetId(liveDefinitionId); + if (liveAssetId === invitedAssetId) { + return bindingFrom(row, requireDomain(run.address)); + } + } + return undefined; +} + /** * The runs this participant used to be, newest first — the order * `fetchBlob` walks them in, so the most recently retired session is diff --git a/packages/chat/src/platform-adapter.ts b/packages/chat/src/platform-adapter.ts index b1d81ce0c..ba8ab665f 100644 --- a/packages/chat/src/platform-adapter.ts +++ b/packages/chat/src/platform-adapter.ts @@ -29,6 +29,7 @@ import { type SendFoldedMailParams, } from "@corbits/folded-runs"; import { + findStandingLaunchByDefinition, isBeyondWake, listLaunchesBeyondWake, readBindingByAddress, @@ -758,6 +759,15 @@ export function createHubChatPlatform( return { row, projection: resolved.projection }; } + async function resolveDefinitionAssetId( + definitionId: string, + ): Promise { + const row = await deps.db.query.workflowDefinition.findFirst({ + where: eq(workflowDefinition.id, definitionId), + }); + return row?.assetId ?? undefined; + } + const platform: ChatPlatform = { async launchInvite(input): Promise { const definitionRow = await deps.db.query.workflowDefinition.findFirst({ @@ -790,6 +800,15 @@ export function createHubChatPlatform( throw new Error(`No tenant "${input.tenantId}"`); } + const standing = await findStandingLaunchByDefinition(deps.db, { + tenantId: input.tenantId, + definitionId: input.definitionId, + resolveDefinitionAssetId, + }); + if (standing !== undefined) { + return { instanceId: standing.stableId, address: standing.roomAddress }; + } + const { row: resolvedDefinitionRow, projection } = await resolveAuthoredProjectedDefinition(input.tenantId, { assetId: definitionRow.assetId, @@ -873,10 +892,7 @@ export function createHubChatPlatform( }, async resolveDefinitionAssetId(definitionId): Promise { - const row = await deps.db.query.workflowDefinition.findFirst({ - where: eq(workflowDefinition.id, definitionId), - }); - return row?.assetId ?? undefined; + return resolveDefinitionAssetId(definitionId); }, async resolveDefinitionNameSource(definitionId) { diff --git a/packages/chat/src/workbench-service.ts b/packages/chat/src/workbench-service.ts index 96d756ef5..02bf4f1d5 100644 --- a/packages/chat/src/workbench-service.ts +++ b/packages/chat/src/workbench-service.ts @@ -257,25 +257,47 @@ export async function resolveInvitedDisplayName( } /** - * The invite core: launches the definition's own instance, derives - * its friendly mention handle, appends the participant record, posts - * the join event onto the workbench's timeline, and arms the reply - * bridge. Shared by `POST .../invite` and chat creation (a chat's - * single agent is invited exactly this way, at creation) so the two - * paths can never drift. + * The invite core: launches the definition's own instance (or reuses + * the tenant's standing run for that agent), derives its friendly + * mention handle, appends the participant record, posts the join + * event onto the workbench's timeline, and arms the reply bridge. + * Shared by `POST .../invite` and chat creation (a chat's single + * agent is invited exactly this way, at creation) so the two paths + * can never drift. * - * Always launches: an explicit invite deliberately CAN place a second - * instance of one definition in a room (that is what handle - * de-duplication — "echo", "echo-2" — exists for). "One room - * participant = one live run" (CL-6451) is enforced where the sibling - * was never asked for: the message pipeline's command intercept and - * `startWorkflowCommand` resolve residency via - * `findResidentAgentForDefinition` before ever reaching this launch. + * Room-local first (CL-6978): if this room already holds a participant + * launched from the definition, return that handle/address without + * launching and without appending a second row — `addParticipant` does + * not de-dupe addresses. Tenant-wide, `launchInvite` reuses the + * standing `workbench_launch` so the same principal can sit in its DM + * and many channels without a sibling instance. */ export async function launchAndJoinAgent( deps: LaunchAndJoinAgentDeps, input: LaunchAndJoinAgentInput, ): Promise { + const participants = participantsOf(input.existingSettings); + const resident = await findResidentAgentForDefinition( + deps.platform, + participants, + input.definitionId, + ); + if (resident !== undefined) { + const displayName = await resolveInvitedDisplayName( + deps.platform, + input.invitable, + input.definitionId, + ); + return { + address: resident.address, + definitionId: input.definitionId, + handle: resident.handle, + displayName, + settings: input.existingSettings, + joinEventDelivered: Promise.resolve(), + }; + } + const launched = await deps.platform.launchInvite({ tenantId: input.tenantId, creatorPrincipalId: input.principalId, @@ -299,7 +321,6 @@ export async function launchAndJoinAgent( // the settings PATCH route's record-then-mail ordering: the // participant list is the durable source of truth, so a failure // below never leaves it unwritten. - const participants = participantsOf(input.existingSettings); const row = await deps.store.updateWorkbenchSettings({ tenantId: input.tenantId, workbenchId: input.workbenchId, @@ -826,12 +847,11 @@ export type StartWorkflowCommandResult = { * starts the run, mirroring corbits-code's own workflow dispatch: no * args is "Continue.", not "nothing to do". * - * One room participant = one live run (CL-6451): a command naming a - * definition already resident in the room delivers into the existing - * participant's run — the same anti-sibling rule the message - * pipeline's `@name` intercept enforces — instead of launching again. - * A deliberate second instance stays possible through the explicit - * invite affordance, which always launches. + * One room participant = one live run (CL-6451 / CL-6978): a command + * naming a definition already resident in the room delivers into the + * existing participant's run — the same anti-sibling rule the message + * pipeline's `@name` intercept and explicit invite both enforce — + * instead of launching again. */ export async function startWorkflowCommand( deps: StartWorkflowCommandDeps, From 55e5d41a15a6009d6989a81f006e59e13fe74843 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 24 Aug 2026 11:31:42 -0700 Subject: [PATCH 06/11] Add tests for Agents vs Channels sidebar split --- apps/web/src/shell/sidebar-rows.test.ts | 144 ++++++++++++++++------ apps/web/src/shell/workbench-list.test.ts | 53 +++++++- apps/web/test/sidebar.test.tsx | 95 ++++++++++++-- scripts/checks/test/ui-vocabulary.test.ts | 45 ++++--- 4 files changed, 261 insertions(+), 76 deletions(-) diff --git a/apps/web/src/shell/sidebar-rows.test.ts b/apps/web/src/shell/sidebar-rows.test.ts index 474c65979..164ad880d 100644 --- a/apps/web/src/shell/sidebar-rows.test.ts +++ b/apps/web/src/shell/sidebar-rows.test.ts @@ -2,7 +2,8 @@ import { describe, expect, test } from "bun:test"; import type { Workbench } from "@corbits/chat-ui"; -import { buildSidebarRows } from "./sidebar-rows"; +import { buildSidebarSections } from "./sidebar-rows"; +import type { SidebarSection } from "./sidebar-rows"; function workbench(overrides: Partial = {}): Workbench { return { @@ -15,22 +16,60 @@ function workbench(overrides: Partial = {}): Workbench { } as Workbench; } -describe("buildSidebarRows", () => { - test("mixes workbenches and conversational DMs into one recency-sorted stream", () => { - const older = workbench({ - id: "ch_old", +function idsOf(section: SidebarSection | undefined): readonly string[] { + return section?.rows.map((row) => row.workbench.id) ?? []; +} + +describe("buildSidebarSections", () => { + test("kind:chat rows land in Agents, kind:workbench in Channels", () => { + const channel = workbench({ + id: "ch_room", + kind: "workbench", lastActivityAt: "2026-01-01T00:00:00.000Z", }); - const newer = workbench({ - id: "ch_new", + const dm = workbench({ + id: "ch_dm", kind: "chat", - lastActivityAt: "2026-01-03T00:00:00.000Z", + lastActivityAt: "2026-01-02T00:00:00.000Z", }); - const rows = buildSidebarRows([older], [newer]); + const sections = buildSidebarSections([channel], [dm]); - expect(rows.map((row) => row.workbench.id)).toEqual(["ch_new", "ch_old"]); - expect(rows.every((row) => row.kind === "workbench")).toBe(true); + expect(sections.map((section) => section.id)).toEqual([ + "agents", + "channels", + ]); + expect(sections.map((section) => section.label)).toEqual([ + "Agents", + "Channels", + ]); + expect(idsOf(sections[0])).toEqual(["ch_dm"]); + expect(idsOf(sections[1])).toEqual(["ch_room"]); + expect(sections[0]?.rows.every((row) => row.kind === "workbench")).toBe( + true, + ); + }); + + test("mixed recency does not interleave sections — a newer channel stays below Agents", () => { + const olderDm = workbench({ + id: "ch_dm", + kind: "chat", + lastActivityAt: "2026-01-01T00:00:00.000Z", + }); + const newerChannel = workbench({ + id: "ch_room", + kind: "workbench", + lastActivityAt: "2026-01-05T00:00:00.000Z", + }); + + const sections = buildSidebarSections([newerChannel], [olderDm]); + + expect(sections.map((section) => section.id)).toEqual([ + "agents", + "channels", + ]); + expect(idsOf(sections[0])).toEqual(["ch_dm"]); + expect(idsOf(sections[1])).toEqual(["ch_room"]); }); test("never synthesizes a row for an agent that has not been opened as a DM", () => { @@ -39,26 +78,45 @@ describe("buildSidebarRows", () => { lastActivityAt: "2026-01-01T00:00:00.000Z", }); - const rows = buildSidebarRows([older], []); + const sections = buildSidebarSections([older], []); - expect(rows).toEqual([{ kind: "workbench", workbench: older }]); + expect(idsOf(sections[0])).toEqual([]); + expect(sections[1]?.rows).toEqual([ + { kind: "workbench", workbench: older }, + ]); }); - test("pinned workbench rows float above every unpinned row regardless of recency", () => { - const pinned = workbench({ - id: "ch_pinned", + test("pinned floats within its section, not above the other section", () => { + const pinnedChannel = workbench({ + id: "ch_pinned_room", + kind: "workbench", + pinned: true, + lastActivityAt: "2026-01-01T00:00:00.000Z", + }); + const recentChannel = workbench({ + id: "ch_recent_room", + kind: "workbench", + lastActivityAt: "2026-01-05T00:00:00.000Z", + }); + const pinnedDm = workbench({ + id: "ch_pinned_dm", + kind: "chat", pinned: true, lastActivityAt: "2026-01-01T00:00:00.000Z", }); - const recent = workbench({ - id: "ch_recent", + const recentDm = workbench({ + id: "ch_recent_dm", + kind: "chat", lastActivityAt: "2026-01-05T00:00:00.000Z", }); - const rows = buildSidebarRows([pinned, recent], []); + const sections = buildSidebarSections( + [pinnedChannel, recentChannel], + [recentDm, pinnedDm], + ); - expect(rows[0]).toEqual({ kind: "workbench", workbench: pinned }); - expect(rows[1]).toEqual({ kind: "workbench", workbench: recent }); + expect(idsOf(sections[0])).toEqual(["ch_pinned_dm", "ch_recent_dm"]); + expect(idsOf(sections[1])).toEqual(["ch_pinned_room", "ch_recent_room"]); }); test("an agent already opened as a DM appears once, as its workbench row", () => { @@ -69,9 +127,10 @@ describe("buildSidebarRows", () => { lastActivityAt: "2026-01-01T00:00:00.000Z", }); - const rows = buildSidebarRows([], [dm]); + const sections = buildSidebarSections([], [dm]); - expect(rows).toEqual([{ kind: "workbench", workbench: dm }]); + expect(sections[0]?.rows).toEqual([{ kind: "workbench", workbench: dm }]); + expect(idsOf(sections[1])).toEqual([]); }); test("every created workbench keeps its row even when each minted its own definition (CL-6621)", () => { @@ -91,13 +150,9 @@ describe("buildSidebarRows", () => { }), ); - const rows = buildSidebarRows([], created); + const sections = buildSidebarSections([], created); - expect(rows.map((row) => row.workbench.id)).toEqual([ - "ch_new_3", - "ch_new_2", - "ch_new_1", - ]); + expect(idsOf(sections[0])).toEqual(["ch_new_3", "ch_new_2", "ch_new_1"]); }); test("two distinct agents whose slugs humanize to the same title never collapse into one row (CL-6413)", () => { @@ -128,12 +183,12 @@ describe("buildSidebarRows", () => { lastActivityAt: "2026-01-02T00:00:00.000Z", }); - const rows = buildSidebarRows( + const sections = buildSidebarSections( [], [researchAnalystHyphen, researchAnalystUnderscore], ); - expect(rows.map((row) => row.workbench.id).sort()).toEqual([ + expect([...idsOf(sections[0])].sort()).toEqual([ "ch_research_analyst_hyphen", "ch_research_analyst_underscore", ]); @@ -155,9 +210,9 @@ describe("buildSidebarRows", () => { lastActivityAt: "2026-01-05T00:00:00.000Z", }); - const rows = buildSidebarRows([], [olderDm, newerDm]); + const sections = buildSidebarSections([], [olderDm, newerDm]); - expect(rows.map((row) => row.workbench.id)).toEqual([ + expect(idsOf(sections[0])).toEqual([ "ch_legacy_leaf", "ch_legacy_ancestor", ]); @@ -167,11 +222,24 @@ describe("buildSidebarRows", () => { const groupOne = workbench({ id: "ch_group_1", title: "Launch plan" }); const groupTwo = workbench({ id: "ch_group_2", title: "Launch plan" }); - const rows = buildSidebarRows([], [groupOne, groupTwo]); + const sections = buildSidebarSections([groupOne, groupTwo], []); - expect(rows.map((row) => row.workbench.id)).toEqual([ - "ch_group_1", - "ch_group_2", - ]); + expect(idsOf(sections[1])).toEqual(["ch_group_1", "ch_group_2"]); + }); + + test("buckets by workbench.kind even if a row arrived in the other list", () => { + const misplacedChannel = workbench({ + id: "ch_room", + kind: "workbench", + }); + const misplacedDm = workbench({ + id: "ch_dm", + kind: "chat", + }); + + const sections = buildSidebarSections([misplacedDm], [misplacedChannel]); + + expect(idsOf(sections[0])).toEqual(["ch_dm"]); + expect(idsOf(sections[1])).toEqual(["ch_room"]); }); }); diff --git a/apps/web/src/shell/workbench-list.test.ts b/apps/web/src/shell/workbench-list.test.ts index 1f0818d82..ca58939aa 100644 --- a/apps/web/src/shell/workbench-list.test.ts +++ b/apps/web/src/shell/workbench-list.test.ts @@ -2,12 +2,15 @@ import { describe, expect, test } from "bun:test"; import type { Workbench } from "@corbits/chat-ui"; -import type { SidebarRow } from "./sidebar-rows"; +import type { SidebarRow, SidebarSection } from "./sidebar-rows"; +import { buildSidebarSections } from "./sidebar-rows"; import { filterSidebarRows, + filterSidebarSections, orderWorkbenchRows, renamePayload, rowMenuLabels, + sidebarSectionEmptyCopy, workbenchRowSignals, } from "./workbench-list"; @@ -186,3 +189,51 @@ describe("filterSidebarRows", () => { ).toEqual(["ch_1"]); }); }); + +describe("sidebarSectionEmptyCopy", () => { + test("names the empty group, never a single workbenches list", () => { + expect(sidebarSectionEmptyCopy("agents")).toBe("No agents yet"); + expect(sidebarSectionEmptyCopy("channels")).toBe("No channels yet"); + }); +}); + +describe("filterSidebarSections", () => { + test("filters Agents and Channels independently without mixing them", () => { + const sections = buildSidebarSections( + [ + workbench({ + id: "ch_room", + kind: "workbench", + title: "Launch plan", + }), + ], + [ + workbench({ + id: "ch_dm", + kind: "chat", + title: "Myra", + preview: "Drafted the Solvora outreach email", + }), + ], + ); + const byTitle = filterSidebarSections(sections, "launch"); + expect(byTitle.map((section) => section.id)).toEqual([ + "agents", + "channels", + ]); + expect(byTitle[0]?.rows.map((r) => r.workbench.id)).toEqual([]); + expect(byTitle[1]?.rows.map((r) => r.workbench.id)).toEqual(["ch_room"]); + + const byPreview = filterSidebarSections(sections, "Solvora"); + expect(byPreview[0]?.rows.map((r) => r.workbench.id)).toEqual(["ch_dm"]); + expect(byPreview[1]?.rows.map((r) => r.workbench.id)).toEqual([]); + }); + + test("an empty query keeps both sections intact", () => { + const sections: readonly SidebarSection[] = buildSidebarSections( + [workbench({ id: "ch_room", kind: "workbench", title: "Launch" })], + [workbench({ id: "ch_dm", kind: "chat", title: "Myra" })], + ); + expect(filterSidebarSections(sections, "")).toEqual(sections); + }); +}); diff --git a/apps/web/test/sidebar.test.tsx b/apps/web/test/sidebar.test.tsx index b86abb03d..b3f806e75 100644 --- a/apps/web/test/sidebar.test.tsx +++ b/apps/web/test/sidebar.test.tsx @@ -208,11 +208,12 @@ describe("Sidebar", () => { container.remove(); }); - test("titles itself Workbenches and never renders a page-nav list", () => { + test("titles itself Agents and Channels and never renders a page-nav list", () => { const markup = renderSidebar("/settings/agents"); - // The visible "Workbenches" label lives inside the list (below its - // search box); the panel keeps the accessible name. - expect(markup).toContain('aria-label="Workbenches"'); + // Section labels live inside the list (below its search box); the + // panel keeps the accessible name for both groups. + expect(markup).toContain('aria-label="Agents and Channels"'); + expect(markup).not.toContain('aria-label="Workbenches"'); expect(markup).not.toContain(">Pages<"); expect(markup).not.toContain("shell-rail-item"); }); @@ -238,11 +239,6 @@ describe("Sidebar", () => { expect(markup.indexOf("shell-sidebar-mission-control")).toBeLessThan( markup.indexOf("shell-sidebar-footer-row"), ); - expect(markup.indexOf(">Routines<")).toBeLessThan( - markup.indexOf(">Files<"), - ); - expect(markup.indexOf(">Files<")).toBeLessThan(markup.indexOf(">Skills<")); - expect(markup.indexOf(">Skills<")).toBeLessThan(markup.indexOf(">Agents<")); }); test("first-run footer rail does not list Evals or Insights before there is honest usage", async () => { @@ -414,7 +410,7 @@ describe("Sidebar", () => { const { container, root } = await mountAt(path); expect( - container.querySelector('[aria-label="Search workbenches"]'), + container.querySelector('[aria-label="Search agents and channels"]'), ).not.toBeNull(); const row = container.querySelector(".shell-ch-row"); expect(row).not.toBeNull(); @@ -445,7 +441,7 @@ describe("Sidebar", () => { }); }); - // Sidebar = workbenches + conversational DMs only. Visible agent + // Sidebar = opened DMs (Agents) + rooms (Channels). Visible agent // definitions that have never been opened do not get a synthetic row. describe("agent DM rows", () => { const workbench = { @@ -543,6 +539,77 @@ describe("Sidebar", () => { }); }); + test("renders Agents then Channels, without mixing a newer room above DMs", async () => { + const channel = { + id: "ch_room", + title: "Launch plan", + kind: "workbench", + pinned: false, + participants: [], + lastActivityAt: "2026-01-10T00:00:00.000Z", + }; + const dm = { + id: "ch_dm", + title: "Myra", + kind: "chat", + pinned: false, + participants: [], + lastActivityAt: "2026-01-01T00:00:00.000Z", + }; + globalThis.fetch = ((input: RequestInfo | URL) => { + const path = typeof input === "string" ? input : String(input); + if (path.includes("/api/me/principals")) + return Promise.resolve(json(membership)); + if (path.includes("/chat/workbenches?kind=workbench")) + return Promise.resolve(json({ items: [channel] })); + if (path.includes("/chat/workbenches?kind=chat")) + return Promise.resolve(json({ items: [dm] })); + if (path.includes("/approvals/needs-you")) + return Promise.resolve(json({ items: [] })); + if (path.includes("/top-level-runs")) + return Promise.resolve(json({ data: [], nextCursor: null })); + if (path.includes("/agent-definitions/visible")) + return Promise.resolve(json({ definitions: [] })); + return Promise.resolve(json({ items: [] })); + }) as typeof fetch; + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + await act(async () => { + root.render( + + + + + , + ); + }); + for (let i = 0; i < 40; i++) { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + if (container.textContent?.includes("Launch plan")) break; + } + + const agentsHeading = container.querySelector("#sidebar-agents-heading"); + const channelsHeading = container.querySelector( + "#sidebar-channels-heading", + ); + expect(agentsHeading?.textContent).toBe("Agents"); + expect(channelsHeading?.textContent).toBe("Channels"); + expect(container.innerHTML.indexOf("sidebar-agents-heading")).toBeLessThan( + container.innerHTML.indexOf("sidebar-channels-heading"), + ); + expect(container.innerHTML.indexOf("Myra")).toBeLessThan( + container.innerHTML.indexOf("Launch plan"), + ); + expect(container.innerHTML).not.toContain("No workbenches yet"); + + act(() => root.unmount()); + container.remove(); + }); + test("does not render the activity or approvals band", () => { const markup = renderSidebar("/w"); expect(markup).not.toContain("panel-activity-slot"); @@ -569,9 +636,11 @@ describe("Sidebar", () => { await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); - if (container.innerHTML.includes("No workbenches yet")) break; + if (container.innerHTML.includes("No agents yet")) break; } - expect(container.innerHTML).toContain("No workbenches yet"); + expect(container.innerHTML).toContain("No agents yet"); + expect(container.innerHTML).toContain("No channels yet"); + expect(container.innerHTML).not.toContain("No workbenches yet"); act(() => root.unmount()); container.remove(); }); diff --git a/scripts/checks/test/ui-vocabulary.test.ts b/scripts/checks/test/ui-vocabulary.test.ts index 76a406aa5..9a839df7d 100644 --- a/scripts/checks/test/ui-vocabulary.test.ts +++ b/scripts/checks/test/ui-vocabulary.test.ts @@ -151,67 +151,64 @@ test("multi-line template literals are still scanned", () => { expect(report.violations).toHaveLength(1); }); -test("a reintroduced Channels nav band label is a violation", () => { +test("Agents and Channels sidebar section labels are legal (CL-6977)", () => { const report = auditUiVocabulary([ { - relPath: "apps/web/src/routes.tsx", + relPath: "apps/web/src/shell/sidebar-rows.ts", contents: `label: "Channels",`, }, ]); - expect(report.violations).toHaveLength(1); - expect(report.violations[0]).toContain("apps/web/src/routes.tsx"); - expect(report.violations[0]).toContain("Channels"); + expect(report.violations).toEqual([]); }); -test("a reintroduced Channels page band title is a violation", () => { +test("a Channels page band title is legal (CL-6977)", () => { const report = auditUiVocabulary([ { relPath: "apps/web/src/shell/panel-contributions.tsx", contents: `title: "Channels",`, }, ]); - expect(report.violations).toHaveLength(1); + expect(report.violations).toEqual([]); }); -test("a reintroduced Channels aria-label is a violation", () => { +test("a Channels aria-label is legal (CL-6977)", () => { const report = auditUiVocabulary([ { relPath: "apps/web/src/shell/panel-contributions.tsx", contents: `
`, }, ]); - expect(report.violations).toHaveLength(1); + expect(report.violations).toEqual([]); }); -test("a reintroduced Channels JSX title attribute is a violation", () => { +test("a Channels JSX title attribute is legal (CL-6977)", () => { const report = auditUiVocabulary([ { relPath: "apps/web/src/shell/rail.tsx", contents: ``, }, ]); - expect(report.violations).toHaveLength(1); + expect(report.violations).toEqual([]); }); -test("a reintroduced Channels JSX label attribute is a violation", () => { +test("a Channels JSX label attribute is legal (CL-6977)", () => { const report = auditUiVocabulary([ { relPath: "apps/web/src/shell/rail.tsx", contents: ``, }, ]); - expect(report.violations).toHaveLength(1); + expect(report.violations).toEqual([]); }); -test("prose mentioning Channels is now a plain banned-term violation, not just a band-label one (CL-6071)", () => { +test("prose mentioning Channels is legal (CL-6977)", () => { const report = auditUiVocabulary([ { relPath: "apps/web/src/shell/panel-contributions.tsx", contents: `description="Channels and running routines for this workbench will appear here."`, }, ]); - expect(report.violations).toHaveLength(1); - expect(report.violations[0]).toContain("channel"); + expect(report.violations).toEqual([]); }); test("a reintroduced 'chat' in user-facing prose is a violation (workbench is the noun)", () => { @@ -319,18 +316,17 @@ test("chat-ui's channel-kind section label is not a band-label violation", () => expect(report.violations).toEqual([]); }); -test("a reintroduced 'channel' in user-facing prose is a violation (CL-6071: channel is now space/chat)", () => { +test("empty-state 'No channels yet' copy is legal (CL-6977)", () => { const report = auditUiVocabulary([ { relPath: "packages/chat-ui/src/strings.ts", - contents: `noChannelsTitle: "No channel yet",`, + contents: `noChannelsTitle: "No channels yet",`, }, ]); - expect(report.violations).toHaveLength(1); - expect(report.violations[0]).toContain("channel"); + expect(report.violations).toEqual([]); }); -test("reports a 'channel' violation alongside other banned terms in the same file", () => { +test("reports a 'bench' violation even when Channels copy sits beside it", () => { const report = auditUiVocabulary([ { relPath: "apps/web/src/pages/agents-settings-section.tsx", @@ -340,7 +336,8 @@ test("reports a 'channel' violation alongside other banned terms in the same fil ].join("\n"), }, ]); - expect(report.violations.length).toBeGreaterThanOrEqual(2); + expect(report.violations).toHaveLength(1); + expect(report.violations[0]).toContain("bench"); }); test("'kind: \"channel\"' internal type literals never false-match — no space, not prose", () => { @@ -367,11 +364,11 @@ test("the same template literal still catches a banned term outside any interpol const report = auditUiVocabulary([ { relPath: "apps/web/src/pages/routines-page.tsx", - contents: "`${when}, delivers to a channel named ${title}.`", + contents: "`${when}, delivers to a hub named ${title}.`", }, ]); expect(report.violations).toHaveLength(1); - expect(report.violations[0]).toContain("channel"); + expect(report.violations[0]).toContain("hub"); }); test("stripNonUserFacing preserves line and column positions", () => { From a41945401f175ba464c4a25dc889f3c406dd02b9 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 24 Aug 2026 11:31:45 -0700 Subject: [PATCH 07/11] Split the sidebar into Agents and Channels --- apps/web/src/app.css | 7 + apps/web/src/shell/sidebar-rows.ts | 51 ++++++-- apps/web/src/shell/sidebar.tsx | 12 +- apps/web/src/shell/workbench-list.tsx | 177 +++++++++++++++++++------- scripts/checks/ui-vocabulary.ts | 58 ++++----- scripts/e2e/browser/walkthrough.ts | 34 ++--- 6 files changed, 235 insertions(+), 104 deletions(-) diff --git a/apps/web/src/app.css b/apps/web/src/app.css index 476534e05..2a2e64eb5 100644 --- a/apps/web/src/app.css +++ b/apps/web/src/app.css @@ -3105,6 +3105,13 @@ tr.insights-row-clickable:hover { color: var(--muted-foreground); } +.shell-panel-list-empty { + margin: 0 0 0.35rem; + padding: 0.15rem 0.4rem 0.35rem; + font-size: 0.78rem; + color: var(--muted-foreground); +} + /* CL-6224: per-workbench Timeline — one wall-clock spine (chat messages, thread forks, routine runs, tasks, approvals), day dividers, and a right rail of per-day KPI counts. Matches the Insights stage's own visual diff --git a/apps/web/src/shell/sidebar-rows.ts b/apps/web/src/shell/sidebar-rows.ts index 91cd9d4ad..303b64965 100644 --- a/apps/web/src/shell/sidebar-rows.ts +++ b/apps/web/src/shell/sidebar-rows.ts @@ -1,5 +1,6 @@ -// The sidebar's recency-sorted stream: workbenches and conversational -// DMs (already persisted as `Workbench` rows). Unopened agent +// The sidebar's two lists: Agents (`kind: "chat"` DMs, including Myra's +// once opened) and Channels (`kind: "workbench"` rooms). Recency and pin +// order apply within each section, never across. Unopened agent // definitions do not get a synthetic row — opening a DM is a // conversation act, not a standing nav item. @@ -10,6 +11,14 @@ export type SidebarRow = { readonly workbench: Workbench; }; +export type SidebarSectionId = "agents" | "channels"; + +export type SidebarSection = { + readonly id: SidebarSectionId; + readonly label: "Agents" | "Channels"; + readonly rows: readonly SidebarRow[]; +}; + function activityOf(chat: Workbench): number { return chat.lastActivityAt ? Date.parse(chat.lastActivityAt) : 0; } @@ -23,14 +32,11 @@ function isPinned(row: SidebarRow): boolean { } /** - * Every workbench plus every conversational DM, pinned first, then - * most-recent first within each half — the same ordering rule - * `orderWorkbenchRows` applies to workbenches alone, widened to the - * union. Stable within ties. + * Pinned first, then most-recent first within each half. Stable within + * ties. Same rule `orderWorkbenchRows` applies to a single list. */ export function buildSidebarRows( - workbenches: readonly Workbench[], - chats: readonly Workbench[], + items: readonly Workbench[], ): readonly SidebarRow[] { // Every row a person can see in Postgres appears here. An earlier // heuristic (CL-6271) collapsed same-agent chats onto the newest @@ -41,7 +47,7 @@ export function buildSidebarRows( // Hiding real workbenches reads as data loss; a duplicate stale DM is // merely untidy. If stale siblings resurface, fix them server-side at // list time, not with a client-side identity guess. - const rows: SidebarRow[] = [...workbenches, ...chats].map( + const rows: SidebarRow[] = items.map( (workbench) => ({ kind: "workbench", workbench }) as const, ); const byRecency = (a: SidebarRow, b: SidebarRow) => @@ -51,3 +57,30 @@ export function buildSidebarRows( ...rows.filter((row) => !isPinned(row)).sort(byRecency), ]; } + +/** + * Two labeled sections over the existing workbench / chat listings. + * Split by `workbench.kind` so a row that arrived in the other fetch + * still lands in the right group. Agents always precede Channels; + * recency never lifts a channel above the Agents heading. + */ +export function buildSidebarSections( + workbenches: readonly Workbench[], + chats: readonly Workbench[], +): readonly SidebarSection[] { + const listed = [...workbenches, ...chats]; + return [ + { + id: "agents", + label: "Agents", + rows: buildSidebarRows(listed.filter((item) => item.kind === "chat")), + }, + { + id: "channels", + label: "Channels", + rows: buildSidebarRows( + listed.filter((item) => item.kind === "workbench"), + ), + }, + ]; +} diff --git a/apps/web/src/shell/sidebar.tsx b/apps/web/src/shell/sidebar.tsx index befd02d45..e009ab0b9 100644 --- a/apps/web/src/shell/sidebar.tsx +++ b/apps/web/src/shell/sidebar.tsx @@ -1,5 +1,5 @@ -// The one sidebar. Header: the brand mark, then create + search. Body: the -// workbench list — nothing page-scoped ever renders here. Footer: the +// The one sidebar. Header: the brand mark, then create + search. Body: +// Agents and Channels — nothing page-scoped ever renders here. Footer: the // first-run rail is Routines, Files, Skills, Agents; Insights and Evals // join only when the existing usage / eval-run reads return real items // (never a fabricated row, never a new analytics store). Plugins is @@ -143,10 +143,10 @@ export function Sidebar({ {/* Owner's shape: logo with "+" on the first row, the search box - (inside the list) below, then the plain "Workbenches" label. No + (inside the list) below, then Agents and Channels. No header icon cluster — search is the box. */}
@@ -160,8 +160,8 @@ export function Sidebar({
- {/* The "Workbenches" label renders inside the list, below its search - box (owner's order: logo · search · Workbenches · rows). */} + {/* Agents and Channels labels render inside the list, below its + search box (owner's order: logo · search · sections · rows). */} diff --git a/apps/web/src/shell/workbench-list.tsx b/apps/web/src/shell/workbench-list.tsx index d60ddfee2..d5b50ddf5 100644 --- a/apps/web/src/shell/workbench-list.tsx +++ b/apps/web/src/shell/workbench-list.tsx @@ -1,7 +1,6 @@ -// The sidebar's one list: every workbench (an agent conversation, each its -// own tenancy under the hood) as a flat run of rows — no kind sections, no -// per-page variants. Pinned rows float to the top; everything else keeps -// the order the platform returns. +// The sidebar's two lists: Agents (opened DMs) and Channels (multi-principal +// rooms). Search filters both. Pinned rows float to the top of their own +// section; recency never mixes the two. import { Badge, @@ -25,7 +24,7 @@ import type { Workbench } from "@corbits/chat-ui"; import { useQueryClient } from "@tanstack/react-query"; import { ChatCircle, DotsThree, Hash, MagnifyingGlass } from "@corbits/icons"; import { useEffect, useState } from "react"; -import type { KeyboardEvent } from "react"; +import type { KeyboardEvent, ReactNode } from "react"; import { useNeedsYouCount } from "../api"; import { useBench } from "../bench-context"; @@ -36,7 +35,12 @@ import { } from "../workbench-rename-events"; import { useBenchActivity } from "./bench-activity"; import { Chip } from "./chip"; -import { buildSidebarRows, type SidebarRow } from "./sidebar-rows"; +import { + buildSidebarSections, + type SidebarRow, + type SidebarSection, + type SidebarSectionId, +} from "./sidebar-rows"; /** * The bench-wide "something needs you" signal above the row list. The @@ -159,10 +163,10 @@ export function workbenchRowSignals( } /** - * Flat ordering for the one list: pinned rows first, then most-recent + * Flat ordering for one section: pinned rows first, then most-recent * activity first within each half. Missing timestamps sort last but keep - * their given relative order (stable sort). Pure so the "no kind sections" - * and "recency, not insertion order" rules are testable. + * their given relative order (stable sort). Kind grouping is + * `buildSidebarSections`, not this helper. */ export function orderWorkbenchRows( workbenches: readonly Workbench[], @@ -200,6 +204,28 @@ export function filterSidebarRows( }); } +/** + * Empty-section copy shown when a group has no opened rows. Agents and + * Channels each speak for themselves — never a single "No workbenches yet". + */ +export function sidebarSectionEmptyCopy(id: SidebarSectionId): string { + return id === "agents" ? "No agents yet" : "No channels yet"; +} + +/** + * Apply the sidebar search to both sections independently. Empty query + * keeps every row; a miss in one section does not hide the other. + */ +export function filterSidebarSections( + sections: readonly SidebarSection[], + query: string, +): readonly SidebarSection[] { + return sections.map((section) => ({ + ...section, + rows: filterSidebarRows(section.rows, query), + })); +} + /** * One workbench row — avatar, name (the agent's for an agent conversation, * the row's own title for a multi-party one), optional shared/live, optional @@ -379,6 +405,38 @@ function WorkbenchRow({ ); } +function SidebarSectionBlock({ + section, + empty, + searching, + children, +}: { + readonly section: SidebarSection; + readonly empty?: boolean; + readonly searching: boolean; + readonly children?: ReactNode; +}) { + const headingId = `sidebar-${section.id}-heading`; + const hasChildren = children !== undefined && children !== null; + if (searching && section.rows.length === 0 && !hasChildren) return null; + const showEmpty = + empty === true || (section.rows.length === 0 && !searching && !hasChildren); + return ( +
+

+ {section.label} +

+ {showEmpty ? ( +

+ {sidebarSectionEmptyCopy(section.id)} +

+ ) : ( + children + )} +
+ ); +} + export function WorkbenchList({ path, onNavigate, @@ -402,77 +460,110 @@ export function WorkbenchList({ ); } if (activity.kind === "empty") { + const emptySections = buildSidebarSections([], []); return ( - } - title="No workbenches yet" - description="Start a new one with the + above." - /> +
+ {emptySections.map((section) => ( + + ))} +
); } if (activity.kind === "error") { return ( } - title="Couldn't load workbenches" + title="Couldn't load agents and channels" description={activity.message} /> ); } - const all = buildSidebarRows(activity.workbenches, activity.chats); + const sections = buildSidebarSections(activity.workbenches, activity.chats); + const total = sections.reduce( + (count, section) => count + section.rows.length, + 0, + ); - if (all.length === 0) { + if (total === 0) { return ( -
+
-

Workbenches

-
- -
+ {sections.map((section) => ( + + {section.id === "agents" ? ( +
+ +
+ ) : null} +
+ ))}
); } - const filtered = filterSidebarRows(all, query); - + const filtered = filterSidebarSections(sections, query); + const matchCount = filtered.reduce( + (count, section) => count + section.rows.length, + 0, + ); + const searching = query.trim() !== ""; const tenantId = selectedTenantId ?? ""; return ( -
+
-

Workbenches

- {filtered.length === 0 ? ( + {matchCount === 0 ? ( } title="No matches" description={`Nothing matches “${query.trim()}”.`} /> ) : ( -
- {filtered.map((row) => ( - onNavigate(workbenchPath(row.workbench.id))} - signals={workbenchRowSignals( - row.workbench, - row.workbench.id === activeId, - )} - /> - ))} -
+ filtered.map((section) => ( + + {section.rows.length === 0 ? null : ( +
+ {section.rows.map((row) => ( + onNavigate(workbenchPath(row.workbench.id))} + signals={workbenchRowSignals( + row.workbench, + row.workbench.id === activeId, + )} + /> + ))} +
+ )} +
+ )) )}
); diff --git a/scripts/checks/ui-vocabulary.ts b/scripts/checks/ui-vocabulary.ts index ed3af28f4..4339d09b3 100644 --- a/scripts/checks/ui-vocabulary.ts +++ b/scripts/checks/ui-vocabulary.ts @@ -2,22 +2,21 @@ // leak into a string a user reads. "Hub", "bench", "rail", "principal", // "tenant", "instance", "deploy(ed)", "definition", "DATABASE_URL", and // "asset" are all names for things from the operator/platform side of -// the fence; the product word for a workspace is "workbench" and its -// switching control is the "switcher". "Channel", "space(s)", and "chat(s)" -// are banned too. The rule, after the single-concept collapse: a WORKBENCH -// IS an agent conversation (each already its own tenancy under the hood), -// so user-facing copy uses "workbench"/"workbenches" as the noun for that -// surface — never "chat", "space", or "channel". "Conversation" stays -// legal as the generic word for the exchange itself ("Untitled -// conversation", "invite an agent into this conversation"), and verb-ish -// message copy ("Send a message…", "talk to") needs no noun at all — the -// sweep prefers rephrasing over allowlisting, so the ALLOWLIST below stays -// empty unless a phrase genuinely cannot avoid a banned term. All banned -// words stay legal as the underlying platform/API vocabulary: -// `kind: "chat"`, route paths, event names, and other non-prose -// identifiers, none of which this check's prose filter treats as copy -// anyway — see docs/GLOSSARY.md and the CL-6016, CL-6071, and CL-6081 -// copy sweeps this check guards. +// the fence. The sidebar is Agents + Channels (CL-6977): an Agent is a +// `kind: "chat"` DM, a Channel is a `kind: "workbench"` room. "Workbench" +// stays the product name and the mint verb ("New workbench"). "Space(s)" +// and "chat(s)" stay banned as retired surface nouns — never "Chats" as +// a list title. "Conversation" stays legal as the generic word for the +// exchange itself ("Untitled conversation", "invite an agent into this +// conversation"), and verb-ish message copy ("Send a message…", "talk +// to") needs no noun at all — the sweep prefers rephrasing over +// allowlisting, so the ALLOWLIST below stays empty unless a phrase +// genuinely cannot avoid a banned term. All banned words stay legal as +// the underlying platform/API vocabulary: `kind: "chat"`, route paths, +// event names, and other non-prose identifiers, none of which this +// check's prose filter treats as copy anyway — see docs/GLOSSARY.md +// and the CL-6016, CL-6071, CL-6081, and CL-6977 copy sweeps this +// check guards. // // This scans string and template literals in apps/web/src and // packages/chat-ui/src (excluding *.test.ts(x)) for the banned terms. @@ -54,7 +53,6 @@ const BANNED_TERMS: readonly { name: string; pattern: RegExp }[] = [ { name: "definition", pattern: /\bdefinitions?\b/i }, { name: "DATABASE_URL", pattern: /\bDATABASE_URL\b/ }, { name: "asset", pattern: /\bassets?\b/i }, - { name: "channel", pattern: /\bchannels?\b/i }, { name: "space", pattern: /\bspaces?\b/i }, { name: "chat", pattern: /\bchats?\b/i }, // CL-6089: "workspace" is a synonym the teardown deliberately keeps out @@ -76,21 +74,19 @@ const BANNED_TERMS: readonly { name: string; pattern: RegExp }[] = [ ]; /** - * The surface's label renamed "Channels" → "Spaces" (CL-6054), "Spaces" → - * "Chats" (CL-6081), and "Chats"/"Chat" → "Workbenches" (the - * single-concept collapse: the sidebar lists workbenches). A single-word - * label value like `"Chats"` has no whitespace, so `isProseLiteral`'s - * space-heuristic never even hands it to the `BANNED_TERMS` scan below — - * this pattern is the dedicated regression guard for exactly that gap, - * matching the shape a reintroduced label would take for any retired - * name: a `label`/`title` object property (`title: "Chats"`), the same - * as a JSX attribute (`title="Chat"`), or an `aria-label` attribute set - * to precisely one of those words — so it never trips on legitimate - * copy that merely contains one (prose is the term scan's job, and a - * palette `heading:` or a different single-word value never matches). + * Retired single-word surface labels. "Channels" is legal again + * (CL-6977 — the sidebar's room list). "Spaces", "Chats", and "Chat" + * stay retired so a collapse back to one recency list cannot hide + * behind those nouns. A single-word label value like `"Chats"` has no + * whitespace, so `isProseLiteral`'s space-heuristic never even hands + * it to the `BANNED_TERMS` scan below — this pattern is the dedicated + * regression guard for exactly that gap, matching the shape a + * reintroduced label would take: a `label`/`title` object property + * (`title: "Chats"`), the same as a JSX attribute (`title="Chat"`), or + * an `aria-label` attribute set to precisely one of those words. */ const BAND_LABEL_PATTERN = - /\b(?:label|title)\s*[:=]\s*"(?:Channels|Spaces|Chats|Chat)"|aria-label\s*=\s*"(?:Channels|Spaces|Chats|Chat)"/g; + /\b(?:label|title)\s*[:=]\s*"(?:Spaces|Chats|Chat)"|aria-label\s*=\s*"(?:Spaces|Chats|Chat)"/g; /** * Exact strings that legitimately contain a banned term as UI copy. @@ -234,7 +230,7 @@ export function findViolations(files: readonly ScannedFile[]): Violation[] { violations.push({ relPath, line, - term: "Channels/Spaces/Chats (retired surface label)", + term: "Spaces/Chats (retired surface label)", literal: lines[line - 1] ?? match[0], }); } diff --git a/scripts/e2e/browser/walkthrough.ts b/scripts/e2e/browser/walkthrough.ts index 20439d4ab..2fd45a2ff 100644 --- a/scripts/e2e/browser/walkthrough.ts +++ b/scripts/e2e/browser/walkthrough.ts @@ -653,29 +653,33 @@ async function run(): Promise { timeout: 15_000, }); // A hard navigation re-mounts `BenchProvider` from scratch — the - // sidebar's "+" renders unconditionally, but the list's own label - // (and the rest of the list) only replaces its "Nothing selected" + // sidebar's "+" renders unconditionally, but the list's own labels + // (and the rest of the list) only replace the "Nothing selected" // empty state once `/api/me/principals` resolves and picks a // tenant. Poll rather than reading once, so this never flakes on // that ordinary reload race. The owner's sidebar reshape dropped - // the old header-bar slot entirely (logo · search · label · rows, - // no separate title bar) — `.shell-panel-list-label` is the - // "Workbenches" text that actually renders now. - let sidebarTitle: string | null = null; + // the old header-bar slot entirely (logo · search · labels · rows, + // no separate title bar) — `.shell-panel-list-label` is Agents, + // then Channels (CL-6977). + let sidebarTitles: string[] = []; for (let attempt = 0; attempt < 15; attempt += 1) { - sidebarTitle = await page.evaluate( - () => - document - .querySelector(".shell-panel-list-label") - ?.textContent?.trim() ?? null, + sidebarTitles = await page.evaluate(() => + [...document.querySelectorAll(".shell-panel-list-label")].map( + (el) => el.textContent?.trim() ?? "", + ), ); - if (sidebarTitle === "Workbenches") break; + if ( + sidebarTitles[0] === "Agents" && + sidebarTitles[1] === "Channels" + ) { + break; + } await new Promise((resolve) => setTimeout(resolve, 500)); } - if (sidebarTitle !== "Workbenches") { + if (sidebarTitles[0] !== "Agents" || sidebarTitles[1] !== "Channels") { return { status: "fail", - detail: `expected the sidebar title "Workbenches", got ${JSON.stringify(sidebarTitle)}`, + detail: `expected sidebar titles ["Agents","Channels"], got ${JSON.stringify(sidebarTitles)}`, }; } const createButtons = await countMatching( @@ -691,7 +695,7 @@ async function run(): Promise { return { status: "pass", detail: - 'single always-visible sidebar: titled "Workbenches", one "+ New workbench" affordance', + 'single always-visible sidebar: titled Agents then Channels, one "+ New workbench" affordance', }; }, ); From 4801d3efec584d8f5ed7bc553a636b63c02f733d Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 24 Aug 2026 12:03:09 -0700 Subject: [PATCH 08/11] Add tests for dropping Myra as the home land-hop --- apps/web/src/command-palette-actions.test.ts | 62 +++++++++++++++++- apps/web/test/home-page.test.tsx | 69 +++++++------------- 2 files changed, 83 insertions(+), 48 deletions(-) diff --git a/apps/web/src/command-palette-actions.test.ts b/apps/web/src/command-palette-actions.test.ts index ffffe125a..2a0a3d4d6 100644 --- a/apps/web/src/command-palette-actions.test.ts +++ b/apps/web/src/command-palette-actions.test.ts @@ -35,7 +35,8 @@ function context(overrides: { const ctx = { path: overrides.path, navigate: (to: string) => navigated.push(to), - tenantId: overrides.tenantId ?? "tenant-1", + tenantId: + overrides.tenantId !== undefined ? overrides.tenantId : "tenant-1", cycleTheme: () => { themeCycled = true; }, @@ -156,6 +157,65 @@ describe("runActionCommand", () => { await runActionCommand("talk-to-myra", ctx); expect(navigated).toEqual([]); }); + + test("talk-to-myra opens Myra's DM via kind=chat + definitionId, not a title-match mint", async () => { + const calls: { readonly path: string; readonly init?: RequestInit }[] = []; + 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 }); + if (path.includes("/workflows/definitions")) { + return Promise.resolve( + new Response( + JSON.stringify({ + data: [ + { + id: "wfd_assistant", + tenantId: "tenant-1", + name: "assistant", + currentVersion: "1", + status: "deployed", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + ], + nextCursor: null, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ); + } + if (path.endsWith("/chat/workbenches")) { + return Promise.resolve( + new Response( + JSON.stringify({ + id: "chan_myra_dm", + 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); + + 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: "wfd_assistant", + reuseExisting: true, + }); + expect(navigated).toEqual(["/w/chan_myra_dm"]); + }); }); // Backs the `>` command palette's "New routine" and "Make this a routine" diff --git a/apps/web/test/home-page.test.tsx b/apps/web/test/home-page.test.tsx index bf8feb8a2..af2c8ab24 100644 --- a/apps/web/test/home-page.test.tsx +++ b/apps/web/test/home-page.test.tsx @@ -1,18 +1,20 @@ // The land-hop every entry point funnels through: `/` (HomeRoute) // 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 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. +// workbenches yet. `/` is a hop onto the most-recent existing workbench +// or `/new`, never a parallel Myra home. A bench with one or more hops +// onto `workbenches[0]` — the listing's first row — without minting or +// ensuring Myra. A brand-new bench 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"; @@ -23,14 +25,12 @@ import type { Root } from "react-dom/client"; import { BenchProvider } from "../src/bench-context"; import { NavigationProvider } from "../src/navigation"; import { HomeRoute } from "../src/pages/home-page"; -import { resetMyraWorkbenchCache } from "../src/myra-workbench"; import { TestQueryProvider } from "./test-query-provider"; const realFetch = globalThis.fetch; afterEach(() => { globalThis.fetch = realFetch; - resetMyraWorkbenchCache(); }); const json = (body: unknown, status = 200) => @@ -80,14 +80,15 @@ const PRINCIPALS_RESPONSE = { }; describe("HomeRoute (the `/` land hop every entry point funnels through)", () => { - test("a bench with an existing workbench ensures Myra's workbench and navigates straight into it", async () => { + test("a bench with an existing workbench hops onto the first listed workbench, never minting Myra", async () => { stubFetch((path, method) => { if (path === "/api/me/principals") { return json(PRINCIPALS_RESPONSE); } if (path.endsWith("/chat/workbenches") && method === "GET") { // The all-kinds emptiness check (`listAllWorkbenches`) finds an - // existing workbench, so the ensure+redirect hop below runs. + // existing workbench, so the hop lands on that row — not a + // hidden Myra DM. return json({ items: [ { @@ -100,36 +101,10 @@ describe("HomeRoute (the `/` land hop every entry point funnels through)", () => ], }); } - 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: [ - { - 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_myra", - title: "Myra", - kind: "chat", - pinned: false, - participants: [], - }); + throw new Error( + `unexpected POST ${path} — HomeRoute must not mint a Myra workbench`, + ); } throw new Error(`unexpected fetch: ${method} ${path}`); }); @@ -154,7 +129,7 @@ describe("HomeRoute (the `/` land hop every entry point funnels through)", () => if (navigated.length > 0) break; } - expect(navigated).toEqual(["/w/chan_myra"]); + expect(navigated).toEqual(["/w/chan_existing"]); }); test("a brand-new bench with zero workbenches sends the person to the guided picker, not an auto-minted workbench", async () => { From 10a97ff1c3fd50c417ad162a006d7262659e048e Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 24 Aug 2026 12:03:12 -0700 Subject: [PATCH 09/11] Land on the open workbench, not a hidden Myra home --- apps/web/src/command-palette-actions.ts | 12 +++--- apps/web/src/pages/home-page.tsx | 51 ++++++++----------------- 2 files changed, 22 insertions(+), 41 deletions(-) diff --git a/apps/web/src/command-palette-actions.ts b/apps/web/src/command-palette-actions.ts index 4f43e233f..8e0f394d5 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,9 @@ 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 myra = findMyraDefinition(await listAgentDefinitions(ctx.tenantId)); + if (myra === undefined) return; + await openAgentDmChat(ctx.tenantId, myra.id, ctx.navigate); return; } case "go-workbenches": { diff --git a/apps/web/src/pages/home-page.tsx b/apps/web/src/pages/home-page.tsx index c3782e4cc..766832f1b 100644 --- a/apps/web/src/pages/home-page.tsx +++ b/apps/web/src/pages/home-page.tsx @@ -1,11 +1,13 @@ -// 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 +// Default land: `/` is a hop onto the most-recent existing workbench or +// `/new`, never a parallel Myra home. A bench that already has one or +// more workbenches lands in the first listed row (`workbenches[0]`) 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 @@ -37,7 +39,6 @@ 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 { useNavigate } from "../navigation"; import { NEW_WORKBENCH_PATH, ONBOARDING_PATH } from "../routes"; @@ -101,22 +102,6 @@ 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" @@ -148,24 +133,18 @@ export function HomeRoute({ void listAllWorkbenches(selectedTenantId).then( (workbenches) => { if (cancelled) return; - if (workbenches.length === 0) { + const first = workbenches[0]; + if (first === undefined) { 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(workbenchPath(first.id)); }, (cause: unknown) => { if (cancelled) return; setState({ kind: "error", - message: describeApiError(cause, "opening Myra"), + message: describeApiError(cause, "opening the workbench"), }); }, ); @@ -218,7 +197,7 @@ export function HomeRoute({ } - title="Couldn't open Myra" + title="Couldn't open the workbench" description={state.message} action={