@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
package/src/index.ts CHANGED
@@ -234,6 +234,7 @@ export type {
234
234
  SessionRunConfig,
235
235
  SetupTabProps,
236
236
  SetupTabMutationCallbacks,
237
+ SetupTabAutoApprove,
237
238
  } from "./session/index.js";
238
239
 
239
240
  // Activity — unified recent activity (sessions + workflow executions)
@@ -285,6 +286,8 @@ export {
285
286
  ContextGauge,
286
287
  SummarizationBadge,
287
288
  SummarizationCard,
289
+ RecalledMemoriesCard,
290
+ resolveInjectedFacts,
288
291
  PlanCompletionCard,
289
292
  PlanArtifactCard,
290
293
  PlanStreamingCard,
@@ -411,6 +414,7 @@ export type {
411
414
  ContextGaugeProps,
412
415
  SummarizationBadgeProps,
413
416
  SummarizationCardProps,
417
+ RecalledMemoriesCardProps,
414
418
  PlanCompletionCardProps,
415
419
  PlanArtifactCardProps,
416
420
  PlanStreamingCardProps,
@@ -551,7 +555,6 @@ export type {
551
555
  SessionComposerHandle,
552
556
  SessionComposerProps,
553
557
  SessionComposerSubmitContext,
554
- ComposerAutoApproveProps,
555
558
  InteractionModePickerProps,
556
559
  InteractionModeOption,
557
560
  } from "./composer/index.js";
@@ -866,6 +869,7 @@ export type {
866
869
  // hooks (confirm/reject/delete/edit), grouping helpers, and the list panel
867
870
  export {
868
871
  useMemories,
872
+ useMemory,
869
873
  useConfirmMemory,
870
874
  useRejectMemory,
871
875
  useDeleteMemory,
@@ -873,15 +877,18 @@ export {
873
877
  groupMemoriesByLifecycle,
874
878
  formatMemoryProvenance,
875
879
  MemoryListPanel,
880
+ MemoryProposalCardBody,
876
881
  } from "./memory/index.js";
877
882
  export type {
878
883
  UseMemoriesReturn,
884
+ UseMemoryReturn,
879
885
  UseConfirmMemoryReturn,
880
886
  UseRejectMemoryReturn,
881
887
  UseDeleteMemoryReturn,
882
888
  UseUpdateMemoryContentReturn,
883
889
  MemoryGroups,
884
890
  MemoryListPanelProps,
891
+ MemoryProposalCardBodyProps,
885
892
  } from "./memory/index.js";
886
893
 
887
894
  // IAM Policy — data hooks, behavior hooks, headless hook, and styled components for access management
@@ -0,0 +1,92 @@
1
+ "use client";
2
+
3
+ import { cn } from "@stigmer/theme";
4
+ import { getUserMessage } from "@stigmer/sdk";
5
+ import { Switch } from "../switch/Switch.js";
6
+ import { SpinnerIcon } from "./SpinnerIcon.js";
7
+
8
+ /** Props for {@link MemoryEnabledRow}. */
9
+ export interface MemoryEnabledRowProps {
10
+ /** Id for the switch control (aria-labelledby wiring). */
11
+ readonly id: string;
12
+ /** The stored flag value (server state, or an optimistic pending value). */
13
+ readonly checked: boolean;
14
+ /** Fired with the next value when the user flips the switch. */
15
+ readonly onToggle: (next: boolean) => void;
16
+ /** `true` while the flip is being saved (switch disabled, spinner shown). */
17
+ readonly saving: boolean;
18
+ /** `true` when the caller may not edit the flag (read-only rendering). */
19
+ readonly readOnly?: boolean;
20
+ /** Error from the last failed flip, or `null`. */
21
+ readonly error: Error | null;
22
+ /**
23
+ * The scope-specific helper copy (DD-006 D6's transparency statement
24
+ * belongs here). Rendered under the title, wired as the switch's
25
+ * accessible description.
26
+ */
27
+ readonly helperText: string;
28
+ }
29
+
30
+ /**
31
+ * The `memory_enabled` consent toggle — one row, shared verbatim by
32
+ * {@link OrgPreferencesPanel} and {@link AccountPreferencesPanel} so the
33
+ * two scopes of the double opt-in (DD-006 D1) present identically.
34
+ *
35
+ * INSTANT-APPLY by deliberate exception (UX checkpoint, owner-approved
36
+ * 2026-08-22): both host panels are save-button forms, but a consent bit
37
+ * flipped-and-unsaved that silently reverts on navigation is exactly the
38
+ * failure consent UX must not have — so this row saves the moment it is
39
+ * flipped, matching the Switch primitive's own contract ("instant binary
40
+ * changes") and every peer product's memory toggle. The helper copy
41
+ * states it. Hosts wire `onToggle` to their own full-spec-replace update
42
+ * and pass the in-flight/error state back down.
43
+ */
44
+ export function MemoryEnabledRow({
45
+ id,
46
+ checked,
47
+ onToggle,
48
+ saving,
49
+ readOnly = false,
50
+ error,
51
+ helperText,
52
+ }: MemoryEnabledRowProps) {
53
+ const titleId = `${id}-title`;
54
+ const descriptionId = `${id}-description`;
55
+
56
+ return (
57
+ <div className="stg:space-y-1">
58
+ <div className="stg:flex stg:items-start stg:justify-between stg:gap-3">
59
+ <div className="stg:min-w-0 stg:flex-1">
60
+ <span
61
+ id={titleId}
62
+ className="stg:block stg:text-xs stg:font-medium stg:text-foreground"
63
+ >
64
+ Memory
65
+ </span>
66
+ <p
67
+ id={descriptionId}
68
+ className="stg:text-[0.65rem] stg:leading-snug stg:text-muted-foreground"
69
+ >
70
+ {helperText}
71
+ </p>
72
+ </div>
73
+ <span className="stg:flex stg:shrink-0 stg:items-center stg:gap-1.5">
74
+ {saving && <SpinnerIcon size={12} />}
75
+ <Switch
76
+ id={id}
77
+ checked={checked}
78
+ onCheckedChange={onToggle}
79
+ disabled={saving || readOnly}
80
+ aria-labelledby={titleId}
81
+ aria-describedby={descriptionId}
82
+ />
83
+ </span>
84
+ </div>
85
+ {error && (
86
+ <p className="stg:text-[0.65rem] stg:text-destructive" role="alert">
87
+ {getUserMessage(error)}
88
+ </p>
89
+ )}
90
+ </div>
91
+ );
92
+ }
@@ -25,6 +25,7 @@ import { ApprovalContext, type ApprovalContextValue } from "../execution/Approva
25
25
  import { FileReviewContext, type FileReviewContextValue } from "../execution/FileReviewContext.js";
26
26
  import { DevProfiler, useDomNodeCount } from "./dev/index.js";
27
27
  import { JumpToLatestButton } from "./JumpToLatestButton.js";
28
+ import { usePinToLatestOnSignal } from "./useAutoScroll.js";
28
29
  import { ApprovalPeekBar } from "./ApprovalPeekBar.js";
29
30
  import { ThreadItemWrapper } from "./ThreadItemWrapper.js";
30
31
 
@@ -57,6 +58,8 @@ export interface VirtualizedThreadProps {
57
58
  readonly onRetryExecution?: (message: string) => void;
58
59
  readonly onEditMessage?: (text: string) => void;
59
60
  readonly slots?: MessageThreadSlots;
61
+ /** Scroll-on-send counter from `MessageThread` (see `usePinToLatestOnSignal`). */
62
+ readonly pinToLatestSignal?: number;
60
63
  }
61
64
 
62
65
  // ---------------------------------------------------------------------------
@@ -127,6 +130,7 @@ export function VirtualizedThread({
127
130
  onRetryExecution,
128
131
  onEditMessage,
129
132
  slots,
133
+ pinToLatestSignal,
130
134
  }: VirtualizedThreadProps) {
131
135
  const virtuosoRef = useRef<VirtuosoHandle>(null);
132
136
  const scrollerRef = useRef<HTMLDivElement | null>(null);
@@ -149,6 +153,7 @@ export function VirtualizedThread({
149
153
  behavior: "smooth",
150
154
  });
151
155
  }, []);
156
+ usePinToLatestOnSignal(pinToLatestSignal, jumpToLatest);
152
157
 
153
158
  const renderProps = useMemo(
154
159
  () => ({
@@ -17,7 +17,11 @@
17
17
 
18
18
  import { describe, it, expect, vi, afterEach } from "vitest";
19
19
  import { render, cleanup } from "@testing-library/react";
20
- import { useAutoScroll, type UseAutoScrollReturn } from "../useAutoScroll";
20
+ import {
21
+ useAutoScroll,
22
+ usePinToLatestOnSignal,
23
+ type UseAutoScrollReturn,
24
+ } from "../useAutoScroll";
21
25
 
22
26
  const VIEWPORT_PX = 240;
23
27
  const ITEM_PX = 48;
@@ -125,9 +129,11 @@ describe("useAutoScroll under real layout (F-09)", () => {
125
129
  scroller().scrollTop = 0;
126
130
  await settled(() => expect(latest.isFollowing).toBe(false));
127
131
 
128
- // Growth while disengaged (an incoming message, or the reader's own
129
- // send arriving via refetch): the view must NOT move under them.
130
- // This is the measured scroll-on-send posture see the F-09 record.
132
+ // Growth while disengaged (an INCOMING message): the view must NOT
133
+ // move under them. Unchanged by scroll-on-send (stigmer-cloud#267):
134
+ // the reader's OWN send now pins via the surface-driven signal
135
+ // (`usePinToLatestOnSignal`, suite below) — the hook's growth pathway
136
+ // itself still never moves a scrolled-up reader.
131
137
  rerender(<AsyncThread items={messages(31)} />);
132
138
  await new Promise((resolve) => setTimeout(resolve, 100));
133
139
  expect(scroller().scrollTop).toBe(0);
@@ -141,3 +147,98 @@ describe("useAutoScroll under real layout (F-09)", () => {
141
147
  });
142
148
  });
143
149
  });
150
+
151
+ // ---------------------------------------------------------------------------
152
+ // Scroll-on-send: the surface-driven pin signal (stigmer-cloud#267)
153
+ // ---------------------------------------------------------------------------
154
+
155
+ /**
156
+ * AsyncThread plus the send-signal seam every thread surface wires — same
157
+ * late-mounting wrapper shape (see the determinism note above: the bottom
158
+ * pin coming from the ResizeObserver pathway is what makes the scroll-up
159
+ * steps race-free).
160
+ */
161
+ function SignalThread({
162
+ items,
163
+ signal,
164
+ }: {
165
+ readonly items: readonly string[];
166
+ readonly signal: number | undefined;
167
+ }) {
168
+ const hook = useAutoScroll();
169
+ latest = hook;
170
+ usePinToLatestOnSignal(signal, hook.jumpToLatest);
171
+ return (
172
+ <div
173
+ ref={hook.scrollRef}
174
+ data-testid="scroller"
175
+ style={{ height: VIEWPORT_PX, overflowY: "auto" }}
176
+ >
177
+ {items.length === 0 ? (
178
+ <div data-testid="skeleton" style={{ height: 100 }} />
179
+ ) : (
180
+ <div ref={hook.contentRef} data-testid="content">
181
+ {items.map((text) => (
182
+ <div key={text} style={{ height: ITEM_PX }}>
183
+ {text}
184
+ </div>
185
+ ))}
186
+ </div>
187
+ )}
188
+ <div ref={hook.sentinelRef} aria-hidden="true" />
189
+ </div>
190
+ );
191
+ }
192
+
193
+ describe("usePinToLatestOnSignal under real layout (stigmer-cloud#267)", () => {
194
+ it("a signal increment pins a scrolled-up reader to the latest content and re-engages follow", async () => {
195
+ const { rerender } = render(<SignalThread items={[]} signal={0} />);
196
+ rerender(<SignalThread items={messages(30)} signal={0} />);
197
+ await settled(() => {
198
+ expect(isPinnedToBottom(scroller())).toBe(true);
199
+ // Follow-STATE quiescence before the reader scrolls (the F-09
200
+ // case's own discipline) — a queued pre-scroll TRUE must not
201
+ // re-arm follow behind the scroll-up.
202
+ expect(latest.isFollowing).toBe(true);
203
+ });
204
+
205
+ // The reader deliberately scrolls up — follow disengages.
206
+ scroller().scrollTop = 0;
207
+ await settled(() => expect(latest.isFollowing).toBe(false));
208
+
209
+ // Their OWN send: the surface increments the signal. The pin fires
210
+ // AND re-engages follow, so the reply that streams in next stays in
211
+ // view — the whole point of stigmer-cloud#267.
212
+ rerender(<SignalThread items={messages(30)} signal={1} />);
213
+ await settled(() => {
214
+ expect(isPinnedToBottom(scroller())).toBe(true);
215
+ expect(latest.isFollowing).toBe(true);
216
+ });
217
+
218
+ // And subsequent growth (the streamed reply) keeps following.
219
+ rerender(<SignalThread items={messages(31)} signal={1} />);
220
+ await settled(() => expect(isPinnedToBottom(scroller())).toBe(true));
221
+ });
222
+
223
+ it("never pins on mount, on an unchanged signal, or on the undefined→number transition (opt-out and prop-appearing are not sends)", async () => {
224
+ // Mount without a signal: no pin beyond the hook's own first-fill.
225
+ const { rerender } = render(<SignalThread items={[]} signal={undefined} />);
226
+ rerender(<SignalThread items={messages(30)} signal={undefined} />);
227
+ await settled(() => {
228
+ expect(isPinnedToBottom(scroller())).toBe(true);
229
+ expect(latest.isFollowing).toBe(true);
230
+ });
231
+
232
+ scroller().scrollTop = 0;
233
+ await settled(() => expect(latest.isFollowing).toBe(false));
234
+
235
+ // undefined → number (the surface flips scrollOnSend back on, or the
236
+ // prop gets wired late): a prop appearing is not a send.
237
+ rerender(<SignalThread items={messages(30)} signal={3} />);
238
+ // Unchanged signal on a later render: also not a send.
239
+ rerender(<SignalThread items={messages(30)} signal={3} />);
240
+ await new Promise((resolve) => setTimeout(resolve, 100));
241
+ expect(scroller().scrollTop).toBe(0);
242
+ expect(latest.isFollowing).toBe(false);
243
+ });
244
+ });
@@ -121,13 +121,20 @@ afterEach(() => {
121
121
  * observation-time geometry and can be delivered after the reader has
122
122
  * scrolled again (the stale-payload race pinned in the browser suite) —
123
123
  * so the entry list itself is deliberately empty here.
124
+ *
125
+ * The far-from-bottom position is 100, not 0, and that is load-bearing:
126
+ * the hook disengages only when the READER moved (scrollTop differs from
127
+ * the hook's own last write — the growth-vs-reader discriminator,
128
+ * stigmer-cloud#267), and in this harness the mount write landed at 0
129
+ * (happy-dom scrollHeight is 0 at mount). A real reader's scroll-up always
130
+ * moves scrollTop off the pin position; 100 simulates exactly that.
124
131
  */
125
132
  function fireIO(nearBottom: boolean) {
126
133
  const scroller = screen.getByTestId("scroller");
127
134
  Object.defineProperty(scroller, "scrollHeight", { value: 1000, configurable: true });
128
135
  Object.defineProperty(scroller, "clientHeight", { value: 200, configurable: true });
129
136
  // Within the 80px near-bottom margin, or far above it.
130
- scroller.scrollTop = nearBottom ? 800 : 0;
137
+ scroller.scrollTop = nearBottom ? 800 : 100;
131
138
  act(() => {
132
139
  ioCallback([], {} as IntersectionObserver);
133
140
  });
@@ -212,6 +219,39 @@ describe("useAutoScroll", () => {
212
219
  expect(latestResult.isFollowing).toBe(true);
213
220
  });
214
221
 
222
+ it("holds follow when a not-visible delivery measures growth under the hook's own pin — only the READER may disengage (stigmer-cloud#267)", () => {
223
+ render(<Harness />);
224
+ const scroller = screen.getByTestId("scroller");
225
+ Object.defineProperty(scroller, "clientHeight", { value: 200, configurable: true });
226
+
227
+ // The hook pins: jumpToLatest writes scrollTop = scrollHeight and
228
+ // records the write.
229
+ Object.defineProperty(scroller, "scrollHeight", { value: 1000, configurable: true });
230
+ act(() => {
231
+ latestResult.jumpToLatest();
232
+ });
233
+ expect(scroller.scrollTop).toBe(1000);
234
+
235
+ // Content grows UNDER the pin before the RO write runs; a queued IO
236
+ // delivery measures the post-growth geometry: far from the bottom,
237
+ // yet scrollTop still sits exactly on the hook's own write — the
238
+ // reader never moved. Disengaging here would make the imminent RO
239
+ // callback drop its write and strand the thread (the deadlock the
240
+ // browser suite reproduced ~1-in-5 before the discriminator).
241
+ Object.defineProperty(scroller, "scrollHeight", { value: 2000, configurable: true });
242
+ act(() => {
243
+ ioCallback([], {} as IntersectionObserver);
244
+ });
245
+ expect(latestResult.isFollowing).toBe(true);
246
+
247
+ // The READER escapes (scrollTop moves off the pin): disengage holds.
248
+ scroller.scrollTop = 500;
249
+ act(() => {
250
+ ioCallback([], {} as IntersectionObserver);
251
+ });
252
+ expect(latestResult.isFollowing).toBe(false);
253
+ });
254
+
215
255
  it("jumpToLatest re-engages follow mode", () => {
216
256
  render(<Harness />);
217
257
  fireIO(false);
@@ -69,6 +69,13 @@ export function useAutoScroll(): UseAutoScrollReturn {
69
69
  const [isFollowing, setIsFollowing] = useState(true);
70
70
  const isFollowingRef = useRef(true);
71
71
  const rafIdRef = useRef(0);
72
+ // The last scrollTop THIS HOOK wrote (mount pin, rAF growth writes,
73
+ // jumpToLatest). The reader-vs-growth discriminator for the observer
74
+ // callbacks: content growth never moves scrollTop — only the reader
75
+ // does — so "not near bottom" while scrollTop still sits exactly on the
76
+ // system's own last write means content grew under a pin, not that the
77
+ // reader escaped.
78
+ const lastSystemScrollTopRef = useRef<number | null>(null);
72
79
 
73
80
  // Keep ref in sync with state so observer callbacks read the latest
74
81
  // value without re-subscribing on every state change.
@@ -85,6 +92,7 @@ export function useAutoScroll(): UseAutoScrollReturn {
85
92
  // Establish initial position at the bottom so the first IO
86
93
  // callback sees the sentinel as intersecting.
87
94
  scroller.scrollTop = scroller.scrollHeight;
95
+ lastSystemScrollTopRef.current = scroller.scrollTop;
88
96
 
89
97
  const io = new IntersectionObserver(
90
98
  () => {
@@ -101,6 +109,18 @@ export function useAutoScroll(): UseAutoScrollReturn {
101
109
  const visible =
102
110
  el.scrollHeight - el.scrollTop - el.clientHeight <=
103
111
  NEAR_BOTTOM_MARGIN_PX;
112
+ // The growth-vs-reader discriminator (the write-time guard's
113
+ // mirror, found via the stigmer-cloud#267 pin-on-send suite): a
114
+ // delivery can measure geometry where content ALREADY grew below
115
+ // a system pin but the pin's ResizeObserver write has not run
116
+ // yet. Measured live that reads "not visible" — yet the reader
117
+ // never moved (scrollTop still sits exactly on the system's own
118
+ // last write). Disengaging here would make the imminent RO
119
+ // callback drop its write and strand the thread one row shy of
120
+ // the bottom with follow off. Only the READER may disengage.
121
+ if (!visible && el.scrollTop === lastSystemScrollTopRef.current) {
122
+ return;
123
+ }
104
124
  isFollowingRef.current = visible;
105
125
  setIsFollowing(visible);
106
126
  },
@@ -147,6 +167,7 @@ export function useAutoScroll(): UseAutoScrollReturn {
147
167
  if (!el) return;
148
168
  if (scheduledAt !== null && el.scrollTop !== scheduledAt) return;
149
169
  el.scrollTop = el.scrollHeight;
170
+ lastSystemScrollTopRef.current = el.scrollTop;
150
171
  });
151
172
  });
152
173
  ro.observe(node);
@@ -157,6 +178,7 @@ export function useAutoScroll(): UseAutoScrollReturn {
157
178
  const el = scrollRef.current;
158
179
  if (!el) return;
159
180
  el.scrollTop = el.scrollHeight;
181
+ lastSystemScrollTopRef.current = el.scrollTop;
160
182
  // Eagerly set following — IO callback will confirm when the
161
183
  // sentinel becomes visible after the scroll.
162
184
  isFollowingRef.current = true;
@@ -165,3 +187,35 @@ export function useAutoScroll(): UseAutoScrollReturn {
165
187
 
166
188
  return { scrollRef, sentinelRef, contentRef, isFollowing, jumpToLatest };
167
189
  }
190
+
191
+ /**
192
+ * Pins the thread to its latest content whenever `signal` changes — the
193
+ * scroll-on-send idiom (stigmer-cloud#267): each surface increments a
194
+ * monotonic counter at its own "the reader sent something" moment (an
195
+ * optimistic message appearing, a conversation reply dispatched, a HITL
196
+ * decision submitted), and the pin re-engages follow mode so the resulting
197
+ * content lands in view even for a reader who had deliberately scrolled up.
198
+ * WhatsApp convention: showing the result of the reader's OWN action is
199
+ * Nielsen #1 system-status feedback — distinct from INCOMING content, which
200
+ * must never move a scrolled-up reader (the F-09 posture, unchanged).
201
+ *
202
+ * <p>No pin fires on mount, on an `undefined` signal (surface opted out or
203
+ * prop not wired), or on the `undefined`→number transition (a prop
204
+ * appearing is not a send).
205
+ *
206
+ * @internal Not part of the public API.
207
+ */
208
+ export function usePinToLatestOnSignal(
209
+ signal: number | undefined,
210
+ pinToLatest: () => void,
211
+ ): void {
212
+ const previousRef = useRef(signal);
213
+ useEffect(() => {
214
+ const previous = previousRef.current;
215
+ previousRef.current = signal;
216
+ if (signal === undefined || previous === undefined || signal === previous) {
217
+ return;
218
+ }
219
+ pinToLatest();
220
+ }, [signal, pinToLatest]);
221
+ }
@@ -0,0 +1,181 @@
1
+ "use client";
2
+
3
+ import { useCallback, useState } from "react";
4
+ import { cn } from "@stigmer/theme";
5
+ import { getUserMessage } from "@stigmer/sdk";
6
+ import type { Memory } from "@stigmer/protos/ai/stigmer/agentic/memory/v1/api_pb";
7
+ import { MemoryLifecycleState } from "@stigmer/protos/ai/stigmer/agentic/memory/v1/enum_pb";
8
+ import { SpinnerIcon } from "../internal/SpinnerIcon.js";
9
+ import { formatMemoryProvenance } from "./memoryGroups.js";
10
+ import { useConfirmMemory } from "./useConfirmMemory.js";
11
+ import { useMemory } from "./useMemory.js";
12
+ import { useRejectMemory } from "./useRejectMemory.js";
13
+
14
+ /** Props for {@link MemoryProposalCardBody}. */
15
+ export interface MemoryProposalCardBodyProps {
16
+ /** The proposed memory's resource id (from the remember tool's answer). */
17
+ readonly memoryId: string;
18
+ /**
19
+ * The proposed fact as frozen in the tool result — shown VERBATIM
20
+ * (DD-005 D6: what you confirm is what future prompts inject, byte for
21
+ * byte). The live record's text supersedes it once fetched, so an edit
22
+ * made on the memory page is reflected here too.
23
+ */
24
+ readonly fact: string;
25
+ /** Additional CSS class names for the root container. */
26
+ readonly className?: string;
27
+ }
28
+
29
+ /**
30
+ * The in-session consent surface for a memory proposal (DD-005 D4): the
31
+ * body of a `remember` tool call's row in the message thread, rendering
32
+ * the proposed fact verbatim with one-click Confirm / Reject actions.
33
+ *
34
+ * Lifecycle honesty across surfaces and reloads: the tool result this
35
+ * renders from is FROZEN at capture time, so the card fetches the
36
+ * record's current state ({@link useMemory}) — a proposal decided from
37
+ * the memory settings page (or another device) shows its decided state
38
+ * here instead of stale action buttons, and a deleted record reads
39
+ * "no longer stored" rather than failing. Rejection is one click, no
40
+ * confirmation dialog (the T04 Cursor lesson: expensive review teaches
41
+ * users to ignore the queue); deletion-with-confirmation lives on the
42
+ * memory page, the catch-up surface.
43
+ *
44
+ * Borderless by design (the {@link ApprovalCardBody} posture): the tool
45
+ * row's card owns the chrome, this component owns only the body. All
46
+ * visual properties flow through `--stgm-*` design tokens.
47
+ */
48
+ export function MemoryProposalCardBody({
49
+ memoryId,
50
+ fact,
51
+ className,
52
+ }: MemoryProposalCardBodyProps) {
53
+ const { memory, isLoading, notFound, error: fetchError } = useMemory(memoryId);
54
+ const { confirmMemory, isConfirming, error: confirmError } = useConfirmMemory();
55
+ const { rejectMemory, isRejecting, error: rejectError } = useRejectMemory();
56
+
57
+ // The card's own decision lands here so the settled state renders
58
+ // immediately — the fetch stays the authority for every OTHER writer
59
+ // (the memory page, another device), read once on mount.
60
+ const [decided, setDecided] = useState<Memory | null>(null);
61
+
62
+ const act = useCallback(
63
+ async (action: (id: string) => Promise<Memory>) => {
64
+ try {
65
+ setDecided(await action(memoryId));
66
+ } catch {
67
+ // Error state is surfaced via the hooks.
68
+ }
69
+ },
70
+ [memoryId],
71
+ );
72
+
73
+ const record = decided ?? memory;
74
+ const state = record?.status?.lifecycleState;
75
+ // The live record's text supersedes the frozen tool result (the subject
76
+ // may have edited the fact on the memory page before deciding).
77
+ const displayFact = record?.spec?.content || fact;
78
+ const provenance = record ? formatMemoryProvenance(record) : null;
79
+ const actionError = confirmError ?? rejectError;
80
+ const isBusy = isConfirming || isRejecting;
81
+
82
+ const showActions =
83
+ !notFound &&
84
+ fetchError == null &&
85
+ (state === undefined ||
86
+ state === MemoryLifecycleState.lifecycle_state_unspecified ||
87
+ state === MemoryLifecycleState.lifecycle_state_proposed);
88
+
89
+ return (
90
+ <div className={cn("stg:space-y-2", className)} data-cursor-target="memory-proposal">
91
+ <p className="stg:text-sm stg:text-foreground stg:whitespace-pre-wrap">
92
+ {displayFact}
93
+ </p>
94
+
95
+ <p className="stg:text-xs stg:text-muted-foreground">
96
+ {provenance ? `${provenance} · ` : ""}
97
+ Proposed to remember — nothing is stored in your sessions until you
98
+ confirm it.
99
+ </p>
100
+
101
+ {notFound ? (
102
+ <p className="stg:text-xs stg:text-muted-foreground" role="status">
103
+ No longer stored.
104
+ </p>
105
+ ) : fetchError ? (
106
+ // The fact stays visible (it is frozen in the tool result); only
107
+ // the actionable state is unavailable. Decide from the memory
108
+ // page instead — the catch-up surface.
109
+ <p className="stg:text-xs stg:text-muted-foreground" role="status">
110
+ Couldn&apos;t load this proposal&apos;s current state — review it in
111
+ Settings → Memory.
112
+ </p>
113
+ ) : showActions ? (
114
+ <div className="stg:flex stg:items-center stg:gap-1.5">
115
+ <ChipButton
116
+ label={`Confirm memory: ${displayFact}`}
117
+ onClick={() => void act(confirmMemory)}
118
+ disabled={isBusy || isLoading}
119
+ emphasis="primary"
120
+ >
121
+ {isConfirming && <SpinnerIcon size={12} />}
122
+ Confirm
123
+ </ChipButton>
124
+ <ChipButton
125
+ label={`Reject memory: ${displayFact}`}
126
+ onClick={() => void act(rejectMemory)}
127
+ disabled={isBusy || isLoading}
128
+ >
129
+ {isRejecting && <SpinnerIcon size={12} />}
130
+ Reject
131
+ </ChipButton>
132
+ </div>
133
+ ) : (
134
+ <p className="stg:text-xs stg:text-muted-foreground" role="status">
135
+ {state === MemoryLifecycleState.lifecycle_state_confirmed
136
+ ? "Confirmed — recalled in your future sessions."
137
+ : "Rejected — not stored."}
138
+ </p>
139
+ )}
140
+
141
+ {actionError && (
142
+ <p className="stg:text-[0.65rem] stg:text-destructive" role="alert">
143
+ {getUserMessage(actionError)}
144
+ </p>
145
+ )}
146
+ </div>
147
+ );
148
+ }
149
+
150
+ function ChipButton({
151
+ label,
152
+ onClick,
153
+ disabled,
154
+ emphasis,
155
+ children,
156
+ }: {
157
+ readonly label: string;
158
+ readonly onClick: () => void;
159
+ readonly disabled?: boolean;
160
+ readonly emphasis?: "primary";
161
+ readonly children: React.ReactNode;
162
+ }) {
163
+ return (
164
+ <button
165
+ type="button"
166
+ aria-label={label}
167
+ onClick={onClick}
168
+ disabled={disabled}
169
+ className={cn(
170
+ "stg:inline-flex stg:items-center stg:gap-1 stg:rounded-md stg:px-2.5 stg:py-1 stg:text-xs stg:font-medium",
171
+ emphasis === "primary"
172
+ ? "stg:bg-primary stg:text-primary-foreground stg:hover:bg-primary-hover"
173
+ : "stg:border stg:border-input stg:bg-background stg:text-foreground stg:hover:bg-accent stg:hover:text-accent-foreground",
174
+ "stg:focus-visible:outline-none stg:focus-visible:ring-2 stg:focus-visible:ring-ring",
175
+ "stg:disabled:pointer-events-none stg:disabled:opacity-50",
176
+ )}
177
+ >
178
+ {children}
179
+ </button>
180
+ );
181
+ }