@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.
- package/attachment/AttachmentChipList.d.ts.map +1 -1
- package/attachment/AttachmentChipList.js +30 -1
- package/attachment/AttachmentChipList.js.map +1 -1
- package/attachment/attachment-utils.d.ts +11 -0
- package/attachment/attachment-utils.d.ts.map +1 -1
- package/attachment/attachment-utils.js +23 -0
- package/attachment/attachment-utils.js.map +1 -1
- package/attachment/clipboard.d.ts +51 -0
- package/attachment/clipboard.d.ts.map +1 -0
- package/attachment/clipboard.js +89 -0
- package/attachment/clipboard.js.map +1 -0
- package/attachment/index.d.ts +6 -1
- package/attachment/index.d.ts.map +1 -1
- package/attachment/index.js +4 -1
- package/attachment/index.js.map +1 -1
- package/attachment/prepare-image.d.ts +43 -0
- package/attachment/prepare-image.d.ts.map +1 -0
- package/attachment/prepare-image.js +162 -0
- package/attachment/prepare-image.js.map +1 -0
- package/attachment/useAttachments.d.ts.map +1 -1
- package/attachment/useAttachments.js +23 -3
- package/attachment/useAttachments.js.map +1 -1
- package/attachment/vision-fit.d.ts +50 -0
- package/attachment/vision-fit.d.ts.map +1 -0
- package/attachment/vision-fit.js +76 -0
- package/attachment/vision-fit.js.map +1 -0
- package/channel/AgentChannelsPanel.d.ts.map +1 -1
- package/channel/AgentChannelsPanel.js +21 -2
- package/channel/AgentChannelsPanel.js.map +1 -1
- package/channel/ChannelConversationsDialog.d.ts +12 -4
- package/channel/ChannelConversationsDialog.d.ts.map +1 -1
- package/channel/ChannelConversationsDialog.js +18 -24
- package/channel/ChannelConversationsDialog.js.map +1 -1
- package/channel/providers.d.ts +23 -0
- package/channel/providers.d.ts.map +1 -1
- package/channel/providers.js +4 -0
- package/channel/providers.js.map +1 -1
- package/channel/useSaveAgentChannel.d.ts +5 -4
- package/channel/useSaveAgentChannel.d.ts.map +1 -1
- package/channel/useSaveAgentChannel.js +18 -4
- package/channel/useSaveAgentChannel.js.map +1 -1
- package/composer/ComposerToolbar.d.ts +1 -1
- package/composer/ComposerToolbar.d.ts.map +1 -1
- package/composer/SessionComposer.d.ts +4 -2
- package/composer/SessionComposer.d.ts.map +1 -1
- package/composer/SessionComposer.js +51 -5
- package/composer/SessionComposer.js.map +1 -1
- package/conversation/ConversationAttentionBanner.d.ts +22 -0
- package/conversation/ConversationAttentionBanner.d.ts.map +1 -0
- package/conversation/ConversationAttentionBanner.js +25 -0
- package/conversation/ConversationAttentionBanner.js.map +1 -0
- package/conversation/ConversationComposer.d.ts +41 -0
- package/conversation/ConversationComposer.d.ts.map +1 -0
- package/conversation/ConversationComposer.js +57 -0
- package/conversation/ConversationComposer.js.map +1 -0
- package/conversation/ConversationControlBanner.d.ts +52 -0
- package/conversation/ConversationControlBanner.d.ts.map +1 -0
- package/conversation/ConversationControlBanner.js +59 -0
- package/conversation/ConversationControlBanner.js.map +1 -0
- package/conversation/ConversationListPane.d.ts +68 -0
- package/conversation/ConversationListPane.d.ts.map +1 -0
- package/conversation/ConversationListPane.js +120 -0
- package/conversation/ConversationListPane.js.map +1 -0
- package/conversation/ConversationTimelineView.d.ts +46 -0
- package/conversation/ConversationTimelineView.d.ts.map +1 -0
- package/conversation/ConversationTimelineView.js +166 -0
- package/conversation/ConversationTimelineView.js.map +1 -0
- package/conversation/ConversationsWorkbench.d.ts +63 -0
- package/conversation/ConversationsWorkbench.d.ts.map +1 -0
- package/conversation/ConversationsWorkbench.js +165 -0
- package/conversation/ConversationsWorkbench.js.map +1 -0
- package/conversation/conversationPresentation.d.ts +137 -0
- package/conversation/conversationPresentation.d.ts.map +1 -0
- package/conversation/conversationPresentation.js +230 -0
- package/conversation/conversationPresentation.js.map +1 -0
- package/conversation/index.d.ts +26 -0
- package/conversation/index.d.ts.map +1 -0
- package/conversation/index.js +14 -0
- package/conversation/index.js.map +1 -0
- package/conversation/polling.d.ts +39 -0
- package/conversation/polling.d.ts.map +1 -0
- package/conversation/polling.js +39 -0
- package/conversation/polling.js.map +1 -0
- package/conversation/useConversation.d.ts +61 -0
- package/conversation/useConversation.d.ts.map +1 -0
- package/conversation/useConversation.js +153 -0
- package/conversation/useConversation.js.map +1 -0
- package/conversation/useConversationList.d.ts +98 -0
- package/conversation/useConversationList.d.ts.map +1 -0
- package/conversation/useConversationList.js +200 -0
- package/conversation/useConversationList.js.map +1 -0
- package/conversation/useConversationParticipation.d.ts +63 -0
- package/conversation/useConversationParticipation.d.ts.map +1 -0
- package/conversation/useConversationParticipation.js +84 -0
- package/conversation/useConversationParticipation.js.map +1 -0
- package/conversation/useConversationTimeline.d.ts +71 -0
- package/conversation/useConversationTimeline.d.ts.map +1 -0
- package/conversation/useConversationTimeline.js +152 -0
- package/conversation/useConversationTimeline.js.map +1 -0
- package/conversation/useConversationsWantsHumanCount.d.ts +42 -0
- package/conversation/useConversationsWantsHumanCount.d.ts.map +1 -0
- package/conversation/useConversationsWantsHumanCount.js +40 -0
- package/conversation/useConversationsWantsHumanCount.js.map +1 -0
- package/index.d.ts +6 -2
- package/index.d.ts.map +1 -1
- package/index.js +11 -2
- package/index.js.map +1 -1
- package/internal/tooltip.d.ts.map +1 -1
- package/internal/tooltip.js +7 -1
- package/internal/tooltip.js.map +1 -1
- package/internal/useAutoScroll.d.ts +25 -4
- package/internal/useAutoScroll.d.ts.map +1 -1
- package/internal/useAutoScroll.js +55 -15
- package/internal/useAutoScroll.js.map +1 -1
- package/internal/useFetch.d.ts.map +1 -1
- package/internal/useFetch.js +14 -4
- package/internal/useFetch.js.map +1 -1
- package/models/ModelSelector.d.ts +24 -5
- package/models/ModelSelector.d.ts.map +1 -1
- package/models/ModelSelector.js +31 -9
- package/models/ModelSelector.js.map +1 -1
- package/models/registry.d.ts +3 -2
- package/models/registry.d.ts.map +1 -1
- package/models/registry.js.map +1 -1
- package/models/service-tier.d.ts +1 -1
- package/models/service-tier.d.ts.map +1 -1
- package/models/service-tier.js +1 -1
- package/models/service-tier.js.map +1 -1
- package/package.json +4 -4
- package/schedule/ScheduleDetailView.js +1 -1
- package/schedule/ScheduleDetailView.js.map +1 -1
- package/schedule/ScheduleForm.d.ts.map +1 -1
- package/schedule/ScheduleForm.js +7 -6
- package/schedule/ScheduleForm.js.map +1 -1
- package/sharing/useSaveAgentShare.d.ts +10 -1
- package/sharing/useSaveAgentShare.d.ts.map +1 -1
- package/sharing/useSaveAgentShare.js +9 -0
- package/sharing/useSaveAgentShare.js.map +1 -1
- package/sidebar/WorkspaceSidebar.d.ts +12 -4
- package/sidebar/WorkspaceSidebar.d.ts.map +1 -1
- package/sidebar/WorkspaceSidebar.js +19 -7
- package/sidebar/WorkspaceSidebar.js.map +1 -1
- package/src/attachment/AttachmentChipList.tsx +45 -1
- package/src/attachment/__tests__/AttachmentChipList.test.tsx +103 -0
- package/src/attachment/__tests__/attachment-utils.test.ts +54 -0
- package/src/attachment/__tests__/clipboard.test.ts +110 -0
- package/src/attachment/__tests__/prepare-image.browser.test.ts +166 -0
- package/src/attachment/__tests__/prepare-image.test.ts +32 -0
- package/src/attachment/__tests__/vision-fit.test.ts +93 -0
- package/src/attachment/attachment-utils.ts +27 -0
- package/src/attachment/clipboard.ts +102 -0
- package/src/attachment/index.ts +13 -0
- package/src/attachment/prepare-image.ts +179 -0
- package/src/attachment/useAttachments.ts +26 -2
- package/src/attachment/vision-fit.ts +90 -0
- package/src/channel/AgentChannelsPanel.tsx +33 -2
- package/src/channel/ChannelConversationsDialog.tsx +22 -25
- package/src/channel/__tests__/AgentChannelsPanel.test.tsx +36 -5
- package/src/channel/__tests__/ChannelConversationsDialog.test.tsx +38 -1
- package/src/channel/__tests__/useSaveAgentChannel.test.tsx +26 -0
- package/src/channel/providers.ts +27 -0
- package/src/channel/useSaveAgentChannel.ts +18 -4
- package/src/composer/ComposerToolbar.tsx +1 -1
- package/src/composer/SessionComposer.tsx +75 -6
- package/src/composer/__tests__/SessionComposer-paste.test.tsx +263 -0
- package/src/composer/__tests__/SessionComposer-serviceTier.test.tsx +4 -3
- package/src/composer/__tests__/SessionComposer-uploadGate.test.tsx +239 -0
- package/src/conversation/ConversationAttentionBanner.tsx +77 -0
- package/src/conversation/ConversationComposer.tsx +156 -0
- package/src/conversation/ConversationControlBanner.tsx +193 -0
- package/src/conversation/ConversationListPane.tsx +459 -0
- package/src/conversation/ConversationTimelineView.tsx +435 -0
- package/src/conversation/ConversationsWorkbench.tsx +378 -0
- package/src/conversation/__tests__/ConversationAttentionBanner.test.tsx +82 -0
- package/src/conversation/__tests__/ConversationComposer.test.tsx +160 -0
- package/src/conversation/__tests__/ConversationControlBanner.test.tsx +190 -0
- package/src/conversation/__tests__/ConversationListPane.test.tsx +372 -0
- package/src/conversation/__tests__/ConversationTimelineView.layout.test.tsx +160 -0
- package/src/conversation/__tests__/ConversationTimelineView.test.tsx +405 -0
- package/src/conversation/__tests__/ConversationsWorkbench.test.tsx +860 -0
- package/src/conversation/__tests__/a11y/conversation-surfaces.a11y.test.tsx +235 -0
- package/src/conversation/__tests__/a11y/harness.tsx +100 -0
- package/src/conversation/__tests__/conversation-tooltips.layout.test.tsx +167 -0
- package/src/conversation/__tests__/conversationPollingComposition.test.tsx +138 -0
- package/src/conversation/__tests__/conversationPresentation.test.ts +289 -0
- package/src/conversation/__tests__/useConversation.test.tsx +243 -0
- package/src/conversation/__tests__/useConversationList.test.tsx +492 -0
- package/src/conversation/__tests__/useConversationParticipation.test.tsx +190 -0
- package/src/conversation/__tests__/useConversationTimeline.test.tsx +258 -0
- package/src/conversation/__tests__/useConversationsWantsHumanCount.test.tsx +102 -0
- package/src/conversation/conversationPresentation.ts +305 -0
- package/src/conversation/index.ts +80 -0
- package/src/conversation/polling.ts +41 -0
- package/src/conversation/useConversation.ts +221 -0
- package/src/conversation/useConversationList.ts +328 -0
- package/src/conversation/useConversationParticipation.ts +178 -0
- package/src/conversation/useConversationTimeline.ts +244 -0
- package/src/conversation/useConversationsWantsHumanCount.ts +86 -0
- package/src/index.ts +78 -1
- package/src/internal/__tests__/fake-timer-slices.ts +29 -0
- package/src/internal/__tests__/useAutoScroll.layout.test.tsx +143 -0
- package/src/internal/__tests__/useAutoScroll.test.tsx +62 -5
- package/src/internal/__tests__/useFetch-poll-under-render-pressure.test.tsx +79 -0
- package/src/internal/tooltip.tsx +6 -1
- package/src/internal/useAutoScroll.ts +66 -20
- package/src/internal/useFetch.ts +15 -4
- package/src/models/ModelSelector.tsx +81 -49
- package/src/models/__tests__/ModelSelector-serviceTier.test.tsx +105 -21
- package/src/models/registry.ts +3 -2
- package/src/models/service-tier.ts +1 -1
- package/src/schedule/ScheduleDetailView.tsx +1 -1
- package/src/schedule/ScheduleForm.tsx +7 -6
- package/src/schedule/__tests__/scheduleCreation.test.tsx +2 -2
- package/src/sharing/__tests__/useSaveAgentShare.test.tsx +55 -1
- package/src/sharing/__tests__/useShareToolReadiness.test.tsx +1 -0
- package/src/sharing/useSaveAgentShare.ts +19 -1
- package/src/sidebar/WorkspaceSidebar.tsx +62 -3
- package/src/sidebar/__tests__/WorkspaceSidebar.test.tsx +56 -1
- 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'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
|
+
}
|