@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
|
@@ -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>
|
package/src/execution/index.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
129
|
-
//
|
|
130
|
-
//
|
|
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 :
|
|
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);
|