@stigmer/react 3.7.0 → 3.9.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 (219) hide show
  1. package/attachment/AttachmentChipList.d.ts.map +1 -1
  2. package/attachment/AttachmentChipList.js +30 -1
  3. package/attachment/AttachmentChipList.js.map +1 -1
  4. package/attachment/attachment-utils.d.ts +11 -0
  5. package/attachment/attachment-utils.d.ts.map +1 -1
  6. package/attachment/attachment-utils.js +23 -0
  7. package/attachment/attachment-utils.js.map +1 -1
  8. package/attachment/clipboard.d.ts +51 -0
  9. package/attachment/clipboard.d.ts.map +1 -0
  10. package/attachment/clipboard.js +89 -0
  11. package/attachment/clipboard.js.map +1 -0
  12. package/attachment/index.d.ts +6 -1
  13. package/attachment/index.d.ts.map +1 -1
  14. package/attachment/index.js +4 -1
  15. package/attachment/index.js.map +1 -1
  16. package/attachment/prepare-image.d.ts +43 -0
  17. package/attachment/prepare-image.d.ts.map +1 -0
  18. package/attachment/prepare-image.js +162 -0
  19. package/attachment/prepare-image.js.map +1 -0
  20. package/attachment/useAttachments.d.ts.map +1 -1
  21. package/attachment/useAttachments.js +23 -3
  22. package/attachment/useAttachments.js.map +1 -1
  23. package/attachment/vision-fit.d.ts +50 -0
  24. package/attachment/vision-fit.d.ts.map +1 -0
  25. package/attachment/vision-fit.js +76 -0
  26. package/attachment/vision-fit.js.map +1 -0
  27. package/channel/AgentChannelsPanel.d.ts.map +1 -1
  28. package/channel/AgentChannelsPanel.js +21 -2
  29. package/channel/AgentChannelsPanel.js.map +1 -1
  30. package/channel/ChannelConversationsDialog.d.ts +12 -4
  31. package/channel/ChannelConversationsDialog.d.ts.map +1 -1
  32. package/channel/ChannelConversationsDialog.js +18 -24
  33. package/channel/ChannelConversationsDialog.js.map +1 -1
  34. package/channel/providers.d.ts +23 -0
  35. package/channel/providers.d.ts.map +1 -1
  36. package/channel/providers.js +4 -0
  37. package/channel/providers.js.map +1 -1
  38. package/channel/useSaveAgentChannel.d.ts +5 -4
  39. package/channel/useSaveAgentChannel.d.ts.map +1 -1
  40. package/channel/useSaveAgentChannel.js +18 -4
  41. package/channel/useSaveAgentChannel.js.map +1 -1
  42. package/composer/ComposerToolbar.d.ts +1 -1
  43. package/composer/ComposerToolbar.d.ts.map +1 -1
  44. package/composer/SessionComposer.d.ts +4 -2
  45. package/composer/SessionComposer.d.ts.map +1 -1
  46. package/composer/SessionComposer.js +51 -5
  47. package/composer/SessionComposer.js.map +1 -1
  48. package/conversation/ConversationAttentionBanner.d.ts +22 -0
  49. package/conversation/ConversationAttentionBanner.d.ts.map +1 -0
  50. package/conversation/ConversationAttentionBanner.js +25 -0
  51. package/conversation/ConversationAttentionBanner.js.map +1 -0
  52. package/conversation/ConversationComposer.d.ts +41 -0
  53. package/conversation/ConversationComposer.d.ts.map +1 -0
  54. package/conversation/ConversationComposer.js +57 -0
  55. package/conversation/ConversationComposer.js.map +1 -0
  56. package/conversation/ConversationControlBanner.d.ts +52 -0
  57. package/conversation/ConversationControlBanner.d.ts.map +1 -0
  58. package/conversation/ConversationControlBanner.js +59 -0
  59. package/conversation/ConversationControlBanner.js.map +1 -0
  60. package/conversation/ConversationListPane.d.ts +68 -0
  61. package/conversation/ConversationListPane.d.ts.map +1 -0
  62. package/conversation/ConversationListPane.js +120 -0
  63. package/conversation/ConversationListPane.js.map +1 -0
  64. package/conversation/ConversationTimelineView.d.ts +46 -0
  65. package/conversation/ConversationTimelineView.d.ts.map +1 -0
  66. package/conversation/ConversationTimelineView.js +166 -0
  67. package/conversation/ConversationTimelineView.js.map +1 -0
  68. package/conversation/ConversationsWorkbench.d.ts +63 -0
  69. package/conversation/ConversationsWorkbench.d.ts.map +1 -0
  70. package/conversation/ConversationsWorkbench.js +165 -0
  71. package/conversation/ConversationsWorkbench.js.map +1 -0
  72. package/conversation/conversationPresentation.d.ts +137 -0
  73. package/conversation/conversationPresentation.d.ts.map +1 -0
  74. package/conversation/conversationPresentation.js +230 -0
  75. package/conversation/conversationPresentation.js.map +1 -0
  76. package/conversation/index.d.ts +26 -0
  77. package/conversation/index.d.ts.map +1 -0
  78. package/conversation/index.js +14 -0
  79. package/conversation/index.js.map +1 -0
  80. package/conversation/polling.d.ts +39 -0
  81. package/conversation/polling.d.ts.map +1 -0
  82. package/conversation/polling.js +39 -0
  83. package/conversation/polling.js.map +1 -0
  84. package/conversation/useConversation.d.ts +61 -0
  85. package/conversation/useConversation.d.ts.map +1 -0
  86. package/conversation/useConversation.js +153 -0
  87. package/conversation/useConversation.js.map +1 -0
  88. package/conversation/useConversationList.d.ts +98 -0
  89. package/conversation/useConversationList.d.ts.map +1 -0
  90. package/conversation/useConversationList.js +200 -0
  91. package/conversation/useConversationList.js.map +1 -0
  92. package/conversation/useConversationParticipation.d.ts +63 -0
  93. package/conversation/useConversationParticipation.d.ts.map +1 -0
  94. package/conversation/useConversationParticipation.js +84 -0
  95. package/conversation/useConversationParticipation.js.map +1 -0
  96. package/conversation/useConversationTimeline.d.ts +71 -0
  97. package/conversation/useConversationTimeline.d.ts.map +1 -0
  98. package/conversation/useConversationTimeline.js +152 -0
  99. package/conversation/useConversationTimeline.js.map +1 -0
  100. package/conversation/useConversationsWantsHumanCount.d.ts +42 -0
  101. package/conversation/useConversationsWantsHumanCount.d.ts.map +1 -0
  102. package/conversation/useConversationsWantsHumanCount.js +40 -0
  103. package/conversation/useConversationsWantsHumanCount.js.map +1 -0
  104. package/index.d.ts +6 -2
  105. package/index.d.ts.map +1 -1
  106. package/index.js +11 -2
  107. package/index.js.map +1 -1
  108. package/internal/tooltip.d.ts.map +1 -1
  109. package/internal/tooltip.js +7 -1
  110. package/internal/tooltip.js.map +1 -1
  111. package/internal/useAutoScroll.d.ts +25 -4
  112. package/internal/useAutoScroll.d.ts.map +1 -1
  113. package/internal/useAutoScroll.js +55 -15
  114. package/internal/useAutoScroll.js.map +1 -1
  115. package/internal/useFetch.d.ts.map +1 -1
  116. package/internal/useFetch.js +14 -4
  117. package/internal/useFetch.js.map +1 -1
  118. package/models/ModelSelector.d.ts +24 -5
  119. package/models/ModelSelector.d.ts.map +1 -1
  120. package/models/ModelSelector.js +31 -9
  121. package/models/ModelSelector.js.map +1 -1
  122. package/models/registry.d.ts +3 -2
  123. package/models/registry.d.ts.map +1 -1
  124. package/models/registry.js.map +1 -1
  125. package/models/service-tier.d.ts +1 -1
  126. package/models/service-tier.d.ts.map +1 -1
  127. package/models/service-tier.js +1 -1
  128. package/models/service-tier.js.map +1 -1
  129. package/package.json +4 -4
  130. package/schedule/ScheduleDetailView.js +1 -1
  131. package/schedule/ScheduleDetailView.js.map +1 -1
  132. package/schedule/ScheduleForm.d.ts.map +1 -1
  133. package/schedule/ScheduleForm.js +7 -6
  134. package/schedule/ScheduleForm.js.map +1 -1
  135. package/sharing/useSaveAgentShare.d.ts +10 -1
  136. package/sharing/useSaveAgentShare.d.ts.map +1 -1
  137. package/sharing/useSaveAgentShare.js +9 -0
  138. package/sharing/useSaveAgentShare.js.map +1 -1
  139. package/sidebar/WorkspaceSidebar.d.ts +12 -4
  140. package/sidebar/WorkspaceSidebar.d.ts.map +1 -1
  141. package/sidebar/WorkspaceSidebar.js +19 -7
  142. package/sidebar/WorkspaceSidebar.js.map +1 -1
  143. package/src/attachment/AttachmentChipList.tsx +45 -1
  144. package/src/attachment/__tests__/AttachmentChipList.test.tsx +103 -0
  145. package/src/attachment/__tests__/attachment-utils.test.ts +54 -0
  146. package/src/attachment/__tests__/clipboard.test.ts +110 -0
  147. package/src/attachment/__tests__/prepare-image.browser.test.ts +166 -0
  148. package/src/attachment/__tests__/prepare-image.test.ts +32 -0
  149. package/src/attachment/__tests__/vision-fit.test.ts +93 -0
  150. package/src/attachment/attachment-utils.ts +27 -0
  151. package/src/attachment/clipboard.ts +102 -0
  152. package/src/attachment/index.ts +13 -0
  153. package/src/attachment/prepare-image.ts +179 -0
  154. package/src/attachment/useAttachments.ts +26 -2
  155. package/src/attachment/vision-fit.ts +90 -0
  156. package/src/channel/AgentChannelsPanel.tsx +33 -2
  157. package/src/channel/ChannelConversationsDialog.tsx +22 -25
  158. package/src/channel/__tests__/AgentChannelsPanel.test.tsx +36 -5
  159. package/src/channel/__tests__/ChannelConversationsDialog.test.tsx +38 -1
  160. package/src/channel/__tests__/useSaveAgentChannel.test.tsx +26 -0
  161. package/src/channel/providers.ts +27 -0
  162. package/src/channel/useSaveAgentChannel.ts +18 -4
  163. package/src/composer/ComposerToolbar.tsx +1 -1
  164. package/src/composer/SessionComposer.tsx +75 -6
  165. package/src/composer/__tests__/SessionComposer-paste.test.tsx +263 -0
  166. package/src/composer/__tests__/SessionComposer-serviceTier.test.tsx +4 -3
  167. package/src/composer/__tests__/SessionComposer-uploadGate.test.tsx +239 -0
  168. package/src/conversation/ConversationAttentionBanner.tsx +77 -0
  169. package/src/conversation/ConversationComposer.tsx +156 -0
  170. package/src/conversation/ConversationControlBanner.tsx +193 -0
  171. package/src/conversation/ConversationListPane.tsx +459 -0
  172. package/src/conversation/ConversationTimelineView.tsx +435 -0
  173. package/src/conversation/ConversationsWorkbench.tsx +378 -0
  174. package/src/conversation/__tests__/ConversationAttentionBanner.test.tsx +82 -0
  175. package/src/conversation/__tests__/ConversationComposer.test.tsx +160 -0
  176. package/src/conversation/__tests__/ConversationControlBanner.test.tsx +190 -0
  177. package/src/conversation/__tests__/ConversationListPane.test.tsx +372 -0
  178. package/src/conversation/__tests__/ConversationTimelineView.layout.test.tsx +160 -0
  179. package/src/conversation/__tests__/ConversationTimelineView.test.tsx +405 -0
  180. package/src/conversation/__tests__/ConversationsWorkbench.test.tsx +860 -0
  181. package/src/conversation/__tests__/a11y/conversation-surfaces.a11y.test.tsx +235 -0
  182. package/src/conversation/__tests__/a11y/harness.tsx +100 -0
  183. package/src/conversation/__tests__/conversation-tooltips.layout.test.tsx +167 -0
  184. package/src/conversation/__tests__/conversationPollingComposition.test.tsx +138 -0
  185. package/src/conversation/__tests__/conversationPresentation.test.ts +289 -0
  186. package/src/conversation/__tests__/useConversation.test.tsx +243 -0
  187. package/src/conversation/__tests__/useConversationList.test.tsx +492 -0
  188. package/src/conversation/__tests__/useConversationParticipation.test.tsx +190 -0
  189. package/src/conversation/__tests__/useConversationTimeline.test.tsx +258 -0
  190. package/src/conversation/__tests__/useConversationsWantsHumanCount.test.tsx +102 -0
  191. package/src/conversation/conversationPresentation.ts +305 -0
  192. package/src/conversation/index.ts +80 -0
  193. package/src/conversation/polling.ts +41 -0
  194. package/src/conversation/useConversation.ts +221 -0
  195. package/src/conversation/useConversationList.ts +328 -0
  196. package/src/conversation/useConversationParticipation.ts +178 -0
  197. package/src/conversation/useConversationTimeline.ts +244 -0
  198. package/src/conversation/useConversationsWantsHumanCount.ts +86 -0
  199. package/src/index.ts +78 -1
  200. package/src/internal/__tests__/fake-timer-slices.ts +29 -0
  201. package/src/internal/__tests__/useAutoScroll.layout.test.tsx +143 -0
  202. package/src/internal/__tests__/useAutoScroll.test.tsx +62 -5
  203. package/src/internal/__tests__/useFetch-poll-under-render-pressure.test.tsx +79 -0
  204. package/src/internal/tooltip.tsx +6 -1
  205. package/src/internal/useAutoScroll.ts +66 -20
  206. package/src/internal/useFetch.ts +15 -4
  207. package/src/models/ModelSelector.tsx +81 -49
  208. package/src/models/__tests__/ModelSelector-serviceTier.test.tsx +105 -21
  209. package/src/models/registry.ts +3 -2
  210. package/src/models/service-tier.ts +1 -1
  211. package/src/schedule/ScheduleDetailView.tsx +1 -1
  212. package/src/schedule/ScheduleForm.tsx +7 -6
  213. package/src/schedule/__tests__/scheduleCreation.test.tsx +2 -2
  214. package/src/sharing/__tests__/useSaveAgentShare.test.tsx +55 -1
  215. package/src/sharing/__tests__/useShareToolReadiness.test.tsx +1 -0
  216. package/src/sharing/useSaveAgentShare.ts +19 -1
  217. package/src/sidebar/WorkspaceSidebar.tsx +62 -3
  218. package/src/sidebar/__tests__/WorkspaceSidebar.test.tsx +56 -1
  219. package/styles.css +1 -1
@@ -0,0 +1,258 @@
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 { ConversationTimelineItemSchema } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/conversation_io_pb";
13
+ import { ChannelDeliveryStatus } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/delivery_pb";
14
+ import { ChannelReceiptState } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/outbound_pb";
15
+ import { StigmerContext } from "../../context";
16
+ import { FetchCacheContext } from "../../internal/FetchCacheProvider";
17
+ import { useConversationTimeline } from "../useConversationTimeline";
18
+
19
+ function item(id: string, iso: string, overrides: Record<string, unknown> = {}) {
20
+ return create(ConversationTimelineItemSchema, {
21
+ itemId: id,
22
+ at: timestampFromDate(new Date(iso)),
23
+ text: `body of ${id}`,
24
+ ...overrides,
25
+ });
26
+ }
27
+
28
+ function createMockStigmer(getTimeline: (input: unknown) => Promise<unknown>) {
29
+ return { agentChannel: { getTimeline } } as never;
30
+ }
31
+
32
+ function wrapper(client: unknown) {
33
+ return function Wrapper({ children }: { children: ReactNode }) {
34
+ return (
35
+ <FetchCacheContext.Provider value={null}>
36
+ <StigmerContext.Provider value={client as never}>
37
+ {children}
38
+ </StigmerContext.Provider>
39
+ </FetchCacheContext.Provider>
40
+ );
41
+ };
42
+ }
43
+
44
+ const NO_POLL = { refetchIntervalMs: false as const };
45
+
46
+ describe("useConversationTimeline", () => {
47
+ beforeEach(() => {
48
+ vi.restoreAllMocks();
49
+ });
50
+
51
+ it("serves the head page in chronological order — the server pages newest-first", async () => {
52
+ const getTimeline = vi.fn().mockResolvedValue({
53
+ items: [item("wa:3", "2026-08-07T09:02:00Z"), item("wa:1", "2026-08-07T09:00:00Z")],
54
+ nextPageToken: "",
55
+ });
56
+ const { result } = renderHook(
57
+ () => useConversationTimeline("ach_1", "15550001111", NO_POLL),
58
+ { wrapper: wrapper(createMockStigmer(getTimeline)) },
59
+ );
60
+
61
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
62
+ expect(result.current.items.map((i) => i.itemId)).toEqual(["wa:1", "wa:3"]);
63
+ expect(result.current.hasOlder).toBe(false);
64
+ });
65
+
66
+ it("loads older pages behind the cursor and never infers the end from a short page", async () => {
67
+ const getTimeline = vi
68
+ .fn()
69
+ .mockResolvedValueOnce({
70
+ items: [item("wa:9", "2026-08-07T09:09:00Z")],
71
+ nextPageToken: "cursor-1",
72
+ })
73
+ // A SHORT mid-history page with a live cursor: same-second bursts
74
+ // narrow pages, so only the empty token means the end.
75
+ .mockResolvedValueOnce({
76
+ items: [item("wa:5", "2026-08-07T09:05:00Z")],
77
+ nextPageToken: "cursor-2",
78
+ })
79
+ .mockResolvedValueOnce({
80
+ items: [item("wa:1", "2026-08-07T09:01:00Z")],
81
+ nextPageToken: "",
82
+ });
83
+
84
+ const { result } = renderHook(
85
+ () => useConversationTimeline("ach_1", "15550001111", NO_POLL),
86
+ { wrapper: wrapper(createMockStigmer(getTimeline)) },
87
+ );
88
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
89
+ expect(result.current.hasOlder).toBe(true);
90
+
91
+ act(() => result.current.loadOlder());
92
+ await waitFor(() => expect(result.current.isLoadingOlder).toBe(false));
93
+ expect(result.current.items.map((i) => i.itemId)).toEqual(["wa:5", "wa:9"]);
94
+ expect(result.current.hasOlder).toBe(true);
95
+ expect(getTimeline).toHaveBeenLastCalledWith(
96
+ expect.objectContaining({ pageToken: "cursor-1" }),
97
+ );
98
+
99
+ act(() => result.current.loadOlder());
100
+ await waitFor(() => expect(result.current.isLoadingOlder).toBe(false));
101
+ expect(result.current.items.map((i) => i.itemId)).toEqual(["wa:1", "wa:5", "wa:9"]);
102
+ expect(result.current.hasOlder).toBe(false);
103
+ });
104
+
105
+ it("keeps items that slide out of the polled head window — nothing ever vanishes mid-scroll", async () => {
106
+ const getTimeline = vi
107
+ .fn()
108
+ .mockResolvedValueOnce({
109
+ items: [item("wa:2", "2026-08-07T09:02:00Z"), item("wa:1", "2026-08-07T09:01:00Z")],
110
+ nextPageToken: "",
111
+ })
112
+ // The refreshed head window has shifted entirely to newer items;
113
+ // wa:1 and wa:2 are in no loaded page anymore.
114
+ .mockResolvedValueOnce({
115
+ items: [item("wa:4", "2026-08-07T09:04:00Z"), item("wa:3", "2026-08-07T09:03:00Z")],
116
+ nextPageToken: "cursor-x",
117
+ });
118
+
119
+ const { result } = renderHook(
120
+ () => useConversationTimeline("ach_1", "15550001111", NO_POLL),
121
+ { wrapper: wrapper(createMockStigmer(getTimeline)) },
122
+ );
123
+ await waitFor(() => expect(result.current.items).toHaveLength(2));
124
+
125
+ act(() => result.current.refetch());
126
+ await waitFor(() => expect(result.current.items).toHaveLength(4));
127
+
128
+ expect(result.current.items.map((i) => i.itemId)).toEqual([
129
+ "wa:1",
130
+ "wa:2",
131
+ "wa:3",
132
+ "wa:4",
133
+ ]);
134
+ // The older cursor stays anchored to the FIRST head page: it tracks
135
+ // scroll depth into history, not the shifting head window.
136
+ expect(result.current.hasOlder).toBe(false);
137
+ });
138
+
139
+ it("upserts mutated statuses in place — delivery and receipt ticks advance on poll", async () => {
140
+ const sent = item("ob:1", "2026-08-07T09:00:00Z", {
141
+ deliveryStatus: ChannelDeliveryStatus.delivered,
142
+ receiptState: ChannelReceiptState.receipt_sent,
143
+ });
144
+ const read = item("ob:1", "2026-08-07T09:00:00Z", {
145
+ deliveryStatus: ChannelDeliveryStatus.delivered,
146
+ receiptState: ChannelReceiptState.receipt_read,
147
+ });
148
+ const getTimeline = vi
149
+ .fn()
150
+ .mockResolvedValueOnce({ items: [sent], nextPageToken: "" })
151
+ .mockResolvedValueOnce({ items: [read], nextPageToken: "" });
152
+
153
+ const { result } = renderHook(
154
+ () => useConversationTimeline("ach_1", "15550001111", NO_POLL),
155
+ { wrapper: wrapper(createMockStigmer(getTimeline)) },
156
+ );
157
+ await waitFor(() => expect(result.current.items).toHaveLength(1));
158
+ expect(result.current.items[0].receiptState).toBe(ChannelReceiptState.receipt_sent);
159
+
160
+ act(() => result.current.refetch());
161
+ await waitFor(() =>
162
+ expect(result.current.items[0].receiptState).toBe(ChannelReceiptState.receipt_read),
163
+ );
164
+ expect(result.current.items).toHaveLength(1);
165
+ });
166
+
167
+ it("keeps item and list references stable across polls that change nothing (DD-010)", async () => {
168
+ const getTimeline = vi.fn().mockImplementation(() =>
169
+ Promise.resolve({
170
+ // Fresh-but-equal proto objects every call, like a real poll.
171
+ items: [item("wa:1", "2026-08-07T09:00:00Z")],
172
+ nextPageToken: "",
173
+ }),
174
+ );
175
+ const { result } = renderHook(
176
+ () => useConversationTimeline("ach_1", "15550001111", NO_POLL),
177
+ { wrapper: wrapper(createMockStigmer(getTimeline)) },
178
+ );
179
+ await waitFor(() => expect(result.current.items).toHaveLength(1));
180
+ const firstItems = result.current.items;
181
+
182
+ act(() => result.current.refetch());
183
+ await waitFor(() => expect(result.current.isRefetching).toBe(false));
184
+
185
+ expect(result.current.items).toBe(firstItems);
186
+ });
187
+
188
+ it("resets on identity change so conversation A's thread never flashes under B", async () => {
189
+ const getTimeline = vi
190
+ .fn()
191
+ .mockResolvedValueOnce({
192
+ items: [item("wa:1", "2026-08-07T09:00:00Z")],
193
+ nextPageToken: "cursor-a",
194
+ })
195
+ .mockReturnValue(new Promise(() => {}));
196
+
197
+ const { result, rerender } = renderHook(
198
+ ({ key }: { key: string }) => useConversationTimeline("ach_1", key, NO_POLL),
199
+ {
200
+ wrapper: wrapper(createMockStigmer(getTimeline)),
201
+ initialProps: { key: "15550001111" },
202
+ },
203
+ );
204
+ await waitFor(() => expect(result.current.items).toHaveLength(1));
205
+
206
+ rerender({ key: "15550002222" });
207
+
208
+ expect(result.current.items).toHaveLength(0);
209
+ expect(result.current.hasOlder).toBe(false);
210
+ });
211
+
212
+ it("refetches the head when the window regains focus (DD-012: refocus is fresh)", async () => {
213
+ const getTimeline = vi.fn().mockResolvedValue({ items: [], nextPageToken: "" });
214
+ renderHook(() => useConversationTimeline("ach_1", "15550001111", NO_POLL), {
215
+ wrapper: wrapper(createMockStigmer(getTimeline)),
216
+ });
217
+ await waitFor(() => expect(getTimeline).toHaveBeenCalledTimes(1));
218
+
219
+ await act(async () => {
220
+ window.dispatchEvent(new Event("focus"));
221
+ });
222
+
223
+ await waitFor(() => expect(getTimeline).toHaveBeenCalledTimes(2));
224
+ });
225
+
226
+ it("skips fetching for empty identity (stable no-op)", async () => {
227
+ const getTimeline = vi.fn();
228
+ const { result } = renderHook(() => useConversationTimeline("", "", NO_POLL), {
229
+ wrapper: wrapper(createMockStigmer(getTimeline)),
230
+ });
231
+
232
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
233
+ expect(getTimeline).not.toHaveBeenCalled();
234
+ });
235
+
236
+ it("records a loadOlder failure beside the control, leaving the loaded thread intact", async () => {
237
+ const getTimeline = vi
238
+ .fn()
239
+ .mockResolvedValueOnce({
240
+ items: [item("wa:1", "2026-08-07T09:00:00Z")],
241
+ nextPageToken: "cursor-1",
242
+ })
243
+ .mockRejectedValueOnce(new Error("boom"));
244
+
245
+ const { result } = renderHook(
246
+ () => useConversationTimeline("ach_1", "15550001111", NO_POLL),
247
+ { wrapper: wrapper(createMockStigmer(getTimeline)) },
248
+ );
249
+ await waitFor(() => expect(result.current.items).toHaveLength(1));
250
+
251
+ act(() => result.current.loadOlder());
252
+ await waitFor(() => expect(result.current.loadOlderError).not.toBeNull());
253
+
254
+ expect(result.current.items).toHaveLength(1);
255
+ expect(result.current.error).toBeNull();
256
+ expect(result.current.hasOlder).toBe(true);
257
+ });
258
+ });
@@ -0,0 +1,102 @@
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
+ import { ChannelConversationListFilter } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/conversation_io_pb";
5
+ import { StigmerContext } from "../../context";
6
+ import { FetchCacheContext } from "../../internal/FetchCacheProvider";
7
+ import { useConversationsWantsHumanCount } from "../useConversationsWantsHumanCount";
8
+
9
+ // The focus-refetch test broadcasts window events, which reach EVERY
10
+ // still-mounted hook in this file. Unmount between tests so a window
11
+ // broadcast only ever exercises the test that sent it.
12
+ afterEach(cleanup);
13
+
14
+ function createMockStigmer(listConversations: (input: unknown) => Promise<unknown>) {
15
+ return { agentChannel: { listConversations } } as never;
16
+ }
17
+
18
+ function wrapper(client: unknown) {
19
+ return function Wrapper({ children }: { children: ReactNode }) {
20
+ return (
21
+ <FetchCacheContext.Provider value={null}>
22
+ <StigmerContext.Provider value={client as never}>
23
+ {children}
24
+ </StigmerContext.Provider>
25
+ </FetchCacheContext.Provider>
26
+ );
27
+ };
28
+ }
29
+
30
+ const NO_POLL = { refetchIntervalMs: false as const };
31
+
32
+ describe("useConversationsWantsHumanCount", () => {
33
+ beforeEach(() => {
34
+ vi.restoreAllMocks();
35
+ });
36
+
37
+ it("reads the filtered list's total_count with a one-row page — zero dedicated RPCs (DD-011 D-g)", async () => {
38
+ const listConversations = vi
39
+ .fn()
40
+ .mockResolvedValue({ items: [], totalCount: 7 });
41
+ const { result } = renderHook(
42
+ () => useConversationsWantsHumanCount("acme", NO_POLL),
43
+ { wrapper: wrapper(createMockStigmer(listConversations)) },
44
+ );
45
+
46
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
47
+
48
+ expect(result.current.count).toBe(7);
49
+ expect(listConversations).toHaveBeenCalledWith(
50
+ expect.objectContaining({
51
+ org: "acme",
52
+ filter: ChannelConversationListFilter.filter_wants_human,
53
+ pageInfo: expect.objectContaining({ num: 1, size: 1 }),
54
+ }),
55
+ );
56
+ });
57
+
58
+ it("skips fetching without an org and answers zero", async () => {
59
+ const listConversations = vi.fn();
60
+ const { result } = renderHook(
61
+ () => useConversationsWantsHumanCount(null, NO_POLL),
62
+ { wrapper: wrapper(createMockStigmer(listConversations)) },
63
+ );
64
+
65
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
66
+ expect(listConversations).not.toHaveBeenCalled();
67
+ expect(result.current.count).toBe(0);
68
+ });
69
+
70
+ it("keeps the stale count visible through a failed refresh — a flapping badge teaches distrust", async () => {
71
+ const listConversations = vi
72
+ .fn()
73
+ .mockResolvedValueOnce({ items: [], totalCount: 4 })
74
+ .mockRejectedValueOnce(new Error("boom"));
75
+ const { result } = renderHook(
76
+ () => useConversationsWantsHumanCount("acme", NO_POLL),
77
+ { wrapper: wrapper(createMockStigmer(listConversations)) },
78
+ );
79
+ await waitFor(() => expect(result.current.count).toBe(4));
80
+
81
+ act(() => result.current.refetch());
82
+ await waitFor(() => expect(result.current.error).not.toBeNull());
83
+
84
+ expect(result.current.count).toBe(4);
85
+ });
86
+
87
+ it("refetches when the window regains focus (DD-012: refocus is fresh)", async () => {
88
+ const listConversations = vi
89
+ .fn()
90
+ .mockResolvedValue({ items: [], totalCount: 2 });
91
+ renderHook(() => useConversationsWantsHumanCount("acme", NO_POLL), {
92
+ wrapper: wrapper(createMockStigmer(listConversations)),
93
+ });
94
+ await waitFor(() => expect(listConversations).toHaveBeenCalledTimes(1));
95
+
96
+ await act(async () => {
97
+ window.dispatchEvent(new Event("focus"));
98
+ });
99
+
100
+ await waitFor(() => expect(listConversations).toHaveBeenCalledTimes(2));
101
+ });
102
+ });
@@ -0,0 +1,305 @@
1
+ import { timestampDate, type Timestamp } from "@bufbuild/protobuf/wkt";
2
+ import {
3
+ type ChannelConversation,
4
+ ConversationControl,
5
+ ConversationItemAuthor,
6
+ ConversationLane,
7
+ type ConversationTimelineItem,
8
+ } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/conversation_io_pb";
9
+ import { ChannelDeliveryStatus } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/delivery_pb";
10
+ import { ChannelReceiptState } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/outbound_pb";
11
+ import type { ChannelProviderId } from "../channel/providers.js";
12
+
13
+ /**
14
+ * Pure render vocabulary for the conversation surface — the single place
15
+ * the wire enums become words and layout facts (the providerPresentation
16
+ * convention). No React, no I/O; every component and every test reads the
17
+ * same mapping.
18
+ */
19
+
20
+ /** Who authored a timeline item, in render vocabulary. */
21
+ export type ConversationAuthorKind =
22
+ | "customer"
23
+ | "agent"
24
+ | "teammate"
25
+ | "platform"
26
+ | "unknown";
27
+
28
+ /** Map the wire author onto the render vocabulary. */
29
+ export function authorKindOf(item: ConversationTimelineItem): ConversationAuthorKind {
30
+ switch (item.author) {
31
+ case ConversationItemAuthor.author_customer:
32
+ return "customer";
33
+ case ConversationItemAuthor.author_agent:
34
+ return "agent";
35
+ case ConversationItemAuthor.author_teammate:
36
+ return "teammate";
37
+ case ConversationItemAuthor.author_platform:
38
+ return "platform";
39
+ default:
40
+ return "unknown";
41
+ }
42
+ }
43
+
44
+ /** True for org-internal items (escalations, attention clears, notes). */
45
+ export function isInternalItem(item: ConversationTimelineItem): boolean {
46
+ return item.lane === ConversationLane.lane_internal;
47
+ }
48
+
49
+ /**
50
+ * The platform's own send attempt, in render vocabulary. `null` on items
51
+ * that carry no attempt (inbound messages, internal events).
52
+ *
53
+ * `"suppressed"` and `"failed"` are items the customer NEVER saw and
54
+ * `"pending"`/`"delivering"` are items the customer has not seen YET —
55
+ * a renderer that shows any of them as a plain sent bubble lies to staff.
56
+ */
57
+ export type SendAttemptKind =
58
+ | "pending"
59
+ | "delivering"
60
+ | "delivered"
61
+ | "failed"
62
+ | "suppressed";
63
+
64
+ /** Map the wire delivery status onto the render vocabulary. */
65
+ export function sendAttemptOf(item: ConversationTimelineItem): SendAttemptKind | null {
66
+ switch (item.deliveryStatus) {
67
+ case ChannelDeliveryStatus.pending:
68
+ return "pending";
69
+ case ChannelDeliveryStatus.delivering:
70
+ return "delivering";
71
+ case ChannelDeliveryStatus.delivered:
72
+ return "delivered";
73
+ case ChannelDeliveryStatus.failed:
74
+ return "failed";
75
+ case ChannelDeliveryStatus.suppressed:
76
+ return "suppressed";
77
+ default:
78
+ return null;
79
+ }
80
+ }
81
+
82
+ /**
83
+ * The provider's receipt report, in render vocabulary. `null` until the
84
+ * provider reports (and always on items with no receipt axis).
85
+ *
86
+ * A SECOND axis beside {@link sendAttemptOf}, never collapsed into it
87
+ * (channel-conversations DD-004 D-d): "we handed it to WhatsApp" and
88
+ * "it reached the phone" are different facts — an item can be
89
+ * `delivered` + `receipt_failed`.
90
+ */
91
+ export type ReceiptKind = "sent" | "delivered" | "read" | "failed";
92
+
93
+ /** Map the wire receipt state onto the render vocabulary. */
94
+ export function receiptOf(item: ConversationTimelineItem): ReceiptKind | null {
95
+ switch (item.receiptState) {
96
+ case ChannelReceiptState.receipt_sent:
97
+ return "sent";
98
+ case ChannelReceiptState.receipt_delivered:
99
+ return "delivered";
100
+ case ChannelReceiptState.receipt_read:
101
+ return "read";
102
+ case ChannelReceiptState.receipt_failed:
103
+ return "failed";
104
+ default:
105
+ return null;
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Placeholder copy for inbound items whose body the platform cannot
111
+ * render (`text` empty, `provider_message_type` names the kind). Media
112
+ * retrieval is a named non-goal of this surface — the placeholder tells
113
+ * staff something arrived and what shape it was.
114
+ *
115
+ * Returns `null` when the item needs no placeholder (it has text, or it
116
+ * is not a provider-typed inbound item — e.g. an attention-clear event,
117
+ * whose copy is the renderer's own vocabulary).
118
+ */
119
+ export function inboundPlaceholderOf(item: ConversationTimelineItem): string | null {
120
+ if (item.text !== "" || item.providerMessageType === "") return null;
121
+ switch (item.providerMessageType) {
122
+ case "text":
123
+ return null;
124
+ case "image":
125
+ return "Photo";
126
+ case "video":
127
+ return "Video";
128
+ case "audio":
129
+ return "Voice message";
130
+ case "document":
131
+ return "Document";
132
+ case "sticker":
133
+ return "Sticker";
134
+ case "location":
135
+ return "Location";
136
+ case "contacts":
137
+ return "Contact card";
138
+ default:
139
+ // The provider's vocabulary passes through verbatim (never
140
+ // re-encoded), so an unknown kind still names itself.
141
+ return `Unsupported message (${item.providerMessageType})`;
142
+ }
143
+ }
144
+
145
+ /**
146
+ * The list-row label for a conversation: the provider-reported display
147
+ * name when one exists, else a provider-aware fallback.
148
+ *
149
+ * The fallback is provider-aware because conversation keys are not
150
+ * uniformly meaningful: WhatsApp's key is the customer's phone number
151
+ * (a real label), while Slack's key is a thread timestamp like
152
+ * `1723012345.678900` (noise a human cannot use).
153
+ */
154
+ export function conversationLabelOf(
155
+ conversation: ChannelConversation,
156
+ provider: ChannelProviderId | null,
157
+ ): string {
158
+ if (conversation.displayName !== "") return conversation.displayName;
159
+ if (provider === "slack") return "Slack thread";
160
+ return conversation.conversationKey;
161
+ }
162
+
163
+ /**
164
+ * The customer's reachable address, when the label hides it: the fact a
165
+ * staffer needs to call or message the customer outside the platform.
166
+ *
167
+ * Provider-aware like {@link conversationLabelOf}, because conversation
168
+ * keys are not uniformly addresses: WhatsApp's key IS the customer's
169
+ * phone number (the wa_id), and when a display name wins the label slot
170
+ * that number would otherwise appear nowhere on the surface. Slack's
171
+ * key is a thread timestamp — not a way to reach anyone — so there is
172
+ * nothing to show. Unknown providers show nothing rather than guess.
173
+ *
174
+ * Returns `null` when the label already shows the key (no display
175
+ * name), so the same fact never renders twice. The key returns
176
+ * verbatim — no fabricated `+` prefix: the wire value is the truth this
177
+ * module reports, and formatting belongs to hosts that know their
178
+ * display rules.
179
+ */
180
+ export function conversationContactOf(
181
+ conversation: ChannelConversation,
182
+ provider: ChannelProviderId | null,
183
+ ): string | null {
184
+ if (provider !== "whatsapp") return null;
185
+ if (conversation.displayName === "") return null;
186
+ return conversation.conversationKey;
187
+ }
188
+
189
+ /**
190
+ * Meta's customer-service window: a business can send free-form WhatsApp
191
+ * messages for 24 hours after the customer's last inbound message; after
192
+ * that, only template messages deliver. The constant lives beside its
193
+ * only consumer and is promoted to `ChannelProviderDescriptor` only if a
194
+ * second windowed provider ever appears (the second-sighting rule,
195
+ * DD-014 D-a).
196
+ */
197
+ const WHATSAPP_SERVICE_WINDOW_MS = 24 * 60 * 60 * 1000;
198
+
199
+ /** The service-window estimate, in render vocabulary. */
200
+ export type ServiceWindowState = "open" | "closed";
201
+
202
+ /**
203
+ * Estimate whether the provider's free-form reply window is open
204
+ * (channel-conversations DD-014 D-a). Provider-aware like
205
+ * {@link conversationLabelOf}: WhatsApp is the only windowed provider, so
206
+ * every other provider answers `null` — no window, no claim — as does a
207
+ * conversation whose customer has never written
208
+ * (`last_customer_message_at` unset; that conversation takes the
209
+ * composer's disabled branch instead).
210
+ *
211
+ * Client-derived on purpose, and NOT a divergence from DD-011 A-1's
212
+ * "the predicate is expressed once, in SQL" rule: the window is
213
+ * wall-clock-anchored — it flips with NO row change — so a
214
+ * server-computed boolean would be stale by construction between polls.
215
+ * Time-decaying presentation derived from wire instants is this
216
+ * surface's established class (relative timestamps, day grouping).
217
+ *
218
+ * Honesty caveat (DD-014 D-a): the anchor is the platform's receipt
219
+ * instant, minutes AFTER Meta's own clock started the window, so the
220
+ * estimate can claim "open" briefly after Meta closed it — which is why
221
+ * consumers warn and never block (D-b). For the same reason the exact
222
+ * 24-hour tie reads "closed": erring a moment early offsets an anchor
223
+ * that always errs late.
224
+ */
225
+ export function serviceWindowOf(
226
+ conversation: ChannelConversation,
227
+ provider: ChannelProviderId | null,
228
+ now: Date,
229
+ ): ServiceWindowState | null {
230
+ if (provider !== "whatsapp") return null;
231
+ const anchor = conversation.lastCustomerMessageAt;
232
+ if (anchor === undefined) return null;
233
+ const elapsedMs = now.getTime() - timestampDate(anchor).getTime();
234
+ return elapsedMs >= WHATSAPP_SERVICE_WINDOW_MS ? "closed" : "open";
235
+ }
236
+
237
+ /**
238
+ * How strongly the awaiting-reply indicator renders. Two strengths, not
239
+ * one, because "waiting" means different things per holder (DD-011 D-a).
240
+ */
241
+ export type AwaitingIndicator = "strong" | "muted";
242
+
243
+ /**
244
+ * The awaiting-reply indicator for a conversation (channel-conversations
245
+ * DD-011 D-a): `"strong"` when a human holds it — the agent will not
246
+ * answer, so a person must (F-13's exact case) — and `"muted"` when the
247
+ * agent holds it: the agent is about to answer, and if its turn dies the
248
+ * conversation stays visibly waiting, which is the built-in recovery
249
+ * path (DD-011 D-b). `null` when the customer's last message has been
250
+ * answered (or the customer has not written).
251
+ *
252
+ * The fact itself is server-derived (`awaiting_reply` rides the wire as
253
+ * a boolean precisely so no client re-implements the NULL-and-compare
254
+ * rule); this helper only maps fact × holder onto render strength.
255
+ */
256
+ export function awaitingIndicatorOf(
257
+ conversation: ChannelConversation,
258
+ ): AwaitingIndicator | null {
259
+ if (!conversation.awaitingReply) return null;
260
+ return conversation.control === ConversationControl.control_human
261
+ ? "strong"
262
+ : "muted";
263
+ }
264
+
265
+ /**
266
+ * The timeline item id an accepted staff reply appears under: the
267
+ * outbound ledger's `ob:` namespace over the reply output's
268
+ * `outbound_message_id` — the contract
269
+ * `useConversationParticipation.reply` documents ("the item will
270
+ * appear on the timeline as `ob:<that id>`"). The workbench's
271
+ * post-send busy state watches for exactly this id (F-05).
272
+ */
273
+ export function outboundItemIdOf(outboundMessageId: string): string {
274
+ return `ob:${outboundMessageId}`;
275
+ }
276
+
277
+ /**
278
+ * Newest-first timeline order: `(at, item_id)` descending — the exact
279
+ * comparator the server pages with, so client-side merges can never
280
+ * disagree with server pages about order.
281
+ */
282
+ export function compareTimelineItemsNewestFirst(
283
+ a: ConversationTimelineItem,
284
+ b: ConversationTimelineItem,
285
+ ): number {
286
+ const byInstant = compareInstants(b.at, a.at);
287
+ if (byInstant !== 0) return byInstant;
288
+ return a.itemId < b.itemId ? 1 : a.itemId > b.itemId ? -1 : 0;
289
+ }
290
+
291
+ // BigInt() call form, not a 0n literal: the web console compiles SDK
292
+ // sources under a pre-ES2020 target where the literal is a syntax error.
293
+ const ZERO_SECONDS = BigInt(0);
294
+
295
+ function compareInstants(a: Timestamp | undefined, b: Timestamp | undefined): number {
296
+ // A missing instant sorts oldest — the server always stamps `at`, so
297
+ // this only orders a defensively-handled corrupt item to the top of
298
+ // history rather than crashing the sort.
299
+ const aSeconds = a?.seconds ?? ZERO_SECONDS;
300
+ const bSeconds = b?.seconds ?? ZERO_SECONDS;
301
+ if (aSeconds !== bSeconds) return aSeconds < bSeconds ? -1 : 1;
302
+ const aNanos = a?.nanos ?? 0;
303
+ const bNanos = b?.nanos ?? 0;
304
+ return aNanos - bNanos;
305
+ }