@cosmicdrift/kumiko-renderer-web 0.183.0 → 0.183.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer-web",
3
- "version": "0.183.0",
3
+ "version": "0.183.2",
4
4
  "description": "Web-platform bindings for @cosmicdrift/kumiko-renderer. HTML default-primitives, browser history-based navigation, EventSource-backed live events, and a one-call createKumikoApp that mounts the whole stack via react-dom.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -16,9 +16,9 @@
16
16
  "./styles.css": "./src/styles.css"
17
17
  },
18
18
  "dependencies": {
19
- "@cosmicdrift/kumiko-dispatcher-live": "0.183.0",
20
- "@cosmicdrift/kumiko-headless": "0.183.0",
21
- "@cosmicdrift/kumiko-renderer": "0.183.0",
19
+ "@cosmicdrift/kumiko-dispatcher-live": "0.183.2",
20
+ "@cosmicdrift/kumiko-headless": "0.183.2",
21
+ "@cosmicdrift/kumiko-renderer": "0.183.2",
22
22
  "@radix-ui/react-dialog": "^1.1.15",
23
23
  "@radix-ui/react-dropdown-menu": "^2.1.16",
24
24
  "@radix-ui/react-label": "^2.1.8",
@@ -10,6 +10,7 @@ import {
10
10
  PrimitivesProvider,
11
11
  } from "@cosmicdrift/kumiko-renderer";
12
12
  import { render as _render, act } from "@testing-library/react";
13
+ import userEvent from "@testing-library/user-event";
13
14
  import type { ReactNode } from "react";
14
15
  import { useBrowserNavApi } from "../app/nav";
15
16
  import {
@@ -20,7 +21,17 @@ import {
20
21
  } from "../layout/workspace-shell";
21
22
  import { WorkspaceSwitcher } from "../layout/workspace-switcher";
22
23
  import { defaultPrimitives } from "../primitives";
23
- import { createMockDispatcher, fireEvent, render, screen } from "./test-utils";
24
+ import { createMockDispatcher, renderWithSidebar, screen } from "./test-utils";
25
+
26
+ // Dropdown instead of a tab row (see workspace-switcher.tsx) — the
27
+ // trigger always shows only the active label, Radix renders the list
28
+ // only once open. Assertions that just check "which workspace is
29
+ // active" read the trigger text instead of a per-tab aria-selected;
30
+ // only tests that need the full list or click an entry open the
31
+ // dropdown (userEvent, Radix reacts to pointerdown).
32
+ function activeWorkspaceLabel(): string | null {
33
+ return screen.getByTestId("workspace-switcher-trigger").textContent;
34
+ }
24
35
 
25
36
  // jsdom shares window.history across tests in the same file. Reset to /
26
37
  // before each render so URL-driven workspace state from one test doesn't
@@ -208,18 +219,19 @@ describe("firstNavScreenId", () => {
208
219
 
209
220
  describe("WorkspaceSwitcher", () => {
210
221
  test("renders nothing for a single workspace (no choice = no UI)", () => {
211
- const { container } = render(
222
+ const { container } = renderWithSidebar(
212
223
  <WorkspaceSwitcher
213
224
  workspaces={[ws("only", { openToAll: true })]}
214
225
  activeId="only"
215
226
  onSelect={() => {}}
216
227
  />,
217
228
  );
218
- expect(container.firstChild).toBeNull();
229
+ expect(container.querySelector('[data-testid="workspace-switcher-trigger"]')).toBeNull();
219
230
  });
220
231
 
221
- test("renders a tab per workspace and marks the active one", () => {
222
- render(
232
+ test("trigger shows the active workspace, dropdown lists all with aria-checked", async () => {
233
+ const user = userEvent.setup();
234
+ renderWithSidebar(
223
235
  <WorkspaceSwitcher
224
236
  workspaces={[
225
237
  ws("admin", { label: "Admin", openToAll: true }),
@@ -229,15 +241,16 @@ describe("WorkspaceSwitcher", () => {
229
241
  onSelect={() => {}}
230
242
  />,
231
243
  );
232
- const adminTab = screen.getByTestId("workspace-tab-admin");
233
- const driverTab = screen.getByTestId("workspace-tab-driver");
234
- expect(adminTab.getAttribute("aria-selected")).toBe("true");
235
- expect(driverTab.getAttribute("aria-selected")).toBe("false");
244
+ expect(activeWorkspaceLabel()).toBe("Admin");
245
+ await user.click(screen.getByTestId("workspace-switcher-trigger"));
246
+ expect(screen.getByTestId("workspace-tab-admin").getAttribute("aria-checked")).toBe("true");
247
+ expect(screen.getByTestId("workspace-tab-driver").getAttribute("aria-checked")).toBe("false");
236
248
  });
237
249
 
238
- test("clicking a tab calls onSelect with that workspace id", () => {
250
+ test("clicking a dropdown entry calls onSelect with that workspace id", async () => {
251
+ const user = userEvent.setup();
239
252
  const onSelect = mock();
240
- render(
253
+ renderWithSidebar(
241
254
  <WorkspaceSwitcher
242
255
  workspaces={[
243
256
  ws("admin", { label: "Admin", openToAll: true }),
@@ -247,7 +260,8 @@ describe("WorkspaceSwitcher", () => {
247
260
  onSelect={onSelect}
248
261
  />,
249
262
  );
250
- fireEvent.click(screen.getByTestId("workspace-tab-driver"));
263
+ await user.click(screen.getByTestId("workspace-switcher-trigger"));
264
+ await user.click(screen.getByTestId("workspace-tab-driver"));
251
265
  expect(onSelect).toHaveBeenCalledWith("driver");
252
266
  });
253
267
  });
@@ -292,7 +306,8 @@ describe("WorkspaceShell", () => {
292
306
  ],
293
307
  } as const;
294
308
 
295
- test("an admin sees admin + dispatch in the switcher (driver hidden)", () => {
309
+ test("an admin sees admin + dispatch in the switcher (driver hidden)", async () => {
310
+ const user = userEvent.setup();
296
311
  renderShell(
297
312
  <WorkspaceShell
298
313
  brand={<div>Brand</div>}
@@ -302,6 +317,7 @@ describe("WorkspaceShell", () => {
302
317
  <div>content</div>
303
318
  </WorkspaceShell>,
304
319
  );
320
+ await user.click(screen.getByTestId("workspace-switcher-trigger"));
305
321
  expect(screen.getByTestId("workspace-tab-admin")).toBeTruthy();
306
322
  expect(screen.getByTestId("workspace-tab-dispatch")).toBeTruthy();
307
323
  expect(screen.queryByTestId("workspace-tab-driver")).toBeNull();
@@ -317,7 +333,7 @@ describe("WorkspaceShell", () => {
317
333
  <div>content</div>
318
334
  </WorkspaceShell>,
319
335
  );
320
- expect(screen.getByTestId("workspace-tab-admin").getAttribute("aria-selected")).toBe("true");
336
+ expect(activeWorkspaceLabel()).toBe("Admin");
321
337
  });
322
338
 
323
339
  test("only the active workspace's nav members appear in the sidebar", () => {
@@ -337,7 +353,8 @@ describe("WorkspaceShell", () => {
337
353
  expect(screen.queryAllByText("Tours").length).toBe(0);
338
354
  });
339
355
 
340
- test("clicking the dispatch tab swaps the visible nav set", () => {
356
+ test("clicking the dispatch tab swaps the visible nav set", async () => {
357
+ const user = userEvent.setup();
341
358
  renderShell(
342
359
  <WorkspaceShell
343
360
  brand={<div>Brand</div>}
@@ -347,7 +364,8 @@ describe("WorkspaceShell", () => {
347
364
  <div>content</div>
348
365
  </WorkspaceShell>,
349
366
  );
350
- fireEvent.click(screen.getByTestId("workspace-tab-dispatch"));
367
+ await user.click(screen.getByTestId("workspace-switcher-trigger"));
368
+ await user.click(screen.getByTestId("workspace-tab-dispatch"));
351
369
  // dispatch → orders + tours, NOT system (getAllByText: Desktop + Mobile-Sheet)
352
370
  expect(screen.getAllByText("Orders").length).toBeGreaterThan(0);
353
371
  expect(screen.getAllByText("Tours").length).toBeGreaterThan(0);
@@ -401,7 +419,7 @@ describe("WorkspaceShell", () => {
401
419
  <div>content</div>
402
420
  </WorkspaceShell>,
403
421
  );
404
- expect(screen.getByTestId("workspace-tab-dispatch").getAttribute("aria-selected")).toBe("true");
422
+ expect(activeWorkspaceLabel()).toBe("Cockpit");
405
423
  });
406
424
 
407
425
  // Security regression — without the empty-allow-set branch, the NavTree
@@ -535,17 +553,18 @@ describe("WorkspaceShell — URL sync (path-based)", () => {
535
553
  <div>content</div>
536
554
  </WorkspaceShell>,
537
555
  );
538
- expect(screen.getByTestId("workspace-tab-dispatch").getAttribute("aria-selected")).toBe("true");
539
- expect(screen.getByTestId("workspace-tab-admin").getAttribute("aria-selected")).toBe("false");
556
+ expect(activeWorkspaceLabel()).toBe("dispatch");
540
557
  });
541
558
 
542
- test("clicking a tab pushes /<workspace>/<screen> to the URL", () => {
559
+ test("clicking a tab pushes /<workspace>/<screen> to the URL", async () => {
560
+ const user = userEvent.setup();
543
561
  renderShell(
544
562
  <WorkspaceShell brand={<div>B</div>} schema={schema} user={{ id: "u1", roles: ["admin"] }}>
545
563
  <div>content</div>
546
564
  </WorkspaceShell>,
547
565
  );
548
- fireEvent.click(screen.getByTestId("workspace-tab-dispatch"));
566
+ await user.click(screen.getByTestId("workspace-switcher-trigger"));
567
+ await user.click(screen.getByTestId("workspace-tab-dispatch"));
549
568
  // dispatch's first nav-member is bmc:nav:orders → screenId "orders"
550
569
  expect(window.location.pathname).toBe("/dispatch/orders");
551
570
  });
@@ -604,7 +623,7 @@ describe("WorkspaceShell — URL sync (path-based)", () => {
604
623
  </WorkspaceShell>,
605
624
  );
606
625
  // Default workspace = admin. Ghost id ignored, no error thrown.
607
- expect(screen.getByTestId("workspace-tab-admin").getAttribute("aria-selected")).toBe("true");
626
+ expect(activeWorkspaceLabel()).toBe("admin");
608
627
  });
609
628
 
610
629
  test("popstate (back/forward) updates the active tab", () => {
@@ -614,7 +633,7 @@ describe("WorkspaceShell — URL sync (path-based)", () => {
614
633
  <div>content</div>
615
634
  </WorkspaceShell>,
616
635
  );
617
- expect(screen.getByTestId("workspace-tab-admin").getAttribute("aria-selected")).toBe("true");
636
+ expect(activeWorkspaceLabel()).toBe("admin");
618
637
  // Simulate the user hitting "forward" to /dispatch/orders. pushState
619
638
  // alone doesn't fire popstate — we synthesize the event so the
620
639
  // hook's listener-set notifies subscribers. act() flushes the
@@ -623,7 +642,7 @@ describe("WorkspaceShell — URL sync (path-based)", () => {
623
642
  window.history.pushState(null, "", "/dispatch/orders");
624
643
  window.dispatchEvent(new PopStateEvent("popstate"));
625
644
  });
626
- expect(screen.getByTestId("workspace-tab-dispatch").getAttribute("aria-selected")).toBe("true");
645
+ expect(activeWorkspaceLabel()).toBe("dispatch");
627
646
  });
628
647
  });
629
648
 
@@ -1,8 +1,10 @@
1
1
  // WorkspaceSwitcher Render-Tests (Phase 1, test-luecken-integration, Tier 2).
2
2
  //
3
- // Dumb presentational component (kein Radix). Pinnt: kein Switcher bei
4
- // <= 1 Workspace, tablist mit aria-selected am aktiven, onSelect-Callback.
5
- // Nutzt useTranslation über test-utils mit LocaleProvider gerendert.
3
+ // Dropdown built on Radix (like LanguageSwitcher/TenantSwitcher). Pins:
4
+ // no switcher at <= 1 workspace, trigger shows the active label, dropdown
5
+ // lists all workspaces with aria-checked on the active one, onSelect
6
+ // callback. Radix opens on pointerdown → userEvent instead of
7
+ // fireEvent.click, same as language-switcher.test.tsx.
6
8
 
7
9
  import { describe, expect, mock, test } from "bun:test";
8
10
  import {
@@ -10,7 +12,8 @@ import {
10
12
  LocaleProvider,
11
13
  type WorkspaceSchema,
12
14
  } from "@cosmicdrift/kumiko-renderer";
13
- import { fireEvent, render, screen } from "../../__tests__/test-utils";
15
+ import userEvent from "@testing-library/user-event";
16
+ import { renderWithSidebar, screen } from "../../__tests__/test-utils";
14
17
  import { WorkspaceSwitcher } from "../workspace-switcher";
15
18
 
16
19
  function ws(id: string, label = id): WorkspaceSchema {
@@ -19,14 +22,14 @@ function ws(id: string, label = id): WorkspaceSchema {
19
22
 
20
23
  describe("WorkspaceSwitcher — Render", () => {
21
24
  test("ein einziger Workspace → rendert nichts (kein nutzloser Switcher)", () => {
22
- const { container } = render(
25
+ const { container } = renderWithSidebar(
23
26
  <WorkspaceSwitcher workspaces={[ws("a")]} activeId="a" onSelect={() => {}} />,
24
27
  );
25
- expect(container.querySelector('[role="tablist"]')).toBeNull();
28
+ expect(container.querySelector('[data-testid="workspace-switcher-trigger"]')).toBeNull();
26
29
  });
27
30
 
28
- test("mehrere Workspaces → tablist, aria-selected am aktiven Tab", () => {
29
- render(
31
+ test("mehrere Workspaces → Trigger zeigt aktives Label", () => {
32
+ renderWithSidebar(
30
33
  <WorkspaceSwitcher
31
34
  workspaces={[ws("a", "Alpha"), ws("b", "Beta")]}
32
35
  activeId="b"
@@ -34,29 +37,45 @@ describe("WorkspaceSwitcher — Render", () => {
34
37
  testId="sw"
35
38
  />,
36
39
  );
37
- expect(screen.getByTestId("sw").getAttribute("role")).toBe("tablist");
38
- expect(screen.getByTestId("workspace-tab-a").getAttribute("aria-selected")).toBe("false");
39
- expect(screen.getByTestId("workspace-tab-b").getAttribute("aria-selected")).toBe("true");
40
+ expect(screen.getByTestId("sw")).toBeTruthy();
41
+ expect(screen.getByText("Beta")).toBeTruthy();
40
42
  });
41
43
 
42
- test("Click ruft onSelect mit der Workspace-id", () => {
44
+ test("Dropdown öffnen listet alle Workspaces, aria-checked am aktiven", async () => {
45
+ const user = userEvent.setup();
46
+ renderWithSidebar(
47
+ <WorkspaceSwitcher
48
+ workspaces={[ws("a", "Alpha"), ws("b", "Beta")]}
49
+ activeId="b"
50
+ onSelect={() => {}}
51
+ />,
52
+ );
53
+ await user.click(screen.getByTestId("workspace-switcher-trigger"));
54
+ expect(screen.getByTestId("workspace-tab-a").getAttribute("aria-checked")).toBe("false");
55
+ expect(screen.getByTestId("workspace-tab-b").getAttribute("aria-checked")).toBe("true");
56
+ });
57
+
58
+ test("Click auf einen Eintrag ruft onSelect mit der Workspace-id", async () => {
59
+ const user = userEvent.setup();
43
60
  const onSelect = mock((_id: string) => {});
44
- render(
61
+ renderWithSidebar(
45
62
  <WorkspaceSwitcher
46
63
  workspaces={[ws("a", "Alpha"), ws("b", "Beta")]}
47
64
  activeId="a"
48
65
  onSelect={onSelect}
49
66
  />,
50
67
  );
51
- fireEvent.click(screen.getByTestId("workspace-tab-b"));
68
+ await user.click(screen.getByTestId("workspace-switcher-trigger"));
69
+ await user.click(screen.getByTestId("workspace-tab-b"));
52
70
  expect(onSelect).toHaveBeenCalledWith("b");
53
71
  });
54
72
  });
55
73
 
56
74
  describe("WorkspaceSwitcher — i18n-Labels (Punkt-Konvention)", () => {
57
- test("Label mit Punkt geht durch t() und rendert die Übersetzung", () => {
75
+ test("Label mit Punkt geht durch t() und rendert die Übersetzung", async () => {
76
+ const user = userEvent.setup();
58
77
  const bundle = { en: { "nav.adminArea": "Admin Area" } };
59
- const { getByTestId } = render(
78
+ renderWithSidebar(
60
79
  <LocaleProvider
61
80
  resolver={createStaticLocaleResolver({ locale: "en" })}
62
81
  fallbackBundles={[bundle]}
@@ -68,8 +87,10 @@ describe("WorkspaceSwitcher — i18n-Labels (Punkt-Konvention)", () => {
68
87
  />
69
88
  </LocaleProvider>,
70
89
  );
71
- expect(getByTestId("workspace-tab-a").textContent).toBe("Admin Area");
72
- // ohne Punkt: verbatim, kein t()-Roundtrip
73
- expect(getByTestId("workspace-tab-b").textContent).toBe("Plain Label");
90
+ // Trigger shows the translated active label.
91
+ expect(screen.getByText("Admin Area")).toBeTruthy();
92
+ await user.click(screen.getByTestId("workspace-switcher-trigger"));
93
+ // No dot: verbatim, no t() roundtrip.
94
+ expect(screen.getByTestId("workspace-tab-b").textContent).toBe("Plain Label");
74
95
  });
75
96
  });
@@ -31,11 +31,13 @@ import {
31
31
  import {
32
32
  BarChart3,
33
33
  Bell,
34
+ BookOpen,
34
35
  Building,
35
36
  Calculator,
36
37
  CalendarDays,
37
38
  ChevronDown,
38
39
  ChevronRight,
40
+ ClipboardList,
39
41
  Coins,
40
42
  CreditCard,
41
43
  Download,
@@ -54,6 +56,7 @@ import {
54
56
  List,
55
57
  Lock,
56
58
  Mail,
59
+ Package,
57
60
  Palette,
58
61
  PiggyBank,
59
62
  Plus,
@@ -109,6 +112,9 @@ import { parseTargetFromSearchParams } from "./target-url";
109
112
  const NAV_ICONS: Readonly<Record<string, typeof Folder>> = {
110
113
  dashboard: LayoutDashboard,
111
114
  "layout-grid": LayoutGrid,
115
+ "book-open": BookOpen,
116
+ "clipboard-list": ClipboardList,
117
+ package: Package,
112
118
  gauge: Gauge,
113
119
  list: List,
114
120
  table: Table,
@@ -231,7 +237,7 @@ export function NavTree({
231
237
  <NavFilterContext.Provider value={navFilter}>
232
238
  <NavBadgesContext.Provider value={navBadges ?? EMPTY_BADGES}>
233
239
  <div data-testid={testId} data-kumiko-layout="nav-tree" className="flex w-full flex-col">
234
- <div className="px-2 pt-2 pb-1">
240
+ <div className="px-2 pt-2 pb-1 group-data-[collapsible=icon]:hidden">
235
241
  <SidebarInput
236
242
  value={filter}
237
243
  onChange={(e) => setFilter(e.target.value)}
@@ -37,11 +37,13 @@ export function SidebarBrand({
37
37
  <div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
38
38
  {logo ?? <span className="text-sm font-semibold">{name.charAt(0)}</span>}
39
39
  </div>
40
- <div className="grid flex-1 text-left text-sm leading-tight">
40
+ <div className="grid flex-1 text-left text-sm leading-tight group-data-[collapsible=icon]:hidden">
41
41
  <span className="truncate font-semibold">{name}</span>
42
42
  {plan !== undefined && <span className="truncate text-xs">{plan}</span>}
43
43
  </div>
44
- {collapsible && <ChevronsUpDown className="ml-auto" />}
44
+ {collapsible && (
45
+ <ChevronsUpDown className="ml-auto group-data-[collapsible=icon]:hidden" />
46
+ )}
45
47
  </SidebarMenuButton>
46
48
  </SidebarMenuItem>
47
49
  </SidebarMenu>
@@ -3,11 +3,28 @@
3
3
  // id, and a callback. Stays presentational so WorkspaceShell can own the
4
4
  // state (URL ?w=, defaults, role filtering) and tests can hand any list
5
5
  // in directly.
6
+ //
7
+ // Dropdown instead of a tab row: a row of fixed buttons overflowed the
8
+ // sidebar width with 3+ workspaces (or even 2 longer names) —
9
+ // truncate+scroll then hid the last entry entirely, unclickable. A
10
+ // dropdown has constant trigger width regardless of count/length.
11
+ //
12
+ // Hidden entirely when collapsed instead of an icon/initial: a label at
13
+ // icon width isn't readable anyway, and the user switches workspaces via
14
+ // the full sidebar — no attempt to keep this operable in the icon rail
15
+ // too (same as the search box in nav-tree.tsx).
6
16
 
7
17
  import type { WorkspaceSchema } from "@cosmicdrift/kumiko-renderer";
8
18
  import { useTranslation } from "@cosmicdrift/kumiko-renderer";
19
+ import { ChevronsUpDown } from "lucide-react";
9
20
  import type { ReactNode } from "react";
10
- import { cn } from "../lib/cn";
21
+ import {
22
+ DropdownMenu,
23
+ DropdownMenuCheckboxItem,
24
+ DropdownMenuContent,
25
+ DropdownMenuTrigger,
26
+ } from "../primitives/dropdown-menu";
27
+ import { SidebarMenu, SidebarMenuButton, SidebarMenuItem } from "../ui/sidebar";
11
28
 
12
29
  export type WorkspaceSwitcherProps = {
13
30
  readonly workspaces: readonly WorkspaceSchema[];
@@ -26,37 +43,39 @@ export function WorkspaceSwitcher({
26
43
  // Single workspace doesn't need a switcher — the user has no choice
27
44
  // anyway. Render nothing instead of a useless one-button row.
28
45
  if (workspaces.length <= 1) return null;
46
+
47
+ const labelOf = (ws: WorkspaceSchema): string =>
48
+ ws.definition.label.includes(".") ? t(ws.definition.label) : ws.definition.label;
49
+ const active = workspaces.find((ws) => ws.definition.id === activeId);
50
+
29
51
  return (
30
- <div
52
+ <SidebarMenu
31
53
  data-testid={testId}
32
54
  data-kumiko-layout="workspace-switcher"
33
- role="tablist"
34
- className="flex items-center gap-1"
55
+ className="group-data-[collapsible=icon]:hidden"
35
56
  >
36
- {workspaces.map((ws) => {
37
- const active = ws.definition.id === activeId;
38
- const label = ws.definition.label.includes(".")
39
- ? t(ws.definition.label)
40
- : ws.definition.label;
41
- return (
42
- <button
43
- type="button"
44
- key={ws.definition.id}
45
- role="tab"
46
- aria-selected={active}
47
- data-testid={`workspace-tab-${ws.definition.id}`}
48
- onClick={() => onSelect(ws.definition.id)}
49
- className={cn(
50
- "rounded-md px-3 py-1.5 text-sm font-medium transition-colors",
51
- active
52
- ? "bg-accent text-accent-foreground"
53
- : "text-muted-foreground hover:bg-accent/40",
54
- )}
55
- >
56
- {label}
57
- </button>
58
- );
59
- })}
60
- </div>
57
+ <SidebarMenuItem>
58
+ <DropdownMenu>
59
+ <DropdownMenuTrigger asChild>
60
+ <SidebarMenuButton data-testid="workspace-switcher-trigger">
61
+ <span className="truncate">{active !== undefined ? labelOf(active) : ""}</span>
62
+ <ChevronsUpDown className="ml-auto" />
63
+ </SidebarMenuButton>
64
+ </DropdownMenuTrigger>
65
+ <DropdownMenuContent align="start" className="min-w-[10rem]">
66
+ {workspaces.map((ws) => (
67
+ <DropdownMenuCheckboxItem
68
+ key={ws.definition.id}
69
+ checked={ws.definition.id === activeId}
70
+ data-testid={`workspace-tab-${ws.definition.id}`}
71
+ onSelect={() => onSelect(ws.definition.id)}
72
+ >
73
+ <span className="truncate">{labelOf(ws)}</span>
74
+ </DropdownMenuCheckboxItem>
75
+ ))}
76
+ </DropdownMenuContent>
77
+ </DropdownMenu>
78
+ </SidebarMenuItem>
79
+ </SidebarMenu>
61
80
  );
62
81
  }