@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
@@ -82,6 +82,19 @@ export interface ConversationsWorkbenchProps {
82
82
  readonly channelHref?: (channel: AgentChannel) => string | null;
83
83
  /** Frozen instant for deterministic hosts (tests, documentation tours). */
84
84
  readonly now?: Date;
85
+ /**
86
+ * Scroll to the reader's own reply when they send one from a scrolled-up
87
+ * position (stigmer-cloud#267): an accepted send pins the timeline to the
88
+ * latest content and re-engages follow mode, so the reply stays in view
89
+ * when the refetch delivers its real ledger item. Refused sends never pin
90
+ * — the composer restores the draft and there is nothing to show. Default
91
+ * `true` on all three SDK thread surfaces at once (the ratified DD-011
92
+ * divergence — cross-surface consistency is the point); set `false` to
93
+ * keep today's leave-the-reader-alone behavior.
94
+ *
95
+ * @default true
96
+ */
97
+ readonly scrollOnSend?: boolean;
85
98
  /** Additional classes for the workbench container. */
86
99
  readonly className?: string;
87
100
  }
@@ -103,6 +116,7 @@ export function ConversationsWorkbench({
103
116
  headerAccessory,
104
117
  channelHref,
105
118
  now,
119
+ scrollOnSend = true,
106
120
  className,
107
121
  }: ConversationsWorkbenchProps) {
108
122
  const [channelFilter, setChannelFilter] = useState("");
@@ -233,6 +247,12 @@ export function ConversationsWorkbench({
233
247
  }
234
248
  }, [timeline.items, settlingItemId]);
235
249
 
250
+ // Scroll-on-send (stigmer-cloud#267): a monotonic counter the timeline
251
+ // view pins on. Lives outside the conversation-keyed column, which is
252
+ // safe by the signal contract: the view initializes to the current value
253
+ // on (re)mount and pins only on increments it observes.
254
+ const [sendPinSignal, setSendPinSignal] = useState(0);
255
+
236
256
  const handleSend = useCallback(
237
257
  async (payload: ConversationReplyPayload) => {
238
258
  const output = await participation.reply(payload);
@@ -252,6 +272,10 @@ export function ConversationsWorkbench({
252
272
  selectionKey,
253
273
  itemId: outboundItemIdOf(output.outboundMessageId),
254
274
  });
275
+ // The reply produced a ledger item to show — pin the timeline so
276
+ // it lands in view (a refusal pins nothing: the draft came back
277
+ // and the reader's position should not move for it).
278
+ setSendPinSignal((n) => n + 1);
255
279
  }
256
280
  timeline.refetch();
257
281
  return output;
@@ -379,6 +403,7 @@ export function ConversationsWorkbench({
379
403
  isLoadingOlder={timeline.isLoadingOlder}
380
404
  provider={descriptor?.id ?? null}
381
405
  now={now}
406
+ pinToLatestSignal={scrollOnSend ? sendPinSignal : undefined}
382
407
  />
383
408
 
384
409
  <ConversationComposer
@@ -35,7 +35,13 @@ function items(count: number): ConversationTimelineItem[] {
35
35
  ? ConversationItemAuthor.author_customer
36
36
  : ConversationItemAuthor.author_agent,
37
37
  text: `message ${i}`,
38
- at: timestampFromDate(new Date(NOW.getTime() - (count - i) * 60_000)),
38
+ // Anchored by INDEX, never by count: growth must only APPEND rows,
39
+ // as production items keep their timestamps. A count-relative time
40
+ // rewrote every existing row on growth — the resulting full-thread
41
+ // reflow could clamp scrollTop mid-pin and trip the hook's
42
+ // reader-took-control guard (measured flaky ~1-in-5 on the
43
+ // scroll-on-send case).
44
+ at: timestampFromDate(new Date(NOW.getTime() - (1_000 - i) * 60_000)),
39
45
  }),
40
46
  );
41
47
  }
@@ -43,6 +49,7 @@ function items(count: number): ConversationTimelineItem[] {
43
49
  function view(props: {
44
50
  readonly items: readonly ConversationTimelineItem[];
45
51
  readonly isLoading: boolean;
52
+ readonly pinToLatestSignal?: number;
46
53
  }): ReactElement {
47
54
  return (
48
55
  <ConversationTimelineView
@@ -54,6 +61,7 @@ function view(props: {
54
61
  isLoadingOlder={false}
55
62
  provider="whatsapp"
56
63
  now={NOW}
64
+ pinToLatestSignal={props.pinToLatestSignal}
57
65
  />
58
66
  );
59
67
  }
@@ -157,4 +165,32 @@ describe("ConversationTimelineView scroll behavior (F-09)", () => {
157
165
  expect(jumpButton().getAttribute("aria-hidden")).toBe("true");
158
166
  });
159
167
  });
168
+
169
+ it("pins a scrolled-up reader on their OWN send via pinToLatestSignal, and keeps following as the reply's real item lands (stigmer-cloud#267)", async () => {
170
+ const { rerender } = renderInPane(
171
+ view({ items: [], isLoading: true, pinToLatestSignal: 0 }),
172
+ );
173
+ rerender(view({ items: items(40), isLoading: false, pinToLatestSignal: 0 }));
174
+ await settled(() => expect(isPinnedToBottom(scroller())).toBe(true));
175
+
176
+ // The reader scrolls up to read history.
177
+ scroller().scrollTop = 0;
178
+ await settled(() =>
179
+ expect(jumpButton().getAttribute("aria-hidden")).toBe("false"),
180
+ );
181
+
182
+ // They send a reply: the workbench increments the signal on the
183
+ // accepted send. The view pins and re-engages follow — the timeline
184
+ // has no optimistic item, so re-engaged follow is what carries the
185
+ // reply's REAL ledger item into view when the refetch delivers it.
186
+ rerender(view({ items: items(40), isLoading: false, pinToLatestSignal: 1 }));
187
+ await settled(() => {
188
+ expect(isPinnedToBottom(scroller())).toBe(true);
189
+ expect(jumpButton().getAttribute("aria-hidden")).toBe("true");
190
+ });
191
+
192
+ // The refetch delivers the item — still pinned, the reply visible.
193
+ rerender(view({ items: items(41), isLoading: false, pinToLatestSignal: 1 }));
194
+ await settled(() => expect(isPinnedToBottom(scroller())).toBe(true));
195
+ });
160
196
  });
@@ -1,8 +1,9 @@
1
1
  "use client";
2
2
 
3
- import { lazy, memo, Suspense, useCallback, useMemo, type ComponentType } from "react";
3
+ import { lazy, memo, Suspense, useCallback, useEffect, useMemo, useRef, useState, type ComponentType } from "react";
4
4
  import { create } from "@bufbuild/protobuf";
5
- import type { AgentExecution } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
5
+ import type { AgentExecution, RecalledMemoriesReport } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
6
+ import type { RecalledMemoryFact } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/spec_pb";
6
7
  import type { AgentMessage, ToolCall } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
7
8
  import { AgentMessageSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
8
9
  import type { SubAgentExecution } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/subagent_pb";
@@ -29,6 +30,7 @@ import { ToolCallGroup } from "./ToolCallGroup.js";
29
30
  import { SubAgentSection } from "./SubAgentSection.js";
30
31
  import { ExecutionPhaseBadge } from "./ExecutionPhaseBadge.js";
31
32
  import { SetupProgress, type SetupProgressProps } from "./SetupProgress.js";
33
+ import { RecalledMemoriesCard, type RecalledMemoriesCardProps } from "./RecalledMemoriesCard.js";
32
34
  import {
33
35
  LivenessStatusLine,
34
36
  type LivenessStatusLineProps,
@@ -56,7 +58,7 @@ import {
56
58
  unwrapEnclosingMarkdownFence,
57
59
  } from "../internal/markdown-components.js";
58
60
  import { useRenderTracer, useKeyStability, useDomNodeCount, DevProfiler } from "../internal/dev/index.js";
59
- import { useAutoScroll } from "../internal/useAutoScroll.js";
61
+ import { useAutoScroll, usePinToLatestOnSignal } from "../internal/useAutoScroll.js";
60
62
  import { JumpToLatestButton } from "../internal/JumpToLatestButton.js";
61
63
  import { ApprovalPeekBar } from "../internal/ApprovalPeekBar.js";
62
64
  import { ThreadItemWrapper } from "../internal/ThreadItemWrapper.js";
@@ -113,6 +115,11 @@ export interface MessageThreadSlots {
113
115
  readonly PlanStreamingCard?: ComponentType<PlanStreamingCardProps>;
114
116
  /** Pre-first-token setup / "Thinking…" indicator. */
115
117
  readonly SetupProgress?: ComponentType<SetupProgressProps>;
118
+ /**
119
+ * The retriever transparency card at a selection-active execution's
120
+ * segment start — "Recalled N of M memories" (DD-008 D5).
121
+ */
122
+ readonly RecalledMemoriesCard?: ComponentType<RecalledMemoriesCardProps>;
116
123
  /**
117
124
  * The terminal execution-failure notice. Receives the raw server-reported
118
125
  * reason and the retry wiring, so a host can turn the failure into its own
@@ -170,6 +177,25 @@ export interface MessageThreadProps {
170
177
  * "Retry" control when {@link pendingMessageFailed} is `true`.
171
178
  */
172
179
  readonly onRetrySend?: () => void;
180
+ /**
181
+ * Scroll to the reader's own message when they send one from a
182
+ * scrolled-up position (stigmer-cloud#267 — the WhatsApp convention:
183
+ * showing the result of the reader's own action is Nielsen #1
184
+ * system-status feedback). The send moment is the
185
+ * {@link pendingUserMessage} transition from empty to present; the pin
186
+ * re-engages follow mode, so the optimistic bubble and the streamed
187
+ * reply stay in view. Incoming content is unaffected — it still never
188
+ * moves a scrolled-up reader.
189
+ *
190
+ * Default `true` on all three SDK thread surfaces at once — a deliberate,
191
+ * ratified divergence from DD-011's opt-in default: the issue's whole
192
+ * point is cross-surface consistency, and a per-surface opt-in would
193
+ * re-create the inconsistency it fixes. Set `false` to keep today's
194
+ * leave-the-reader-alone behavior.
195
+ *
196
+ * @default true
197
+ */
198
+ readonly scrollOnSend?: boolean;
173
199
  /**
174
200
  * When provided, the in-flight human turn (the active execution's prompt)
175
201
  * shows a hover "Edit" affordance. Clicking it invokes this callback with
@@ -379,6 +405,7 @@ export type ThreadItem =
379
405
  | { readonly kind: "approval-request"; readonly pendingApproval: PendingApproval; readonly key: string }
380
406
  | { readonly kind: "file-review-record"; readonly fileChangeSet: FileChangeSet; readonly key: string }
381
407
  | { readonly kind: "setup-progress"; readonly workspaceEntries: readonly WorkspaceEntry[]; readonly serverPhase?: string; readonly isAwaitingResponse?: boolean; readonly key: string }
408
+ | { readonly kind: "recalled-memories"; readonly report: RecalledMemoriesReport; readonly facts: readonly RecalledMemoryFact[]; readonly key: string }
382
409
  | { readonly kind: "context-compacted"; readonly event: SummarizationEventView; readonly key: string }
383
410
  | {
384
411
  readonly kind: "todos";
@@ -805,6 +832,24 @@ export function buildThreadItems(
805
832
  });
806
833
  }
807
834
 
835
+ // The retriever transparency card (stigmer#293 Phase 3a, DD-008 D5):
836
+ // this execution ran semantic selection over its memory snapshot, so
837
+ // its segment discloses the subset right after the user's turn —
838
+ // spec.message is the query the retriever embedded. Absent report or
839
+ // selection_active=false means wholesale (unchanged Phase 2 behavior)
840
+ // and renders nothing. Gated on the REPORT, not the prompt bubble:
841
+ // syntheticUserPrompt deliberately skips some user turns (empty
842
+ // prompt, build-from-plan) that can still be selection-active.
843
+ const recalledReport = exec.status?.recalledMemoriesReport;
844
+ if (recalledReport?.selectionActive) {
845
+ items.push({
846
+ kind: "recalled-memories",
847
+ report: recalledReport,
848
+ facts: exec.spec?.recalledMemories?.facts ?? [],
849
+ key: `${execId}-recalled-memories`,
850
+ });
851
+ }
852
+
808
853
  for (let mi = 0; mi < messages.length; mi++) {
809
854
  const msg = messages[mi];
810
855
 
@@ -1163,6 +1208,7 @@ export function MessageThread({
1163
1208
  pendingAttachments,
1164
1209
  pendingMessageFailed = false,
1165
1210
  onRetrySend,
1211
+ scrollOnSend = true,
1166
1212
  onRetryExecution,
1167
1213
  onEditMessage,
1168
1214
  className,
@@ -1199,6 +1245,22 @@ export function MessageThread({
1199
1245
 
1200
1246
  useKeyStability(items);
1201
1247
 
1248
+ // Scroll-on-send (stigmer-cloud#267): the send moment is the optimistic
1249
+ // message's empty→present transition — the one signal both render paths
1250
+ // share. A monotonic counter (not the message text) carries it down, so
1251
+ // repeated sends of identical text still pin and a retry of a FAILED send
1252
+ // (pending stays present throughout) deliberately does not re-pin.
1253
+ const [sendSignal, setSendSignal] = useState(0);
1254
+ const wasPendingRef = useRef(false);
1255
+ useEffect(() => {
1256
+ const isPending = !!pendingUserMessage;
1257
+ if (isPending && !wasPendingRef.current) {
1258
+ setSendSignal((n) => n + 1);
1259
+ }
1260
+ wasPendingRef.current = isPending;
1261
+ }, [pendingUserMessage]);
1262
+ const pinToLatestSignal = scrollOnSend ? sendSignal : undefined;
1263
+
1202
1264
  const filePathCtx = useMemo<FilePathContextValue>(
1203
1265
  () => ({
1204
1266
  workspaceEntries: workspaceEntries ?? [],
@@ -1288,6 +1350,7 @@ export function MessageThread({
1288
1350
  onRetryExecution={onRetryExecution}
1289
1351
  onEditMessage={onEditMessage}
1290
1352
  slots={slots}
1353
+ pinToLatestSignal={pinToLatestSignal}
1291
1354
  />
1292
1355
  </Suspense>
1293
1356
  </div>
@@ -1317,6 +1380,7 @@ export function MessageThread({
1317
1380
  onRetryExecution={onRetryExecution}
1318
1381
  onEditMessage={onEditMessage}
1319
1382
  slots={slots}
1383
+ pinToLatestSignal={pinToLatestSignal}
1320
1384
  />
1321
1385
  );
1322
1386
  }
@@ -1352,6 +1416,8 @@ interface NonVirtualizedThreadProps {
1352
1416
  readonly onRetryExecution?: (message: string) => void;
1353
1417
  readonly onEditMessage?: (text: string) => void;
1354
1418
  readonly slots?: MessageThreadSlots;
1419
+ /** Scroll-on-send counter from the parent (see `usePinToLatestOnSignal`). */
1420
+ readonly pinToLatestSignal?: number;
1355
1421
  }
1356
1422
 
1357
1423
  function NonVirtualizedThread({
@@ -1376,9 +1442,11 @@ function NonVirtualizedThread({
1376
1442
  onRetryExecution,
1377
1443
  onEditMessage,
1378
1444
  slots,
1445
+ pinToLatestSignal,
1379
1446
  }: NonVirtualizedThreadProps) {
1380
1447
  const { scrollRef, sentinelRef, contentRef, isFollowing, jumpToLatest } =
1381
1448
  useAutoScroll();
1449
+ usePinToLatestOnSignal(pinToLatestSignal, jumpToLatest);
1382
1450
 
1383
1451
  useDomNodeCount(scrollRef, "MessageThread");
1384
1452
 
@@ -1593,6 +1661,10 @@ export function ThreadItemRenderer({
1593
1661
  />
1594
1662
  );
1595
1663
  }
1664
+ case "recalled-memories": {
1665
+ const Recalled = slots?.RecalledMemoriesCard ?? RecalledMemoriesCard;
1666
+ return <Recalled report={item.report} facts={item.facts} />;
1667
+ }
1596
1668
  case "context-compacted":
1597
1669
  return <SummarizationCard event={item.event} />;
1598
1670
  case "liveness": {
@@ -0,0 +1,164 @@
1
+ "use client";
2
+
3
+ import { memo, useId, useMemo, useState } from "react";
4
+ import type { RecalledMemoriesReport } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
5
+ import type { RecalledMemoryFact } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/spec_pb";
6
+ import { cn } from "@stigmer/theme";
7
+
8
+ /**
9
+ * Resolve the report's injected memory ids against the execution's
10
+ * snapshot facts, preserving snapshot order (the stable prompt order —
11
+ * the selector re-sorts its subset the same way, so this join renders
12
+ * the facts exactly as the agent saw them).
13
+ *
14
+ * The contract guarantees injected ids are a subset of the snapshot
15
+ * (the merge path's never-invent pin), so an unknown id can only mean a
16
+ * bug upstream — it is skipped, never invented. The summary count stays
17
+ * the wire truth (`injectedMemoryIds.length`) regardless.
18
+ */
19
+ export function resolveInjectedFacts(
20
+ report: RecalledMemoriesReport,
21
+ facts: readonly RecalledMemoryFact[],
22
+ ): RecalledMemoryFact[] {
23
+ const injected = new Set(report.injectedMemoryIds);
24
+ return facts.filter((fact) => injected.has(fact.memoryId));
25
+ }
26
+
27
+ /** Props for {@link RecalledMemoriesCard}. */
28
+ export interface RecalledMemoriesCardProps {
29
+ /** The runner's injection report from the execution's status. */
30
+ readonly report: RecalledMemoriesReport;
31
+ /** The full candidate set from `spec.recalled_memories.facts`. */
32
+ readonly facts: readonly RecalledMemoryFact[];
33
+ /** Additional CSS classes for the root element. */
34
+ readonly className?: string;
35
+ }
36
+
37
+ /**
38
+ * Inline timeline card disclosing the semantic retriever's selection for
39
+ * one execution: "Recalled N of M memories", expandable to the injected
40
+ * facts (stigmer/stigmer#293 Phase 3a, DD-008 D5's transparency promise).
41
+ *
42
+ * Renders at the top of the execution's {@link MessageThread} segment,
43
+ * right after the user's turn — `spec.message` is the query the
44
+ * retriever embedded, so the card reads as "for this message, these
45
+ * memories were most relevant".
46
+ *
47
+ * Renders NOTHING unless the report says selection was active: an
48
+ * absent report and `selection_active=false` both mean wholesale (every
49
+ * snapshot fact injected — the shipped Phase 2 behavior), and unchanged
50
+ * behavior earns no UI noise. The same rule is applied by the thread
51
+ * builder; it is repeated here so directly-embedding platform builders
52
+ * get the correct contract by construction.
53
+ *
54
+ * All visual properties flow through `--stgm-*` tokens.
55
+ *
56
+ * @see resolveInjectedFacts - the report→snapshot join this card renders
57
+ * @see SummarizationCard - the sibling system-event card this follows
58
+ */
59
+ export const RecalledMemoriesCard = memo(function RecalledMemoriesCard({
60
+ report,
61
+ facts,
62
+ className,
63
+ }: RecalledMemoriesCardProps) {
64
+ const [expanded, setExpanded] = useState(false);
65
+ const listId = useId();
66
+
67
+ const injectedFacts = useMemo(
68
+ () => resolveInjectedFacts(report, facts),
69
+ [report, facts],
70
+ );
71
+
72
+ if (!report.selectionActive) return null;
73
+
74
+ const injectedCount = report.injectedMemoryIds.length;
75
+ const summary = `Recalled ${injectedCount} of ${facts.length} memories`;
76
+
77
+ return (
78
+ <div
79
+ role="status"
80
+ aria-label={summary}
81
+ className={cn(
82
+ "stg:mx-4 stg:rounded-md stg:border stg:border-border/50 stg:bg-muted/30",
83
+ className,
84
+ )}
85
+ >
86
+ <button
87
+ type="button"
88
+ aria-expanded={expanded}
89
+ aria-controls={listId}
90
+ onClick={() => setExpanded((prev) => !prev)}
91
+ className={cn(
92
+ "stg:flex stg:w-full stg:items-center stg:gap-3 stg:px-3 stg:py-2 stg:text-left",
93
+ "stg:text-xs stg:text-muted-foreground hover:stg:text-foreground",
94
+ )}
95
+ >
96
+ <MemoryIcon />
97
+ <span className="stg:flex stg:min-w-0 stg:flex-1 stg:flex-wrap stg:items-baseline stg:gap-x-2">
98
+ <span className="stg:font-medium">{summary}</span>
99
+ {report.embeddingModel && (
100
+ <>
101
+ <span aria-hidden="true" className="stg:text-muted-foreground/50">&middot;</span>
102
+ <span className="stg:text-muted-foreground/80">{report.embeddingModel}</span>
103
+ </>
104
+ )}
105
+ </span>
106
+ <ChevronIcon expanded={expanded} />
107
+ </button>
108
+ {expanded && (
109
+ <ul
110
+ id={listId}
111
+ className="stg:m-0 stg:flex stg:list-none stg:flex-col stg:gap-1.5 stg:border-t stg:border-border/50 stg:px-3 stg:py-2"
112
+ >
113
+ {injectedFacts.map((fact) => (
114
+ <li
115
+ key={fact.memoryId}
116
+ className="stg:text-xs stg:leading-relaxed stg:text-muted-foreground"
117
+ >
118
+ {fact.content}
119
+ </li>
120
+ ))}
121
+ </ul>
122
+ )}
123
+ </div>
124
+ );
125
+ });
126
+
127
+ function MemoryIcon() {
128
+ return (
129
+ <svg
130
+ width="16"
131
+ height="16"
132
+ viewBox="0 0 16 16"
133
+ fill="none"
134
+ stroke="currentColor"
135
+ strokeWidth="1.5"
136
+ strokeLinecap="round"
137
+ strokeLinejoin="round"
138
+ className="stg:shrink-0 stg:text-muted-foreground/70"
139
+ aria-hidden="true"
140
+ >
141
+ <path d="M8 2a3 3 0 0 0-3 3c-1.2.4-2 1.5-2 2.8 0 1 .5 1.9 1.3 2.4A2.8 2.8 0 0 0 7 13.8c.4.1.7.2 1 .2s.6-.1 1-.2a2.8 2.8 0 0 0 2.7-3.6c.8-.5 1.3-1.4 1.3-2.4 0-1.3-.8-2.4-2-2.8a3 3 0 0 0-3-3z" />
142
+ <path d="M8 2v12" />
143
+ </svg>
144
+ );
145
+ }
146
+
147
+ function ChevronIcon({ expanded }: { expanded: boolean }) {
148
+ return (
149
+ <svg
150
+ width="14"
151
+ height="14"
152
+ viewBox="0 0 16 16"
153
+ fill="none"
154
+ stroke="currentColor"
155
+ strokeWidth="1.5"
156
+ strokeLinecap="round"
157
+ strokeLinejoin="round"
158
+ className="stg:shrink-0 stg:text-muted-foreground/70"
159
+ aria-hidden="true"
160
+ >
161
+ {expanded ? <path d="M4 10l4-4 4 4" /> : <path d="M4 6l4 4 4-4" />}
162
+ </svg>
163
+ );
164
+ }
@@ -0,0 +1,123 @@
1
+ // The retriever transparency card (stigmer/stigmer#293 Phase 3a, DD-008
2
+ // D5): an honest "Recalled N of M memories" join of the status report
3
+ // against the spec snapshot — and NOTHING for wholesale, the majority
4
+ // case that must stay noise-free.
5
+
6
+ import { describe, it, expect, afterEach } from "vitest";
7
+ import { cleanup, render, screen } from "@testing-library/react";
8
+ import userEvent from "@testing-library/user-event";
9
+ import { create } from "@bufbuild/protobuf";
10
+ import {
11
+ RecalledMemoriesReportSchema,
12
+ type RecalledMemoriesReport,
13
+ } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
14
+ import {
15
+ RecalledMemoryFactSchema,
16
+ type RecalledMemoryFact,
17
+ } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/spec_pb";
18
+ import {
19
+ RecalledMemoriesCard,
20
+ resolveInjectedFacts,
21
+ } from "../RecalledMemoriesCard";
22
+
23
+ function makeFact(id: string, content: string): RecalledMemoryFact {
24
+ return create(RecalledMemoryFactSchema, { memoryId: id, content });
25
+ }
26
+
27
+ function makeReport(
28
+ overrides: Partial<Pick<RecalledMemoriesReport, "selectionActive" | "injectedMemoryIds" | "embeddingModel">>,
29
+ ): RecalledMemoriesReport {
30
+ return create(RecalledMemoriesReportSchema, {
31
+ selectionActive: true,
32
+ embeddingModel: "text-embedding-3-small",
33
+ ...overrides,
34
+ });
35
+ }
36
+
37
+ const SNAPSHOT: RecalledMemoryFact[] = [
38
+ makeFact("mem_a", "Prefers concise answers."),
39
+ makeFact("mem_b", "Works in the Europe/Berlin timezone."),
40
+ makeFact("mem_c", "Deploys with Bazel."),
41
+ makeFact("mem_d", "Reviews PRs on Fridays."),
42
+ ];
43
+
44
+ afterEach(cleanup);
45
+
46
+ describe("resolveInjectedFacts", () => {
47
+ it("preserves snapshot order regardless of the report's id order", () => {
48
+ const report = makeReport({ injectedMemoryIds: ["mem_d", "mem_a"] });
49
+ expect(resolveInjectedFacts(report, SNAPSHOT).map((f) => f.memoryId)).toEqual([
50
+ "mem_a",
51
+ "mem_d",
52
+ ]);
53
+ });
54
+
55
+ it("skips an unknown id — never invents a fact", () => {
56
+ const report = makeReport({ injectedMemoryIds: ["mem_b", "mem_ghost"] });
57
+ expect(resolveInjectedFacts(report, SNAPSHOT).map((f) => f.memoryId)).toEqual([
58
+ "mem_b",
59
+ ]);
60
+ });
61
+
62
+ it("returns nothing against an empty snapshot", () => {
63
+ const report = makeReport({ injectedMemoryIds: ["mem_a"] });
64
+ expect(resolveInjectedFacts(report, [])).toEqual([]);
65
+ });
66
+ });
67
+
68
+ describe("RecalledMemoriesCard", () => {
69
+ it("renders nothing for a wholesale report (selection_active=false)", () => {
70
+ const report = makeReport({
71
+ selectionActive: false,
72
+ injectedMemoryIds: [],
73
+ embeddingModel: "",
74
+ });
75
+ const { container } = render(
76
+ <RecalledMemoriesCard report={report} facts={SNAPSHOT} />,
77
+ );
78
+ expect(container.innerHTML).toBe("");
79
+ });
80
+
81
+ it("summarizes the selection with the wire count and the embedding model", () => {
82
+ const report = makeReport({ injectedMemoryIds: ["mem_a", "mem_c"] });
83
+ render(<RecalledMemoriesCard report={report} facts={SNAPSHOT} />);
84
+
85
+ expect(screen.getByRole("status", { name: "Recalled 2 of 4 memories" })).toBeTruthy();
86
+ expect(screen.getByText("text-embedding-3-small")).toBeTruthy();
87
+ // Collapsed by default: the facts are behind the disclosure.
88
+ expect(screen.queryByText("Prefers concise answers.")).toBeNull();
89
+ });
90
+
91
+ it("expands to exactly the injected facts, in snapshot order", async () => {
92
+ const report = makeReport({ injectedMemoryIds: ["mem_c", "mem_a"] });
93
+ render(<RecalledMemoriesCard report={report} facts={SNAPSHOT} />);
94
+ const user = userEvent.setup();
95
+
96
+ const toggle = screen.getByRole("button", { expanded: false });
97
+ expect(toggle.getAttribute("aria-controls")).toBeTruthy();
98
+
99
+ await user.click(toggle);
100
+
101
+ expect(toggle.getAttribute("aria-expanded")).toBe("true");
102
+ const items = screen.getAllByRole("listitem").map((li) => li.textContent);
103
+ expect(items).toEqual(["Prefers concise answers.", "Deploys with Bazel."]);
104
+ // Non-injected snapshot facts never render.
105
+ expect(screen.queryByText("Works in the Europe/Berlin timezone.")).toBeNull();
106
+
107
+ await user.click(toggle);
108
+ expect(toggle.getAttribute("aria-expanded")).toBe("false");
109
+ expect(screen.queryByRole("listitem")).toBeNull();
110
+ });
111
+
112
+ it("keeps the summary count honest when an id cannot be resolved", async () => {
113
+ // Contractually impossible (injected ids ⊂ snapshot, the merge path's
114
+ // never-invent pin) — but if it ever happens, the count stays the wire
115
+ // truth and the list simply omits the unresolvable entry.
116
+ const report = makeReport({ injectedMemoryIds: ["mem_b", "mem_ghost"] });
117
+ render(<RecalledMemoriesCard report={report} facts={SNAPSHOT} />);
118
+
119
+ expect(screen.getByRole("status", { name: "Recalled 2 of 4 memories" })).toBeTruthy();
120
+ await userEvent.setup().click(screen.getByRole("button"));
121
+ expect(screen.getAllByRole("listitem")).toHaveLength(1);
122
+ });
123
+ });