@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.
Files changed (35) hide show
  1. package/cursor-accounts/CursorAccountsConsole.d.ts +3 -2
  2. package/cursor-accounts/CursorAccountsConsole.d.ts.map +1 -1
  3. package/cursor-accounts/CursorAccountsConsole.js +3 -2
  4. package/cursor-accounts/CursorAccountsConsole.js.map +1 -1
  5. package/cursor-accounts/MemberCoverageTable.d.ts +11 -3
  6. package/cursor-accounts/MemberCoverageTable.d.ts.map +1 -1
  7. package/cursor-accounts/MemberCoverageTable.js +70 -42
  8. package/cursor-accounts/MemberCoverageTable.js.map +1 -1
  9. package/cursor-accounts/MemberKeysPanel.d.ts.map +1 -1
  10. package/cursor-accounts/MemberKeysPanel.js +4 -1
  11. package/cursor-accounts/MemberKeysPanel.js.map +1 -1
  12. package/internal/ResizableSplit.d.ts +1 -1
  13. package/package.json +4 -4
  14. package/sidebar/SettingsSidebar.d.ts.map +1 -1
  15. package/sidebar/SettingsSidebar.js +3 -3
  16. package/sidebar/SettingsSidebar.js.map +1 -1
  17. package/sidebar/WorkspaceSidebar.d.ts +1 -1
  18. package/sidebar/WorkspaceSidebar.d.ts.map +1 -1
  19. package/sidebar/WorkspaceSidebar.js +19 -15
  20. package/sidebar/WorkspaceSidebar.js.map +1 -1
  21. package/sidebar/chrome.d.ts +21 -7
  22. package/sidebar/chrome.d.ts.map +1 -1
  23. package/sidebar/chrome.js +25 -7
  24. package/sidebar/chrome.js.map +1 -1
  25. package/src/cursor-accounts/CursorAccountsConsole.tsx +3 -2
  26. package/src/cursor-accounts/MemberCoverageTable.tsx +222 -200
  27. package/src/cursor-accounts/MemberKeysPanel.tsx +25 -1
  28. package/src/cursor-accounts/__tests__/CursorAccountsConsole.test.tsx +64 -8
  29. package/src/cursor-accounts/__tests__/MemberCoverageTable.layout.test.tsx +212 -0
  30. package/src/internal/ResizableSplit.tsx +1 -1
  31. package/src/sidebar/SettingsSidebar.tsx +11 -5
  32. package/src/sidebar/WorkspaceSidebar.tsx +75 -67
  33. package/src/sidebar/__tests__/SettingsSidebar.test.tsx +6 -2
  34. package/src/sidebar/chrome.tsx +29 -15
  35. package/styles.css +1 -1
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
 
3
+ import { MoreHorizontal, Pause, Play, Trash2 } from "lucide-react";
3
4
  import { useCopyFeedback } from "../internal/useCopyFeedback.js";
4
5
  import { cn } from "@stigmer/theme";
5
6
  import {
@@ -8,6 +9,7 @@ import {
8
9
  type CursorTeamMemberView,
9
10
  } from "@stigmer/protos/ai/stigmer/platform/cursoraccount/v1/io_pb";
10
11
  import type { CursorMemberSpend } from "@stigmer/protos/ai/stigmer/platform/cursoraccount/v1/cursor_account_pb";
12
+ import { ActionMenu } from "../action-menu/index.js";
11
13
  import { Button } from "../button/index.js";
12
14
  import { TruncatedText } from "../internal/truncated-text.js";
13
15
  import { StateBadge } from "./badges.js";
@@ -17,19 +19,25 @@ import type { useCursorMemberKeyActions } from "./useCursorMemberKeyActions.js";
17
19
 
18
20
  /**
19
21
  * One grid template shared by the header and every row so the columns
20
- * can never drift apart: Member · Key · First-party % · API % ·
21
- * Included $ · On-demand $ · Status · Actions.
22
+ * can never drift apart: Member · First-party % · API % · Included $ ·
23
+ * On-demand $ · Status · actions kebab.
22
24
  *
23
25
  * The four numeric columns mirror Cursor's own Members page (First-Party
24
26
  * Models %, API %, On-Demand $) plus the included-quota dollars, so an
25
27
  * operator can read this table and the Cursor dashboard side by side.
26
28
  *
27
- * Member gets the largest flexible share: the email is the row's
28
- * identity, so it wraps rather than truncates when space runs out (see
29
- * MemberCell) while the key name stays truncate-with-tooltip.
29
+ * The column budget is sized for the table's real canvas: both client
30
+ * apps render settings inside `max-w-3xl` (768px), leaving ~720px of
31
+ * content. Fixed tracks total ~25rem, so the flexible member column
32
+ * keeps ~240px — enough to render typical emails whole (stigmer#929: an
33
+ * earlier 8-column layout with a three-button actions column consumed
34
+ * the full width and collapsed the member column to zero, shattering
35
+ * emails one character per line). Key name/label live inside the member
36
+ * cell and row actions inside a kebab menu precisely to defend that
37
+ * budget.
30
38
  */
31
39
  const ROW_GRID =
32
- "stg:grid stg:grid-cols-[minmax(0,2.2fr)_minmax(0,1fr)_4.5rem_4.5rem_4.5rem_4.5rem_minmax(6rem,auto)_minmax(10rem,auto)] stg:items-center stg:gap-2 stg:px-3 stg:py-2";
40
+ "stg:grid stg:grid-cols-[minmax(0,1fr)_4rem_4rem_4.5rem_4.5rem_6rem_2.25rem] stg:items-center stg:gap-2 stg:px-3 stg:py-2";
33
41
 
34
42
  /**
35
43
  * The roster-coverage table: every member and every stored execution key
@@ -41,9 +49,17 @@ const ROW_GRID =
41
49
  * help the operator prioritize whose key to import.
42
50
  * 3. **Key held, not on the team** — imported keys whose owner left the
43
51
  * team or never joined it. Resolved by a Cursor-dashboard invite:
44
- * when the account carries a team invite link, each row offers
45
- * one-click copy (never navigation opening an invite link would
46
- * join the OPERATOR's own Cursor account to the team).
52
+ * when the account carries a team invite link, the group header
53
+ * offers one-click copy (one button, not one per row — the link is
54
+ * account-level and identical for every owner; and always a copy,
55
+ * never navigation, since opening an invite link would join the
56
+ * OPERATOR's own Cursor account to the team).
57
+ *
58
+ * The categories render as grouped sections of ONE table — deliberately
59
+ * not tabs. Together they answer a single question ("is this roster
60
+ * healthy?"), so all three counts must be visible at once; groups are
61
+ * team-sized (5–30 rows), never large enough to need pagination-style
62
+ * chrome.
47
63
  *
48
64
  * Before the first sync there is no roster to classify against, so keys
49
65
  * render unclassified with a "sync now" notice instead of being falsely
@@ -64,119 +80,119 @@ export function MemberCoverageTable({
64
80
  readonly onChanged: () => void;
65
81
  }) {
66
82
  return (
67
- <div
68
- role="table"
69
- aria-label="Team coverage"
70
- className="stg:rounded-lg stg:border stg:border-border stg:bg-card"
71
- >
72
- <div
73
- role="row"
74
- className={cn(
75
- ROW_GRID,
76
- "stg:border-b stg:border-border stg:text-[11px] stg:font-medium stg:text-muted-foreground",
77
- )}
78
- >
79
- <span role="columnheader">Member</span>
80
- <span role="columnheader">Key</span>
81
- <span role="columnheader" className="stg:text-right">
82
- First-party
83
- </span>
84
- <span role="columnheader" className="stg:text-right">
85
- API
86
- </span>
87
- <span role="columnheader" className="stg:text-right">
88
- Included
89
- </span>
90
- <span role="columnheader" className="stg:text-right">
91
- On-demand
92
- </span>
93
- <span role="columnheader">Status</span>
94
- <span role="columnheader" className="stg:text-right">
95
- Actions
96
- </span>
97
- </div>
98
-
99
- {!coverage.hasRoster && (
100
- <CoverageGroup
101
- title="Execution keys — not yet classified"
102
- description='No roster sync has run for this account. Run "Sync now" to load the team roster and classify these keys.'
103
- count={coverage.unclassified.length}
83
+ // Narrow-host guard (the ResourceTable idiom): below the grid's
84
+ // legible minimum the table scrolls horizontally instead of letting
85
+ // the flexible member column collapse. The scroll container carries
86
+ // the card chrome; the ARIA table starts inside it so `role="row"`
87
+ // children stay directly owned by the table.
88
+ <div className="stg:overflow-x-auto stg:rounded-lg stg:border stg:border-border stg:bg-card">
89
+ <div role="table" aria-label="Team coverage" className="stg:min-w-[40rem]">
90
+ <div
91
+ role="row"
92
+ className={cn(
93
+ ROW_GRID,
94
+ "stg:border-b stg:border-border stg:text-[11px] stg:font-medium stg:text-muted-foreground",
95
+ )}
104
96
  >
105
- {coverage.unclassified.map((keyView) => (
106
- <KeyRow
107
- key={keyView.key?.keyId}
108
- accountId={accountId}
109
- keyView={keyView}
110
- hasRoster={false}
111
- inviteLink=""
112
- actions={actions}
113
- onChanged={onChanged}
114
- />
115
- ))}
116
- </CoverageGroup>
117
- )}
97
+ <span role="columnheader">Member</span>
98
+ <span role="columnheader" className="stg:text-right">
99
+ First-party
100
+ </span>
101
+ <span role="columnheader" className="stg:text-right">
102
+ API
103
+ </span>
104
+ <span role="columnheader" className="stg:text-right">
105
+ Included
106
+ </span>
107
+ <span role="columnheader" className="stg:text-right">
108
+ On-demand
109
+ </span>
110
+ <span role="columnheader">Status</span>
111
+ <span role="columnheader">
112
+ <span className="stg:sr-only">Actions</span>
113
+ </span>
114
+ </div>
118
115
 
119
- {coverage.onTeamWithKey.length > 0 && (
120
- <CoverageGroup
121
- title="On the team key held"
122
- count={coverage.onTeamWithKey.length}
123
- >
124
- {coverage.onTeamWithKey.map((keyView) => (
125
- <KeyRow
126
- key={keyView.key?.keyId}
127
- accountId={accountId}
128
- keyView={keyView}
129
- hasRoster
130
- inviteLink=""
131
- actions={actions}
132
- onChanged={onChanged}
133
- />
134
- ))}
135
- </CoverageGroup>
136
- )}
116
+ {!coverage.hasRoster && (
117
+ <CoverageGroup
118
+ title="Execution keysnot yet classified"
119
+ description='No roster sync has run for this account. Run "Sync now" to load the team roster and classify these keys.'
120
+ count={coverage.unclassified.length}
121
+ >
122
+ {coverage.unclassified.map((keyView) => (
123
+ <KeyRow
124
+ key={keyView.key?.keyId}
125
+ accountId={accountId}
126
+ keyView={keyView}
127
+ hasRoster={false}
128
+ actions={actions}
129
+ onChanged={onChanged}
130
+ />
131
+ ))}
132
+ </CoverageGroup>
133
+ )}
137
134
 
138
- {/* Rendered whenever a roster exists — an empty gap is an answer
139
- ("fully covered"), not an absence. Hiding the group made a
140
- healthy roster indistinguishable from a broken sync. */}
141
- {coverage.hasRoster && (
142
- <CoverageGroup
143
- title="On the team — no execution key"
144
- description={
145
- coverage.onTeamWithoutKey.length > 0
146
- ? "Sessions can never run under these members' identity or included quota. Add their user-scoped keys below to close the gap."
147
- : "Every active roster member holds an execution key — the roster is fully covered."
148
- }
149
- count={coverage.onTeamWithoutKey.length}
150
- >
151
- {coverage.onTeamWithoutKey.map((memberView) => (
152
- <GapRow key={memberView.member?.email} memberView={memberView} />
153
- ))}
154
- </CoverageGroup>
155
- )}
135
+ {coverage.onTeamWithKey.length > 0 && (
136
+ <CoverageGroup
137
+ title="On the team key held"
138
+ count={coverage.onTeamWithKey.length}
139
+ >
140
+ {coverage.onTeamWithKey.map((keyView) => (
141
+ <KeyRow
142
+ key={keyView.key?.keyId}
143
+ accountId={accountId}
144
+ keyView={keyView}
145
+ hasRoster
146
+ actions={actions}
147
+ onChanged={onChanged}
148
+ />
149
+ ))}
150
+ </CoverageGroup>
151
+ )}
156
152
 
157
- {coverage.offTeamWithKey.length > 0 && (
158
- <CoverageGroup
159
- title="Key held not on the team"
160
- description={
161
- inviteLink !== ""
162
- ? "These key owners must join the Cursor team before their usage counts against its quota. Copy the invite link, send it to them out-of-band, then \u201CSync now\u201D reclassifies once they join."
163
- : "These key owners must join the Cursor team before their usage counts against its quota. Invites are sent from the Cursor dashboard (cursor.com \u2192 Invite Members); paste the team's invite link into the account editor to enable one-click copying here."
164
- }
165
- count={coverage.offTeamWithKey.length}
166
- >
167
- {coverage.offTeamWithKey.map((keyView) => (
168
- <KeyRow
169
- key={keyView.key?.keyId}
170
- accountId={accountId}
171
- keyView={keyView}
172
- hasRoster
173
- inviteLink={inviteLink}
174
- actions={actions}
175
- onChanged={onChanged}
176
- />
177
- ))}
178
- </CoverageGroup>
179
- )}
153
+ {/* Rendered whenever a roster exists — an empty gap is an answer
154
+ ("fully covered"), not an absence. Hiding the group made a
155
+ healthy roster indistinguishable from a broken sync. */}
156
+ {coverage.hasRoster && (
157
+ <CoverageGroup
158
+ title="On the team no execution key"
159
+ description={
160
+ coverage.onTeamWithoutKey.length > 0
161
+ ? "Sessions can never run under these members' identity or included quota. Add their user-scoped keys below to close the gap."
162
+ : "Every active roster member holds an execution key — the roster is fully covered."
163
+ }
164
+ count={coverage.onTeamWithoutKey.length}
165
+ >
166
+ {coverage.onTeamWithoutKey.map((memberView) => (
167
+ <GapRow key={memberView.member?.email} memberView={memberView} />
168
+ ))}
169
+ </CoverageGroup>
170
+ )}
171
+
172
+ {coverage.offTeamWithKey.length > 0 && (
173
+ <CoverageGroup
174
+ title="Key held — not on the team"
175
+ description={
176
+ inviteLink !== ""
177
+ ? "These key owners must join the Cursor team before their usage counts against its quota — copy the invite link, send it out-of-band, then \u201CSync now\u201D reclassifies once they join."
178
+ : "These key owners must join the Cursor team before their usage counts against its quota. Invites are sent from the Cursor dashboard (cursor.com \u2192 Invite Members); paste the team's invite link into the account editor to enable one-click copying here."
179
+ }
180
+ count={coverage.offTeamWithKey.length}
181
+ action={inviteLink !== "" ? <CopyInviteButton inviteLink={inviteLink} /> : undefined}
182
+ >
183
+ {coverage.offTeamWithKey.map((keyView) => (
184
+ <KeyRow
185
+ key={keyView.key?.keyId}
186
+ accountId={accountId}
187
+ keyView={keyView}
188
+ hasRoster
189
+ actions={actions}
190
+ onChanged={onChanged}
191
+ />
192
+ ))}
193
+ </CoverageGroup>
194
+ )}
195
+ </div>
180
196
  </div>
181
197
  );
182
198
  }
@@ -189,28 +205,38 @@ function CoverageGroup({
189
205
  title,
190
206
  description,
191
207
  count,
208
+ action,
192
209
  children,
193
210
  }: {
194
211
  readonly title: string;
195
212
  readonly description?: string;
196
213
  readonly count: number;
214
+ /** Optional group-level action (e.g. the off-team invite copy). */
215
+ readonly action?: React.ReactNode;
197
216
  readonly children: React.ReactNode;
198
217
  }) {
199
218
  return (
200
219
  <div role="rowgroup" className="stg:border-b stg:border-border stg:last:border-b-0">
201
220
  <div role="row" className="stg:bg-muted-subtle stg:px-3 stg:py-1.5">
202
- <div role="cell" aria-colspan={8}>
203
- <span className="stg:text-[11px] stg:font-semibold stg:text-foreground">
204
- {title}
205
- <span className="stg:ml-1.5 stg:font-normal stg:text-muted-foreground">
206
- {count}
221
+ <div
222
+ role="cell"
223
+ aria-colspan={7}
224
+ className="stg:flex stg:items-start stg:justify-between stg:gap-2"
225
+ >
226
+ <span className="stg:min-w-0">
227
+ <span className="stg:text-[11px] stg:font-semibold stg:text-foreground">
228
+ {title}
229
+ <span className="stg:ml-1.5 stg:font-normal stg:text-muted-foreground">
230
+ {count}
231
+ </span>
207
232
  </span>
233
+ {description && (
234
+ <span className="stg:block stg:text-[11px] stg:text-muted-foreground">
235
+ {description}
236
+ </span>
237
+ )}
208
238
  </span>
209
- {description && (
210
- <span className="stg:block stg:text-[11px] stg:text-muted-foreground">
211
- {description}
212
- </span>
213
- )}
239
+ {action && <span className="stg:shrink-0">{action}</span>}
214
240
  </div>
215
241
  </div>
216
242
  {children}
@@ -224,30 +250,29 @@ function CoverageGroup({
224
250
 
225
251
  /**
226
252
  * The member-identity cell shared by key rows and gap rows. The email is
227
- * the row's identity, so it must be fully readable: it wraps onto more
228
- * lines when the column is narrow (emails are unbroken strings, so they
229
- * need explicit word-breaking), unlike the other text cells which
230
- * truncate. The secondary name line stays truncated it is display
231
- * sugar, not identity.
253
+ * the row's identity and truncates with the overflow-gated house tooltip
254
+ * (never `break-words`: with grid columns under pressure, word-breaking
255
+ * degenerated to one character per line stigmer#929; the full value
256
+ * stays available to selection, screen readers, and the tooltip). The
257
+ * secondary line carries what each row type actually has: the key
258
+ * name/label on key rows, the roster display name on gap rows
259
+ * (`CursorMemberKeyView` carries no member name to show).
232
260
  */
233
261
  function MemberCell({
234
262
  email,
235
- name,
263
+ secondary,
236
264
  }: {
237
265
  readonly email: string;
238
- readonly name?: string;
266
+ readonly secondary?: string;
239
267
  }) {
240
268
  return (
241
269
  <span role="cell" className="stg:min-w-0">
242
- {/* No tooltip: break-words shows the full email, so a hover hint
243
- would only repeat what is already visible. */}
244
- <span className="stg:block stg:break-words stg:font-medium stg:text-foreground">
245
- {email}
246
- </span>
247
- {name && (
248
- <span className="stg:block stg:truncate stg:text-[11px] stg:text-muted-foreground">
249
- {name}
250
- </span>
270
+ <TruncatedText text={email} className="stg:font-medium stg:text-foreground" />
271
+ {secondary && (
272
+ <TruncatedText
273
+ text={secondary}
274
+ className="stg:text-[11px] stg:text-muted-foreground"
275
+ />
251
276
  )}
252
277
  </span>
253
278
  );
@@ -258,71 +283,70 @@ function KeyRow({
258
283
  accountId,
259
284
  keyView,
260
285
  hasRoster,
261
- inviteLink,
262
286
  actions,
263
287
  onChanged,
264
288
  }: {
265
289
  readonly accountId: string;
266
290
  readonly keyView: CursorMemberKeyView;
267
291
  readonly hasRoster: boolean;
268
- readonly inviteLink: string;
269
292
  readonly actions: ReturnType<typeof useCursorMemberKeyActions>;
270
293
  readonly onChanged: () => void;
271
294
  }) {
272
295
  const key = keyView.key;
273
296
  if (!key) return null;
274
297
 
298
+ const keyMeta = [key.cursorKeyName || "unnamed key", key.label]
299
+ .filter((part) => part !== "")
300
+ .join(" · ");
301
+
275
302
  return (
276
303
  <div role="row" className={cn(ROW_GRID, "stg:border-t stg:border-border-muted stg:text-xs")}>
277
- <MemberCell email={key.boundEmail} />
278
- <span role="cell" className="stg:min-w-0">
279
- <TruncatedText
280
- text={key.cursorKeyName || "unnamed key"}
281
- className="stg:block stg:text-muted-foreground"
282
- />
283
- {key.label && (
284
- <TruncatedText
285
- text={key.label}
286
- className="stg:block stg:text-[11px] stg:text-muted-foreground"
287
- />
288
- )}
289
- </span>
304
+ <MemberCell email={key.boundEmail} secondary={keyMeta} />
290
305
  <SpendCells spend={keyView.spend} />
291
306
  <span role="cell" className="stg:flex stg:flex-wrap stg:items-center stg:gap-1">
292
307
  {keyView.usageGuardTripped && <StateBadge tone="warn" label="Usage guard" />}
293
308
  <KeyStatusBadge keyView={keyView} hasRoster={hasRoster} />
294
309
  </span>
295
- <span role="cell" className="stg:flex stg:items-center stg:justify-end stg:gap-2">
296
- {inviteLink !== "" && <CopyInviteButton inviteLink={inviteLink} />}
297
- <Button
298
- size="sm"
299
- variant="outline"
300
- disabled={actions.isSubmitting}
301
- onClick={() => {
302
- void actions
303
- .setKeyEnabled(accountId, key.keyId, !key.enabled)
304
- .then(onChanged, () => {
305
- // Surfaced via actions.error.
306
- });
307
- }}
308
- >
309
- {key.enabled ? "Disable" : "Enable"}
310
- </Button>
311
- <Button
312
- size="sm"
313
- variant="destructive"
314
- disabled={actions.isSubmitting}
315
- onClick={() => {
316
- void actions
317
- .removeKey({ accountId, keyId: key.keyId })
318
- .then(onChanged, () => {
319
- // Surfaced via actions.error (incl. the live-pin guard's
320
- // "disable instead, or force" message).
321
- });
322
- }}
323
- >
324
- Remove
325
- </Button>
310
+ <span role="cell" className="stg:text-right">
311
+ <ActionMenu>
312
+ <ActionMenu.Trigger aria-label={`Actions for ${key.boundEmail}`}>
313
+ <MoreHorizontal className="stg:size-4" />
314
+ </ActionMenu.Trigger>
315
+ <ActionMenu.Content>
316
+ {/* `disabled` guards against a re-fire while a prior key
317
+ mutation is still in flight; outcomes surface via
318
+ actions.error under the table. */}
319
+ <ActionMenu.Item
320
+ icon={key.enabled ? <Pause /> : <Play />}
321
+ disabled={actions.isSubmitting}
322
+ onSelect={() => {
323
+ void actions
324
+ .setKeyEnabled(accountId, key.keyId, !key.enabled)
325
+ .then(onChanged, () => {
326
+ // Surfaced via actions.error.
327
+ });
328
+ }}
329
+ >
330
+ {key.enabled ? "Disable" : "Enable"}
331
+ </ActionMenu.Item>
332
+ <ActionMenu.Separator />
333
+ <ActionMenu.Item
334
+ icon={<Trash2 />}
335
+ variant="destructive"
336
+ disabled={actions.isSubmitting}
337
+ onSelect={() => {
338
+ void actions
339
+ .removeKey({ accountId, keyId: key.keyId })
340
+ .then(onChanged, () => {
341
+ // Surfaced via actions.error (incl. the live-pin guard's
342
+ // "disable instead, or force" message).
343
+ });
344
+ }}
345
+ >
346
+ Remove
347
+ </ActionMenu.Item>
348
+ </ActionMenu.Content>
349
+ </ActionMenu>
326
350
  </span>
327
351
  </div>
328
352
  );
@@ -335,17 +359,14 @@ function GapRow({ memberView }: { readonly memberView: CursorTeamMemberView }) {
335
359
 
336
360
  return (
337
361
  <div role="row" className={cn(ROW_GRID, "stg:border-t stg:border-border-muted stg:text-xs")}>
338
- <MemberCell email={member.email} name={member.name} />
339
- <span role="cell" className="stg:text-muted-foreground">
340
-
341
- </span>
362
+ <MemberCell email={member.email} secondary={member.name || undefined} />
342
363
  <SpendCells spend={memberView.spend} />
343
364
  <span role="cell">
344
365
  <StateBadge tone="muted" label="No key" />
345
366
  </span>
346
- <span role="cell" className="stg:text-right stg:text-muted-foreground">
347
-
348
- </span>
367
+ {/* No key, no actions — an empty cell keeps every row at the
368
+ header's seven columns. */}
369
+ <span role="cell" />
349
370
  </div>
350
371
  );
351
372
  }
@@ -415,9 +436,10 @@ function KeyStatusBadge({
415
436
  // ---------------------------------------------------------------------------
416
437
 
417
438
  /**
418
- * Copies the account-level team invite link. Deliberately a copy, never
419
- * a navigation: a Cursor invite link is a join link, and opening it
420
- * would join the operator's own Cursor account to the team.
439
+ * Copies the account-level team invite link, once per group (the link is
440
+ * identical for every off-team owner). Deliberately a copy, never a
441
+ * navigation: a Cursor invite link is a join link, and opening it would
442
+ * join the operator's own Cursor account to the team.
421
443
  */
422
444
  function CopyInviteButton({ inviteLink }: { readonly inviteLink: string }) {
423
445
  const { copy, copied } = useCopyFeedback();
@@ -143,7 +143,31 @@ export function MemberKeysPanel({
143
143
 
144
144
  return (
145
145
  <section className="stg:space-y-2">
146
- <h4 className="stg:text-xs stg:font-semibold stg:text-foreground">Team coverage</h4>
146
+ <div>
147
+ <h4 className="stg:text-xs stg:font-semibold stg:text-foreground">Team coverage</h4>
148
+ {/* One-line health subtitle so the coverage answer is readable
149
+ even when the gap group sits below the fold. The counts are
150
+ the table's own groups (no re-derivation); the gap count is
151
+ the attention point, tinted only when it is non-zero. Before
152
+ the first sync there is nothing to classify, so no subtitle. */}
153
+ {coverage.hasRoster && (
154
+ <p className="stg:text-[11px] stg:text-muted-foreground">
155
+ {coverage.onTeamWithKey.length} covered
156
+ {" · "}
157
+ <span
158
+ className={cn(
159
+ coverage.onTeamWithoutKey.length > 0 &&
160
+ "stg:font-medium stg:text-destructive",
161
+ )}
162
+ >
163
+ {coverage.onTeamWithoutKey.length} without keys
164
+ </span>
165
+ {coverage.offTeamWithKey.length > 0
166
+ ? ` · ${coverage.offTeamWithKey.length} off-team`
167
+ : ""}
168
+ </p>
169
+ )}
170
+ </div>
147
171
 
148
172
  {keyViews.length === 0 && (
149
173
  <p className="stg:text-xs stg:text-muted-foreground" role="status">