@stigmer/react 3.12.8 → 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 (71) hide show
  1. package/README.md +1 -3
  2. package/conversation/ConversationTimelineView.d.ts +15 -1
  3. package/conversation/ConversationTimelineView.d.ts.map +1 -1
  4. package/conversation/ConversationTimelineView.js +3 -2
  5. package/conversation/ConversationTimelineView.js.map +1 -1
  6. package/conversation/ConversationsWorkbench.d.ts +14 -1
  7. package/conversation/ConversationsWorkbench.d.ts.map +1 -1
  8. package/conversation/ConversationsWorkbench.js +11 -2
  9. package/conversation/ConversationsWorkbench.js.map +1 -1
  10. package/execution/MessageThread.d.ts +33 -2
  11. package/execution/MessageThread.d.ts.map +1 -1
  12. package/execution/MessageThread.js +44 -6
  13. package/execution/MessageThread.js.map +1 -1
  14. package/execution/RecalledMemoriesCard.d.ts +47 -0
  15. package/execution/RecalledMemoriesCard.d.ts.map +1 -0
  16. package/execution/RecalledMemoriesCard.js +58 -0
  17. package/execution/RecalledMemoriesCard.js.map +1 -0
  18. package/execution/index.d.ts +2 -0
  19. package/execution/index.d.ts.map +1 -1
  20. package/execution/index.js +1 -0
  21. package/execution/index.js.map +1 -1
  22. package/identity-account/AccountPreferencesPanel.js +1 -1
  23. package/identity-account/AccountPreferencesPanel.js.map +1 -1
  24. package/index.d.ts +2 -2
  25. package/index.d.ts.map +1 -1
  26. package/index.js +1 -1
  27. package/index.js.map +1 -1
  28. package/internal/VirtualizedThread.d.ts +3 -1
  29. package/internal/VirtualizedThread.d.ts.map +1 -1
  30. package/internal/VirtualizedThread.js +3 -1
  31. package/internal/VirtualizedThread.js.map +1 -1
  32. package/internal/useAutoScroll.d.ts +18 -0
  33. package/internal/useAutoScroll.d.ts.map +1 -1
  34. package/internal/useAutoScroll.js +50 -0
  35. package/internal/useAutoScroll.js.map +1 -1
  36. package/organization/OrgPreferencesPanel.js +1 -1
  37. package/organization/OrgPreferencesPanel.js.map +1 -1
  38. package/package.json +4 -4
  39. package/settings/MemorySection.d.ts.map +1 -1
  40. package/settings/MemorySection.js +1 -1
  41. package/settings/MemorySection.js.map +1 -1
  42. package/src/conversation/ConversationTimelineView.tsx +17 -1
  43. package/src/conversation/ConversationsWorkbench.tsx +25 -0
  44. package/src/conversation/__tests__/ConversationTimelineView.layout.test.tsx +37 -1
  45. package/src/execution/MessageThread.tsx +75 -3
  46. package/src/execution/RecalledMemoriesCard.tsx +164 -0
  47. package/src/execution/__tests__/RecalledMemoriesCard.test.tsx +123 -0
  48. package/src/execution/__tests__/message-thread-recalled-memories.test.tsx +216 -0
  49. package/src/execution/__tests__/message-thread-scroll-on-send.test.tsx +141 -0
  50. package/src/execution/__tests__/message-thread-slots.test.tsx +36 -0
  51. package/src/execution/index.ts +2 -0
  52. package/src/identity-account/AccountPreferencesPanel.tsx +1 -1
  53. package/src/index.ts +3 -0
  54. package/src/internal/VirtualizedThread.tsx +5 -0
  55. package/src/internal/__tests__/useAutoScroll.layout.test.tsx +105 -4
  56. package/src/internal/__tests__/useAutoScroll.test.tsx +41 -1
  57. package/src/internal/useAutoScroll.ts +54 -0
  58. package/src/organization/OrgPreferencesPanel.tsx +1 -1
  59. package/src/settings/MemorySection.tsx +4 -2
  60. package/src/workflow/__tests__/WorkflowExecutionViewer.approvals.test.tsx +26 -7
  61. package/src/workflow/__tests__/task-presentation.test.ts +49 -12
  62. package/src/workflow/__tests__/workflow-thread-scroll-on-send.test.tsx +145 -0
  63. package/src/workflow/thread/WorkflowTaskThread.tsx +59 -32
  64. package/src/workflow/thread/task-presentation.ts +52 -7
  65. package/workflow/thread/WorkflowTaskThread.d.ts +13 -0
  66. package/workflow/thread/WorkflowTaskThread.d.ts.map +1 -1
  67. package/workflow/thread/WorkflowTaskThread.js +33 -25
  68. package/workflow/thread/WorkflowTaskThread.js.map +1 -1
  69. package/workflow/thread/task-presentation.d.ts.map +1 -1
  70. package/workflow/thread/task-presentation.js +50 -7
  71. package/workflow/thread/task-presentation.js.map +1 -1
@@ -0,0 +1,216 @@
1
+ // Thread emission rules for the retriever transparency card
2
+ // (stigmer/stigmer#293 Phase 3a, DD-008 D5). The load-bearing pins:
3
+ //
4
+ // 1. The card is a PER-SEGMENT item derived from each execution's own
5
+ // spec+status — NOT a live-only tail indicator like setup-progress.
6
+ // A historical (non-active) execution keeps its card forever; that
7
+ // is the audit property, and the regression this file exists to
8
+ // catch (a live-only mechanism would render correctly during
9
+ // streaming and silently lose the record afterwards).
10
+ // 2. Absent report and selection_active=false both render NOTHING —
11
+ // wholesale is the unchanged majority behavior and earns no noise.
12
+ // 3. The card is gated on the REPORT, not the prompt bubble:
13
+ // syntheticUserPrompt deliberately skips some user turns (empty
14
+ // prompt), and those executions can still be selection-active.
15
+
16
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
17
+ import { render, screen, cleanup } from "@testing-library/react";
18
+ import { create } from "@bufbuild/protobuf";
19
+ import {
20
+ AgentExecutionSchema,
21
+ AgentExecutionStatusSchema,
22
+ RecalledMemoriesReportSchema,
23
+ type AgentExecution,
24
+ } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
25
+ import {
26
+ AgentExecutionSpecSchema,
27
+ RecalledMemoriesSchema,
28
+ } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/spec_pb";
29
+ import { ApiResourceMetadataSchema } from "@stigmer/protos/ai/stigmer/commons/apiresource/metadata_pb";
30
+ import { AgentMessageSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
31
+ import {
32
+ ExecutionPhase,
33
+ MessageType,
34
+ } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
35
+
36
+ import { MessageThread } from "../MessageThread";
37
+
38
+ // useAutoScroll depends on browser APIs not available in happy-dom.
39
+ beforeEach(() => {
40
+ vi.stubGlobal(
41
+ "IntersectionObserver",
42
+ vi.fn(() => ({
43
+ observe: vi.fn(),
44
+ unobserve: vi.fn(),
45
+ disconnect: vi.fn(),
46
+ takeRecords: vi.fn(() => []),
47
+ root: null,
48
+ rootMargin: "",
49
+ thresholds: [0],
50
+ })),
51
+ );
52
+ vi.stubGlobal(
53
+ "ResizeObserver",
54
+ vi.fn(() => ({ observe: vi.fn(), unobserve: vi.fn(), disconnect: vi.fn() })),
55
+ );
56
+ });
57
+
58
+ afterEach(() => {
59
+ cleanup();
60
+ vi.restoreAllMocks();
61
+ });
62
+
63
+ function makeExecution(opts: {
64
+ id: string;
65
+ specMessage?: string;
66
+ facts?: ReadonlyArray<{ id: string; content: string }>;
67
+ report?: { selectionActive: boolean; injectedMemoryIds?: string[] };
68
+ }): AgentExecution {
69
+ const exec = create(AgentExecutionSchema);
70
+ exec.metadata = create(ApiResourceMetadataSchema, { id: opts.id });
71
+
72
+ const spec = create(AgentExecutionSpecSchema);
73
+ spec.message = opts.specMessage ?? "Hello";
74
+ if (opts.facts) {
75
+ spec.recalledMemories = create(RecalledMemoriesSchema, {
76
+ enabled: true,
77
+ facts: opts.facts.map((f) => ({ memoryId: f.id, content: f.content })),
78
+ });
79
+ }
80
+ exec.spec = spec;
81
+
82
+ const status = create(AgentExecutionStatusSchema);
83
+ status.phase = ExecutionPhase.EXECUTION_COMPLETED;
84
+ status.messages = [
85
+ create(AgentMessageSchema, { type: MessageType.MESSAGE_AI, content: "Done." }),
86
+ ];
87
+ if (opts.report) {
88
+ status.recalledMemoriesReport = create(RecalledMemoriesReportSchema, {
89
+ selectionActive: opts.report.selectionActive,
90
+ injectedMemoryIds: opts.report.injectedMemoryIds ?? [],
91
+ embeddingModel: opts.report.selectionActive ? "text-embedding-3-small" : "",
92
+ });
93
+ }
94
+ exec.status = status;
95
+ return exec;
96
+ }
97
+
98
+ const FACTS = [
99
+ { id: "mem_a", content: "Prefers concise answers." },
100
+ { id: "mem_b", content: "Deploys with Bazel." },
101
+ { id: "mem_c", content: "Reviews PRs on Fridays." },
102
+ ];
103
+
104
+ describe("MessageThread recalled-memories item", () => {
105
+ it("emits the card inside a selection-active execution's segment, after the user turn", () => {
106
+ render(
107
+ <MessageThread
108
+ executions={[
109
+ makeExecution({
110
+ id: "e1",
111
+ specMessage: "prompt text",
112
+ facts: FACTS,
113
+ report: { selectionActive: true, injectedMemoryIds: ["mem_a", "mem_c"] },
114
+ }),
115
+ ]}
116
+ />,
117
+ );
118
+
119
+ const card = screen.getByRole("status", { name: "Recalled 2 of 3 memories" });
120
+ const userBubble = screen.getByRole("article", { name: "User message" });
121
+ // Segment order: user turn first, then the card the turn's prompt selected.
122
+ expect(
123
+ userBubble.compareDocumentPosition(card) & Node.DOCUMENT_POSITION_FOLLOWING,
124
+ ).toBeTruthy();
125
+ });
126
+
127
+ it("emits nothing for an absent report (pre-3a and no-injection executions read identically)", () => {
128
+ render(
129
+ <MessageThread
130
+ executions={[makeExecution({ id: "e1", facts: FACTS })]}
131
+ />,
132
+ );
133
+ expect(screen.queryByRole("status", { name: /Recalled/ })).toBeNull();
134
+ });
135
+
136
+ it("emits nothing for a wholesale report (selection_active=false)", () => {
137
+ render(
138
+ <MessageThread
139
+ executions={[
140
+ makeExecution({
141
+ id: "e1",
142
+ facts: FACTS,
143
+ report: { selectionActive: false },
144
+ }),
145
+ ]}
146
+ />,
147
+ );
148
+ expect(screen.queryByRole("status", { name: /Recalled/ })).toBeNull();
149
+ });
150
+
151
+ it("keeps the card on a HISTORICAL execution while another execution streams", () => {
152
+ // The audit pin: the card derives from the execution's own segment, so
153
+ // it must survive the execution no longer being the active stream — a
154
+ // live-only mechanism (the setup-progress shape) would lose it here.
155
+ const historical = makeExecution({
156
+ id: "e-old",
157
+ specMessage: "earlier turn",
158
+ facts: FACTS,
159
+ report: { selectionActive: true, injectedMemoryIds: ["mem_b"] },
160
+ });
161
+ const live = makeExecution({ id: "e-live", specMessage: "current turn" });
162
+ live.status!.phase = ExecutionPhase.EXECUTION_IN_PROGRESS;
163
+
164
+ render(
165
+ <MessageThread executions={[historical]} activeStreamExecution={live} />,
166
+ );
167
+
168
+ expect(
169
+ screen.getByRole("status", { name: "Recalled 1 of 3 memories" }),
170
+ ).toBeTruthy();
171
+ });
172
+
173
+ it("emits one card per selection-active execution", () => {
174
+ render(
175
+ <MessageThread
176
+ executions={[
177
+ makeExecution({
178
+ id: "e1",
179
+ facts: FACTS,
180
+ report: { selectionActive: true, injectedMemoryIds: ["mem_a"] },
181
+ }),
182
+ makeExecution({ id: "e2", facts: FACTS }),
183
+ makeExecution({
184
+ id: "e3",
185
+ facts: FACTS,
186
+ report: { selectionActive: true, injectedMemoryIds: ["mem_a", "mem_b"] },
187
+ }),
188
+ ]}
189
+ />,
190
+ );
191
+
192
+ expect(screen.getByRole("status", { name: "Recalled 1 of 3 memories" })).toBeTruthy();
193
+ expect(screen.getByRole("status", { name: "Recalled 2 of 3 memories" })).toBeTruthy();
194
+ expect(screen.getAllByRole("status", { name: /Recalled/ })).toHaveLength(2);
195
+ });
196
+
197
+ it("emits the card even when the user-turn bubble is skipped (empty prompt)", () => {
198
+ render(
199
+ <MessageThread
200
+ executions={[
201
+ makeExecution({
202
+ id: "e1",
203
+ specMessage: "",
204
+ facts: FACTS,
205
+ report: { selectionActive: true, injectedMemoryIds: ["mem_c"] },
206
+ }),
207
+ ]}
208
+ />,
209
+ );
210
+
211
+ expect(screen.queryByRole("article", { name: "User message" })).toBeNull();
212
+ expect(
213
+ screen.getByRole("status", { name: "Recalled 1 of 3 memories" }),
214
+ ).toBeTruthy();
215
+ });
216
+ });
@@ -0,0 +1,141 @@
1
+ // Scroll-on-send wiring pins for MessageThread (stigmer-cloud#267): the
2
+ // send moment is the optimistic message's empty→present transition, and it
3
+ // must pin the thread exactly once — default-ON, opt-out via
4
+ // `scrollOnSend={false}` (the ratified DD-011 divergence). The REAL scroll
5
+ // mechanics (pin + follow re-engagement under real layout) are pinned in
6
+ // `internal/__tests__/useAutoScroll.layout.test.tsx`; this file pins the
7
+ // surface's signal derivation through a spied `jumpToLatest`, with the real
8
+ // `usePinToLatestOnSignal` connecting them.
9
+
10
+ import { describe, it, expect, vi, afterEach } from "vitest";
11
+ import { render, cleanup } from "@testing-library/react";
12
+ import { create } from "@bufbuild/protobuf";
13
+ import {
14
+ AgentExecutionSchema,
15
+ type AgentExecution,
16
+ } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
17
+ import { AgentExecutionSpecSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/spec_pb";
18
+ import { ApiResourceMetadataSchema } from "@stigmer/protos/ai/stigmer/commons/apiresource/metadata_pb";
19
+
20
+ const jumpToLatestSpy = vi.fn();
21
+
22
+ // The fake replaces only the scroll machine (browser observers happy-dom
23
+ // lacks); `usePinToLatestOnSignal` stays REAL, so these tests exercise the
24
+ // actual signal contract between MessageThread and its render paths.
25
+ vi.mock("../../internal/useAutoScroll", async (importOriginal) => {
26
+ const original =
27
+ await importOriginal<typeof import("../../internal/useAutoScroll")>();
28
+ return {
29
+ ...original,
30
+ useAutoScroll: () => ({
31
+ scrollRef: { current: null },
32
+ sentinelRef: { current: null },
33
+ contentRef: () => {},
34
+ isFollowing: true,
35
+ jumpToLatest: jumpToLatestSpy,
36
+ }),
37
+ };
38
+ });
39
+
40
+ import { MessageThread } from "../MessageThread";
41
+
42
+ afterEach(() => {
43
+ cleanup();
44
+ jumpToLatestSpy.mockClear();
45
+ });
46
+
47
+ function makeExecution(id: string, message: string): AgentExecution {
48
+ const exec = create(AgentExecutionSchema);
49
+ const meta = create(ApiResourceMetadataSchema);
50
+ meta.id = id;
51
+ exec.metadata = meta;
52
+ const spec = create(AgentExecutionSpecSchema);
53
+ spec.message = message;
54
+ exec.spec = spec;
55
+ return exec;
56
+ }
57
+
58
+ describe("MessageThread — scroll-on-send (stigmer-cloud#267)", () => {
59
+ it("pins the thread exactly once when the optimistic message appears (default-on)", () => {
60
+ const executions = [makeExecution("aex_1", "earlier turn")];
61
+ const { rerender } = render(
62
+ <MessageThread executions={executions} pendingUserMessage={null} />,
63
+ );
64
+ expect(jumpToLatestSpy).not.toHaveBeenCalled();
65
+
66
+ // The send: the composer sets the optimistic message.
67
+ rerender(
68
+ <MessageThread executions={executions} pendingUserMessage="do the thing" />,
69
+ );
70
+ expect(jumpToLatestSpy).toHaveBeenCalledTimes(1);
71
+
72
+ // Re-renders while the message stays pending must not re-pin — the
73
+ // reader may have scrolled up again to read history mid-turn.
74
+ rerender(
75
+ <MessageThread executions={executions} pendingUserMessage="do the thing" />,
76
+ );
77
+ expect(jumpToLatestSpy).toHaveBeenCalledTimes(1);
78
+ });
79
+
80
+ it("pins again on the NEXT send after the pending message clears", () => {
81
+ const executions = [makeExecution("aex_1", "earlier turn")];
82
+ const { rerender } = render(
83
+ <MessageThread executions={executions} pendingUserMessage={null} />,
84
+ );
85
+
86
+ rerender(
87
+ <MessageThread executions={executions} pendingUserMessage="first send" />,
88
+ );
89
+ rerender(<MessageThread executions={executions} pendingUserMessage={null} />);
90
+ rerender(
91
+ <MessageThread executions={executions} pendingUserMessage="second send" />,
92
+ );
93
+
94
+ expect(jumpToLatestSpy).toHaveBeenCalledTimes(2);
95
+ });
96
+
97
+ it("does not re-pin on a failed send's retry — pending stays present throughout, and the reader was already brought to it", () => {
98
+ const executions = [makeExecution("aex_1", "earlier turn")];
99
+ const { rerender } = render(
100
+ <MessageThread executions={executions} pendingUserMessage={null} />,
101
+ );
102
+
103
+ rerender(
104
+ <MessageThread executions={executions} pendingUserMessage="flaky send" />,
105
+ );
106
+ rerender(
107
+ <MessageThread
108
+ executions={executions}
109
+ pendingUserMessage="flaky send"
110
+ pendingMessageFailed
111
+ onRetrySend={() => {}}
112
+ />,
113
+ );
114
+ rerender(
115
+ <MessageThread executions={executions} pendingUserMessage="flaky send" />,
116
+ );
117
+
118
+ expect(jumpToLatestSpy).toHaveBeenCalledTimes(1);
119
+ });
120
+
121
+ it("never pins with scrollOnSend={false} — the opt-out preserves today's leave-the-reader-alone behavior", () => {
122
+ const executions = [makeExecution("aex_1", "earlier turn")];
123
+ const { rerender } = render(
124
+ <MessageThread
125
+ executions={executions}
126
+ pendingUserMessage={null}
127
+ scrollOnSend={false}
128
+ />,
129
+ );
130
+
131
+ rerender(
132
+ <MessageThread
133
+ executions={executions}
134
+ pendingUserMessage="do the thing"
135
+ scrollOnSend={false}
136
+ />,
137
+ );
138
+
139
+ expect(jumpToLatestSpy).not.toHaveBeenCalled();
140
+ });
141
+ });
@@ -5,11 +5,13 @@ import { create } from "@bufbuild/protobuf";
5
5
  import {
6
6
  AgentExecutionSchema,
7
7
  AgentExecutionStatusSchema,
8
+ RecalledMemoriesReportSchema,
8
9
  type AgentExecution,
9
10
  } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
10
11
  import {
11
12
  AgentExecutionSpecSchema,
12
13
  ExecutionConfigSchema,
14
+ RecalledMemoriesSchema,
13
15
  } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/spec_pb";
14
16
  import { ApiResourceMetadataSchema } from "@stigmer/protos/ai/stigmer/commons/apiresource/metadata_pb";
15
17
  import { AgentMessageSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
@@ -32,6 +34,7 @@ import type { ExecutionErrorNoticeProps } from "../ExecutionErrorNotice";
32
34
  import type { TodoCardProps } from "../TodoCard";
33
35
  import type { TodoRowProps } from "../TodoList";
34
36
  import type { SetupProgressProps } from "../SetupProgress";
37
+ import type { RecalledMemoriesCardProps } from "../RecalledMemoriesCard";
35
38
  import type { LivenessStatusLineProps } from "../LivenessStatusLine";
36
39
  import type { PlanCompletionCardProps } from "../PlanCompletionCard";
37
40
  import type { PlanArtifactCardProps } from "../PlanArtifactCard";
@@ -318,6 +321,39 @@ describe("MessageThread slots", () => {
318
321
  expect(screen.getByTestId("custom-setup").textContent).toBe("waiting");
319
322
  });
320
323
 
324
+ it("renders the RecalledMemoriesCard slot for a selection-active execution", () => {
325
+ const CustomRecalled = ({ report, facts }: RecalledMemoriesCardProps) => (
326
+ <div data-testid="custom-recalled">
327
+ {report.injectedMemoryIds.length} of {facts.length}
328
+ </div>
329
+ );
330
+
331
+ const exec = makeExecution({ id: "e1" });
332
+ exec.spec!.recalledMemories = create(RecalledMemoriesSchema, {
333
+ enabled: true,
334
+ facts: [
335
+ { memoryId: "mem_a", content: "Prefers concise answers." },
336
+ { memoryId: "mem_b", content: "Deploys with Bazel." },
337
+ ],
338
+ });
339
+ exec.status!.recalledMemoriesReport = create(RecalledMemoriesReportSchema, {
340
+ selectionActive: true,
341
+ injectedMemoryIds: ["mem_a"],
342
+ embeddingModel: "text-embedding-3-small",
343
+ });
344
+
345
+ render(
346
+ <MessageThread
347
+ executions={[exec]}
348
+ slots={{ RecalledMemoriesCard: CustomRecalled }}
349
+ />,
350
+ );
351
+
352
+ expect(screen.getByTestId("custom-recalled").textContent).toBe("1 of 2");
353
+ // The built-in card must not render alongside the override.
354
+ expect(screen.queryByRole("status", { name: /Recalled/ })).toBeNull();
355
+ });
356
+
321
357
  it("renders the LivenessStatusLine slot while the execution is live between events", () => {
322
358
  const CustomLiveness = ({ label }: LivenessStatusLineProps) => (
323
359
  <div data-testid="custom-liveness">{label ?? "custom-live"}</div>
@@ -42,6 +42,8 @@ export type { SummarizationBadgeProps } from "./SummarizationBadge.js";
42
42
 
43
43
  export { SummarizationCard } from "./SummarizationCard.js";
44
44
  export type { SummarizationCardProps } from "./SummarizationCard.js";
45
+ export { RecalledMemoriesCard, resolveInjectedFacts } from "./RecalledMemoriesCard.js";
46
+ export type { RecalledMemoriesCardProps } from "./RecalledMemoriesCard.js";
45
47
 
46
48
  export { PlanCompletionCard } from "./PlanCompletionCard.js";
47
49
  export type { PlanCompletionCardProps } from "./PlanCompletionCard.js";
@@ -416,7 +416,7 @@ function AccountPreferencesForm({
416
416
  onToggle={(next) => void handleMemoryToggle(next)}
417
417
  saving={isSavingMemoryFlag}
418
418
  error={memoryFlagError}
419
- helperText="When on, agents may propose facts to remember about you; only facts you confirm are stored. Confirmed memories are shared with your future sessions and appear on those executions' records. Requires your organization to have memory enabled. Changes apply immediately."
419
+ helperText="When on, agents may propose facts to remember about you; only facts you confirm are stored. Confirmed memories are shared with your future sessions and appear on those executions' records; once you have many, each conversation recalls the most relevant ones, shown on the execution. Requires your organization to have memory enabled. Changes apply immediately."
420
420
  />
421
421
 
422
422
  {updateError && (
package/src/index.ts CHANGED
@@ -286,6 +286,8 @@ export {
286
286
  ContextGauge,
287
287
  SummarizationBadge,
288
288
  SummarizationCard,
289
+ RecalledMemoriesCard,
290
+ resolveInjectedFacts,
289
291
  PlanCompletionCard,
290
292
  PlanArtifactCard,
291
293
  PlanStreamingCard,
@@ -412,6 +414,7 @@ export type {
412
414
  ContextGaugeProps,
413
415
  SummarizationBadgeProps,
414
416
  SummarizationCardProps,
417
+ RecalledMemoriesCardProps,
415
418
  PlanCompletionCardProps,
416
419
  PlanArtifactCardProps,
417
420
  PlanStreamingCardProps,
@@ -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);