@stigmer/react 3.12.7 → 3.12.9

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 (196) hide show
  1. package/README.md +1 -3
  2. package/composer/ComposerToolbar.d.ts +1 -19
  3. package/composer/ComposerToolbar.d.ts.map +1 -1
  4. package/composer/ComposerToolbar.js +2 -7
  5. package/composer/ComposerToolbar.js.map +1 -1
  6. package/composer/SessionComposer.d.ts +0 -10
  7. package/composer/SessionComposer.d.ts.map +1 -1
  8. package/composer/SessionComposer.js +2 -2
  9. package/composer/SessionComposer.js.map +1 -1
  10. package/composer/index.d.ts +0 -1
  11. package/composer/index.d.ts.map +1 -1
  12. package/composer/index.js.map +1 -1
  13. package/conversation/ConversationTimelineView.d.ts +15 -1
  14. package/conversation/ConversationTimelineView.d.ts.map +1 -1
  15. package/conversation/ConversationTimelineView.js +3 -2
  16. package/conversation/ConversationTimelineView.js.map +1 -1
  17. package/conversation/ConversationsWorkbench.d.ts +14 -1
  18. package/conversation/ConversationsWorkbench.d.ts.map +1 -1
  19. package/conversation/ConversationsWorkbench.js +11 -2
  20. package/conversation/ConversationsWorkbench.js.map +1 -1
  21. package/execution/MessageThread.d.ts +33 -2
  22. package/execution/MessageThread.d.ts.map +1 -1
  23. package/execution/MessageThread.js +44 -6
  24. package/execution/MessageThread.js.map +1 -1
  25. package/execution/RecalledMemoriesCard.d.ts +47 -0
  26. package/execution/RecalledMemoriesCard.d.ts.map +1 -0
  27. package/execution/RecalledMemoriesCard.js +58 -0
  28. package/execution/RecalledMemoriesCard.js.map +1 -0
  29. package/execution/ResultView.d.ts.map +1 -1
  30. package/execution/ResultView.js +6 -0
  31. package/execution/ResultView.js.map +1 -1
  32. package/execution/ToolCallItem.d.ts.map +1 -1
  33. package/execution/ToolCallItem.js +13 -2
  34. package/execution/ToolCallItem.js.map +1 -1
  35. package/execution/index.d.ts +2 -0
  36. package/execution/index.d.ts.map +1 -1
  37. package/execution/index.js +1 -0
  38. package/execution/index.js.map +1 -1
  39. package/execution/tool-categories.d.ts +1 -1
  40. package/execution/tool-categories.d.ts.map +1 -1
  41. package/execution/tool-categories.js +6 -0
  42. package/execution/tool-categories.js.map +1 -1
  43. package/identity-account/AccountPreferencesPanel.d.ts +2 -1
  44. package/identity-account/AccountPreferencesPanel.d.ts.map +1 -1
  45. package/identity-account/AccountPreferencesPanel.js +41 -3
  46. package/identity-account/AccountPreferencesPanel.js.map +1 -1
  47. package/identity-account/useAccountExecutionDefaults.d.ts +7 -0
  48. package/identity-account/useAccountExecutionDefaults.d.ts.map +1 -1
  49. package/identity-account/useAccountExecutionDefaults.js +5 -2
  50. package/identity-account/useAccountExecutionDefaults.js.map +1 -1
  51. package/index.d.ts +6 -6
  52. package/index.d.ts.map +1 -1
  53. package/index.js +2 -2
  54. package/index.js.map +1 -1
  55. package/internal/MemoryEnabledRow.d.ts +37 -0
  56. package/internal/MemoryEnabledRow.d.ts.map +1 -0
  57. package/internal/MemoryEnabledRow.js +25 -0
  58. package/internal/MemoryEnabledRow.js.map +1 -0
  59. package/internal/VirtualizedThread.d.ts +3 -1
  60. package/internal/VirtualizedThread.d.ts.map +1 -1
  61. package/internal/VirtualizedThread.js +3 -1
  62. package/internal/VirtualizedThread.js.map +1 -1
  63. package/internal/useAutoScroll.d.ts +18 -0
  64. package/internal/useAutoScroll.d.ts.map +1 -1
  65. package/internal/useAutoScroll.js +50 -0
  66. package/internal/useAutoScroll.js.map +1 -1
  67. package/memory/MemoryProposalCard.d.ts +35 -0
  68. package/memory/MemoryProposalCard.d.ts.map +1 -0
  69. package/memory/MemoryProposalCard.js +73 -0
  70. package/memory/MemoryProposalCard.js.map +1 -0
  71. package/memory/index.d.ts +2 -0
  72. package/memory/index.d.ts.map +1 -1
  73. package/memory/index.js +2 -0
  74. package/memory/index.js.map +1 -1
  75. package/memory/useMemory.d.ts +39 -0
  76. package/memory/useMemory.d.ts.map +1 -0
  77. package/memory/useMemory.js +43 -0
  78. package/memory/useMemory.js.map +1 -0
  79. package/organization/OrgPreferencesPanel.d.ts.map +1 -1
  80. package/organization/OrgPreferencesPanel.js +36 -4
  81. package/organization/OrgPreferencesPanel.js.map +1 -1
  82. package/package.json +4 -4
  83. package/session/AutoApproveIndicator.d.ts +18 -0
  84. package/session/AutoApproveIndicator.d.ts.map +1 -0
  85. package/session/AutoApproveIndicator.js +23 -0
  86. package/session/AutoApproveIndicator.js.map +1 -0
  87. package/session/NewSessionViewer.d.ts.map +1 -1
  88. package/session/NewSessionViewer.js +10 -9
  89. package/session/NewSessionViewer.js.map +1 -1
  90. package/session/SessionViewer.d.ts +21 -9
  91. package/session/SessionViewer.d.ts.map +1 -1
  92. package/session/SessionViewer.js +31 -21
  93. package/session/SessionViewer.js.map +1 -1
  94. package/session/facets/SetupTab.d.ts +43 -6
  95. package/session/facets/SetupTab.d.ts.map +1 -1
  96. package/session/facets/SetupTab.js +32 -36
  97. package/session/facets/SetupTab.js.map +1 -1
  98. package/session/facets/UsageTab.d.ts +2 -1
  99. package/session/facets/UsageTab.d.ts.map +1 -1
  100. package/session/facets/UsageTab.js +11 -5
  101. package/session/facets/UsageTab.js.map +1 -1
  102. package/session/facets/index.d.ts +1 -1
  103. package/session/facets/index.d.ts.map +1 -1
  104. package/session/facets/primitives.d.ts +58 -0
  105. package/session/facets/primitives.d.ts.map +1 -0
  106. package/session/facets/primitives.js +59 -0
  107. package/session/facets/primitives.js.map +1 -0
  108. package/session/index.d.ts +1 -1
  109. package/session/index.d.ts.map +1 -1
  110. package/session/index.js.map +1 -1
  111. package/session/useNewSessionFlow.d.ts +9 -4
  112. package/session/useNewSessionFlow.d.ts.map +1 -1
  113. package/session/useNewSessionFlow.js +20 -9
  114. package/session/useNewSessionFlow.js.map +1 -1
  115. package/session/useSessionPageFlow.d.ts +28 -10
  116. package/session/useSessionPageFlow.d.ts.map +1 -1
  117. package/session/useSessionPageFlow.js +16 -10
  118. package/session/useSessionPageFlow.js.map +1 -1
  119. package/settings/MemorySection.d.ts.map +1 -1
  120. package/settings/MemorySection.js +1 -1
  121. package/settings/MemorySection.js.map +1 -1
  122. package/src/attachment/__tests__/prepare-image.test.ts +12 -6
  123. package/src/composer/ComposerToolbar.tsx +0 -57
  124. package/src/composer/SessionComposer.tsx +1 -13
  125. package/src/composer/index.ts +0 -1
  126. package/src/conversation/ConversationTimelineView.tsx +17 -1
  127. package/src/conversation/ConversationsWorkbench.tsx +25 -0
  128. package/src/conversation/__tests__/ConversationTimelineView.layout.test.tsx +37 -1
  129. package/src/execution/MessageThread.tsx +75 -3
  130. package/src/execution/RecalledMemoriesCard.tsx +164 -0
  131. package/src/execution/ResultView.tsx +6 -0
  132. package/src/execution/ToolCallItem.tsx +18 -1
  133. package/src/execution/__tests__/RecalledMemoriesCard.test.tsx +123 -0
  134. package/src/execution/__tests__/message-thread-recalled-memories.test.tsx +216 -0
  135. package/src/execution/__tests__/message-thread-scroll-on-send.test.tsx +141 -0
  136. package/src/execution/__tests__/message-thread-slots.test.tsx +36 -0
  137. package/src/execution/index.ts +2 -0
  138. package/src/execution/tool-categories.ts +7 -0
  139. package/src/identity-account/AccountPreferencesPanel.tsx +89 -2
  140. package/src/identity-account/__tests__/AccountPreferencesPanel.test.tsx +112 -0
  141. package/src/identity-account/__tests__/useAccountExecutionDefaults.test.tsx +34 -1
  142. package/src/identity-account/useAccountExecutionDefaults.ts +12 -2
  143. package/src/index.ts +8 -1
  144. package/src/internal/MemoryEnabledRow.tsx +92 -0
  145. package/src/internal/VirtualizedThread.tsx +5 -0
  146. package/src/internal/__tests__/useAutoScroll.layout.test.tsx +105 -4
  147. package/src/internal/__tests__/useAutoScroll.test.tsx +41 -1
  148. package/src/internal/useAutoScroll.ts +54 -0
  149. package/src/memory/MemoryProposalCard.tsx +181 -0
  150. package/src/memory/__tests__/MemoryProposalCard.test.tsx +236 -0
  151. package/src/memory/__tests__/memoryHooks.test.tsx +63 -0
  152. package/src/memory/index.ts +5 -0
  153. package/src/memory/useMemory.ts +80 -0
  154. package/src/organization/OrgPreferencesPanel.tsx +52 -3
  155. package/src/organization/__tests__/OrgPreferencesPanel.test.tsx +65 -0
  156. package/src/session/AutoApproveIndicator.tsx +45 -0
  157. package/src/session/NewSessionViewer.tsx +18 -15
  158. package/src/session/SessionViewer.tsx +70 -32
  159. package/src/session/__tests__/NewSessionViewer.test.tsx +32 -0
  160. package/src/session/__tests__/SessionViewer-autoApprove.test.tsx +244 -0
  161. package/src/session/__tests__/SessionViewer-transcriptExport.test.tsx +60 -22
  162. package/src/session/__tests__/useNewSessionFlow.test.tsx +56 -0
  163. package/src/session/__tests__/useSessionPageFlow.autoApprove.test.tsx +87 -0
  164. package/src/session/facets/SetupTab.tsx +193 -157
  165. package/src/session/facets/UsageTab.tsx +38 -38
  166. package/src/session/facets/index.ts +1 -0
  167. package/src/session/facets/primitives.tsx +169 -0
  168. package/src/session/index.ts +1 -0
  169. package/src/session/useNewSessionFlow.ts +30 -15
  170. package/src/session/useSessionPageFlow.ts +44 -20
  171. package/src/settings/MemorySection.tsx +4 -2
  172. package/src/switch/Switch.tsx +4 -0
  173. package/src/workflow/__tests__/WorkflowExecutionViewer.approvals.test.tsx +26 -7
  174. package/src/workflow/__tests__/task-presentation.test.ts +49 -12
  175. package/src/workflow/__tests__/useWorkflowCanvas.dirty.test.tsx +7 -2
  176. package/src/workflow/__tests__/useWorkflowCanvas.drag.test.tsx +7 -2
  177. package/src/workflow/__tests__/workflow-thread-scroll-on-send.test.tsx +145 -0
  178. package/src/workflow/thread/WorkflowTaskThread.tsx +59 -32
  179. package/src/workflow/thread/task-presentation.ts +52 -7
  180. package/src/workspace/WorkspaceSurface.tsx +26 -3
  181. package/styles.css +1 -1
  182. package/switch/Switch.d.ts +3 -1
  183. package/switch/Switch.d.ts.map +1 -1
  184. package/switch/Switch.js +2 -2
  185. package/switch/Switch.js.map +1 -1
  186. package/workflow/thread/WorkflowTaskThread.d.ts +13 -0
  187. package/workflow/thread/WorkflowTaskThread.d.ts.map +1 -1
  188. package/workflow/thread/WorkflowTaskThread.js +33 -25
  189. package/workflow/thread/WorkflowTaskThread.js.map +1 -1
  190. package/workflow/thread/task-presentation.d.ts.map +1 -1
  191. package/workflow/thread/task-presentation.js +50 -7
  192. package/workflow/thread/task-presentation.js.map +1 -1
  193. package/workspace/WorkspaceSurface.js +11 -2
  194. package/workspace/WorkspaceSurface.js.map +1 -1
  195. package/src/composer/__tests__/ComposerToolbar-autoApprove.test.tsx +0 -80
  196. package/src/session/__tests__/SessionViewer-autoApproveToggle.test.tsx +0 -178
@@ -0,0 +1,236 @@
1
+ // The in-session consent surface (DD-005 D4): verbatim fact, one-click
2
+ // Confirm/Reject, and lifecycle honesty — the frozen tool result never
3
+ // shows stale action buttons over a record that was already decided,
4
+ // deleted, or unreachable.
5
+
6
+ import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
7
+ import { cleanup, render, screen, waitFor } from "@testing-library/react";
8
+ import userEvent from "@testing-library/user-event";
9
+ import type { ReactNode } from "react";
10
+ import { create } from "@bufbuild/protobuf";
11
+ import { MemorySchema, type Memory } from "@stigmer/protos/ai/stigmer/agentic/memory/v1/api_pb";
12
+ import { MemoryLifecycleState } from "@stigmer/protos/ai/stigmer/agentic/memory/v1/enum_pb";
13
+ import { StigmerError } from "@stigmer/sdk";
14
+ import { StigmerContext } from "../../context";
15
+ import { FetchCacheContext } from "../../internal/FetchCacheProvider";
16
+ import { MemoryProposalCardBody } from "../MemoryProposalCard";
17
+
18
+ function wrapper(client: unknown) {
19
+ return function Wrapper({ children }: { children: ReactNode }) {
20
+ return (
21
+ <FetchCacheContext.Provider value={null}>
22
+ <StigmerContext.Provider value={client as never}>
23
+ {children}
24
+ </StigmerContext.Provider>
25
+ </FetchCacheContext.Provider>
26
+ );
27
+ };
28
+ }
29
+
30
+ const FACT = "Prefers concise answers with code examples.";
31
+
32
+ function makeMemory(state: MemoryLifecycleState, content = FACT): Memory {
33
+ return create(MemorySchema, {
34
+ apiVersion: "agentic.stigmer.ai/v1",
35
+ kind: "Memory",
36
+ metadata: { id: "mem_1", name: "mem_1", slug: "mem-1", org: "test-org" },
37
+ spec: {
38
+ content,
39
+ provenance: { agentId: "agt_1", sessionId: "ses_1" },
40
+ },
41
+ status: { lifecycleState: state },
42
+ });
43
+ }
44
+
45
+ function renderCard(client: unknown) {
46
+ return render(<MemoryProposalCardBody memoryId="mem_1" fact={FACT} />, {
47
+ wrapper: wrapper(client),
48
+ });
49
+ }
50
+
51
+ /** Waits for the record fetch to settle and the named action to arm. */
52
+ async function waitForAction(name: string): Promise<HTMLElement> {
53
+ let button: HTMLElement | null = null;
54
+ await waitFor(() => {
55
+ button = screen.getByRole("button", { name });
56
+ expect(button.hasAttribute("disabled")).toBe(false);
57
+ });
58
+ return button!;
59
+ }
60
+
61
+ beforeEach(() => vi.clearAllMocks());
62
+ afterEach(cleanup);
63
+
64
+ describe("MemoryProposalCardBody", () => {
65
+ it("renders the verbatim fact with Confirm/Reject while the record is proposed", async () => {
66
+ const client = {
67
+ memory: {
68
+ get: vi.fn().mockResolvedValue(
69
+ makeMemory(MemoryLifecycleState.lifecycle_state_proposed),
70
+ ),
71
+ },
72
+ };
73
+
74
+ renderCard(client);
75
+
76
+ // The exact stored text — never paraphrased (DD-005 D6).
77
+ expect(screen.getByText(FACT)).toBeTruthy();
78
+ await waitForAction(`Confirm memory: ${FACT}`);
79
+ expect(screen.getByRole("button", { name: `Reject memory: ${FACT}` })).toBeTruthy();
80
+ // Provenance renders beside the fact (DD-005 D6: origin builds trust).
81
+ expect(screen.getByText(/Proposed by agent agt_1/)).toBeTruthy();
82
+ });
83
+
84
+ it("confirms in one click and settles into the confirmed state line", async () => {
85
+ const confirm = vi
86
+ .fn()
87
+ .mockResolvedValue(makeMemory(MemoryLifecycleState.lifecycle_state_confirmed));
88
+ const client = {
89
+ memory: {
90
+ get: vi.fn().mockResolvedValue(
91
+ makeMemory(MemoryLifecycleState.lifecycle_state_proposed),
92
+ ),
93
+ confirm,
94
+ },
95
+ };
96
+
97
+ renderCard(client);
98
+ const user = userEvent.setup();
99
+ const confirmButton = await waitForAction(`Confirm memory: ${FACT}`);
100
+
101
+ await user.click(confirmButton);
102
+
103
+ expect(confirm).toHaveBeenCalledWith("mem_1");
104
+ await waitFor(() =>
105
+ expect(
106
+ screen.getByText("Confirmed — recalled in your future sessions."),
107
+ ).toBeTruthy(),
108
+ );
109
+ expect(screen.queryByRole("button")).toBeNull();
110
+ });
111
+
112
+ it("rejects in ONE click — no confirmation dialog (the T04 Cursor lesson)", async () => {
113
+ const reject = vi
114
+ .fn()
115
+ .mockResolvedValue(makeMemory(MemoryLifecycleState.lifecycle_state_rejected));
116
+ const client = {
117
+ memory: {
118
+ get: vi.fn().mockResolvedValue(
119
+ makeMemory(MemoryLifecycleState.lifecycle_state_proposed),
120
+ ),
121
+ reject,
122
+ },
123
+ };
124
+
125
+ renderCard(client);
126
+ const user = userEvent.setup();
127
+ const rejectButton = await waitForAction(`Reject memory: ${FACT}`);
128
+
129
+ await user.click(rejectButton);
130
+
131
+ expect(reject).toHaveBeenCalledWith("mem_1");
132
+ await waitFor(() =>
133
+ expect(screen.getByText("Rejected — not stored.")).toBeTruthy(),
134
+ );
135
+ });
136
+
137
+ it("shows the decided state instead of stale actions when another surface decided it", async () => {
138
+ // The tool result is frozen; the record was confirmed from the
139
+ // memory page (or another device) — the reloaded chip must say so.
140
+ const client = {
141
+ memory: {
142
+ get: vi.fn().mockResolvedValue(
143
+ makeMemory(MemoryLifecycleState.lifecycle_state_confirmed),
144
+ ),
145
+ },
146
+ };
147
+
148
+ renderCard(client);
149
+
150
+ await waitFor(() =>
151
+ expect(
152
+ screen.getByText("Confirmed — recalled in your future sessions."),
153
+ ).toBeTruthy(),
154
+ );
155
+ expect(screen.queryByRole("button")).toBeNull();
156
+ });
157
+
158
+ it("renders the live record's text when the fact was edited before deciding", async () => {
159
+ const edited = "Prefers concise answers, with Go examples.";
160
+ const client = {
161
+ memory: {
162
+ get: vi.fn().mockResolvedValue(
163
+ makeMemory(MemoryLifecycleState.lifecycle_state_proposed, edited),
164
+ ),
165
+ },
166
+ };
167
+
168
+ renderCard(client);
169
+
170
+ await waitFor(() => expect(screen.getByText(edited)).toBeTruthy());
171
+ expect(screen.queryByText(FACT)).toBeNull();
172
+ });
173
+
174
+ it("reads 'no longer stored' for a deleted record — a state, never an error", async () => {
175
+ const client = {
176
+ memory: {
177
+ get: vi
178
+ .fn()
179
+ .mockRejectedValue(new StigmerError("not-found", "memory not found", 5)),
180
+ },
181
+ };
182
+
183
+ renderCard(client);
184
+
185
+ await waitFor(() =>
186
+ expect(screen.getByText("No longer stored.")).toBeTruthy(),
187
+ );
188
+ expect(screen.queryByRole("button")).toBeNull();
189
+ expect(screen.queryByRole("alert")).toBeNull();
190
+ });
191
+
192
+ it("keeps the frozen fact visible when the state fetch fails, pointing at the memory page", async () => {
193
+ const client = {
194
+ memory: {
195
+ get: vi
196
+ .fn()
197
+ .mockRejectedValue(new StigmerError("unavailable", "server down", 14)),
198
+ },
199
+ };
200
+
201
+ renderCard(client);
202
+
203
+ await waitFor(() =>
204
+ expect(screen.getByText(/review it in Settings → Memory/)).toBeTruthy(),
205
+ );
206
+ // The fact is frozen in the tool result — it never disappears.
207
+ expect(screen.getByText(FACT)).toBeTruthy();
208
+ expect(screen.queryByRole("button")).toBeNull();
209
+ });
210
+
211
+ it("surfaces a failed decision inline and keeps the actions available", async () => {
212
+ const client = {
213
+ memory: {
214
+ get: vi.fn().mockResolvedValue(
215
+ makeMemory(MemoryLifecycleState.lifecycle_state_proposed),
216
+ ),
217
+ confirm: vi
218
+ .fn()
219
+ .mockRejectedValue(new StigmerError("unavailable", "server down", 14)),
220
+ },
221
+ };
222
+
223
+ renderCard(client);
224
+ const user = userEvent.setup();
225
+ const confirmButton = await waitForAction(`Confirm memory: ${FACT}`);
226
+
227
+ await user.click(confirmButton);
228
+
229
+ await waitFor(() => expect(screen.getByRole("alert")).toBeTruthy());
230
+ expect(
231
+ screen
232
+ .getByRole("button", { name: `Confirm memory: ${FACT}` })
233
+ .hasAttribute("disabled"),
234
+ ).toBe(false);
235
+ });
236
+ });
@@ -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; once a member has many memories, each conversation recalls their most relevant ones, shown on the execution. 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
+ }