@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,160 @@
1
+ // Scroll-behavior regression for the conversation timeline (F-09), against
2
+ // the REAL component and the shipped stylesheet in a real Chromium — the
3
+ // composition that shipped the bug: `ConversationTimelineView` mounts its
4
+ // content wrapper inside the loading branch, so the auto-scroll machinery
5
+ // must attach to a wrapper that does not exist on first render. The
6
+ // hook-level mechanics are pinned in
7
+ // `internal/__tests__/useAutoScroll.layout.test.tsx`; this suite proves the
8
+ // component wiring delivers them: a conversation OPENS at the newest
9
+ // message with "Jump to latest" hidden, and stays pinned as messages land.
10
+
11
+ import "../../../dist/styles.css";
12
+
13
+ import { describe, it, expect, vi, afterEach } from "vitest";
14
+ import { render, cleanup } from "@testing-library/react";
15
+ import type { ReactElement } from "react";
16
+ import { create } from "@bufbuild/protobuf";
17
+ import { timestampFromDate } from "@bufbuild/protobuf/wkt";
18
+ import {
19
+ ConversationItemAuthor,
20
+ ConversationLane,
21
+ ConversationTimelineItemSchema,
22
+ type ConversationTimelineItem,
23
+ } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/conversation_io_pb";
24
+ import { ConversationTimelineView } from "../ConversationTimelineView.js";
25
+
26
+ const NOW = new Date("2026-08-07T12:00:00Z");
27
+
28
+ function items(count: number): ConversationTimelineItem[] {
29
+ return Array.from({ length: count }, (_, i) =>
30
+ create(ConversationTimelineItemSchema, {
31
+ itemId: `wa:${i}`,
32
+ lane: ConversationLane.lane_public,
33
+ author:
34
+ i % 2 === 0
35
+ ? ConversationItemAuthor.author_customer
36
+ : ConversationItemAuthor.author_agent,
37
+ text: `message ${i}`,
38
+ at: timestampFromDate(new Date(NOW.getTime() - (count - i) * 60_000)),
39
+ }),
40
+ );
41
+ }
42
+
43
+ function view(props: {
44
+ readonly items: readonly ConversationTimelineItem[];
45
+ readonly isLoading: boolean;
46
+ }): ReactElement {
47
+ return (
48
+ <ConversationTimelineView
49
+ items={props.items}
50
+ isLoading={props.isLoading}
51
+ error={null}
52
+ hasOlder={false}
53
+ loadOlder={() => {}}
54
+ isLoadingOlder={false}
55
+ provider="whatsapp"
56
+ now={NOW}
57
+ />
58
+ );
59
+ }
60
+
61
+ /**
62
+ * Render inside a fixed-height themed `.stgm` box (the shipped stylesheet
63
+ * is scoped to it) so the timeline's internal scroller actually overflows.
64
+ */
65
+ function renderInPane(ui: ReactElement) {
66
+ const pane = document.createElement("div");
67
+ pane.className = "stgm";
68
+ pane.style.height = "360px";
69
+ pane.style.width = "640px";
70
+ pane.style.display = "flex";
71
+ pane.style.flexDirection = "column";
72
+ document.body.appendChild(pane);
73
+ return render(ui, { container: pane });
74
+ }
75
+
76
+ function scroller(): HTMLElement {
77
+ // The component's scroll container is the timeline's direct child pane
78
+ // (ConversationTimelineView.tsx: the overflow-y-auto div under the
79
+ // aria-labelled root).
80
+ const rootEl = document.querySelector('[aria-label="Conversation timeline"]');
81
+ return rootEl?.firstElementChild as HTMLElement;
82
+ }
83
+
84
+ function jumpButton(): HTMLElement {
85
+ return document.querySelector('button[aria-label="Jump to latest"]') as HTMLElement;
86
+ }
87
+
88
+ const isPinnedToBottom = (el: HTMLElement) =>
89
+ el.scrollHeight > el.clientHeight &&
90
+ Math.abs(el.scrollTop - (el.scrollHeight - el.clientHeight)) <= 1;
91
+
92
+ async function settled(assertion: () => void): Promise<void> {
93
+ await vi.waitFor(assertion, { timeout: 2000, interval: 20 });
94
+ }
95
+
96
+ // Determinism note: every case starts loading and fills content on a
97
+ // rerender — the production sequence, and also what makes the scroll-up
98
+ // case race-free: with a late-mounting wrapper the mount-time scroll
99
+ // write is a no-op, so "pinned" can only come from the ResizeObserver
100
+ // pathway, proving its initial delivery and rAF write have both run
101
+ // before any simulated reader scroll. (Headless Chromium delivers
102
+ // initial observations on its own schedule; frame-count waits were
103
+ // measured flaky.)
104
+
105
+ afterEach(() => {
106
+ cleanup();
107
+ document.querySelectorAll(".stgm").forEach((node) => node.remove());
108
+ });
109
+
110
+ describe("ConversationTimelineView scroll behavior (F-09)", () => {
111
+ it("opens at the newest message after the async first load", async () => {
112
+ // The production sequence: the timeline mounts loading (skeleton, no
113
+ // content wrapper), then the first page lands.
114
+ const { rerender } = renderInPane(view({ items: [], isLoading: true }));
115
+ rerender(view({ items: items(40), isLoading: false }));
116
+
117
+ await settled(() => {
118
+ expect(
119
+ isPinnedToBottom(scroller()),
120
+ "a conversation must open at its newest message",
121
+ ).toBe(true);
122
+ // Following ⇒ the recovery affordance stays hidden.
123
+ expect(jumpButton().getAttribute("aria-hidden")).toBe("true");
124
+ });
125
+ });
126
+
127
+ it("stays pinned as new messages land on poll ticks", async () => {
128
+ const { rerender } = renderInPane(view({ items: [], isLoading: true }));
129
+ rerender(view({ items: items(40), isLoading: false }));
130
+ await settled(() => expect(isPinnedToBottom(scroller())).toBe(true));
131
+
132
+ rerender(view({ items: items(42), isLoading: false }));
133
+
134
+ await settled(() => {
135
+ expect(isPinnedToBottom(scroller())).toBe(true);
136
+ expect(jumpButton().getAttribute("aria-hidden")).toBe("true");
137
+ });
138
+ });
139
+
140
+ it("offers Jump to latest to a reader who scrolled up, without moving them", async () => {
141
+ const { rerender } = renderInPane(view({ items: [], isLoading: true }));
142
+ rerender(view({ items: items(40), isLoading: false }));
143
+ await settled(() => expect(isPinnedToBottom(scroller())).toBe(true));
144
+
145
+ scroller().scrollTop = 0;
146
+ await settled(() =>
147
+ expect(jumpButton().getAttribute("aria-hidden")).toBe("false"),
148
+ );
149
+
150
+ rerender(view({ items: items(41), isLoading: false }));
151
+ await new Promise((resolve) => setTimeout(resolve, 100));
152
+ expect(scroller().scrollTop).toBe(0);
153
+
154
+ jumpButton().click();
155
+ await settled(() => {
156
+ expect(isPinnedToBottom(scroller())).toBe(true);
157
+ expect(jumpButton().getAttribute("aria-hidden")).toBe("true");
158
+ });
159
+ });
160
+ });
@@ -0,0 +1,405 @@
1
+ import { describe, it, expect, vi, afterEach } from "vitest";
2
+ import { cleanup, render, screen } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import { create } from "@bufbuild/protobuf";
5
+ import { timestampFromDate } from "@bufbuild/protobuf/wkt";
6
+ import {
7
+ ConversationItemAuthor,
8
+ ConversationLane,
9
+ ConversationTimelineItemSchema,
10
+ } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/conversation_io_pb";
11
+ import { ChannelDeliveryStatus } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/delivery_pb";
12
+ import { ChannelReceiptState } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/outbound_pb";
13
+ import { ConversationTimelineView } from "../ConversationTimelineView";
14
+
15
+ const NOW = new Date("2026-08-07T12:00:00Z");
16
+
17
+ function item(id: string, fields: Record<string, unknown> = {}) {
18
+ return create(ConversationTimelineItemSchema, {
19
+ itemId: id,
20
+ lane: ConversationLane.lane_public,
21
+ at: timestampFromDate(new Date("2026-08-07T09:00:00Z")),
22
+ ...fields,
23
+ });
24
+ }
25
+
26
+ function baseProps() {
27
+ return {
28
+ items: [] as never[],
29
+ isLoading: false,
30
+ error: null,
31
+ hasOlder: false,
32
+ loadOlder: vi.fn(),
33
+ isLoadingOlder: false,
34
+ provider: "whatsapp" as const,
35
+ now: NOW,
36
+ };
37
+ }
38
+
39
+ describe("ConversationTimelineView", () => {
40
+ afterEach(() => cleanup());
41
+
42
+ it("renders customer and business sides with author captions", () => {
43
+ render(
44
+ <ConversationTimelineView
45
+ {...baseProps()}
46
+ items={[
47
+ item("wa:1", {
48
+ author: ConversationItemAuthor.author_customer,
49
+ text: "where is my order?",
50
+ }),
51
+ item("dl:2", {
52
+ author: ConversationItemAuthor.author_agent,
53
+ text: "checking now",
54
+ deliveryStatus: ChannelDeliveryStatus.delivered,
55
+ }),
56
+ item("ob:3", {
57
+ author: ConversationItemAuthor.author_teammate,
58
+ text: "I'll take this one",
59
+ deliveryStatus: ChannelDeliveryStatus.delivered,
60
+ }),
61
+ item("ob:4", {
62
+ author: ConversationItemAuthor.author_platform,
63
+ text: "A team member will reply shortly.",
64
+ deliveryStatus: ChannelDeliveryStatus.delivered,
65
+ }),
66
+ ]}
67
+ />,
68
+ );
69
+
70
+ expect(screen.getByText("where is my order?")).toBeDefined();
71
+ expect(screen.getByText("Agent")).toBeDefined();
72
+ expect(screen.getByText("Teammate")).toBeDefined();
73
+ expect(screen.getByText("Automated")).toBeDefined();
74
+ // The customer side never carries a caption.
75
+ expect(screen.queryByText("Customer")).toBeNull();
76
+ });
77
+
78
+ it("renders a typed placeholder for non-text inbound instead of hiding it", () => {
79
+ render(
80
+ <ConversationTimelineView
81
+ {...baseProps()}
82
+ items={[
83
+ item("wa:1", {
84
+ author: ConversationItemAuthor.author_customer,
85
+ providerMessageType: "image",
86
+ }),
87
+ ]}
88
+ />,
89
+ );
90
+ expect(screen.getByText("Photo")).toBeDefined();
91
+ });
92
+
93
+ it("keeps the two status axes distinct: provider-accepted yet receipt-failed", () => {
94
+ render(
95
+ <ConversationTimelineView
96
+ {...baseProps()}
97
+ items={[
98
+ item("ob:1", {
99
+ author: ConversationItemAuthor.author_agent,
100
+ text: "your table is ready",
101
+ deliveryStatus: ChannelDeliveryStatus.delivered,
102
+ receiptState: ChannelReceiptState.receipt_failed,
103
+ }),
104
+ ]}
105
+ />,
106
+ );
107
+ // The attempt succeeded — no "Not delivered" — but the provider's
108
+ // receipt axis reports the failure on its own.
109
+ expect(screen.queryByText("Not delivered")).toBeNull();
110
+ expect(screen.getByText("Delivery failed")).toBeDefined();
111
+ });
112
+
113
+ it("renders the provider's failure explanation as VISIBLE text on a failed receipt (DD-014 D-c, R-1)", () => {
114
+ render(
115
+ <ConversationTimelineView
116
+ {...baseProps()}
117
+ items={[
118
+ item("ob:1", {
119
+ author: ConversationItemAuthor.author_teammate,
120
+ text: "your table is ready",
121
+ deliveryStatus: ChannelDeliveryStatus.delivered,
122
+ receiptState: ChannelReceiptState.receipt_failed,
123
+ receiptDetail:
124
+ "More than 24 hours have passed since the recipient last replied to the sender number.",
125
+ receiptErrorCode: 131047,
126
+ }),
127
+ ]}
128
+ />,
129
+ );
130
+
131
+ // Verbatim relay — the provider's own words, never pattern-matched
132
+ // into local copy. Visible (not hover-only): the reason changes what
133
+ // the operator does next, and the footer glyphs are deliberately
134
+ // non-focusable (F-18), so a tooltip reaches mouse users only.
135
+ expect(
136
+ screen.getByText(/More than 24 hours have passed since the recipient last replied/),
137
+ ).toBeDefined();
138
+ // The numeric code stays off the surface — machine vocabulary for
139
+ // clients that branch, not operator copy.
140
+ expect(screen.queryByText(/131047/)).toBeNull();
141
+ });
142
+
143
+ it("keeps today's generic copy when the provider sent no explanation", () => {
144
+ render(
145
+ <ConversationTimelineView
146
+ {...baseProps()}
147
+ items={[
148
+ item("ob:1", {
149
+ author: ConversationItemAuthor.author_agent,
150
+ text: "your table is ready",
151
+ deliveryStatus: ChannelDeliveryStatus.delivered,
152
+ receiptState: ChannelReceiptState.receipt_failed,
153
+ // A code with no prose: the bare number would tell an
154
+ // operator nothing, so the tick keeps its generic copy.
155
+ receiptErrorCode: 131047,
156
+ }),
157
+ ]}
158
+ />,
159
+ );
160
+ expect(screen.getByText("Delivery failed")).toBeDefined();
161
+ expect(screen.queryByText(/131047/)).toBeNull();
162
+ });
163
+
164
+ it("never shows the receipt explanation on an attempt-failed item — the F-25 boundary", () => {
165
+ render(
166
+ <ConversationTimelineView
167
+ {...baseProps()}
168
+ items={[
169
+ item("dl:1", {
170
+ author: ConversationItemAuthor.author_agent,
171
+ text: "lost reply",
172
+ deliveryStatus: ChannelDeliveryStatus.failed,
173
+ // A malformed item carrying a detail on the wrong axis: the
174
+ // attempt-axis explanation (last_error) is F-25's separate
175
+ // slice, so this lane renders nothing extra.
176
+ receiptDetail: "should never render",
177
+ }),
178
+ ]}
179
+ />,
180
+ );
181
+ expect(screen.getByText("Not delivered")).toBeDefined();
182
+ expect(screen.queryByText("should never render")).toBeNull();
183
+ });
184
+
185
+ it("renders a dead-lettered send as exactly that", () => {
186
+ render(
187
+ <ConversationTimelineView
188
+ {...baseProps()}
189
+ items={[
190
+ item("dl:1", {
191
+ author: ConversationItemAuthor.author_agent,
192
+ text: "lost reply",
193
+ deliveryStatus: ChannelDeliveryStatus.failed,
194
+ }),
195
+ ]}
196
+ />,
197
+ );
198
+ expect(screen.getByText("Not delivered")).toBeDefined();
199
+ });
200
+
201
+ it("renders a suppressed send as held, never as sent", () => {
202
+ render(
203
+ <ConversationTimelineView
204
+ {...baseProps()}
205
+ items={[
206
+ item("ob:1", {
207
+ author: ConversationItemAuthor.author_agent,
208
+ text: "reminder that never went",
209
+ deliveryStatus: ChannelDeliveryStatus.suppressed,
210
+ }),
211
+ ]}
212
+ />,
213
+ );
214
+ expect(screen.getByText("Held")).toBeDefined();
215
+ });
216
+
217
+ it("advances the receipt ticks: sent, delivered, read", () => {
218
+ render(
219
+ <ConversationTimelineView
220
+ {...baseProps()}
221
+ items={[
222
+ item("ob:1", {
223
+ author: ConversationItemAuthor.author_agent,
224
+ text: "one",
225
+ deliveryStatus: ChannelDeliveryStatus.delivered,
226
+ receiptState: ChannelReceiptState.receipt_sent,
227
+ }),
228
+ item("ob:2", {
229
+ author: ConversationItemAuthor.author_agent,
230
+ text: "two",
231
+ deliveryStatus: ChannelDeliveryStatus.delivered,
232
+ receiptState: ChannelReceiptState.receipt_delivered,
233
+ }),
234
+ item("ob:3", {
235
+ author: ConversationItemAuthor.author_agent,
236
+ text: "three",
237
+ deliveryStatus: ChannelDeliveryStatus.delivered,
238
+ receiptState: ChannelReceiptState.receipt_read,
239
+ }),
240
+ ]}
241
+ />,
242
+ );
243
+ expect(screen.getByText("Sent")).toBeDefined();
244
+ expect(screen.getByText("Delivered")).toBeDefined();
245
+ expect(screen.getByText("Read")).toBeDefined();
246
+ });
247
+
248
+ it("explains status glyphs with tooltips, never native titles, and adds no tab stops (F-18)", () => {
249
+ // Every footer status variant at once: failed, suppressed, sending,
250
+ // and the three receipt ticks (sent / delivered / read) plus the
251
+ // receipt-failed arm.
252
+ const { container } = render(
253
+ <ConversationTimelineView
254
+ {...baseProps()}
255
+ items={[
256
+ item("dl:1", {
257
+ author: ConversationItemAuthor.author_agent,
258
+ text: "failed send",
259
+ deliveryStatus: ChannelDeliveryStatus.failed,
260
+ }),
261
+ item("ob:2", {
262
+ author: ConversationItemAuthor.author_agent,
263
+ text: "held send",
264
+ deliveryStatus: ChannelDeliveryStatus.suppressed,
265
+ }),
266
+ item("ob:3", {
267
+ author: ConversationItemAuthor.author_teammate,
268
+ text: "sending now",
269
+ deliveryStatus: ChannelDeliveryStatus.pending,
270
+ }),
271
+ item("ob:4", {
272
+ author: ConversationItemAuthor.author_teammate,
273
+ text: "sent",
274
+ deliveryStatus: ChannelDeliveryStatus.delivered,
275
+ }),
276
+ item("ob:5", {
277
+ author: ConversationItemAuthor.author_teammate,
278
+ text: "delivered",
279
+ deliveryStatus: ChannelDeliveryStatus.delivered,
280
+ receiptState: ChannelReceiptState.receipt_delivered,
281
+ }),
282
+ item("ob:6", {
283
+ author: ConversationItemAuthor.author_teammate,
284
+ text: "read",
285
+ deliveryStatus: ChannelDeliveryStatus.delivered,
286
+ receiptState: ChannelReceiptState.receipt_read,
287
+ }),
288
+ item("ob:7", {
289
+ author: ConversationItemAuthor.author_teammate,
290
+ text: "receipt failed",
291
+ deliveryStatus: ChannelDeliveryStatus.delivered,
292
+ receiptState: ChannelReceiptState.receipt_failed,
293
+ }),
294
+ ]}
295
+ />,
296
+ );
297
+
298
+ // Native titles are gone — OS-delayed, imprecise, keyboard- and
299
+ // touch-invisible. The house tooltip replaced them.
300
+ expect(container.querySelector("[title]")).toBeNull();
301
+ // The screen-reader names survive the change (name stays the
302
+ // sr-only/visible text; the tooltip is only the visual description).
303
+ expect(screen.getByText("Sending")).toBeDefined();
304
+ expect(screen.getByText("Sent")).toBeDefined();
305
+ expect(screen.getByText("Delivered")).toBeDefined();
306
+ expect(screen.getByText("Read")).toBeDefined();
307
+ expect(screen.getByText("Not delivered")).toBeDefined();
308
+ expect(screen.getByText("Held")).toBeDefined();
309
+ expect(screen.getByText("Delivery failed")).toBeDefined();
310
+ // Zero new tab stops: seven focusable glyphs per screen of messages
311
+ // would be an accessibility regression dressed as a fix. Nothing
312
+ // inside a message row may be focusable — the Jump-to-latest
313
+ // affordance (which manages its own tabIndex) lives outside the
314
+ // rows and stays the surface's only interactive element.
315
+ expect(container.querySelectorAll("li [tabindex]")).toHaveLength(0);
316
+ expect(container.querySelectorAll("li button")).toHaveLength(0);
317
+ });
318
+
319
+ it("renders internal-lane events as system rows the customer never saw", () => {
320
+ render(
321
+ <ConversationTimelineView
322
+ {...baseProps()}
323
+ items={[
324
+ item("ev:1", {
325
+ lane: ConversationLane.lane_internal,
326
+ author: ConversationItemAuthor.author_agent,
327
+ text: "the member is asking about a refund I cannot process",
328
+ }),
329
+ item("ev:2", {
330
+ lane: ConversationLane.lane_internal,
331
+ author: ConversationItemAuthor.author_teammate,
332
+ }),
333
+ ]}
334
+ />,
335
+ );
336
+
337
+ expect(
338
+ screen.getByText(/Agent escalated to a human — “the member is asking/),
339
+ ).toBeDefined();
340
+ expect(screen.getByText("A teammate resolved the attention flag")).toBeDefined();
341
+ });
342
+
343
+ it("tells the truth about Slack timelines", () => {
344
+ const { rerender } = render(
345
+ <ConversationTimelineView {...baseProps()} provider="slack" />,
346
+ );
347
+ expect(
348
+ screen.getByText(/Customer messages on Slack channels aren't shown here yet/),
349
+ ).toBeDefined();
350
+
351
+ rerender(<ConversationTimelineView {...baseProps()} provider="whatsapp" />);
352
+ expect(
353
+ screen.queryByText(/Customer messages on .* channels aren't shown/),
354
+ ).toBeNull();
355
+ });
356
+
357
+ it("separates days and labels the current one Today", () => {
358
+ render(
359
+ <ConversationTimelineView
360
+ {...baseProps()}
361
+ items={[
362
+ item("wa:1", {
363
+ author: ConversationItemAuthor.author_customer,
364
+ text: "yesterday's question",
365
+ at: timestampFromDate(new Date("2026-08-06T18:00:00Z")),
366
+ }),
367
+ item("wa:2", {
368
+ author: ConversationItemAuthor.author_customer,
369
+ text: "today's follow-up",
370
+ at: timestampFromDate(new Date("2026-08-07T09:00:00Z")),
371
+ }),
372
+ ]}
373
+ />,
374
+ );
375
+
376
+ expect(screen.getByText("Yesterday")).toBeDefined();
377
+ expect(screen.getByText("Today")).toBeDefined();
378
+ });
379
+
380
+ it("offers Load earlier messages exactly while older history exists", async () => {
381
+ const user = userEvent.setup();
382
+ const loadOlder = vi.fn();
383
+ render(
384
+ <ConversationTimelineView
385
+ {...baseProps()}
386
+ items={[item("wa:1", { author: ConversationItemAuthor.author_customer, text: "hi" })]}
387
+ hasOlder
388
+ loadOlder={loadOlder}
389
+ />,
390
+ );
391
+
392
+ await user.click(screen.getByRole("button", { name: "Load earlier messages" }));
393
+ expect(loadOlder).toHaveBeenCalled();
394
+ });
395
+
396
+ it("renders the empty and error states honestly", () => {
397
+ const { rerender } = render(<ConversationTimelineView {...baseProps()} />);
398
+ expect(screen.getByText("No messages yet")).toBeDefined();
399
+
400
+ rerender(
401
+ <ConversationTimelineView {...baseProps()} error={new Error("stitch failed")} />,
402
+ );
403
+ expect(screen.getByText("stitch failed")).toBeDefined();
404
+ });
405
+ });