@stigmer/react 3.12.7 → 3.12.8

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 (137) hide show
  1. package/composer/ComposerToolbar.d.ts +1 -19
  2. package/composer/ComposerToolbar.d.ts.map +1 -1
  3. package/composer/ComposerToolbar.js +2 -7
  4. package/composer/ComposerToolbar.js.map +1 -1
  5. package/composer/SessionComposer.d.ts +0 -10
  6. package/composer/SessionComposer.d.ts.map +1 -1
  7. package/composer/SessionComposer.js +2 -2
  8. package/composer/SessionComposer.js.map +1 -1
  9. package/composer/index.d.ts +0 -1
  10. package/composer/index.d.ts.map +1 -1
  11. package/composer/index.js.map +1 -1
  12. package/execution/ResultView.d.ts.map +1 -1
  13. package/execution/ResultView.js +6 -0
  14. package/execution/ResultView.js.map +1 -1
  15. package/execution/ToolCallItem.d.ts.map +1 -1
  16. package/execution/ToolCallItem.js +13 -2
  17. package/execution/ToolCallItem.js.map +1 -1
  18. package/execution/tool-categories.d.ts +1 -1
  19. package/execution/tool-categories.d.ts.map +1 -1
  20. package/execution/tool-categories.js +6 -0
  21. package/execution/tool-categories.js.map +1 -1
  22. package/identity-account/AccountPreferencesPanel.d.ts +2 -1
  23. package/identity-account/AccountPreferencesPanel.d.ts.map +1 -1
  24. package/identity-account/AccountPreferencesPanel.js +41 -3
  25. package/identity-account/AccountPreferencesPanel.js.map +1 -1
  26. package/identity-account/useAccountExecutionDefaults.d.ts +7 -0
  27. package/identity-account/useAccountExecutionDefaults.d.ts.map +1 -1
  28. package/identity-account/useAccountExecutionDefaults.js +5 -2
  29. package/identity-account/useAccountExecutionDefaults.js.map +1 -1
  30. package/index.d.ts +4 -4
  31. package/index.d.ts.map +1 -1
  32. package/index.js +1 -1
  33. package/index.js.map +1 -1
  34. package/internal/MemoryEnabledRow.d.ts +37 -0
  35. package/internal/MemoryEnabledRow.d.ts.map +1 -0
  36. package/internal/MemoryEnabledRow.js +25 -0
  37. package/internal/MemoryEnabledRow.js.map +1 -0
  38. package/memory/MemoryProposalCard.d.ts +35 -0
  39. package/memory/MemoryProposalCard.d.ts.map +1 -0
  40. package/memory/MemoryProposalCard.js +73 -0
  41. package/memory/MemoryProposalCard.js.map +1 -0
  42. package/memory/index.d.ts +2 -0
  43. package/memory/index.d.ts.map +1 -1
  44. package/memory/index.js +2 -0
  45. package/memory/index.js.map +1 -1
  46. package/memory/useMemory.d.ts +39 -0
  47. package/memory/useMemory.d.ts.map +1 -0
  48. package/memory/useMemory.js +43 -0
  49. package/memory/useMemory.js.map +1 -0
  50. package/organization/OrgPreferencesPanel.d.ts.map +1 -1
  51. package/organization/OrgPreferencesPanel.js +36 -4
  52. package/organization/OrgPreferencesPanel.js.map +1 -1
  53. package/package.json +4 -4
  54. package/session/AutoApproveIndicator.d.ts +18 -0
  55. package/session/AutoApproveIndicator.d.ts.map +1 -0
  56. package/session/AutoApproveIndicator.js +23 -0
  57. package/session/AutoApproveIndicator.js.map +1 -0
  58. package/session/NewSessionViewer.d.ts.map +1 -1
  59. package/session/NewSessionViewer.js +10 -9
  60. package/session/NewSessionViewer.js.map +1 -1
  61. package/session/SessionViewer.d.ts +21 -9
  62. package/session/SessionViewer.d.ts.map +1 -1
  63. package/session/SessionViewer.js +31 -21
  64. package/session/SessionViewer.js.map +1 -1
  65. package/session/facets/SetupTab.d.ts +43 -6
  66. package/session/facets/SetupTab.d.ts.map +1 -1
  67. package/session/facets/SetupTab.js +32 -36
  68. package/session/facets/SetupTab.js.map +1 -1
  69. package/session/facets/UsageTab.d.ts +2 -1
  70. package/session/facets/UsageTab.d.ts.map +1 -1
  71. package/session/facets/UsageTab.js +11 -5
  72. package/session/facets/UsageTab.js.map +1 -1
  73. package/session/facets/index.d.ts +1 -1
  74. package/session/facets/index.d.ts.map +1 -1
  75. package/session/facets/primitives.d.ts +58 -0
  76. package/session/facets/primitives.d.ts.map +1 -0
  77. package/session/facets/primitives.js +59 -0
  78. package/session/facets/primitives.js.map +1 -0
  79. package/session/index.d.ts +1 -1
  80. package/session/index.d.ts.map +1 -1
  81. package/session/index.js.map +1 -1
  82. package/session/useNewSessionFlow.d.ts +9 -4
  83. package/session/useNewSessionFlow.d.ts.map +1 -1
  84. package/session/useNewSessionFlow.js +20 -9
  85. package/session/useNewSessionFlow.js.map +1 -1
  86. package/session/useSessionPageFlow.d.ts +28 -10
  87. package/session/useSessionPageFlow.d.ts.map +1 -1
  88. package/session/useSessionPageFlow.js +16 -10
  89. package/session/useSessionPageFlow.js.map +1 -1
  90. package/src/attachment/__tests__/prepare-image.test.ts +12 -6
  91. package/src/composer/ComposerToolbar.tsx +0 -57
  92. package/src/composer/SessionComposer.tsx +1 -13
  93. package/src/composer/index.ts +0 -1
  94. package/src/execution/ResultView.tsx +6 -0
  95. package/src/execution/ToolCallItem.tsx +18 -1
  96. package/src/execution/tool-categories.ts +7 -0
  97. package/src/identity-account/AccountPreferencesPanel.tsx +89 -2
  98. package/src/identity-account/__tests__/AccountPreferencesPanel.test.tsx +112 -0
  99. package/src/identity-account/__tests__/useAccountExecutionDefaults.test.tsx +34 -1
  100. package/src/identity-account/useAccountExecutionDefaults.ts +12 -2
  101. package/src/index.ts +5 -1
  102. package/src/internal/MemoryEnabledRow.tsx +92 -0
  103. package/src/memory/MemoryProposalCard.tsx +181 -0
  104. package/src/memory/__tests__/MemoryProposalCard.test.tsx +236 -0
  105. package/src/memory/__tests__/memoryHooks.test.tsx +63 -0
  106. package/src/memory/index.ts +5 -0
  107. package/src/memory/useMemory.ts +80 -0
  108. package/src/organization/OrgPreferencesPanel.tsx +52 -3
  109. package/src/organization/__tests__/OrgPreferencesPanel.test.tsx +65 -0
  110. package/src/session/AutoApproveIndicator.tsx +45 -0
  111. package/src/session/NewSessionViewer.tsx +18 -15
  112. package/src/session/SessionViewer.tsx +70 -32
  113. package/src/session/__tests__/NewSessionViewer.test.tsx +32 -0
  114. package/src/session/__tests__/SessionViewer-autoApprove.test.tsx +244 -0
  115. package/src/session/__tests__/SessionViewer-transcriptExport.test.tsx +60 -22
  116. package/src/session/__tests__/useNewSessionFlow.test.tsx +56 -0
  117. package/src/session/__tests__/useSessionPageFlow.autoApprove.test.tsx +87 -0
  118. package/src/session/facets/SetupTab.tsx +193 -157
  119. package/src/session/facets/UsageTab.tsx +38 -38
  120. package/src/session/facets/index.ts +1 -0
  121. package/src/session/facets/primitives.tsx +169 -0
  122. package/src/session/index.ts +1 -0
  123. package/src/session/useNewSessionFlow.ts +30 -15
  124. package/src/session/useSessionPageFlow.ts +44 -20
  125. package/src/switch/Switch.tsx +4 -0
  126. package/src/workflow/__tests__/useWorkflowCanvas.dirty.test.tsx +7 -2
  127. package/src/workflow/__tests__/useWorkflowCanvas.drag.test.tsx +7 -2
  128. package/src/workspace/WorkspaceSurface.tsx +26 -3
  129. package/styles.css +1 -1
  130. package/switch/Switch.d.ts +3 -1
  131. package/switch/Switch.d.ts.map +1 -1
  132. package/switch/Switch.js +2 -2
  133. package/switch/Switch.js.map +1 -1
  134. package/workspace/WorkspaceSurface.js +11 -2
  135. package/workspace/WorkspaceSurface.js.map +1 -1
  136. package/src/composer/__tests__/ComposerToolbar-autoApprove.test.tsx +0 -80
  137. package/src/session/__tests__/SessionViewer-autoApproveToggle.test.tsx +0 -178
@@ -5,9 +5,11 @@ import { create } from "@bufbuild/protobuf";
5
5
  import { MemorySchema, type Memory } from "@stigmer/protos/ai/stigmer/agentic/memory/v1/api_pb";
6
6
  import { MemoryLifecycleState } from "@stigmer/protos/ai/stigmer/agentic/memory/v1/enum_pb";
7
7
  import { MemoryListSchema } from "@stigmer/protos/ai/stigmer/agentic/memory/v1/io_pb";
8
+ import { StigmerError } from "@stigmer/sdk";
8
9
  import { StigmerContext } from "../../context";
9
10
  import { FetchCacheContext } from "../../internal/FetchCacheProvider";
10
11
  import { useMemories } from "../useMemories";
12
+ import { useMemory } from "../useMemory";
11
13
  import { useConfirmMemory } from "../useConfirmMemory";
12
14
  import { useRejectMemory } from "../useRejectMemory";
13
15
  import { useDeleteMemory } from "../useDeleteMemory";
@@ -101,6 +103,67 @@ describe("useMemories", () => {
101
103
  });
102
104
  });
103
105
 
106
+ describe("useMemory", () => {
107
+ it("fetches one record by id", async () => {
108
+ const get = vi.fn().mockResolvedValue(PROPOSED);
109
+ const client = { memory: { get } };
110
+
111
+ const { result } = renderHook(() => useMemory("mem_proposed"), {
112
+ wrapper: wrapper(client),
113
+ });
114
+
115
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
116
+ expect(result.current.memory).toBe(PROPOSED);
117
+ expect(result.current.notFound).toBe(false);
118
+ expect(result.current.error).toBeNull();
119
+ expect(get).toHaveBeenCalledWith("mem_proposed");
120
+ });
121
+
122
+ it("reports a deleted record as the notFound STATE, never an error", async () => {
123
+ // Deletion is the consent-revocation mechanism — a consumer must
124
+ // render "no longer stored", not a failure.
125
+ const get = vi
126
+ .fn()
127
+ .mockRejectedValue(new StigmerError("not-found", "memory not found", 5));
128
+ const client = { memory: { get } };
129
+
130
+ const { result } = renderHook(() => useMemory("mem_gone"), {
131
+ wrapper: wrapper(client),
132
+ });
133
+
134
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
135
+ expect(result.current.notFound).toBe(true);
136
+ expect(result.current.memory).toBeNull();
137
+ expect(result.current.error).toBeNull();
138
+ });
139
+
140
+ it("propagates every other failure as an error", async () => {
141
+ const failure = new StigmerError("unavailable", "server down", 14);
142
+ const client = { memory: { get: vi.fn().mockRejectedValue(failure) } };
143
+
144
+ const { result } = renderHook(() => useMemory("mem_x"), {
145
+ wrapper: wrapper(client),
146
+ });
147
+
148
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
149
+ expect(result.current.error).toBe(failure);
150
+ expect(result.current.notFound).toBe(false);
151
+ });
152
+
153
+ it("skips fetching when id is null", () => {
154
+ const get = vi.fn();
155
+ const client = { memory: { get } };
156
+
157
+ const { result } = renderHook(() => useMemory(null), {
158
+ wrapper: wrapper(client),
159
+ });
160
+
161
+ expect(result.current.isLoading).toBe(false);
162
+ expect(result.current.memory).toBeNull();
163
+ expect(get).not.toHaveBeenCalled();
164
+ });
165
+ });
166
+
104
167
  // ---------------------------------------------------------------------------
105
168
  // Decision hooks
106
169
  // ---------------------------------------------------------------------------
@@ -4,12 +4,17 @@
4
4
  // is recalled until its subject confirms it.
5
5
 
6
6
  export { MemoryListPanel, type MemoryListPanelProps } from "./MemoryListPanel.js";
7
+ export {
8
+ MemoryProposalCardBody,
9
+ type MemoryProposalCardBodyProps,
10
+ } from "./MemoryProposalCard.js";
7
11
  export {
8
12
  groupMemoriesByLifecycle,
9
13
  formatMemoryProvenance,
10
14
  type MemoryGroups,
11
15
  } from "./memoryGroups.js";
12
16
  export { useMemories, type UseMemoriesReturn } from "./useMemories.js";
17
+ export { useMemory, type UseMemoryReturn } from "./useMemory.js";
13
18
  export { useConfirmMemory, type UseConfirmMemoryReturn } from "./useConfirmMemory.js";
14
19
  export { useRejectMemory, type UseRejectMemoryReturn } from "./useRejectMemory.js";
15
20
  export { useDeleteMemory, type UseDeleteMemoryReturn } from "./useDeleteMemory.js";
@@ -0,0 +1,80 @@
1
+ "use client";
2
+
3
+ import type { Memory } from "@stigmer/protos/ai/stigmer/agentic/memory/v1/api_pb";
4
+ import { isNotFound } from "@stigmer/sdk";
5
+ import { useStigmer } from "../hooks.js";
6
+ import { useFetch } from "../internal/useFetch.js";
7
+
8
+ /** Return value of {@link useMemory}. */
9
+ export interface UseMemoryReturn {
10
+ /** The memory record, or `null` while loading / not found / on error. */
11
+ readonly memory: Memory | null;
12
+ /** `true` while the initial fetch is in flight. */
13
+ readonly isLoading: boolean;
14
+ /**
15
+ * `true` when the record no longer exists. A first-class STATE, not an
16
+ * error: deletion is the platform's consent-revocation mechanism, so a
17
+ * consumer (the proposal chip, a deep link) must render "no longer
18
+ * stored" honestly rather than a failure.
19
+ */
20
+ readonly notFound: boolean;
21
+ /** Error from the last failed request (never not-found), or `null`. */
22
+ readonly error: Error | null;
23
+ /** Discard cached data and re-fetch the record from the server. */
24
+ readonly refetch: () => void;
25
+ }
26
+
27
+ /** The fetch result shape that keeps not-found distinct from errors. */
28
+ interface MemoryLookup {
29
+ readonly memory: Memory | null;
30
+ readonly notFound: boolean;
31
+ }
32
+
33
+ const PENDING_LOOKUP: MemoryLookup = { memory: null, notFound: false };
34
+
35
+ /**
36
+ * Data hook that fetches one memory record by id.
37
+ *
38
+ * Built for surfaces that hold a memory's identity but not its CURRENT
39
+ * lifecycle state — the in-thread proposal chip is the primary consumer:
40
+ * the tool result it renders from is frozen at capture time, while the
41
+ * record may have been confirmed, rejected, or deleted since (from the
42
+ * chip itself, the memory settings page, or another device). Content
43
+ * visibility is subject-only, so the fetch answers only for the person
44
+ * the memory is about.
45
+ *
46
+ * Pass `null` as `id` to skip fetching (stable no-op).
47
+ *
48
+ * @example
49
+ * ```tsx
50
+ * const { memory, isLoading, notFound } = useMemory(memoryId);
51
+ * ```
52
+ */
53
+ export function useMemory(id: string | null): UseMemoryReturn {
54
+ const stigmer = useStigmer();
55
+
56
+ const { data, isLoading, error, refetch } = useFetch(
57
+ id
58
+ ? () =>
59
+ stigmer.memory.get(id).then(
60
+ (memory): MemoryLookup => ({ memory, notFound: false }),
61
+ (err): MemoryLookup => {
62
+ // Deletion is a state this hook reports, never an error it
63
+ // throws — every other failure propagates.
64
+ if (isNotFound(err)) return { memory: null, notFound: true };
65
+ throw err;
66
+ },
67
+ )
68
+ : null,
69
+ [stigmer, id],
70
+ PENDING_LOOKUP,
71
+ );
72
+
73
+ return {
74
+ memory: data.memory,
75
+ isLoading,
76
+ notFound: data.notFound,
77
+ error,
78
+ refetch,
79
+ };
80
+ }
@@ -7,6 +7,7 @@ import type { Organization } from "@stigmer/protos/ai/stigmer/tenancy/organizati
7
7
  import { useOrganization } from "./useOrganization.js";
8
8
  import { useUpdateOrganization } from "./useUpdateOrganization.js";
9
9
  import { useCheckPermission } from "../iam-policy/useCheckPermission.js";
10
+ import { MemoryEnabledRow } from "../internal/MemoryEnabledRow.js";
10
11
  import { StandingContextField } from "../internal/StandingContextField.js";
11
12
  import { SpinnerIcon } from "../internal/SpinnerIcon.js";
12
13
 
@@ -60,6 +61,14 @@ export function OrgPreferencesPanel({
60
61
  clearError,
61
62
  } = useUpdateOrganization();
62
63
 
64
+ // The memory toggle saves instantly (its own hook instance, so a flip's
65
+ // in-flight/error state never bleeds into the form's Save button).
66
+ const {
67
+ update: updateMemoryFlag,
68
+ isUpdating: isSavingMemoryFlag,
69
+ error: memoryFlagError,
70
+ } = useUpdateOrganization();
71
+
63
72
  // Fail-open: OSS local mode has no IAM service, so editing stays
64
73
  // available there; cloud gets a genuine server verdict.
65
74
  const { allowed: canEdit } = useCheckPermission(
@@ -100,10 +109,16 @@ export function OrgPreferencesPanel({
100
109
  clearError();
101
110
  try {
102
111
  // update() is a full-spec replace: spread the complete mapped input
103
- // so unedited spec fields survive, and override only preferences.
112
+ // so unedited spec fields survive. `preferences` is a nested message,
113
+ // so the override spreads the mapper's COMPLETE preferences too —
114
+ // fields this form does not own (memory_enabled) survive the save.
115
+ const mapped = toOrganizationUpdateInput(organization);
104
116
  const updated = await update({
105
- ...toOrganizationUpdateInput(organization),
106
- preferences: { standingContext: standingContext.trim() || undefined },
117
+ ...mapped,
118
+ preferences: {
119
+ ...mapped.preferences,
120
+ standingContext: standingContext.trim() || undefined,
121
+ },
107
122
  });
108
123
  refetch();
109
124
  onUpdated?.(updated);
@@ -114,6 +129,30 @@ export function OrgPreferencesPanel({
114
129
  [canSubmit, organization, standingContext, update, clearError, refetch, onUpdated],
115
130
  );
116
131
 
132
+ // The memory consent flag applies instantly (the UX-checkpoint decision):
133
+ // a consent bit flipped-but-unsaved that silently reverts on navigation is
134
+ // the failure consent UX must not have. Same wipe-safe double spread.
135
+ const handleMemoryToggle = useCallback(
136
+ async (next: boolean) => {
137
+ if (!organization) return;
138
+ try {
139
+ const mapped = toOrganizationUpdateInput(organization);
140
+ const updated = await updateMemoryFlag({
141
+ ...mapped,
142
+ preferences: {
143
+ ...mapped.preferences,
144
+ memoryEnabled: next || undefined,
145
+ },
146
+ });
147
+ refetch();
148
+ onUpdated?.(updated);
149
+ } catch {
150
+ // error state is managed by the toggle's own hook instance
151
+ }
152
+ },
153
+ [organization, updateMemoryFlag, refetch, onUpdated],
154
+ );
155
+
117
156
  // -----------------------------------------------------------------------
118
157
  // Loading state
119
158
  // -----------------------------------------------------------------------
@@ -175,6 +214,16 @@ export function OrgPreferencesPanel({
175
214
  helperText="Shared with agents as background context — not instructions. Applies to executions started by signed-in members of this organization and is visible on their execution records."
176
215
  />
177
216
 
217
+ <MemoryEnabledRow
218
+ id={`${baseId}-memory-enabled`}
219
+ checked={organization.spec?.preferences?.memoryEnabled ?? false}
220
+ onToggle={(next) => void handleMemoryToggle(next)}
221
+ saving={isSavingMemoryFlag}
222
+ readOnly={!canEdit}
223
+ error={memoryFlagError}
224
+ helperText="Allow agents to remember confirmed facts about members of this organization. Each member must also turn memory on in their own account preferences. Changes apply immediately."
225
+ />
226
+
178
227
  {!canEdit && (
179
228
  <p className="stg:text-[0.65rem] stg:text-muted-foreground">
180
229
  Only organization admins can edit these preferences.
@@ -125,6 +125,71 @@ describe("OrgPreferencesPanel", () => {
125
125
  expect(screen.queryByRole("button", { name: "Save changes" })).toBeNull();
126
126
  });
127
127
 
128
+ it("memory toggle applies instantly with the full mapped input (wipe-safe)", async () => {
129
+ const update = vi.fn(async (_input: OrganizationInput) => ORG);
130
+ renderPanel(createMockStigmer({ update }));
131
+ await findSyncedField("We deploy to us-east-1.");
132
+
133
+ // No Save click: consent applies the moment it is flipped (the
134
+ // UX-checkpoint decision — a flipped-but-unsaved consent bit that
135
+ // silently reverts on navigation is the failure this prevents).
136
+ fireEvent.click(screen.getByRole("switch", { name: "Memory" }));
137
+
138
+ await waitFor(() => expect(update).toHaveBeenCalledTimes(1));
139
+ const input = update.mock.calls[0]![0];
140
+ expect(input.preferences?.memoryEnabled).toBe(true);
141
+ // The double spread: the flip must not wipe the sibling preference…
142
+ expect(input.preferences?.standingContext).toBe("We deploy to us-east-1.");
143
+ // …nor any profile field this row never renders.
144
+ expect(input.name).toBe("Acme Corp");
145
+ expect(input.description).toBe("We make everything.");
146
+ });
147
+
148
+ it("saving standing context preserves memory_enabled (the wipe-hazard regression)", async () => {
149
+ const orgWithMemoryOn = create(OrganizationSchema, {
150
+ metadata: { id: "acme", name: "Acme Corp", slug: "acme", org: "acme" },
151
+ spec: {
152
+ description: "We make everything.",
153
+ logoUrl: "https://acme.example/logo.png",
154
+ preferences: {
155
+ standingContext: "We deploy to us-east-1.",
156
+ memoryEnabled: true,
157
+ },
158
+ },
159
+ });
160
+ const update = vi.fn(async (_input: OrganizationInput) => orgWithMemoryOn);
161
+ const client = {
162
+ organization: {
163
+ get: vi.fn(async () => orgWithMemoryOn),
164
+ update,
165
+ },
166
+ iamPolicy: {
167
+ checkMyPermission: vi.fn(async () => ({ isAuthorized: true })),
168
+ },
169
+ } as never;
170
+ renderPanel(client);
171
+
172
+ const field = await findSyncedField("We deploy to us-east-1.");
173
+ fireEvent.change(field, { target: { value: "Prefer terse answers." } });
174
+ fireEvent.click(screen.getByRole("button", { name: "Save changes" }));
175
+
176
+ await waitFor(() => expect(update).toHaveBeenCalledTimes(1));
177
+ // Update is a full-spec replace: without the nested preferences
178
+ // spread, this save would silently switch the org's memory OFF.
179
+ expect(update.mock.calls[0]![0].preferences?.memoryEnabled).toBe(true);
180
+ });
181
+
182
+ it("memory toggle is read-only without can_edit", async () => {
183
+ renderPanel(createMockStigmer({ canEdit: false }));
184
+ await screen.findByLabelText("Standing context");
185
+
186
+ await waitFor(() =>
187
+ expect(
188
+ screen.getByRole("switch", { name: "Memory" }).hasAttribute("disabled"),
189
+ ).toBe(true),
190
+ );
191
+ });
192
+
128
193
  it("shows the fetch error with a retry action when loading fails", async () => {
129
194
  const client = {
130
195
  organization: {
@@ -0,0 +1,45 @@
1
+ "use client";
2
+
3
+ /**
4
+ * Armed-only indicator for the session-scoped auto-approve state, docked
5
+ * above the composer. Renders nothing about approvals until auto-approve is
6
+ * actually ON — the composer stays clean by default — and while ON it is the
7
+ * always-visible safety surface: the user can never be silently
8
+ * auto-approving without seeing this strip and its one-click "Turn off".
9
+ *
10
+ * The way ON lives elsewhere (the Config facet's Run Config switch, the
11
+ * gate-time "Approve & don't ask again", the account's `default_auto_approve`
12
+ * preference, the host's `approvalDefaults`); this strip is deliberately only
13
+ * the way OFF plus the disclosure. Shared by `SessionViewer` and
14
+ * `NewSessionViewer` (DD-016) — a persisted account preference can arm the
15
+ * launcher before the first message, so both surfaces need the disclosure.
16
+ */
17
+ export function AutoApproveIndicator({ onTurnOff }: { readonly onTurnOff: () => void }) {
18
+ return (
19
+ <div
20
+ role="status"
21
+ className="stg:flex stg:items-center stg:gap-2 stg:border-t stg:border-border-muted stg:px-4 stg:py-1.5 stg:text-xs stg:text-muted-foreground"
22
+ >
23
+ <ShieldCheckIcon />
24
+ <span className="stg:min-w-0 stg:flex-1 stg:truncate">
25
+ Auto-approving tool calls for this session
26
+ </span>
27
+ <button
28
+ type="button"
29
+ onClick={onTurnOff}
30
+ className="stg:shrink-0 stg:rounded stg:font-medium stg:text-foreground stg:underline-offset-2 stg:hover:underline stg:focus-visible:outline-none stg:focus-visible:ring-2 stg:focus-visible:ring-ring"
31
+ >
32
+ Turn off
33
+ </button>
34
+ </div>
35
+ );
36
+ }
37
+
38
+ function ShieldCheckIcon() {
39
+ return (
40
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="stg:shrink-0 stg:text-success" aria-hidden="true">
41
+ <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
42
+ <path d="m9 12 2 2 4-4" />
43
+ </svg>
44
+ );
45
+ }
@@ -6,7 +6,7 @@ import type { UseGitHubConnectionReturn } from "../github/useGitHubConnection.js
6
6
  import type { WorkspaceFileLister } from "../workspace/WorkspaceFileLister.js";
7
7
  import type { WorkspaceFileReader } from "../workspace/WorkspaceFileReader.js";
8
8
  import type { WorkspaceContentSearcher } from "../workspace/WorkspaceContentSearcher.js";
9
- import type { ComposerAutoApproveProps, InteractionModeOption } from "../composer/index.js";
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
12
  import type { AccountExecutionDefaults } from "../identity-account/useAccountExecutionDefaults.js";
@@ -18,6 +18,7 @@ import { useNewSessionFlow } from "./useNewSessionFlow.js";
18
18
  import { useSessionPanel } from "./useSessionPanel.js";
19
19
  import { useSessionRailViews } from "./useSessionRailViews.js";
20
20
  import { SessionPanelChip } from "./SessionPanelChip.js";
21
+ import { AutoApproveIndicator } from "./AutoApproveIndicator.js";
21
22
  import type { RuntimeEnvProvider } from "./runtime-env.js";
22
23
  import type { SessionAudience, SessionPanelMode } from "./audience.js";
23
24
  import type { SessionRunConfig } from "./run-config.js";
@@ -299,19 +300,6 @@ export function NewSessionViewer({
299
300
  // SessionViewer derives (DD-016), gating the chip and the region together.
300
301
  const panelEnabled = panelMode !== "none" && !isGuest;
301
302
 
302
- // Always-visible auto-approve toggle (#816), present before the first
303
- // message is ever typed — the walk-away user arms it here and the create
304
- // carries spec.auto_approve_all server-side. Guests never get it (the
305
- // operator-consent reasoning as the #302 host default). Memoized per
306
- // DD-010 — the composer is memo'd and an inline object would defeat it.
307
- const autoApprove = useMemo<ComposerAutoApproveProps | undefined>(
308
- () =>
309
- isGuest
310
- ? undefined
311
- : { armed: flow.autoApproveAll, onChange: flow.setAutoApproveAll },
312
- [isGuest, flow.autoApproveAll, flow.setAutoApproveAll],
313
- );
314
-
315
303
  // Guest agent binding is host configuration, not a picker interaction:
316
304
  // the composer's agent machinery (picker, env-collection, personal
317
305
  // environments — all org reads a guest token cannot make) stays fully
@@ -387,6 +375,13 @@ export function NewSessionViewer({
387
375
  harness: flow.harness,
388
376
  executionTarget: undefined,
389
377
  modelId: flow.modelId,
378
+ // Pre-session arming (#816): the switch covers the session this
379
+ // surface will create — the bootstrap execution carries
380
+ // spec.auto_approve_all. Guests never get it (they never render the
381
+ // panel either — belt and braces for the operator-consent withhold).
382
+ autoApprove: isGuest
383
+ ? undefined
384
+ : { armed: flow.autoApproveAll, onChange: flow.setAutoApproveAll },
390
385
  // Curated audiences see the configuration but cannot strip it — the
391
386
  // Setup tab renders read-only without mutation callbacks (DD-011).
392
387
  mutations: isCurated
@@ -400,6 +395,7 @@ export function NewSessionViewer({
400
395
  [
401
396
  flow.agentRef, flow.mcpServerUsages, flow.skillRefs,
402
397
  flow.sessionVariables, flow.harness, flow.modelId,
398
+ flow.autoApproveAll, flow.setAutoApproveAll, isGuest,
403
399
  isCurated, handleRemoveAgent, handleRemoveMcp, handleRemoveSkill,
404
400
  ],
405
401
  );
@@ -465,7 +461,6 @@ export function NewSessionViewer({
465
461
  onInteractionModeChange={setInteractionMode}
466
462
  showInteractionModePicker={!isGuest}
467
463
  showModelSelector={modelSelectorVisible}
468
- autoApprove={autoApprove}
469
464
  enableAttachments={!isGuest}
470
465
  defaultModelId={flow.modelId}
471
466
  onModelChange={flow.setModelId}
@@ -475,6 +470,14 @@ export function NewSessionViewer({
475
470
  ariaLabel="Start a new session"
476
471
  />
477
472
 
473
+ {/* Armed-only disclosure (#816 rework): a persisted account preference
474
+ or the host's approvalDefaults can pre-arm the session this surface
475
+ will create — the user must see that before the first send, with
476
+ the one-click way off. Guests never inherit either seed. */}
477
+ {!isGuest && flow.autoApproveAll && (
478
+ <AutoApproveIndicator onTurnOff={() => flow.setAutoApproveAll(false)} />
479
+ )}
480
+
478
481
  {footerContent}
479
482
 
480
483
  <p className="stg:text-center stg:text-[0.65rem] stg:text-muted-foreground">
@@ -17,7 +17,8 @@ import {
17
17
  WorkspaceSurface,
18
18
  type SurfaceVirtualDocument,
19
19
  } from "../workspace/WorkspaceSurface.js";
20
- import type { ComposerAutoApproveProps, InteractionModeOption, SessionComposerHandle, SessionComposerSubmitContext } from "../composer/index.js";
20
+ import type { InteractionModeOption, SessionComposerHandle, SessionComposerSubmitContext } from "../composer/index.js";
21
+ import type { AccountExecutionDefaults } from "../identity-account/useAccountExecutionDefaults.js";
21
22
  import type { ApplyResourceResult } from "../library/useApplyResource.js";
22
23
  import { SessionViewerLayout } from "./SessionViewerLayout.js";
23
24
  import { useWorkspaceEditors, isVirtualEntryId } from "../internal/store/index.js";
@@ -52,6 +53,7 @@ import { useSessionRailViews } from "./useSessionRailViews.js";
52
53
  import { useSessionPanel, type SessionPanelController } from "./useSessionPanel.js";
53
54
  import { SessionPanelChip } from "./SessionPanelChip.js";
54
55
  import { TranscriptExportMenu } from "./TranscriptExportMenu.js";
56
+ import { AutoApproveIndicator } from "./AutoApproveIndicator.js";
55
57
  import { useSessionWriteBacks } from "./useSessionWriteBacks.js";
56
58
  import { useWorkspaceReadRefs } from "./useWorkspaceReadRefs.js";
57
59
  import {
@@ -240,6 +242,17 @@ export interface SessionViewerProps {
240
242
  * server-side share policy owns. See {@link SessionRunConfig}.
241
243
  */
242
244
  readonly runConfig?: SessionRunConfig;
245
+ /**
246
+ * The user's account-level execution defaults, typically from
247
+ * `useAccountExecutionDefaults()` — same seam as
248
+ * `NewSessionViewer.accountDefaults` (DD-016). On the session page only
249
+ * the `autoApprove` default applies: it seeds the session-scoped
250
+ * auto-approve state so a declared `default_auto_approve` preference
251
+ * covers follow-ups on existing sessions too. An explicit flip of the
252
+ * Config facet's switch wins for this session. Ignored for the
253
+ * `"guest"` audience.
254
+ */
255
+ readonly accountDefaults?: AccountExecutionDefaults;
243
256
  /**
244
257
  * Whether the follow-up composer offers the model picker. `false`
245
258
  * hides the picker while keeping the SDK's model sourcing (persisted
@@ -295,14 +308,14 @@ export interface SessionViewerProps {
295
308
  */
296
309
  readonly headerActions?: ReactNode;
297
310
  /**
298
- * Whether the viewer renders its built-in whole-conversation export
299
- * control ({@link TranscriptExportMenu}) in the header corner
300
- * (stigmer/stigmer#814). Default ON a deliberate DD-011 divergence,
301
- * owner-ratified: wherever a conversation can be viewed its transcript
302
- * should be one click away, the control is an additive header button (no
303
- * rendering-strategy or DOM-structure change to existing content), and
304
- * the export carries only what the current caller can already read.
305
- * Hosts that own their export surface (or inert demo scenes) opt out.
311
+ * Whether the viewer offers its built-in whole-conversation export
312
+ * (stigmer/stigmer#814). Placement is panel-aware: viewers with the
313
+ * session panel export from the Config facet's Transcript section, and
314
+ * panel-less viewers (guests, `panel="none"`) render the header
315
+ * {@link TranscriptExportMenu} instead so wherever a conversation can
316
+ * be viewed, its transcript stays reachable. The export carries only
317
+ * what the current caller can already read (`can_view`-scoped). Hosts
318
+ * that own their export surface (or inert demo scenes) opt out.
306
319
  *
307
320
  * @default true
308
321
  */
@@ -382,6 +395,7 @@ export function SessionViewer({
382
395
  getRuntimeEnv,
383
396
  audience = "integrator",
384
397
  runConfig,
398
+ accountDefaults,
385
399
  showModelSelector = true,
386
400
  panel: panelMode = "auto",
387
401
  defaultPanelOpen,
@@ -400,6 +414,7 @@ export function SessionViewer({
400
414
  getRuntimeEnv,
401
415
  audience,
402
416
  runConfig,
417
+ accountDefaults,
403
418
  });
404
419
  const { conv } = flow;
405
420
  const isGuest = audience === "guest";
@@ -661,15 +676,18 @@ export function SessionViewer({
661
676
  return (
662
677
  <SessionViewerLayout
663
678
  className={className}
664
- // Top-right controls: host actions + the built-in transcript export +
665
- // the panel chip. The chip is the panel's always-mounted toggle; while
666
- // collapsed it carries only the pending-item count. Execution status
667
- // is never surfaced as header chrome — the thread itself communicates
668
- // run state. A viewer without a panel (guests — session configuration
669
- // is not a visitor's business — or a host's panel="none") has no
670
- // toggle: it is simply absent.
679
+ // Top-right controls: host actions + the panel chip. The chip is the
680
+ // panel's always-mounted toggle; while collapsed it carries only the
681
+ // pending-item count. Execution status is never surfaced as header
682
+ // chrome — the thread itself communicates run state. A viewer without
683
+ // a panel (guests — session configuration is not a visitor's business —
684
+ // or a host's panel="none") has no toggle: it is simply absent.
685
+ //
686
+ // Transcript export lives in the panel's Config facet; the header menu
687
+ // renders ONLY for panel-less viewers, so no audience loses export
688
+ // (guests and panel="none" hosts have no facet to reach it through).
671
689
  headerActions={
672
- transcriptExport ? (
690
+ transcriptExport && !panelEnabled ? (
673
691
  <>
674
692
  {headerActions}
675
693
  <TranscriptExportMenu sessionId={sessionId} />
@@ -745,6 +763,10 @@ export function SessionViewer({
745
763
  workspaceFileReader={workspaceFileReader}
746
764
  workspaceContentSearcher={workspaceContentSearcher}
747
765
  isCurated={isCurated}
766
+ isObserver={isObserver}
767
+ // Drives the Config facet's Transcript section only — a host
768
+ // that opted out of export gets no facet section either.
769
+ exportSessionId={transcriptExport ? sessionId : null}
748
770
  />
749
771
  ) : null
750
772
  }
@@ -849,20 +871,6 @@ const ConversationColumn = memo(function ConversationColumn({
849
871
  void conv.stop();
850
872
  }, [conv.stop]);
851
873
 
852
- // Always-visible auto-approve toggle (#816), replacing the old armed-only
853
- // indicator banner: the walk-away user needs a way ON before any gate
854
- // exists, not just a way off after one. Guests never get it (they don't
855
- // inherit the host default and this is the operator's consent surface);
856
- // observers have no composer at all. Memoized per DD-010 — the composer
857
- // is memo'd and an inline object would defeat it.
858
- const autoApprove = useMemo<ComposerAutoApproveProps | undefined>(
859
- () =>
860
- isGuest
861
- ? undefined
862
- : { armed: flow.autoApproveAll, onChange: flow.setAutoApproveAll },
863
- [isGuest, flow.autoApproveAll, flow.setAutoApproveAll],
864
- );
865
-
866
874
  // Edit-and-resubmit: stop the in-flight turn, pre-fill the composer with
867
875
  // the original text, and remember which execution is being edited. The
868
876
  // append-only execution log is never rewritten — submitting while editing
@@ -987,6 +995,13 @@ const ConversationColumn = memo(function ConversationColumn({
987
995
  />
988
996
  )}
989
997
  {!isObserver && sendError && <SendErrorBanner error={sendError} />}
998
+ {/* Armed-only disclosure (#816 rework): auto-approve's way ON lives in
999
+ the Config facet and the account preference — this strip is the
1000
+ always-visible proof it is on, plus the one-click way off. Never
1001
+ shown to observers (they cannot arm or disarm anything). */}
1002
+ {!isObserver && flow.autoApproveAll && (
1003
+ <AutoApproveIndicator onTurnOff={() => flow.setAutoApproveAll(false)} />
1004
+ )}
990
1005
  {/* Pending file reviews dock here — pinned above the composer so the
991
1006
  decision the agent is blocked on can never scroll out of view. The
992
1007
  thread renders only observational rows (badges) and read-only
@@ -1028,7 +1043,6 @@ const ConversationColumn = memo(function ConversationColumn({
1028
1043
  onInteractionModeChange={setInteractionMode}
1029
1044
  showInteractionModePicker={!isGuest}
1030
1045
  showModelSelector={modelSelectorVisible}
1031
- autoApprove={autoApprove}
1032
1046
  enableAttachments={!isGuest}
1033
1047
  workspace={isGuest ? undefined : flow.workspace}
1034
1048
  gitHubConnection={isGuest ? undefined : gitHubConnection}
@@ -1105,6 +1119,17 @@ interface SessionPanelRegionProps {
1105
1119
  readonly workspaceContentSearcher?: WorkspaceContentSearcher;
1106
1120
  /** Curated audience (endUser or guest): the Setup facet renders read-only. */
1107
1121
  readonly isCurated: boolean;
1122
+ /**
1123
+ * Observer audience (incl. channel-origin sessions): read-only viewers who
1124
+ * must never receive the Config facet's auto-approve switch — the same
1125
+ * withhold as approval submission. (Guests never render the panel at all.)
1126
+ */
1127
+ readonly isObserver: boolean;
1128
+ /**
1129
+ * Session id for the Config facet's Transcript export section, or `null`
1130
+ * when the host opted out of export (`transcriptExport={false}`).
1131
+ */
1132
+ readonly exportSessionId: string | null;
1108
1133
  }
1109
1134
 
1110
1135
  function SessionPanelRegion({
@@ -1128,6 +1153,8 @@ function SessionPanelRegion({
1128
1153
  workspaceFileReader,
1129
1154
  workspaceContentSearcher,
1130
1155
  isCurated,
1156
+ isObserver,
1157
+ exportSessionId,
1131
1158
  }: SessionPanelRegionProps) {
1132
1159
  const { editors, activeKey, activeFile, reveal } = useWorkspaceEditors(
1133
1160
  panel.editorsStore,
@@ -1295,6 +1322,16 @@ function SessionPanelRegion({
1295
1322
  harness: flow.harness,
1296
1323
  executionTarget: flow.executionTarget,
1297
1324
  modelId: flow.model[0],
1325
+ // Arming auto-approve is an operator decision — withheld from
1326
+ // observers with the same gate as approval submission (guests never
1327
+ // render the panel). The switch stays interactive mid-run: flipping
1328
+ // it ON releases the in-flight run's gates via the flow's standing
1329
+ // responder (#816).
1330
+ autoApprove: isObserver
1331
+ ? undefined
1332
+ : { armed: flow.autoApproveAll, onChange: flow.setAutoApproveAll },
1333
+ // Transcript export is view-scoped, so observers keep it (#814).
1334
+ sessionId: exportSessionId,
1298
1335
  // Curated audiences see the configuration but cannot strip it — the
1299
1336
  // Config facet renders read-only without mutation callbacks (DD-011).
1300
1337
  mutations: isCurated
@@ -1309,6 +1346,7 @@ function SessionPanelRegion({
1309
1346
  [
1310
1347
  flow.agentRef, flow.isDefaultAgent, flow.mcpServerUsages, flow.skillRefs,
1311
1348
  flow.sessionVariables, flow.harness, flow.executionTarget, flow.model,
1349
+ flow.autoApproveAll, flow.setAutoApproveAll, isObserver, exportSessionId,
1312
1350
  isCurated, handleRemoveAgent, handleRemoveMcp, handleRemoveSkill,
1313
1351
  accessSlot,
1314
1352
  ],