diff --git a/DESIGN.md b/DESIGN.md index 594ac2fc5..b7a43e1dc 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -16,10 +16,12 @@ Top to bottom: 1. **Brand row** — logo mark and a "New workbench" button (`+`) that opens `/new`. -2. **Bench list** — the "Workbenches" label, then rows of workbench - conversations, with search built into the list itself. Nothing - page-scoped ever renders in this body; it lists conversations, not - product sections. +2. **Bench list** — two labels, Agents then Channels, with search built + into the list itself. Agent conversations and unopened conversational + definitions sit under Agents; rooms sit under Channels. Pinning is + local to each section. One search filters both without dropping either + label. 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 @@ -35,13 +37,13 @@ Top to bottom: direct one-click control to Settings, not a menu item — Settings never cost two clicks to reach. -A workbench is an agent conversation, and the bench list IS the switcher — -its rows are the primary way to move between workbenches, with no separate -"switcher" control layered on top. The command palette's hidden "Switch -workbench" action is a second door onto the same list, reachable by search -rather than by scanning rows; it does not replace the sidebar as the -switching mechanism. Approvals render inside the conversation, never as a -standing band in the shell. +A workbench is an agent conversation or a room, and the bench list IS the +switcher — its Agents and Channels rows are the primary way to move between +them, with no separate "switcher" control layered on top. The command +palette's hidden "Switch workbench" action is a second door onto the same +conversations, reachable by search rather than by scanning rows; it does +not replace the sidebar as the switching mechanism. Approvals render +inside the conversation, never as a standing band in the shell. ## Pages & Routing diff --git a/PRODUCT.md b/PRODUCT.md index cfefc00e2..acbc50abf 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -14,9 +14,10 @@ conversation is also its own tenant, so its membership and grants are its own. 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 one list of workbenches, not sections split by -kind — every workbench a person has, agent conversation or group -conversation alike, shows up the same way. See +This is why the sidebar lists workbenches under two labeled sections — +**Agents** (agent conversations) and **Channels** (rooms) — rather than +one undifferentiated list. Every row is still a workbench; the sections +are how a person finds the right one. See [docs/GLOSSARY.md](docs/GLOSSARY.md) for the full term mapping and [docs/CHAT.md](docs/CHAT.md) for how a conversation is built underneath. @@ -32,10 +33,10 @@ 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 workbenches** lists every conversation in the selected - bench, flat, most-recently-active first. There is no separate "channels" - vs. "chats" grouping the sidebar exposes to a person — every row is a - workbench. +- **A sidebar of Agents and Channels** lists every conversation in the + selected bench under those two labeled sections, most-recently-active + first within each. Pinning stays inside a section; one search filters + both without dropping either label. Every row is a workbench. - **"+ New Workbench" always creates.** It opens `/new` — the shipped prompt-primary picker — never a picker of existing things to join. Starting a new workbench is the one way in, whether the result is a diff --git a/apps/web/src/routes.tsx b/apps/web/src/routes.tsx index 37678acd0..447fdcf4e 100644 --- a/apps/web/src/routes.tsx +++ b/apps/web/src/routes.tsx @@ -274,7 +274,7 @@ export const APP_ROUTES: readonly AppRoute[] = [ }, { path: WORKBENCH_PATH_PREFIX, - label: "Workbenches", + label: "Workbench", icon: , render: (path: string, navigate: (to: string) => void) => ( diff --git a/apps/web/src/shell/bench-activity.ts b/apps/web/src/shell/bench-activity.ts index e25956a14..f5d58c51e 100644 --- a/apps/web/src/shell/bench-activity.ts +++ b/apps/web/src/shell/bench-activity.ts @@ -7,7 +7,7 @@ // query with nowhere to point. // // `useBenchActivity` may be mounted more than once per navigation (the -// sidebar's `WorkbenchList` in `./workbench-list.tsx`), so every listing +// sidebar's `SidebarSections` in `./workbench-list.tsx`), so every listing // below goes through `useQuery` keyed with the shared `tenantKeys` // factories — both mounts subscribe to the same cached queries instead of // each firing its own fetch. This uses `useQuery` directly rather than the diff --git a/apps/web/src/shell/sidebar-rows.test.ts b/apps/web/src/shell/sidebar-rows.test.ts deleted file mode 100644 index 474c65979..000000000 --- a/apps/web/src/shell/sidebar-rows.test.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import type { Workbench } from "@corbits/chat-ui"; - -import { buildSidebarRows } from "./sidebar-rows"; - -function workbench(overrides: Partial = {}): Workbench { - return { - id: "ch_1", - title: "General", - kind: "workbench", - pinned: false, - participants: [], - ...overrides, - } as Workbench; -} - -describe("buildSidebarRows", () => { - test("mixes workbenches and conversational DMs into one recency-sorted stream", () => { - const older = workbench({ - id: "ch_old", - lastActivityAt: "2026-01-01T00:00:00.000Z", - }); - const newer = workbench({ - id: "ch_new", - kind: "chat", - lastActivityAt: "2026-01-03T00:00:00.000Z", - }); - - const rows = buildSidebarRows([older], [newer]); - - expect(rows.map((row) => row.workbench.id)).toEqual(["ch_new", "ch_old"]); - expect(rows.every((row) => row.kind === "workbench")).toBe(true); - }); - - test("never synthesizes a row for an agent that has not been opened as a DM", () => { - const older = workbench({ - id: "ch_old", - lastActivityAt: "2026-01-01T00:00:00.000Z", - }); - - const rows = buildSidebarRows([older], []); - - expect(rows).toEqual([{ kind: "workbench", workbench: older }]); - }); - - test("pinned workbench rows float above every unpinned row regardless of recency", () => { - const pinned = workbench({ - id: "ch_pinned", - pinned: true, - lastActivityAt: "2026-01-01T00:00:00.000Z", - }); - const recent = workbench({ - id: "ch_recent", - lastActivityAt: "2026-01-05T00:00:00.000Z", - }); - - const rows = buildSidebarRows([pinned, recent], []); - - expect(rows[0]).toEqual({ kind: "workbench", workbench: pinned }); - expect(rows[1]).toEqual({ kind: "workbench", workbench: recent }); - }); - - test("an agent already opened as a DM appears once, as its workbench row", () => { - const dm = workbench({ - id: "ch_dm", - kind: "chat", - definitionId: "wfd_outreach", - lastActivityAt: "2026-01-01T00:00:00.000Z", - }); - - const rows = buildSidebarRows([], [dm]); - - expect(rows).toEqual([{ kind: "workbench", workbench: dm }]); - }); - - test("every created workbench keeps its row even when each minted its own definition (CL-6621)", () => { - // Creation now clones a fresh definition per workbench (CL-6452), so - // sibling rows for the same agent legitimately carry distinct - // definitionIds. The old CL-6271 collapse keyed on exactly that and - // hid every workbench but the newest — a person creating a second - // workbench watched the first vanish. - const created = ["ch_new_1", "ch_new_2", "ch_new_3"].map((id, index) => - workbench({ - id, - kind: "chat", - title: "New Workbench", - definitionId: `wfd_myra_${id}`, - participants: [{ address: "myra@acme.localhost", handle: "myra" }], - lastActivityAt: `2026-01-0${index + 1}T00:00:00.000Z`, - }), - ); - - const rows = buildSidebarRows([], created); - - expect(rows.map((row) => row.workbench.id)).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)", () => { - const researchAnalystHyphen = workbench({ - id: "ch_research_analyst_hyphen", - kind: "chat", - title: "Research Analyst", - definitionId: "wfd_research_analyst_hyphen", - participants: [ - { - address: "research-analyst@acme.localhost", - handle: "research-analyst", - }, - ], - lastActivityAt: "2026-01-01T00:00:00.000Z", - }); - const researchAnalystUnderscore = workbench({ - id: "ch_research_analyst_underscore", - kind: "chat", - title: "Research Analyst", - definitionId: "wfd_research_analyst_underscore", - participants: [ - { - address: "research_analyst@acme.localhost", - handle: "research_analyst", - }, - ], - lastActivityAt: "2026-01-02T00:00:00.000Z", - }); - - const rows = buildSidebarRows( - [], - [researchAnalystHyphen, researchAnalystUnderscore], - ); - - expect(rows.map((row) => row.workbench.id).sort()).toEqual([ - "ch_research_analyst_hyphen", - "ch_research_analyst_underscore", - ]); - }); - - test("same-titled DMs both stay: a title is not identity (CL-6621)", () => { - const olderDm = workbench({ - id: "ch_legacy_ancestor", - kind: "chat", - title: "Assist", - definitionId: "wfd_legacy_ancestor", - lastActivityAt: "2026-01-01T00:00:00.000Z", - }); - const newerDm = workbench({ - id: "ch_legacy_leaf", - kind: "chat", - title: "Assist", - definitionId: "wfd_legacy_leaf", - lastActivityAt: "2026-01-05T00:00:00.000Z", - }); - - const rows = buildSidebarRows([], [olderDm, newerDm]); - - expect(rows.map((row) => row.workbench.id)).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 rows = buildSidebarRows([], [groupOne, groupTwo]); - - expect(rows.map((row) => row.workbench.id)).toEqual([ - "ch_group_1", - "ch_group_2", - ]); - }); -}); diff --git a/apps/web/src/shell/sidebar-rows.ts b/apps/web/src/shell/sidebar-rows.ts deleted file mode 100644 index 91cd9d4ad..000000000 --- a/apps/web/src/shell/sidebar-rows.ts +++ /dev/null @@ -1,53 +0,0 @@ -// The sidebar's recency-sorted stream: workbenches and conversational -// DMs (already persisted as `Workbench` rows). 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"; - -export type SidebarRow = { - readonly kind: "workbench"; - readonly workbench: Workbench; -}; - -function activityOf(chat: Workbench): number { - return chat.lastActivityAt ? Date.parse(chat.lastActivityAt) : 0; -} - -function recencyOf(row: SidebarRow): number { - return activityOf(row.workbench); -} - -function isPinned(row: SidebarRow): boolean { - return row.workbench.pinned; -} - -/** - * 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. - */ -export function buildSidebarRows( - workbenches: readonly Workbench[], - chats: 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 - // definitionId to hide stale cross-tenant DM siblings; once creation - // began cloning a fresh definition per workbench (CL-6452), that - // heuristic could no longer tell a stale sibling from a deliberately - // created workbench and hid every workbench but the newest (CL-6621). - // 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( - (workbench) => ({ kind: "workbench", workbench }) as const, - ); - const byRecency = (a: SidebarRow, b: SidebarRow) => - recencyOf(b) - recencyOf(a); - return [ - ...rows.filter(isPinned).sort(byRecency), - ...rows.filter((row) => !isPinned(row)).sort(byRecency), - ]; -} diff --git a/apps/web/src/shell/sidebar-section-rows.test.ts b/apps/web/src/shell/sidebar-section-rows.test.ts new file mode 100644 index 000000000..1e04bd89f --- /dev/null +++ b/apps/web/src/shell/sidebar-section-rows.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, test } from "bun:test"; + +import type { VisibleAgentDefinition, Workbench } from "@corbits/chat-ui"; + +import { buildSidebarSections } from "./sidebar-section-rows"; + +function workbench(overrides: Partial = {}): Workbench { + return { + id: "ch_1", + title: "General", + kind: "workbench", + pinned: false, + participants: [], + ...overrides, + } as Workbench; +} + +function definition( + overrides: Partial = {}, +): VisibleAgentDefinition { + return { + id: "wfd_myra", + name: "Myra", + tenantId: "tnt_1", + tenantName: "Acme", + createdAt: "2026-01-02T00:00:00.000Z", + ...overrides, + }; +} + +describe("buildSidebarSections", () => { + test("projects agent chats and rooms into separate sections", () => { + const agentChat = workbench({ + id: "ch_agent", + kind: "chat", + definitionId: "wfd_myra", + }); + const room = workbench({ id: "ch_room" }); + + const sections = buildSidebarSections([room], [agentChat], []); + + expect(sections.agents).toEqual([ + { kind: "persisted", workbench: agentChat }, + ]); + expect(sections.channels).toEqual([room]); + }); + + test("adds unopened visible definitions but not a standing row for an exact persisted definition", () => { + const opened = definition({ id: "wfd_opened", name: "Opened" }); + const unopened = definition({ id: "wfd_unopened", name: "Unopened" }); + const chat = workbench({ + id: "ch_opened", + kind: "chat", + definitionId: opened.id, + lastActivityAt: "2026-01-03T00:00:00.000Z", + }); + + const sections = buildSidebarSections([], [chat], [opened, unopened]); + + expect(sections.agents).toEqual([ + { kind: "persisted", workbench: chat }, + { kind: "definition", definition: unopened }, + ]); + }); + + test("excludes human DMs from Agents", () => { + const humanDm = workbench({ + id: "ch_human", + kind: "chat", + }); + + expect(buildSidebarSections([], [humanDm], []).agents).toEqual([]); + }); + + test("floats pinned persisted rows only inside their own section", () => { + const oldPinnedAgent = workbench({ + id: "ch_agent_pinned", + kind: "chat", + definitionId: "wfd_pinned", + pinned: true, + lastActivityAt: "2026-01-01T00:00:00.000Z", + }); + const recentAgent = workbench({ + id: "ch_agent_recent", + kind: "chat", + definitionId: "wfd_recent", + lastActivityAt: "2026-01-05T00:00:00.000Z", + }); + const oldPinnedRoom = workbench({ + id: "ch_room_pinned", + pinned: true, + lastActivityAt: "2026-01-01T00:00:00.000Z", + }); + const recentRoom = workbench({ + id: "ch_room_recent", + lastActivityAt: "2026-01-05T00:00:00.000Z", + }); + + const sections = buildSidebarSections( + [recentRoom, oldPinnedRoom], + [recentAgent, oldPinnedAgent], + [], + ); + + expect( + sections.agents.map((row) => + row.kind === "persisted" ? row.workbench.id : row.definition.id, + ), + ).toEqual(["ch_agent_pinned", "ch_agent_recent"]); + expect(sections.channels.map((row) => row.id)).toEqual([ + "ch_room_pinned", + "ch_room_recent", + ]); + }); + + test("never collapses persisted chats by title or by a different definition id", () => { + const chats = [ + workbench({ + id: "ch_one", + kind: "chat", + title: "Assistant", + definitionId: "wfd_one", + }), + workbench({ + id: "ch_two", + kind: "chat", + title: "Assistant", + definitionId: "wfd_two", + }), + ]; + + const sections = buildSidebarSections([], chats, [ + definition({ id: "wfd_three", name: "Assistant" }), + ]); + + expect(sections.agents).toHaveLength(3); + }); +}); diff --git a/apps/web/src/shell/sidebar-section-rows.ts b/apps/web/src/shell/sidebar-section-rows.ts new file mode 100644 index 000000000..b4d3ee3d5 --- /dev/null +++ b/apps/web/src/shell/sidebar-section-rows.ts @@ -0,0 +1,63 @@ +import type { VisibleAgentDefinition, Workbench } from "@corbits/chat-ui"; + +export type AgentSidebarRow = + | { readonly kind: "persisted"; readonly workbench: Workbench } + | { + readonly kind: "definition"; + readonly definition: VisibleAgentDefinition; + }; + +export type SidebarSections = { + readonly agents: readonly AgentSidebarRow[]; + readonly channels: readonly Workbench[]; +}; + +function activityOf(workbench: Workbench): number { + return workbench.lastActivityAt ? Date.parse(workbench.lastActivityAt) : 0; +} + +function orderPersistedRows(rows: readonly Workbench[]): readonly Workbench[] { + const byRecency = (a: Workbench, b: Workbench) => + activityOf(b) - activityOf(a); + return [ + ...rows.filter((row) => row.pinned).sort(byRecency), + ...rows.filter((row) => !row.pinned).sort(byRecency), + ]; +} + +function agentRecency(row: AgentSidebarRow): number { + return row.kind === "persisted" + ? activityOf(row.workbench) + : Date.parse(row.definition.createdAt); +} + +export function buildSidebarSections( + workbenches: readonly Workbench[], + chats: readonly Workbench[], + definitions: readonly VisibleAgentDefinition[], +): SidebarSections { + const agentChats = chats.filter( + (chat): chat is Workbench & { readonly definitionId: string } => + chat.definitionId !== undefined, + ); + const openedDefinitionIds = new Set( + agentChats.map((chat) => chat.definitionId), + ); + const persistedAgents: AgentSidebarRow[] = orderPersistedRows(agentChats).map( + (workbench) => ({ kind: "persisted", workbench }), + ); + const unopenedAgents: AgentSidebarRow[] = definitions + .filter((definition) => !openedDefinitionIds.has(definition.id)) + .map((definition) => ({ kind: "definition", definition })); + const pinnedAgents = persistedAgents.filter( + (row) => row.kind === "persisted" && row.workbench.pinned, + ); + const unpinnedAgents = [...persistedAgents, ...unopenedAgents] + .filter((row) => row.kind === "definition" || !row.workbench.pinned) + .sort((a, b) => agentRecency(b) - agentRecency(a)); + + return { + agents: [...pinnedAgents, ...unpinnedAgents], + channels: orderPersistedRows(workbenches), + }; +} diff --git a/apps/web/src/shell/sidebar.tsx b/apps/web/src/shell/sidebar.tsx index befd02d45..b3f232287 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 +// Agents and Channels lists — 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 @@ -71,7 +71,7 @@ import { import type { SessionUser } from "../session"; import { SidebarBrandMark } from "./brand-mark"; import { initialsOf } from "./docks"; -import { WorkbenchList } from "./workbench-list"; +import { SidebarSections } from "./workbench-list"; /** The repo's own issue tracker — read off this package's manifest (set * from `git remote`) rather than a hardcoded org/repo guess. */ @@ -143,11 +143,9 @@ 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 - header icon cluster — search is the box. */} + {/* Logo and creation stay above the shared Agents/Channels search. */}
- {/* The "Workbenches" label renders inside the list, below its search - box (owner's order: logo · search · Workbenches · rows). */} - - + {/* Mission Control is pinned above the footer rail as its own row diff --git a/apps/web/src/shell/workbench-list.test.ts b/apps/web/src/shell/workbench-list.test.ts index fa0723465..cd6705392 100644 --- a/apps/web/src/shell/workbench-list.test.ts +++ b/apps/web/src/shell/workbench-list.test.ts @@ -3,7 +3,6 @@ import { describe, expect, test } from "bun:test"; import type { Workbench } from "@corbits/chat-ui"; import { - orderWorkbenchRows, renamePayload, rowMenuLabels, workbenchRowSignals, @@ -59,56 +58,3 @@ describe("workbenchRowSignals", () => { ).toBe(0); }); }); - -describe("orderWorkbenchRows", () => { - test("floats pinned rows to the top, keeping each half's given order", () => { - const rows = [ - workbench({ id: "a" }), - workbench({ id: "b", pinned: true }), - workbench({ id: "c" }), - workbench({ id: "d", pinned: true }), - ]; - expect(orderWorkbenchRows(rows).map((row) => row.id)).toEqual([ - "b", - "d", - "a", - "c", - ]); - }); - - test("orders most-recent activity first within each half", () => { - const rows = [ - workbench({ id: "old", lastActivityAt: "2026-08-01T00:00:00Z" }), - workbench({ id: "new", lastActivityAt: "2026-08-10T00:00:00Z" }), - workbench({ - id: "pinned-old", - pinned: true, - lastActivityAt: "2026-08-01T00:00:00Z", - }), - workbench({ - id: "pinned-new", - pinned: true, - lastActivityAt: "2026-08-10T00:00:00Z", - }), - ]; - expect(orderWorkbenchRows(rows).map((row) => row.id)).toEqual([ - "pinned-new", - "pinned-old", - "new", - "old", - ]); - }); - - test("never groups by kind — a flat list in, a flat list out", () => { - const rows = [ - workbench({ id: "a", kind: "chat" }), - workbench({ id: "b", kind: "workbench" }), - workbench({ id: "c", kind: "chat" }), - ]; - expect(orderWorkbenchRows(rows).map((row) => row.id)).toEqual([ - "a", - "b", - "c", - ]); - }); -}); diff --git a/apps/web/src/shell/workbench-list.tsx b/apps/web/src/shell/workbench-list.tsx index 9c86ea1e9..669430579 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 projects bench activity into two stable navigation sections: +// persisted and unopened agent conversations under Agents, and rooms under +// Channels. One search filters both without merging their ordering. import { Badge, @@ -21,12 +20,13 @@ import { workbenchesQueryKeyPrefix, patchWorkbenchSettings, } from "@corbits/chat-ui"; -import type { Workbench } from "@corbits/chat-ui"; +import type { VisibleAgentDefinition, 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 { openAgentDmChat } from "../agent-dm-launch"; import { useNeedsYouCount } from "../api"; import { useBench } from "../bench-context"; import { workbenchIdFromPath, workbenchPath } from "../workbench-path"; @@ -36,7 +36,10 @@ import { } from "../workbench-rename-events"; import { useBenchActivity } from "./bench-activity"; import { Chip } from "./chip"; -import { buildSidebarRows, type SidebarRow } from "./sidebar-rows"; +import { + buildSidebarSections, + type AgentSidebarRow, +} from "./sidebar-section-rows"; /** * The bench-wide "something needs you" signal above the row list. The @@ -158,26 +161,6 @@ export function workbenchRowSignals( }; } -/** - * Flat ordering for the one list: 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. - */ -export function orderWorkbenchRows( - workbenches: readonly Workbench[], -): readonly Workbench[] { - const byRecency = (a: Workbench, b: Workbench) => { - const at = a.lastActivityAt ? Date.parse(a.lastActivityAt) : 0; - const bt = b.lastActivityAt ? Date.parse(b.lastActivityAt) : 0; - return bt - at; - }; - return [ - ...workbenches.filter((workbench) => workbench.pinned).sort(byRecency), - ...workbenches.filter((workbench) => !workbench.pinned).sort(byRecency), - ]; -} - /** * 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 @@ -357,7 +340,43 @@ function WorkbenchRow({ ); } -export function WorkbenchList({ +function DefinitionRow({ + definition, + onSelect, +}: { + readonly definition: VisibleAgentDefinition; + readonly onSelect: () => void; +}) { + return ( + + ); +} + +function persistedRowName(workbench: Workbench): string { + return ( + displayWorkbenchTitle(workbench.title, workbench.id) || + CHAT_STRINGS.unnamedWorkbench + ); +} + +function agentRowName(row: AgentSidebarRow): string { + return row.kind === "persisted" + ? persistedRowName(row.workbench) + : row.definition.name; +} + +export function SidebarSections({ path, onNavigate, }: { @@ -383,7 +402,7 @@ export function WorkbenchList({ return ( } - title="No workbenches yet" + title="No conversations yet" description="Start a new one with the + above." /> ); @@ -392,60 +411,52 @@ export function WorkbenchList({ return ( } - title="Couldn't load workbenches" + title="Couldn't load conversations" description={activity.message} /> ); } - const all = buildSidebarRows(activity.workbenches, activity.chats); - - if (all.length === 0) { - return ( -
- -

Workbenches

-
- -
-
- ); - } - - const rowName = (row: SidebarRow): string => - displayWorkbenchTitle(row.workbench.title, row.workbench.id) || - CHAT_STRINGS.unnamedWorkbench; - + const sections = buildSidebarSections( + activity.workbenches, + activity.chats, + activity.agents, + ); + const hasRows = sections.agents.length > 0 || sections.channels.length > 0; const q = query.trim().toLowerCase(); - const filtered = + const agents = q === "" - ? all - : all.filter((row) => rowName(row).toLowerCase().includes(q)); - + ? sections.agents + : sections.agents.filter((row) => + agentRowName(row).toLowerCase().includes(q), + ); + const channels = + q === "" + ? sections.channels + : sections.channels.filter((row) => + persistedRowName(row).toLowerCase().includes(q), + ); + const noMatches = q !== "" && agents.length === 0 && channels.length === 0; const tenantId = selectedTenantId ?? ""; return ( -
- +
+ {hasRows ? ( + + ) : null} -

Workbenches

- {filtered.length === 0 ? ( - } - title="No matches" - description={`Nothing matches “${query.trim()}”.`} - /> - ) : ( -
- {filtered.map((row) => ( +

Agents

+
+ {agents.map((row) => + row.kind === "persisted" ? ( - ))} -
- )} + ) : ( + + void openAgentDmChat( + row.definition.tenantId, + row.definition.id, + onNavigate, + ) + } + /> + ), + )} +
+

Channels

+
+ {channels.map((workbench) => ( + onNavigate(workbenchPath(workbench.id))} + signals={workbenchRowSignals(workbench, workbench.id === activeId)} + /> + ))} + {!hasRows ? : null} +
+ {noMatches ? ( + } + title="No matches" + description={`Nothing matches “${query.trim()}”.`} + /> + ) : null}
); } diff --git a/apps/web/test/fetch-dedupe.test.tsx b/apps/web/test/fetch-dedupe.test.tsx index d36faf305..28ce8e77f 100644 --- a/apps/web/test/fetch-dedupe.test.tsx +++ b/apps/web/test/fetch-dedupe.test.tsx @@ -1,7 +1,7 @@ // CL-6045: the shell used to fire the same listing 4-5x per navigation // because independent components each fetched independently instead of // sharing a cache. Every mount of `useBenchActivity` (the sidebar's -// `WorkbenchList`, and any second subscriber) shares the same TanStack +// `SidebarSections`, and any second subscriber) shares the same TanStack // Query keys (`tenantKeys.workbenches`, `.topLevelRuns` — see // `../src/query-client.ts`) under one `QueryClient`, so two mounts fetch // each listing exactly once. @@ -12,7 +12,7 @@ import type { Root } from "react-dom/client"; import { BenchProvider } from "../src/bench-context"; import { requestWorkbenchRename } from "../src/workbench-rename-events"; -import { WorkbenchList } from "../src/shell/workbench-list"; +import { SidebarSections } from "../src/shell/workbench-list"; import { createTestQueryClient, TestQueryProvider, @@ -87,7 +87,7 @@ function countsByMatch( } describe("shell listing dedupe (CL-6045)", () => { - test("two WorkbenchList mounts together fetch each listing exactly once", async () => { + test("two SidebarSections mounts together fetch each listing exactly once", async () => { const calls: string[] = []; stubFetch(calls); const queryClient = createTestQueryClient(); @@ -99,8 +99,8 @@ describe("shell listing dedupe (CL-6045)", () => { root?.render( - undefined} /> - undefined} /> + undefined} /> + undefined} /> , ); @@ -156,7 +156,7 @@ describe("shell listing dedupe (CL-6045)", () => { root?.render( - undefined} /> + undefined} /> , ); diff --git a/apps/web/test/sidebar.test.tsx b/apps/web/test/sidebar.test.tsx index b86abb03d..c17133216 100644 --- a/apps/web/test/sidebar.test.tsx +++ b/apps/web/test/sidebar.test.tsx @@ -208,11 +208,9 @@ describe("Sidebar", () => { container.remove(); }); - test("titles itself Workbenches and never renders a page-nav list", () => { + test("titles itself Conversations 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"'); + expect(markup).toContain('aria-label="Conversations"'); expect(markup).not.toContain(">Pages<"); expect(markup).not.toContain("shell-rail-item"); }); @@ -414,7 +412,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,9 +443,7 @@ describe("Sidebar", () => { }); }); - // Sidebar = workbenches + conversational DMs only. Visible agent - // definitions that have never been opened do not get a synthetic row. - describe("agent DM rows", () => { + describe("agent rows", () => { const workbench = { id: "ch_1", title: "Research brief", @@ -524,7 +520,7 @@ describe("Sidebar", () => { return { container, root }; } - test("does not render a synthetic row for an unopened agent", async () => { + test("renders unopened local and inherited agent definitions", async () => { stubAgentSidebar(); const { container, root } = await mountSidebar(); @@ -534,9 +530,8 @@ describe("Sidebar", () => { expect(labels.some((label) => label.includes("Research brief"))).toBe( true, ); - expect(labels.some((label) => label.includes("Outreach"))).toBe(false); - expect(labels.some((label) => label.includes("Researcher"))).toBe(false); - expect(container.querySelector("[data-ctx-agent]")).toBeNull(); + expect(labels.some((label) => label.includes("Outreach"))).toBe(true); + expect(labels.some((label) => label.includes("Researcher"))).toBe(true); act(() => root.unmount()); container.remove(); @@ -569,16 +564,16 @@ 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 conversations yet")) break; } - expect(container.innerHTML).toContain("No workbenches yet"); + expect(container.innerHTML).toContain("No conversations 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 + // icon "No conversations 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 () => { stubFetch(); diff --git a/apps/web/test/workbench-list.test.tsx b/apps/web/test/workbench-list.test.tsx index e35dba5af..71dbfe9e8 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 { SidebarSections } from "../src/shell/workbench-list"; import { TestQueryProvider } from "./test-query-provider"; const realFetch = globalThis.fetch; @@ -46,8 +46,14 @@ function json(body: unknown): Response { }); } -function stubFetch(data: { readonly needsYou?: readonly unknown[] }): void { - globalThis.fetch = ((input: RequestInfo | URL) => { +function stubFetch(data: { + readonly needsYou?: readonly unknown[]; + readonly workbenches?: readonly unknown[]; + readonly chats?: readonly unknown[]; + readonly agents?: readonly unknown[]; + readonly openedChat?: unknown; +}): void { + globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { const path = typeof input === "string" ? input : String(input); if (path.includes("/api/me/principals")) return Promise.resolve(json(membership)); @@ -56,7 +62,13 @@ function stubFetch(data: { readonly needsYou?: readonly unknown[] }): void { if (path.includes("/approvals/needs-you")) return Promise.resolve(json({ items: data.needsYou ?? [] })); if (path.includes("/agent-definitions/visible")) - return Promise.resolve(json({ definitions: [] })); + return Promise.resolve(json({ definitions: data.agents ?? [] })); + if (path.includes("/chat/workbenches?kind=workbench")) + return Promise.resolve(json({ items: data.workbenches ?? [] })); + if (path.includes("/chat/workbenches?kind=chat")) + return Promise.resolve(json({ items: data.chats ?? [] })); + if (path.includes("/chat/workbenches") && init?.method === "POST") + return Promise.resolve(json(data.openedChat)); return Promise.resolve(json({ items: [] })); }) as typeof fetch; } @@ -82,7 +94,7 @@ async function mount(onNavigate: (to: string) => void = () => undefined) { root?.render( - + , ); @@ -95,7 +107,120 @@ async function mount(onNavigate: (to: string) => void = () => undefined) { return container; } -describe("WorkbenchList — needs-you signal", () => { +describe("SidebarSections", () => { + const room = { + id: "ch_room", + title: "Launch Room", + kind: "workbench", + pinned: false, + participants: [], + }; + const agentChat = { + id: "ch_agent", + title: "Myra", + kind: "chat", + definitionId: "wfd_myra", + pinned: false, + participants: [], + }; + const humanDm = { + id: "ch_human", + title: "Ada", + kind: "chat", + principalId: "prn_ada", + pinned: false, + participants: [{ address: "prn_ada", handle: "Ada" }], + }; + const unopened = { + id: "wfd_research", + name: "Research", + tenantId: "tnt_ancestor", + tenantName: "Parent Bench", + createdAt: "2026-08-01T00:00:00.000Z", + }; + + test("renders Agents and Channels without human DMs or duplicate definition rows", async () => { + stubFetch({ + workbenches: [room], + chats: [agentChat, humanDm], + agents: [ + { + id: "wfd_myra", + name: "Myra definition", + tenantId: "tnt_1", + tenantName: "Corbits Bench", + createdAt: "2026-08-01T00:00:00.000Z", + }, + unopened, + ], + }); + + const el = await mount(); + + expect( + [...el.querySelectorAll("h2")].map((node) => node.textContent), + ).toEqual(["Agents", "Channels"]); + expect(el.textContent).toContain("Myra"); + expect(el.textContent).toContain("Research"); + expect(el.textContent).toContain("Launch Room"); + expect(el.textContent).not.toContain("Ada"); + expect(el.textContent).not.toContain("Myra definition"); + }); + + test("one search filters both sections while retaining both labels", async () => { + stubFetch({ workbenches: [room], agents: [unopened] }); + const el = await mount(); + const input = el.querySelector( + 'input[aria-label="Search agents and channels"]', + ); + const setter = Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + "value", + )?.set; + if (input === null || setter === undefined) + throw new Error("missing search"); + + act(() => { + setter.call(input, "Research"); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + + expect( + [...el.querySelectorAll("h2")].map((node) => node.textContent), + ).toEqual(["Agents", "Channels"]); + expect(el.textContent).toContain("Research"); + expect(el.textContent).not.toContain("Launch Room"); + }); + + test("opens a standing definition through the agent-DM launcher and navigates", async () => { + const navigated: string[] = []; + stubFetch({ + agents: [unopened], + openedChat: { + id: "ch_research", + title: "Research", + kind: "chat", + definitionId: unopened.id, + pinned: false, + participants: [], + }, + }); + const el = await mount((to) => navigated.push(to)); + const row = [...el.querySelectorAll("button")].find( + (button) => button.textContent?.includes("Research") === true, + ); + if (row === undefined) throw new Error("missing definition row"); + + await act(async () => row.click()); + for (let i = 0; i < 5; i++) { + await act(async () => new Promise((resolve) => setTimeout(resolve, 0))); + } + + expect(navigated).toEqual(["/w/ch_research"]); + }); +}); + +describe("SidebarSections — needs-you signal", () => { test("hides the signal when nothing is pending", async () => { stubFetch({ needsYou: [] }); const el = await mount(); diff --git a/docs/CHAT.md b/docs/CHAT.md index 3d4169640..37ec9e296 100644 --- a/docs/CHAT.md +++ b/docs/CHAT.md @@ -416,6 +416,12 @@ bench to talk to and the current user, and mirrors the active workbench into its own routing. Each sidebar row also carries a hover-revealed ellipsis menu (Rename, Pin/Unpin, Workbench settings). +The host shell's left rail (`apps/web/src/shell/workbench-list.tsx`) is not a +flat Workbenches list. It is two labeled sections: **Agents** (persisted +agent conversations plus unopened conversational definitions) and +**Channels** (rooms). Pinning stays inside each section. One search filters +both without dropping either label. Human DMs do not appear under Agents. + Workbenches are tenants, so their settings are never a dialog: the gear icon in the workbench header routes to a full stage surface, `WorkbenchSettingsSurface` (`packages/chat-ui/src/workbench-settings/`) — diff --git a/docs/GLOSSARY.md b/docs/GLOSSARY.md index f9ea9de30..876e3f0f1 100644 --- a/docs/GLOSSARY.md +++ b/docs/GLOSSARY.md @@ -21,7 +21,8 @@ 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 sidebar's one list: every conversation in the selected bench, flat — no kind sections | +| **Agents** | — | The left-rail section for agent conversations and unopened conversational definitions in the selected bench | +| **Channels** | — | The left-rail section for rooms (multi-party workbenches) in the selected bench; pinning stays inside this section, never mixed with Agents | | **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/scripts/checks/test/ui-vocabulary.test.ts b/scripts/checks/test/ui-vocabulary.test.ts index 76a406aa5..2af0385b3 100644 --- a/scripts/checks/test/ui-vocabulary.test.ts +++ b/scripts/checks/test/ui-vocabulary.test.ts @@ -151,67 +151,86 @@ 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 rail list labels are clean (CL-6977)", () => { const report = auditUiVocabulary([ { - relPath: "apps/web/src/routes.tsx", - contents: `label: "Channels",`, + relPath: "apps/web/src/shell/workbench-list.tsx", + contents: [ + `

Agents

`, + `

Channels

`, + ].join("\n"), }, ]); - 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("Agents/Channels label properties are clean rail copy (CL-6977)", () => { const report = auditUiVocabulary([ { - relPath: "apps/web/src/shell/panel-contributions.tsx", - contents: `title: "Channels",`, + relPath: "apps/web/src/shell/workbench-list.tsx", + contents: [`label: "Agents",`, `label: "Channels",`].join("\n"), }, ]); - expect(report.violations).toHaveLength(1); + expect(report.violations).toEqual([]); +}); + +test("an Agents/Channels JSX title attribute is clean", () => { + const report = auditUiVocabulary([ + { + relPath: "apps/web/src/shell/rail.tsx", + contents: [ + ``, + ``, + ].join("\n"), + }, + ]); + expect(report.violations).toEqual([]); }); -test("a reintroduced Channels aria-label is a violation", () => { +test("an Agents/Channels aria-label is clean", () => { 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 flat Workbenches shell-panel-list-label is a violation (CL-6977)", () => { const report = auditUiVocabulary([ { - relPath: "apps/web/src/shell/rail.tsx", - contents: ``, + relPath: "apps/web/src/shell/workbench-list.tsx", + contents: `

Workbenches

`, }, ]); expect(report.violations).toHaveLength(1); + expect(report.violations[0]).toContain( + "apps/web/src/shell/workbench-list.tsx", + ); + expect(report.violations[0]).toContain("Workbenches"); }); -test("a reintroduced Channels JSX label attribute is a violation", () => { +test("a reintroduced Workbenches nav band label is a violation", () => { const report = auditUiVocabulary([ { - relPath: "apps/web/src/shell/rail.tsx", - contents: ``, + relPath: "apps/web/src/routes.tsx", + contents: `label: "Workbenches",`, }, ]); expect(report.violations).toHaveLength(1); + expect(report.violations[0]).toContain("apps/web/src/routes.tsx"); + expect(report.violations[0]).toContain("Workbenches"); }); -test("prose mentioning Channels is now a plain banned-term violation, not just a band-label one (CL-6071)", () => { +test("Channels prose is legal copy again, not a banned-term violation (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 +338,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("'channel' in user-facing prose is legal copy again (CL-6977)", () => { const report = auditUiVocabulary([ { relPath: "packages/chat-ui/src/strings.ts", contents: `noChannelsTitle: "No channel 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("a remaining banned term is still reported when channel copy sits beside it", () => { const report = auditUiVocabulary([ { relPath: "apps/web/src/pages/agents-settings-section.tsx", @@ -340,7 +358,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 +386,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 space named ${title}.`", }, ]); expect(report.violations).toHaveLength(1); - expect(report.violations[0]).toContain("channel"); + expect(report.violations[0]).toContain("space"); }); test("stripNonUserFacing preserves line and column positions", () => { diff --git a/scripts/checks/ui-vocabulary.ts b/scripts/checks/ui-vocabulary.ts index ed3af28f4..780ec873d 100644 --- a/scripts/checks/ui-vocabulary.ts +++ b/scripts/checks/ui-vocabulary.ts @@ -3,12 +3,12 @@ // "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 +// switching control is the "switcher". "Space(s)" and "chat(s)" stay +// banned. CL-6977 restored "channel(s)" as the rail section noun +// alongside "Agents" — a WORKBENCH is still the product noun for a +// conversation, but the sidebar lists those conversations under +// Agents and Channels, never a flat "Workbenches" band. "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 @@ -16,8 +16,8 @@ // 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. +// 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 +54,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 @@ -78,19 +77,23 @@ 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). + * single-concept collapse). CL-6977 then split that one sidebar list + * into Agents and Channels, so a flat "Workbenches" band is the + * retired label this guard still rejects — "Channels" is legal rail + * copy again. A single-word label value like `"Workbenches"` 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: "Workbenches"`), the same as a JSX + * attribute (`title="Chat"`), an `aria-label` attribute set to + * precisely one of those words, or JSX text in `.shell-panel-list-label` + * — 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). */ 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*"(?:Workbenches|Spaces|Chats|Chat)"|aria-label\s*=\s*"(?:Workbenches|Spaces|Chats|Chat)"|shell-panel-list-label"[^>]*>\s*Workbenches/g; /** * Exact strings that legitimately contain a banned term as UI copy. @@ -234,7 +237,7 @@ export function findViolations(files: readonly ScannedFile[]): Violation[] { violations.push({ relPath, line, - term: "Channels/Spaces/Chats (retired surface label)", + term: "Workbenches/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..e785dbed7 100644 --- a/scripts/e2e/browser/walkthrough.ts +++ b/scripts/e2e/browser/walkthrough.ts @@ -653,29 +653,50 @@ 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, + // the old header-bar slot entirely (logo · search · labels · rows, // no separate title bar) — `.shell-panel-list-label` is the - // "Workbenches" text that actually renders now. - let sidebarTitle: string | null = null; - for (let attempt = 0; attempt < 15; attempt += 1) { - sidebarTitle = await page.evaluate( - () => - document - .querySelector(".shell-panel-list-label") - ?.textContent?.trim() ?? null, + // "Agents" and "Channels" text that actually renders now. + const readListLabels = () => + page.evaluate(() => + Array.from( + document.querySelectorAll(".shell-panel-list-label"), + ).map((el) => el.textContent?.trim() ?? ""), ); - if (sidebarTitle === "Workbenches") break; + const hasAgentsAndChannels = (labels: readonly string[]) => + labels.includes("Agents") && labels.includes("Channels"); + let sidebarLabels: string[] = []; + for (let attempt = 0; attempt < 15; attempt += 1) { + sidebarLabels = await readListLabels(); + if (hasAgentsAndChannels(sidebarLabels)) break; await new Promise((resolve) => setTimeout(resolve, 500)); } - if (sidebarTitle !== "Workbenches") { + if (!hasAgentsAndChannels(sidebarLabels)) { + return { + status: "fail", + detail: `expected sidebar list labels "Agents" and "Channels", got ${JSON.stringify(sidebarLabels)}`, + }; + } + const search = await page.$( + 'input[aria-label="Search agents and channels"]', + ); + if (search === null) { + return { + status: "fail", + detail: + "expected a Search agents and channels field before checking labels after search", + }; + } + await search.type("zzz-no-match"); + sidebarLabels = await readListLabels(); + if (!hasAgentsAndChannels(sidebarLabels)) { return { status: "fail", - detail: `expected the sidebar title "Workbenches", got ${JSON.stringify(sidebarTitle)}`, + detail: `after search, expected sidebar list labels "Agents" and "Channels", got ${JSON.stringify(sidebarLabels)}`, }; } const createButtons = await countMatching( @@ -691,7 +712,7 @@ async function run(): Promise { return { status: "pass", detail: - 'single always-visible sidebar: titled "Workbenches", one "+ New workbench" affordance', + 'single always-visible sidebar: "Agents" and "Channels" list labels, one "+ New workbench" affordance', }; }, );