diff --git a/apps/web/src/pages/onboarding-page.tsx b/apps/web/src/pages/onboarding-page.tsx index 56e9b9dca..5f5fdc658 100644 --- a/apps/web/src/pages/onboarding-page.tsx +++ b/apps/web/src/pages/onboarding-page.tsx @@ -55,11 +55,18 @@ import type { SessionUser } from "../session"; * derives one from the account so `/api/onboarding/provision` never gets * called bare. Prefers the account's display name; an account with no * usable name falls back to the email's local part. Editable later from - * Settings, same as any other display name. */ -function defaultWorkbenchName(user: SessionUser): string { + * Settings, same as any other display name. + * + * This names the account's one root tenant — the container real + * workbenches (each its own child tenant, CL-6089) live under, never a + * workbench itself (CL-6368). "…'s workbench" mislabeled it as one; + * every fresh account now mints under its own name instead ("team space" + * / "workspace" stay off the table too — check:ui-vocabulary bans both as + * synonyms the CL-6089 product collapse deliberately retired). */ +function defaultTeamName(user: SessionUser): string { const source = user.name.trim().length > 0 ? user.name.trim() : user.email.split("@")[0]; - return `${source || "Your"}'s workbench`; + return `${source || "Your"}'s team`; } type WizardState = @@ -281,7 +288,7 @@ export function OnboardingPage({ user }: { readonly user: SessionUser }) { // or a stale connect error from a duplicate callback this page never // saw resolved — provisions with a default name derived from the // account: there is no naming step to gate this on, so it must always - // send a name (see `defaultWorkbenchName`). A returning member's + // send a name (see `defaultTeamName`). A returning member's // already-provisioned workbench is unaffected — the hub route only // creates one the first time an account has none. useEffect(() => { @@ -306,7 +313,7 @@ export function OnboardingPage({ user }: { readonly user: SessionUser }) { }); return; } - runProvisioning(defaultWorkbenchName(user)); + runProvisioning(defaultTeamName(user)); // Mount-only: this reads `state.phase` exactly once, at the value // `initialWizardState` produced, to decide which of the two checks // above applies to this landing. @@ -408,7 +415,7 @@ export function OnboardingPage({ user }: { readonly user: SessionUser }) { action={ diff --git a/apps/web/src/pages/skills-page.tsx b/apps/web/src/pages/skills-page.tsx index 3e6271335..420c46afa 100644 --- a/apps/web/src/pages/skills-page.tsx +++ b/apps/web/src/pages/skills-page.tsx @@ -19,10 +19,9 @@ import { Badge, Button, EmptyState, - Input, + LibrarySearchInput, RichEmptyState, Section, - SidebarItemRow, Table, TableBody, TableCell, @@ -31,7 +30,7 @@ import { TableRow, formatRelativeTime, } from "@corbits/react-ui"; -import { Plus, Search, Sparkles } from "lucide-react"; +import { Plus, Sparkles } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; import { consumePendingNewSkill } from "../command-palette-actions"; @@ -410,16 +409,12 @@ export function SkillsPage({ return (
-
- +
+ @@ -432,31 +427,35 @@ export function SkillsPage({ description={`Nothing matches “${query.trim()}”.`} /> ) : ( -
- {filtered.map((skill) => ( - } - name={ - - {skill.name} - {skill.description} - - } - meta={ - + + + + Name + Description + Access + + + + {filtered.map((skill) => ( + select(skill.name)} > - {skill.scope === "tenant" ? "Shared" : "Private"} - - } - onSelect={() => select(skill.name)} - /> - ))} + {skill.name} + + {skill.description} + + + + {skill.scope === "tenant" ? "Shared" : "Private"} + + + + ))} + +
)} {createDialog} diff --git a/apps/web/test/skills-page.test.tsx b/apps/web/test/skills-page.test.tsx index 291b0a3e4..7703765dd 100644 --- a/apps/web/test/skills-page.test.tsx +++ b/apps/web/test/skills-page.test.tsx @@ -374,8 +374,8 @@ describe("SkillsPage", () => { }); const navigated: string[] = []; const el = await mount({ navigate: (to) => navigated.push(to) }); - const row = Array.from(el.querySelectorAll("button")).find((button) => - button.textContent?.includes("triage"), + const row = Array.from(el.querySelectorAll("tr")).find((tr) => + tr.textContent?.includes("triage"), ); await act(async () => { row?.dispatchEvent(new MouseEvent("click", { bubbles: true })); diff --git a/apps/web/test/stage-chrome-consistency.test.tsx b/apps/web/test/stage-chrome-consistency.test.tsx new file mode 100644 index 000000000..2b22e85a2 --- /dev/null +++ b/apps/web/test/stage-chrome-consistency.test.tsx @@ -0,0 +1,125 @@ +// CL-6368: /files, /skills, /agents must use the same stage chrome as the +// reference pages (Insights, Plugins) — the shared `StageTopBar` component +// and `Table` row idiom, not bespoke divs standing in for either. This is +// a screenshot-free assertion that each page's presentational component +// renders those shared components rather than imitating them. + +import { afterEach, describe, expect, test } from "bun:test"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { AgentsPage } from "../src/pages/agents-page"; +import { LibraryPage } from "../src/pages/library-page"; +import { SkillsPage } from "../src/pages/skills-page"; +import { TestQueryProvider } from "./test-query-provider"; + +const noop = () => undefined; + +let container: HTMLDivElement | null = null; +let root: Root | null = null; +const originalFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = originalFetch; + if (root !== null) { + act(() => { + root?.unmount(); + }); + root = null; + } + container?.remove(); + container = null; +}); + +describe("stage chrome consistency (CL-6368)", () => { + test("Agents uses the shared StageTopBar and Table row idiom", () => { + const markup = renderToStaticMarkup( + , + ); + expect(markup).toContain('data-testid="stage-top-bar"'); + expect(markup).toContain('data-slot="table"'); + }); + + test("Files uses the shared StageTopBar and Table row idiom (rows view)", () => { + const markup = renderToStaticMarkup( + , + ); + expect(markup).toContain('data-testid="stage-top-bar"'); + }); + + test("Skills renders its list with the shared Table row idiom, not a bespoke row component", async () => { + const TENANT = "tnt_1"; + globalThis.fetch = (async (input: unknown) => { + const path = String(input); + if (path === `/api/tenants/${TENANT}/skills`) { + return new Response( + JSON.stringify({ + skills: [ + { + assetId: "ast_1", + name: "triage", + description: "Sorts inbound issues.", + scope: "private", + creatorPrincipalId: "prn_1", + updatedAtIso: "2026-08-05T11:00:00.000Z", + }, + ], + }), + { status: 200 }, + ); + } + return new Response(JSON.stringify({ error: { message: "no stub" } }), { + status: 404, + }); + }) as unknown as typeof fetch; + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + await act(async () => { + root?.render( + + + , + ); + }); + await act(async () => { + await Promise.resolve(); + }); + + expect(container.querySelector('[data-slot="table"]')).not.toBeNull(); + expect(container.textContent).toContain("triage"); + }); +}); diff --git a/packages/insights/src/routes.ts b/packages/insights/src/routes.ts index 4434a9f36..1dfd5493f 100644 --- a/packages/insights/src/routes.ts +++ b/packages/insights/src/routes.ts @@ -206,6 +206,13 @@ export function createInsightsRoutes( * link each bar to `/insights/workbench/:tenantId` instead of only * seeing the scope's sum. Calling it for a leaf workbench (no * descendants) returns that one workbench's own row. + * + * A `parentId === null` requested tenant is never itself a workbench — + * it is the account root, the container real workbenches (each its own + * child tenant, CL-6089) live under. Its own row would just be a + * zero-usage rollup-of-itself duplicate of the "All workbenches" + * landing this chart already sits on, so it is dropped rather than + * listed alongside its children (CL-6368). */ app.get( "/workbenches", @@ -219,7 +226,9 @@ export function createInsightsRoutes( summarizeUsageByTenant(deps.store, scope, range), tenantNames(deps.db, tenant.id, tenant.name, scope), ]); + const isTeamSpace = tenant.parentId === null; const items = rows + .filter((row) => !isTeamSpace || row.tenantId !== tenant.id) .map((row) => ({ tenantId: row.tenantId, name: names.get(row.tenantId) ?? row.tenantId, diff --git a/packages/insights/test/routes-scope.test.ts b/packages/insights/test/routes-scope.test.ts index 621f9b930..7624fe9a2 100644 --- a/packages/insights/test/routes-scope.test.ts +++ b/packages/insights/test/routes-scope.test.ts @@ -414,17 +414,15 @@ describeIfDb("createInsightsRoutes workspace rollup (deps.db wired)", () => { items: { tenantId: string; name: string; turns: number }[]; }; // Ranked by turns descending — childB has recorded more turns than - // childA across this describe block's fixtures, and the parent - // itself (a rolled-up total, no usage of its own) sorts last. - expect(body.items.map((i) => i.tenantId)).toEqual([ - childBId, - childAId, - parentId, - ]); + // childA across this describe block's fixtures. The parent itself + // is the team space, not a workbench (CL-6368) — it never appears + // as a row here, even though it is the tenant this rollup was + // requested against. + expect(body.items.map((i) => i.tenantId)).toEqual([childBId, childAId]); expect(body.items.find((i) => i.tenantId === childBId)?.name).toBe( "Acme — Sales", ); - expect(body.items.find((i) => i.tenantId === parentId)?.turns).toBe(0); + expect(body.items.some((i) => i.tenantId === parentId)).toBe(false); // The unrelated root tenant never appears in the parent's scope. expect(body.items.some((i) => i.tenantId === unrelatedId)).toBe(false);