diff --git a/DESIGN.md b/DESIGN.md index 6a35892b7..e08e9a9be 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -16,11 +16,12 @@ Top to bottom: 1. **Brand row** — logo mark and a "New workbench" button (`+`) that opens `/new`. -2. **Bench list** — **Agents**, then **Channels**. Agent rows are - `kind: chat` DMs (one per agent). Channel rows are `kind: workbench` - rooms. Search is built into the list itself. Nothing page-scoped - ever renders in this body; it lists conversations, not product - sections. +2. **Bench list** — one recency list of conversations, pins first. + Agent DMs (`kind: chat`, one per agent) mix with channels + (`kind: workbench` rooms). Not two labeled empty sections, and not + a list titled Workbenches. Search is built into the list itself. + Nothing page-scoped ever renders in this body; it lists + conversations, not product sections. 3. **Footer rail** — Mission Control is pinned above the rail as its own row. The first-run rail below it is Routines, Files, Skills, Agents, in that order (CL-6765). Insights and Evals join that rail only when @@ -60,13 +61,15 @@ consumed by both). A route that gets renamed or relocated leaves a redirect behind at its old path — old links and bookmarks always land somewhere real, never a 404. -**`/new` is the shipped create surface** (`NewWorkbenchPickerRoute`). The -primary act is a prompt: say what the channel should do, or pick a -named-template shortcut underneath. Blank `+` / prompt mint an empty -channel and invite nobody. Named templates mint that same empty -channel, then invite existing agents (including Myra as a participant, -never as the mint host). The sidebar `+` and an empty-bench land on -`/` both open this route — there is no Describe door. +**`/new` is the shipped create surface** (`NewWorkbenchPickerRoute`), +not home. The primary act is a prompt: say what the channel should do, +or pick a named-template shortcut underneath. Blank `+` / prompt mint +an empty channel and invite nobody. Named templates mint that same +empty channel, then invite existing agents (including Myra as a +participant, never as the mint host). The sidebar `+` opens this +route. First-run after credential does not: `/` hops to Myra's one +DM (`openAgentDm` / find-or-reopen). There is no parallel Myra home +route and no Describe door. **`/inbox` is gone as a page** (CL-6151). The path stays as a redirect home so old links still resolve; it is not a live groups inbox. diff --git a/PRODUCT.md b/PRODUCT.md index e3ec0614b..9ff2ec3e0 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -15,8 +15,10 @@ opens a multi-principal room (`kind: workbench`). There is no separate "project" or "space" object sitting above the conversation; the conversation is the unit of work. -This is why the sidebar is Agents then Channels, not one recency list -titled Workbenches. The same Interchange agent can sit in its DM and in +This is why the sidebar is one recency list of conversations (pins +first) — agent DMs mixed with channels — not two labeled sections, and +not a list titled Workbenches. That noun is the product name and the +mint verb. The same Interchange agent can sit in its DM and in many channels; product reopens or invites, and does not clone the definition or mint a sibling instance per room. See [docs/GLOSSARY.md](docs/GLOSSARY.md) for the full term mapping and @@ -34,18 +36,20 @@ team spaces); within a bench, they open, create, and work in workbenches. Workbench is intentionally not a multi-pane IDE. The product surface is one column at a time: -- **A sidebar of Agents then Channels** lists conversations in the - selected bench. Agent rows are DMs (`kind: chat`); channel rows are - rooms (`kind: workbench`). There is no one recency list titled +- **A sidebar of conversations** lists agent DMs and channels together + by recency, pins first, in the selected bench. Agent rows are DMs + (`kind: chat`); channel rows are shared rooms (`kind: workbench`). + There are not two labeled empty sections, and the list is not titled Workbenches. - **"New workbench" always creates.** It opens `/new` — the shipped - prompt-primary picker — never a picker of existing things to join. - Plus mints an empty channel. Nobody is auto-hosted. + prompt-primary picker — never home, and never a picker of existing + things to join. Plus mints an empty channel. Nobody is auto-hosted. - **Agents are principals, not templates.** Opening Sales opens Sales — the one 1:1 tenant with that agent. The same agent can sit in its DM and in many channels. Product reopens or invites; it does not clone - the definition or mint a sibling instance per room. Myra is an agent - row, not a special home slot. + the definition or mint a sibling instance per room. Myra is the + first-run guide in her DM, not a special home slot and not a + parallel home route. - The active workbench occupies the main column; a contextual panel beside it carries account-wide surfaces (approvals, recent activity) that stay visible regardless of which workbench is open. @@ -53,24 +57,27 @@ column at a time: ## First run A brand-new account is walked through login and a credential connect, then -lands on the create surface rather than an empty shell: +lands in Myra's one DM rather than `/new` or an empty shell: 1. **Login.** 2. **Credential** — connect a model provider (one-click OAuth for supported providers, or a pasted API key); see `packages/onboarding`. -3. **Create** — `/` hops an empty bench (zero workbenches) to `/new`, the - prompt-primary picker (`apps/web/src/pages/new-workbench-picker.tsx`). - A prompt box is the primary act: typing a goal and submitting mints an - empty channel and sends that text as the first message; blank plus - invites nobody. Named-template rows underneath mint that same empty - channel, then invite existing principals (including Myra as a - participant, never as mint `definitionId`) — one-click shortcuts, not - a kind-then-Create second step. There is no Describe door and no - `describe-first-workbench.tsx`. - -A bench that already has one or more workbenches skips create and lands -on `workbenches[0]` (see `apps/web/src/pages/home-page.tsx`). Myra is an -agent row, not a home slot. +3. **Myra's DM** — `/` hops an empty bench to that agent's one DM + (`openAgentDm` / find-or-reopen). There is no parallel Myra home + route. `/new` stays the create door (sidebar `+`), not this hop. + +Create stays on `/new` (`apps/web/src/pages/new-workbench-picker.tsx`): +a prompt box is the primary act: typing a goal and submitting mints an +empty channel and sends that text as the first message; blank plus +invites nobody. Named-template rows underneath mint that same empty +channel, then invite existing principals (including Myra as a +participant, never as mint `definitionId`) — one-click shortcuts, not +a kind-then-Create second step. There is no Describe door and no +`describe-first-workbench.tsx`. + +A bench that already has one or more workbenches skips first-run and +lands on `workbenches[0]` (see `apps/web/src/pages/home-page.tsx`). Myra +is the first-run guide in her DM, not a home slot. The shell's first-run destinations stay small on purpose (CL-6765): Mission Control is pinned above the footer rail; the rail itself is @@ -158,13 +165,15 @@ as an explicit absence, never a fabricated zero. See `packages/insights`. User-facing surfaces (UI, docs, support) use exactly these nouns: - **Workbench** — the product name, and the mint verb ("New workbench"). - A workbench is a conversation tenant: a DM or a channel. -- **Agent** — a coworker identity in the sidebar's Agents list. Opening - the row reopens that agent's one DM. Never "template." + A workbench is a conversation tenant: a DM or a channel. Do not title + the sidebar list Workbenches. +- **Agent** — a coworker principal. Opening the row reopens that agent's + one DM. Never "template." Myra is the first-run guide in her DM. - **DM** — the one 1:1 conversation with an agent. Never cloned by a second open. -- **Channel** — a multi-party room. Plus mints an empty one; nobody is - auto-hosted. Named templates invite existing agents into that room. +- **Channel** — a shared room between people and agents. Plus mints an + empty one; nobody is auto-hosted. Named templates invite existing + principals into that room. - **Bench** — the shared team scope a person signs into and switches between; shown in the bench switcher, never called a "workspace" or "org" in copy. diff --git a/apps/web/src/pages/home-page.tsx b/apps/web/src/pages/home-page.tsx index a156db062..64731ea05 100644 --- a/apps/web/src/pages/home-page.tsx +++ b/apps/web/src/pages/home-page.tsx @@ -1,28 +1,24 @@ // 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 +// Myra's one DM, never the picker. 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. +// the main stage. A brand-new bench with zero workbenches waits for +// Myra's own definition to exist, then opens her DM the same way +// "Talk to Myra" does (`openAgentDmChat`) — never `/new`, never +// `ensureMyraWorkbench`. Home as a dashboard does not earn its keep — +// `/` only exists as this hop onto `/w/:workbenchId`. Deep links to +// other pages are unchanged. // // Right after a provider connect this hop is also the wait (CL-6457's // deploys run in the background, so landing here can beat them). CL-6462 // settled what that wait looks like: one warm loader and nothing else. For // a zero-workbench bench the wait is for Myra's own definition to exist at -// all — the picker's "Create workbench" needs it too, so checking here -// first means the picker never opens onto a create button that would just -// throw. The check is simply retried every few seconds, because Myra's -// readiness IS the test of whether the person can start — she is deployed -// first (`SETUP_AGENT_ASSET_NAME` leads `DEFAULT_WORKFLOWS`), so the moment -// she's ready we go, with every other seeded workflow still converging -// behind us. Readiness is read only to tell a wait from a genuine failure, -// never to draw a progress number: a seed count is an implementation -// detail, and "0 of 5" told a waiting person nothing. +// all — then we open her DM. The check is simply retried every few seconds, +// because Myra's readiness IS the test of whether the person can start — +// she is deployed first (`SETUP_AGENT_ASSET_NAME` leads `DEFAULT_WORKFLOWS`), +// so the moment she's ready we go, with every other seeded workflow still +// converging behind us. Readiness is read only to tell a wait from a +// genuine failure, never to draw a progress number: a seed count is an +// implementation detail, and "0 of 5" told a waiting person nothing. // // CL-6780: that wait is for the agent, never a workbench that does not // exist yet — so the loader says "Preparing your agent", and a skip with @@ -37,10 +33,13 @@ import { listAllWorkbenches, WorkbenchLoadingState } from "@corbits/chat-ui"; import { describeApiError } from "@corbits/api-query"; import { fetchAgentReadiness, hasActiveCredential } from "../onboarding"; +import { listAgentDefinitions } from "../agents-api"; +import { openAgentDmChat } from "../agent-dm-launch"; +import { findMyraDefinition } from "../myra-workbench"; import { useBench } from "../bench-context"; import { workbenchPath } from "../workbench-path"; import { useNavigate } from "../navigation"; -import { NEW_WORKBENCH_PATH, ONBOARDING_PATH } from "../routes"; +import { ONBOARDING_PATH } from "../routes"; type LandState = /** Working on it: the warm loader, whether we are reading the bench's @@ -102,18 +101,26 @@ export function HomeRoute({ retryTimer = setTimeout(() => setAttempt((count) => count + 1), retryMs); }; - // Zero workbenches: wait for Myra's own definition to exist, then send - // the person to the picker rather than minting anything ourselves — - // "she can't start yet" and "here, go create your first workbench" - // are different messages, and only the readiness check tells them - // apart. Without a credential the drain never starts (CL-6780), so a - // not-ready status with no credential is an honest next step, not a - // forever spin on "getting ready". + // Zero workbenches: wait for Myra's own definition to exist, then + // open her DM the same way "Talk to Myra" does — never `/new`. + // "She can't start yet" and "here is her chat" are different + // messages, and only the readiness check plus a real definition + // tell them apart. Without a credential the drain never starts + // (CL-6780), so a not-ready status with no credential is an honest + // next step, not a forever spin on "getting ready". const awaitFirstWorkbench = () => { void fetchAgentReadiness().then((readiness) => { if (cancelled) return; if (readiness.kind === "ready" || readiness.kind === "chat-ready") { - navigate(NEW_WORKBENCH_PATH); + void listAgentDefinitions(selectedTenantId).then((definitions) => { + if (cancelled) return; + const myra = findMyraDefinition(definitions); + if (myra === undefined) { + waitAndRetry(); + return; + } + void openAgentDmChat(selectedTenantId, myra.id, navigate); + }); return; } void hasActiveCredential(selectedTenantId).then((probe) => { diff --git a/apps/web/src/shell/sidebar-rows.test.ts b/apps/web/src/shell/sidebar-rows.test.ts index 164ad880d..f06bd0e84 100644 --- a/apps/web/src/shell/sidebar-rows.test.ts +++ b/apps/web/src/shell/sidebar-rows.test.ts @@ -2,8 +2,8 @@ import { describe, expect, test } from "bun:test"; import type { Workbench } from "@corbits/chat-ui"; -import { buildSidebarSections } from "./sidebar-rows"; -import type { SidebarSection } from "./sidebar-rows"; +import { buildSidebarRows } from "./sidebar-rows"; +import type { SidebarRow } from "./sidebar-rows"; function workbench(overrides: Partial = {}): Workbench { return { @@ -16,12 +16,19 @@ function workbench(overrides: Partial = {}): Workbench { } as Workbench; } -function idsOf(section: SidebarSection | undefined): readonly string[] { - return section?.rows.map((row) => row.workbench.id) ?? []; +function idsOf(rows: readonly SidebarRow[]): readonly string[] { + return rows.map((row) => row.workbench.id); } -describe("buildSidebarSections", () => { - test("kind:chat rows land in Agents, kind:workbench in Channels", () => { +function mix( + workbenches: readonly Workbench[], + chats: readonly Workbench[], +): readonly SidebarRow[] { + return buildSidebarRows([...workbenches, ...chats]); +} + +describe("buildSidebarRows", () => { + test("kind:chat and kind:workbench share one list, with kind kept on each workbench", () => { const channel = workbench({ id: "ch_room", kind: "workbench", @@ -33,24 +40,17 @@ describe("buildSidebarSections", () => { lastActivityAt: "2026-01-02T00:00:00.000Z", }); - const sections = buildSidebarSections([channel], [dm]); + const rows = mix([channel], [dm]); - expect(sections.map((section) => section.id)).toEqual([ - "agents", - "channels", + expect(idsOf(rows)).toEqual(["ch_dm", "ch_room"]); + expect(rows.map((row) => row.workbench.kind)).toEqual([ + "chat", + "workbench", ]); - 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, - ); + expect(rows.every((row) => row.kind === "workbench")).toBe(true); }); - test("mixed recency does not interleave sections — a newer channel stays below Agents", () => { + test("mixed recency interleaves — a newer channel sits above an older DM", () => { const olderDm = workbench({ id: "ch_dm", kind: "chat", @@ -62,14 +62,9 @@ describe("buildSidebarSections", () => { lastActivityAt: "2026-01-05T00:00:00.000Z", }); - const sections = buildSidebarSections([newerChannel], [olderDm]); + const rows = mix([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"]); + expect(idsOf(rows)).toEqual(["ch_room", "ch_dm"]); }); test("never synthesizes a row for an agent that has not been opened as a DM", () => { @@ -78,15 +73,13 @@ describe("buildSidebarSections", () => { lastActivityAt: "2026-01-01T00:00:00.000Z", }); - const sections = buildSidebarSections([older], []); + const rows = mix([older], []); - expect(idsOf(sections[0])).toEqual([]); - expect(sections[1]?.rows).toEqual([ - { kind: "workbench", workbench: older }, - ]); + expect(idsOf(rows)).toEqual(["ch_old"]); + expect(rows).toEqual([{ kind: "workbench", workbench: older }]); }); - test("pinned floats within its section, not above the other section", () => { + test("pinned floats across the mixed list, not within a kind", () => { const pinnedChannel = workbench({ id: "ch_pinned_room", kind: "workbench", @@ -102,21 +95,22 @@ describe("buildSidebarSections", () => { id: "ch_pinned_dm", kind: "chat", pinned: true, - lastActivityAt: "2026-01-01T00:00:00.000Z", + lastActivityAt: "2026-01-02T00:00:00.000Z", }); const recentDm = workbench({ id: "ch_recent_dm", kind: "chat", - lastActivityAt: "2026-01-05T00:00:00.000Z", + lastActivityAt: "2026-01-04T00:00:00.000Z", }); - const sections = buildSidebarSections( - [pinnedChannel, recentChannel], - [recentDm, pinnedDm], - ); + const rows = mix([pinnedChannel, recentChannel], [recentDm, pinnedDm]); - expect(idsOf(sections[0])).toEqual(["ch_pinned_dm", "ch_recent_dm"]); - expect(idsOf(sections[1])).toEqual(["ch_pinned_room", "ch_recent_room"]); + expect(idsOf(rows)).toEqual([ + "ch_pinned_dm", + "ch_pinned_room", + "ch_recent_room", + "ch_recent_dm", + ]); }); test("an agent already opened as a DM appears once, as its workbench row", () => { @@ -127,10 +121,9 @@ describe("buildSidebarSections", () => { lastActivityAt: "2026-01-01T00:00:00.000Z", }); - const sections = buildSidebarSections([], [dm]); + const rows = mix([], [dm]); - expect(sections[0]?.rows).toEqual([{ kind: "workbench", workbench: dm }]); - expect(idsOf(sections[1])).toEqual([]); + expect(rows).toEqual([{ kind: "workbench", workbench: dm }]); }); test("every created workbench keeps its row even when each minted its own definition (CL-6621)", () => { @@ -150,9 +143,9 @@ describe("buildSidebarSections", () => { }), ); - const sections = buildSidebarSections([], created); + const rows = mix([], created); - expect(idsOf(sections[0])).toEqual(["ch_new_3", "ch_new_2", "ch_new_1"]); + expect(idsOf(rows)).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)", () => { @@ -183,12 +176,9 @@ describe("buildSidebarSections", () => { lastActivityAt: "2026-01-02T00:00:00.000Z", }); - const sections = buildSidebarSections( - [], - [researchAnalystHyphen, researchAnalystUnderscore], - ); + const rows = mix([], [researchAnalystHyphen, researchAnalystUnderscore]); - expect([...idsOf(sections[0])].sort()).toEqual([ + expect([...idsOf(rows)].sort()).toEqual([ "ch_research_analyst_hyphen", "ch_research_analyst_underscore", ]); @@ -210,24 +200,21 @@ describe("buildSidebarSections", () => { lastActivityAt: "2026-01-05T00:00:00.000Z", }); - const sections = buildSidebarSections([], [olderDm, newerDm]); + const rows = mix([], [olderDm, newerDm]); - expect(idsOf(sections[0])).toEqual([ - "ch_legacy_leaf", - "ch_legacy_ancestor", - ]); + expect(idsOf(rows)).toEqual(["ch_legacy_leaf", "ch_legacy_ancestor"]); }); test("group workbenches with identical titles each keep their row", () => { const groupOne = workbench({ id: "ch_group_1", title: "Launch plan" }); const groupTwo = workbench({ id: "ch_group_2", title: "Launch plan" }); - const sections = buildSidebarSections([groupOne, groupTwo], []); + const rows = mix([groupOne, groupTwo], []); - expect(idsOf(sections[1])).toEqual(["ch_group_1", "ch_group_2"]); + expect(idsOf(rows)).toEqual(["ch_group_1", "ch_group_2"]); }); - test("buckets by workbench.kind even if a row arrived in the other list", () => { + test("concatenates both fetches without re-bucketing by kind", () => { const misplacedChannel = workbench({ id: "ch_room", kind: "workbench", @@ -237,9 +224,10 @@ describe("buildSidebarSections", () => { kind: "chat", }); - const sections = buildSidebarSections([misplacedDm], [misplacedChannel]); + const rows = mix([misplacedDm], [misplacedChannel]); - expect(idsOf(sections[0])).toEqual(["ch_dm"]); - expect(idsOf(sections[1])).toEqual(["ch_room"]); + expect(idsOf(rows)).toEqual(["ch_dm", "ch_room"]); + expect(rows[0]?.workbench.kind).toBe("chat"); + expect(rows[1]?.workbench.kind).toBe("workbench"); }); }); diff --git a/apps/web/src/shell/sidebar-rows.ts b/apps/web/src/shell/sidebar-rows.ts index 303b64965..fabbbdf62 100644 --- a/apps/web/src/shell/sidebar-rows.ts +++ b/apps/web/src/shell/sidebar-rows.ts @@ -1,8 +1,8 @@ -// 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. +// The sidebar's one recency list: opened agent DMs (`kind: "chat"`, +// including Myra's once opened) mixed with channels (`kind: "workbench"`). +// Pinned first, then recency — across kinds, never two labeled sections. +// Unopened agent definitions do not get a synthetic row — opening a DM is +// a conversation act, not a standing nav item. import type { Workbench } from "@corbits/chat-ui"; @@ -11,14 +11,6 @@ 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; } @@ -33,7 +25,9 @@ function isPinned(row: SidebarRow): boolean { /** * Pinned first, then most-recent first within each half. Stable within - * ties. Same rule `orderWorkbenchRows` applies to a single list. + * ties. Same rule `orderWorkbenchRows` applies to a single list. Concatenate + * the kind:chat and kind:workbench fetches, then sort here — do not split + * by kind. */ export function buildSidebarRows( items: readonly Workbench[], @@ -57,30 +51,3 @@ 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/workbench-list.test.ts b/apps/web/src/shell/workbench-list.test.ts index ca58939aa..463bdb6e0 100644 --- a/apps/web/src/shell/workbench-list.test.ts +++ b/apps/web/src/shell/workbench-list.test.ts @@ -2,15 +2,13 @@ import { describe, expect, test } from "bun:test"; import type { Workbench } from "@corbits/chat-ui"; -import type { SidebarRow, SidebarSection } from "./sidebar-rows"; -import { buildSidebarSections } from "./sidebar-rows"; +import type { SidebarRow } from "./sidebar-rows"; import { filterSidebarRows, - filterSidebarSections, orderWorkbenchRows, renamePayload, rowMenuLabels, - sidebarSectionEmptyCopy, + SIDEBAR_EMPTY_COPY, workbenchRowSignals, } from "./workbench-list"; @@ -188,52 +186,39 @@ describe("filterSidebarRows", () => { filterSidebarRows(rows, "launch").map((r) => r.workbench.id), ).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", + test("filters a mixed list without splitting by kind", () => { + const rows = [ + row({ + id: "ch_dm", + kind: "chat", + title: "Myra", + preview: "Drafted the Solvora outreach email", + }), + row({ + id: "ch_room", + kind: "workbench", + title: "Launch plan", + }), + ]; + expect( + filterSidebarRows(rows, "launch").map((r) => r.workbench.id), + ).toEqual(["ch_room"]); + expect( + filterSidebarRows(rows, "Solvora").map((r) => r.workbench.id), + ).toEqual(["ch_dm"]); + expect(filterSidebarRows(rows, "").map((r) => r.workbench.id)).toEqual([ + "ch_dm", + "ch_room", ]); - 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); +describe("SIDEBAR_EMPTY_COPY", () => { + test("is one mixed-list empty, never per-section Agents/Channels copy", () => { + expect(SIDEBAR_EMPTY_COPY).toBe("No conversations yet"); + expect(SIDEBAR_EMPTY_COPY).not.toBe("No agents yet"); + expect(SIDEBAR_EMPTY_COPY).not.toBe("No channels yet"); + expect(SIDEBAR_EMPTY_COPY).not.toBe("No workbenches yet"); }); }); diff --git a/apps/web/src/shell/workbench-list.tsx b/apps/web/src/shell/workbench-list.tsx index f6566a7e5..c30cff393 100644 --- a/apps/web/src/shell/workbench-list.tsx +++ b/apps/web/src/shell/workbench-list.tsx @@ -1,6 +1,7 @@ -// 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. +// The sidebar's one recency list: opened DMs mixed with multi-principal +// rooms. Search filters the mixed list. Pinned rows float to the top +// across kinds; recency interleaves the rest. Unopened agent definitions +// never get a synthetic row. import { Badge, @@ -30,7 +31,7 @@ import { PushPin, } from "@corbits/icons"; import { useEffect, useState } from "react"; -import type { KeyboardEvent, ReactNode } from "react"; +import type { KeyboardEvent } from "react"; import { useNeedsYouCount } from "../api"; import { useBench } from "../bench-context"; @@ -41,12 +42,7 @@ import { } from "../workbench-rename-events"; import { useBenchActivity } from "./bench-activity"; import { Chip } from "./chip"; -import { - buildSidebarSections, - type SidebarRow, - type SidebarSection, - type SidebarSectionId, -} from "./sidebar-rows"; +import { buildSidebarRows, type SidebarRow } from "./sidebar-rows"; /** * The bench-wide "something needs you" signal above the row list. The @@ -66,36 +62,6 @@ function NeedsYouSignal({ tenantId }: { readonly tenantId: string | null }) { ); } -/** - * The always-active, no-op row naming the current screen as "where the - * person already is" (CL-6124) while a brand-new bench's zero-workbench - * `/` land is auto-minting its first Myra workbench (CL-6138) — a - * transient state, but one that still deserves an honest sidebar row - * rather than an empty list. Styled as the already-active row per house - * rule (grey structure, orange edge for "here"); selecting it is a no-op - * since it names the current screen, not a destination. - */ -function NewWorkbenchStubRow() { - return ( - - ); -} - /** * The ellipsis-menu item labels for a workbench row: rename and the * pin/unpin toggle — a strict subset of the conversation's own settings. @@ -169,10 +135,10 @@ export function workbenchRowSignals( } /** - * 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). Kind grouping is - * `buildSidebarSections`, not this helper. + * Flat ordering: pinned rows first, then most-recent activity first + * within each half. Missing timestamps sort last but keep their given + * relative order (stable sort). Kind is kept on each workbench for + * icons/create; this helper never groups by it. */ export function orderWorkbenchRows( workbenches: readonly Workbench[], @@ -210,27 +176,8 @@ 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), - })); -} +/** Honest empty copy for the mixed list — never a fake New Workbench stub. */ +export const SIDEBAR_EMPTY_COPY = "No conversations yet"; /** * One workbench row — avatar, name (the agent's for an agent conversation, @@ -425,36 +372,8 @@ 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 - )} -
- ); +function MixedListEmpty() { + return

{SIDEBAR_EMPTY_COPY}

; } export function WorkbenchList({ @@ -480,17 +399,9 @@ export function WorkbenchList({ ); } if (activity.kind === "empty") { - const emptySections = buildSidebarSections([], []); return (
- {emptySections.map((section) => ( - - ))} +
); } @@ -504,40 +415,18 @@ export function WorkbenchList({ ); } - const sections = buildSidebarSections(activity.workbenches, activity.chats); - const total = sections.reduce( - (count, section) => count + section.rows.length, - 0, - ); + const rows = buildSidebarRows([...activity.workbenches, ...activity.chats]); - if (total === 0) { + if (rows.length === 0) { return (
- {sections.map((section) => ( - - {section.id === "agents" ? ( -
- -
- ) : null} -
- ))} +
); } - const filtered = filterSidebarSections(sections, query); - const matchCount = filtered.reduce( - (count, section) => count + section.rows.length, - 0, - ); - const searching = query.trim() !== ""; + const filtered = filterSidebarRows(rows, query); const tenantId = selectedTenantId ?? ""; return ( @@ -552,38 +441,28 @@ export function WorkbenchList({ /> - {matchCount === 0 ? ( + {filtered.length === 0 ? ( } title="No matches" description={`Nothing matches “${query.trim()}”.`} /> ) : ( - 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, - )} - /> - ))} -
- )} -
- )) +
+ {filtered.map((row) => ( + onNavigate(workbenchPath(row.workbench.id))} + signals={workbenchRowSignals( + row.workbench, + row.workbench.id === activeId, + )} + /> + ))} +
)} ); diff --git a/apps/web/test/home-page.test.tsx b/apps/web/test/home-page.test.tsx index af2c8ab24..3b8be2830 100644 --- a/apps/web/test/home-page.test.tsx +++ b/apps/web/test/home-page.test.tsx @@ -1,20 +1,18 @@ // The land-hop every entry point funnels through: `/` (HomeRoute) // resolves to one of two places depending on whether the bench has any // 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 +// or Myra's one DM, never the picker. 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. +// Myra's own definition to exist, then opens her DM the same way +// "Talk to Myra" does (`openAgentDmChat`) — never `/new`, no +// `ensureMyraWorkbench`, no second creation path. All three entries +// CL-6081 asks for (a direct visit to `/`, `main.tsx`'s post-login +// `navigate("/")`, and the onboarding wizard's post-credential hand-off) +// resolve through this exact hop, so proving HomeRoute itself lands +// correctly in both cases proves the direct-`/` case fully; the other +// two are proven by the narrower source assertions below, which pin the +// exact call each entry point makes onto this same route. import { afterEach, describe, expect, test } from "bun:test"; import { act } from "react"; @@ -39,11 +37,13 @@ const json = (body: unknown, status = 200) => headers: { "content-type": "application/json" }, }); -function stubFetch(respond: (path: string, method: string) => Response) { +function stubFetch( + respond: (path: string, method: string, init?: RequestInit) => Response, +) { globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { const path = typeof input === "string" ? input : new URL(String(input)).pathname; - return Promise.resolve(respond(path, init?.method ?? "GET")); + return Promise.resolve(respond(path, init?.method ?? "GET", init)); }) as typeof fetch; } @@ -79,6 +79,34 @@ const PRINCIPALS_RESPONSE = { nextCursor: null, }; +const MYRA_DEFINITION = { + 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", +}; + +const MYRA_DM = { + id: "chan_myra_dm", + title: "Myra", + kind: "chat", + pinned: false, + participants: [], +}; + +function respondMyraDmLaunch(path: string, method: string) { + if (path.includes("/workflows/definitions")) { + return json({ data: [MYRA_DEFINITION], nextCursor: null }); + } + if (path === "/api/tenants/tnt_1/chat/workbenches" && method === "POST") { + return json(MYRA_DM); + } + return null; +} + describe("HomeRoute (the `/` land hop every entry point funnels through)", () => { test("a bench with an existing workbench hops onto the first listed workbench, never minting Myra", async () => { stubFetch((path, method) => { @@ -132,20 +160,28 @@ describe("HomeRoute (the `/` land hop every entry point funnels through)", () => 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 () => { - stubFetch((path, method) => { + test("a brand-new bench with zero workbenches opens Myra's one DM, not the guided picker", async () => { + let posted: unknown; + stubFetch((path, method, init) => { if (path === "/api/me/principals") { return json(PRINCIPALS_RESPONSE); } if (path.endsWith("/chat/workbenches") && method === "GET") { // listAllWorkbenches finds nothing — this bench has no workbenches - // yet, so HomeRoute waits for Myra's readiness and redirects to - // the picker rather than minting anything itself. + // yet, so HomeRoute waits for Myra's readiness and opens her DM + // rather than sending anyone to `/new`. return json({ items: [] }); } if (path === "/api/onboarding/provisioning-status") { return json({ kind: "ready", setupAgentReady: true }); } + if (path.includes("/workflows/definitions")) { + return json({ data: [MYRA_DEFINITION], nextCursor: null }); + } + if (path === "/api/tenants/tnt_1/chat/workbenches" && method === "POST") { + posted = JSON.parse(String(init?.body)); + return json(MYRA_DM); + } throw new Error(`unexpected fetch: ${method} ${path}`); }); @@ -169,7 +205,12 @@ describe("HomeRoute (the `/` land hop every entry point funnels through)", () => if (navigated.length > 0) break; } - expect(navigated).toEqual(["/new"]); + expect(posted).toEqual({ + kind: "chat", + definitionId: "wfd_assistant", + reuseExisting: true, + }); + expect(navigated).toEqual(["/w/chan_myra_dm"]); }); }); @@ -207,6 +248,8 @@ describe("the wait right after connecting a provider", () => { pending: ["assistant"], }); } + const myraLaunch = respondMyraDmLaunch(path, method); + if (myraLaunch !== null) return myraLaunch; throw new Error(`unexpected fetch: ${method} ${path}`); }); return state; @@ -301,7 +344,7 @@ describe("the wait right after connecting a provider", () => { if (navigated.length > 0) break; } - expect(navigated).toEqual(["/new"]); + expect(navigated).toEqual(["/w/chan_myra_dm"]); expect(state.statusCalls).toBeGreaterThan(0); }); @@ -343,7 +386,7 @@ describe("the wait right after connecting a provider", () => { if (navigated.length > 0) break; } - expect(navigated).toEqual(["/new"]); + expect(navigated).toEqual(["/w/chan_myra_dm"]); }); }); diff --git a/apps/web/test/sidebar.test.tsx b/apps/web/test/sidebar.test.tsx index 3b7be13b9..66eefa578 100644 --- a/apps/web/test/sidebar.test.tsx +++ b/apps/web/test/sidebar.test.tsx @@ -11,6 +11,7 @@ import { renderToStaticMarkup } from "react-dom/server"; import { BenchProvider } from "../src/bench-context"; import { APP_ROUTES, NAV_ROUTES } from "../src/routes"; import { Sidebar } from "../src/shell/sidebar"; +import { SIDEBAR_EMPTY_COPY } from "../src/shell/workbench-list"; import { TestQueryProvider } from "./test-query-provider"; const noop = () => undefined; @@ -539,7 +540,7 @@ describe("Sidebar", () => { }); }); - test("renders Agents then Channels, without mixing a newer room above DMs", async () => { + test("renders one mixed recency list, interleaving a newer room above an older DM", async () => { const channel = { id: "ch_room", title: "Launch plan", @@ -596,13 +597,18 @@ describe("Sidebar", () => { 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(agentsHeading).toBeNull(); + expect(channelsHeading).toBeNull(); + expect(container.querySelectorAll(".shell-panel-list-label")).toHaveLength( + 0, ); - expect(container.innerHTML.indexOf("Myra")).toBeLessThan( - container.innerHTML.indexOf("Launch plan"), + const wraps = [...container.querySelectorAll(".shell-ch-row-wrap")]; + expect(wraps.map((row) => row.getAttribute("data-ctx-workbench"))).toEqual([ + "ch_room", + "ch_dm", + ]); + expect(container.innerHTML.indexOf("Launch plan")).toBeLessThan( + container.innerHTML.indexOf("Myra"), ); expect(container.innerHTML).not.toContain("No workbenches yet"); @@ -636,20 +642,20 @@ describe("Sidebar", () => { await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); - if (container.innerHTML.includes("No agents yet")) break; + if (container.innerHTML.includes(SIDEBAR_EMPTY_COPY)) break; } - expect(container.innerHTML).toContain("No agents yet"); - expect(container.innerHTML).toContain("No channels yet"); + expect(container.innerHTML).toContain(SIDEBAR_EMPTY_COPY); + expect(container.innerHTML).not.toContain("No agents yet"); + expect(container.innerHTML).not.toContain("No channels yet"); expect(container.innerHTML).not.toContain("No workbenches yet"); act(() => root.unmount()); container.remove(); }); - // CL-6124: a bench with zero workbenches lands on the first-run chat - // (`/`), and the sidebar names it as a single active row — never the - // icon "No workbenches yet" empty state, since the create-a-workbench - // surface IS this screen now. - test("zero workbenches: a single New Workbench row, styled active, not an icon empty state", async () => { + // Zero opened conversations: the mixed list speaks honestly — never a + // fake New Workbench stub, and never the icon "No workbenches yet" + // empty state. Create still lives on the + control (`/new`). + test("zero workbenches: honest mixed-list empty copy, not a New Workbench stub", async () => { stubFetch(); const container = document.createElement("div"); document.body.appendChild(container); @@ -667,11 +673,11 @@ describe("Sidebar", () => { await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); - if (container.innerHTML.includes("shell-ch-row")) break; + if (container.innerHTML.includes(SIDEBAR_EMPTY_COPY)) break; } - const row = container.querySelector('.shell-ch-row[data-active="true"]'); - expect(row).not.toBeNull(); - expect(row?.textContent).toContain("New Workbench"); + expect(container.innerHTML).toContain(SIDEBAR_EMPTY_COPY); + expect(container.querySelector(".shell-ch-row")).toBeNull(); + expect(container.innerHTML).not.toContain("New Workbench"); expect(container.innerHTML).not.toContain("No workbenches yet"); act(() => root.unmount()); container.remove(); diff --git a/apps/web/test/workbench-list.test.tsx b/apps/web/test/workbench-list.test.tsx index 0f0d6c973..b53e778ab 100644 --- a/apps/web/test/workbench-list.test.tsx +++ b/apps/web/test/workbench-list.test.tsx @@ -4,7 +4,7 @@ import { createRoot } from "react-dom/client"; import type { Root } from "react-dom/client"; import { BenchProvider } from "../src/bench-context"; -import { WorkbenchList } from "../src/shell/workbench-list"; +import { WorkbenchList, SIDEBAR_EMPTY_COPY } from "../src/shell/workbench-list"; import { TestQueryProvider } from "./test-query-provider"; const realFetch = globalThis.fetch; @@ -101,19 +101,18 @@ async function mount(onNavigate: (to: string) => void = () => undefined) { await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); - // Prefer `shell-ch-row-wrap` over bare `shell-ch-row`: the minting stub - // ("New Workbench") uses the row class alone and paints before the - // parallel needs-you query settles — breaking on it races the chip. + // Prefer `shell-ch-row-wrap` over bare `shell-ch-row`: a loading + // skeleton can paint before the parallel needs-you query settles. if ( container.innerHTML.includes("shell-ch-row-wrap") || container.innerHTML.includes("waiting on you") ) { break; } - // Stub-only list: keep spinning until needs-you resolves (chip text) or - // enough ticks have passed for an empty needs-you to settle as null. + // Empty mixed list: keep spinning until needs-you resolves (chip text) + // or enough ticks have passed for an empty needs-you to settle as null. if ( - container.innerHTML.includes("New Workbench") && + container.innerHTML.includes(SIDEBAR_EMPTY_COPY) && !container.innerHTML.includes("shell-activity-skeleton") && i >= 15 ) { @@ -143,7 +142,7 @@ describe("WorkbenchList — needs-you signal", () => { }); describe("WorkbenchList — pin visibility and order (CL-6657)", () => { - test("pin glyph floats within Agents and within Channels, never across", async () => { + test("pin glyph floats across the mixed list, never within Agents/Channels sections", async () => { stubFetch({ chats: [ { @@ -160,7 +159,7 @@ describe("WorkbenchList — pin visibility and order (CL-6657)", () => { kind: "chat", pinned: true, participants: [], - lastActivityAt: "2026-08-01T00:00:00.000Z", + lastActivityAt: "2026-08-02T00:00:00.000Z", }, ], workbenches: [ @@ -170,7 +169,7 @@ describe("WorkbenchList — pin visibility and order (CL-6657)", () => { kind: "workbench", pinned: false, participants: [], - lastActivityAt: "2026-08-10T00:00:00.000Z", + lastActivityAt: "2026-08-11T00:00:00.000Z", }, { id: "ch_pinned_room", @@ -183,23 +182,21 @@ describe("WorkbenchList — pin visibility and order (CL-6657)", () => { ], }); const el = await mount(); - expect( - [...el.querySelectorAll(".shell-panel-list-label")].map( - (heading) => heading.textContent, - ), - ).toEqual(["Agents", "Channels"]); - expect(el.textContent).not.toContain("Workbenches"); + expect(el.querySelectorAll(".shell-panel-list-label")).toHaveLength(0); + expect(el.textContent).not.toContain("Agents"); + expect(el.textContent).not.toContain("Channels"); const wraps = [...el.querySelectorAll(".shell-ch-row-wrap")]; expect(wraps.map((row) => row.getAttribute("data-ctx-workbench"))).toEqual([ "ch_pinned_dm", - "ch_recent_dm", "ch_pinned_room", "ch_recent_room", + "ch_recent_dm", ]); expect(wraps[0]?.querySelector(".shell-ch-pin")).not.toBeNull(); expect(wraps[0]?.getAttribute("data-ctx-workbench-pinned")).toBe("true"); - expect(wraps[1]?.querySelector(".shell-ch-pin")).toBeNull(); - expect(wraps[2]?.querySelector(".shell-ch-pin")).not.toBeNull(); + expect(wraps[1]?.querySelector(".shell-ch-pin")).not.toBeNull(); + expect(wraps[1]?.getAttribute("data-ctx-workbench-pinned")).toBe("true"); + expect(wraps[2]?.querySelector(".shell-ch-pin")).toBeNull(); expect(wraps[3]?.querySelector(".shell-ch-pin")).toBeNull(); }); }); diff --git a/docs/GLOSSARY.md b/docs/GLOSSARY.md index bf711bc34..a9b780e5e 100644 --- a/docs/GLOSSARY.md +++ b/docs/GLOSSARY.md @@ -13,9 +13,9 @@ lower-level name left to list. | ---------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Bench** | tenant | A shared space where a team and its agents work — members, definitions, runs, and grants live here | | **User** | principal | An identity that can act in a bench — human or agent | -| **Agent** | principal (agent) | A named coworker identity, listed under Agents; opening the row reopens that agent's one DM | +| **Agent** | principal (agent) | A named coworker principal, not a template; opening the row reopens that agent's one DM. The sidebar mixes that DM with channels in one recency list (pins first) | | **DM** | kind: chat | The one 1:1 tenant with that agent — two opens never clone a second DM | -| **Channel** | kind: workbench | A multi-principal tenant. `+` mints an empty one with nobody hosted; named templates mint the same empty channel, then invite existing principals | +| **Channel** | kind: workbench | A shared room between people and agents (multi-principal tenant). `+` mints an empty one with nobody hosted; named templates mint the same empty channel, then invite existing principals | | **Definition** | workflow definition | A deployable unit of agent behavior, authored as code — not a template you mint into many conversations | | **Run** | workflow run | A definition executing in a bench; interactive runs carry conversations | | **Routine** | — | The named parent entity over runs of one definition — a trigger (or none), a delivery workbench, and its run history; see [`@corbits/routines`](../packages/routines/README.md) | @@ -24,7 +24,7 @@ lower-level name left to list. | **Hub** | hub | The API and coordination service a bench lives on | | **Sidecar** | sidecar | The execution host that runs definitions on behalf of a hub | | **Extension** | — | A route factory mounted on the hub to add product surface | -| **Workbenches** | — | The product noun for conversation tenants. The sidebar lists them as Agents (DMs) then Channels (rooms), not one recency list | +| **Workbenches** | — | The product name and the mint verb ("New workbench"), not a sidebar heading. Conversation tenants (agent DMs and channels) share one recency list, pins first — not two labeled sections | | **Workbench** | — | The one conversation surface: an agent conversation (named by its agent) or a multi-party conversation (named by its own title) — durable hub-side data with no run of its own; also its own tenant, parented under the bench it was created in, so its membership and grants are its own — see [CHAT.md](CHAT.md) and [workbench-tenancy.md](workbench-tenancy.md) | | **Timeline** | — | A workbench's own message rows, read back in order, as the conversation record | | **Participant** | — | An address (human or agent) a workbench's settings list as able to post or be mentioned | diff --git a/packages/chat/src/workbench-service.ts b/packages/chat/src/workbench-service.ts index 02bf4f1d5..859d311b1 100644 --- a/packages/chat/src/workbench-service.ts +++ b/packages/chat/src/workbench-service.ts @@ -10,7 +10,6 @@ import { generateId } from "@intx/hub-common"; import { getLogger } from "@intx/log"; import { reportError } from "@corbits/error-sink"; import { InferenceResolutionError } from "@corbits/folded-runs"; -import { workbenchTemplate } from "@corbits/workflow-catalog"; import { encodeParts } from "./codec"; import type { Part as PartType } from "./parts"; import { localPartOf } from "./agent-address"; @@ -410,37 +409,12 @@ export type PostCannedGreetingInput = CannedGreetingInput & { }; /** - * The two template ids a blank room's opener may name. Each one's - * participants fully resolve through `instantiateWorkbenchTemplate`'s - * agent-request ports today (the code reviewers and Scout both have a - * real `createParticipantAgent` request), so offering them is a promise - * the picker's "Create workbench" flow can actually keep. `gtm` stays - * out: its crm/collateral participants have no such resolver yet, so - * naming it would offer something that can't be created. + * Next steps a blank room's opener may name: more agents, routines, or + * a shared channel. Catalog templates stay out of the first bubble — + * first-run speech is a coworker intro, not a picker recap. */ -const BLANK_ROOM_OFFER_TEMPLATE_IDS = ["code-review", "due-diligence"] as const; - -/** The titles to name in a blank room's opener, read live off - * `@corbits/workflow-catalog` every time — never a copy of the title - * text, so a rename in the catalog shows up here with no edit. */ -function blankRoomOfferTitles(): readonly string[] { - return BLANK_ROOM_OFFER_TEMPLATE_IDS.map( - (id) => workbenchTemplate(id)?.title, - ).filter((title): title is string => title !== undefined); -} - -/** One clause naming what's ready to go, or "" once neither offer id - * resolves (a catalog shipping neither template) — never a bare - * "there's a" with nothing after it. Exactly two ids are ever offered - * today (see `BLANK_ROOM_OFFER_TEMPLATE_IDS`), so this only has to - * handle naming one or both. */ -function templateOfferClause(): string { - const [first, second] = blankRoomOfferTitles(); - if (first === undefined) return ""; - if (second === undefined) { - return ` There's a ${first} setup ready to go, if that fits.`; - } - return ` There's a ${first} setup and a ${second} one ready to go, if either fits.`; +function nextStepsOfferClause(): string { + return " We can create more agents, set up routines, or open a shared channel."; } /** @@ -449,32 +423,30 @@ function templateOfferClause(): string { * chat used to stay silent through a whole kickoff inference turn, and * a person who typed into that silence wrong-footed the conversation. * Each takes the leading address (" Alice" or ""), the agent's display - * name, and the template-offer clause (see `templateOfferClause`), + * name, and the next-steps offer (see `nextStepsOfferClause`), * inserted just before the closing question; none may mention the * workbench's title (a label the opener picked, never a request). */ const GREETING_VARIATIONS: readonly (( who: string, agent: string, - templateOffer: string, + nextStepsOffer: string, ) => string)[] = [ - (who, agent, templateOffer) => + (who, agent, nextStepsOffer) => `Hey${who} — good to have a space to work in together. I'm ${agent}, ` + - "your teammate here; I can write, plan, pull pieces together, and " + - `line up the specialists and automations when we need them.${templateOffer} ` + - "What are you working on?", - (who, agent, templateOffer) => + "your teammate here; I can write, plan, and pull pieces together." + + `${nextStepsOffer} What are you working on?`, + (who, agent, nextStepsOffer) => `Hi${who}, I'm ${agent} — your teammate here. Drafting, planning, ` + - `research, lining up automations: all fair game.${templateOffer} What ` + - "should we dig into first?", - (who, agent, templateOffer) => + `research: all fair game.${nextStepsOffer} What should we dig into ` + + "first?", + (who, agent, nextStepsOffer) => `Welcome in${who === "" ? "" : `,${who}`}. I'm ${agent}; think of me ` + - "as the teammate who writes, plans, and pulls in the right " + - `specialists when a job calls for them.${templateOffer} What's on ` + - "your plate?", - (who, agent, templateOffer) => + "as the teammate who writes, plans, and pulls in the right people." + + `${nextStepsOffer} What's on your plate?`, + (who, agent, nextStepsOffer) => `Hey${who} — ${agent} here. This space is ours to work in: I can ` + - `draft, plan, and wire things up as we go.${templateOffer} What are ` + + `draft, plan, and wire things up as we go.${nextStepsOffer} What are ` + "you working on?", ]; @@ -513,7 +485,7 @@ export function cannedGreeting(input: CannedGreetingInput): string { const variation = GREETING_VARIATIONS[greetingVariationIndex(input.workbenchId)]; if (variation === undefined) throw new Error("no greeting variations"); - return variation(who, input.agentName, templateOfferClause()); + return variation(who, input.agentName, nextStepsOfferClause()); } /** diff --git a/packages/chat/test/workbench-service.test.ts b/packages/chat/test/workbench-service.test.ts index 0cdc6d5dd..79dadfa39 100644 --- a/packages/chat/test/workbench-service.test.ts +++ b/packages/chat/test/workbench-service.test.ts @@ -9,7 +9,6 @@ import { decodeParts } from "../src/codec"; import type { Part } from "../src/parts"; import { createInMemoryWorkbenchTenancyStore } from "../src/workbench-tenancy"; import { AgentUnreachableError } from "../src/platform-port"; -import { workbenchTemplate } from "@corbits/workflow-catalog"; import { cannedGreeting, postCannedGreeting } from "../src/workbench-service"; import { buildDeps, @@ -78,26 +77,27 @@ describe("postCannedGreeting (CL-6126)", () => { expect(greeting).not.toContain("undefined"); }); - test("a blank room's greeting names the templates read live off the catalog, never a frozen string", () => { - const codeReviewTitle = workbenchTemplate("code-review")?.title; - const dueDiligenceTitle = workbenchTemplate("due-diligence")?.title; - if (codeReviewTitle === undefined || dueDiligenceTitle === undefined) { - throw new Error("expected both offer templates to carry a title"); - } - + test("a blank room's greeting offers agents, routines, and a shared channel, not catalog templates", () => { for (const workbenchId of ["chan_0", "chan_1", "chan_2", "chan_3"]) { const greeting = cannedGreeting({ workbenchId, agentName: "Myra" }); - expect(greeting).toContain(codeReviewTitle); - expect(greeting).toContain(dueDiligenceTitle); + expect(greeting).toMatch(/create more agents/i); + expect(greeting).toMatch(/routines/i); + expect(greeting).toMatch(/shared channel/i); + expect(greeting).not.toMatch(/code[- ]review/i); + expect(greeting).not.toMatch(/due[- ]diligence/i); + expect(greeting).not.toMatch(/what do you want your Workbench to do/i); } }); - test("a blank room's greeting points at the templates rather than claiming to build one", () => { + test("a blank room's greeting talks like a teammate offering next steps", () => { const greeting = cannedGreeting({ workbenchId: "chan_1", agentName: "Myra", }); - expect(greeting).not.toMatch(/I('ll| will) (set up|spin up|create|build)/i); + expect(greeting).toMatch(/teammate|together|ours to work in/i); + expect(greeting).toMatch(/create more agents/i); + expect(greeting).toMatch(/set up routines/i); + expect(greeting).toMatch(/open a shared channel/i); }); test("the same chat always gets the same variation", () => { diff --git a/scripts/e2e/browser/walkthrough.ts b/scripts/e2e/browser/walkthrough.ts index 2fd45a2ff..06ea5e648 100644 --- a/scripts/e2e/browser/walkthrough.ts +++ b/scripts/e2e/browser/walkthrough.ts @@ -288,16 +288,15 @@ async function countMatching(page: Page, selector: string): Promise { // --- the walkthrough ----------------------------------------------------- -/** The picker row that mints a plain Myra room (`workbench-templates.ts`). */ +/** The picker card that mints a plain empty channel (`workbench-templates.ts`). */ const BLANK_TEMPLATE_TITLE = "Just start talking"; /** * Drives the one creation flow a person actually walks: the sidebar's "+" - * opens the new-workbench picker (CL-6342 — "+" no longer mints on the - * spot), a kind is chosen, and "Create workbench" mints it and navigates - * in. Waits for the URL to actually land on a fresh `/w/:id` distinct from - * wherever the click started, rather than assuming a fixed delay covers - * the mint + navigate round trip. + * opens `/new` (prompt-primary picker). Clicking a prefab card mints + * immediately — no kind radiogroup, no second "Create workbench" step. + * Waits for the URL to land on a fresh `/w/:id` distinct from wherever + * the click started. */ async function createMyraChat(page: Page): Promise { const before = await page.evaluate(() => window.location.pathname); @@ -308,15 +307,14 @@ async function createMyraChat(page: Page): Promise { let landed = false; for (let attempt = 0; attempt < 3 && !landed; attempt += 1) { await clickStable(page, 'button[aria-label="New workbench"]'); - await page.waitForSelector( - '[role="radiogroup"][aria-label="Workbench kind"]', - { - timeout: 15_000, - }, - ); + await page.waitForSelector(".new-workbench-prefab-grid", { + timeout: 15_000, + }); const picked = await page.evaluate((title: string) => { const rows = Array.from( - document.querySelectorAll('[role="radio"]'), + document.querySelectorAll( + "button.new-workbench-prefab-card", + ), ); const row = rows.find((candidate) => (candidate.textContent ?? "").includes(title), @@ -327,10 +325,9 @@ async function createMyraChat(page: Page): Promise { }, BLANK_TEMPLATE_TITLE); if (!picked) { throw new Error( - `the new-workbench picker offered no "${BLANK_TEMPLATE_TITLE}" row`, + `the new-workbench picker offered no "${BLANK_TEMPLATE_TITLE}" card`, ); } - await clickStable(page, ".new-workbench-picker-foot button"); landed = await page .waitForFunction( (previous: string) => { @@ -610,22 +607,21 @@ async function run(): Promise { }, ); - // --- Step 2: CL-6138 — the one creation verb. A brand-new account's - // bare root auto-mints its first Myra workbench and lands straight in - // it — no describe screen, no separate first-run form. The account's - // very first workbench comes from the exact same - // `instant-agent-create.ts` mint every later "+ New workbench" click - // uses (proven again below in 05-second-create-mints-new-workbench). + // --- Step 2: CL-7053 — `/` opens Myra's DM (kind chat, titled Myra). + // A brand-new account's bare root lands in that DM — not an auto-minted + // empty workbench, not `/new`, no describe screen. Plus/picker still + // mints empty "New Workbench" channels (step 05). Keep waiting for `/w/` + // plus the composer; only the comments and later row titles change. let firstWorkbenchPath = ""; await step( () => page, "04-bare-root-lands-in-myra-conversation", async () => { // `testAndPersistCredential`/`ensureSeeded` above deployed the - // default workflows but never created a single chat workbench — - // this account has zero workbenches at this point, so `/` (bare - // root, `HomeRoute`) must auto-mint the first one and land in it - // rather than stranding the account on a spinner. + // default workflows but never opened Myra's DM — this account has + // zero conversations at this point, so `/` (bare root, `HomeRoute`) + // must land in Myra's DM rather than stranding the account on a + // spinner or bouncing to `/new`. await page.goto(webBaseUrl, { waitUntil: "domcontentloaded" }); await page.waitForFunction( () => window.location.pathname.startsWith("/w/"), @@ -639,7 +635,7 @@ async function run(): Promise { }); return { status: "pass", - detail: `bare root with zero workbenches auto-minted Myra's workbench and landed at ${firstWorkbenchPath}`, + detail: `bare root landed in Myra's DM at ${firstWorkbenchPath}`, }; }, ); @@ -653,33 +649,26 @@ 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 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 · labels · rows, - // no separate title bar) — `.shell-panel-list-label` is Agents, - // then Channels (CL-6977). - let sidebarTitles: string[] = []; + // sidebar's "+" renders unconditionally, but the conversation list + // only replaces the 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 rail is one mixed list + // of agent DMs and channels (CL-7053) — do not require labeled + // "Agents" / "Channels" headings. Presence of Myra's row is the + // mixed-list signal. + let myraRows = 0; for (let attempt = 0; attempt < 15; attempt += 1) { - sidebarTitles = await page.evaluate(() => - [...document.querySelectorAll(".shell-panel-list-label")].map( - (el) => el.textContent?.trim() ?? "", - ), + myraRows = await countMatching( + page, + '.shell-ch-row-wrap[data-ctx-workbench-title="Myra"]', ); - if ( - sidebarTitles[0] === "Agents" && - sidebarTitles[1] === "Channels" - ) { - break; - } + if (myraRows >= 1) break; await new Promise((resolve) => setTimeout(resolve, 500)); } - if (sidebarTitles[0] !== "Agents" || sidebarTitles[1] !== "Channels") { + if (myraRows < 1) { return { status: "fail", - detail: `expected sidebar titles ["Agents","Channels"], got ${JSON.stringify(sidebarTitles)}`, + detail: `expected a mixed conversation list with a Myra row, found ${myraRows}`, }; } const createButtons = await countMatching( @@ -695,16 +684,15 @@ async function run(): Promise { return { status: "pass", detail: - 'single always-visible sidebar: titled Agents then Channels, one "+ New workbench" affordance', + 'mixed conversation list includes Myra; one "+ New workbench" affordance', }; }, ); - // --- Step 3: CL-6342 — the sidebar's "+" opens the new-workbench - // picker, and a workbench is minted only once a kind is chosen and - // "Create workbench" is pressed. The create must land somewhere NEW, - // distinct from the auto-minted first one; only the initial land-hop - // ever reopens an existing conversation. + // --- Step 3: CL-6342 — the sidebar's "+" opens `/new`. Clicking + // "Just start talking" mints an empty channel immediately (no kind + // radio, no Create step). The mint must land somewhere NEW, distinct + // from Myra's DM. await step( () => page, "05-second-create-mints-new-workbench", @@ -732,40 +720,50 @@ async function run(): Promise { () => page, "06-sidebar-lists-each-minted-workbench", async () => { - // The always-visible sidebar already lists every workbench — the - // rows are just there, no navigation or priming needed. Two rows - // titled "New Workbench" are expected here: the bare-root - // auto-mint (04) and the "+" mint (05) — `instant-agent-create.ts` - // titles every mint "New Workbench" regardless of which template - // backs it (CL-6089/CL-6138), so this is the one creation verb's - // own name, not the agent's. The list is a cache invalidated by - // the create event, so give the refetch a bounded moment to land - // before judging. + // The always-visible sidebar already lists every conversation — + // the rows are just there, no navigation or priming needed. First + // land is Myra's DM (titled Myra). The picker mint (05) is the one + // empty "New Workbench" channel — plus does not clone the DM + // (CL-7053). The list is a cache invalidated by the create event, + // so give the refetch a bounded moment to land before judging. await page.waitForSelector(".shell-ch-row-wrap", { timeout: 15_000 }); let mintedRows = 0; + let myraRows = 0; for (let attempt = 0; attempt < 10; attempt += 1) { mintedRows = await countMatching( page, '.shell-ch-row-wrap[data-ctx-workbench-title="New Workbench"]', ); - if (mintedRows === 2) break; + myraRows = await countMatching( + page, + '.shell-ch-row-wrap[data-ctx-workbench-title="Myra"]', + ); + if (mintedRows === 1 && myraRows >= 1) break; await new Promise((resolve) => setTimeout(resolve, 800)); } - if (mintedRows !== 2) { + if (myraRows < 1) { + return { + status: "fail", + detail: `expected Myra's DM row still in the mixed list, found ${myraRows}`, + }; + } + if (mintedRows !== 1) { return { status: "fail", - detail: `expected 2 "New Workbench" sidebar rows (the two mints), found ${mintedRows}`, + detail: `expected 1 "New Workbench" sidebar row (the picker mint), found ${mintedRows}`, }; } return { status: "pass", detail: - 'sidebar lists 2 "New Workbench" rows — both mints, one row per conversation', + 'sidebar lists Myra plus 1 "New Workbench" row from the picker mint', }; }, ); - // --- Step 4: send "hi" in the Myra chat, expect *some* reply bubble + // --- Step 4: send "hi" in the picker-minted New Workbench, expect + // *some* reply bubble. Click that row by title — first land is Myra, + // not another "New Workbench". await step( () => page, "07-send-hi-to-myra", @@ -968,6 +966,8 @@ async function run(): Promise { waitUntil: "domcontentloaded", timeout: 20_000, }); + // Reopen the picker-minted "New Workbench" channel, not Myra's DM + // (first land is titled Myra). const rowAppeared = await page .waitForSelector( '.shell-ch-row-wrap[data-ctx-workbench-title="New Workbench"]', @@ -981,7 +981,7 @@ async function run(): Promise { return { status: "repro-confirmed", detail: - "after restart, the reloaded shell never regained a Myra sidebar row within 15s " + + "after restart, the reloaded shell never regained the picker-minted New Workbench row within 15s " + "(stuck disconnected) — CL-6067 reproduced", }; } diff --git a/workflows/assistant/src/index.ts b/workflows/assistant/src/index.ts index 6ecbfce17..b683eb6d6 100644 --- a/workflows/assistant/src/index.ts +++ b/workflows/assistant/src/index.ts @@ -60,9 +60,8 @@ export const ASSISTANT_TOOL_PACKAGE_PINS: readonly ToolPackagePin[] = [ */ const ASSISTANT_WELCOME_CLAUSE = "When you introduce yourself, say plainly, once, what you can " + - "actually do here: stand up a specialist agent and invite it in, " + - "create and manage this workbench's routines, or save something " + - "worth remembering as a skill or in firm memory — an offer, not a " + + "actually do here: create more agents, set up routines, or open a " + + "shared channel — an offer, not a " + "checklist to read off, " + "and never a reason to withhold help until asked whether you're " + "allowed to.";