@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,289 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { create } from "@bufbuild/protobuf";
3
+ import { timestampFromDate } from "@bufbuild/protobuf/wkt";
4
+ import {
5
+ ChannelConversationSchema,
6
+ ConversationControl,
7
+ ConversationItemAuthor,
8
+ ConversationLane,
9
+ ConversationTimelineItemSchema,
10
+ type ConversationTimelineItem,
11
+ } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/conversation_io_pb";
12
+ import { ChannelDeliveryStatus } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/delivery_pb";
13
+ import { ChannelReceiptState } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/outbound_pb";
14
+ import {
15
+ authorKindOf,
16
+ awaitingIndicatorOf,
17
+ compareTimelineItemsNewestFirst,
18
+ conversationContactOf,
19
+ conversationLabelOf,
20
+ inboundPlaceholderOf,
21
+ isInternalItem,
22
+ outboundItemIdOf,
23
+ receiptOf,
24
+ sendAttemptOf,
25
+ serviceWindowOf,
26
+ } from "../conversationPresentation";
27
+
28
+ function item(fields: Parameters<typeof create<typeof ConversationTimelineItemSchema>>[1]) {
29
+ return create(ConversationTimelineItemSchema, fields);
30
+ }
31
+
32
+ describe("authorKindOf", () => {
33
+ it("maps every wire author onto the render vocabulary", () => {
34
+ expect(authorKindOf(item({ author: ConversationItemAuthor.author_customer }))).toBe("customer");
35
+ expect(authorKindOf(item({ author: ConversationItemAuthor.author_agent }))).toBe("agent");
36
+ expect(authorKindOf(item({ author: ConversationItemAuthor.author_teammate }))).toBe("teammate");
37
+ expect(authorKindOf(item({ author: ConversationItemAuthor.author_platform }))).toBe("platform");
38
+ });
39
+
40
+ it("answers unknown for the unspecified author rather than guessing", () => {
41
+ expect(authorKindOf(item({}))).toBe("unknown");
42
+ });
43
+ });
44
+
45
+ describe("isInternalItem", () => {
46
+ it("flags internal-lane items and only those", () => {
47
+ expect(isInternalItem(item({ lane: ConversationLane.lane_internal }))).toBe(true);
48
+ expect(isInternalItem(item({ lane: ConversationLane.lane_public }))).toBe(false);
49
+ expect(isInternalItem(item({}))).toBe(false);
50
+ });
51
+ });
52
+
53
+ describe("awaitingIndicatorOf (DD-011 D-a: strength maps the holder)", () => {
54
+ it("renders strongly when a human holds an awaiting conversation — the agent will not answer", () => {
55
+ expect(
56
+ awaitingIndicatorOf(
57
+ create(ChannelConversationSchema, {
58
+ awaitingReply: true,
59
+ control: ConversationControl.control_human,
60
+ }),
61
+ ),
62
+ ).toBe("strong");
63
+ });
64
+
65
+ it("renders mutedly when the agent holds an awaiting conversation — an answer is on its way", () => {
66
+ expect(
67
+ awaitingIndicatorOf(
68
+ create(ChannelConversationSchema, {
69
+ awaitingReply: true,
70
+ control: ConversationControl.control_agent,
71
+ }),
72
+ ),
73
+ ).toBe("muted");
74
+ // The unspecified holder defaults to the agent (proto3 zero value).
75
+ expect(
76
+ awaitingIndicatorOf(create(ChannelConversationSchema, { awaitingReply: true })),
77
+ ).toBe("muted");
78
+ });
79
+
80
+ it("renders nothing once the customer is answered, whoever holds it", () => {
81
+ expect(
82
+ awaitingIndicatorOf(
83
+ create(ChannelConversationSchema, {
84
+ control: ConversationControl.control_human,
85
+ }),
86
+ ),
87
+ ).toBeNull();
88
+ expect(awaitingIndicatorOf(create(ChannelConversationSchema, {}))).toBeNull();
89
+ });
90
+ });
91
+
92
+ describe("outboundItemIdOf", () => {
93
+ it("names the timeline item an accepted staff reply appears under (the ob: namespace)", () => {
94
+ expect(outboundItemIdOf("obm_1")).toBe("ob:obm_1");
95
+ });
96
+ });
97
+
98
+ describe("the two status axes (DD-004 D-d: never collapsed)", () => {
99
+ it("maps the send attempt independently of the receipt", () => {
100
+ expect(sendAttemptOf(item({ deliveryStatus: ChannelDeliveryStatus.pending }))).toBe("pending");
101
+ expect(sendAttemptOf(item({ deliveryStatus: ChannelDeliveryStatus.delivering }))).toBe("delivering");
102
+ expect(sendAttemptOf(item({ deliveryStatus: ChannelDeliveryStatus.delivered }))).toBe("delivered");
103
+ expect(sendAttemptOf(item({ deliveryStatus: ChannelDeliveryStatus.failed }))).toBe("failed");
104
+ expect(sendAttemptOf(item({ deliveryStatus: ChannelDeliveryStatus.suppressed }))).toBe("suppressed");
105
+ expect(sendAttemptOf(item({}))).toBeNull();
106
+ });
107
+
108
+ it("maps the receipt independently of the send attempt", () => {
109
+ expect(receiptOf(item({ receiptState: ChannelReceiptState.receipt_sent }))).toBe("sent");
110
+ expect(receiptOf(item({ receiptState: ChannelReceiptState.receipt_delivered }))).toBe("delivered");
111
+ expect(receiptOf(item({ receiptState: ChannelReceiptState.receipt_read }))).toBe("read");
112
+ expect(receiptOf(item({ receiptState: ChannelReceiptState.receipt_failed }))).toBe("failed");
113
+ expect(receiptOf(item({}))).toBeNull();
114
+ });
115
+
116
+ it("represents the provider-accepted-then-failed item: delivered attempt, failed receipt", () => {
117
+ // The combination a collapsed single status cannot express — "we
118
+ // handed it to the provider" AND "it never reached the phone".
119
+ const acceptedThenFailed = item({
120
+ deliveryStatus: ChannelDeliveryStatus.delivered,
121
+ receiptState: ChannelReceiptState.receipt_failed,
122
+ });
123
+ expect(sendAttemptOf(acceptedThenFailed)).toBe("delivered");
124
+ expect(receiptOf(acceptedThenFailed)).toBe("failed");
125
+ });
126
+
127
+ it("keeps inbound items off both axes", () => {
128
+ const inbound = item({
129
+ author: ConversationItemAuthor.author_customer,
130
+ text: "hello",
131
+ });
132
+ expect(sendAttemptOf(inbound)).toBeNull();
133
+ expect(receiptOf(inbound)).toBeNull();
134
+ });
135
+ });
136
+
137
+ describe("inboundPlaceholderOf", () => {
138
+ it("names the known non-text kinds", () => {
139
+ expect(inboundPlaceholderOf(item({ providerMessageType: "image" }))).toBe("Photo");
140
+ expect(inboundPlaceholderOf(item({ providerMessageType: "audio" }))).toBe("Voice message");
141
+ expect(inboundPlaceholderOf(item({ providerMessageType: "document" }))).toBe("Document");
142
+ expect(inboundPlaceholderOf(item({ providerMessageType: "location" }))).toBe("Location");
143
+ });
144
+
145
+ it("passes an unknown provider kind through verbatim, never re-encoded", () => {
146
+ expect(inboundPlaceholderOf(item({ providerMessageType: "reaction" })))
147
+ .toBe("Unsupported message (reaction)");
148
+ });
149
+
150
+ it("needs no placeholder for items with text or without a provider type", () => {
151
+ expect(inboundPlaceholderOf(item({ text: "hi", providerMessageType: "text" }))).toBeNull();
152
+ // Empty text + empty type: internal events (attention clears) and
153
+ // template sends — their copy is the renderer's, not a placeholder.
154
+ expect(inboundPlaceholderOf(item({}))).toBeNull();
155
+ });
156
+ });
157
+
158
+ describe("conversationLabelOf", () => {
159
+ it("prefers the provider-reported display name", () => {
160
+ const conversation = create(ChannelConversationSchema, {
161
+ conversationKey: "15550001111",
162
+ displayName: "Pat",
163
+ });
164
+ expect(conversationLabelOf(conversation, "whatsapp")).toBe("Pat");
165
+ });
166
+
167
+ it("falls back to the key on WhatsApp — a phone number is a real label", () => {
168
+ const conversation = create(ChannelConversationSchema, {
169
+ conversationKey: "15550001111",
170
+ });
171
+ expect(conversationLabelOf(conversation, "whatsapp")).toBe("15550001111");
172
+ expect(conversationLabelOf(conversation, null)).toBe("15550001111");
173
+ });
174
+
175
+ it("never shows a Slack thread timestamp as a name", () => {
176
+ const conversation = create(ChannelConversationSchema, {
177
+ conversationKey: "1723012345.678900",
178
+ });
179
+ expect(conversationLabelOf(conversation, "slack")).toBe("Slack thread");
180
+ });
181
+ });
182
+
183
+ describe("conversationContactOf", () => {
184
+ it("surfaces the WhatsApp number a display name would otherwise hide (F-17)", () => {
185
+ const conversation = create(ChannelConversationSchema, {
186
+ conversationKey: "15550001111",
187
+ displayName: "Pat",
188
+ });
189
+ // Verbatim wire value — no fabricated "+" prefix.
190
+ expect(conversationContactOf(conversation, "whatsapp")).toBe("15550001111");
191
+ });
192
+
193
+ it("adds nothing when the label already shows the key", () => {
194
+ const conversation = create(ChannelConversationSchema, {
195
+ conversationKey: "15550001111",
196
+ });
197
+ expect(conversationContactOf(conversation, "whatsapp")).toBeNull();
198
+ });
199
+
200
+ it("answers null for keys that are not reachable addresses", () => {
201
+ const slackThread = create(ChannelConversationSchema, {
202
+ conversationKey: "1723012345.678900",
203
+ displayName: "deploy question",
204
+ });
205
+ expect(conversationContactOf(slackThread, "slack")).toBeNull();
206
+ // An unknown provider shows nothing rather than guessing that its
207
+ // key is an address.
208
+ expect(conversationContactOf(slackThread, null)).toBeNull();
209
+ });
210
+ });
211
+
212
+ describe("serviceWindowOf (DD-014 D-a: client-derived, WhatsApp-only)", () => {
213
+ const NOW = new Date("2026-08-08T12:00:00Z");
214
+ const lastWrote = (iso: string) =>
215
+ create(ChannelConversationSchema, {
216
+ conversationKey: "15550001111",
217
+ lastCustomerMessageAt: timestampFromDate(new Date(iso)),
218
+ });
219
+
220
+ it("answers open inside the 24-hour window", () => {
221
+ expect(serviceWindowOf(lastWrote("2026-08-08T11:59:00Z"), "whatsapp", NOW)).toBe("open");
222
+ // One millisecond inside the boundary.
223
+ expect(serviceWindowOf(lastWrote("2026-08-07T12:00:00.001Z"), "whatsapp", NOW)).toBe(
224
+ "open",
225
+ );
226
+ });
227
+
228
+ it("answers closed at exactly 24 hours — the tie closes because our anchor already lags Meta's", () => {
229
+ // The stored anchor is the platform's receipt instant, minutes AFTER
230
+ // Meta's own clock started the window, so the estimate inherently
231
+ // errs toward claiming open too long. Closing the tie is the honest
232
+ // offset; the warn-never-block posture (DD-014 D-b) makes either
233
+ // direction safe to render.
234
+ expect(serviceWindowOf(lastWrote("2026-08-07T12:00:00Z"), "whatsapp", NOW)).toBe("closed");
235
+ expect(serviceWindowOf(lastWrote("2026-08-06T09:00:00Z"), "whatsapp", NOW)).toBe("closed");
236
+ });
237
+
238
+ it("makes no claim without a customer message — that conversation is the composer's disabled branch", () => {
239
+ expect(serviceWindowOf(create(ChannelConversationSchema, {}), "whatsapp", NOW)).toBeNull();
240
+ });
241
+
242
+ it("makes no claim for providers without a window", () => {
243
+ const stale = lastWrote("2026-08-01T09:00:00Z");
244
+ expect(serviceWindowOf(stale, "slack", NOW)).toBeNull();
245
+ expect(serviceWindowOf(stale, null, NOW)).toBeNull();
246
+ });
247
+
248
+ it("reads a future-dated customer message as open instead of misfiring on clock skew", () => {
249
+ expect(serviceWindowOf(lastWrote("2026-08-08T12:05:00Z"), "whatsapp", NOW)).toBe("open");
250
+ });
251
+ });
252
+
253
+ describe("compareTimelineItemsNewestFirst", () => {
254
+ const at = (iso: string) => timestampFromDate(new Date(iso));
255
+
256
+ it("orders by instant descending", () => {
257
+ const older = item({ itemId: "wa:1", at: at("2026-08-07T09:00:00Z") });
258
+ const newer = item({ itemId: "dl:2", at: at("2026-08-07T09:00:05Z") });
259
+ expect([older, newer].sort(compareTimelineItemsNewestFirst)).toEqual([newer, older]);
260
+ });
261
+
262
+ it("orders sub-second instants by nanos — the 007/D7 string-inversion trap is structurally absent", () => {
263
+ const wholeSecond = item({ itemId: "wa:1", at: at("2026-08-07T09:00:00.000Z") });
264
+ const midSecond = item({ itemId: "wa:2", at: at("2026-08-07T09:00:00.123Z") });
265
+ // As RFC-3339 TEXT, "..:00.123Z" sorts BEFORE "..:00Z" — the
266
+ // inversion the server corrects by parsing. This comparator works on
267
+ // seconds+nanos, so chronology holds by construction.
268
+ expect([midSecond, wholeSecond].sort(compareTimelineItemsNewestFirst)).toEqual([
269
+ midSecond,
270
+ wholeSecond,
271
+ ]);
272
+ });
273
+
274
+ it("breaks instant ties by item id descending — the server's exact tiebreak", () => {
275
+ const instant = at("2026-08-07T09:00:00Z");
276
+ const a = item({ itemId: "dl:aaa", at: instant });
277
+ const b = item({ itemId: "wa:bbb", at: instant });
278
+ expect([a, b].sort(compareTimelineItemsNewestFirst)).toEqual([b, a]);
279
+ });
280
+
281
+ it("sorts an instant-less defensive item oldest instead of crashing", () => {
282
+ const bare = item({ itemId: "wa:bare" });
283
+ const dated = item({ itemId: "wa:dated", at: at("2026-08-07T09:00:00Z") });
284
+ const sorted: ConversationTimelineItem[] = [bare, dated].sort(
285
+ compareTimelineItemsNewestFirst,
286
+ );
287
+ expect(sorted[0]).toBe(dated);
288
+ });
289
+ });
@@ -0,0 +1,243 @@
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 tests broadcast 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 { StigmerError } from "@stigmer/sdk";
12
+ import {
13
+ ChannelConversationSchema,
14
+ ConversationControl,
15
+ } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/conversation_io_pb";
16
+ import { StigmerContext } from "../../context";
17
+ import { FetchCacheContext } from "../../internal/FetchCacheProvider";
18
+ import { useConversation } from "../useConversation";
19
+
20
+ function row(overrides: { control?: ConversationControl; controlledBy?: string } = {}) {
21
+ return create(ChannelConversationSchema, {
22
+ agentChannelId: "ach_1",
23
+ conversationKey: "15550001111",
24
+ org: "acme",
25
+ control: overrides.control ?? ConversationControl.control_agent,
26
+ controlledBy: overrides.controlledBy ?? "",
27
+ displayName: "Pat",
28
+ });
29
+ }
30
+
31
+ function notFound() {
32
+ return new StigmerError("not-found", "no conversation with this key exists on channel ach_1", 5);
33
+ }
34
+
35
+ function createMockStigmer(getConversation: (input: unknown) => Promise<unknown>) {
36
+ return { agentChannel: { getConversation } } 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
+ const NO_POLL = { refetchIntervalMs: false as const };
52
+
53
+ describe("useConversation", () => {
54
+ beforeEach(() => {
55
+ vi.restoreAllMocks();
56
+ });
57
+
58
+ it("loads the conversation row", async () => {
59
+ const getConversation = vi.fn().mockResolvedValue(row());
60
+ const { result } = renderHook(
61
+ () => useConversation("ach_1", "15550001111", NO_POLL),
62
+ { wrapper: wrapper(createMockStigmer(getConversation)) },
63
+ );
64
+
65
+ expect(result.current.isLoading).toBe(true);
66
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
67
+
68
+ expect(result.current.conversation?.displayName).toBe("Pat");
69
+ expect(result.current.awaitingCustomer).toBe(false);
70
+ expect(result.current.error).toBeNull();
71
+ expect(getConversation).toHaveBeenCalledWith(
72
+ expect.objectContaining({ agentChannelId: "ach_1", conversationKey: "15550001111" }),
73
+ );
74
+ });
75
+
76
+ it("answers NOT_FOUND as the awaiting-customer state, never an error", async () => {
77
+ const getConversation = vi.fn().mockRejectedValue(notFound());
78
+ const { result } = renderHook(
79
+ () => useConversation("ach_1", "15550001111", NO_POLL),
80
+ { wrapper: wrapper(createMockStigmer(getConversation)) },
81
+ );
82
+
83
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
84
+
85
+ // A cold-send conversation has a timeline before it has a row; the
86
+ // hook must let consumers render "controls unlock when the customer
87
+ // writes" rather than an error page.
88
+ expect(result.current.awaitingCustomer).toBe(true);
89
+ expect(result.current.conversation).toBeNull();
90
+ expect(result.current.error).toBeNull();
91
+ });
92
+
93
+ it("surfaces every other failure as the error state", async () => {
94
+ const getConversation = vi
95
+ .fn()
96
+ .mockRejectedValue(new StigmerError("permission-denied", "unauthorized", 7));
97
+ const { result } = renderHook(
98
+ () => useConversation("ach_1", "15550001111", NO_POLL),
99
+ { wrapper: wrapper(createMockStigmer(getConversation)) },
100
+ );
101
+
102
+ await waitFor(() => expect(result.current.error).not.toBeNull());
103
+ expect(result.current.awaitingCustomer).toBe(false);
104
+ expect(result.current.conversation).toBeNull();
105
+ });
106
+
107
+ it("skips fetching for empty identity (stable no-op)", async () => {
108
+ const getConversation = vi.fn();
109
+ const { result } = renderHook(() => useConversation("", "", NO_POLL), {
110
+ wrapper: wrapper(createMockStigmer(getConversation)),
111
+ });
112
+
113
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
114
+ expect(getConversation).not.toHaveBeenCalled();
115
+ });
116
+
117
+ it("fences a stale in-flight poll behind applyServerState — command truth is never overwritten", async () => {
118
+ // Sequence: the first read answers agent-held; a second read is
119
+ // started and parked in flight; a takeover response is applied; the
120
+ // parked read then resolves with the PRE-command state. The fence
121
+ // must discard it — otherwise the control banner flips back to
122
+ // "agent" under the cursor of the human who just took over, until
123
+ // the next poll corrects it.
124
+ let release: (value: unknown) => void = () => {};
125
+ const parked = new Promise((resolve) => {
126
+ release = resolve;
127
+ });
128
+ const getConversation = vi
129
+ .fn()
130
+ .mockResolvedValueOnce(row())
131
+ .mockReturnValueOnce(parked);
132
+
133
+ const { result } = renderHook(
134
+ () => useConversation("ach_1", "15550001111", NO_POLL),
135
+ { wrapper: wrapper(createMockStigmer(getConversation)) },
136
+ );
137
+ await waitFor(() =>
138
+ expect(result.current.conversation?.control).toBe(ConversationControl.control_agent),
139
+ );
140
+
141
+ act(() => result.current.refetch());
142
+ await waitFor(() => expect(result.current.isRefetching).toBe(true));
143
+
144
+ const taken = row({ control: ConversationControl.control_human, controlledBy: "idt_me" });
145
+ act(() => result.current.applyServerState(taken));
146
+ expect(result.current.conversation?.control).toBe(ConversationControl.control_human);
147
+
148
+ await act(async () => {
149
+ release(row());
150
+ await parked;
151
+ });
152
+
153
+ expect(result.current.conversation?.control).toBe(ConversationControl.control_human);
154
+ expect(result.current.conversation?.controlledBy).toBe("idt_me");
155
+ // The fenced answer still settles the in-flight flag, so polling
156
+ // can resume — a stranded flag would silence liveness forever.
157
+ await waitFor(() => expect(result.current.isRefetching).toBe(false));
158
+ });
159
+
160
+ it("resets state on identity change so channel A never renders under channel B", async () => {
161
+ const getConversation = vi
162
+ .fn()
163
+ .mockResolvedValueOnce(row())
164
+ .mockReturnValue(new Promise(() => {}));
165
+
166
+ const { result, rerender } = renderHook(
167
+ ({ key }: { key: string }) => useConversation("ach_1", key, NO_POLL),
168
+ {
169
+ wrapper: wrapper(createMockStigmer(getConversation)),
170
+ initialProps: { key: "15550001111" },
171
+ },
172
+ );
173
+ await waitFor(() => expect(result.current.conversation).not.toBeNull());
174
+
175
+ rerender({ key: "15550002222" });
176
+
177
+ expect(result.current.conversation).toBeNull();
178
+ expect(result.current.isLoading).toBe(true);
179
+ });
180
+
181
+ it("refetches the row when the window regains focus (DD-012: refocus is fresh)", async () => {
182
+ const getConversation = vi.fn().mockResolvedValue(row());
183
+ renderHook(() => useConversation("ach_1", "15550001111", NO_POLL), {
184
+ wrapper: wrapper(createMockStigmer(getConversation)),
185
+ });
186
+ await waitFor(() => expect(getConversation).toHaveBeenCalledTimes(1));
187
+
188
+ await act(async () => {
189
+ window.dispatchEvent(new Event("focus"));
190
+ });
191
+
192
+ await waitFor(() => expect(getConversation).toHaveBeenCalledTimes(2));
193
+ });
194
+
195
+ it("skips the focus refetch while a fetch is already in flight (no pile-up)", async () => {
196
+ let release: (value: unknown) => void = () => {};
197
+ const parked = new Promise((resolve) => {
198
+ release = resolve;
199
+ });
200
+ const getConversation = vi
201
+ .fn()
202
+ .mockResolvedValueOnce(row())
203
+ .mockReturnValueOnce(parked)
204
+ .mockResolvedValue(row());
205
+
206
+ const { result } = renderHook(
207
+ () => useConversation("ach_1", "15550001111", NO_POLL),
208
+ { wrapper: wrapper(createMockStigmer(getConversation)) },
209
+ );
210
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
211
+
212
+ // Park a fetch in flight, then refocus: the guard must not stack a
213
+ // second request on top of it.
214
+ act(() => result.current.refetch());
215
+ await waitFor(() => expect(result.current.isRefetching).toBe(true));
216
+
217
+ await act(async () => {
218
+ window.dispatchEvent(new Event("focus"));
219
+ });
220
+ expect(getConversation).toHaveBeenCalledTimes(2);
221
+
222
+ await act(async () => {
223
+ release(row());
224
+ await parked;
225
+ });
226
+ await waitFor(() => expect(result.current.isRefetching).toBe(false));
227
+ });
228
+
229
+ it("keeps the row reference stable across polls that change nothing (DD-010)", async () => {
230
+ const getConversation = vi.fn().mockImplementation(() => Promise.resolve(row()));
231
+ const { result } = renderHook(
232
+ () => useConversation("ach_1", "15550001111", NO_POLL),
233
+ { wrapper: wrapper(createMockStigmer(getConversation)) },
234
+ );
235
+ await waitFor(() => expect(result.current.conversation).not.toBeNull());
236
+ const first = result.current.conversation;
237
+
238
+ act(() => result.current.refetch());
239
+ await waitFor(() => expect(result.current.isRefetching).toBe(false));
240
+
241
+ expect(result.current.conversation).toBe(first);
242
+ });
243
+ });