@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,435 @@
1
+ "use client";
2
+
3
+ import { memo, useMemo } from "react";
4
+ import { timestampDate } from "@bufbuild/protobuf/wkt";
5
+ import { Ban, Check, CheckCheck, Clock, Info, MessageSquare, TriangleAlert } from "lucide-react";
6
+ import { cn } from "@stigmer/theme";
7
+ import { getUserMessage } from "@stigmer/sdk";
8
+ import type { ConversationTimelineItem } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/conversation_io_pb";
9
+ import { channelProviderOf, type ChannelProviderId } from "../channel/providers.js";
10
+ import { Button } from "../button/Button.js";
11
+ import { EmptyState } from "../empty-state/EmptyState.js";
12
+ import { JumpToLatestButton } from "../internal/JumpToLatestButton.js";
13
+ import {
14
+ Tooltip,
15
+ TooltipContent,
16
+ TooltipProvider,
17
+ TooltipTrigger,
18
+ } from "../internal/tooltip.js";
19
+ import { useAutoScroll } from "../internal/useAutoScroll.js";
20
+ import {
21
+ authorKindOf,
22
+ inboundPlaceholderOf,
23
+ isInternalItem,
24
+ receiptOf,
25
+ sendAttemptOf,
26
+ type ConversationAuthorKind,
27
+ } from "./conversationPresentation.js";
28
+
29
+ /** Props for {@link ConversationTimelineView}. */
30
+ export interface ConversationTimelineViewProps {
31
+ /** Timeline items in chronological order (from `useConversationTimeline`). */
32
+ readonly items: readonly ConversationTimelineItem[];
33
+ /** `true` only during the first load. */
34
+ readonly isLoading: boolean;
35
+ /** Error from the timeline read, or `null`. */
36
+ readonly error: Error | null;
37
+ /** `true` while older history exists beyond what is loaded. */
38
+ readonly hasOlder: boolean;
39
+ /** Load the next older page. */
40
+ readonly loadOlder: () => void;
41
+ /** `true` while an older page is in flight. */
42
+ readonly isLoadingOlder: boolean;
43
+ /**
44
+ * The channel's provider, for the honest-timeline notice: providers
45
+ * whose inbound lane has no timeline source (Slack) render an
46
+ * explicit "customer messages aren't shown" notice instead of a
47
+ * silently one-sided thread. `null` renders no notice.
48
+ */
49
+ readonly provider: ChannelProviderId | null;
50
+ /** Frozen instant for deterministic hosts (tests, documentation tours). */
51
+ readonly now?: Date;
52
+ /** Additional classes for the view container. */
53
+ readonly className?: string;
54
+ }
55
+
56
+ /**
57
+ * One conversation's customer-visible thread, WhatsApp-style: customer
58
+ * on the left, the business (agent, teammate, platform copy) on the
59
+ * right, internal-lane events (escalations, attention clears) as inline
60
+ * system rows the customer never saw.
61
+ *
62
+ * Honesty rules, enforced here rather than hoped for: outbound items
63
+ * carry their send attempt AND the provider's receipt as two independent
64
+ * facts (DD-004 D-d — an item can be handed to the provider yet fail on
65
+ * the customer's side), failed and suppressed sends render as exactly
66
+ * that, and non-text inbound renders a typed placeholder instead of
67
+ * disappearing.
68
+ *
69
+ * Presentational — pair with `useConversationTimeline`, or compose via
70
+ * `ConversationsWorkbench`.
71
+ */
72
+ export function ConversationTimelineView({
73
+ items,
74
+ isLoading,
75
+ error,
76
+ hasOlder,
77
+ loadOlder,
78
+ isLoadingOlder,
79
+ provider,
80
+ now,
81
+ className,
82
+ }: ConversationTimelineViewProps) {
83
+ const { scrollRef, sentinelRef, contentRef, isFollowing, jumpToLatest } =
84
+ useAutoScroll();
85
+
86
+ const days = useMemo(() => groupByDay(items, now), [items, now]);
87
+ const descriptor = channelProviderOf(provider ?? undefined);
88
+ const showsCustomerMessages = descriptor?.timelineIncludesCustomerMessages ?? true;
89
+
90
+ return (
91
+ // The provider is context-only (no DOM node); one per view arms
92
+ // every status-glyph tooltip below (the WorkspaceSidebar precedent).
93
+ <TooltipProvider>
94
+ <div
95
+ aria-label="Conversation timeline"
96
+ className={cn("relative flex min-h-0 flex-1 flex-col", className)}
97
+ >
98
+ <div
99
+ ref={scrollRef}
100
+ className="min-h-0 flex-1 overflow-y-auto [overflow-anchor:none] px-4 py-3"
101
+ >
102
+ {!showsCustomerMessages && descriptor && (
103
+ <div className="mb-3 flex items-start gap-2 rounded-md border border-border bg-muted-faint px-3 py-2 text-xs text-muted-foreground">
104
+ <Info aria-hidden="true" className="mt-0.5 size-3.5 shrink-0" />
105
+ <p>
106
+ Customer messages on {descriptor.label} channels aren&apos;t shown
107
+ here yet — this thread has replies, sends, and internal events only.
108
+ </p>
109
+ </div>
110
+ )}
111
+
112
+ {isLoading ? (
113
+ <TimelineSkeleton />
114
+ ) : error ? (
115
+ <p className="px-2 py-6 text-center text-sm text-destructive">
116
+ {getUserMessage(error)}
117
+ </p>
118
+ ) : items.length === 0 ? (
119
+ <EmptyState
120
+ variant="first-use"
121
+ icon={<MessageSquare className="size-8" />}
122
+ title="No messages yet"
123
+ description="The conversation's messages appear here as they happen."
124
+ />
125
+ ) : (
126
+ <div ref={contentRef}>
127
+ {hasOlder && (
128
+ <div className="flex justify-center pb-2">
129
+ <Button
130
+ variant="ghost"
131
+ size="xs"
132
+ onClick={loadOlder}
133
+ disabled={isLoadingOlder}
134
+ >
135
+ {isLoadingOlder ? "Loading…" : "Load earlier messages"}
136
+ </Button>
137
+ </div>
138
+ )}
139
+ {days.map((day) => (
140
+ <div key={day.label}>
141
+ <div
142
+ className="my-2 text-center text-xs text-muted-foreground-faint"
143
+ role="separator"
144
+ >
145
+ {day.label}
146
+ </div>
147
+ <ul className="space-y-1.5">
148
+ {day.items.map((item) => (
149
+ <TimelineItemRow key={item.itemId} item={item} />
150
+ ))}
151
+ </ul>
152
+ </div>
153
+ ))}
154
+ </div>
155
+ )}
156
+ <div ref={sentinelRef} aria-hidden="true" />
157
+ </div>
158
+ <JumpToLatestButton visible={!isFollowing} onClick={jumpToLatest} />
159
+ </div>
160
+ </TooltipProvider>
161
+ );
162
+ }
163
+
164
+ const BUSINESS_CAPTION: Record<Exclude<ConversationAuthorKind, "customer">, string> = {
165
+ agent: "Agent",
166
+ teammate: "Teammate",
167
+ platform: "Automated",
168
+ unknown: "Unknown sender",
169
+ };
170
+
171
+ const TimelineItemRow = memo(function TimelineItemRow({
172
+ item,
173
+ }: {
174
+ readonly item: ConversationTimelineItem;
175
+ }) {
176
+ if (isInternalItem(item)) {
177
+ return <InternalEventRow item={item} />;
178
+ }
179
+
180
+ const author = authorKindOf(item);
181
+ const isCustomer = author === "customer";
182
+ const body = item.text || inboundPlaceholderOf(item);
183
+
184
+ // DD-014 D-c (amended at the Sitting 2 gate, R-1): the provider's
185
+ // failure explanation is decision-bearing — window closed vs bad
186
+ // number changes what the operator does next — so it renders as
187
+ // VISIBLE text, not hover-only (the footer glyphs are deliberately
188
+ // non-focusable per F-18, which makes a tooltip mouse-only). Verbatim
189
+ // relay, never pattern-matched; the numeric twin (receipt_error_code)
190
+ // stays off the surface as machine vocabulary. Gated exactly like
191
+ // ReceiptTicks' failed arm — attempt delivered AND receipt failed —
192
+ // so the attempt-axis explanation (last_error, F-25's slice) can
193
+ // structurally never leak in here.
194
+ const receiptExplanation =
195
+ sendAttemptOf(item) === "delivered" &&
196
+ receiptOf(item) === "failed" &&
197
+ item.receiptDetail !== ""
198
+ ? item.receiptDetail
199
+ : null;
200
+
201
+ return (
202
+ <li className={cn("flex", isCustomer ? "justify-start" : "justify-end")}>
203
+ <div
204
+ className={cn(
205
+ "max-w-[80%] rounded-lg px-3 py-2",
206
+ isCustomer ? "bg-muted-subtle" : "bg-primary-subtle",
207
+ )}
208
+ >
209
+ {!isCustomer && (
210
+ <p className="text-xs font-medium text-muted-foreground">
211
+ {BUSINESS_CAPTION[author]}
212
+ </p>
213
+ )}
214
+ <p
215
+ className={cn(
216
+ "whitespace-pre-wrap break-words text-sm text-foreground",
217
+ !item.text && "italic text-muted-foreground",
218
+ )}
219
+ >
220
+ {body || "Message content unavailable"}
221
+ </p>
222
+ {receiptExplanation !== null && (
223
+ <p className="mt-1 break-words text-xs text-destructive">
224
+ {receiptExplanation}
225
+ </p>
226
+ )}
227
+ <ItemFooter item={item} />
228
+ </div>
229
+ </li>
230
+ );
231
+ });
232
+
233
+ /**
234
+ * A footer status glyph with its explanation on the house tooltip
235
+ * (F-18, replacing native `title` — OS-delayed, imprecise, invisible to
236
+ * keyboard and touch). The trigger renders as a plain `<span>` and is
237
+ * deliberately NOT focusable: these sit inside every bubble's footer,
238
+ * so focusable triggers would add several tab stops per message. Their
239
+ * accessible name stays the visible or `sr-only` text; the tooltip is
240
+ * the visual description only.
241
+ */
242
+ function StatusHint({
243
+ hint,
244
+ className,
245
+ children,
246
+ }: {
247
+ readonly hint: string;
248
+ readonly className?: string;
249
+ readonly children: React.ReactNode;
250
+ }) {
251
+ return (
252
+ <Tooltip>
253
+ <TooltipTrigger render={<span className={className} />}>
254
+ {children}
255
+ </TooltipTrigger>
256
+ <TooltipContent side="top">{hint}</TooltipContent>
257
+ </Tooltip>
258
+ );
259
+ }
260
+
261
+ /**
262
+ * The bubble footer: the item's instant plus, for outbound items, the
263
+ * two status axes — our attempt and the provider's receipt, never
264
+ * collapsed into one glyph.
265
+ */
266
+ function ItemFooter({ item }: { readonly item: ConversationTimelineItem }) {
267
+ const attempt = sendAttemptOf(item);
268
+ const time = item.at
269
+ ? timestampDate(item.at).toLocaleTimeString(undefined, {
270
+ hour: "2-digit",
271
+ minute: "2-digit",
272
+ })
273
+ : "";
274
+
275
+ return (
276
+ <p className="mt-1 flex items-center justify-end gap-1 text-xs text-muted-foreground-faint">
277
+ <span>{time}</span>
278
+ {attempt === "failed" && (
279
+ <StatusHint
280
+ hint="The platform could not deliver this message"
281
+ className="flex items-center gap-0.5 text-destructive"
282
+ >
283
+ <TriangleAlert aria-hidden="true" className="size-3" />
284
+ Not delivered
285
+ </StatusHint>
286
+ )}
287
+ {attempt === "suppressed" && (
288
+ <StatusHint
289
+ hint="Withheld because a human had the conversation when it came due"
290
+ className="flex items-center gap-0.5"
291
+ >
292
+ <Ban aria-hidden="true" className="size-3" />
293
+ Held
294
+ </StatusHint>
295
+ )}
296
+ {(attempt === "pending" || attempt === "delivering") && (
297
+ <StatusHint hint="Sending" className="flex items-center gap-0.5">
298
+ <Clock aria-hidden="true" className="size-3" />
299
+ <span className="sr-only">Sending</span>
300
+ </StatusHint>
301
+ )}
302
+ {attempt === "delivered" && <ReceiptTicks item={item} />}
303
+ </p>
304
+ );
305
+ }
306
+
307
+ /**
308
+ * The provider's receipt report, WhatsApp-tick style. Renders only on
309
+ * items the platform successfully handed to the provider; the receipt
310
+ * can still be `failed` — the "we sent it, it never arrived" case a
311
+ * collapsed status hides.
312
+ */
313
+ function ReceiptTicks({ item }: { readonly item: ConversationTimelineItem }) {
314
+ const receipt = receiptOf(item);
315
+ if (receipt === "failed") {
316
+ return (
317
+ <StatusHint
318
+ hint="The provider accepted this message but could not deliver it"
319
+ className="flex items-center gap-0.5 text-destructive"
320
+ >
321
+ <TriangleAlert aria-hidden="true" className="size-3" />
322
+ Delivery failed
323
+ </StatusHint>
324
+ );
325
+ }
326
+ if (receipt === "read") {
327
+ return (
328
+ <StatusHint hint="Read by the customer" className="text-primary">
329
+ <CheckCheck aria-hidden="true" className="size-3.5" />
330
+ <span className="sr-only">Read</span>
331
+ </StatusHint>
332
+ );
333
+ }
334
+ if (receipt === "delivered") {
335
+ return (
336
+ <StatusHint hint="Delivered to the customer's device">
337
+ <CheckCheck aria-hidden="true" className="size-3.5" />
338
+ <span className="sr-only">Delivered</span>
339
+ </StatusHint>
340
+ );
341
+ }
342
+ // Handed to the provider; no (further) receipt yet.
343
+ return (
344
+ <StatusHint hint="Sent">
345
+ <Check aria-hidden="true" className="size-3.5" />
346
+ <span className="sr-only">Sent</span>
347
+ </StatusHint>
348
+ );
349
+ }
350
+
351
+ /**
352
+ * Internal-lane rows — org-internal events the customer never saw,
353
+ * rendered as centered system copy so they can never be misread as
354
+ * messages: the agent's escalation (its reason verbatim) and a
355
+ * teammate's attention clear.
356
+ */
357
+ function InternalEventRow({ item }: { readonly item: ConversationTimelineItem }) {
358
+ const author = authorKindOf(item);
359
+ const isEscalation = author === "agent" && item.text !== "";
360
+
361
+ return (
362
+ <li className="flex justify-center">
363
+ <div
364
+ className={cn(
365
+ "flex max-w-[90%] items-start gap-1.5 rounded-md px-3 py-1.5 text-center text-xs",
366
+ isEscalation
367
+ ? "bg-destructive-subtle text-destructive"
368
+ : "bg-muted-faint text-muted-foreground",
369
+ )}
370
+ >
371
+ {isEscalation ? (
372
+ <>
373
+ <TriangleAlert aria-hidden="true" className="mt-0.5 size-3.5 shrink-0" />
374
+ <span className="min-w-0 break-words">
375
+ Agent escalated to a human — “{item.text}”
376
+ </span>
377
+ </>
378
+ ) : item.text !== "" ? (
379
+ // A future internal note flows through with zero code change.
380
+ <span className="min-w-0 break-words">{item.text}</span>
381
+ ) : (
382
+ <span>A teammate resolved the attention flag</span>
383
+ )}
384
+ </div>
385
+ </li>
386
+ );
387
+ }
388
+
389
+ function TimelineSkeleton() {
390
+ return (
391
+ <div className="space-y-2" aria-hidden="true">
392
+ <div className="h-10 w-3/5 animate-pulse rounded-lg bg-muted-faint" />
393
+ <div className="ml-auto h-10 w-3/5 animate-pulse rounded-lg bg-muted-faint" />
394
+ <div className="h-10 w-2/5 animate-pulse rounded-lg bg-muted-faint" />
395
+ </div>
396
+ );
397
+ }
398
+
399
+ interface DayGroup {
400
+ readonly label: string;
401
+ readonly items: readonly ConversationTimelineItem[];
402
+ }
403
+
404
+ /** Group chronological items under day separators. */
405
+ function groupByDay(
406
+ items: readonly ConversationTimelineItem[],
407
+ now?: Date,
408
+ ): readonly DayGroup[] {
409
+ const ref = now ?? new Date();
410
+ const groups: { label: string; items: ConversationTimelineItem[] }[] = [];
411
+ let currentKey = "";
412
+ for (const item of items) {
413
+ const date = item.at ? timestampDate(item.at) : null;
414
+ const key = date ? date.toDateString() : "undated";
415
+ if (key !== currentKey) {
416
+ currentKey = key;
417
+ groups.push({ label: date ? dayLabel(date, ref) : "Earlier", items: [] });
418
+ }
419
+ groups[groups.length - 1].items.push(item);
420
+ }
421
+ return groups;
422
+ }
423
+
424
+ function dayLabel(date: Date, ref: Date): string {
425
+ if (date.toDateString() === ref.toDateString()) return "Today";
426
+ const yesterday = new Date(ref);
427
+ yesterday.setDate(ref.getDate() - 1);
428
+ if (date.toDateString() === yesterday.toDateString()) return "Yesterday";
429
+ return date.toLocaleDateString(undefined, {
430
+ weekday: "short",
431
+ month: "short",
432
+ day: "numeric",
433
+ ...(date.getFullYear() === ref.getFullYear() ? {} : { year: "numeric" }),
434
+ });
435
+ }