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 (
-
`,
},
]);
- 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',
};
},
);