@stigmer/react 3.12.4 → 3.12.5

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 (190) hide show
  1. package/attachment/AttachmentChipList.js +16 -6
  2. package/attachment/AttachmentChipList.js.map +1 -1
  3. package/attachment/index.d.ts +1 -1
  4. package/attachment/index.d.ts.map +1 -1
  5. package/attachment/index.js.map +1 -1
  6. package/attachment/useAttachments.d.ts +51 -8
  7. package/attachment/useAttachments.d.ts.map +1 -1
  8. package/attachment/useAttachments.js +74 -3
  9. package/attachment/useAttachments.js.map +1 -1
  10. package/composer/SessionComposer.d.ts.map +1 -1
  11. package/composer/SessionComposer.js +17 -10
  12. package/composer/SessionComposer.js.map +1 -1
  13. package/conversation/ConversationComposer.d.ts +19 -3
  14. package/conversation/ConversationComposer.d.ts.map +1 -1
  15. package/conversation/ConversationComposer.js +4 -4
  16. package/conversation/ConversationComposer.js.map +1 -1
  17. package/conversation/ConversationTemplatePickerDialog.d.ts +63 -0
  18. package/conversation/ConversationTemplatePickerDialog.d.ts.map +1 -0
  19. package/conversation/ConversationTemplatePickerDialog.js +151 -0
  20. package/conversation/ConversationTemplatePickerDialog.js.map +1 -0
  21. package/conversation/ConversationsWorkbench.d.ts.map +1 -1
  22. package/conversation/ConversationsWorkbench.js +16 -5
  23. package/conversation/ConversationsWorkbench.js.map +1 -1
  24. package/conversation/index.d.ts +3 -1
  25. package/conversation/index.d.ts.map +1 -1
  26. package/conversation/index.js +1 -0
  27. package/conversation/index.js.map +1 -1
  28. package/conversation/useConversationParticipation.d.ts +41 -4
  29. package/conversation/useConversationParticipation.d.ts.map +1 -1
  30. package/conversation/useConversationParticipation.js +13 -2
  31. package/conversation/useConversationParticipation.js.map +1 -1
  32. package/identity-account/AccountPreferencesPanel.d.ts +35 -0
  33. package/identity-account/AccountPreferencesPanel.d.ts.map +1 -0
  34. package/identity-account/AccountPreferencesPanel.js +187 -0
  35. package/identity-account/AccountPreferencesPanel.js.map +1 -0
  36. package/identity-account/index.d.ts +8 -0
  37. package/identity-account/index.d.ts.map +1 -1
  38. package/identity-account/index.js +4 -0
  39. package/identity-account/index.js.map +1 -1
  40. package/identity-account/useAccountExecutionDefaults.d.ts +36 -0
  41. package/identity-account/useAccountExecutionDefaults.d.ts.map +1 -0
  42. package/identity-account/useAccountExecutionDefaults.js +46 -0
  43. package/identity-account/useAccountExecutionDefaults.js.map +1 -0
  44. package/identity-account/useMyIdentityAccount.d.ts +55 -0
  45. package/identity-account/useMyIdentityAccount.d.ts.map +1 -0
  46. package/identity-account/useMyIdentityAccount.js +37 -0
  47. package/identity-account/useMyIdentityAccount.js.map +1 -0
  48. package/identity-account/useUpdateIdentityAccount.d.ts +42 -0
  49. package/identity-account/useUpdateIdentityAccount.d.ts.map +1 -0
  50. package/identity-account/useUpdateIdentityAccount.js +53 -0
  51. package/identity-account/useUpdateIdentityAccount.js.map +1 -0
  52. package/index.d.ts +11 -9
  53. package/index.d.ts.map +1 -1
  54. package/index.js +8 -5
  55. package/index.js.map +1 -1
  56. package/internal/StandingContextField.d.ts +37 -0
  57. package/internal/StandingContextField.d.ts.map +1 -0
  58. package/internal/StandingContextField.js +24 -0
  59. package/internal/StandingContextField.js.map +1 -0
  60. package/library/InstanceVisibilitySelector.d.ts.map +1 -1
  61. package/library/InstanceVisibilitySelector.js +9 -2
  62. package/library/InstanceVisibilitySelector.js.map +1 -1
  63. package/library/ResourceVisibilityControl.d.ts +13 -3
  64. package/library/ResourceVisibilityControl.d.ts.map +1 -1
  65. package/library/ResourceVisibilityControl.js +33 -5
  66. package/library/ResourceVisibilityControl.js.map +1 -1
  67. package/library/VisibilityOptionRow.d.ts +6 -0
  68. package/library/VisibilityOptionRow.d.ts.map +1 -1
  69. package/library/VisibilityOptionRow.js +11 -1
  70. package/library/VisibilityOptionRow.js.map +1 -1
  71. package/library/VisibilitySelector.d.ts.map +1 -1
  72. package/library/VisibilitySelector.js +14 -2
  73. package/library/VisibilitySelector.js.map +1 -1
  74. package/library/index.d.ts +4 -2
  75. package/library/index.d.ts.map +1 -1
  76. package/library/index.js +2 -1
  77. package/library/index.js.map +1 -1
  78. package/library/useCanSetPublicVisibility.d.ts +30 -0
  79. package/library/useCanSetPublicVisibility.d.ts.map +1 -0
  80. package/library/useCanSetPublicVisibility.js +41 -0
  81. package/library/useCanSetPublicVisibility.js.map +1 -0
  82. package/library/visibilityLevels.d.ts +32 -1
  83. package/library/visibilityLevels.d.ts.map +1 -1
  84. package/library/visibilityLevels.js +34 -14
  85. package/library/visibilityLevels.js.map +1 -1
  86. package/mcp-server/useMcpServerConnect.d.ts +8 -5
  87. package/mcp-server/useMcpServerConnect.d.ts.map +1 -1
  88. package/mcp-server/useMcpServerConnect.js +2 -1
  89. package/mcp-server/useMcpServerConnect.js.map +1 -1
  90. package/mcp-server/useMcpServerOAuthConnect.d.ts.map +1 -1
  91. package/mcp-server/useMcpServerOAuthConnect.js +7 -2
  92. package/mcp-server/useMcpServerOAuthConnect.js.map +1 -1
  93. package/oauth-app/CreateOAuthAppForm.d.ts +2 -2
  94. package/oauth-app/CreateOAuthAppForm.d.ts.map +1 -1
  95. package/oauth-app/CreateOAuthAppForm.js +13 -4
  96. package/oauth-app/CreateOAuthAppForm.js.map +1 -1
  97. package/oauth-app/OAuthAppDetailPanel.d.ts.map +1 -1
  98. package/oauth-app/OAuthAppDetailPanel.js +32 -5
  99. package/oauth-app/OAuthAppDetailPanel.js.map +1 -1
  100. package/organization/OrgPreferencesPanel.d.ts +32 -0
  101. package/organization/OrgPreferencesPanel.d.ts.map +1 -0
  102. package/organization/OrgPreferencesPanel.js +90 -0
  103. package/organization/OrgPreferencesPanel.js.map +1 -0
  104. package/organization/OrgProfilePanel.d.ts.map +1 -1
  105. package/organization/OrgProfilePanel.js +5 -4
  106. package/organization/OrgProfilePanel.js.map +1 -1
  107. package/organization/index.d.ts +2 -0
  108. package/organization/index.d.ts.map +1 -1
  109. package/organization/index.js +1 -0
  110. package/organization/index.js.map +1 -1
  111. package/package.json +4 -4
  112. package/session/NewSessionViewer.d.ts +10 -1
  113. package/session/NewSessionViewer.d.ts.map +1 -1
  114. package/session/NewSessionViewer.js +2 -1
  115. package/session/NewSessionViewer.js.map +1 -1
  116. package/session/useNewSessionFlow.d.ts +27 -1
  117. package/session/useNewSessionFlow.d.ts.map +1 -1
  118. package/session/useNewSessionFlow.js +45 -8
  119. package/session/useNewSessionFlow.js.map +1 -1
  120. package/settings/AccountPreferencesSection.d.ts +3 -0
  121. package/settings/AccountPreferencesSection.d.ts.map +1 -0
  122. package/settings/AccountPreferencesSection.js +10 -0
  123. package/settings/AccountPreferencesSection.js.map +1 -0
  124. package/settings/OrgPreferencesSection.d.ts +3 -0
  125. package/settings/OrgPreferencesSection.d.ts.map +1 -0
  126. package/settings/OrgPreferencesSection.js +13 -0
  127. package/settings/OrgPreferencesSection.js.map +1 -0
  128. package/settings/index.d.ts +2 -0
  129. package/settings/index.d.ts.map +1 -1
  130. package/settings/index.js +2 -0
  131. package/settings/index.js.map +1 -1
  132. package/settings/settings-nav.d.ts.map +1 -1
  133. package/settings/settings-nav.js +17 -1
  134. package/settings/settings-nav.js.map +1 -1
  135. package/src/attachment/AttachmentChipList.tsx +19 -8
  136. package/src/attachment/__tests__/AttachmentChipList.test.tsx +12 -0
  137. package/src/attachment/__tests__/useAttachments.test.ts +293 -0
  138. package/src/attachment/index.ts +1 -0
  139. package/src/attachment/useAttachments.ts +143 -11
  140. package/src/composer/SessionComposer.tsx +23 -14
  141. package/src/conversation/ConversationComposer.tsx +38 -4
  142. package/src/conversation/ConversationTemplatePickerDialog.tsx +441 -0
  143. package/src/conversation/ConversationsWorkbench.tsx +40 -4
  144. package/src/conversation/__tests__/ConversationComposer.test.tsx +56 -1
  145. package/src/conversation/__tests__/ConversationTemplatePickerDialog.test.tsx +224 -0
  146. package/src/conversation/__tests__/useConversationParticipation.test.tsx +45 -4
  147. package/src/conversation/index.ts +4 -0
  148. package/src/conversation/useConversationParticipation.ts +58 -6
  149. package/src/identity-account/AccountPreferencesPanel.tsx +446 -0
  150. package/src/identity-account/__tests__/AccountPreferencesPanel.test.tsx +233 -0
  151. package/src/identity-account/__tests__/useAccountExecutionDefaults.test.tsx +116 -0
  152. package/src/identity-account/__tests__/useMyIdentityAccount.test.tsx +121 -0
  153. package/src/identity-account/index.ts +11 -0
  154. package/src/identity-account/useAccountExecutionDefaults.ts +63 -0
  155. package/src/identity-account/useMyIdentityAccount.ts +75 -0
  156. package/src/identity-account/useUpdateIdentityAccount.ts +73 -0
  157. package/src/index.ts +24 -2
  158. package/src/internal/StandingContextField.tsx +92 -0
  159. package/src/library/InstanceVisibilitySelector.tsx +9 -2
  160. package/src/library/ResourceVisibilityControl.tsx +46 -13
  161. package/src/library/VisibilityOptionRow.tsx +28 -6
  162. package/src/library/VisibilitySelector.tsx +14 -2
  163. package/src/library/__tests__/VisibilitySelector.test.tsx +48 -1
  164. package/src/library/__tests__/visibilityLevels.test.ts +67 -1
  165. package/src/library/index.ts +6 -1
  166. package/src/library/useCanSetPublicVisibility.ts +56 -0
  167. package/src/library/visibilityLevels.ts +65 -14
  168. package/src/mcp-server/__tests__/useMcpServerConnect.asyncLane.test.tsx +117 -0
  169. package/src/mcp-server/useMcpServerConnect.ts +9 -6
  170. package/src/mcp-server/useMcpServerOAuthConnect.ts +7 -2
  171. package/src/oauth-app/CreateOAuthAppForm.tsx +58 -3
  172. package/src/oauth-app/OAuthAppDetailPanel.tsx +84 -3
  173. package/src/organization/OrgPreferencesPanel.tsx +221 -0
  174. package/src/organization/OrgProfilePanel.tsx +5 -4
  175. package/src/organization/__tests__/OrgPreferencesPanel.test.tsx +145 -0
  176. package/src/organization/__tests__/OrgProfilePanel.test.tsx +108 -0
  177. package/src/organization/index.ts +2 -0
  178. package/src/session/NewSessionViewer.tsx +12 -0
  179. package/src/session/__tests__/useNewSessionFlow.test.tsx +151 -0
  180. package/src/session/useNewSessionFlow.ts +76 -9
  181. package/src/settings/AccountPreferencesSection.tsx +25 -0
  182. package/src/settings/OrgPreferencesSection.tsx +35 -0
  183. package/src/settings/__tests__/settings-nav.test.ts +27 -0
  184. package/src/settings/index.ts +2 -0
  185. package/src/settings/settings-nav.ts +18 -0
  186. package/src/workflow/ApprovalFormBuilder.tsx +5 -27
  187. package/styles.css +1 -1
  188. package/workflow/ApprovalFormBuilder.d.ts.map +1 -1
  189. package/workflow/ApprovalFormBuilder.js +8 -5
  190. package/workflow/ApprovalFormBuilder.js.map +1 -1
@@ -0,0 +1,446 @@
1
+ "use client";
2
+
3
+ import { type FormEvent, useCallback, useEffect, useId, useMemo, useState } from "react";
4
+ import { cn } from "@stigmer/theme";
5
+ import { getUserMessage, toIdentityAccountUpdateInput } from "@stigmer/sdk";
6
+ import type { IdentityAccount } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/api_pb";
7
+ import { useMyIdentityAccount } from "./useMyIdentityAccount.js";
8
+ import { useUpdateIdentityAccount } from "./useUpdateIdentityAccount.js";
9
+ import { useResourceAvailable, ApiResourceKind } from "../deployment-mode.js";
10
+ import { useModelRegistry } from "../models/index.js";
11
+ import { HARNESS_META, type HarnessOption } from "../models/harness.js";
12
+ import { CloudFeatureNotice } from "../internal/CloudFeatureNotice.js";
13
+ import { StandingContextField } from "../internal/StandingContextField.js";
14
+ import { SpinnerIcon } from "../internal/SpinnerIcon.js";
15
+
16
+ /** Props for {@link AccountPreferencesPanel}. */
17
+ export interface AccountPreferencesPanelProps {
18
+ /** Fired with the updated resource after a successful save. */
19
+ readonly onUpdated?: (account: IdentityAccount) => void;
20
+ /** Additional CSS class names for the root container. */
21
+ readonly className?: string;
22
+ }
23
+
24
+ /**
25
+ * Self-contained editor for the current user's declared preferences
26
+ * (`IdentityAccountSpec.preferences.standing_context`).
27
+ *
28
+ * The declared text is snapshotted into the user's own eligible agent
29
+ * executions and delivered to the agent as background context.
30
+ * Self-service: the account is resolved via `whoAmI()` and updated
31
+ * through the caller's self-ownership permission — no explicit
32
+ * permission check is needed. On save, calls `identityAccount.update()`
33
+ * with the complete mapped input (full-spec-replace safety) and fires
34
+ * `onUpdated`.
35
+ *
36
+ * Cloud-only: the OSS local server has no IdentityAccount (local mode
37
+ * is single-user, so the organization's preferences cover it). In local
38
+ * mode the panel renders a {@link CloudFeatureNotice} instead.
39
+ *
40
+ * All visual properties flow through `--stgm-*` design tokens. Zero
41
+ * dependencies on Console routing, auth context, or layout — platform
42
+ * builders can embed it directly:
43
+ *
44
+ * @example
45
+ * ```tsx
46
+ * <AccountPreferencesPanel />
47
+ * ```
48
+ */
49
+ export function AccountPreferencesPanel({
50
+ onUpdated,
51
+ className,
52
+ }: AccountPreferencesPanelProps) {
53
+ const baseId = useId();
54
+ const available = useResourceAvailable(ApiResourceKind.identity_account);
55
+
56
+ if (!available) {
57
+ return (
58
+ <CloudFeatureNotice className={className}>
59
+ Personal preferences require Stigmer Cloud. Local mode is
60
+ single-user, so the organization&apos;s preferences apply to every
61
+ execution — set standing context there instead.
62
+ </CloudFeatureNotice>
63
+ );
64
+ }
65
+
66
+ return (
67
+ <AccountPreferencesForm
68
+ baseId={baseId}
69
+ onUpdated={onUpdated}
70
+ className={className}
71
+ />
72
+ );
73
+ }
74
+
75
+ /**
76
+ * Inner form, mounted only when IdentityAccount is available — keeps the
77
+ * data hooks from issuing doomed RPCs against a local server.
78
+ */
79
+ function AccountPreferencesForm({
80
+ baseId,
81
+ onUpdated,
82
+ className,
83
+ }: {
84
+ readonly baseId: string;
85
+ readonly onUpdated?: (account: IdentityAccount) => void;
86
+ readonly className?: string;
87
+ }) {
88
+ const {
89
+ account,
90
+ isLoading: isFetching,
91
+ error: fetchError,
92
+ refetch,
93
+ } = useMyIdentityAccount();
94
+
95
+ const {
96
+ update,
97
+ isUpdating,
98
+ error: updateError,
99
+ clearError,
100
+ } = useUpdateIdentityAccount();
101
+
102
+ const [standingContext, setStandingContext] = useState("");
103
+ const [defaultHarness, setDefaultHarness] = useState("");
104
+ const [defaultNativeModel, setDefaultNativeModel] = useState("");
105
+ const [defaultCursorModel, setDefaultCursorModel] = useState("");
106
+
107
+ const serverPreferences = account?.spec?.preferences;
108
+ const serverStandingContext = serverPreferences?.standingContext ?? "";
109
+ const serverDefaultHarness = serverPreferences?.defaultHarness ?? "";
110
+ const serverDefaultNativeModel = serverPreferences?.defaultNativeModel ?? "";
111
+ const serverDefaultCursorModel = serverPreferences?.defaultCursorModel ?? "";
112
+
113
+ // Sync the form fields when server data changes.
114
+ useEffect(() => {
115
+ if (!account) return;
116
+ const prefs = account.spec?.preferences;
117
+ setStandingContext(prefs?.standingContext ?? "");
118
+ setDefaultHarness(prefs?.defaultHarness ?? "");
119
+ setDefaultNativeModel(prefs?.defaultNativeModel ?? "");
120
+ setDefaultCursorModel(prefs?.defaultCursorModel ?? "");
121
+ }, [account]);
122
+
123
+ const hasChanges = useMemo(
124
+ () =>
125
+ standingContext.trim() !== serverStandingContext ||
126
+ defaultHarness !== serverDefaultHarness ||
127
+ defaultNativeModel !== serverDefaultNativeModel ||
128
+ defaultCursorModel !== serverDefaultCursorModel,
129
+ [
130
+ standingContext,
131
+ defaultHarness,
132
+ defaultNativeModel,
133
+ defaultCursorModel,
134
+ serverStandingContext,
135
+ serverDefaultHarness,
136
+ serverDefaultNativeModel,
137
+ serverDefaultCursorModel,
138
+ ],
139
+ );
140
+
141
+ const canSubmit = hasChanges && !isUpdating;
142
+
143
+ const handleDiscard = useCallback(() => {
144
+ setStandingContext(serverStandingContext);
145
+ setDefaultHarness(serverDefaultHarness);
146
+ setDefaultNativeModel(serverDefaultNativeModel);
147
+ setDefaultCursorModel(serverDefaultCursorModel);
148
+ clearError();
149
+ }, [
150
+ serverStandingContext,
151
+ serverDefaultHarness,
152
+ serverDefaultNativeModel,
153
+ serverDefaultCursorModel,
154
+ clearError,
155
+ ]);
156
+
157
+ const handleSubmit = useCallback(
158
+ async (e: FormEvent) => {
159
+ e.preventDefault();
160
+ if (!canSubmit || !account) return;
161
+
162
+ clearError();
163
+ try {
164
+ // update() is a full-spec replace: spread the complete mapped input
165
+ // so unedited spec fields survive. `preferences` is a nested message,
166
+ // so the override spreads the mapper's COMPLETE preferences too —
167
+ // fields this form does not own (added by later phases) survive.
168
+ const mapped = toIdentityAccountUpdateInput(account);
169
+ const updated = await update({
170
+ ...mapped,
171
+ preferences: {
172
+ ...mapped.preferences,
173
+ standingContext: standingContext.trim() || undefined,
174
+ defaultHarness: defaultHarness || undefined,
175
+ defaultNativeModel: defaultNativeModel || undefined,
176
+ defaultCursorModel: defaultCursorModel || undefined,
177
+ },
178
+ });
179
+ refetch();
180
+ onUpdated?.(updated);
181
+ } catch {
182
+ // error state is managed by useUpdateIdentityAccount
183
+ }
184
+ },
185
+ [
186
+ canSubmit,
187
+ account,
188
+ standingContext,
189
+ defaultHarness,
190
+ defaultNativeModel,
191
+ defaultCursorModel,
192
+ update,
193
+ clearError,
194
+ refetch,
195
+ onUpdated,
196
+ ],
197
+ );
198
+
199
+ // -----------------------------------------------------------------------
200
+ // Loading state
201
+ // -----------------------------------------------------------------------
202
+
203
+ if (isFetching && !account) {
204
+ return (
205
+ <div
206
+ className={cn("stg:space-y-4", className)}
207
+ aria-busy="true"
208
+ aria-label="Loading account preferences"
209
+ >
210
+ <div className="stg:bg-muted-subtle stg:h-28 stg:animate-pulse stg:rounded" />
211
+ <div className="stg:bg-muted-subtle stg:h-8 stg:w-32 stg:animate-pulse stg:rounded" />
212
+ </div>
213
+ );
214
+ }
215
+
216
+ // -----------------------------------------------------------------------
217
+ // Fetch error
218
+ // -----------------------------------------------------------------------
219
+
220
+ if (fetchError) {
221
+ return (
222
+ <div className={cn("stg:space-y-3", className)} role="alert">
223
+ <p className="stg:text-destructive stg:text-sm">
224
+ {getUserMessage(fetchError)}
225
+ </p>
226
+ <button
227
+ type="button"
228
+ onClick={refetch}
229
+ className={cn(
230
+ "stg:rounded-md stg:px-3 stg:py-1.5 stg:text-xs stg:font-medium",
231
+ "stg:bg-primary stg:text-primary-foreground stg:hover:bg-primary-hover",
232
+ )}
233
+ >
234
+ Retry
235
+ </button>
236
+ </div>
237
+ );
238
+ }
239
+
240
+ if (!account) return null;
241
+
242
+ // -----------------------------------------------------------------------
243
+ // Render
244
+ // -----------------------------------------------------------------------
245
+
246
+ return (
247
+ <form onSubmit={handleSubmit} className={cn("stg:space-y-4", className)}>
248
+ <StandingContextField
249
+ id={`${baseId}-standing-context`}
250
+ value={standingContext}
251
+ onChange={setStandingContext}
252
+ disabled={isUpdating}
253
+ placeholder={
254
+ "e.g. Keep answers terse. I prefer TypeScript examples. My timezone is IST."
255
+ }
256
+ helperText="Shared with agents as background context — not instructions. Applies only to executions you start and is visible on those execution records."
257
+ />
258
+
259
+ <div className="stg:space-y-3">
260
+ <div>
261
+ <h3 className="stg:text-xs stg:font-medium stg:text-foreground">
262
+ Execution defaults
263
+ </h3>
264
+ <p className="stg:text-[0.65rem] stg:text-muted-foreground">
265
+ Seed new sessions across your devices and the CLI. An explicit
266
+ pick in the composer always wins for that device.
267
+ </p>
268
+ </div>
269
+
270
+ <DefaultHarnessRadioGroup
271
+ baseId={baseId}
272
+ value={defaultHarness}
273
+ onChange={setDefaultHarness}
274
+ disabled={isUpdating}
275
+ />
276
+
277
+ <div className="stg:grid stg:gap-3 stg:sm:grid-cols-2">
278
+ <DefaultModelSelect
279
+ id={`${baseId}-default-native-model`}
280
+ harness="native"
281
+ label={`Default model — ${HARNESS_META.native.label}`}
282
+ value={defaultNativeModel}
283
+ onChange={setDefaultNativeModel}
284
+ disabled={isUpdating}
285
+ />
286
+ <DefaultModelSelect
287
+ id={`${baseId}-default-cursor-model`}
288
+ harness="cursor"
289
+ label={`Default model — ${HARNESS_META.cursor.label}`}
290
+ value={defaultCursorModel}
291
+ onChange={setDefaultCursorModel}
292
+ disabled={isUpdating}
293
+ />
294
+ </div>
295
+ </div>
296
+
297
+ {updateError && (
298
+ <p className="stg:text-destructive stg:text-[0.65rem]" role="alert">
299
+ {getUserMessage(updateError)}
300
+ </p>
301
+ )}
302
+
303
+ <div className="stg:flex stg:items-center stg:gap-2">
304
+ <button
305
+ type="submit"
306
+ disabled={!canSubmit}
307
+ className={cn(
308
+ "stg:inline-flex stg:items-center stg:gap-1.5 stg:rounded-md stg:px-3 stg:py-1.5 stg:text-xs stg:font-medium",
309
+ "stg:bg-primary stg:text-primary-foreground stg:hover:bg-primary-hover",
310
+ "stg:disabled:pointer-events-none stg:disabled:opacity-40",
311
+ )}
312
+ >
313
+ {isUpdating && <SpinnerIcon size={12} />}
314
+ Save changes
315
+ </button>
316
+
317
+ {hasChanges && !isUpdating && (
318
+ <button
319
+ type="button"
320
+ onClick={handleDiscard}
321
+ className={cn(
322
+ "stg:rounded-md stg:px-3 stg:py-1.5 stg:text-xs",
323
+ "stg:text-muted-foreground stg:hover:text-foreground stg:hover:bg-accent-hover",
324
+ )}
325
+ >
326
+ Discard
327
+ </button>
328
+ )}
329
+ </div>
330
+ </form>
331
+ );
332
+ }
333
+
334
+ /**
335
+ * Only harnesses with a shipped runtime are offerable as a default — must
336
+ * stay in lockstep with the proto's `default_harness` in-list validation.
337
+ */
338
+ const OFFERABLE_HARNESSES: readonly HarnessOption[] = ["native", "cursor"];
339
+
340
+ /**
341
+ * Default-harness choice: Platform default plus each shipped harness.
342
+ * A mutually exclusive, always-visible set — radio group, not a select.
343
+ */
344
+ function DefaultHarnessRadioGroup({
345
+ baseId,
346
+ value,
347
+ onChange,
348
+ disabled,
349
+ }: {
350
+ readonly baseId: string;
351
+ readonly value: string;
352
+ readonly onChange: (value: string) => void;
353
+ readonly disabled: boolean;
354
+ }) {
355
+ const name = `${baseId}-default-harness`;
356
+ const options: readonly { value: string; label: string }[] = [
357
+ { value: "", label: "Platform default" },
358
+ ...OFFERABLE_HARNESSES.map((h) => ({ value: h, label: HARNESS_META[h].label })),
359
+ ];
360
+
361
+ return (
362
+ <fieldset className="stg:space-y-1" disabled={disabled}>
363
+ <legend className="stg:text-xs stg:font-medium stg:text-foreground">
364
+ Default harness
365
+ </legend>
366
+ <div className="stg:flex stg:flex-wrap stg:gap-x-4 stg:gap-y-1">
367
+ {options.map((option) => (
368
+ <label
369
+ key={option.value}
370
+ className={cn(
371
+ "stg:inline-flex stg:cursor-pointer stg:items-center stg:gap-1.5 stg:text-xs stg:text-foreground",
372
+ disabled && "stg:cursor-default stg:opacity-50",
373
+ )}
374
+ >
375
+ <input
376
+ type="radio"
377
+ name={name}
378
+ value={option.value}
379
+ checked={value === option.value}
380
+ onChange={() => onChange(option.value)}
381
+ disabled={disabled}
382
+ className="stg:accent-primary stg:focus-visible:outline-none stg:focus-visible:ring-1 stg:focus-visible:ring-ring"
383
+ />
384
+ {option.label}
385
+ </label>
386
+ ))}
387
+ </div>
388
+ </fieldset>
389
+ );
390
+ }
391
+
392
+ /**
393
+ * Per-harness default model select, fed by the harness-filtered registry.
394
+ *
395
+ * A saved model that the registry no longer lists is rendered as its own
396
+ * "(unavailable)" option so the select never lies about the stored value;
397
+ * composers self-heal such a preference to the platform default on read.
398
+ */
399
+ function DefaultModelSelect({
400
+ id,
401
+ harness,
402
+ label,
403
+ value,
404
+ onChange,
405
+ disabled,
406
+ }: {
407
+ readonly id: string;
408
+ readonly harness: HarnessOption;
409
+ readonly label: string;
410
+ readonly value: string;
411
+ readonly onChange: (value: string) => void;
412
+ readonly disabled: boolean;
413
+ }) {
414
+ const { models, isLoading } = useModelRegistry({ harness });
415
+ const isStale = value !== "" && !isLoading && !models.some((m) => m.modelId === value);
416
+
417
+ return (
418
+ <div className="stg:space-y-1">
419
+ <label
420
+ htmlFor={id}
421
+ className="stg:text-xs stg:font-medium stg:text-foreground"
422
+ >
423
+ {label}
424
+ </label>
425
+ <select
426
+ id={id}
427
+ value={value}
428
+ onChange={(e) => onChange(e.target.value)}
429
+ disabled={disabled || isLoading}
430
+ className={cn(
431
+ "stg:w-full stg:rounded-md stg:border stg:border-input stg:bg-background stg:px-2.5 stg:py-1.5 stg:text-xs stg:text-foreground",
432
+ "stg:focus-visible:outline-none stg:focus-visible:ring-1 stg:focus-visible:ring-ring",
433
+ "stg:disabled:pointer-events-none stg:disabled:opacity-50",
434
+ )}
435
+ >
436
+ <option value="">Platform default</option>
437
+ {isStale && <option value={value}>{value} (unavailable)</option>}
438
+ {models.map((model) => (
439
+ <option key={model.modelId} value={model.modelId}>
440
+ {model.displayName || model.modelId}
441
+ </option>
442
+ ))}
443
+ </select>
444
+ </div>
445
+ );
446
+ }
@@ -0,0 +1,233 @@
1
+ import { describe, it, expect, vi, afterEach } from "vitest";
2
+ import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
3
+ import { create } from "@bufbuild/protobuf";
4
+ import {
5
+ IdentityAccountSchema,
6
+ type IdentityAccount,
7
+ } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/api_pb";
8
+ import type { IdentityAccountInput } from "@stigmer/sdk";
9
+ import type { DeploymentMode } from "@stigmer/sdk";
10
+ import { StigmerContext } from "../../context";
11
+ import { DeploymentModeContext } from "../../deployment-mode";
12
+ import { ModelRegistryContext, type ModelRegistryState } from "../../models/ModelRegistryContext";
13
+ import { parseRegistryJson } from "../../models/registry";
14
+ import { AccountPreferencesPanel } from "../AccountPreferencesPanel";
15
+
16
+ const ACCOUNT: IdentityAccount = create(IdentityAccountSchema, {
17
+ metadata: { id: "ia-1", name: "Ada Lovelace", slug: "ada", org: "acme" },
18
+ spec: {
19
+ idpId: "auth0|abc",
20
+ email: "ada@acme.example",
21
+ preferences: { standingContext: "Keep answers terse." },
22
+ },
23
+ });
24
+
25
+ const TEST_MODELS = parseRegistryJson({
26
+ models: [
27
+ { id: "claude-sonnet-4.6", displayName: "Claude Sonnet 4.6", shortDescription: "", speedTier: "fast", provider: "anthropic", harness: "native", costTier: "standard", featured: true, pricing: { inputPricePerMillion: 3, outputPricePerMillion: 15, cacheWritePricePerMillion: 3.75, cacheReadPricePerMillion: 0.3 } },
28
+ { id: "default", displayName: "Cursor Auto", shortDescription: "", speedTier: "fast", provider: "cursor", harness: "cursor", costTier: "standard", featured: true, pricing: { inputPricePerMillion: 1.25, outputPricePerMillion: 6, cacheWritePricePerMillion: 1.25, cacheReadPricePerMillion: 0.25 } },
29
+ ],
30
+ });
31
+
32
+ const REGISTRY_STATE: ModelRegistryState = {
33
+ models: TEST_MODELS,
34
+ isLoading: false,
35
+ error: null,
36
+ refetch: () => {},
37
+ };
38
+
39
+ function createMockStigmer(overrides?: {
40
+ whoAmI?: ReturnType<typeof vi.fn>;
41
+ update?: ReturnType<typeof vi.fn>;
42
+ }) {
43
+ return {
44
+ identityAccount: {
45
+ whoAmI: overrides?.whoAmI ?? vi.fn(async () => ACCOUNT),
46
+ update: overrides?.update ?? vi.fn(async () => ACCOUNT),
47
+ },
48
+ } as never;
49
+ }
50
+
51
+ function renderPanel(client: unknown, mode: DeploymentMode = "cloud") {
52
+ return render(
53
+ <StigmerContext.Provider value={client as never}>
54
+ <DeploymentModeContext.Provider value={mode}>
55
+ <ModelRegistryContext.Provider value={REGISTRY_STATE}>
56
+ <AccountPreferencesPanel />
57
+ </ModelRegistryContext.Provider>
58
+ </DeploymentModeContext.Provider>
59
+ </StigmerContext.Provider>,
60
+ );
61
+ }
62
+
63
+ afterEach(cleanup);
64
+
65
+ /**
66
+ * Waits until the form has synced from server data (the panel copies the
67
+ * fetched value into local state in a passive effect, one flush after the
68
+ * field first renders) — interacting earlier races the sync.
69
+ */
70
+ async function findSyncedField(value: string) {
71
+ const field = await screen.findByLabelText("Standing context");
72
+ await waitFor(() => expect(field).toHaveProperty("value", value));
73
+ return field;
74
+ }
75
+
76
+ describe("AccountPreferencesPanel", () => {
77
+ it("renders the cloud notice in local mode without issuing any RPCs", () => {
78
+ const whoAmI = vi.fn(async () => ACCOUNT);
79
+ renderPanel(createMockStigmer({ whoAmI }), "local");
80
+
81
+ expect(screen.getByRole("status")).toBeTruthy();
82
+ expect(screen.queryByLabelText("Standing context")).toBeNull();
83
+ // The inner form must not mount — no doomed whoAmI against a local server.
84
+ expect(whoAmI).not.toHaveBeenCalled();
85
+ });
86
+
87
+ it("loads and displays the caller's declared standing context", async () => {
88
+ renderPanel(createMockStigmer());
89
+
90
+ await findSyncedField("Keep answers terse.");
91
+ });
92
+
93
+ it("saves the full mapped input — identity fields survive (wipe-bug guard)", async () => {
94
+ const update = vi.fn(async (_input: IdentityAccountInput) => ACCOUNT);
95
+ renderPanel(createMockStigmer({ update }));
96
+
97
+ const field = await findSyncedField("Keep answers terse.");
98
+ fireEvent.change(field, { target: { value: "Prefer bullet points." } });
99
+ fireEvent.click(screen.getByRole("button", { name: "Save changes" }));
100
+
101
+ await waitFor(() => expect(update).toHaveBeenCalledTimes(1));
102
+ const input = update.mock.calls[0]![0];
103
+
104
+ expect(input.preferences).toEqual({
105
+ standingContext: "Prefer bullet points.",
106
+ });
107
+ // Fields this form never renders must round-trip untouched.
108
+ expect(input.idpId).toBe("auth0|abc");
109
+ expect(input.email).toBe("ada@acme.example");
110
+ // The update pipeline addresses the resource by org + slug.
111
+ expect(input.org).toBe("acme");
112
+ expect(input.slug).toBe("ada");
113
+ });
114
+
115
+ it("refetches the account after a successful save", async () => {
116
+ const whoAmI = vi.fn(async () => ACCOUNT);
117
+ renderPanel(createMockStigmer({ whoAmI }));
118
+
119
+ const field = await findSyncedField("Keep answers terse.");
120
+ fireEvent.change(field, { target: { value: "New context" } });
121
+ fireEvent.click(screen.getByRole("button", { name: "Save changes" }));
122
+
123
+ // Initial load + post-save refetch.
124
+ await waitFor(() => expect(whoAmI).toHaveBeenCalledTimes(2));
125
+ });
126
+
127
+ it("shows the fetch error with a retry action when whoAmI fails", async () => {
128
+ const whoAmI = vi.fn(async () => {
129
+ throw new Error("boom");
130
+ });
131
+ renderPanel(createMockStigmer({ whoAmI }));
132
+
133
+ expect(await screen.findByRole("alert")).toBeTruthy();
134
+ expect(screen.getByRole("button", { name: "Retry" })).toBeTruthy();
135
+ });
136
+
137
+ describe("execution defaults (oss#293 Phase 1.5)", () => {
138
+ const ACCOUNT_WITH_DEFAULTS: IdentityAccount = create(IdentityAccountSchema, {
139
+ metadata: { id: "ia-1", name: "Ada Lovelace", slug: "ada", org: "acme" },
140
+ spec: {
141
+ idpId: "auth0|abc",
142
+ email: "ada@acme.example",
143
+ preferences: {
144
+ standingContext: "Keep answers terse.",
145
+ defaultHarness: "cursor",
146
+ defaultNativeModel: "claude-sonnet-4.6",
147
+ defaultCursorModel: "default",
148
+ },
149
+ },
150
+ });
151
+
152
+ it("renders the harness radio group and registry-fed model selects", async () => {
153
+ renderPanel(createMockStigmer());
154
+ await findSyncedField("Keep answers terse.");
155
+
156
+ expect(screen.getByRole("radio", { name: "Platform default" })).toBeTruthy();
157
+ expect(screen.getByRole("radio", { name: "Stigmer" })).toBeTruthy();
158
+ expect(screen.getByRole("radio", { name: "Cursor" })).toBeTruthy();
159
+ expect(screen.getByLabelText("Default model — Stigmer")).toBeTruthy();
160
+ expect(screen.getByLabelText("Default model — Cursor")).toBeTruthy();
161
+ });
162
+
163
+ it("reflects the saved defaults in the form", async () => {
164
+ renderPanel(createMockStigmer({ whoAmI: vi.fn(async () => ACCOUNT_WITH_DEFAULTS) }));
165
+ await findSyncedField("Keep answers terse.");
166
+
167
+ await waitFor(() => {
168
+ expect(screen.getByRole("radio", { name: "Cursor" })).toHaveProperty("checked", true);
169
+ expect(screen.getByLabelText("Default model — Stigmer")).toHaveProperty("value", "claude-sonnet-4.6");
170
+ expect(screen.getByLabelText("Default model — Cursor")).toHaveProperty("value", "default");
171
+ });
172
+ });
173
+
174
+ it("saves the complete preferences object — all default fields in one write", async () => {
175
+ const update = vi.fn(async (_input: IdentityAccountInput) => ACCOUNT);
176
+ renderPanel(createMockStigmer({ update }));
177
+ await findSyncedField("Keep answers terse.");
178
+
179
+ fireEvent.click(screen.getByRole("radio", { name: "Cursor" }));
180
+ fireEvent.change(screen.getByLabelText("Default model — Stigmer"), {
181
+ target: { value: "claude-sonnet-4.6" },
182
+ });
183
+ fireEvent.click(screen.getByRole("button", { name: "Save changes" }));
184
+
185
+ await waitFor(() => expect(update).toHaveBeenCalledTimes(1));
186
+ expect(update.mock.calls[0]![0].preferences).toEqual({
187
+ standingContext: "Keep answers terse.",
188
+ defaultHarness: "cursor",
189
+ defaultNativeModel: "claude-sonnet-4.6",
190
+ defaultCursorModel: undefined,
191
+ });
192
+ });
193
+
194
+ it("editing only standing context preserves the structured defaults (nested wipe-bug guard)", async () => {
195
+ const update = vi.fn(async (_input: IdentityAccountInput) => ACCOUNT_WITH_DEFAULTS);
196
+ renderPanel(
197
+ createMockStigmer({ whoAmI: vi.fn(async () => ACCOUNT_WITH_DEFAULTS), update }),
198
+ );
199
+
200
+ const field = await findSyncedField("Keep answers terse.");
201
+ fireEvent.change(field, { target: { value: "Terser still." } });
202
+ fireEvent.click(screen.getByRole("button", { name: "Save changes" }));
203
+
204
+ await waitFor(() => expect(update).toHaveBeenCalledTimes(1));
205
+ expect(update.mock.calls[0]![0].preferences).toEqual({
206
+ standingContext: "Terser still.",
207
+ defaultHarness: "cursor",
208
+ defaultNativeModel: "claude-sonnet-4.6",
209
+ defaultCursorModel: "default",
210
+ });
211
+ });
212
+
213
+ it("keeps a stale saved model legible as an unavailable option", async () => {
214
+ const stale = create(IdentityAccountSchema, {
215
+ metadata: { id: "ia-1", name: "Ada", slug: "ada", org: "acme" },
216
+ spec: {
217
+ idpId: "auth0|abc",
218
+ preferences: { defaultNativeModel: "retired-model" },
219
+ },
220
+ });
221
+ renderPanel(createMockStigmer({ whoAmI: vi.fn(async () => stale) }));
222
+ await findSyncedField("");
223
+
224
+ await waitFor(() => {
225
+ const select = screen.getByLabelText("Default model — Stigmer") as HTMLSelectElement;
226
+ expect(select.value).toBe("retired-model");
227
+ expect(
228
+ Array.from(select.options).some((o) => o.text === "retired-model (unavailable)"),
229
+ ).toBe(true);
230
+ });
231
+ });
232
+ });
233
+ });