@stigmer/react 3.13.0 → 3.14.0-dev.20260910084630
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/cursor-accounts/CursorAccountsConsole.d.ts +3 -2
- package/cursor-accounts/CursorAccountsConsole.d.ts.map +1 -1
- package/cursor-accounts/CursorAccountsConsole.js +3 -2
- package/cursor-accounts/CursorAccountsConsole.js.map +1 -1
- package/cursor-accounts/MemberCoverageTable.d.ts +11 -3
- package/cursor-accounts/MemberCoverageTable.d.ts.map +1 -1
- package/cursor-accounts/MemberCoverageTable.js +70 -42
- package/cursor-accounts/MemberCoverageTable.js.map +1 -1
- package/cursor-accounts/MemberKeysPanel.d.ts.map +1 -1
- package/cursor-accounts/MemberKeysPanel.js +4 -1
- package/cursor-accounts/MemberKeysPanel.js.map +1 -1
- package/internal/ResizableSplit.d.ts +1 -1
- package/package.json +4 -4
- package/sidebar/SettingsSidebar.d.ts.map +1 -1
- package/sidebar/SettingsSidebar.js +3 -3
- package/sidebar/SettingsSidebar.js.map +1 -1
- package/sidebar/WorkspaceSidebar.d.ts +1 -1
- package/sidebar/WorkspaceSidebar.d.ts.map +1 -1
- package/sidebar/WorkspaceSidebar.js +19 -15
- package/sidebar/WorkspaceSidebar.js.map +1 -1
- package/sidebar/chrome.d.ts +21 -7
- package/sidebar/chrome.d.ts.map +1 -1
- package/sidebar/chrome.js +25 -7
- package/sidebar/chrome.js.map +1 -1
- package/src/cursor-accounts/CursorAccountsConsole.tsx +3 -2
- package/src/cursor-accounts/MemberCoverageTable.tsx +222 -200
- package/src/cursor-accounts/MemberKeysPanel.tsx +25 -1
- package/src/cursor-accounts/__tests__/CursorAccountsConsole.test.tsx +64 -8
- package/src/cursor-accounts/__tests__/MemberCoverageTable.layout.test.tsx +212 -0
- package/src/internal/ResizableSplit.tsx +1 -1
- package/src/sidebar/SettingsSidebar.tsx +11 -5
- package/src/sidebar/WorkspaceSidebar.tsx +75 -67
- package/src/sidebar/__tests__/SettingsSidebar.test.tsx +6 -2
- package/src/sidebar/chrome.tsx +29 -15
- package/styles.css +1 -1
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
} from "@stigmer/protos/ai/stigmer/platform/cursoraccount/v1/io_pb";
|
|
22
22
|
import { StigmerContext } from "../../context";
|
|
23
23
|
import { FetchCacheContext } from "../../internal/FetchCacheProvider";
|
|
24
|
+
import { openMenu } from "../../__tests__/helpers/open-menu";
|
|
24
25
|
import { CursorAccountsConsole } from "../CursorAccountsConsole";
|
|
25
26
|
|
|
26
27
|
interface MockCursorAccounts {
|
|
@@ -257,13 +258,17 @@ describe("CursorAccountsConsole", () => {
|
|
|
257
258
|
await waitFor(() =>
|
|
258
259
|
expect(screen.getByRole("table", { name: "Team coverage" })).toBeTruthy(),
|
|
259
260
|
);
|
|
260
|
-
// Category 1: on the team, key held —
|
|
261
|
+
// Category 1: on the team, key held — the key name/label folds into
|
|
262
|
+
// the identity cell (no separate key column: stigmer#929 traded it
|
|
263
|
+
// for member-column width).
|
|
261
264
|
expect(screen.getByText(/On the team — key held/)).toBeTruthy();
|
|
262
265
|
expect(screen.getByText("zane@scenar.ai")).toBeTruthy();
|
|
263
266
|
expect(screen.getByText("stigmer-prod")).toBeTruthy();
|
|
264
|
-
// The email is the row's identity:
|
|
265
|
-
//
|
|
266
|
-
//
|
|
267
|
+
// The email is the row's identity: it truncates with the
|
|
268
|
+
// overflow-gated house tooltip (never break-words, which #929 showed
|
|
269
|
+
// degenerating to one character per line under column pressure) and
|
|
270
|
+
// never a native title (banned, stigmer-cloud#268) — the full value
|
|
271
|
+
// stays in the DOM as selectable text on both row kinds.
|
|
267
272
|
expect(document.querySelector("[title]")).toBeNull();
|
|
268
273
|
expect(screen.getByText("$0.17")).toBeTruthy(); // included, 169342 micro-USD
|
|
269
274
|
expect(screen.getByText("Active")).toBeTruthy();
|
|
@@ -282,6 +287,12 @@ describe("CursorAccountsConsole", () => {
|
|
|
282
287
|
// Header sync line: active members from server facts, removed seats
|
|
283
288
|
// by list arithmetic (roster entries minus active members).
|
|
284
289
|
expect(screen.getByText(/2 members · 1 removed seat/)).toBeTruthy();
|
|
290
|
+
// Only key-backed rows carry an actions kebab; gap rows have none.
|
|
291
|
+
expect(screen.getAllByRole("button", { name: /^Actions for/ })).toHaveLength(1);
|
|
292
|
+
// The heading subtitle answers roster health without scrolling to
|
|
293
|
+
// the gap group.
|
|
294
|
+
expect(screen.getByText(/1 covered/)).toBeTruthy();
|
|
295
|
+
expect(screen.getByText("1 without keys")).toBeTruthy();
|
|
285
296
|
});
|
|
286
297
|
|
|
287
298
|
it("affirms full coverage instead of hiding the gap group when every active member holds a key", async () => {
|
|
@@ -377,14 +388,16 @@ describe("CursorAccountsConsole", () => {
|
|
|
377
388
|
expect(screen.getByText("Not on team")).toBeTruthy();
|
|
378
389
|
expect(screen.getByText("Left team")).toBeTruthy();
|
|
379
390
|
|
|
380
|
-
//
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
await userEvent.click(
|
|
391
|
+
// ONE "Copy invite" in the group header — the link is account-level
|
|
392
|
+
// and identical for every off-team owner, so per-row buttons were
|
|
393
|
+
// redundant (stigmer#929). Always a copy, never a navigation.
|
|
394
|
+
await userEvent.click(screen.getByRole("button", { name: "Copy invite" }));
|
|
384
395
|
await waitFor(() =>
|
|
385
396
|
expect(writeText).toHaveBeenCalledWith("https://cursor.com/team-invite/abc"),
|
|
386
397
|
);
|
|
387
398
|
expect(screen.getByRole("button", { name: "Copied" })).toBeTruthy();
|
|
399
|
+
// The heading subtitle counts the off-team keys.
|
|
400
|
+
expect(screen.getByText(/2 off-team/)).toBeTruthy();
|
|
388
401
|
});
|
|
389
402
|
|
|
390
403
|
it("points at the Cursor dashboard when no invite link is configured", async () => {
|
|
@@ -448,6 +461,49 @@ describe("CursorAccountsConsole", () => {
|
|
|
448
461
|
expect(screen.queryByText(/Key held — not on the team/)).toBeNull();
|
|
449
462
|
});
|
|
450
463
|
|
|
464
|
+
it("drives Enable/Disable and Remove through the row's kebab menu", async () => {
|
|
465
|
+
const setMemberKeyEnabled = vi.fn().mockResolvedValue(scenarAccount());
|
|
466
|
+
const removeMemberKey = vi.fn().mockResolvedValue(scenarAccount());
|
|
467
|
+
const client = createMockStigmer({
|
|
468
|
+
listAccounts: vi.fn().mockResolvedValue({ accounts: [scenarSummary()] }),
|
|
469
|
+
getAccountView: vi.fn().mockResolvedValue(scenarView()),
|
|
470
|
+
setMemberKeyEnabled,
|
|
471
|
+
removeMemberKey,
|
|
472
|
+
});
|
|
473
|
+
render(<CursorAccountsConsole />, { wrapper: wrapper(client) });
|
|
474
|
+
|
|
475
|
+
await waitFor(() => expect(screen.getByText("scenar team")).toBeTruthy());
|
|
476
|
+
await userEvent.click(screen.getByRole("button", { name: /scenar team/ }));
|
|
477
|
+
await waitFor(() =>
|
|
478
|
+
expect(screen.getByRole("table", { name: "Team coverage" })).toBeTruthy(),
|
|
479
|
+
);
|
|
480
|
+
|
|
481
|
+
// The enabled key offers Disable; the menu closes on select.
|
|
482
|
+
await openMenu(
|
|
483
|
+
screen.getByRole("button", { name: "Actions for zane@scenar.ai" }),
|
|
484
|
+
);
|
|
485
|
+
await userEvent.click(screen.getByRole("menuitem", { name: "Disable" }));
|
|
486
|
+
await waitFor(() =>
|
|
487
|
+
expect(setMemberKeyEnabled).toHaveBeenCalledWith({
|
|
488
|
+
accountId: "acc-1",
|
|
489
|
+
keyId: "k-1",
|
|
490
|
+
enabled: false,
|
|
491
|
+
}),
|
|
492
|
+
);
|
|
493
|
+
|
|
494
|
+
// Remove is the kebab's destructive item (live-pin-guarded server-side).
|
|
495
|
+
await openMenu(
|
|
496
|
+
screen.getByRole("button", { name: "Actions for zane@scenar.ai" }),
|
|
497
|
+
);
|
|
498
|
+
await userEvent.click(screen.getByRole("menuitem", { name: "Remove" }));
|
|
499
|
+
await waitFor(() =>
|
|
500
|
+
expect(removeMemberKey).toHaveBeenCalledWith({
|
|
501
|
+
accountId: "acc-1",
|
|
502
|
+
keyId: "k-1",
|
|
503
|
+
}),
|
|
504
|
+
);
|
|
505
|
+
});
|
|
506
|
+
|
|
451
507
|
it("adds a member key and refreshes both views", async () => {
|
|
452
508
|
const addMemberKey = vi.fn().mockResolvedValue(scenarAccount());
|
|
453
509
|
const client = createMockStigmer({
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// Layout-contract regression suite for the coverage table's column
|
|
2
|
+
// budget (stigmer#929). Runs in a real Chromium via
|
|
3
|
+
// `vitest.a11y.config.ts` — the defect this pins was a grid-track
|
|
4
|
+
// collapse (fixed tracks starving the flexible member column until
|
|
5
|
+
// emails rendered one character per line), which only a real layout
|
|
6
|
+
// engine can resolve.
|
|
7
|
+
//
|
|
8
|
+
// The contract has two halves:
|
|
9
|
+
//
|
|
10
|
+
// 1. At the settings-page canvas (both client apps render settings in
|
|
11
|
+
// `max-w-3xl`, ~720px of content), the table fits WITHOUT horizontal
|
|
12
|
+
// scroll and the member column keeps enough width to render typical
|
|
13
|
+
// emails whole.
|
|
14
|
+
// 2. In a narrower host (the SDK component is embeddable at any width,
|
|
15
|
+
// DD-004), the grid's min-width guard turns crushing into horizontal
|
|
16
|
+
// scrolling — the member column never collapses.
|
|
17
|
+
//
|
|
18
|
+
// Like the provider layout suite (DD-019), this renders against the
|
|
19
|
+
// SHIPPED stylesheet (`dist/styles.css`, built by `npm run build:libs`),
|
|
20
|
+
// so the contract is verified on the artifact consumers actually load.
|
|
21
|
+
|
|
22
|
+
import "../../../dist/styles.css";
|
|
23
|
+
|
|
24
|
+
import { describe, it, expect, afterEach } from "vitest";
|
|
25
|
+
import { render, cleanup, screen } from "@testing-library/react";
|
|
26
|
+
import { create } from "@bufbuild/protobuf";
|
|
27
|
+
import { timestampFromDate } from "@bufbuild/protobuf/wkt";
|
|
28
|
+
import type { Stigmer } from "@stigmer/sdk";
|
|
29
|
+
import {
|
|
30
|
+
CursorAccountSchema,
|
|
31
|
+
CursorAccountSyncSnapshotSchema,
|
|
32
|
+
CursorMemberKeySchema,
|
|
33
|
+
CursorMemberSpendSchema,
|
|
34
|
+
CursorTeamMemberSchema,
|
|
35
|
+
} from "@stigmer/protos/ai/stigmer/platform/cursoraccount/v1/cursor_account_pb";
|
|
36
|
+
import {
|
|
37
|
+
CursorAccountViewSchema,
|
|
38
|
+
CursorMemberKeyViewSchema,
|
|
39
|
+
CursorMemberKeyState,
|
|
40
|
+
CursorTeamMemberViewSchema,
|
|
41
|
+
} from "@stigmer/protos/ai/stigmer/platform/cursoraccount/v1/io_pb";
|
|
42
|
+
import { StigmerProvider } from "../../provider";
|
|
43
|
+
import { deriveCoverage } from "../cursor-account-coverage";
|
|
44
|
+
import type { useCursorMemberKeyActions } from "../useCursorMemberKeyActions";
|
|
45
|
+
import { MemberCoverageTable } from "../MemberCoverageTable";
|
|
46
|
+
|
|
47
|
+
afterEach(cleanup);
|
|
48
|
+
|
|
49
|
+
// A minimal client: the provider eagerly fetches registries on mount;
|
|
50
|
+
// a null credential keeps that non-blocking and off the network.
|
|
51
|
+
function makeClient(): Stigmer {
|
|
52
|
+
return {
|
|
53
|
+
baseUrl: "https://example.test",
|
|
54
|
+
getAuthCredential: async () => null,
|
|
55
|
+
fetch: (async () => {
|
|
56
|
+
throw new Error("network disabled in test");
|
|
57
|
+
}) as unknown as typeof globalThis.fetch,
|
|
58
|
+
} as unknown as Stigmer;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// The table renders actions through this hook's return; layout never
|
|
62
|
+
// invokes them, so rejecting stubs are honest.
|
|
63
|
+
const stubActions: ReturnType<typeof useCursorMemberKeyActions> = {
|
|
64
|
+
addKey: () => Promise.reject(new Error("unused in layout test")),
|
|
65
|
+
removeKey: () => Promise.reject(new Error("unused in layout test")),
|
|
66
|
+
setKeyEnabled: () => Promise.reject(new Error("unused in layout test")),
|
|
67
|
+
isSubmitting: false,
|
|
68
|
+
error: null,
|
|
69
|
+
clearError: () => {},
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* A view shaped like the account that exposed #929: all three coverage
|
|
74
|
+
* categories populated, real-length emails, a key with both a Cursor
|
|
75
|
+
* key name and an operator label (the widest identity cell).
|
|
76
|
+
*/
|
|
77
|
+
function fixtureView() {
|
|
78
|
+
const spend = (email: string) =>
|
|
79
|
+
create(CursorMemberSpendSchema, {
|
|
80
|
+
email,
|
|
81
|
+
autoPercentUsed: 12.4,
|
|
82
|
+
apiPercentUsed: 100,
|
|
83
|
+
includedSpendUsdMicros: 201550000n,
|
|
84
|
+
overageSpendUsdMicros: 0n,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
return create(CursorAccountViewSchema, {
|
|
88
|
+
account: create(CursorAccountSchema, {
|
|
89
|
+
accountId: "acc-layout",
|
|
90
|
+
displayName: "layout team",
|
|
91
|
+
adminApiKey: "***REDACTED***",
|
|
92
|
+
enabled: true,
|
|
93
|
+
teamInviteLink: "https://cursor.com/team-invite/layout",
|
|
94
|
+
}),
|
|
95
|
+
snapshot: create(CursorAccountSyncSnapshotSchema, {
|
|
96
|
+
accountId: "acc-layout",
|
|
97
|
+
syncedAt: timestampFromDate(new Date("2026-08-28T10:00:00Z")),
|
|
98
|
+
members: [
|
|
99
|
+
create(CursorTeamMemberSchema, {
|
|
100
|
+
userId: "u1",
|
|
101
|
+
email: "timothy@leftbin.com",
|
|
102
|
+
name: "Timothy M",
|
|
103
|
+
role: "owner",
|
|
104
|
+
}),
|
|
105
|
+
create(CursorTeamMemberSchema, {
|
|
106
|
+
userId: "u2",
|
|
107
|
+
email: "amelia@leftbin.com",
|
|
108
|
+
name: "Amelia F",
|
|
109
|
+
role: "member",
|
|
110
|
+
}),
|
|
111
|
+
],
|
|
112
|
+
}),
|
|
113
|
+
keyViews: [
|
|
114
|
+
create(CursorMemberKeyViewSchema, {
|
|
115
|
+
key: create(CursorMemberKeySchema, {
|
|
116
|
+
keyId: "k-1",
|
|
117
|
+
apiKey: "***REDACTED***",
|
|
118
|
+
boundEmail: "timothy@leftbin.com",
|
|
119
|
+
cursorKeyName: "stigmer-production-key",
|
|
120
|
+
label: "timothy — prod",
|
|
121
|
+
enabled: true,
|
|
122
|
+
}),
|
|
123
|
+
state: CursorMemberKeyState.member_key_active,
|
|
124
|
+
spend: spend("timothy@leftbin.com"),
|
|
125
|
+
}),
|
|
126
|
+
create(CursorMemberKeyViewSchema, {
|
|
127
|
+
key: create(CursorMemberKeySchema, {
|
|
128
|
+
keyId: "k-2",
|
|
129
|
+
apiKey: "***REDACTED***",
|
|
130
|
+
boundEmail: "lucasb@oneighty.com",
|
|
131
|
+
cursorKeyName: "personal-key",
|
|
132
|
+
enabled: true,
|
|
133
|
+
}),
|
|
134
|
+
state: CursorMemberKeyState.member_key_owner_unknown,
|
|
135
|
+
spend: spend("lucasb@oneighty.com"),
|
|
136
|
+
}),
|
|
137
|
+
],
|
|
138
|
+
membersWithoutKeysViews: [
|
|
139
|
+
create(CursorTeamMemberViewSchema, {
|
|
140
|
+
member: create(CursorTeamMemberSchema, {
|
|
141
|
+
userId: "u2",
|
|
142
|
+
email: "amelia@leftbin.com",
|
|
143
|
+
name: "Amelia F",
|
|
144
|
+
role: "member",
|
|
145
|
+
}),
|
|
146
|
+
}),
|
|
147
|
+
],
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function renderTableAt(hostWidth: number) {
|
|
152
|
+
const view = fixtureView();
|
|
153
|
+
render(
|
|
154
|
+
<div style={{ width: hostWidth }}>
|
|
155
|
+
<StigmerProvider client={makeClient()}>
|
|
156
|
+
<MemberCoverageTable
|
|
157
|
+
accountId="acc-layout"
|
|
158
|
+
coverage={deriveCoverage(view)}
|
|
159
|
+
inviteLink={view.account?.teamInviteLink ?? ""}
|
|
160
|
+
actions={stubActions}
|
|
161
|
+
onChanged={() => {}}
|
|
162
|
+
/>
|
|
163
|
+
</StigmerProvider>
|
|
164
|
+
</div>,
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
const table = screen.getByRole("table", { name: "Team coverage" });
|
|
168
|
+
// The overflow guard is the table's direct wrapper (the card-chrome div).
|
|
169
|
+
const scroller = table.parentElement as HTMLElement;
|
|
170
|
+
return { table, scroller };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function emailCell(email: string): HTMLElement {
|
|
174
|
+
const cell = screen.getByText(email).closest('[role="cell"]');
|
|
175
|
+
expect(cell, `member cell for ${email}`).not.toBeNull();
|
|
176
|
+
return cell as HTMLElement;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
describe("MemberCoverageTable column budget (#929)", () => {
|
|
180
|
+
it("fits the 768px settings canvas without horizontal scroll, with a legible member column", () => {
|
|
181
|
+
// max-w-3xl (768px) minus the settings page's px-6 → ~720px content.
|
|
182
|
+
const { scroller } = renderTableAt(720);
|
|
183
|
+
|
|
184
|
+
expect(scroller.scrollWidth).toBe(scroller.clientWidth);
|
|
185
|
+
|
|
186
|
+
// The member column must hold real emails whole. The #929 failure
|
|
187
|
+
// rendered this cell ~1ch wide; anything under ~180px would push
|
|
188
|
+
// typical emails back into truncation as the norm rather than the
|
|
189
|
+
// exception.
|
|
190
|
+
const cell = emailCell("lucasb@oneighty.com");
|
|
191
|
+
expect(cell.getBoundingClientRect().width).toBeGreaterThan(180);
|
|
192
|
+
|
|
193
|
+
// One line: the email never wraps (truncation instead of the
|
|
194
|
+
// character-per-line wrapping that motivated this suite).
|
|
195
|
+
const emailEl = screen.getByText("lucasb@oneighty.com");
|
|
196
|
+
const lineHeight = parseFloat(getComputedStyle(emailEl).lineHeight);
|
|
197
|
+
expect(emailEl.getBoundingClientRect().height).toBeLessThan(lineHeight * 1.5);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("scrolls horizontally in a narrow host instead of collapsing the member column", () => {
|
|
201
|
+
const { table, scroller } = renderTableAt(400);
|
|
202
|
+
|
|
203
|
+
// The min-width guard keeps the grid at its legible minimum and
|
|
204
|
+
// hands the difference to the scroll container.
|
|
205
|
+
expect(scroller.scrollWidth).toBeGreaterThan(scroller.clientWidth);
|
|
206
|
+
expect(table.getBoundingClientRect().width).toBeGreaterThanOrEqual(640);
|
|
207
|
+
|
|
208
|
+
// Even mid-scroll the member cell keeps usable width.
|
|
209
|
+
const cell = emailCell("lucasb@oneighty.com");
|
|
210
|
+
expect(cell.getBoundingClientRect().width).toBeGreaterThan(100);
|
|
211
|
+
});
|
|
212
|
+
});
|
|
@@ -66,7 +66,7 @@ export interface ResizableSplitProps {
|
|
|
66
66
|
* query. 48rem clears the session layout's content minimums (320px min
|
|
67
67
|
* chat + drag handle + a usable workspace panel) and approximately
|
|
68
68
|
* preserves the console's previous viewport-`lg` trigger point, where the
|
|
69
|
-
* viewer's box is the ~1024px window minus the ~
|
|
69
|
+
* viewer's box is the ~1024px window minus the ~240px sidebar. It is
|
|
70
70
|
* deliberately not a prop: container-query conditions cannot read CSS
|
|
71
71
|
* custom properties, so a runtime threshold would need an inline-`<style>`
|
|
72
72
|
* or ResizeObserver mechanism — heavier than the static utility this is,
|
|
@@ -4,7 +4,12 @@ import type { ReactNode } from "react";
|
|
|
4
4
|
import { ArrowLeft } from "lucide-react";
|
|
5
5
|
import type { Organization } from "@stigmer/protos/ai/stigmer/tenancy/organization/v1/api_pb";
|
|
6
6
|
import type { SettingsNavGroup } from "../settings/settings-nav.js";
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
SidebarChrome,
|
|
9
|
+
SidebarSeparator,
|
|
10
|
+
navRowClassName,
|
|
11
|
+
sectionLabelClassName,
|
|
12
|
+
} from "./chrome.js";
|
|
8
13
|
import type { RenderSidebarLink } from "./types.js";
|
|
9
14
|
|
|
10
15
|
// ---------------------------------------------------------------------------
|
|
@@ -86,13 +91,14 @@ export function SettingsSidebar({
|
|
|
86
91
|
onOrgChanged={onOrgChanged}
|
|
87
92
|
footer={footer}
|
|
88
93
|
>
|
|
89
|
-
{/* Back to Sessions
|
|
94
|
+
{/* Back to Sessions — an exit, not a destination; the resting-row
|
|
95
|
+
treatment (muted, brightening on hover) already reads as such. */}
|
|
90
96
|
<div className="stg:flex-none stg:px-3 stg:py-1">
|
|
91
97
|
{renderLink({
|
|
92
98
|
id: "back-to-sessions",
|
|
93
99
|
href: backHref,
|
|
94
100
|
active: false,
|
|
95
|
-
className: navRowClassName(false
|
|
101
|
+
className: navRowClassName(false),
|
|
96
102
|
"aria-current": undefined,
|
|
97
103
|
children: (
|
|
98
104
|
<>
|
|
@@ -106,10 +112,10 @@ export function SettingsSidebar({
|
|
|
106
112
|
<SidebarSeparator />
|
|
107
113
|
|
|
108
114
|
{/* Settings nav links — grouped, permission-aware via `groups` */}
|
|
109
|
-
<div className="stg:flex stg:flex-col stg:gap-
|
|
115
|
+
<div className="stg:flex stg:flex-col stg:gap-5 stg:px-3 stg:py-1">
|
|
110
116
|
{groups.map((group) => (
|
|
111
117
|
<div key={group.label} className="stg:flex stg:flex-col stg:gap-0.5">
|
|
112
|
-
<span className=
|
|
118
|
+
<span className={`${sectionLabelClassName()} stg:pb-1`}>
|
|
113
119
|
{group.label}
|
|
114
120
|
</span>
|
|
115
121
|
{group.items.map((item) => {
|
|
@@ -23,7 +23,12 @@ import {
|
|
|
23
23
|
TooltipTrigger,
|
|
24
24
|
TooltipContent,
|
|
25
25
|
} from "../internal/tooltip.js";
|
|
26
|
-
import {
|
|
26
|
+
import {
|
|
27
|
+
SidebarChrome,
|
|
28
|
+
SidebarSeparator,
|
|
29
|
+
navRowClassName,
|
|
30
|
+
sectionLabelClassName,
|
|
31
|
+
} from "./chrome.js";
|
|
27
32
|
import type { RenderSidebarLink } from "./types.js";
|
|
28
33
|
|
|
29
34
|
// ---------------------------------------------------------------------------
|
|
@@ -110,7 +115,7 @@ export interface WorkspaceSidebarProps {
|
|
|
110
115
|
* from the product because it *is* the product (stigmer/stigmer#317).
|
|
111
116
|
*
|
|
112
117
|
* Layout note: the sidebar fills its container's height and defines no
|
|
113
|
-
* width — the console's app shell owns the `w-
|
|
118
|
+
* width — the console's app shell owns the `w-60` (240px) column.
|
|
114
119
|
*
|
|
115
120
|
* @example
|
|
116
121
|
* ```tsx
|
|
@@ -153,55 +158,57 @@ export function WorkspaceSidebar({
|
|
|
153
158
|
onOrgChanged={onOrgChanged}
|
|
154
159
|
footer={footer}
|
|
155
160
|
>
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
161
|
+
{/* Primary nav — one group container with the same row rhythm as
|
|
162
|
+
the settings groups (gap-0.5), so the two zones share one beat. */}
|
|
163
|
+
<div className="stg:flex stg:flex-none stg:flex-col stg:gap-0.5 stg:px-3 stg:py-1">
|
|
164
|
+
<PrimaryNavRow
|
|
165
|
+
id="new-session"
|
|
166
|
+
href="/"
|
|
167
|
+
label="New Session"
|
|
168
|
+
icon={Plus}
|
|
169
|
+
active={activeNav === "new-session"}
|
|
170
|
+
renderLink={renderLink}
|
|
171
|
+
/>
|
|
172
|
+
<PrimaryNavRow
|
|
173
|
+
id="dashboard"
|
|
174
|
+
href="/dashboard"
|
|
175
|
+
label="Dashboard"
|
|
176
|
+
icon={LayoutDashboard}
|
|
177
|
+
active={activeNav === "dashboard"}
|
|
178
|
+
renderLink={renderLink}
|
|
179
|
+
/>
|
|
180
|
+
{/* MessagesSquare, deliberately not MessageSquare — the singular
|
|
181
|
+
mark is the session glyph in Recents, and the two areas must
|
|
182
|
+
not read as one. */}
|
|
183
|
+
<PrimaryNavRow
|
|
184
|
+
id="conversations"
|
|
185
|
+
href="/conversations"
|
|
186
|
+
label="Conversations"
|
|
187
|
+
icon={MessagesSquare}
|
|
188
|
+
active={activeNav === "conversations"}
|
|
189
|
+
renderLink={renderLink}
|
|
190
|
+
accessory={
|
|
191
|
+
conversationsBadgeCount !== undefined && conversationsBadgeCount > 0 ? (
|
|
192
|
+
<WantsHumanBadge count={conversationsBadgeCount} />
|
|
193
|
+
) : null
|
|
194
|
+
}
|
|
195
|
+
/>
|
|
196
|
+
<PrimaryNavRow
|
|
197
|
+
id="library"
|
|
198
|
+
href="/library"
|
|
199
|
+
label="Library"
|
|
200
|
+
icon={Library}
|
|
201
|
+
active={activeNav === "library"}
|
|
202
|
+
renderLink={renderLink}
|
|
203
|
+
/>
|
|
204
|
+
</div>
|
|
196
205
|
|
|
197
206
|
<SidebarSeparator />
|
|
198
207
|
|
|
199
208
|
{/* Scrollable recents */}
|
|
200
209
|
<ScrollArea className="stg:flex-1">
|
|
201
210
|
<div className="stg:p-3">
|
|
202
|
-
<p className=
|
|
203
|
-
Recents
|
|
204
|
-
</p>
|
|
211
|
+
<p className={`${sectionLabelClassName()} stg:mb-2`}>Recents</p>
|
|
205
212
|
{isLoading ? (
|
|
206
213
|
<RecentsSkeletons />
|
|
207
214
|
) : error ? (
|
|
@@ -246,24 +253,20 @@ function PrimaryNavRow({
|
|
|
246
253
|
/** Trailing row content (e.g. the Conversations count pill). */
|
|
247
254
|
readonly accessory?: ReactNode;
|
|
248
255
|
}) {
|
|
249
|
-
return (
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
),
|
|
264
|
-
})}
|
|
265
|
-
</div>
|
|
266
|
-
);
|
|
256
|
+
return renderLink({
|
|
257
|
+
id,
|
|
258
|
+
href,
|
|
259
|
+
active,
|
|
260
|
+
className: navRowClassName(active),
|
|
261
|
+
"aria-current": active ? "page" : undefined,
|
|
262
|
+
children: (
|
|
263
|
+
<>
|
|
264
|
+
<Icon className="stg:size-4 stg:shrink-0" />
|
|
265
|
+
{label}
|
|
266
|
+
{accessory}
|
|
267
|
+
</>
|
|
268
|
+
),
|
|
269
|
+
});
|
|
267
270
|
}
|
|
268
271
|
|
|
269
272
|
/**
|
|
@@ -311,7 +314,8 @@ function ActivityGroupList({
|
|
|
311
314
|
<div className="stg:space-y-4">
|
|
312
315
|
{groups.map((group) => (
|
|
313
316
|
<div key={group.label}>
|
|
314
|
-
|
|
317
|
+
{/* One step below the section label: same voice, 10px. */}
|
|
318
|
+
<p className="stg:text-sidebar-muted-foreground stg:mb-1 stg:px-2 stg:text-[10px] stg:font-medium stg:tracking-wide stg:uppercase">
|
|
315
319
|
{group.label}
|
|
316
320
|
</p>
|
|
317
321
|
<ul className={`${UNSTYLED_LIST} stg:space-y-0.5`} role="list">
|
|
@@ -404,11 +408,15 @@ const ActivityEntry = memo(function ActivityEntry({
|
|
|
404
408
|
);
|
|
405
409
|
});
|
|
406
410
|
|
|
407
|
-
/**
|
|
411
|
+
/**
|
|
412
|
+
* Recents rows are denser than primary nav: 12px text, top-aligned icon.
|
|
413
|
+
* Same state grammar as `navRowClassName` — muted at rest, accent fill
|
|
414
|
+
* when active, constant weight so activation never shifts text width.
|
|
415
|
+
*/
|
|
408
416
|
function cnActivityRow(active: boolean): string {
|
|
409
417
|
return active
|
|
410
|
-
? "stg:flex stg:items-start stg:gap-2 stg:rounded-
|
|
411
|
-
: "stg:flex stg:items-start stg:gap-2 stg:rounded-
|
|
418
|
+
? "stg:flex stg:items-start stg:gap-2 stg:rounded-md stg:px-2 stg:py-1 stg:text-xs stg:transition-colors stg:bg-sidebar-accent stg:text-sidebar-accent-foreground"
|
|
419
|
+
: "stg:flex stg:items-start stg:gap-2 stg:rounded-md stg:px-2 stg:py-1 stg:text-xs stg:transition-colors stg:text-sidebar-muted-foreground stg:hover:bg-sidebar-accent stg:hover:text-sidebar-accent-foreground";
|
|
412
420
|
}
|
|
413
421
|
|
|
414
422
|
function RecentsSkeletons() {
|
|
@@ -85,15 +85,19 @@ describe("SettingsSidebar", () => {
|
|
|
85
85
|
expect(inactive.getAttribute("aria-current")).toBeNull();
|
|
86
86
|
});
|
|
87
87
|
|
|
88
|
-
it("renders Back to Sessions as a
|
|
88
|
+
it("renders Back to Sessions as a resting (never-active) row targeting backHref", () => {
|
|
89
89
|
const { container } = renderSidebar(
|
|
90
90
|
<SettingsSidebar {...baseProps()} backHref="/sessions/ses_123" />,
|
|
91
91
|
);
|
|
92
92
|
|
|
93
93
|
const back = container.querySelector('[data-row-id="back-to-sessions"]')!;
|
|
94
94
|
expect(back.getAttribute("href")).toBe("/sessions/ses_123");
|
|
95
|
-
expect(back.className).toContain("stg:text-sidebar-muted-foreground");
|
|
96
95
|
expect(back.textContent).toContain("Back to Sessions");
|
|
96
|
+
// An exit, not a destination: always the resting-row treatment, never
|
|
97
|
+
// the active accent — even though it is the row you "came from".
|
|
98
|
+
expect(back.getAttribute("aria-current")).toBeNull();
|
|
99
|
+
expect(back.className.split(" ")).toContain("stg:text-sidebar-muted-foreground");
|
|
100
|
+
expect(back.className.split(" ")).not.toContain("stg:bg-sidebar-accent");
|
|
97
101
|
});
|
|
98
102
|
|
|
99
103
|
it("renders the footer slot", () => {
|
package/src/sidebar/chrome.tsx
CHANGED
|
@@ -9,8 +9,9 @@ import { OrgSwitcher } from "../organization/OrgSwitcher.js";
|
|
|
9
9
|
// ---------------------------------------------------------------------------
|
|
10
10
|
// Shared sidebar chrome — the pieces WorkspaceSidebar and SettingsSidebar
|
|
11
11
|
// have in common: the nav container, the top row (collapse toggle + org
|
|
12
|
-
// switcher), the hairline separator, the footer band, and the
|
|
13
|
-
//
|
|
12
|
+
// switcher), the hairline separator, the footer band, and the shared class
|
|
13
|
+
// recipes (nav rows, section labels). Internal to the sidebar domain; not
|
|
14
|
+
// exported from the SDK.
|
|
14
15
|
//
|
|
15
16
|
// Every class here is transcribed from the console's sidebars — this file
|
|
16
17
|
// IS the console's chrome now, so a change here changes the console, the
|
|
@@ -107,23 +108,36 @@ export function SidebarSeparator() {
|
|
|
107
108
|
}
|
|
108
109
|
|
|
109
110
|
/**
|
|
110
|
-
* The one nav-row class recipe both sidebars share:
|
|
111
|
-
* 16px icons (sized by callers), 8px/
|
|
111
|
+
* The one nav-row class recipe both sidebars share: 13px/16px regular
|
|
112
|
+
* labels, 16px icons (sized by callers), 8px/4px padding — 24px rows.
|
|
113
|
+
* The explicit `leading-4` is load-bearing: without it the inherited
|
|
114
|
+
* line-height (~1.5) pads every row to ~28px, and across a full column
|
|
115
|
+
* of rows that difference alone decides whether the sidebar breathes.
|
|
116
|
+
* 24px also stays exactly at the WCAG 2.5.8 minimum pointer-target size.
|
|
112
117
|
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
118
|
+
* Resting rows are muted so the navigation recedes and the content leads;
|
|
119
|
+
* the active row carries the accent fill, hover brightens to the accent
|
|
120
|
+
* foreground. Font weight is constant across states — state is expressed
|
|
121
|
+
* through color and fill only, so activating a row never shifts its text
|
|
122
|
+
* width. The muted-on-sidebar pairing is held to WCAG AA by the theme's
|
|
123
|
+
* contrast contract (sdk/theme/src/contract/pairs.ts) in every preset.
|
|
115
124
|
*/
|
|
116
|
-
export function navRowClassName(
|
|
117
|
-
active: boolean,
|
|
118
|
-
{ muted = false }: { muted?: boolean } = {},
|
|
119
|
-
): string {
|
|
125
|
+
export function navRowClassName(active: boolean): string {
|
|
120
126
|
return cn(
|
|
121
|
-
"stg:flex stg:items-center stg:gap-2 stg:rounded-
|
|
127
|
+
"stg:flex stg:items-center stg:gap-2 stg:rounded-md stg:px-2 stg:py-1 stg:text-[13px] stg:leading-4 stg:transition-colors",
|
|
122
128
|
active
|
|
123
129
|
? "stg:bg-sidebar-accent stg:text-sidebar-accent-foreground"
|
|
124
|
-
:
|
|
125
|
-
muted ? "stg:text-sidebar-muted-foreground" : "stg:text-sidebar-foreground",
|
|
126
|
-
"stg:hover:bg-sidebar-accent stg:hover:text-sidebar-accent-foreground",
|
|
127
|
-
),
|
|
130
|
+
: "stg:text-sidebar-muted-foreground stg:hover:bg-sidebar-accent stg:hover:text-sidebar-accent-foreground",
|
|
128
131
|
);
|
|
129
132
|
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The section-label recipe both sidebars share (settings group labels,
|
|
136
|
+
* the Recents heading): 11px/16px medium uppercase in the muted sidebar
|
|
137
|
+
* tone. Spacing around a label stays with the caller — it is a layout
|
|
138
|
+
* concern; this recipe owns only the typography, so the two sidebars
|
|
139
|
+
* cannot drift.
|
|
140
|
+
*/
|
|
141
|
+
export function sectionLabelClassName(): string {
|
|
142
|
+
return "stg:text-sidebar-muted-foreground stg:px-2 stg:text-[11px] stg:leading-4 stg:font-medium stg:tracking-wide stg:uppercase";
|
|
143
|
+
}
|