@stigmer/react 3.7.0 → 3.8.0

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 (177) hide show
  1. package/channel/AgentChannelsPanel.d.ts.map +1 -1
  2. package/channel/AgentChannelsPanel.js +21 -2
  3. package/channel/AgentChannelsPanel.js.map +1 -1
  4. package/channel/ChannelConversationsDialog.d.ts +12 -4
  5. package/channel/ChannelConversationsDialog.d.ts.map +1 -1
  6. package/channel/ChannelConversationsDialog.js +18 -24
  7. package/channel/ChannelConversationsDialog.js.map +1 -1
  8. package/channel/providers.d.ts +23 -0
  9. package/channel/providers.d.ts.map +1 -1
  10. package/channel/providers.js +4 -0
  11. package/channel/providers.js.map +1 -1
  12. package/channel/useSaveAgentChannel.d.ts +5 -4
  13. package/channel/useSaveAgentChannel.d.ts.map +1 -1
  14. package/channel/useSaveAgentChannel.js +18 -4
  15. package/channel/useSaveAgentChannel.js.map +1 -1
  16. package/composer/ComposerToolbar.d.ts +1 -1
  17. package/composer/ComposerToolbar.d.ts.map +1 -1
  18. package/composer/SessionComposer.d.ts +1 -1
  19. package/composer/SessionComposer.js +1 -1
  20. package/composer/SessionComposer.js.map +1 -1
  21. package/conversation/ConversationAttentionBanner.d.ts +22 -0
  22. package/conversation/ConversationAttentionBanner.d.ts.map +1 -0
  23. package/conversation/ConversationAttentionBanner.js +25 -0
  24. package/conversation/ConversationAttentionBanner.js.map +1 -0
  25. package/conversation/ConversationComposer.d.ts +41 -0
  26. package/conversation/ConversationComposer.d.ts.map +1 -0
  27. package/conversation/ConversationComposer.js +57 -0
  28. package/conversation/ConversationComposer.js.map +1 -0
  29. package/conversation/ConversationControlBanner.d.ts +52 -0
  30. package/conversation/ConversationControlBanner.d.ts.map +1 -0
  31. package/conversation/ConversationControlBanner.js +59 -0
  32. package/conversation/ConversationControlBanner.js.map +1 -0
  33. package/conversation/ConversationListPane.d.ts +68 -0
  34. package/conversation/ConversationListPane.d.ts.map +1 -0
  35. package/conversation/ConversationListPane.js +120 -0
  36. package/conversation/ConversationListPane.js.map +1 -0
  37. package/conversation/ConversationTimelineView.d.ts +46 -0
  38. package/conversation/ConversationTimelineView.d.ts.map +1 -0
  39. package/conversation/ConversationTimelineView.js +166 -0
  40. package/conversation/ConversationTimelineView.js.map +1 -0
  41. package/conversation/ConversationsWorkbench.d.ts +63 -0
  42. package/conversation/ConversationsWorkbench.d.ts.map +1 -0
  43. package/conversation/ConversationsWorkbench.js +165 -0
  44. package/conversation/ConversationsWorkbench.js.map +1 -0
  45. package/conversation/conversationPresentation.d.ts +137 -0
  46. package/conversation/conversationPresentation.d.ts.map +1 -0
  47. package/conversation/conversationPresentation.js +230 -0
  48. package/conversation/conversationPresentation.js.map +1 -0
  49. package/conversation/index.d.ts +26 -0
  50. package/conversation/index.d.ts.map +1 -0
  51. package/conversation/index.js +14 -0
  52. package/conversation/index.js.map +1 -0
  53. package/conversation/polling.d.ts +39 -0
  54. package/conversation/polling.d.ts.map +1 -0
  55. package/conversation/polling.js +39 -0
  56. package/conversation/polling.js.map +1 -0
  57. package/conversation/useConversation.d.ts +61 -0
  58. package/conversation/useConversation.d.ts.map +1 -0
  59. package/conversation/useConversation.js +153 -0
  60. package/conversation/useConversation.js.map +1 -0
  61. package/conversation/useConversationList.d.ts +98 -0
  62. package/conversation/useConversationList.d.ts.map +1 -0
  63. package/conversation/useConversationList.js +200 -0
  64. package/conversation/useConversationList.js.map +1 -0
  65. package/conversation/useConversationParticipation.d.ts +63 -0
  66. package/conversation/useConversationParticipation.d.ts.map +1 -0
  67. package/conversation/useConversationParticipation.js +84 -0
  68. package/conversation/useConversationParticipation.js.map +1 -0
  69. package/conversation/useConversationTimeline.d.ts +71 -0
  70. package/conversation/useConversationTimeline.d.ts.map +1 -0
  71. package/conversation/useConversationTimeline.js +152 -0
  72. package/conversation/useConversationTimeline.js.map +1 -0
  73. package/conversation/useConversationsWantsHumanCount.d.ts +42 -0
  74. package/conversation/useConversationsWantsHumanCount.d.ts.map +1 -0
  75. package/conversation/useConversationsWantsHumanCount.js +40 -0
  76. package/conversation/useConversationsWantsHumanCount.js.map +1 -0
  77. package/index.d.ts +4 -0
  78. package/index.d.ts.map +1 -1
  79. package/index.js +8 -0
  80. package/index.js.map +1 -1
  81. package/internal/tooltip.d.ts.map +1 -1
  82. package/internal/tooltip.js +7 -1
  83. package/internal/tooltip.js.map +1 -1
  84. package/internal/useAutoScroll.d.ts +25 -4
  85. package/internal/useAutoScroll.d.ts.map +1 -1
  86. package/internal/useAutoScroll.js +55 -15
  87. package/internal/useAutoScroll.js.map +1 -1
  88. package/internal/useFetch.d.ts.map +1 -1
  89. package/internal/useFetch.js +14 -4
  90. package/internal/useFetch.js.map +1 -1
  91. package/models/ModelSelector.d.ts +24 -5
  92. package/models/ModelSelector.d.ts.map +1 -1
  93. package/models/ModelSelector.js +31 -9
  94. package/models/ModelSelector.js.map +1 -1
  95. package/models/registry.d.ts +3 -2
  96. package/models/registry.d.ts.map +1 -1
  97. package/models/registry.js.map +1 -1
  98. package/models/service-tier.d.ts +1 -1
  99. package/models/service-tier.d.ts.map +1 -1
  100. package/models/service-tier.js +1 -1
  101. package/models/service-tier.js.map +1 -1
  102. package/package.json +4 -4
  103. package/schedule/ScheduleDetailView.js +1 -1
  104. package/schedule/ScheduleDetailView.js.map +1 -1
  105. package/schedule/ScheduleForm.d.ts.map +1 -1
  106. package/schedule/ScheduleForm.js +7 -6
  107. package/schedule/ScheduleForm.js.map +1 -1
  108. package/sharing/useSaveAgentShare.d.ts +10 -1
  109. package/sharing/useSaveAgentShare.d.ts.map +1 -1
  110. package/sharing/useSaveAgentShare.js +9 -0
  111. package/sharing/useSaveAgentShare.js.map +1 -1
  112. package/sidebar/WorkspaceSidebar.d.ts +12 -4
  113. package/sidebar/WorkspaceSidebar.d.ts.map +1 -1
  114. package/sidebar/WorkspaceSidebar.js +19 -7
  115. package/sidebar/WorkspaceSidebar.js.map +1 -1
  116. package/src/channel/AgentChannelsPanel.tsx +33 -2
  117. package/src/channel/ChannelConversationsDialog.tsx +22 -25
  118. package/src/channel/__tests__/AgentChannelsPanel.test.tsx +36 -5
  119. package/src/channel/__tests__/ChannelConversationsDialog.test.tsx +38 -1
  120. package/src/channel/__tests__/useSaveAgentChannel.test.tsx +26 -0
  121. package/src/channel/providers.ts +27 -0
  122. package/src/channel/useSaveAgentChannel.ts +18 -4
  123. package/src/composer/ComposerToolbar.tsx +1 -1
  124. package/src/composer/SessionComposer.tsx +2 -2
  125. package/src/composer/__tests__/SessionComposer-serviceTier.test.tsx +4 -3
  126. package/src/conversation/ConversationAttentionBanner.tsx +77 -0
  127. package/src/conversation/ConversationComposer.tsx +156 -0
  128. package/src/conversation/ConversationControlBanner.tsx +193 -0
  129. package/src/conversation/ConversationListPane.tsx +459 -0
  130. package/src/conversation/ConversationTimelineView.tsx +435 -0
  131. package/src/conversation/ConversationsWorkbench.tsx +378 -0
  132. package/src/conversation/__tests__/ConversationAttentionBanner.test.tsx +82 -0
  133. package/src/conversation/__tests__/ConversationComposer.test.tsx +160 -0
  134. package/src/conversation/__tests__/ConversationControlBanner.test.tsx +190 -0
  135. package/src/conversation/__tests__/ConversationListPane.test.tsx +372 -0
  136. package/src/conversation/__tests__/ConversationTimelineView.layout.test.tsx +160 -0
  137. package/src/conversation/__tests__/ConversationTimelineView.test.tsx +405 -0
  138. package/src/conversation/__tests__/ConversationsWorkbench.test.tsx +860 -0
  139. package/src/conversation/__tests__/a11y/conversation-surfaces.a11y.test.tsx +235 -0
  140. package/src/conversation/__tests__/a11y/harness.tsx +100 -0
  141. package/src/conversation/__tests__/conversation-tooltips.layout.test.tsx +167 -0
  142. package/src/conversation/__tests__/conversationPollingComposition.test.tsx +138 -0
  143. package/src/conversation/__tests__/conversationPresentation.test.ts +289 -0
  144. package/src/conversation/__tests__/useConversation.test.tsx +243 -0
  145. package/src/conversation/__tests__/useConversationList.test.tsx +492 -0
  146. package/src/conversation/__tests__/useConversationParticipation.test.tsx +190 -0
  147. package/src/conversation/__tests__/useConversationTimeline.test.tsx +258 -0
  148. package/src/conversation/__tests__/useConversationsWantsHumanCount.test.tsx +102 -0
  149. package/src/conversation/conversationPresentation.ts +305 -0
  150. package/src/conversation/index.ts +80 -0
  151. package/src/conversation/polling.ts +41 -0
  152. package/src/conversation/useConversation.ts +221 -0
  153. package/src/conversation/useConversationList.ts +328 -0
  154. package/src/conversation/useConversationParticipation.ts +178 -0
  155. package/src/conversation/useConversationTimeline.ts +244 -0
  156. package/src/conversation/useConversationsWantsHumanCount.ts +86 -0
  157. package/src/index.ts +67 -0
  158. package/src/internal/__tests__/fake-timer-slices.ts +29 -0
  159. package/src/internal/__tests__/useAutoScroll.layout.test.tsx +143 -0
  160. package/src/internal/__tests__/useAutoScroll.test.tsx +62 -5
  161. package/src/internal/__tests__/useFetch-poll-under-render-pressure.test.tsx +79 -0
  162. package/src/internal/tooltip.tsx +6 -1
  163. package/src/internal/useAutoScroll.ts +66 -20
  164. package/src/internal/useFetch.ts +15 -4
  165. package/src/models/ModelSelector.tsx +81 -49
  166. package/src/models/__tests__/ModelSelector-serviceTier.test.tsx +105 -21
  167. package/src/models/registry.ts +3 -2
  168. package/src/models/service-tier.ts +1 -1
  169. package/src/schedule/ScheduleDetailView.tsx +1 -1
  170. package/src/schedule/ScheduleForm.tsx +7 -6
  171. package/src/schedule/__tests__/scheduleCreation.test.tsx +2 -2
  172. package/src/sharing/__tests__/useSaveAgentShare.test.tsx +55 -1
  173. package/src/sharing/__tests__/useShareToolReadiness.test.tsx +1 -0
  174. package/src/sharing/useSaveAgentShare.ts +19 -1
  175. package/src/sidebar/WorkspaceSidebar.tsx +62 -3
  176. package/src/sidebar/__tests__/WorkspaceSidebar.test.tsx +56 -1
  177. package/styles.css +1 -1
@@ -0,0 +1,492 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
3
+ import type { ReactNode } from "react";
4
+
5
+ // The focus-refetch test broadcasts window events, which reach EVERY
6
+ // still-mounted hook in this file — including earlier tests' hooks whose
7
+ // one-shot mock chains are exhausted. Unmount between tests so a window
8
+ // broadcast only ever exercises the test that sent it.
9
+ afterEach(cleanup);
10
+ import { create } from "@bufbuild/protobuf";
11
+ import { timestampFromDate } from "@bufbuild/protobuf/wkt";
12
+ import {
13
+ ChannelConversationListFilter,
14
+ ChannelConversationSchema,
15
+ ConversationControl,
16
+ } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/conversation_io_pb";
17
+ import { StigmerContext } from "../../context";
18
+ import { FetchCacheContext } from "../../internal/FetchCacheProvider";
19
+ import { useConversationList } from "../useConversationList";
20
+
21
+ function conversation(channelId: string, key: string, displayName = "") {
22
+ return create(ChannelConversationSchema, {
23
+ agentChannelId: channelId,
24
+ conversationKey: key,
25
+ org: "acme",
26
+ displayName,
27
+ });
28
+ }
29
+
30
+ function conversationAt(
31
+ channelId: string,
32
+ key: string,
33
+ lastActivity: string,
34
+ overrides?: { control?: ConversationControl; displayName?: string },
35
+ ) {
36
+ return create(ChannelConversationSchema, {
37
+ agentChannelId: channelId,
38
+ conversationKey: key,
39
+ org: "acme",
40
+ control: overrides?.control ?? ConversationControl.control_agent,
41
+ displayName: overrides?.displayName ?? "",
42
+ lastActivityAt: timestampFromDate(new Date(lastActivity)),
43
+ });
44
+ }
45
+
46
+ function deferred<T>() {
47
+ let resolve!: (value: T) => void;
48
+ const promise = new Promise<T>((r) => {
49
+ resolve = r;
50
+ });
51
+ return { promise, resolve };
52
+ }
53
+
54
+ function createMockStigmer(listConversations: (input: unknown) => Promise<unknown>) {
55
+ return { agentChannel: { listConversations } } as never;
56
+ }
57
+
58
+ function wrapper(client: unknown) {
59
+ return function Wrapper({ children }: { children: ReactNode }) {
60
+ return (
61
+ <FetchCacheContext.Provider value={null}>
62
+ <StigmerContext.Provider value={client as never}>
63
+ {children}
64
+ </StigmerContext.Provider>
65
+ </FetchCacheContext.Provider>
66
+ );
67
+ };
68
+ }
69
+
70
+ const NO_POLL = { refetchIntervalMs: false as const };
71
+
72
+ describe("useConversationList", () => {
73
+ beforeEach(() => {
74
+ vi.restoreAllMocks();
75
+ });
76
+
77
+ it("loads the org's head page with the exact server total", async () => {
78
+ const listConversations = vi.fn().mockResolvedValue({
79
+ items: [conversation("ach_1", "15550001111", "Pat")],
80
+ totalCount: 12,
81
+ });
82
+ const { result } = renderHook(
83
+ () => useConversationList({ org: "acme", ...NO_POLL }),
84
+ { wrapper: wrapper(createMockStigmer(listConversations)) },
85
+ );
86
+
87
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
88
+
89
+ expect(result.current.conversations).toHaveLength(1);
90
+ expect(result.current.totalCount).toBe(12);
91
+ expect(result.current.hasMore).toBe(true);
92
+ expect(listConversations).toHaveBeenCalledWith(
93
+ expect.objectContaining({
94
+ org: "acme",
95
+ agentChannelId: "",
96
+ pageInfo: expect.objectContaining({ num: 1, size: 50 }),
97
+ }),
98
+ );
99
+ });
100
+
101
+ it("passes the channel filter through", async () => {
102
+ const listConversations = vi.fn().mockResolvedValue({ items: [], totalCount: 0 });
103
+ renderHook(
104
+ () => useConversationList({ org: "acme", agentChannelId: "ach_9", ...NO_POLL }),
105
+ { wrapper: wrapper(createMockStigmer(listConversations)) },
106
+ );
107
+
108
+ await waitFor(() =>
109
+ expect(listConversations).toHaveBeenCalledWith(
110
+ expect.objectContaining({ agentChannelId: "ach_9" }),
111
+ ),
112
+ );
113
+ });
114
+
115
+ it("skips fetching without an org (stable no-op)", async () => {
116
+ const listConversations = vi.fn();
117
+ const { result } = renderHook(() => useConversationList({ org: null, ...NO_POLL }), {
118
+ wrapper: wrapper(createMockStigmer(listConversations)),
119
+ });
120
+
121
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
122
+ expect(listConversations).not.toHaveBeenCalled();
123
+ expect(result.current.conversations).toEqual([]);
124
+ });
125
+
126
+ it("accumulates older pages and dedups a conversation that moved between pages", async () => {
127
+ // Offset pages drift under live activity: 15550002222 sat on page 2
128
+ // when it was fetched, then new activity moved it into the polled
129
+ // head. It must render ONCE, in its head (fresher) position.
130
+ const head = [
131
+ conversation("ach_1", "15550002222", "Moved Up"),
132
+ conversation("ach_1", "15550001111"),
133
+ ];
134
+ const pageTwo = [
135
+ conversation("ach_1", "15550002222", "Stale Copy"),
136
+ conversation("ach_1", "15550003333"),
137
+ ];
138
+ const listConversations = vi
139
+ .fn()
140
+ .mockResolvedValueOnce({ items: head, totalCount: 4 })
141
+ .mockResolvedValueOnce({ items: pageTwo, totalCount: 4 });
142
+
143
+ const { result } = renderHook(
144
+ () => useConversationList({ org: "acme", pageSize: 2, ...NO_POLL }),
145
+ { wrapper: wrapper(createMockStigmer(listConversations)) },
146
+ );
147
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
148
+
149
+ act(() => result.current.loadMore());
150
+ await waitFor(() => expect(result.current.isLoadingMore).toBe(false));
151
+
152
+ expect(listConversations).toHaveBeenLastCalledWith(
153
+ expect.objectContaining({ pageInfo: expect.objectContaining({ num: 2, size: 2 }) }),
154
+ );
155
+ const keys = result.current.conversations.map((c) => c.conversationKey);
156
+ expect(keys).toEqual(["15550002222", "15550001111", "15550003333"]);
157
+ expect(result.current.conversations[0].displayName).toBe("Moved Up");
158
+ // 3 loaded of a real total of 4.
159
+ expect(result.current.hasMore).toBe(true);
160
+ });
161
+
162
+ it("records a loadMore failure without disturbing the loaded list", async () => {
163
+ const listConversations = vi
164
+ .fn()
165
+ .mockResolvedValueOnce({
166
+ items: [conversation("ach_1", "15550001111")],
167
+ totalCount: 5,
168
+ })
169
+ .mockRejectedValueOnce(new Error("boom"));
170
+
171
+ const { result } = renderHook(
172
+ () => useConversationList({ org: "acme", ...NO_POLL }),
173
+ { wrapper: wrapper(createMockStigmer(listConversations)) },
174
+ );
175
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
176
+
177
+ act(() => result.current.loadMore());
178
+ await waitFor(() => expect(result.current.loadMoreError).not.toBeNull());
179
+
180
+ expect(result.current.conversations).toHaveLength(1);
181
+ expect(result.current.error).toBeNull();
182
+ });
183
+
184
+ it("drops accumulated pages when the filter changes — page 2 of channel A is not page 2 of channel B", async () => {
185
+ const listConversations = vi
186
+ .fn()
187
+ .mockResolvedValueOnce({
188
+ items: [conversation("ach_1", "15550001111")],
189
+ totalCount: 3,
190
+ })
191
+ .mockResolvedValueOnce({
192
+ items: [conversation("ach_1", "15550002222")],
193
+ totalCount: 3,
194
+ })
195
+ .mockResolvedValue({ items: [], totalCount: 0 });
196
+
197
+ const { result, rerender } = renderHook(
198
+ ({ channel }: { channel: string }) =>
199
+ useConversationList({ org: "acme", agentChannelId: channel, ...NO_POLL }),
200
+ {
201
+ wrapper: wrapper(createMockStigmer(listConversations)),
202
+ initialProps: { channel: "ach_1" },
203
+ },
204
+ );
205
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
206
+ act(() => result.current.loadMore());
207
+ await waitFor(() => expect(result.current.conversations).toHaveLength(2));
208
+
209
+ rerender({ channel: "ach_2" });
210
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
211
+
212
+ expect(result.current.conversations).toEqual([]);
213
+ expect(result.current.totalCount).toBe(0);
214
+ });
215
+
216
+ it("passes the predicate filter through and drops accumulated pages when it changes", async () => {
217
+ const listConversations = vi
218
+ .fn()
219
+ .mockResolvedValueOnce({
220
+ items: [conversation("ach_1", "15550001111")],
221
+ totalCount: 3,
222
+ })
223
+ .mockResolvedValueOnce({
224
+ items: [conversation("ach_1", "15550002222")],
225
+ totalCount: 3,
226
+ })
227
+ .mockResolvedValue({ items: [], totalCount: 0 });
228
+
229
+ const { result, rerender } = renderHook(
230
+ ({ filter }: { filter: ChannelConversationListFilter }) =>
231
+ useConversationList({ org: "acme", filter, ...NO_POLL }),
232
+ {
233
+ wrapper: wrapper(createMockStigmer(listConversations)),
234
+ initialProps: {
235
+ filter:
236
+ ChannelConversationListFilter.channel_conversation_list_filter_unspecified,
237
+ },
238
+ },
239
+ );
240
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
241
+ act(() => result.current.loadMore());
242
+ await waitFor(() => expect(result.current.conversations).toHaveLength(2));
243
+
244
+ rerender({ filter: ChannelConversationListFilter.filter_wants_human });
245
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
246
+
247
+ expect(listConversations).toHaveBeenLastCalledWith(
248
+ expect.objectContaining({
249
+ filter: ChannelConversationListFilter.filter_wants_human,
250
+ }),
251
+ );
252
+ // Page 2 of "all" is not page 2 of "wants human".
253
+ expect(result.current.conversations).toEqual([]);
254
+ });
255
+
256
+ it("refetches the head when the window regains focus (DD-012: refocus is fresh)", async () => {
257
+ const listConversations = vi
258
+ .fn()
259
+ .mockResolvedValue({ items: [], totalCount: 0 });
260
+ renderHook(() => useConversationList({ org: "acme", ...NO_POLL }), {
261
+ wrapper: wrapper(createMockStigmer(listConversations)),
262
+ });
263
+ await waitFor(() => expect(listConversations).toHaveBeenCalledTimes(1));
264
+
265
+ await act(async () => {
266
+ window.dispatchEvent(new Event("focus"));
267
+ });
268
+
269
+ await waitFor(() => expect(listConversations).toHaveBeenCalledTimes(2));
270
+ });
271
+ });
272
+
273
+ describe("useConversationList.applyServerState (DD-012 D-a: own actions reflect immediately)", () => {
274
+ beforeEach(() => {
275
+ vi.restoreAllMocks();
276
+ });
277
+
278
+ it("overrides a listed row immediately and starts the retiring head fetch", async () => {
279
+ const agentHeld = conversationAt("ach_1", "15550001111", "2026-08-07T11:00:00Z");
280
+ const humanHeld = conversationAt("ach_1", "15550001111", "2026-08-07T11:00:00Z", {
281
+ control: ConversationControl.control_human,
282
+ });
283
+ const postApply = deferred<{ items: unknown[]; totalCount: number }>();
284
+ const listConversations = vi
285
+ .fn()
286
+ .mockResolvedValueOnce({ items: [agentHeld], totalCount: 1 })
287
+ .mockReturnValueOnce(postApply.promise);
288
+
289
+ const { result } = renderHook(
290
+ () => useConversationList({ org: "acme", ...NO_POLL }),
291
+ { wrapper: wrapper(createMockStigmer(listConversations)) },
292
+ );
293
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
294
+ expect(result.current.conversations[0].control).toBe(
295
+ ConversationControl.control_agent,
296
+ );
297
+
298
+ act(() => result.current.applyServerState(humanHeld));
299
+
300
+ // Immediate — no timers, no resolved fetch.
301
+ expect(result.current.conversations[0].control).toBe(
302
+ ConversationControl.control_human,
303
+ );
304
+ // The retiring round-trip started.
305
+ expect(listConversations).toHaveBeenCalledTimes(2);
306
+ });
307
+
308
+ it("a pre-apply head answer resolving after the apply never revives pre-command state; the post-apply answer retires the overlay", async () => {
309
+ const staleAgent = conversationAt("ach_1", "15550001111", "2026-08-07T11:00:00Z", {
310
+ displayName: "Pat",
311
+ });
312
+ const freshHuman = conversationAt("ach_1", "15550001111", "2026-08-07T11:00:00Z", {
313
+ control: ConversationControl.control_human,
314
+ displayName: "Pat",
315
+ });
316
+ // The post-apply server read carries a marker so retiring is observable.
317
+ const serverTruth = conversationAt("ach_1", "15550001111", "2026-08-07T11:00:00Z", {
318
+ control: ConversationControl.control_human,
319
+ displayName: "Pat (server truth)",
320
+ });
321
+ const stale = deferred<{ items: unknown[]; totalCount: number }>();
322
+ const postApply = deferred<{ items: unknown[]; totalCount: number }>();
323
+ const listConversations = vi
324
+ .fn()
325
+ .mockResolvedValueOnce({ items: [staleAgent], totalCount: 1 })
326
+ .mockReturnValueOnce(stale.promise)
327
+ .mockReturnValueOnce(postApply.promise);
328
+
329
+ const { result } = renderHook(
330
+ () => useConversationList({ org: "acme", ...NO_POLL }),
331
+ { wrapper: wrapper(createMockStigmer(listConversations)) },
332
+ );
333
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
334
+
335
+ // A background head refresh is in flight (started BEFORE the command).
336
+ act(() => result.current.refetch());
337
+
338
+ // The command answers while that stale answer is still queued: resolve
339
+ // the stale fetch FIRST, then apply from an async (non-discrete)
340
+ // context — the microtask interleaving applyServerState sees in
341
+ // production, where it is called from a command's promise chain.
342
+ await act(async () => {
343
+ stale.resolve({ items: [staleAgent], totalCount: 1 });
344
+ result.current.applyServerState(freshHuman);
345
+ await Promise.resolve();
346
+ });
347
+
348
+ // Whatever the interleaving did, the rendered row is the command's.
349
+ expect(result.current.conversations[0].control).toBe(
350
+ ConversationControl.control_human,
351
+ );
352
+
353
+ // The post-apply fetch answers: server truth is adopted, overlay gone.
354
+ await act(async () => {
355
+ postApply.resolve({ items: [serverTruth], totalCount: 1 });
356
+ });
357
+ await waitFor(() =>
358
+ expect(result.current.conversations[0].displayName).toBe("Pat (server truth)"),
359
+ );
360
+ expect(result.current.conversations[0].control).toBe(
361
+ ConversationControl.control_human,
362
+ );
363
+ });
364
+
365
+ it("sorted-inserts an unlisted row by activity, never at the head (G-2: a command answer is not a recency claim)", async () => {
366
+ const newest = conversationAt("ach_1", "15550001111", "2026-08-07T12:00:00Z");
367
+ const oldest = conversationAt("ach_1", "15550003333", "2026-08-07T10:00:00Z");
368
+ const middle = conversationAt("ach_1", "15550002222", "2026-08-07T11:00:00Z", {
369
+ control: ConversationControl.control_human,
370
+ });
371
+ const postApply = deferred<{ items: unknown[]; totalCount: number }>();
372
+ const listConversations = vi
373
+ .fn()
374
+ .mockResolvedValueOnce({ items: [newest, oldest], totalCount: 3 })
375
+ .mockReturnValueOnce(postApply.promise);
376
+
377
+ const { result } = renderHook(
378
+ () => useConversationList({ org: "acme", ...NO_POLL }),
379
+ { wrapper: wrapper(createMockStigmer(listConversations)) },
380
+ );
381
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
382
+
383
+ act(() => result.current.applyServerState(middle));
384
+
385
+ expect(result.current.conversations.map((c) => c.conversationKey)).toEqual([
386
+ "15550001111",
387
+ "15550002222",
388
+ "15550003333",
389
+ ]);
390
+ });
391
+
392
+ it("never inserts a foreign-channel row into a channel-scoped list", async () => {
393
+ const listed = conversationAt("ach_1", "15550001111", "2026-08-07T11:00:00Z");
394
+ const foreign = conversationAt("ach_2", "15550009999", "2026-08-07T12:00:00Z", {
395
+ control: ConversationControl.control_human,
396
+ });
397
+ const postApply = deferred<{ items: unknown[]; totalCount: number }>();
398
+ const listConversations = vi
399
+ .fn()
400
+ .mockResolvedValueOnce({ items: [listed], totalCount: 1 })
401
+ .mockReturnValueOnce(postApply.promise);
402
+
403
+ const { result } = renderHook(
404
+ () =>
405
+ useConversationList({ org: "acme", agentChannelId: "ach_1", ...NO_POLL }),
406
+ { wrapper: wrapper(createMockStigmer(listConversations)) },
407
+ );
408
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
409
+
410
+ act(() => result.current.applyServerState(foreign));
411
+
412
+ expect(result.current.conversations.map((c) => c.conversationKey)).toEqual([
413
+ "15550001111",
414
+ ]);
415
+ });
416
+
417
+ it("is update-only under a server-evaluated predicate filter (membership is the server's claim to make)", async () => {
418
+ const listed = conversationAt("ach_1", "15550001111", "2026-08-07T11:00:00Z");
419
+ const listedFresh = conversationAt("ach_1", "15550001111", "2026-08-07T11:00:00Z", {
420
+ control: ConversationControl.control_human,
421
+ });
422
+ const unlisted = conversationAt("ach_1", "15550002222", "2026-08-07T12:00:00Z", {
423
+ control: ConversationControl.control_human,
424
+ });
425
+ const postApply = deferred<{ items: unknown[]; totalCount: number }>();
426
+ const listConversations = vi
427
+ .fn()
428
+ .mockResolvedValueOnce({ items: [listed], totalCount: 1 })
429
+ .mockReturnValue(postApply.promise);
430
+
431
+ const { result } = renderHook(
432
+ () =>
433
+ useConversationList({
434
+ org: "acme",
435
+ filter: ChannelConversationListFilter.filter_wants_human,
436
+ ...NO_POLL,
437
+ }),
438
+ { wrapper: wrapper(createMockStigmer(listConversations)) },
439
+ );
440
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
441
+
442
+ // Absent row: never inserted under the filter.
443
+ act(() => result.current.applyServerState(unlisted));
444
+ expect(result.current.conversations.map((c) => c.conversationKey)).toEqual([
445
+ "15550001111",
446
+ ]);
447
+
448
+ // Listed row: updated in place — always honest.
449
+ act(() => result.current.applyServerState(listedFresh));
450
+ expect(result.current.conversations[0].control).toBe(
451
+ ConversationControl.control_human,
452
+ );
453
+ });
454
+
455
+ it("a stale in-flight loadMore page cannot overwrite an applied row", async () => {
456
+ const staleAgent = conversationAt("ach_1", "15550001111", "2026-08-07T11:00:00Z");
457
+ const freshHuman = conversationAt("ach_1", "15550001111", "2026-08-07T11:00:00Z", {
458
+ control: ConversationControl.control_human,
459
+ });
460
+ const older = conversationAt("ach_1", "15550003333", "2026-08-07T09:00:00Z");
461
+ const pageTwo = deferred<{ items: unknown[]; totalCount: number }>();
462
+ const postApply = deferred<{ items: unknown[]; totalCount: number }>();
463
+ const listConversations = vi
464
+ .fn()
465
+ .mockResolvedValueOnce({ items: [staleAgent], totalCount: 2 })
466
+ .mockReturnValueOnce(pageTwo.promise)
467
+ .mockReturnValueOnce(postApply.promise);
468
+
469
+ const { result } = renderHook(
470
+ () => useConversationList({ org: "acme", pageSize: 1, ...NO_POLL }),
471
+ { wrapper: wrapper(createMockStigmer(listConversations)) },
472
+ );
473
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
474
+
475
+ // Page 2 (carrying a stale copy of the applied row) is in flight when
476
+ // the command answers.
477
+ act(() => result.current.loadMore());
478
+ act(() => result.current.applyServerState(freshHuman));
479
+
480
+ await act(async () => {
481
+ pageTwo.resolve({ items: [staleAgent, older], totalCount: 2 });
482
+ });
483
+ await waitFor(() => expect(result.current.isLoadingMore).toBe(false));
484
+
485
+ const byKey = new Map(
486
+ result.current.conversations.map((c) => [c.conversationKey, c]),
487
+ );
488
+ expect(byKey.get("15550001111")?.control).toBe(ConversationControl.control_human);
489
+ expect(byKey.has("15550003333")).toBe(true);
490
+ expect(result.current.conversations).toHaveLength(2);
491
+ });
492
+ });
@@ -0,0 +1,190 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { act, renderHook, waitFor } from "@testing-library/react";
3
+ import type { ReactNode } from "react";
4
+ import { create } from "@bufbuild/protobuf";
5
+ import {
6
+ ChannelConversationSchema,
7
+ ConversationControl,
8
+ } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/conversation_io_pb";
9
+ import { StigmerContext } from "../../context";
10
+ import { FetchCacheContext } from "../../internal/FetchCacheProvider";
11
+ import { useConversationParticipation } from "../useConversationParticipation";
12
+
13
+ const IDENTITY = { agentChannelId: "ach_1", conversationKey: "15550001111" };
14
+
15
+ function row(control: ConversationControl, controlledBy = "") {
16
+ return create(ChannelConversationSchema, {
17
+ ...IDENTITY,
18
+ org: "acme",
19
+ control,
20
+ controlledBy,
21
+ });
22
+ }
23
+
24
+ function createMockStigmer(overrides: Record<string, unknown> = {}) {
25
+ return {
26
+ agentChannel: {
27
+ reply: vi.fn().mockResolvedValue({ outcome: 1, outboundMessageId: "obm_1" }),
28
+ takeOver: vi.fn().mockResolvedValue(row(ConversationControl.control_human, "idt_me")),
29
+ handBack: vi.fn().mockResolvedValue(row(ConversationControl.control_agent)),
30
+ clearAttention: vi.fn().mockResolvedValue(row(ConversationControl.control_agent)),
31
+ getConversation: vi
32
+ .fn()
33
+ .mockResolvedValue(row(ConversationControl.control_human, "idt_me")),
34
+ ...overrides,
35
+ },
36
+ } as never;
37
+ }
38
+
39
+ function wrapper(client: unknown) {
40
+ return function Wrapper({ children }: { children: ReactNode }) {
41
+ return (
42
+ <FetchCacheContext.Provider value={null}>
43
+ <StigmerContext.Provider value={client as never}>
44
+ {children}
45
+ </StigmerContext.Provider>
46
+ </FetchCacheContext.Provider>
47
+ );
48
+ };
49
+ }
50
+
51
+ describe("useConversationParticipation", () => {
52
+ beforeEach(() => {
53
+ vi.restoreAllMocks();
54
+ });
55
+
56
+ it("takeOver feeds the fresh row to onConversation", async () => {
57
+ const client = createMockStigmer();
58
+ const onConversation = vi.fn();
59
+ const { result } = renderHook(
60
+ () => useConversationParticipation({ ...IDENTITY, onConversation }),
61
+ { wrapper: wrapper(client) },
62
+ );
63
+
64
+ await act(() => result.current.takeOver());
65
+
66
+ expect(onConversation).toHaveBeenCalledWith(
67
+ expect.objectContaining({ control: ConversationControl.control_human }),
68
+ );
69
+ });
70
+
71
+ it("feeds the CAS loser's answer too — the winner's state IS the server truth", async () => {
72
+ // A lost concurrent takeover is success-with-the-winner's-state,
73
+ // never an error. The hook must pass it through unfiltered so the
74
+ // UI shows WHO holds the conversation instead of pretending the
75
+ // caller's transition won.
76
+ const client = createMockStigmer({
77
+ takeOver: vi
78
+ .fn()
79
+ .mockResolvedValue(row(ConversationControl.control_human, "idt_someone_else")),
80
+ });
81
+ const onConversation = vi.fn();
82
+ const { result } = renderHook(
83
+ () => useConversationParticipation({ ...IDENTITY, onConversation }),
84
+ { wrapper: wrapper(client) },
85
+ );
86
+
87
+ const answer = await act(() => result.current.takeOver());
88
+
89
+ expect(answer.controlledBy).toBe("idt_someone_else");
90
+ expect(onConversation).toHaveBeenCalledWith(
91
+ expect.objectContaining({ controlledBy: "idt_someone_else" }),
92
+ );
93
+ });
94
+
95
+ it("reply sends a text payload and follows up with the fresh row — the implicit takeover already happened", async () => {
96
+ const client = createMockStigmer();
97
+ const onConversation = vi.fn();
98
+ const { result } = renderHook(
99
+ () => useConversationParticipation({ ...IDENTITY, onConversation }),
100
+ { wrapper: wrapper(client) },
101
+ );
102
+
103
+ const output = await act(() => result.current.reply("on my way"));
104
+
105
+ expect(output.outboundMessageId).toBe("obm_1");
106
+ const agentChannel = (client as { agentChannel: Record<string, ReturnType<typeof vi.fn>> })
107
+ .agentChannel;
108
+ expect(agentChannel.reply).toHaveBeenCalledWith(
109
+ expect.objectContaining({
110
+ ...IDENTITY,
111
+ payload: expect.objectContaining({
112
+ kind: expect.objectContaining({ case: "text" }),
113
+ }),
114
+ }),
115
+ );
116
+ expect(agentChannel.getConversation).toHaveBeenCalledWith(
117
+ expect.objectContaining(IDENTITY),
118
+ );
119
+ expect(onConversation).toHaveBeenCalledWith(
120
+ expect.objectContaining({ control: ConversationControl.control_human }),
121
+ );
122
+ });
123
+
124
+ it("still resolves the reply when the follow-up row read fails — the send outcome is the answer", async () => {
125
+ const client = createMockStigmer({
126
+ getConversation: vi.fn().mockRejectedValue(new Error("blip")),
127
+ });
128
+ const onConversation = vi.fn();
129
+ const { result } = renderHook(
130
+ () => useConversationParticipation({ ...IDENTITY, onConversation }),
131
+ { wrapper: wrapper(client) },
132
+ );
133
+
134
+ const output = await act(() => result.current.reply("on my way"));
135
+
136
+ expect(output.outboundMessageId).toBe("obm_1");
137
+ expect(onConversation).not.toHaveBeenCalled();
138
+ expect(result.current.commandErrors.size).toBe(0);
139
+ });
140
+
141
+ it("skips the follow-up read entirely when nobody listens for the row", async () => {
142
+ const client = createMockStigmer();
143
+ const { result } = renderHook(() => useConversationParticipation(IDENTITY), {
144
+ wrapper: wrapper(client),
145
+ });
146
+
147
+ await act(() => result.current.reply("on my way"));
148
+
149
+ const agentChannel = (client as { agentChannel: Record<string, ReturnType<typeof vi.fn>> })
150
+ .agentChannel;
151
+ expect(agentChannel.getConversation).not.toHaveBeenCalled();
152
+ });
153
+
154
+ it("attributes in-flight state and failures to the one command they belong to", async () => {
155
+ let releaseTakeOver: (value: unknown) => void = () => {};
156
+ const client = createMockStigmer({
157
+ takeOver: vi.fn().mockReturnValue(
158
+ new Promise((resolve) => {
159
+ releaseTakeOver = resolve;
160
+ }),
161
+ ),
162
+ handBack: vi.fn().mockRejectedValue(new Error("no send lane")),
163
+ });
164
+ const { result } = renderHook(() => useConversationParticipation(IDENTITY), {
165
+ wrapper: wrapper(client),
166
+ });
167
+
168
+ let takeOverPromise: Promise<unknown> = Promise.resolve();
169
+ act(() => {
170
+ takeOverPromise = result.current.takeOver();
171
+ });
172
+ await waitFor(() => expect(result.current.pendingCommands.has("takeOver")).toBe(true));
173
+
174
+ await act(async () => {
175
+ await expect(result.current.handBack()).rejects.toThrow("no send lane");
176
+ });
177
+
178
+ // The failure belongs to handBack; takeOver is still cleanly in flight.
179
+ expect(result.current.commandErrors.get("handBack")?.message).toBe("no send lane");
180
+ expect(result.current.commandErrors.has("takeOver")).toBe(false);
181
+ expect(result.current.pendingCommands.has("takeOver")).toBe(true);
182
+ expect(result.current.pendingCommands.has("handBack")).toBe(false);
183
+
184
+ await act(async () => {
185
+ releaseTakeOver(row(ConversationControl.control_human, "idt_me"));
186
+ await takeOverPromise;
187
+ });
188
+ expect(result.current.pendingCommands.size).toBe(0);
189
+ });
190
+ });