Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 13 additions & 6 deletions apps/web/src/pages/onboarding-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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(() => {
Expand All @@ -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.
Expand Down Expand Up @@ -408,7 +415,7 @@ export function OnboardingPage({ user }: { readonly user: SessionUser }) {
action={
<Button
variant="outline"
onClick={() => runProvisioning(defaultWorkbenchName(user))}
onClick={() => runProvisioning(defaultTeamName(user))}
>
Try again
</Button>
Expand Down
73 changes: 36 additions & 37 deletions apps/web/src/pages/skills-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,9 @@ import {
Badge,
Button,
EmptyState,
Input,
LibrarySearchInput,
RichEmptyState,
Section,
SidebarItemRow,
Table,
TableBody,
TableCell,
Expand All @@ -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";
Expand Down Expand Up @@ -410,16 +409,12 @@ export function SkillsPage({

return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-2">
<label className="shell-panel-search">
<Search aria-hidden="true" />
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search…"
aria-label="Search skills"
/>
</label>
<div className="page-toolbar">
<LibrarySearchInput
label="Search skills"
value={query}
onChange={setQuery}
/>
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus /> New skill
</Button>
Expand All @@ -432,31 +427,35 @@ export function SkillsPage({
description={`Nothing matches “${query.trim()}”.`}
/>
) : (
<div className="flex flex-col gap-1">
{filtered.map((skill) => (
<SidebarItemRow
key={skill.assetId}
leading={<Sparkles />}
name={
<span className="panel-row-copy">
<strong>{skill.name}</strong>
<span>{skill.description}</span>
</span>
}
meta={
<span
className={
skill.scope === "tenant"
? "panel-status is-ok"
: "panel-status is-muted"
}
<div className="px-4 pb-5 sm:px-7">
<Table aria-label="Skills">
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Description</TableHead>
<TableHead>Access</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.map((skill) => (
<TableRow
key={skill.assetId}
className="cursor-pointer"
onClick={() => select(skill.name)}
>
{skill.scope === "tenant" ? "Shared" : "Private"}
</span>
}
onSelect={() => select(skill.name)}
/>
))}
<TableCell className="font-medium">{skill.name}</TableCell>
<TableCell className="text-muted-foreground">
{skill.description}
</TableCell>
<TableCell>
<Badge tone={skill.scope === "tenant" ? "info" : "neutral"}>
{skill.scope === "tenant" ? "Shared" : "Private"}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
{createDialog}
Expand Down
4 changes: 2 additions & 2 deletions apps/web/test/skills-page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }));
Expand Down
125 changes: 125 additions & 0 deletions apps/web/test/stage-chrome-consistency.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<AgentsPage
tenantId="tnt_1"
definitions={[
{
id: "wfd_1",
tenantId: "tnt_1",
name: "Triage bot",
description: "Sorts inbound issues.",
currentVersion: "v1",
status: "deployed",
createdAt: "2026-08-01T00:00:00.000Z",
updatedAt: "2026-08-01T00:00:00.000Z",
},
]}
workbenchCounts={new Map()}
selectedId={null}
onSelect={noop}
createOpen={false}
onCreateOpenChange={noop}
onCreated={noop}
/>,
);
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(
<LibraryPage
artifacts={[
{
id: "art_1",
title: "Report.pdf",
kind: "document",
ownerName: "Alice",
createdAt: "2026-08-01T00:00:00.000Z",
updatedAt: "2026-08-01T00:00:00.000Z",
} as never,
]}
/>,
);
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(
<TestQueryProvider>
<SkillsPage tenantId={TENANT} />
</TestQueryProvider>,
);
});
await act(async () => {
await Promise.resolve();
});

expect(container.querySelector('[data-slot="table"]')).not.toBeNull();
expect(container.textContent).toContain("triage");
});
});
9 changes: 9 additions & 0 deletions packages/insights/src/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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,
Expand Down
14 changes: 6 additions & 8 deletions packages/insights/test/routes-scope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
Loading