@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,145 @@
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
+ OrganizationSchema,
6
+ type Organization,
7
+ } from "@stigmer/protos/ai/stigmer/tenancy/organization/v1/api_pb";
8
+ import type { OrganizationInput } from "@stigmer/sdk";
9
+ import { StigmerContext } from "../../context";
10
+ import { DeploymentModeContext } from "../../deployment-mode";
11
+ import { OrgPreferencesPanel } from "../OrgPreferencesPanel";
12
+
13
+ const ORG: Organization = create(OrganizationSchema, {
14
+ metadata: { id: "acme", name: "Acme Corp", slug: "acme", org: "acme" },
15
+ spec: {
16
+ description: "We make everything.",
17
+ logoUrl: "https://acme.example/logo.png",
18
+ preferences: { standingContext: "We deploy to us-east-1." },
19
+ },
20
+ });
21
+
22
+ function createMockStigmer(overrides?: {
23
+ update?: ReturnType<typeof vi.fn>;
24
+ canEdit?: boolean;
25
+ }) {
26
+ return {
27
+ organization: {
28
+ get: vi.fn(async () => ORG),
29
+ update: overrides?.update ?? vi.fn(async () => ORG),
30
+ },
31
+ iamPolicy: {
32
+ checkMyPermission: vi.fn(async () => ({
33
+ isAuthorized: overrides?.canEdit ?? true,
34
+ })),
35
+ },
36
+ } as never;
37
+ }
38
+
39
+ function renderPanel(client: unknown) {
40
+ return render(
41
+ <StigmerContext.Provider value={client as never}>
42
+ <DeploymentModeContext.Provider value="local">
43
+ <OrgPreferencesPanel orgId="acme" />
44
+ </DeploymentModeContext.Provider>
45
+ </StigmerContext.Provider>,
46
+ );
47
+ }
48
+
49
+ afterEach(cleanup);
50
+
51
+ /**
52
+ * Waits until the form has synced from server data (the panel copies the
53
+ * fetched value into local state in a passive effect, one flush after the
54
+ * field first renders) — interacting earlier races the sync.
55
+ */
56
+ async function findSyncedField(value: string) {
57
+ const field = await screen.findByLabelText("Standing context");
58
+ await waitFor(() => expect(field).toHaveProperty("value", value));
59
+ return field;
60
+ }
61
+
62
+ describe("OrgPreferencesPanel", () => {
63
+ it("loads and displays the declared standing context", async () => {
64
+ renderPanel(createMockStigmer());
65
+
66
+ await findSyncedField("We deploy to us-east-1.");
67
+ });
68
+
69
+ it("saves the full mapped input — unedited profile fields survive (wipe-bug guard)", async () => {
70
+ const update = vi.fn(async (_input: OrganizationInput) => ORG);
71
+ renderPanel(createMockStigmer({ update }));
72
+
73
+ const field = await findSyncedField("We deploy to us-east-1.");
74
+ fireEvent.change(field, { target: { value: "Prefer terse answers." } });
75
+ fireEvent.click(screen.getByRole("button", { name: "Save changes" }));
76
+
77
+ await waitFor(() => expect(update).toHaveBeenCalledTimes(1));
78
+ const input = update.mock.calls[0]![0];
79
+
80
+ expect(input.preferences).toEqual({
81
+ standingContext: "Prefer terse answers.",
82
+ });
83
+ // Fields this form never renders must round-trip untouched.
84
+ expect(input.name).toBe("Acme Corp");
85
+ expect(input.description).toBe("We make everything.");
86
+ expect(input.logoUrl).toBe("https://acme.example/logo.png");
87
+ expect(input.org).toBe("acme");
88
+ expect(input.slug).toBe("acme");
89
+ });
90
+
91
+ it("clears the standing context when the text is emptied", async () => {
92
+ const update = vi.fn(async (_input: OrganizationInput) => ORG);
93
+ renderPanel(createMockStigmer({ update }));
94
+
95
+ const field = await findSyncedField("We deploy to us-east-1.");
96
+ fireEvent.change(field, { target: { value: "" } });
97
+ fireEvent.click(screen.getByRole("button", { name: "Save changes" }));
98
+
99
+ await waitFor(() => expect(update).toHaveBeenCalledTimes(1));
100
+ expect(update.mock.calls[0]![0].preferences).toEqual({
101
+ standingContext: undefined,
102
+ });
103
+ });
104
+
105
+ it("discard resets the field to the server value", async () => {
106
+ renderPanel(createMockStigmer());
107
+
108
+ const field = await findSyncedField("We deploy to us-east-1.");
109
+ fireEvent.change(field, { target: { value: "Edited but regretted" } });
110
+ fireEvent.click(screen.getByRole("button", { name: "Discard" }));
111
+
112
+ expect(field).toHaveProperty("value", "We deploy to us-east-1.");
113
+ });
114
+
115
+ it("renders read-only with an explanation when the caller lacks can_edit", async () => {
116
+ renderPanel(createMockStigmer({ canEdit: false }));
117
+
118
+ const field = await screen.findByLabelText("Standing context");
119
+ await waitFor(() =>
120
+ expect(field).toHaveProperty("readOnly", true),
121
+ );
122
+ expect(
123
+ screen.getByText("Only organization admins can edit these preferences."),
124
+ ).toBeTruthy();
125
+ expect(screen.queryByRole("button", { name: "Save changes" })).toBeNull();
126
+ });
127
+
128
+ it("shows the fetch error with a retry action when loading fails", async () => {
129
+ const client = {
130
+ organization: {
131
+ get: vi.fn(async () => {
132
+ throw new Error("boom");
133
+ }),
134
+ update: vi.fn(),
135
+ },
136
+ iamPolicy: {
137
+ checkMyPermission: vi.fn(async () => ({ isAuthorized: true })),
138
+ },
139
+ } as never;
140
+ renderPanel(client);
141
+
142
+ expect(await screen.findByRole("alert")).toBeTruthy();
143
+ expect(screen.getByRole("button", { name: "Retry" })).toBeTruthy();
144
+ });
145
+ });
@@ -0,0 +1,108 @@
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
+ OrganizationSchema,
6
+ type Organization,
7
+ } from "@stigmer/protos/ai/stigmer/tenancy/organization/v1/api_pb";
8
+ import type { OrganizationInput } from "@stigmer/sdk";
9
+ import { StigmerContext } from "../../context";
10
+ import { DeploymentModeContext } from "../../deployment-mode";
11
+ import { OrgProfilePanel } from "../OrgProfilePanel";
12
+
13
+ /**
14
+ * Regression suite for the full-spec-replace wipe bug (oss#293 Phase 1):
15
+ * `organization.update()` wholesale replaces the stored spec, so a profile
16
+ * save that sends only the edited fields silently wipes every other mutable
17
+ * spec field — most visibly `spec.preferences.standing_context` set via the
18
+ * CLI or the preferences page. The panel must spread the complete mapped
19
+ * input and override only what it edits.
20
+ */
21
+
22
+ const ORG: Organization = create(OrganizationSchema, {
23
+ metadata: {
24
+ id: "acme",
25
+ name: "Acme Corp",
26
+ slug: "acme",
27
+ org: "acme",
28
+ },
29
+ spec: {
30
+ description: "We make everything.",
31
+ logoUrl: "https://acme.example/logo.png",
32
+ preferences: { standingContext: "We deploy to us-east-1." },
33
+ },
34
+ });
35
+
36
+ function createMockStigmer(overrides?: {
37
+ update?: ReturnType<typeof vi.fn>;
38
+ }) {
39
+ return {
40
+ organization: {
41
+ get: vi.fn(async () => ORG),
42
+ update: overrides?.update ?? vi.fn(async () => ORG),
43
+ },
44
+ } as never;
45
+ }
46
+
47
+ // Deployment mode "local" keeps the IdentityProvidersSummary sub-panel
48
+ // inert (identity_provider is cloud-only), so no extra client stubs are
49
+ // needed — the suite tests the form, not the summary.
50
+ function renderPanel(client: unknown) {
51
+ return render(
52
+ <StigmerContext.Provider value={client as never}>
53
+ <DeploymentModeContext.Provider value="local">
54
+ <OrgProfilePanel orgId="acme" />
55
+ </DeploymentModeContext.Provider>
56
+ </StigmerContext.Provider>,
57
+ );
58
+ }
59
+
60
+ afterEach(cleanup);
61
+
62
+ describe("OrgProfilePanel save payload", () => {
63
+ it("round-trips unedited spec fields — preferences survive a profile save", async () => {
64
+ const update = vi.fn(async (_input: OrganizationInput) => ORG);
65
+ const client = createMockStigmer({ update });
66
+ renderPanel(client);
67
+
68
+ // Wait for the server-sync effect to fill the form before editing —
69
+ // interacting on first appearance races the sync.
70
+ const description = await screen.findByLabelText("Description");
71
+ await waitFor(() =>
72
+ expect(description).toHaveProperty("value", "We make everything."),
73
+ );
74
+ fireEvent.change(description, { target: { value: "New description" } });
75
+ fireEvent.click(screen.getByRole("button", { name: "Save changes" }));
76
+
77
+ await waitFor(() => expect(update).toHaveBeenCalledTimes(1));
78
+ const input = update.mock.calls[0]![0];
79
+
80
+ // The edited field.
81
+ expect(input.description).toBe("New description");
82
+ // The unedited fields the form does not render — the wipe-bug guard.
83
+ expect(input.preferences).toEqual({
84
+ standingContext: "We deploy to us-east-1.",
85
+ });
86
+ expect(input.logoUrl).toBe("https://acme.example/logo.png");
87
+ // Addressing fields for the update pipeline's org+slug lookup.
88
+ expect(input.org).toBe("acme");
89
+ expect(input.slug).toBe("acme");
90
+ expect(input.name).toBe("Acme Corp");
91
+ });
92
+
93
+ it("sends the edited name and keeps discard/dirty semantics intact", async () => {
94
+ const update = vi.fn(async (_input: OrganizationInput) => ORG);
95
+ renderPanel(createMockStigmer({ update }));
96
+
97
+ const name = await screen.findByLabelText("Name");
98
+ await waitFor(() => expect(name).toHaveProperty("value", "Acme Corp"));
99
+ fireEvent.change(name, { target: { value: "Acme Corporation" } });
100
+ fireEvent.click(screen.getByRole("button", { name: "Save changes" }));
101
+
102
+ await waitFor(() => expect(update).toHaveBeenCalledTimes(1));
103
+ expect(update.mock.calls[0]![0].name).toBe("Acme Corporation");
104
+ expect(update.mock.calls[0]![0].preferences).toEqual({
105
+ standingContext: "We deploy to us-east-1.",
106
+ });
107
+ });
108
+ });
@@ -16,5 +16,7 @@ export { CreateOrganizationForm } from "./CreateOrganizationForm.js";
16
16
  export type { CreateOrganizationFormProps } from "./CreateOrganizationForm.js";
17
17
  export { OrgProfilePanel } from "./OrgProfilePanel.js";
18
18
  export type { OrgProfilePanelProps } from "./OrgProfilePanel.js";
19
+ export { OrgPreferencesPanel } from "./OrgPreferencesPanel.js";
20
+ export type { OrgPreferencesPanelProps } from "./OrgPreferencesPanel.js";
19
21
  export { OrgSwitcher } from "./OrgSwitcher.js";
20
22
  export type { OrgSwitcherProps } from "./OrgSwitcher.js";
@@ -9,6 +9,7 @@ import type { WorkspaceContentSearcher } from "../workspace/WorkspaceContentSear
9
9
  import type { InteractionModeOption } from "../composer/index.js";
10
10
  import { SessionComposer } from "../composer/index.js";
11
11
  import type { HarnessOption } from "../models/harness.js";
12
+ import type { AccountExecutionDefaults } from "../identity-account/useAccountExecutionDefaults.js";
12
13
  import { SessionViewerLayout } from "./SessionViewerLayout.js";
13
14
  import { useWorkspaceEditors } from "../internal/store/index.js";
14
15
  import { WorkspaceSurface } from "../workspace/WorkspaceSurface.js";
@@ -163,6 +164,15 @@ export interface NewSessionViewerProps {
163
164
  */
164
165
  readonly defaultHarness?: HarnessOption;
165
166
 
167
+ /**
168
+ * The user's account-level execution defaults, typically from
169
+ * `useAccountExecutionDefaults()`. Seeds the harness and model pickers
170
+ * under the layered precedence documented on
171
+ * {@link UseNewSessionFlowOptions.accountDefaults}; explicit device
172
+ * picks always win. Ignored for the `"guest"` audience.
173
+ */
174
+ readonly accountDefaults?: AccountExecutionDefaults;
175
+
166
176
  /** Agent to auto-select on mount (used for draft flows). */
167
177
  readonly initialAgentRef?: ResourceRef;
168
178
  /**
@@ -253,6 +263,7 @@ export function NewSessionViewer({
253
263
  panelOpen,
254
264
  onPanelOpenChange,
255
265
  defaultHarness,
266
+ accountDefaults,
256
267
  initialAgentRef,
257
268
  initialInstanceId,
258
269
  initialAttachments,
@@ -269,6 +280,7 @@ export function NewSessionViewer({
269
280
  onError,
270
281
  getRuntimeEnv,
271
282
  defaultHarness,
283
+ accountDefaults,
272
284
  audience,
273
285
  runConfig,
274
286
  });
@@ -805,6 +805,157 @@ describe("useNewSessionFlow", () => {
805
805
  });
806
806
  });
807
807
 
808
+ describe("accountDefaults (oss#293 Phase 1.5 layered seed)", () => {
809
+ // Two native models so "stored pick outranks account default" is
810
+ // distinguishable; the shared TEST_MODELS carries one per harness.
811
+ const SEED_MODELS = parseRegistryJson({
812
+ models: [
813
+ { 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 } },
814
+ { id: "gpt-5.3", displayName: "GPT 5.3", shortDescription: "", speedTier: "fast", provider: "openai", harness: "native", costTier: "standard", featured: false, pricing: { inputPricePerMillion: 2, outputPricePerMillion: 8, cacheWritePricePerMillion: 2, cacheReadPricePerMillion: 0.2 } },
815
+ { 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 } },
816
+ { id: "composer-2.5", displayName: "Composer 2.5", shortDescription: "", speedTier: "fast", provider: "cursor", harness: "cursor", costTier: "standard", featured: false, pricing: { inputPricePerMillion: 1, outputPricePerMillion: 5, cacheWritePricePerMillion: 1, cacheReadPricePerMillion: 0.1 } },
817
+ ],
818
+ });
819
+
820
+ function seedWrapper() {
821
+ const state: ModelRegistryState = { models: SEED_MODELS, isLoading: false, error: null, refetch: () => {} };
822
+ return function Wrapper({ children }: { children: ReactNode }) {
823
+ return (
824
+ <ModelRegistryContext.Provider value={state}>
825
+ {children}
826
+ </ModelRegistryContext.Provider>
827
+ );
828
+ };
829
+ }
830
+
831
+ it("seeds the harness when nothing is stored", () => {
832
+ const opts = { ...defaultOptions(), accountDefaults: { harness: "cursor" as const } };
833
+ const { result } = renderHook(() => useNewSessionFlow(opts), { wrapper: seedWrapper() });
834
+
835
+ expect(result.current.harness).toBe("cursor");
836
+ });
837
+
838
+ it("a stored harness choice outranks the account default", () => {
839
+ localStorage.setItem(STORAGE_KEY_HARNESS, "native");
840
+ const opts = { ...defaultOptions(), accountDefaults: { harness: "cursor" as const } };
841
+ const { result } = renderHook(() => useNewSessionFlow(opts), { wrapper: seedWrapper() });
842
+
843
+ expect(result.current.harness).toBe("native");
844
+ });
845
+
846
+ it("the account harness outranks the embedder defaultHarness", () => {
847
+ const opts = {
848
+ ...defaultOptions(),
849
+ defaultHarness: "native" as const,
850
+ accountDefaults: { harness: "cursor" as const },
851
+ };
852
+ const { result } = renderHook(() => useNewSessionFlow(opts), { wrapper: seedWrapper() });
853
+
854
+ expect(result.current.harness).toBe("cursor");
855
+ });
856
+
857
+ it("never persists the seeded harness — only explicit choices", () => {
858
+ const opts = { ...defaultOptions(), accountDefaults: { harness: "cursor" as const } };
859
+ renderHook(() => useNewSessionFlow(opts), { wrapper: seedWrapper() });
860
+
861
+ expect(localStorage.getItem(STORAGE_KEY_HARNESS)).toBeNull();
862
+ });
863
+
864
+ it("a late-arriving harness seed applies when the user has not picked (whoAmI resolves after mount)", () => {
865
+ const base: Parameters<typeof useNewSessionFlow>[0] = defaultOptions();
866
+ const { result, rerender } = renderHook(
867
+ (opts: Parameters<typeof useNewSessionFlow>[0]) => useNewSessionFlow(opts),
868
+ { wrapper: seedWrapper(), initialProps: base },
869
+ );
870
+ expect(result.current.harness).toBe("native");
871
+
872
+ rerender({ ...base, accountDefaults: { harness: "cursor" as const } });
873
+
874
+ expect(result.current.harness).toBe("cursor");
875
+ expect(localStorage.getItem(STORAGE_KEY_HARNESS)).toBeNull();
876
+ });
877
+
878
+ it("a late-arriving harness seed never overrides an explicit pick this mount", () => {
879
+ const base: Parameters<typeof useNewSessionFlow>[0] = defaultOptions();
880
+ const { result, rerender } = renderHook(
881
+ (opts: Parameters<typeof useNewSessionFlow>[0]) => useNewSessionFlow(opts),
882
+ { wrapper: seedWrapper(), initialProps: base },
883
+ );
884
+ act(() => result.current.setHarness("native"));
885
+
886
+ rerender({ ...base, accountDefaults: { harness: "cursor" as const } });
887
+
888
+ expect(result.current.harness).toBe("native");
889
+ });
890
+
891
+ it("seeds the model for the active harness when nothing is stored", () => {
892
+ const opts = { ...defaultOptions(), accountDefaults: { nativeModel: "gpt-5.3" } };
893
+ const { result } = renderHook(() => useNewSessionFlow(opts), { wrapper: seedWrapper() });
894
+
895
+ expect(result.current.modelId).toBe("gpt-5.3");
896
+ });
897
+
898
+ it("a stored model pick outranks the account default", async () => {
899
+ localStorage.setItem(STORAGE_KEY_MODEL_NATIVE, "claude-sonnet-4.6");
900
+ const opts = { ...defaultOptions(), accountDefaults: { nativeModel: "gpt-5.3" } };
901
+ const { result } = renderHook(() => useNewSessionFlow(opts), { wrapper: seedWrapper() });
902
+
903
+ // The stored restore lands in a passive effect once the registry is
904
+ // ready; the account seed must not win the race before it.
905
+ await act(async () => {});
906
+ expect(result.current.modelId).toBe("claude-sonnet-4.6");
907
+ });
908
+
909
+ it("self-heals a stale account model to the platform default (undefined)", () => {
910
+ const opts = { ...defaultOptions(), accountDefaults: { nativeModel: "retired-model" } };
911
+ const { result } = renderHook(() => useNewSessionFlow(opts), { wrapper: seedWrapper() });
912
+
913
+ expect(result.current.modelId).toBeUndefined();
914
+ });
915
+
916
+ it("never persists the seeded model — only explicit choices", () => {
917
+ const opts = { ...defaultOptions(), accountDefaults: { nativeModel: "gpt-5.3" } };
918
+ renderHook(() => useNewSessionFlow(opts), { wrapper: seedWrapper() });
919
+
920
+ expect(localStorage.getItem(STORAGE_KEY_MODEL_NATIVE)).toBeNull();
921
+ });
922
+
923
+ it("follows the active harness: cursorModel seeds cursor sessions", () => {
924
+ const opts = {
925
+ ...defaultOptions(),
926
+ accountDefaults: { harness: "cursor" as const, nativeModel: "gpt-5.3", cursorModel: "composer-2.5" },
927
+ };
928
+ const { result } = renderHook(() => useNewSessionFlow(opts), { wrapper: seedWrapper() });
929
+
930
+ expect(result.current.harness).toBe("cursor");
931
+ expect(result.current.modelId).toBe("composer-2.5");
932
+ });
933
+
934
+ it("submits the seeded model explicitly — the pill promise (#663) and the DD-003 seed rule", async () => {
935
+ const opts = { ...defaultOptions(), accountDefaults: { nativeModel: "gpt-5.3" } };
936
+ const { result } = renderHook(() => useNewSessionFlow(opts), { wrapper: seedWrapper() });
937
+
938
+ await act(async () => {
939
+ await result.current.submit("Hello");
940
+ });
941
+
942
+ expect(mockCreateExecution.mock.calls[0][0].modelName).toBe("gpt-5.3");
943
+ });
944
+
945
+ it("guest audience ignores accountDefaults entirely", () => {
946
+ const opts = {
947
+ ...defaultOptions(),
948
+ audience: "guest" as const,
949
+ accountDefaults: { harness: "native" as const, nativeModel: "gpt-5.3", cursorModel: "composer-2.5" },
950
+ };
951
+ const { result } = renderHook(() => useNewSessionFlow(opts), { wrapper: seedWrapper() });
952
+
953
+ // Platform share policy: cursor harness, no client model.
954
+ expect(result.current.harness).toBe("cursor");
955
+ expect(result.current.modelId).toBeUndefined();
956
+ });
957
+ });
958
+
808
959
  describe("submit while default agent is loading", () => {
809
960
  it("awaits default agent and creates session when fetch resolves", async () => {
810
961
  const resolvedAgent = { status: { defaultInstanceId: "awaited-inst" } };
@@ -1,7 +1,8 @@
1
1
  "use client";
2
2
 
3
- import { useCallback, useEffect, useState } from "react";
3
+ import { useCallback, useEffect, useRef, useState } from "react";
4
4
  import { getUserMessage, type McpServerUsageInput, type ResourceRef } from "@stigmer/sdk";
5
+ import type { AccountExecutionDefaults } from "../identity-account/useAccountExecutionDefaults.js";
5
6
  import type { AgentResolution } from "../agent/index.js";
6
7
  import { useDefaultAgent } from "../agent/index.js";
7
8
  import { useModelRegistry } from "../models/index.js";
@@ -102,6 +103,26 @@ export interface UseNewSessionFlowOptions {
102
103
  * @default DEFAULT_HARNESS ("native")
103
104
  */
104
105
  readonly defaultHarness?: HarnessOption;
106
+ /**
107
+ * The user's account-level execution defaults
108
+ * (`IdentityAccountPreferences.default_*`), typically from
109
+ * `useAccountExecutionDefaults()`. A SEED in the layered precedence —
110
+ * explicit device-local choices always outrank it:
111
+ *
112
+ * - harness: stored choice > `accountDefaults.harness` >
113
+ * {@link defaultHarness} > platform default. May arrive after mount
114
+ * (whoAmI resolves async); a late value still seeds unless the user
115
+ * has already picked a harness this mount.
116
+ * - model: stored per-harness choice > the account's model for the
117
+ * active harness (validated against the registry; a stale model
118
+ * silently falls through) > the harness default.
119
+ *
120
+ * Seeded values are never persisted to localStorage — the account
121
+ * preference keeps applying until the user decides. Ignored for the
122
+ * `"guest"` audience (platform share policy owns guest config), and
123
+ * consumers must not fetch identity for guest/embed surfaces.
124
+ */
125
+ readonly accountDefaults?: AccountExecutionDefaults;
105
126
  /**
106
127
  * Who this flow serves. `"guest"` adapts the orchestration to the
107
128
  * guest principal's permission model: the org default-agent lookup
@@ -162,7 +183,12 @@ export interface UseNewSessionFlowReturn {
162
183
  /** Switch the harness. Resets the model if invalid for the new harness. */
163
184
  readonly setHarness: (harness: HarnessOption) => void;
164
185
 
165
- /** Currently selected model ID (persisted per-harness to localStorage). */
186
+ /**
187
+ * The model an untouched send will run: the explicit device pick when
188
+ * one exists (persisted per-harness to localStorage), else the account
189
+ * default for the active harness. `undefined` resolves to the harness
190
+ * default downstream.
191
+ */
166
192
  readonly modelId: string | undefined;
167
193
  /** Update the selected model. Automatically persists to localStorage. */
168
194
  readonly setModelId: (id: string) => void;
@@ -269,6 +295,10 @@ export function useNewSessionFlow(
269
295
  sessionContext,
270
296
  } = options;
271
297
  const isGuest = options.audience === "guest";
298
+ // Guests never inherit account defaults — the platform share policy owns
299
+ // guest execution config (GUEST_HARNESS reasoning), and identity-derived
300
+ // preferences must never shape a share/embed surface.
301
+ const accountDefaults = isGuest ? undefined : options.accountDefaults;
272
302
  // Guests never carry a client pin: guest execution config is owned by
273
303
  // the server-side share policy (GUEST_HARNESS reasoning). Validated at
274
304
  // render so a statically-wrong pin (fast tier, no model) fails in the
@@ -293,13 +323,30 @@ export function useNewSessionFlow(
293
323
  // touch localStorage: a browser previously used in the Console must not
294
324
  // leak its stored harness into a share/embed session.
295
325
  if (isGuest) return GUEST_HARNESS;
296
- if (typeof window === "undefined") return defaultHarness ?? DEFAULT_HARNESS;
326
+ if (typeof window === "undefined")
327
+ return accountDefaults?.harness ?? defaultHarness ?? DEFAULT_HARNESS;
297
328
  // Only explicit user choices are persisted (see setHarness), so a
298
- // stored value always outranks the embedder's defaultHarness.
329
+ // stored value always outranks the account default and the embedder's
330
+ // defaultHarness.
299
331
  const stored = localStorage.getItem(STORAGE_KEY_HARNESS);
300
332
  if (stored === "native" || stored === "cursor") return stored;
301
- return defaultHarness ?? DEFAULT_HARNESS;
333
+ return accountDefaults?.harness ?? defaultHarness ?? DEFAULT_HARNESS;
302
334
  });
335
+ // Set once the user picks a harness this mount — a late-arriving account
336
+ // default must never override an explicit choice (the composer's
337
+ // userOverrodeModel idiom).
338
+ const harnessTouchedRef = useRef(false);
339
+
340
+ // Account defaults resolve async (whoAmI): when the harness seed arrives
341
+ // after mount, apply it once — unless the user has picked or a stored
342
+ // choice exists. Never persisted: seeding must not masquerade as a user
343
+ // choice, or the account preference would stop applying elsewhere.
344
+ useEffect(() => {
345
+ const seed = accountDefaults?.harness;
346
+ if (isGuest || harnessTouchedRef.current || !seed) return;
347
+ if (typeof window !== "undefined" && localStorage.getItem(STORAGE_KEY_HARNESS)) return;
348
+ setHarnessRaw(seed);
349
+ }, [isGuest, accountDefaults?.harness]);
303
350
 
304
351
  const stigmer = useStigmer();
305
352
  const { getModel, isLoading: isModelsLoading } = useModelRegistry({ harness });
@@ -326,8 +373,26 @@ export function useNewSessionFlow(
326
373
 
327
374
  const validModelId = modelId && getModel(modelId) ? modelId : undefined;
328
375
 
376
+ // The account's model for the active harness, registry-validated the same
377
+ // way as the stored choice (a stale/removed preference silently falls
378
+ // through to the harness default — self-healing). Derived, never written
379
+ // into `modelId` state: the persist effect below stores only explicit
380
+ // choices, and a derived seed cannot leak into localStorage.
381
+ const accountModelForHarness =
382
+ harness === "cursor" ? accountDefaults?.cursorModel : accountDefaults?.nativeModel;
383
+ const accountModelId =
384
+ accountModelForHarness && getModel(accountModelForHarness)
385
+ ? accountModelForHarness
386
+ : undefined;
387
+
388
+ // Layered precedence: explicit device pick > account default. The result
389
+ // feeds the composer's model pill AND the submit path, so the pill always
390
+ // shows what an untouched send will run (#663).
391
+ const effectiveModelId = validModelId ?? accountModelId;
392
+
329
393
  const setHarness = useCallback(
330
394
  (h: HarnessOption) => {
395
+ harnessTouchedRef.current = true;
331
396
  setHarnessRaw(h);
332
397
  // Guests have no harness picker; if a caller invokes this anyway, the
333
398
  // guest surface must never write into the Console's preference keys.
@@ -415,8 +480,10 @@ export function useNewSessionFlow(
415
480
  // The owner pin wins over the composer and the restored
416
481
  // preference (#664); the pinned tier is stamped only as
417
482
  // "fast" — standard stays off the wire, preserving the #357
418
- // UNSPECIFIED-vs-explicit telemetry distinction.
419
- modelName: pinnedModelName ?? selectedModel ?? validModelId,
483
+ // UNSPECIFIED-vs-explicit telemetry distinction. The effective
484
+ // model carries the account-default seed explicitly (DD-003:
485
+ // the preference is a seed; the execution spec is the record).
486
+ modelName: pinnedModelName ?? selectedModel ?? effectiveModelId,
420
487
  runtimeEnv,
421
488
  attachments: context?.attachments,
422
489
  interactionMode: context?.interactionMode,
@@ -514,7 +581,7 @@ export function useNewSessionFlow(
514
581
  autoApproveAll,
515
582
  adapter,
516
583
  getRuntimeEnv,
517
- validModelId,
584
+ effectiveModelId,
518
585
  pinnedModelName,
519
586
  pinnedServiceTier,
520
587
  workspace,
@@ -539,7 +606,7 @@ export function useNewSessionFlow(
539
606
  return {
540
607
  harness,
541
608
  setHarness,
542
- modelId: validModelId,
609
+ modelId: effectiveModelId,
543
610
  setModelId,
544
611
  agentRef,
545
612
  setAgentRef,
@@ -0,0 +1,25 @@
1
+ "use client";
2
+
3
+ import { useId } from "react";
4
+ import { AccountPreferencesPanel } from "../identity-account/AccountPreferencesPanel.js";
5
+
6
+ /** Settings section for editing the current user's declared preferences. */
7
+ export function AccountPreferencesSection() {
8
+ const headingId = useId();
9
+
10
+ return (
11
+ <section aria-labelledby={headingId}>
12
+ <h2
13
+ id={headingId}
14
+ className="stg:text-foreground stg:mb-1 stg:text-sm stg:font-semibold"
15
+ >
16
+ Account Preferences
17
+ </h2>
18
+ <p className="stg:text-muted-foreground stg:mb-6 stg:text-xs">
19
+ Personal standing context shared with agents on executions you run.
20
+ </p>
21
+
22
+ <AccountPreferencesPanel />
23
+ </section>
24
+ );
25
+ }
@@ -0,0 +1,35 @@
1
+ "use client";
2
+
3
+ import { useId } from "react";
4
+ import { OrgPreferencesPanel } from "../organization/OrgPreferencesPanel.js";
5
+ import { useOrg } from "../organization/OrgProvider.js";
6
+
7
+ /** Settings section for editing the active organization's declared preferences. */
8
+ export function OrgPreferencesSection() {
9
+ const headingId = useId();
10
+ const { activeOrg } = useOrg();
11
+ const orgId = activeOrg?.metadata?.id ?? "";
12
+
13
+ return (
14
+ <section aria-labelledby={headingId}>
15
+ <h2
16
+ id={headingId}
17
+ className="stg:text-foreground stg:mb-1 stg:text-sm stg:font-semibold"
18
+ >
19
+ Organization Preferences
20
+ </h2>
21
+ <p className="stg:text-muted-foreground stg:mb-6 stg:text-xs">
22
+ Standing context shared with agents on every execution run by this
23
+ organization&apos;s members.
24
+ </p>
25
+
26
+ {!orgId ? (
27
+ <p className="stg:text-muted-foreground stg:py-4 stg:text-center stg:text-xs">
28
+ Select an organization to view its preferences.
29
+ </p>
30
+ ) : (
31
+ <OrgPreferencesPanel orgId={orgId} />
32
+ )}
33
+ </section>
34
+ );
35
+ }