@opengeni/react 7.4.0 → 7.5.0-canary.36919050519001

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 (210) hide show
  1. package/README.md +124 -27
  2. package/dist/artifacts-document.js +3 -2
  3. package/dist/artifacts-presentation.js +3 -2
  4. package/dist/artifacts-spreadsheet.js +2 -2
  5. package/dist/artifacts.d.ts +1 -0
  6. package/dist/artifacts.js +516 -1413
  7. package/dist/artifacts.js.map +1 -1
  8. package/dist/{browser-viewer-2E6R2Y4W.js → browser-viewer-Z7S7QEJL.js} +6 -5
  9. package/dist/chat-interactive-block-UGBTNB3L.js +11 -0
  10. package/dist/{chunk-Y3QTZN2F.js → chunk-2VFNCX4E.js} +6 -6
  11. package/dist/{chunk-UDD6DLLT.js → chunk-3IJJEYW4.js} +15 -3
  12. package/dist/chunk-3IJJEYW4.js.map +1 -0
  13. package/dist/chunk-4RVYOY4O.js +379 -0
  14. package/dist/chunk-4RVYOY4O.js.map +1 -0
  15. package/dist/chunk-5CVXZA3Y.js +292 -0
  16. package/dist/chunk-5CVXZA3Y.js.map +1 -0
  17. package/dist/{chunk-5YK2ZDIG.js → chunk-5J4FOW6Q.js} +2 -10
  18. package/dist/chunk-5J4FOW6Q.js.map +1 -0
  19. package/dist/{chunk-KVERXCFY.js → chunk-5KYZIQFY.js} +40 -3
  20. package/dist/chunk-5KYZIQFY.js.map +1 -0
  21. package/dist/{chunk-KNDIXZN6.js → chunk-7UAMYXJY.js} +25 -18
  22. package/dist/{chunk-KNDIXZN6.js.map → chunk-7UAMYXJY.js.map} +1 -1
  23. package/dist/{chunk-SEEVCT36.js → chunk-A4UJB2MM.js} +2 -2
  24. package/dist/{chunk-X4HIN6TV.js → chunk-AFLPWUQ7.js} +71 -162
  25. package/dist/chunk-AFLPWUQ7.js.map +1 -0
  26. package/dist/chunk-B3V6TG77.js +41 -0
  27. package/dist/chunk-B3V6TG77.js.map +1 -0
  28. package/dist/{chunk-XEJEOAOV.js → chunk-B7WMS3CS.js} +51 -40
  29. package/dist/chunk-B7WMS3CS.js.map +1 -0
  30. package/dist/{chunk-S5JQ4R22.js → chunk-BDKR6BAR.js} +54 -24
  31. package/dist/chunk-BDKR6BAR.js.map +1 -0
  32. package/dist/{chunk-4MDLEHXL.js → chunk-DYWBUX5Q.js} +24 -12
  33. package/dist/chunk-DYWBUX5Q.js.map +1 -0
  34. package/dist/{chunk-YZ3YYMOM.js → chunk-E3G7SAYZ.js} +252 -624
  35. package/dist/chunk-E3G7SAYZ.js.map +1 -0
  36. package/dist/chunk-JT4JW7BC.js +54 -0
  37. package/dist/chunk-JT4JW7BC.js.map +1 -0
  38. package/dist/{chunk-5AZSK6MV.js → chunk-LWNLETQT.js} +5 -5
  39. package/dist/{chunk-USXVQACU.js → chunk-M7LBYP63.js} +5 -4
  40. package/dist/{chunk-USXVQACU.js.map → chunk-M7LBYP63.js.map} +1 -1
  41. package/dist/{chunk-L4OSXQ6H.js → chunk-NBOHNRLJ.js} +1289 -1060
  42. package/dist/chunk-NBOHNRLJ.js.map +1 -0
  43. package/dist/chunk-RHREZ7BO.js +11 -0
  44. package/dist/chunk-RHREZ7BO.js.map +1 -0
  45. package/dist/chunk-SCOTPE2I.js +589 -0
  46. package/dist/chunk-SCOTPE2I.js.map +1 -0
  47. package/dist/chunk-WEHMPOXK.js +39 -0
  48. package/dist/chunk-WEHMPOXK.js.map +1 -0
  49. package/dist/chunk-WRHIZYZF.js +988 -0
  50. package/dist/chunk-WRHIZYZF.js.map +1 -0
  51. package/dist/{chunk-3E2AITTZ.js → chunk-WUISZ3WU.js} +32 -28
  52. package/dist/{chunk-3E2AITTZ.js.map → chunk-WUISZ3WU.js.map} +1 -1
  53. package/dist/chunk-XCHWDJ22.js +107 -0
  54. package/dist/chunk-XCHWDJ22.js.map +1 -0
  55. package/dist/{chunk-DUEBTL3N.js → chunk-Y2PRMYLC.js} +4 -3
  56. package/dist/{chunk-DUEBTL3N.js.map → chunk-Y2PRMYLC.js.map} +1 -1
  57. package/dist/chunk-YBXQDQFX.js +1772 -0
  58. package/dist/chunk-YBXQDQFX.js.map +1 -0
  59. package/dist/chunk-YV4L3ILF.js +9 -0
  60. package/dist/chunk-YV4L3ILF.js.map +1 -0
  61. package/dist/{chunk-V2ABIVYJ.js → chunk-ZXSEDZP7.js} +70 -6
  62. package/dist/chunk-ZXSEDZP7.js.map +1 -0
  63. package/dist/components/artifacts/artifact-chrome.d.ts +120 -0
  64. package/dist/components/artifacts/artifact-sandbox.d.ts +27 -0
  65. package/dist/components/artifacts/chat-interactive-block.d.ts +29 -0
  66. package/dist/components/artifacts/deferred-chat-media.d.ts +15 -0
  67. package/dist/components/artifacts/editable-artifact-ui.d.ts +2 -1
  68. package/dist/components/artifacts/editable-artifact-view.d.ts +46 -0
  69. package/dist/components/artifacts/index.d.ts +7 -0
  70. package/dist/components/artifacts/latest-task-runner.d.ts +15 -0
  71. package/dist/components/artifacts/session-artifact-viewer.d.ts +39 -0
  72. package/dist/components/artifacts/site-view.d.ts +23 -0
  73. package/dist/components/browser-select-control.d.ts +3 -1
  74. package/dist/components/code-editor.d.ts +1 -4
  75. package/dist/components/desktop-viewer.d.ts +1 -1
  76. package/dist/components/message-timeline.d.ts +20 -3
  77. package/dist/components/open-geni-links.d.ts +16 -0
  78. package/dist/components/session-conversation.d.ts +20 -1
  79. package/dist/components/timeline-pill-geometry.d.ts +8 -0
  80. package/dist/components/timeline-pill-placement.d.ts +24 -0
  81. package/dist/components/usage-limit-notice.d.ts +49 -0
  82. package/dist/components/usage-member-list.d.ts +91 -0
  83. package/dist/components/usage-meter.d.ts +73 -0
  84. package/dist/composer.js +5 -5
  85. package/dist/{computer-viewer-WMJE6OA3.js → computer-viewer-KJWLH3GI.js} +7 -5
  86. package/dist/connect.js +7 -6
  87. package/dist/connect.js.map +1 -1
  88. package/dist/conversation-timeline.d.ts +1 -1
  89. package/dist/desktop.d.ts +7 -0
  90. package/dist/desktop.js +26 -0
  91. package/dist/desktop.js.map +1 -0
  92. package/dist/editor.d.ts +7 -0
  93. package/dist/editor.js +21 -0
  94. package/dist/editor.js.map +1 -0
  95. package/dist/hooks/latest-question.d.ts +1 -1
  96. package/dist/hooks/use-composer.d.ts +4 -0
  97. package/dist/hooks/use-desktop-stream.d.ts +1 -1
  98. package/dist/hooks/use-usage.d.ts +40 -0
  99. package/dist/index.d.ts +7 -2
  100. package/dist/index.js +460 -1386
  101. package/dist/index.js.map +1 -1
  102. package/dist/interaction.js +8 -6
  103. package/dist/interaction.js.map +1 -1
  104. package/dist/{latest-question-QB4EUCW5.js → latest-question-IS445HOW.js} +3 -3
  105. package/dist/lib/format.d.ts +6 -1
  106. package/dist/lib/workbench-peers.d.ts +36 -0
  107. package/dist/machines.js +2 -2
  108. package/dist/{markdown-table-layout-PFUEZ2U4.js → markdown-table-layout-QXSLK4BY.js} +37 -5
  109. package/dist/markdown-table-layout-QXSLK4BY.js.map +1 -0
  110. package/dist/{queue-surface-implementation-IM2HD3SK.js → queue-surface-implementation-2CAQAIHM.js} +2 -2
  111. package/dist/realtime.js +3 -3
  112. package/dist/{sandbox-files-PXPIZAFG.js → sandbox-files-AEJTWGT4.js} +8 -5
  113. package/dist/sandbox-files-AEJTWGT4.js.map +1 -0
  114. package/dist/session-ui.d.ts +9 -2
  115. package/dist/session-ui.js +39 -20
  116. package/dist/session-ui.js.map +1 -1
  117. package/dist/session.js +4 -4
  118. package/dist/sites.js +4 -289
  119. package/dist/sites.js.map +1 -1
  120. package/dist/terminal.d.ts +8 -0
  121. package/dist/terminal.js +31 -0
  122. package/dist/terminal.js.map +1 -0
  123. package/dist/timeline/allowance-exhausted-row.d.ts +24 -0
  124. package/dist/timeline/genie-loading.d.ts +3 -1
  125. package/dist/timeline/types.d.ts +8 -1
  126. package/dist/{timeline-annotation-selection-ODLVF4OI.js → timeline-annotation-selection-DMLMM2TX.js} +6 -1
  127. package/dist/timeline-annotation-selection-DMLMM2TX.js.map +1 -0
  128. package/dist/timeline-pill-geometry-Z23V243M.js +110 -0
  129. package/dist/timeline-pill-geometry-Z23V243M.js.map +1 -0
  130. package/dist/usage/allowance-copy.d.ts +25 -0
  131. package/dist/usage/summary.d.ts +35 -0
  132. package/dist/usage.d.ts +13 -0
  133. package/dist/usage.js +1073 -0
  134. package/dist/usage.js.map +1 -0
  135. package/package.json +19 -3
  136. package/src/artifacts.ts +37 -0
  137. package/src/components/artifacts/artifact-chrome.tsx +413 -0
  138. package/src/components/artifacts/artifact-sandbox.tsx +185 -0
  139. package/src/components/artifacts/chat-interactive-block.tsx +260 -0
  140. package/src/components/artifacts/deferred-chat-media.tsx +67 -0
  141. package/src/components/artifacts/editable-artifact-ui.tsx +3 -1
  142. package/src/components/artifacts/editable-artifact-view.tsx +187 -0
  143. package/src/components/artifacts/editable-artifact-workbench.tsx +5 -2
  144. package/src/components/artifacts/editable-document.tsx +34 -36
  145. package/src/components/artifacts/editable-presentation.tsx +21 -16
  146. package/src/components/artifacts/index.ts +41 -0
  147. package/src/components/artifacts/latest-task-runner.ts +49 -0
  148. package/src/components/artifacts/session-artifact-viewer.tsx +376 -0
  149. package/src/components/artifacts/site-view.tsx +116 -0
  150. package/src/components/browser-select-control.tsx +4 -1
  151. package/src/components/browser-viewer.tsx +81 -22
  152. package/src/components/code-editor.tsx +13 -16
  153. package/src/components/computer-viewer.tsx +51 -23
  154. package/src/components/desktop-viewer.tsx +1 -1
  155. package/src/components/markdown-table-layout.ts +49 -5
  156. package/src/components/markdown.tsx +5 -2
  157. package/src/components/message-timeline.tsx +557 -516
  158. package/src/components/open-geni-links.tsx +24 -0
  159. package/src/components/sandbox-terminal.tsx +48 -11
  160. package/src/components/session-chrome.tsx +22 -3
  161. package/src/components/session-conversation.tsx +195 -102
  162. package/src/components/timeline-annotation-selection.tsx +7 -0
  163. package/src/components/timeline-pill-geometry.ts +169 -0
  164. package/src/components/timeline-pill-placement.ts +99 -0
  165. package/src/components/usage-limit-notice.tsx +179 -0
  166. package/src/components/usage-member-list.tsx +792 -0
  167. package/src/components/usage-meter.tsx +382 -0
  168. package/src/conversation-timeline.ts +10 -2
  169. package/src/desktop.ts +24 -0
  170. package/src/editor.ts +13 -0
  171. package/src/hooks/use-available-models.ts +1 -1
  172. package/src/hooks/use-composer.ts +57 -2
  173. package/src/hooks/use-desktop-stream.ts +19 -28
  174. package/src/hooks/use-usage.ts +100 -0
  175. package/src/index.ts +22 -0
  176. package/src/lib/format.ts +32 -6
  177. package/src/lib/workbench-peers.ts +62 -0
  178. package/src/session-ui.ts +22 -0
  179. package/src/terminal.ts +26 -0
  180. package/src/timeline/activity-rail.tsx +5 -3
  181. package/src/timeline/allowance-exhausted-row.tsx +86 -0
  182. package/src/timeline/genie-loading.tsx +16 -6
  183. package/src/timeline/projection.ts +78 -4
  184. package/src/timeline/types.ts +8 -0
  185. package/src/usage/allowance-copy.ts +64 -0
  186. package/src/usage/summary.ts +93 -0
  187. package/src/usage.ts +50 -0
  188. package/styles/compiled.css +325 -0
  189. package/dist/chunk-4MDLEHXL.js.map +0 -1
  190. package/dist/chunk-5YK2ZDIG.js.map +0 -1
  191. package/dist/chunk-KVERXCFY.js.map +0 -1
  192. package/dist/chunk-L4OSXQ6H.js.map +0 -1
  193. package/dist/chunk-S5JQ4R22.js.map +0 -1
  194. package/dist/chunk-UDD6DLLT.js.map +0 -1
  195. package/dist/chunk-UGMKPUEM.js +0 -299
  196. package/dist/chunk-UGMKPUEM.js.map +0 -1
  197. package/dist/chunk-V2ABIVYJ.js.map +0 -1
  198. package/dist/chunk-X4HIN6TV.js.map +0 -1
  199. package/dist/chunk-XEJEOAOV.js.map +0 -1
  200. package/dist/chunk-YZ3YYMOM.js.map +0 -1
  201. package/dist/markdown-table-layout-PFUEZ2U4.js.map +0 -1
  202. package/dist/timeline-annotation-selection-ODLVF4OI.js.map +0 -1
  203. /package/dist/{browser-viewer-2E6R2Y4W.js.map → browser-viewer-Z7S7QEJL.js.map} +0 -0
  204. /package/dist/{computer-viewer-WMJE6OA3.js.map → chat-interactive-block-UGBTNB3L.js.map} +0 -0
  205. /package/dist/{chunk-Y3QTZN2F.js.map → chunk-2VFNCX4E.js.map} +0 -0
  206. /package/dist/{chunk-SEEVCT36.js.map → chunk-A4UJB2MM.js.map} +0 -0
  207. /package/dist/{chunk-5AZSK6MV.js.map → chunk-LWNLETQT.js.map} +0 -0
  208. /package/dist/{sandbox-files-PXPIZAFG.js.map → computer-viewer-KJWLH3GI.js.map} +0 -0
  209. /package/dist/{latest-question-QB4EUCW5.js.map → latest-question-IS445HOW.js.map} +0 -0
  210. /package/dist/{queue-surface-implementation-IM2HD3SK.js.map → queue-surface-implementation-2CAQAIHM.js.map} +0 -0
@@ -0,0 +1,792 @@
1
+ import type {
2
+ MemberAllowanceDefault,
3
+ MemberAllowanceRule,
4
+ MemberAllowanceUsage,
5
+ WorkspaceUsageResponse,
6
+ } from "@opengeni/sdk/usage-allowances";
7
+ import { Slider } from "radix-ui";
8
+ import { useId, useMemo, useState, type ReactNode } from "react";
9
+
10
+ import { cn } from "../lib/cn";
11
+ import { usagePercentLabel } from "../usage/summary";
12
+
13
+ /** How a member is named in the list. Usage rows carry only a subject id. */
14
+ export type UsageMemberIdentity = {
15
+ name: string;
16
+ /** A second line: an email, a role, or "You". */
17
+ detail?: string | null | undefined;
18
+ };
19
+
20
+ /**
21
+ * Opt in to fixed-amount limits. Amounts are USD micros on the wire; your
22
+ * format/parse decide the unit people type ("$50.00", "500 credits").
23
+ */
24
+ export type UsageAmountFormat = {
25
+ format: (micros: number) => string;
26
+ /** Returns micros, or null when the text is not a valid amount. */
27
+ parse: (text: string) => number | null;
28
+ /** Shown before the input, for example "$". */
29
+ prefix?: string | undefined;
30
+ /** The amount input's step in the person's unit. */
31
+ step?: number | undefined;
32
+ };
33
+
34
+ export type UsageMemberListLabels = {
35
+ listLabel: string;
36
+ used: (percent: string) => string;
37
+ noLimitUsed: string;
38
+ amounts: (used: string, limit: string) => string;
39
+ nearLimit: string;
40
+ limitReached: string;
41
+ limit: string;
42
+ defaultEqualShare: (percent: string) => string;
43
+ defaultNone: string;
44
+ defaultShare: (percent: string) => string;
45
+ defaultAmount: (amount: string) => string;
46
+ share: (percent: string) => string;
47
+ amount: (amount: string) => string;
48
+ fixedAmountHidden: string;
49
+ edit: (name: string) => string;
50
+ editorTitle: (name: string) => string;
51
+ optionDefault: string;
52
+ optionShare: string;
53
+ optionAmount: string;
54
+ fixedAmountHint: string;
55
+ shareSlider: string;
56
+ shareInput: string;
57
+ amountInput: string;
58
+ equalShareMultiple: (multiple: string) => string;
59
+ sameAsEqualShare: string;
60
+ atBudget: (amount: string) => string;
61
+ save: string;
62
+ saving: string;
63
+ cancel: string;
64
+ saveFailed: string;
65
+ invalidAmount: string;
66
+ totalWithin: (percent: string) => string;
67
+ totalOver: (percent: string) => string;
68
+ oversubscribedHelp: string;
69
+ unlimitedMembers: (count: number) => string;
70
+ empty: string;
71
+ };
72
+
73
+ export const DEFAULT_USAGE_MEMBER_LIST_LABELS: UsageMemberListLabels = {
74
+ listLabel: "Member usage",
75
+ used: (percent) => `${percent}% used`,
76
+ noLimitUsed: "No individual limit",
77
+ amounts: (used, limit) => `${used} of ${limit}`,
78
+ nearLimit: "Near limit",
79
+ limitReached: "Limit reached",
80
+ limit: "Limit",
81
+ defaultEqualShare: (percent) => `Equal share · ${percent}%`,
82
+ defaultNone: "No individual limit",
83
+ defaultShare: (percent) => `Default · ${percent}%`,
84
+ defaultAmount: (amount) => `Default · ${amount}`,
85
+ share: (percent) => `${percent}% of budget`,
86
+ amount: (amount) => `${amount} fixed`,
87
+ fixedAmountHidden: "Fixed amount",
88
+ edit: (name) => `Change limit for ${name}`,
89
+ editorTitle: (name) => `Limit for ${name}`,
90
+ optionDefault: "Default",
91
+ optionShare: "Share of budget",
92
+ optionAmount: "Fixed amount",
93
+ fixedAmountHint: "Each period. Doesn't grow when the budget does.",
94
+ shareSlider: "Share of the workspace budget",
95
+ shareInput: "Share, percent",
96
+ amountInput: "Fixed amount",
97
+ equalShareMultiple: (multiple) => `${multiple}× an equal share`,
98
+ sameAsEqualShare: "About an equal share",
99
+ atBudget: (amount) => `About ${amount} at the current budget`,
100
+ save: "Save",
101
+ saving: "Saving…",
102
+ cancel: "Cancel",
103
+ saveFailed: "Couldn't save the limit. Refresh and try again.",
104
+ invalidAmount: "Enter an amount of zero or more.",
105
+ totalWithin: (percent) => `Member limits add up to ${percent}% of the budget.`,
106
+ totalOver: (percent) => `Member limits add up to ${percent}% of the budget.`,
107
+ oversubscribedHelp:
108
+ "That's allowed: limits are ceilings, not reservations. The workspace budget still caps what everyone spends together.",
109
+ unlimitedMembers: (count) =>
110
+ count === 1
111
+ ? "1 member has no individual limit."
112
+ : `${count} members have no individual limit.`,
113
+ empty: "No members yet.",
114
+ };
115
+
116
+ export type UsageMemberListProps = {
117
+ /** The full roster for one period (`getAllUsage` / every `getUsage` page). */
118
+ usage: WorkspaceUsageResponse;
119
+ /** The workspace's `memberDefault`, to name what "default" means. */
120
+ memberDefault?: MemberAllowanceDefault | null | undefined;
121
+ /** Name a member. Defaults to the external id, then the subject id. */
122
+ describe?: ((member: MemberAllowanceUsage) => UsageMemberIdentity) | undefined;
123
+ /**
124
+ * Save a member's rule (`null` restores the default). Omit for a read-only
125
+ * list. Run it on your backend: member limits need workspace admin authority.
126
+ */
127
+ onChangeRule?:
128
+ | ((member: MemberAllowanceUsage, rule: MemberAllowanceRule) => Promise<void>)
129
+ | undefined;
130
+ /** Opt in to amounts and fixed-amount limits. Shares only without it. */
131
+ amounts?: UsageAmountFormat | undefined;
132
+ labels?: Partial<UsageMemberListLabels> | undefined;
133
+ /** Draw the list's own border. Turn off inside a card that already has one. */
134
+ framed?: boolean | undefined;
135
+ className?: string | undefined;
136
+ };
137
+
138
+ const STATUS_RANK = { exhausted: 0, warning: 1, ok: 2 } as const;
139
+
140
+ function shareBase(usage: WorkspaceUsageResponse): number {
141
+ return usage.workspace.includedCredits + usage.workspace.grantsRemaining;
142
+ }
143
+
144
+ function formatPercent(value: number): string {
145
+ // One decimal only where a whole number would hide a real difference.
146
+ const scaled = value * 100;
147
+ return Number.isInteger(Math.round(scaled * 10) / 10) || scaled >= 10
148
+ ? String(Math.round(scaled))
149
+ : (Math.round(scaled * 10) / 10).toFixed(1);
150
+ }
151
+
152
+ function formatMultiple(value: number): string {
153
+ return value >= 10 ? String(Math.round(value)) : (Math.round(value * 10) / 10).toString();
154
+ }
155
+
156
+ /** Words for a member's current limit, from their override or the default. */
157
+ export function describeMemberLimit(
158
+ member: Pick<MemberAllowanceUsage, "rule" | "limit">,
159
+ context: {
160
+ memberDefault: MemberAllowanceDefault | null | undefined;
161
+ shareBase: number;
162
+ amounts?: UsageAmountFormat | undefined;
163
+ labels?: Partial<UsageMemberListLabels> | undefined;
164
+ },
165
+ ): string {
166
+ const labels = { ...DEFAULT_USAGE_MEMBER_LIST_LABELS, ...context.labels };
167
+ const ofBudget = (limit: number | null) =>
168
+ limit !== null && context.shareBase > 0 ? formatPercent(limit / context.shareBase) : "0";
169
+ const rule = member.rule;
170
+ if (rule && "share" in rule) return labels.share(formatPercent(rule.share));
171
+ if (rule && "credits" in rule) {
172
+ return context.amounts
173
+ ? labels.amount(context.amounts.format(rule.credits))
174
+ : labels.fixedAmountHidden;
175
+ }
176
+ const fallback = context.memberDefault ?? "none";
177
+ if (fallback === "none" || member.limit === null) return labels.defaultNone;
178
+ if (fallback === "equal_share") return labels.defaultEqualShare(ofBudget(member.limit));
179
+ if ("share" in fallback) return labels.defaultShare(formatPercent(fallback.share));
180
+ return context.amounts
181
+ ? labels.defaultAmount(context.amounts.format(fallback.credits))
182
+ : labels.defaultShare(ofBudget(member.limit));
183
+ }
184
+
185
+ /**
186
+ * Every member's usage against their own limit, with an optional editor:
187
+ * a share-of-budget slider (default), a fixed amount, or the workspace
188
+ * default. Oversubscription is shown, never blocked.
189
+ */
190
+ export function UsageMemberList({
191
+ usage,
192
+ memberDefault,
193
+ describe,
194
+ onChangeRule,
195
+ amounts,
196
+ labels: overrides,
197
+ framed = true,
198
+ className,
199
+ }: UsageMemberListProps) {
200
+ const labels = { ...DEFAULT_USAGE_MEMBER_LIST_LABELS, ...overrides };
201
+ const base = shareBase(usage);
202
+ const [editing, setEditing] = useState<string | null>(null);
203
+ const name = (member: MemberAllowanceUsage): UsageMemberIdentity =>
204
+ describe?.(member) ?? {
205
+ name: member.externalIdentity?.externalId ?? member.subjectId,
206
+ detail: null,
207
+ };
208
+ const members = useMemo(
209
+ () =>
210
+ [...usage.members].sort(
211
+ (left, right) =>
212
+ STATUS_RANK[left.status] - STATUS_RANK[right.status] || right.used - left.used,
213
+ ),
214
+ [usage.members],
215
+ );
216
+ if (members.length === 0) {
217
+ return <p className={cn("og-root text-og-sm text-og-fg-muted", className)}>{labels.empty}</p>;
218
+ }
219
+ return (
220
+ <div className={cn("og-root flex min-w-0 flex-col gap-3", className)}>
221
+ <AllocationSummary usage={usage} base={base} labels={labels} framed={framed} />
222
+ <ul
223
+ aria-label={labels.listLabel}
224
+ className={cn(
225
+ "divide-y divide-og-border",
226
+ framed && "overflow-hidden rounded-og-md border border-og-border",
227
+ )}
228
+ >
229
+ {members.map((member) => (
230
+ <MemberRow
231
+ key={member.subjectId}
232
+ member={member}
233
+ identity={name(member)}
234
+ usage={usage}
235
+ base={base}
236
+ memberDefault={memberDefault}
237
+ amounts={amounts}
238
+ labels={labels}
239
+ framed={framed}
240
+ editing={editing === member.subjectId}
241
+ onEdit={
242
+ onChangeRule
243
+ ? () =>
244
+ setEditing((current) =>
245
+ current === member.subjectId ? null : member.subjectId,
246
+ )
247
+ : undefined
248
+ }
249
+ onClose={() => setEditing(null)}
250
+ onSave={
251
+ onChangeRule
252
+ ? async (rule) => {
253
+ await onChangeRule(member, rule);
254
+ setEditing(null);
255
+ }
256
+ : undefined
257
+ }
258
+ />
259
+ ))}
260
+ </ul>
261
+ </div>
262
+ );
263
+ }
264
+
265
+ function memberShares(usage: WorkspaceUsageResponse, base: number) {
266
+ let total = 0;
267
+ let unlimited = 0;
268
+ for (const member of usage.members) {
269
+ if (member.limit === null) unlimited += 1;
270
+ else total += base > 0 ? member.limit / base : 0;
271
+ }
272
+ return { total, unlimited };
273
+ }
274
+
275
+ function AllocationSummary({
276
+ usage,
277
+ base,
278
+ labels,
279
+ framed,
280
+ }: {
281
+ usage: WorkspaceUsageResponse;
282
+ base: number;
283
+ labels: UsageMemberListLabels;
284
+ framed: boolean;
285
+ }) {
286
+ if (base <= 0) return null;
287
+ const { total, unlimited } = memberShares(usage, base);
288
+ const limited = usage.members.length - unlimited;
289
+ const over = total > 1.005;
290
+ // An untouched default (everyone on an equal share, 100% in total) needs no
291
+ // summary; say something only when the split has been shaped or overflows.
292
+ const shaped = usage.members.some((member) => member.rule !== null);
293
+ if (limited === 0 || (!over && !shaped && unlimited === 0)) return null;
294
+ return (
295
+ <div
296
+ data-og-usage-allocation={over ? "oversubscribed" : "within"}
297
+ className={cn(
298
+ "flex min-w-0 flex-col gap-1 text-og-sm",
299
+ framed || over ? "rounded-og-md px-3 py-2" : "pt-1",
300
+ over
301
+ ? "bg-og-status-waiting/8 text-og-fg"
302
+ : framed
303
+ ? "bg-og-surface-1 text-og-fg-muted"
304
+ : "text-og-fg-muted",
305
+ )}
306
+ >
307
+ {limited > 0 ? (
308
+ <AllocationBar total={total} over={over}>
309
+ {over ? labels.totalOver(formatPercent(total)) : labels.totalWithin(formatPercent(total))}
310
+ </AllocationBar>
311
+ ) : null}
312
+ {over ? <p className="text-og-xs text-og-fg-muted">{labels.oversubscribedHelp}</p> : null}
313
+ {unlimited > 0 ? (
314
+ <p className="text-og-xs text-og-fg-muted">{labels.unlimitedMembers(unlimited)}</p>
315
+ ) : null}
316
+ </div>
317
+ );
318
+ }
319
+
320
+ function AllocationBar({
321
+ total,
322
+ over,
323
+ children,
324
+ }: {
325
+ total: number;
326
+ over: boolean;
327
+ children: ReactNode;
328
+ }) {
329
+ // The track is the budget; past 100% the fill keeps going in a second tone
330
+ // so oversubscription reads as "more promised than exists", not an error.
331
+ const scale = Math.max(1, total);
332
+ return (
333
+ <div className="flex min-w-0 flex-col gap-1.5">
334
+ <p className={cn(over && "font-medium")}>{children}</p>
335
+ <span aria-hidden className="relative block h-1 overflow-hidden rounded-full bg-og-surface-3">
336
+ <span
337
+ className="absolute inset-y-0 left-0 bg-og-accent/70"
338
+ style={{ width: `${(Math.min(total, 1) / scale) * 100}%` }}
339
+ />
340
+ {over ? (
341
+ <>
342
+ <span
343
+ className="absolute inset-y-0 bg-og-status-waiting"
344
+ style={{ left: `${(1 / scale) * 100}%`, right: 0 }}
345
+ />
346
+ <span
347
+ className="absolute inset-y-[-2px] w-px bg-og-fg"
348
+ style={{ left: `${(1 / scale) * 100}%` }}
349
+ />
350
+ </>
351
+ ) : null}
352
+ </span>
353
+ </div>
354
+ );
355
+ }
356
+
357
+ function MemberRow({
358
+ member,
359
+ identity,
360
+ usage,
361
+ base,
362
+ memberDefault,
363
+ amounts,
364
+ labels,
365
+ framed,
366
+ editing,
367
+ onEdit,
368
+ onClose,
369
+ onSave,
370
+ }: {
371
+ framed: boolean;
372
+ member: MemberAllowanceUsage;
373
+ identity: UsageMemberIdentity;
374
+ usage: WorkspaceUsageResponse;
375
+ base: number;
376
+ memberDefault: MemberAllowanceDefault | null | undefined;
377
+ amounts: UsageAmountFormat | undefined;
378
+ labels: UsageMemberListLabels;
379
+ editing: boolean;
380
+ onEdit: (() => void) | undefined;
381
+ onClose: () => void;
382
+ onSave: ((rule: MemberAllowanceRule) => Promise<void>) | undefined;
383
+ }) {
384
+ const editorId = useId();
385
+ const limited = member.limit !== null;
386
+ const fraction = member.fraction ?? 0;
387
+ const limitText = describeMemberLimit(member, {
388
+ memberDefault,
389
+ shareBase: base,
390
+ amounts,
391
+ labels,
392
+ });
393
+ return (
394
+ <li className={cn("min-w-0", framed && "bg-og-bg")} data-og-usage-member={member.subjectId}>
395
+ <div
396
+ className={cn(
397
+ "grid min-w-0 grid-cols-1 gap-x-4 gap-y-2 py-3 sm:grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)_11rem] sm:items-center",
398
+ framed && "px-3.5",
399
+ )}
400
+ >
401
+ <div className="min-w-0">
402
+ <p className="truncate text-og-sm font-medium text-og-fg">{identity.name}</p>
403
+ {identity.detail ? (
404
+ <p className="truncate text-og-xs text-og-fg-subtle">{identity.detail}</p>
405
+ ) : null}
406
+ </div>
407
+ <div className="flex min-w-0 flex-col gap-1">
408
+ <div className="flex min-w-0 items-baseline justify-between gap-2 text-og-xs">
409
+ <span
410
+ className={cn(
411
+ "tabular-nums",
412
+ member.status === "ok" && "text-og-fg-muted",
413
+ member.status === "warning" && "font-medium text-og-status-waiting",
414
+ member.status === "exhausted" && "font-medium text-og-danger",
415
+ )}
416
+ >
417
+ {limited
418
+ ? member.status === "exhausted"
419
+ ? labels.limitReached
420
+ : `${labels.used(usagePercentLabel(fraction))}${
421
+ member.status === "warning" ? ` · ${labels.nearLimit}` : ""
422
+ }`
423
+ : labels.noLimitUsed}
424
+ </span>
425
+ {amounts ? (
426
+ <span className="truncate text-og-fg-subtle tabular-nums">
427
+ {limited
428
+ ? labels.amounts(amounts.format(member.used), amounts.format(member.limit!))
429
+ : amounts.format(member.used)}
430
+ </span>
431
+ ) : null}
432
+ </div>
433
+ <span
434
+ aria-hidden
435
+ className={cn(
436
+ "relative block h-1 overflow-hidden rounded-full",
437
+ limited ? "bg-og-surface-3" : "bg-transparent",
438
+ )}
439
+ >
440
+ {limited ? (
441
+ <span
442
+ className={cn(
443
+ "absolute inset-y-0 left-0 rounded-full",
444
+ member.status === "ok"
445
+ ? "bg-og-accent"
446
+ : member.status === "warning"
447
+ ? "bg-og-status-waiting"
448
+ : "bg-og-danger",
449
+ )}
450
+ style={{ width: `${Math.min(1, Math.max(0, fraction)) * 100}%` }}
451
+ />
452
+ ) : null}
453
+ </span>
454
+ </div>
455
+ <div className="flex min-w-0 items-center justify-between gap-2 sm:justify-end">
456
+ <span className="text-og-xs text-og-fg-subtle sm:hidden">{labels.limit}</span>
457
+ {onEdit ? (
458
+ <button
459
+ type="button"
460
+ onClick={onEdit}
461
+ aria-expanded={editing}
462
+ aria-controls={editing ? editorId : undefined}
463
+ aria-label={`${labels.edit(identity.name)}: ${limitText}`}
464
+ className="inline-flex h-8 max-w-full items-center gap-1.5 truncate rounded-og-sm border border-og-border px-2.5 text-og-control text-og-fg transition hover:bg-og-surface-2 focus-visible:outline-2 focus-visible:outline-og-accent pointer-coarse:h-10"
465
+ >
466
+ {limitText}
467
+ </button>
468
+ ) : (
469
+ <span className="truncate text-og-xs text-og-fg-muted">{limitText}</span>
470
+ )}
471
+ </div>
472
+ </div>
473
+ {editing && onSave ? (
474
+ <MemberLimitEditor
475
+ id={editorId}
476
+ member={member}
477
+ name={identity.name}
478
+ usage={usage}
479
+ base={base}
480
+ memberDefault={memberDefault}
481
+ amounts={amounts}
482
+ labels={labels}
483
+ framed={framed}
484
+ onCancel={onClose}
485
+ onSave={onSave}
486
+ />
487
+ ) : null}
488
+ </li>
489
+ );
490
+ }
491
+
492
+ type Mode = "default" | "share" | "amount";
493
+
494
+ /**
495
+ * Opening the editor means "give this person more or less", so a member on
496
+ * the default starts on the share slider at their current share.
497
+ */
498
+ function initialMode(rule: MemberAllowanceRule, amounts: UsageAmountFormat | undefined): Mode {
499
+ if (rule === null) return "share";
500
+ if ("share" in rule) return "share";
501
+ return amounts ? "amount" : "share";
502
+ }
503
+
504
+ function MemberLimitEditor({
505
+ id,
506
+ member,
507
+ name,
508
+ usage,
509
+ base,
510
+ memberDefault,
511
+ amounts,
512
+ labels,
513
+ framed,
514
+ onCancel,
515
+ onSave,
516
+ }: {
517
+ framed: boolean;
518
+ id: string;
519
+ member: MemberAllowanceUsage;
520
+ name: string;
521
+ usage: WorkspaceUsageResponse;
522
+ base: number;
523
+ memberDefault: MemberAllowanceDefault | null | undefined;
524
+ amounts: UsageAmountFormat | undefined;
525
+ labels: UsageMemberListLabels;
526
+ onCancel: () => void;
527
+ onSave: (rule: MemberAllowanceRule) => Promise<void>;
528
+ }) {
529
+ const count = Math.max(1, usage.members.length);
530
+ const equal = 1 / count;
531
+ const currentShare =
532
+ member.rule && "share" in member.rule
533
+ ? member.rule.share
534
+ : member.limit !== null && base > 0
535
+ ? member.limit / base
536
+ : equal;
537
+ const startPercent = Math.min(100, Math.max(0, Math.round(currentShare * 100)));
538
+ const startAmount =
539
+ amounts && member.rule && "credits" in member.rule
540
+ ? stripPrefix(amounts.format(member.rule.credits), amounts.prefix)
541
+ : amounts && member.limit !== null
542
+ ? stripPrefix(amounts.format(member.limit), amounts.prefix)
543
+ : "";
544
+ const [mode, setMode] = useState<Mode>(() => initialMode(member.rule, amounts));
545
+ const [sharePercent, setSharePercent] = useState(startPercent);
546
+ const [amountText, setAmountText] = useState(startAmount);
547
+ const [saving, setSaving] = useState(false);
548
+ const [error, setError] = useState<string | null>(null);
549
+ const share = sharePercent / 100;
550
+ const parsedAmount = amounts && mode === "amount" ? amounts.parse(amountText) : null;
551
+ const defaultLimitText = describeMemberLimit(
552
+ {
553
+ rule: null,
554
+ limit: member.rule === null ? member.limit : defaultLimitFor(memberDefault, base, count),
555
+ },
556
+ { memberDefault, shareBase: base, amounts, labels },
557
+ );
558
+ // Nothing to save until the person moves something: opening the slider on a
559
+ // default member must not silently turn their default into an override.
560
+ const unchanged =
561
+ mode === "default"
562
+ ? member.rule === null
563
+ : mode === "share"
564
+ ? (member.rule === null || "share" in member.rule) && sharePercent === startPercent
565
+ : member.rule !== null && "credits" in member.rule && amountText === startAmount;
566
+ // The roster total if this member's limit changes to the draft.
567
+ const draftLimit =
568
+ mode === "share"
569
+ ? share * base
570
+ : mode === "amount"
571
+ ? parsedAmount
572
+ : defaultLimitFor(memberDefault, base, count);
573
+ const others = usage.members.reduce(
574
+ (sum, other) =>
575
+ other.subjectId === member.subjectId || other.limit === null ? sum : sum + other.limit,
576
+ 0,
577
+ );
578
+ const draftTotal = base > 0 && draftLimit !== null ? (others + draftLimit) / base : null;
579
+
580
+ async function save() {
581
+ let rule: MemberAllowanceRule;
582
+ if (mode === "default") rule = null;
583
+ else if (mode === "share") rule = { share };
584
+ else {
585
+ if (parsedAmount === null || !Number.isSafeInteger(parsedAmount) || parsedAmount < 0) {
586
+ setError(labels.invalidAmount);
587
+ return;
588
+ }
589
+ rule = { credits: parsedAmount };
590
+ }
591
+ setSaving(true);
592
+ setError(null);
593
+ try {
594
+ await onSave(rule);
595
+ } catch (cause) {
596
+ setError(cause instanceof Error && cause.message ? cause.message : labels.saveFailed);
597
+ setSaving(false);
598
+ }
599
+ }
600
+
601
+ const modes: { value: Mode; label: string }[] = [
602
+ { value: "share", label: labels.optionShare },
603
+ ...(amounts ? [{ value: "amount" as const, label: labels.optionAmount }] : []),
604
+ { value: "default", label: labels.optionDefault },
605
+ ];
606
+
607
+ return (
608
+ <div
609
+ id={id}
610
+ role="group"
611
+ aria-label={labels.editorTitle(name)}
612
+ className={cn(
613
+ "bg-og-surface-1 px-3.5 py-3",
614
+ framed ? "border-t border-og-border" : "mb-3 rounded-og-md",
615
+ )}
616
+ onKeyDown={(event) => {
617
+ if (event.key === "Escape" && !saving) {
618
+ event.stopPropagation();
619
+ onCancel();
620
+ }
621
+ }}
622
+ >
623
+ <fieldset className="flex min-w-0 flex-col gap-3" disabled={saving}>
624
+ <legend className="sr-only">{labels.editorTitle(name)}</legend>
625
+ <div
626
+ role="radiogroup"
627
+ aria-label={labels.limit}
628
+ className="inline-flex w-fit max-w-full flex-wrap gap-0.5 rounded-og-sm bg-og-surface-3/60 p-0.5"
629
+ >
630
+ {modes.map((option) => (
631
+ <label
632
+ key={option.value}
633
+ className={cn(
634
+ "relative inline-flex h-7 cursor-pointer items-center rounded-[5px] px-2.5 text-og-control transition has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-og-accent pointer-coarse:h-9",
635
+ mode === option.value
636
+ ? "bg-og-bg font-medium text-og-fg shadow-sm"
637
+ : "text-og-fg-muted hover:text-og-fg",
638
+ )}
639
+ >
640
+ <input
641
+ type="radio"
642
+ name={`${id}-mode`}
643
+ value={option.value}
644
+ checked={mode === option.value}
645
+ onChange={() => setMode(option.value)}
646
+ className="sr-only"
647
+ />
648
+ {option.label}
649
+ </label>
650
+ ))}
651
+ </div>
652
+
653
+ {mode === "share" ? (
654
+ <div className="flex min-w-0 flex-col gap-2">
655
+ <div className="flex min-w-0 items-center gap-3">
656
+ <Slider.Root
657
+ value={[sharePercent]}
658
+ min={0}
659
+ max={100}
660
+ step={1}
661
+ onValueChange={(next) => setSharePercent(next[0] ?? 0)}
662
+ data-og-share-slider=""
663
+ className="relative flex h-6 min-w-0 flex-1 touch-none items-center select-none pointer-coarse:h-10"
664
+ >
665
+ <Slider.Track className="relative h-1.5 grow overflow-hidden rounded-full bg-og-surface-3">
666
+ <Slider.Range className="absolute h-full rounded-full bg-og-accent" />
667
+ </Slider.Track>
668
+ {count > 1 ? (
669
+ <span
670
+ aria-hidden
671
+ className="pointer-events-none absolute top-1/2 h-3.5 w-0.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-og-fg-subtle"
672
+ style={{ left: `${equal * 100}%` }}
673
+ />
674
+ ) : null}
675
+ <Slider.Thumb
676
+ aria-label={labels.shareSlider}
677
+ aria-valuetext={`${sharePercent}%`}
678
+ className="block size-4 rounded-full border-2 border-og-accent bg-og-bg shadow-sm transition focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-og-accent pointer-coarse:size-6"
679
+ />
680
+ </Slider.Root>
681
+ <label className="flex shrink-0 items-center gap-1 text-og-sm text-og-fg">
682
+ <input
683
+ type="number"
684
+ inputMode="numeric"
685
+ min={0}
686
+ max={100}
687
+ step={1}
688
+ value={sharePercent}
689
+ aria-label={labels.shareInput}
690
+ onChange={(event) => {
691
+ const next = Number(event.target.value);
692
+ if (Number.isFinite(next))
693
+ setSharePercent(Math.min(100, Math.max(0, Math.round(next))));
694
+ }}
695
+ className="h-8 w-16 rounded-og-sm border border-og-border bg-og-bg px-2 text-right tabular-nums focus-visible:outline-2 focus-visible:outline-og-accent pointer-coarse:h-10"
696
+ />
697
+ %
698
+ </label>
699
+ </div>
700
+ <p className="text-og-xs text-og-fg-subtle" aria-live="polite">
701
+ {[
702
+ count > 1
703
+ ? Math.abs(share * count - 1) < 0.05
704
+ ? labels.sameAsEqualShare
705
+ : labels.equalShareMultiple(formatMultiple(share * count))
706
+ : null,
707
+ amounts && base > 0
708
+ ? labels.atBudget(amounts.format(Math.round(share * base)))
709
+ : null,
710
+ ]
711
+ .filter(Boolean)
712
+ .join(" · ")}
713
+ </p>
714
+ </div>
715
+ ) : mode === "amount" && amounts ? (
716
+ <div className="flex items-center gap-1 text-og-sm text-og-fg">
717
+ {amounts.prefix ? <span className="text-og-fg-subtle">{amounts.prefix}</span> : null}
718
+ <input
719
+ type="number"
720
+ inputMode="decimal"
721
+ min={0}
722
+ step={amounts.step ?? 0.01}
723
+ value={amountText}
724
+ aria-label={labels.amountInput}
725
+ aria-invalid={error === labels.invalidAmount || undefined}
726
+ onChange={(event) => setAmountText(event.target.value)}
727
+ className="h-8 w-28 rounded-og-sm border border-og-border bg-og-bg px-2 tabular-nums focus-visible:outline-2 focus-visible:outline-og-accent pointer-coarse:h-10"
728
+ />
729
+ <span className="ml-2 text-og-xs text-og-fg-subtle">{labels.fixedAmountHint}</span>
730
+ </div>
731
+ ) : (
732
+ <p className="text-og-sm text-og-fg-muted">{defaultLimitText}</p>
733
+ )}
734
+ </fieldset>
735
+ {draftTotal !== null ? (
736
+ <p
737
+ className={cn("mt-3 text-og-xs", draftTotal > 1.005 ? "text-og-fg" : "text-og-fg-subtle")}
738
+ >
739
+ {draftTotal > 1.005 ? (
740
+ <>
741
+ <span className="font-medium">{labels.totalOver(formatPercent(draftTotal))}</span>{" "}
742
+ <span className="text-og-fg-muted">{labels.oversubscribedHelp}</span>
743
+ </>
744
+ ) : (
745
+ labels.totalWithin(formatPercent(draftTotal))
746
+ )}
747
+ </p>
748
+ ) : null}
749
+ {error ? (
750
+ <p role="alert" className="mt-2 text-og-xs text-og-danger">
751
+ {error}
752
+ </p>
753
+ ) : null}
754
+ <div className="mt-3 flex items-center justify-end gap-2">
755
+ <button
756
+ type="button"
757
+ onClick={onCancel}
758
+ disabled={saving}
759
+ className="inline-flex h-8 items-center rounded-og-sm px-3 text-og-control text-og-fg transition hover:bg-og-surface-2 focus-visible:outline-2 focus-visible:outline-og-accent disabled:opacity-50 pointer-coarse:h-10"
760
+ >
761
+ {labels.cancel}
762
+ </button>
763
+ <button
764
+ type="button"
765
+ onClick={() => void save()}
766
+ disabled={saving || unchanged}
767
+ className="inline-flex h-8 items-center rounded-og-sm border border-og-primary-border bg-og-primary px-3 text-og-control font-medium text-og-primary-fg transition hover:bg-og-primary-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-og-accent disabled:cursor-not-allowed disabled:opacity-50 pointer-coarse:h-10"
768
+ >
769
+ {saving ? labels.saving : labels.save}
770
+ </button>
771
+ </div>
772
+ </div>
773
+ );
774
+ }
775
+
776
+ function stripPrefix(text: string, prefix: string | undefined): string {
777
+ const plain = prefix && text.startsWith(prefix) ? text.slice(prefix.length) : text;
778
+ return plain.replace(/,/g, "");
779
+ }
780
+
781
+ /** The ceiling the workspace default gives one member (null: none). */
782
+ function defaultLimitFor(
783
+ memberDefault: MemberAllowanceDefault | null | undefined,
784
+ base: number,
785
+ count: number,
786
+ ): number | null {
787
+ const fallback = memberDefault ?? "none";
788
+ if (fallback === "none") return null;
789
+ if (fallback === "equal_share") return base / count;
790
+ if ("share" in fallback) return fallback.share * base;
791
+ return fallback.credits;
792
+ }