@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.
- package/README.md +1 -3
- package/conversation/ConversationTimelineView.d.ts +15 -1
- package/conversation/ConversationTimelineView.d.ts.map +1 -1
- package/conversation/ConversationTimelineView.js +3 -2
- package/conversation/ConversationTimelineView.js.map +1 -1
- package/conversation/ConversationsWorkbench.d.ts +14 -1
- package/conversation/ConversationsWorkbench.d.ts.map +1 -1
- package/conversation/ConversationsWorkbench.js +11 -2
- package/conversation/ConversationsWorkbench.js.map +1 -1
- package/execution/MessageThread.d.ts +33 -2
- package/execution/MessageThread.d.ts.map +1 -1
- package/execution/MessageThread.js +44 -6
- package/execution/MessageThread.js.map +1 -1
- package/execution/RecalledMemoriesCard.d.ts +47 -0
- package/execution/RecalledMemoriesCard.d.ts.map +1 -0
- package/execution/RecalledMemoriesCard.js +58 -0
- package/execution/RecalledMemoriesCard.js.map +1 -0
- package/execution/index.d.ts +2 -0
- package/execution/index.d.ts.map +1 -1
- package/execution/index.js +1 -0
- package/execution/index.js.map +1 -1
- package/identity-account/AccountPreferencesPanel.js +1 -1
- package/identity-account/AccountPreferencesPanel.js.map +1 -1
- package/index.d.ts +2 -2
- package/index.d.ts.map +1 -1
- package/index.js +1 -1
- package/index.js.map +1 -1
- package/internal/VirtualizedThread.d.ts +3 -1
- package/internal/VirtualizedThread.d.ts.map +1 -1
- package/internal/VirtualizedThread.js +3 -1
- package/internal/VirtualizedThread.js.map +1 -1
- package/internal/useAutoScroll.d.ts +18 -0
- package/internal/useAutoScroll.d.ts.map +1 -1
- package/internal/useAutoScroll.js +50 -0
- package/internal/useAutoScroll.js.map +1 -1
- package/organization/OrgPreferencesPanel.js +1 -1
- package/organization/OrgPreferencesPanel.js.map +1 -1
- package/package.json +4 -4
- package/settings/MemorySection.d.ts.map +1 -1
- package/settings/MemorySection.js +1 -1
- package/settings/MemorySection.js.map +1 -1
- package/src/conversation/ConversationTimelineView.tsx +17 -1
- package/src/conversation/ConversationsWorkbench.tsx +25 -0
- package/src/conversation/__tests__/ConversationTimelineView.layout.test.tsx +37 -1
- package/src/execution/MessageThread.tsx +75 -3
- package/src/execution/RecalledMemoriesCard.tsx +164 -0
- package/src/execution/__tests__/RecalledMemoriesCard.test.tsx +123 -0
- package/src/execution/__tests__/message-thread-recalled-memories.test.tsx +216 -0
- package/src/execution/__tests__/message-thread-scroll-on-send.test.tsx +141 -0
- package/src/execution/__tests__/message-thread-slots.test.tsx +36 -0
- package/src/execution/index.ts +2 -0
- package/src/identity-account/AccountPreferencesPanel.tsx +1 -1
- package/src/index.ts +3 -0
- package/src/internal/VirtualizedThread.tsx +5 -0
- package/src/internal/__tests__/useAutoScroll.layout.test.tsx +105 -4
- package/src/internal/__tests__/useAutoScroll.test.tsx +41 -1
- package/src/internal/useAutoScroll.ts +54 -0
- package/src/organization/OrgPreferencesPanel.tsx +1 -1
- package/src/settings/MemorySection.tsx +4 -2
- package/src/workflow/__tests__/WorkflowExecutionViewer.approvals.test.tsx +26 -7
- package/src/workflow/__tests__/task-presentation.test.ts +49 -12
- package/src/workflow/__tests__/workflow-thread-scroll-on-send.test.tsx +145 -0
- package/src/workflow/thread/WorkflowTaskThread.tsx +59 -32
- package/src/workflow/thread/task-presentation.ts +52 -7
- package/workflow/thread/WorkflowTaskThread.d.ts +13 -0
- package/workflow/thread/WorkflowTaskThread.d.ts.map +1 -1
- package/workflow/thread/WorkflowTaskThread.js +33 -25
- package/workflow/thread/WorkflowTaskThread.js.map +1 -1
- package/workflow/thread/task-presentation.d.ts.map +1 -1
- package/workflow/thread/task-presentation.js +50 -7
- 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
|
-
|
|
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">·</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
|
+
});
|