@stigmer/react 3.4.1 → 3.5.1

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.
Files changed (99) hide show
  1. package/agent/AgentDetailView.d.ts +14 -4
  2. package/agent/AgentDetailView.d.ts.map +1 -1
  3. package/agent/AgentDetailView.js +73 -11
  4. package/agent/AgentDetailView.js.map +1 -1
  5. package/agent/internal/agentToInput.d.ts +3 -0
  6. package/agent/internal/agentToInput.d.ts.map +1 -1
  7. package/agent/internal/agentToInput.js +21 -1
  8. package/agent/internal/agentToInput.js.map +1 -1
  9. package/api-key/ApiKeyListPanel.d.ts +8 -1
  10. package/api-key/ApiKeyListPanel.d.ts.map +1 -1
  11. package/api-key/ApiKeyListPanel.js +9 -7
  12. package/api-key/ApiKeyListPanel.js.map +1 -1
  13. package/dependency-graph/DependencyTreeNode.d.ts.map +1 -1
  14. package/dependency-graph/DependencyTreeNode.js +8 -0
  15. package/dependency-graph/DependencyTreeNode.js.map +1 -1
  16. package/dependency-graph/types.d.ts +20 -8
  17. package/dependency-graph/types.d.ts.map +1 -1
  18. package/dependency-graph/useDependencyGraph.d.ts.map +1 -1
  19. package/dependency-graph/useDependencyGraph.js +21 -1
  20. package/dependency-graph/useDependencyGraph.js.map +1 -1
  21. package/index.d.ts +2 -0
  22. package/index.d.ts.map +1 -1
  23. package/index.js +4 -0
  24. package/index.js.map +1 -1
  25. package/internal/scroll-area.d.ts +4 -0
  26. package/internal/scroll-area.d.ts.map +1 -0
  27. package/internal/scroll-area.js +15 -0
  28. package/internal/scroll-area.js.map +1 -0
  29. package/internal/tooltip.d.ts +7 -0
  30. package/internal/tooltip.d.ts.map +1 -0
  31. package/internal/tooltip.js +31 -0
  32. package/internal/tooltip.js.map +1 -0
  33. package/mcp-server/StdioSandboxNotice.d.ts +12 -11
  34. package/mcp-server/StdioSandboxNotice.d.ts.map +1 -1
  35. package/mcp-server/StdioSandboxNotice.js +13 -12
  36. package/mcp-server/StdioSandboxNotice.js.map +1 -1
  37. package/mcp-server/steps/IdentityTransportStep.d.ts.map +1 -1
  38. package/mcp-server/steps/IdentityTransportStep.js +1 -1
  39. package/mcp-server/steps/IdentityTransportStep.js.map +1 -1
  40. package/package.json +4 -4
  41. package/resource-creation/templates/mcp-server-templates.d.ts.map +1 -1
  42. package/resource-creation/templates/mcp-server-templates.js +17 -14
  43. package/resource-creation/templates/mcp-server-templates.js.map +1 -1
  44. package/session/useSessionExecutions.d.ts +14 -0
  45. package/session/useSessionExecutions.d.ts.map +1 -1
  46. package/session/useSessionExecutions.js +27 -1
  47. package/session/useSessionExecutions.js.map +1 -1
  48. package/sidebar/SettingsSidebar.d.ts +62 -0
  49. package/sidebar/SettingsSidebar.d.ts.map +1 -0
  50. package/sidebar/SettingsSidebar.js +60 -0
  51. package/sidebar/SettingsSidebar.js.map +1 -0
  52. package/sidebar/WorkspaceSidebar.d.ts +87 -0
  53. package/sidebar/WorkspaceSidebar.d.ts.map +1 -0
  54. package/sidebar/WorkspaceSidebar.js +95 -0
  55. package/sidebar/WorkspaceSidebar.js.map +1 -0
  56. package/sidebar/chrome.d.ts +48 -0
  57. package/sidebar/chrome.d.ts.map +1 -0
  58. package/sidebar/chrome.js +32 -0
  59. package/sidebar/chrome.js.map +1 -0
  60. package/sidebar/index.d.ts +6 -0
  61. package/sidebar/index.d.ts.map +1 -0
  62. package/sidebar/index.js +9 -0
  63. package/sidebar/index.js.map +1 -0
  64. package/sidebar/types.d.ts +54 -0
  65. package/sidebar/types.d.ts.map +1 -0
  66. package/sidebar/types.js +2 -0
  67. package/sidebar/types.js.map +1 -0
  68. package/src/agent/AgentDetailView.tsx +198 -11
  69. package/src/agent/__tests__/AgentDetailView.datastores.test.tsx +166 -0
  70. package/src/agent/internal/__tests__/agentToInput.test.ts +154 -0
  71. package/src/agent/internal/agentToInput.ts +25 -7
  72. package/src/api-key/ApiKeyListPanel.tsx +17 -4
  73. package/src/dependency-graph/DependencyTreeNode.tsx +8 -0
  74. package/src/dependency-graph/__tests__/useDependencyGraph.test.ts +108 -0
  75. package/src/dependency-graph/types.ts +20 -8
  76. package/src/dependency-graph/useDependencyGraph.ts +23 -1
  77. package/src/index.ts +13 -0
  78. package/src/internal/scroll-area.tsx +37 -0
  79. package/src/internal/tooltip.tsx +66 -0
  80. package/src/mcp-server/StdioSandboxNotice.tsx +16 -14
  81. package/src/mcp-server/__tests__/StdioSandboxNotice.test.tsx +3 -2
  82. package/src/mcp-server/steps/IdentityTransportStep.tsx +7 -0
  83. package/src/resource-creation/templates/mcp-server-templates.ts +17 -14
  84. package/src/session/__tests__/useSessionExecutions.test.ts +63 -0
  85. package/src/session/useSessionExecutions.ts +27 -1
  86. package/src/sidebar/SettingsSidebar.tsx +173 -0
  87. package/src/sidebar/WorkspaceSidebar.tsx +386 -0
  88. package/src/sidebar/__tests__/SettingsSidebar.test.tsx +107 -0
  89. package/src/sidebar/__tests__/WorkspaceSidebar.test.tsx +236 -0
  90. package/src/sidebar/chrome.tsx +129 -0
  91. package/src/sidebar/index.ts +16 -0
  92. package/src/sidebar/types.ts +55 -0
  93. package/src/test/__tests__/samples.test.ts +2 -0
  94. package/src/test/samples.ts +48 -0
  95. package/styles.css +1 -1
  96. package/test/samples.d.ts +21 -0
  97. package/test/samples.d.ts.map +1 -1
  98. package/test/samples.js +32 -0
  99. package/test/samples.js.map +1 -1
@@ -0,0 +1,386 @@
1
+ "use client";
2
+
3
+ import { memo, useMemo, type ReactNode } from "react";
4
+ import {
5
+ Plus,
6
+ LayoutDashboard,
7
+ Library,
8
+ MessageSquare,
9
+ Workflow,
10
+ type LucideIcon,
11
+ } from "lucide-react";
12
+ import type { Organization } from "@stigmer/protos/ai/stigmer/tenancy/organization/v1/api_pb";
13
+ import type { RecentActivityEntry, RecentActivityGroup } from "../activity/types.js";
14
+ import { groupRecentActivityByTime } from "../activity/group-activity.js";
15
+ import { formatRelativeTime } from "../activity/format-relative-time.js";
16
+ import { recentActivityStatusBadge } from "../activity/entry-status-badge.js";
17
+ import { ScrollArea } from "../internal/scroll-area.js";
18
+ import {
19
+ Tooltip,
20
+ TooltipProvider,
21
+ TooltipTrigger,
22
+ TooltipContent,
23
+ } from "../internal/tooltip.js";
24
+ import { SidebarChrome, SidebarSeparator, navRowClassName } from "./chrome.js";
25
+ import type { RenderSidebarLink } from "./types.js";
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Public API
29
+ // ---------------------------------------------------------------------------
30
+
31
+ /** Primary navigation destinations in the workspace sidebar. */
32
+ export type WorkspaceNavId = "new-session" | "dashboard" | "library";
33
+
34
+ /**
35
+ * Recent-activity data for the sidebar's Recents section — the return
36
+ * shape of `useRecentActivity`, narrowed to what the sidebar renders.
37
+ * Deterministic hosts pass frozen fixture entries instead.
38
+ */
39
+ export interface WorkspaceSidebarActivity {
40
+ readonly entries: readonly RecentActivityEntry[];
41
+ /** True during the initial fetch; renders loading skeletons. */
42
+ readonly isLoading?: boolean;
43
+ /** Non-null when the fetch failed; renders an inline alert. */
44
+ readonly error?: { readonly message: string } | null;
45
+ }
46
+
47
+ /** Props for {@link WorkspaceSidebar}. */
48
+ export interface WorkspaceSidebarProps {
49
+ /**
50
+ * Which primary nav row is the current location, or `null`/omitted
51
+ * when none is (e.g. inside a session). The sidebar renders state
52
+ * only — the host decides what "active" means for its routes.
53
+ */
54
+ readonly activeNav?: WorkspaceNavId | null;
55
+ /** Renders each interactive row. See {@link RenderSidebarLink}. */
56
+ readonly renderLink: RenderSidebarLink;
57
+ /** Recent activity entries plus fetch state. */
58
+ readonly recentActivity: WorkspaceSidebarActivity;
59
+ /** Session id highlighted in Recents, if a session is open. */
60
+ readonly activeSessionId?: string | null;
61
+ /** Workflow-execution id highlighted in Recents, if one is open. */
62
+ readonly activeExecutionId?: string | null;
63
+ /**
64
+ * Optional trailing accessory per recents row — e.g. the desktop
65
+ * app's "running in background" pulse dot. Rendered after the
66
+ * timestamp column, inside the row.
67
+ */
68
+ readonly renderEntryAccessory?: (entry: RecentActivityEntry) => ReactNode;
69
+ /** Footer content — typically the host app's `UserMenu` wrapper. */
70
+ readonly footer: ReactNode;
71
+ /** Reflected as `aria-expanded` on the collapse toggle. @default true */
72
+ readonly isOpen?: boolean;
73
+ /** Called when the collapse toggle is pressed. */
74
+ readonly onCollapse?: () => void;
75
+ /** Passed through to the embedded `OrgSwitcher`. */
76
+ readonly onOrgChanged?: (org: Organization) => void;
77
+ /**
78
+ * Reference instant for time bucketing ("Today"/"Yesterday") and
79
+ * relative stamps ("2h"). Defaults to the live clock. **Deterministic
80
+ * hosts (documentation embeds, video export) must pass a frozen
81
+ * instant** — otherwise the depicted times drift with every replay.
82
+ */
83
+ readonly now?: Date;
84
+ }
85
+
86
+ /**
87
+ * The console's workspace-zone sidebar: org switcher, primary navigation
88
+ * (New Session / Dashboard / Library), time-bucketed recent activity,
89
+ * and a user footer.
90
+ *
91
+ * This is the same component the Stigmer web console and desktop app
92
+ * render — hosts differ only in what they return from `renderLink`
93
+ * (their router's link), what they pass as `recentActivity` (live hook
94
+ * data or frozen fixtures), and the `footer` slot. Documentation tours
95
+ * render it too, which is the point: the depicted chrome cannot drift
96
+ * from the product because it *is* the product (stigmer/stigmer#317).
97
+ *
98
+ * Layout note: the sidebar fills its container's height and defines no
99
+ * width — the console's app shell owns the `w-70` (280px) column.
100
+ *
101
+ * @example
102
+ * ```tsx
103
+ * <WorkspaceSidebar
104
+ * activeNav="library"
105
+ * renderLink={({ href, children, ...rest }) => (
106
+ * <Link href={href} {...rest}>{children}</Link>
107
+ * )}
108
+ * recentActivity={useRecentActivity()}
109
+ * footer={<UserMenu />}
110
+ * />
111
+ * ```
112
+ */
113
+ export function WorkspaceSidebar({
114
+ activeNav = null,
115
+ renderLink,
116
+ recentActivity,
117
+ activeSessionId = null,
118
+ activeExecutionId = null,
119
+ renderEntryAccessory,
120
+ footer,
121
+ isOpen = true,
122
+ onCollapse,
123
+ onOrgChanged,
124
+ now,
125
+ }: WorkspaceSidebarProps) {
126
+ const { entries, isLoading = false, error = null } = recentActivity;
127
+
128
+ const groups = useMemo(
129
+ () => groupRecentActivityByTime(entries, now),
130
+ [entries, now],
131
+ );
132
+
133
+ return (
134
+ <SidebarChrome
135
+ ariaLabel="Main navigation"
136
+ isOpen={isOpen}
137
+ onCollapse={onCollapse}
138
+ onOrgChanged={onOrgChanged}
139
+ footer={footer}
140
+ >
141
+ <PrimaryNavRow
142
+ id="new-session"
143
+ href="/"
144
+ label="New Session"
145
+ icon={Plus}
146
+ active={activeNav === "new-session"}
147
+ renderLink={renderLink}
148
+ />
149
+ <PrimaryNavRow
150
+ id="dashboard"
151
+ href="/dashboard"
152
+ label="Dashboard"
153
+ icon={LayoutDashboard}
154
+ active={activeNav === "dashboard"}
155
+ renderLink={renderLink}
156
+ />
157
+ <PrimaryNavRow
158
+ id="library"
159
+ href="/library"
160
+ label="Library"
161
+ icon={Library}
162
+ active={activeNav === "library"}
163
+ renderLink={renderLink}
164
+ />
165
+
166
+ <SidebarSeparator />
167
+
168
+ {/* Scrollable recents */}
169
+ <ScrollArea className="flex-1">
170
+ <div className="p-3">
171
+ <p className="text-sidebar-muted-foreground mb-2 px-1 text-[11px] font-semibold tracking-wider uppercase">
172
+ Recents
173
+ </p>
174
+ {isLoading ? (
175
+ <RecentsSkeletons />
176
+ ) : error ? (
177
+ <RecentsError message={error.message} />
178
+ ) : groups.length === 0 ? (
179
+ <RecentsEmptyState />
180
+ ) : (
181
+ <ActivityGroupList
182
+ groups={groups}
183
+ activeSessionId={activeSessionId}
184
+ activeExecutionId={activeExecutionId}
185
+ renderLink={renderLink}
186
+ renderEntryAccessory={renderEntryAccessory}
187
+ now={now}
188
+ />
189
+ )}
190
+ </div>
191
+ </ScrollArea>
192
+ </SidebarChrome>
193
+ );
194
+ }
195
+
196
+ // ---------------------------------------------------------------------------
197
+ // Internals
198
+ // ---------------------------------------------------------------------------
199
+
200
+ function PrimaryNavRow({
201
+ id,
202
+ href,
203
+ label,
204
+ icon: Icon,
205
+ active,
206
+ renderLink,
207
+ }: {
208
+ readonly id: WorkspaceNavId;
209
+ readonly href: string;
210
+ readonly label: string;
211
+ readonly icon: LucideIcon;
212
+ readonly active: boolean;
213
+ readonly renderLink: RenderSidebarLink;
214
+ }) {
215
+ return (
216
+ <div className="flex-none px-3 py-1">
217
+ {renderLink({
218
+ id,
219
+ href,
220
+ active,
221
+ className: navRowClassName(active),
222
+ "aria-current": active ? "page" : undefined,
223
+ children: (
224
+ <>
225
+ <Icon className="size-4 shrink-0" />
226
+ {label}
227
+ </>
228
+ ),
229
+ })}
230
+ </div>
231
+ );
232
+ }
233
+
234
+ function ActivityGroupList({
235
+ groups,
236
+ activeSessionId,
237
+ activeExecutionId,
238
+ renderLink,
239
+ renderEntryAccessory,
240
+ now,
241
+ }: {
242
+ readonly groups: readonly RecentActivityGroup[];
243
+ readonly activeSessionId: string | null;
244
+ readonly activeExecutionId: string | null;
245
+ readonly renderLink: RenderSidebarLink;
246
+ readonly renderEntryAccessory?: (entry: RecentActivityEntry) => ReactNode;
247
+ readonly now?: Date;
248
+ }) {
249
+ return (
250
+ <TooltipProvider>
251
+ <div className="space-y-4">
252
+ {groups.map((group) => (
253
+ <div key={group.label}>
254
+ <p className="text-sidebar-muted-foreground mb-1 px-2 text-[10px] font-medium tracking-wider uppercase">
255
+ {group.label}
256
+ </p>
257
+ <ul className="space-y-0.5" role="list">
258
+ {group.entries.map((entry) => (
259
+ <ActivityEntry
260
+ key={entry.id}
261
+ entry={entry}
262
+ activeSessionId={activeSessionId}
263
+ activeExecutionId={activeExecutionId}
264
+ renderLink={renderLink}
265
+ renderEntryAccessory={renderEntryAccessory}
266
+ now={now}
267
+ />
268
+ ))}
269
+ </ul>
270
+ </div>
271
+ ))}
272
+ </div>
273
+ </TooltipProvider>
274
+ );
275
+ }
276
+
277
+ const ActivityEntry = memo(function ActivityEntry({
278
+ entry,
279
+ activeSessionId,
280
+ activeExecutionId,
281
+ renderLink,
282
+ renderEntryAccessory,
283
+ now,
284
+ }: {
285
+ readonly entry: RecentActivityEntry;
286
+ readonly activeSessionId: string | null;
287
+ readonly activeExecutionId: string | null;
288
+ readonly renderLink: RenderSidebarLink;
289
+ readonly renderEntryAccessory?: (entry: RecentActivityEntry) => ReactNode;
290
+ readonly now?: Date;
291
+ }) {
292
+ const isSession = entry.type === "session";
293
+ const isActive = isSession
294
+ ? entry.id === activeSessionId
295
+ : entry.id === activeExecutionId;
296
+ const href = isSession ? `/sessions/${entry.id}` : `/executions/${entry.id}`;
297
+ const TypeIcon = isSession ? MessageSquare : Workflow;
298
+ const statusBadge = recentActivityStatusBadge(entry);
299
+
300
+ const row = renderLink({
301
+ id: entry.id,
302
+ href,
303
+ active: isActive,
304
+ "aria-current": isActive ? "page" : undefined,
305
+ entry,
306
+ className: cnActivityRow(isActive),
307
+ children: (
308
+ <>
309
+ <TypeIcon className="mt-0.5 size-3 shrink-0 opacity-50" aria-hidden="true" />
310
+ <span className="line-clamp-2 flex-1">{entry.subject}</span>
311
+ {/* Last-activity stamp + noteworthy status: the list sorts by
312
+ activity while execution names embed creation time, so the row
313
+ must say WHY it is here ("failed · 2h"). */}
314
+ <span className="flex shrink-0 flex-col items-end gap-0.5 text-[10px] leading-tight">
315
+ <span className="text-sidebar-muted-foreground tabular-nums">
316
+ {formatRelativeTime(entry.updatedAt, now)}
317
+ </span>
318
+ {statusBadge && (
319
+ <span
320
+ className={
321
+ statusBadge.tone === "destructive"
322
+ ? "text-destructive"
323
+ : "text-sidebar-muted-foreground"
324
+ }
325
+ >
326
+ {statusBadge.label}
327
+ </span>
328
+ )}
329
+ </span>
330
+ {renderEntryAccessory?.(entry)}
331
+ </>
332
+ ),
333
+ });
334
+
335
+ return (
336
+ <li>
337
+ <Tooltip>
338
+ <TooltipTrigger render={row} />
339
+ <TooltipContent side="right" sideOffset={12}>
340
+ {entry.subject}
341
+ </TooltipContent>
342
+ </Tooltip>
343
+ </li>
344
+ );
345
+ });
346
+
347
+ /** Recents rows are denser than primary nav: 12px text, top-aligned icon. */
348
+ function cnActivityRow(active: boolean): string {
349
+ return active
350
+ ? "flex items-start gap-2 rounded-lg px-2 py-1.5 text-xs transition-colors bg-sidebar-accent text-sidebar-accent-foreground font-medium"
351
+ : "flex items-start gap-2 rounded-lg px-2 py-1.5 text-xs transition-colors text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground";
352
+ }
353
+
354
+ function RecentsSkeletons() {
355
+ return (
356
+ <div className="space-y-2 px-2" aria-busy="true" aria-label="Loading sessions">
357
+ {Array.from({ length: 5 }, (_, i) => (
358
+ <div
359
+ key={i}
360
+ className="bg-sidebar-muted h-5 animate-pulse rounded"
361
+ style={{ width: `${70 + Math.sin(i * 1.5) * 20}%` }}
362
+ />
363
+ ))}
364
+ </div>
365
+ );
366
+ }
367
+
368
+ function RecentsError({ message }: { readonly message: string }) {
369
+ return (
370
+ <>
371
+ <p className="text-destructive mb-4 px-2 text-xs" role="alert">
372
+ {message}
373
+ </p>
374
+ <RecentsEmptyState />
375
+ </>
376
+ );
377
+ }
378
+
379
+ function RecentsEmptyState() {
380
+ return (
381
+ <div className="flex flex-col items-center gap-2 py-8 text-center">
382
+ <MessageSquare className="text-sidebar-muted-foreground size-8" />
383
+ <p className="text-sidebar-muted-foreground text-xs">No recent activity</p>
384
+ </div>
385
+ );
386
+ }
@@ -0,0 +1,107 @@
1
+ import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
2
+ import { render, screen, cleanup } from "@testing-library/react";
3
+ import type { ReactNode } from "react";
4
+ import { createRouterTransport } from "@connectrpc/connect";
5
+ import { Stigmer } from "@stigmer/sdk";
6
+ import { StigmerContext } from "../../context";
7
+ import { OrgProvider } from "../../organization/OrgProvider";
8
+ import { SETTINGS_NAV_GROUPS } from "../../settings/settings-nav";
9
+ import { SettingsSidebar } from "../SettingsSidebar";
10
+ import type { RenderSidebarLink } from "../types";
11
+
12
+ vi.mock("../../portal-container", () => ({
13
+ useStigmerPortalContainer: () => document.body,
14
+ }));
15
+
16
+ beforeAll(() => {
17
+ if (!("ResizeObserver" in globalThis)) {
18
+ (globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver =
19
+ class {
20
+ observe() {}
21
+ unobserve() {}
22
+ disconnect() {}
23
+ };
24
+ }
25
+ });
26
+
27
+ afterEach(cleanup);
28
+
29
+ const renderAnchor: RenderSidebarLink = ({
30
+ id,
31
+ href,
32
+ className,
33
+ children,
34
+ "aria-current": ariaCurrent,
35
+ }) => (
36
+ <a href={href} data-row-id={id} aria-current={ariaCurrent} className={className}>
37
+ {children}
38
+ </a>
39
+ );
40
+
41
+ function renderSidebar(ui: ReactNode) {
42
+ // No RPCs registered: the org switcher degrades gracefully, which is not
43
+ // this suite's subject (covered by WorkspaceSidebar's transport test).
44
+ const client = new Stigmer({
45
+ baseUrl: "/",
46
+ getAccessToken: () => "test-token",
47
+ customTransport: createRouterTransport(() => {}),
48
+ });
49
+ return render(
50
+ <StigmerContext.Provider value={client}>
51
+ <OrgProvider>{ui}</OrgProvider>
52
+ </StigmerContext.Provider>,
53
+ );
54
+ }
55
+
56
+ function baseProps() {
57
+ return {
58
+ groups: SETTINGS_NAV_GROUPS,
59
+ renderLink: renderAnchor,
60
+ footer: <span data-testid="footer-slot">footer</span>,
61
+ };
62
+ }
63
+
64
+ describe("SettingsSidebar", () => {
65
+ it("renders every group heading and item from the nav model", () => {
66
+ const { container } = renderSidebar(<SettingsSidebar {...baseProps()} />);
67
+
68
+ for (const group of SETTINGS_NAV_GROUPS) {
69
+ expect(screen.getByText(group.label)).toBeTruthy();
70
+ for (const item of group.items) {
71
+ const basename = item.href.slice(item.href.lastIndexOf("/") + 1);
72
+ const row = container.querySelector(`[data-row-id="${basename}"]`);
73
+ expect(row?.getAttribute("href")).toBe(item.href);
74
+ }
75
+ }
76
+ });
77
+
78
+ it("matches the active row by exact path or subpath, never by sibling prefix", () => {
79
+ const { container } = renderSidebar(
80
+ <SettingsSidebar {...baseProps()} activePath="/settings/api-keys/rotate" />,
81
+ );
82
+
83
+ const active = container.querySelector('[data-row-id="api-keys"]')!;
84
+ expect(active.getAttribute("aria-current")).toBe("page");
85
+ expect(active.className).toContain("bg-sidebar-accent");
86
+
87
+ // "/settings/api-keys/rotate" must not light up e.g. "/settings/members".
88
+ const inactive = container.querySelector('[data-row-id="members"]')!;
89
+ expect(inactive.getAttribute("aria-current")).toBeNull();
90
+ });
91
+
92
+ it("renders Back to Sessions as a muted exit row targeting backHref", () => {
93
+ const { container } = renderSidebar(
94
+ <SettingsSidebar {...baseProps()} backHref="/sessions/ses_123" />,
95
+ );
96
+
97
+ const back = container.querySelector('[data-row-id="back-to-sessions"]')!;
98
+ expect(back.getAttribute("href")).toBe("/sessions/ses_123");
99
+ expect(back.className).toContain("text-sidebar-muted-foreground");
100
+ expect(back.textContent).toContain("Back to Sessions");
101
+ });
102
+
103
+ it("renders the footer slot", () => {
104
+ renderSidebar(<SettingsSidebar {...baseProps()} />);
105
+ expect(screen.getByTestId("footer-slot")).toBeTruthy();
106
+ });
107
+ });