@stigmer/react 3.7.0 → 3.8.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 (177) hide show
  1. package/channel/AgentChannelsPanel.d.ts.map +1 -1
  2. package/channel/AgentChannelsPanel.js +21 -2
  3. package/channel/AgentChannelsPanel.js.map +1 -1
  4. package/channel/ChannelConversationsDialog.d.ts +12 -4
  5. package/channel/ChannelConversationsDialog.d.ts.map +1 -1
  6. package/channel/ChannelConversationsDialog.js +18 -24
  7. package/channel/ChannelConversationsDialog.js.map +1 -1
  8. package/channel/providers.d.ts +23 -0
  9. package/channel/providers.d.ts.map +1 -1
  10. package/channel/providers.js +4 -0
  11. package/channel/providers.js.map +1 -1
  12. package/channel/useSaveAgentChannel.d.ts +5 -4
  13. package/channel/useSaveAgentChannel.d.ts.map +1 -1
  14. package/channel/useSaveAgentChannel.js +18 -4
  15. package/channel/useSaveAgentChannel.js.map +1 -1
  16. package/composer/ComposerToolbar.d.ts +1 -1
  17. package/composer/ComposerToolbar.d.ts.map +1 -1
  18. package/composer/SessionComposer.d.ts +1 -1
  19. package/composer/SessionComposer.js +1 -1
  20. package/composer/SessionComposer.js.map +1 -1
  21. package/conversation/ConversationAttentionBanner.d.ts +22 -0
  22. package/conversation/ConversationAttentionBanner.d.ts.map +1 -0
  23. package/conversation/ConversationAttentionBanner.js +25 -0
  24. package/conversation/ConversationAttentionBanner.js.map +1 -0
  25. package/conversation/ConversationComposer.d.ts +41 -0
  26. package/conversation/ConversationComposer.d.ts.map +1 -0
  27. package/conversation/ConversationComposer.js +57 -0
  28. package/conversation/ConversationComposer.js.map +1 -0
  29. package/conversation/ConversationControlBanner.d.ts +52 -0
  30. package/conversation/ConversationControlBanner.d.ts.map +1 -0
  31. package/conversation/ConversationControlBanner.js +59 -0
  32. package/conversation/ConversationControlBanner.js.map +1 -0
  33. package/conversation/ConversationListPane.d.ts +68 -0
  34. package/conversation/ConversationListPane.d.ts.map +1 -0
  35. package/conversation/ConversationListPane.js +120 -0
  36. package/conversation/ConversationListPane.js.map +1 -0
  37. package/conversation/ConversationTimelineView.d.ts +46 -0
  38. package/conversation/ConversationTimelineView.d.ts.map +1 -0
  39. package/conversation/ConversationTimelineView.js +166 -0
  40. package/conversation/ConversationTimelineView.js.map +1 -0
  41. package/conversation/ConversationsWorkbench.d.ts +63 -0
  42. package/conversation/ConversationsWorkbench.d.ts.map +1 -0
  43. package/conversation/ConversationsWorkbench.js +165 -0
  44. package/conversation/ConversationsWorkbench.js.map +1 -0
  45. package/conversation/conversationPresentation.d.ts +137 -0
  46. package/conversation/conversationPresentation.d.ts.map +1 -0
  47. package/conversation/conversationPresentation.js +230 -0
  48. package/conversation/conversationPresentation.js.map +1 -0
  49. package/conversation/index.d.ts +26 -0
  50. package/conversation/index.d.ts.map +1 -0
  51. package/conversation/index.js +14 -0
  52. package/conversation/index.js.map +1 -0
  53. package/conversation/polling.d.ts +39 -0
  54. package/conversation/polling.d.ts.map +1 -0
  55. package/conversation/polling.js +39 -0
  56. package/conversation/polling.js.map +1 -0
  57. package/conversation/useConversation.d.ts +61 -0
  58. package/conversation/useConversation.d.ts.map +1 -0
  59. package/conversation/useConversation.js +153 -0
  60. package/conversation/useConversation.js.map +1 -0
  61. package/conversation/useConversationList.d.ts +98 -0
  62. package/conversation/useConversationList.d.ts.map +1 -0
  63. package/conversation/useConversationList.js +200 -0
  64. package/conversation/useConversationList.js.map +1 -0
  65. package/conversation/useConversationParticipation.d.ts +63 -0
  66. package/conversation/useConversationParticipation.d.ts.map +1 -0
  67. package/conversation/useConversationParticipation.js +84 -0
  68. package/conversation/useConversationParticipation.js.map +1 -0
  69. package/conversation/useConversationTimeline.d.ts +71 -0
  70. package/conversation/useConversationTimeline.d.ts.map +1 -0
  71. package/conversation/useConversationTimeline.js +152 -0
  72. package/conversation/useConversationTimeline.js.map +1 -0
  73. package/conversation/useConversationsWantsHumanCount.d.ts +42 -0
  74. package/conversation/useConversationsWantsHumanCount.d.ts.map +1 -0
  75. package/conversation/useConversationsWantsHumanCount.js +40 -0
  76. package/conversation/useConversationsWantsHumanCount.js.map +1 -0
  77. package/index.d.ts +4 -0
  78. package/index.d.ts.map +1 -1
  79. package/index.js +8 -0
  80. package/index.js.map +1 -1
  81. package/internal/tooltip.d.ts.map +1 -1
  82. package/internal/tooltip.js +7 -1
  83. package/internal/tooltip.js.map +1 -1
  84. package/internal/useAutoScroll.d.ts +25 -4
  85. package/internal/useAutoScroll.d.ts.map +1 -1
  86. package/internal/useAutoScroll.js +55 -15
  87. package/internal/useAutoScroll.js.map +1 -1
  88. package/internal/useFetch.d.ts.map +1 -1
  89. package/internal/useFetch.js +14 -4
  90. package/internal/useFetch.js.map +1 -1
  91. package/models/ModelSelector.d.ts +24 -5
  92. package/models/ModelSelector.d.ts.map +1 -1
  93. package/models/ModelSelector.js +31 -9
  94. package/models/ModelSelector.js.map +1 -1
  95. package/models/registry.d.ts +3 -2
  96. package/models/registry.d.ts.map +1 -1
  97. package/models/registry.js.map +1 -1
  98. package/models/service-tier.d.ts +1 -1
  99. package/models/service-tier.d.ts.map +1 -1
  100. package/models/service-tier.js +1 -1
  101. package/models/service-tier.js.map +1 -1
  102. package/package.json +4 -4
  103. package/schedule/ScheduleDetailView.js +1 -1
  104. package/schedule/ScheduleDetailView.js.map +1 -1
  105. package/schedule/ScheduleForm.d.ts.map +1 -1
  106. package/schedule/ScheduleForm.js +7 -6
  107. package/schedule/ScheduleForm.js.map +1 -1
  108. package/sharing/useSaveAgentShare.d.ts +10 -1
  109. package/sharing/useSaveAgentShare.d.ts.map +1 -1
  110. package/sharing/useSaveAgentShare.js +9 -0
  111. package/sharing/useSaveAgentShare.js.map +1 -1
  112. package/sidebar/WorkspaceSidebar.d.ts +12 -4
  113. package/sidebar/WorkspaceSidebar.d.ts.map +1 -1
  114. package/sidebar/WorkspaceSidebar.js +19 -7
  115. package/sidebar/WorkspaceSidebar.js.map +1 -1
  116. package/src/channel/AgentChannelsPanel.tsx +33 -2
  117. package/src/channel/ChannelConversationsDialog.tsx +22 -25
  118. package/src/channel/__tests__/AgentChannelsPanel.test.tsx +36 -5
  119. package/src/channel/__tests__/ChannelConversationsDialog.test.tsx +38 -1
  120. package/src/channel/__tests__/useSaveAgentChannel.test.tsx +26 -0
  121. package/src/channel/providers.ts +27 -0
  122. package/src/channel/useSaveAgentChannel.ts +18 -4
  123. package/src/composer/ComposerToolbar.tsx +1 -1
  124. package/src/composer/SessionComposer.tsx +2 -2
  125. package/src/composer/__tests__/SessionComposer-serviceTier.test.tsx +4 -3
  126. package/src/conversation/ConversationAttentionBanner.tsx +77 -0
  127. package/src/conversation/ConversationComposer.tsx +156 -0
  128. package/src/conversation/ConversationControlBanner.tsx +193 -0
  129. package/src/conversation/ConversationListPane.tsx +459 -0
  130. package/src/conversation/ConversationTimelineView.tsx +435 -0
  131. package/src/conversation/ConversationsWorkbench.tsx +378 -0
  132. package/src/conversation/__tests__/ConversationAttentionBanner.test.tsx +82 -0
  133. package/src/conversation/__tests__/ConversationComposer.test.tsx +160 -0
  134. package/src/conversation/__tests__/ConversationControlBanner.test.tsx +190 -0
  135. package/src/conversation/__tests__/ConversationListPane.test.tsx +372 -0
  136. package/src/conversation/__tests__/ConversationTimelineView.layout.test.tsx +160 -0
  137. package/src/conversation/__tests__/ConversationTimelineView.test.tsx +405 -0
  138. package/src/conversation/__tests__/ConversationsWorkbench.test.tsx +860 -0
  139. package/src/conversation/__tests__/a11y/conversation-surfaces.a11y.test.tsx +235 -0
  140. package/src/conversation/__tests__/a11y/harness.tsx +100 -0
  141. package/src/conversation/__tests__/conversation-tooltips.layout.test.tsx +167 -0
  142. package/src/conversation/__tests__/conversationPollingComposition.test.tsx +138 -0
  143. package/src/conversation/__tests__/conversationPresentation.test.ts +289 -0
  144. package/src/conversation/__tests__/useConversation.test.tsx +243 -0
  145. package/src/conversation/__tests__/useConversationList.test.tsx +492 -0
  146. package/src/conversation/__tests__/useConversationParticipation.test.tsx +190 -0
  147. package/src/conversation/__tests__/useConversationTimeline.test.tsx +258 -0
  148. package/src/conversation/__tests__/useConversationsWantsHumanCount.test.tsx +102 -0
  149. package/src/conversation/conversationPresentation.ts +305 -0
  150. package/src/conversation/index.ts +80 -0
  151. package/src/conversation/polling.ts +41 -0
  152. package/src/conversation/useConversation.ts +221 -0
  153. package/src/conversation/useConversationList.ts +328 -0
  154. package/src/conversation/useConversationParticipation.ts +178 -0
  155. package/src/conversation/useConversationTimeline.ts +244 -0
  156. package/src/conversation/useConversationsWantsHumanCount.ts +86 -0
  157. package/src/index.ts +67 -0
  158. package/src/internal/__tests__/fake-timer-slices.ts +29 -0
  159. package/src/internal/__tests__/useAutoScroll.layout.test.tsx +143 -0
  160. package/src/internal/__tests__/useAutoScroll.test.tsx +62 -5
  161. package/src/internal/__tests__/useFetch-poll-under-render-pressure.test.tsx +79 -0
  162. package/src/internal/tooltip.tsx +6 -1
  163. package/src/internal/useAutoScroll.ts +66 -20
  164. package/src/internal/useFetch.ts +15 -4
  165. package/src/models/ModelSelector.tsx +81 -49
  166. package/src/models/__tests__/ModelSelector-serviceTier.test.tsx +105 -21
  167. package/src/models/registry.ts +3 -2
  168. package/src/models/service-tier.ts +1 -1
  169. package/src/schedule/ScheduleDetailView.tsx +1 -1
  170. package/src/schedule/ScheduleForm.tsx +7 -6
  171. package/src/schedule/__tests__/scheduleCreation.test.tsx +2 -2
  172. package/src/sharing/__tests__/useSaveAgentShare.test.tsx +55 -1
  173. package/src/sharing/__tests__/useShareToolReadiness.test.tsx +1 -0
  174. package/src/sharing/useSaveAgentShare.ts +19 -1
  175. package/src/sidebar/WorkspaceSidebar.tsx +62 -3
  176. package/src/sidebar/__tests__/WorkspaceSidebar.test.tsx +56 -1
  177. package/styles.css +1 -1
@@ -0,0 +1,193 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { Bot, User } from "lucide-react";
5
+ import { cn } from "@stigmer/theme";
6
+ import { getUserMessage } from "@stigmer/sdk";
7
+ import {
8
+ ConversationControl,
9
+ type ChannelConversation,
10
+ } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/conversation_io_pb";
11
+ import { Button } from "../button/Button.js";
12
+ import type { UseConversationParticipationReturn } from "./useConversationParticipation.js";
13
+
14
+ /** Props for {@link ConversationControlBanner}. */
15
+ export interface ConversationControlBannerProps {
16
+ /** The conversation's fresh participation state. */
17
+ readonly conversation: ChannelConversation;
18
+ /** The participation commands (from `useConversationParticipation`). */
19
+ readonly participation: UseConversationParticipationReturn;
20
+ /**
21
+ * `true` when the customer's last message has not been answered —
22
+ * the conversation row's server-derived `awaiting_reply` fact, which
23
+ * is bounce-aware: a staff reply the provider later failed does not
24
+ * count as an answer (channel-conversations F-28/DD-015). Arms the
25
+ * handback confirm guard (DD-007 D-e): handing back runs NO turn, so
26
+ * the agent stays quiet until the customer next speaks, and the
27
+ * unanswered state must be unmissable. Never derive this from
28
+ * timeline authorship — the send attempt's presence on the timeline
29
+ * says nothing about whether the customer received it.
30
+ */
31
+ readonly unansweredCustomer: boolean;
32
+ /**
33
+ * Whether the channel's provider has a staff send lane. Senderless
34
+ * providers disable takeover with the reason — a takeover there would
35
+ * silence the agent while no human reply could reach the customer,
36
+ * which is exactly what the server refuses.
37
+ */
38
+ readonly supportsStaffReplies: boolean;
39
+ /**
40
+ * The signed-in staff member's identity account id, when the host
41
+ * knows it — lets the banner attribute a human hold: "You have this
42
+ * conversation" when it matches, "A teammate has this conversation"
43
+ * when it differs. When omitted the banner only states that a human
44
+ * holds it — the holder may be the viewer themself, so "a teammate"
45
+ * would be a guess. Optional: the SDK never assumes a host's auth
46
+ * state.
47
+ */
48
+ readonly currentIdentityAccountId?: string;
49
+ /** Additional classes for the banner container. */
50
+ readonly className?: string;
51
+ }
52
+
53
+ /**
54
+ * Who may speak to the customer, and the controls that move it: take
55
+ * over (the agent goes quiet) and hand back (the agent resumes, with
56
+ * the context it missed injected on its next turn).
57
+ *
58
+ * Truth rules: state renders from the conversation row the server
59
+ * answered — a takeover that lost its race renders the WINNER's hold,
60
+ * because that is what happened. Failures render verbatim beside the
61
+ * control that caused them.
62
+ */
63
+ export function ConversationControlBanner({
64
+ conversation,
65
+ participation,
66
+ unansweredCustomer,
67
+ supportsStaffReplies,
68
+ currentIdentityAccountId,
69
+ className,
70
+ }: ConversationControlBannerProps) {
71
+ const [confirmingHandBack, setConfirmingHandBack] = useState(false);
72
+
73
+ const humanHeld = conversation.control === ConversationControl.control_human;
74
+ const heldByMe =
75
+ humanHeld &&
76
+ currentIdentityAccountId !== undefined &&
77
+ conversation.controlledBy === currentIdentityAccountId;
78
+
79
+ const { takeOver, handBack, pendingCommands, commandErrors } = participation;
80
+ const error = commandErrors.get("takeOver") ?? commandErrors.get("handBack");
81
+
82
+ const requestHandBack = () => {
83
+ if (unansweredCustomer) {
84
+ setConfirmingHandBack(true);
85
+ return;
86
+ }
87
+ void handBack().catch(() => {
88
+ // Recorded in commandErrors and rendered below.
89
+ });
90
+ };
91
+
92
+ return (
93
+ <div
94
+ className={cn(
95
+ "border-b border-border px-4 py-2",
96
+ humanHeld ? "bg-primary-subtle" : "bg-muted-faint",
97
+ className,
98
+ )}
99
+ >
100
+ <div className="flex flex-wrap items-center justify-between gap-2">
101
+ <p className="flex items-center gap-1.5 text-sm text-foreground">
102
+ {humanHeld ? (
103
+ <>
104
+ <User aria-hidden="true" className="size-4 text-muted-foreground" />
105
+ {heldByMe
106
+ ? "You have this conversation — the agent is quiet until you hand it back."
107
+ : currentIdentityAccountId !== undefined
108
+ ? "A teammate has this conversation — the agent is quiet until handback."
109
+ : // Without the host's identity the holder may be the
110
+ // viewer themself — claiming "a teammate" would be a
111
+ // guess. State only what the row proves: a human
112
+ // holds it (channel-conversations F-01).
113
+ "This conversation is with a human — the agent is quiet until handback."}
114
+ </>
115
+ ) : (
116
+ <>
117
+ <Bot aria-hidden="true" className="size-4 text-muted-foreground" />
118
+ The agent is serving this conversation.
119
+ </>
120
+ )}
121
+ </p>
122
+
123
+ {confirmingHandBack ? (
124
+ <div className="flex items-center gap-2">
125
+ <span className="text-xs text-destructive">
126
+ The customer&apos;s last message has no reply — the agent stays
127
+ quiet until they write again.
128
+ </span>
129
+ <Button
130
+ variant="outline"
131
+ size="xs"
132
+ onClick={() => {
133
+ setConfirmingHandBack(false);
134
+ void handBack().catch(() => {
135
+ // Recorded in commandErrors and rendered below.
136
+ });
137
+ }}
138
+ disabled={pendingCommands.has("handBack")}
139
+ >
140
+ Hand back anyway
141
+ </Button>
142
+ <Button
143
+ variant="primary"
144
+ size="xs"
145
+ onClick={() => setConfirmingHandBack(false)}
146
+ >
147
+ Keep the conversation
148
+ </Button>
149
+ </div>
150
+ ) : humanHeld ? (
151
+ <Button
152
+ variant="outline"
153
+ size="xs"
154
+ onClick={requestHandBack}
155
+ disabled={pendingCommands.has("handBack")}
156
+ >
157
+ {pendingCommands.has("handBack") ? "Handing back…" : "Hand back to agent"}
158
+ </Button>
159
+ ) : (
160
+ <div className="flex items-center gap-2">
161
+ {!supportsStaffReplies && (
162
+ // Visible text, never a native title (F-18): a disabled
163
+ // Button swallows hover (disabled:pointer-events-none) and
164
+ // leaves the tab order, so a title on it is unreachable by
165
+ // every input method. Kept short — the composer's own
166
+ // notice already explains the missing staff reply lane.
167
+ <span className="text-xs text-muted-foreground">
168
+ This channel&apos;s provider has no staff send lane yet — a
169
+ takeover would silence the agent with no way to reply.
170
+ </span>
171
+ )}
172
+ <Button
173
+ variant="outline"
174
+ size="xs"
175
+ onClick={() =>
176
+ void takeOver().catch(() => {
177
+ // Recorded in commandErrors and rendered below.
178
+ })
179
+ }
180
+ disabled={!supportsStaffReplies || pendingCommands.has("takeOver")}
181
+ >
182
+ {pendingCommands.has("takeOver") ? "Taking over…" : "Take over"}
183
+ </Button>
184
+ </div>
185
+ )}
186
+ </div>
187
+
188
+ {error && (
189
+ <p className="mt-1 text-xs text-destructive">{getUserMessage(error)}</p>
190
+ )}
191
+ </div>
192
+ );
193
+ }
@@ -0,0 +1,459 @@
1
+ "use client";
2
+
3
+ import { memo, useCallback, useRef } from "react";
4
+ import { timestampDate } from "@bufbuild/protobuf/wkt";
5
+ import { MessageSquare, TriangleAlert, User } from "lucide-react";
6
+ import { cn } from "@stigmer/theme";
7
+ import { getUserMessage } from "@stigmer/sdk";
8
+ import type { AgentChannel } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/api_pb";
9
+ import {
10
+ ChannelConversationListFilter,
11
+ ConversationControl,
12
+ type ChannelConversation,
13
+ } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/conversation_io_pb";
14
+ import { formatRelativeTime } from "../activity/format-relative-time.js";
15
+ import { channelProviderOf } from "../channel/providers.js";
16
+ import { Button } from "../button/Button.js";
17
+ import { EmptyState } from "../empty-state/EmptyState.js";
18
+ import {
19
+ Tooltip,
20
+ TooltipContent,
21
+ TooltipProvider,
22
+ TooltipTrigger,
23
+ } from "../internal/tooltip.js";
24
+ import {
25
+ awaitingIndicatorOf,
26
+ conversationContactOf,
27
+ conversationLabelOf,
28
+ } from "./conversationPresentation.js";
29
+
30
+ /** The (channel, key) pair identifying one conversation. */
31
+ export interface ConversationIdentity {
32
+ readonly agentChannelId: string;
33
+ readonly conversationKey: string;
34
+ }
35
+
36
+ /** Props for {@link ConversationListPane}. */
37
+ export interface ConversationListPaneProps {
38
+ /** Conversations, newest activity first (from `useConversationList`). */
39
+ readonly conversations: readonly ChannelConversation[];
40
+ /** `true` only during the first load. */
41
+ readonly isLoading: boolean;
42
+ /** Error from the list read, or `null`. */
43
+ readonly error: Error | null;
44
+ /** `true` while more conversations exist beyond what is loaded. */
45
+ readonly hasMore: boolean;
46
+ /** Load the next page. */
47
+ readonly loadMore: () => void;
48
+ /** `true` while the next page is in flight. */
49
+ readonly isLoadingMore: boolean;
50
+ /**
51
+ * The org's channels the caller can view (from
52
+ * `useOrgAgentChannelList`) — the channel filter's options, each
53
+ * row's provider identity, and the honest empty-state distinction
54
+ * (no channel access vs no conversations yet).
55
+ */
56
+ readonly channels: readonly AgentChannel[];
57
+ /** Current channel filter; empty string means all channels. */
58
+ readonly channelFilter: string;
59
+ /** Called when the user picks a channel filter. */
60
+ readonly onChannelFilterChange: (agentChannelId: string) => void;
61
+ /**
62
+ * The server-evaluated predicate filter currently applied to the list
63
+ * (DD-011 D-g — the same predicate the nav badge counts, so the
64
+ * filtered list always matches the number that opened it). Defaults
65
+ * to unspecified (all conversations).
66
+ */
67
+ readonly filter?: ChannelConversationListFilter;
68
+ /**
69
+ * Called when the user picks a predicate filter. The control renders
70
+ * only when this is wired — a dead toggle over an unfiltered list
71
+ * would lie.
72
+ */
73
+ readonly onFilterChange?: (filter: ChannelConversationListFilter) => void;
74
+ /** The open conversation, for row highlighting. `null` when none. */
75
+ readonly selected: ConversationIdentity | null;
76
+ /** Called when the user opens a conversation. */
77
+ readonly onSelect: (conversation: ChannelConversation) => void;
78
+ /** Frozen instant for deterministic hosts (tests, documentation tours). */
79
+ readonly now?: Date;
80
+ /** Additional classes for the pane container. */
81
+ readonly className?: string;
82
+ }
83
+
84
+ /**
85
+ * The conversation inbox pane (channel-conversations T04): every
86
+ * conversation across the org's channels, newest activity first, with
87
+ * control, attention, and awaiting-reply indicators.
88
+ *
89
+ * Presentational — pair with `useConversationList` and
90
+ * `useOrgAgentChannelList`, or compose via `ConversationsWorkbench`.
91
+ * The wants-human filter is SERVER-evaluated (DD-011 D-g closed the API
92
+ * gap that T04 D1 cited): a client-side tab over one fetched page would
93
+ * silently lie across pages, so the pane only reports the choice and the
94
+ * list hook sends it to the server.
95
+ */
96
+ export function ConversationListPane({
97
+ conversations,
98
+ isLoading,
99
+ error,
100
+ hasMore,
101
+ loadMore,
102
+ isLoadingMore,
103
+ channels,
104
+ channelFilter,
105
+ onChannelFilterChange,
106
+ filter = ChannelConversationListFilter.channel_conversation_list_filter_unspecified,
107
+ onFilterChange,
108
+ selected,
109
+ onSelect,
110
+ now,
111
+ className,
112
+ }: ConversationListPaneProps) {
113
+ const providerById = new Map(
114
+ channels.map((channel) => [
115
+ channel.metadata?.id ?? "",
116
+ channelProviderOf(channel.spec?.providerConfig?.case),
117
+ ]),
118
+ );
119
+
120
+ return (
121
+ <div
122
+ aria-label="Conversation list"
123
+ className={cn("flex min-h-0 flex-col", className)}
124
+ >
125
+ {(onFilterChange !== undefined || channels.length > 1) && (
126
+ <div className="flex flex-col gap-2 border-b border-border px-3 py-2">
127
+ {onFilterChange !== undefined && (
128
+ <ConversationFilterToggle value={filter} onChange={onFilterChange} />
129
+ )}
130
+ {channels.length > 1 && (
131
+ <>
132
+ <label className="sr-only" htmlFor="stgm-conversation-channel-filter">
133
+ Filter by channel
134
+ </label>
135
+ <select
136
+ id="stgm-conversation-channel-filter"
137
+ value={channelFilter}
138
+ onChange={(e) => onChannelFilterChange(e.target.value)}
139
+ className={cn(
140
+ "w-full rounded-md border border-border bg-background px-2 py-1.5",
141
+ "text-sm text-foreground",
142
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
143
+ )}
144
+ >
145
+ <option value="">All channels</option>
146
+ {channels.map((channel) => (
147
+ <option key={channel.metadata?.id} value={channel.metadata?.id}>
148
+ {channel.metadata?.name || channel.metadata?.slug}
149
+ </option>
150
+ ))}
151
+ </select>
152
+ </>
153
+ )}
154
+ </div>
155
+ )}
156
+
157
+ <div className="min-h-0 flex-1 overflow-y-auto p-2">
158
+ {isLoading ? (
159
+ <ListSkeleton />
160
+ ) : error ? (
161
+ <p className="px-2 py-6 text-center text-sm text-destructive">
162
+ {getUserMessage(error)}
163
+ </p>
164
+ ) : conversations.length === 0 ? (
165
+ channels.length === 0 ? (
166
+ <EmptyState
167
+ variant="first-use"
168
+ icon={<MessageSquare className="size-8" />}
169
+ title="No channels to watch"
170
+ description="Conversations arrive through an agent's channels. Connect a channel — or ask a channel owner for access — and customer conversations appear here."
171
+ />
172
+ ) : filter === ChannelConversationListFilter.filter_wants_human ? (
173
+ // Its own empty state on purpose: under the filter, "No
174
+ // conversations yet — when a customer messages…" would lie
175
+ // to an org with a full inbox and nothing needing a person.
176
+ <EmptyState
177
+ variant="first-use"
178
+ icon={<MessageSquare className="size-8" />}
179
+ title="Nothing needs a human right now"
180
+ description="Conversations appear here when the agent asks for attention, or when a human-held conversation has a customer message awaiting a reply."
181
+ />
182
+ ) : (
183
+ <EmptyState
184
+ variant="first-use"
185
+ icon={<MessageSquare className="size-8" />}
186
+ title="No conversations yet"
187
+ description="When a customer messages one of your channels, their conversation appears here."
188
+ />
189
+ )
190
+ ) : (
191
+ <>
192
+ {/* One context-only provider arms every row's attention
193
+ tooltip (the WorkspaceSidebar recents-list precedent). */}
194
+ <TooltipProvider>
195
+ <ul className="space-y-0.5">
196
+ {conversations.map((conversation) => (
197
+ <ConversationRow
198
+ key={`${conversation.agentChannelId}:${conversation.conversationKey}`}
199
+ conversation={conversation}
200
+ label={conversationLabelOf(
201
+ conversation,
202
+ providerById.get(conversation.agentChannelId)?.id ?? null,
203
+ )}
204
+ contact={conversationContactOf(
205
+ conversation,
206
+ providerById.get(conversation.agentChannelId)?.id ?? null,
207
+ )}
208
+ isSelected={
209
+ selected?.agentChannelId === conversation.agentChannelId &&
210
+ selected?.conversationKey === conversation.conversationKey
211
+ }
212
+ onSelect={onSelect}
213
+ now={now}
214
+ />
215
+ ))}
216
+ </ul>
217
+ </TooltipProvider>
218
+ {hasMore && (
219
+ <div className="flex justify-center py-2">
220
+ <Button
221
+ variant="ghost"
222
+ size="xs"
223
+ onClick={loadMore}
224
+ disabled={isLoadingMore}
225
+ >
226
+ {isLoadingMore ? "Loading…" : "Show more"}
227
+ </Button>
228
+ </div>
229
+ )}
230
+ </>
231
+ )}
232
+ </div>
233
+ </div>
234
+ );
235
+ }
236
+
237
+ const FILTER_OPTIONS: readonly {
238
+ readonly value: ChannelConversationListFilter;
239
+ readonly label: string;
240
+ readonly ariaLabel: string;
241
+ }[] = [
242
+ {
243
+ value: ChannelConversationListFilter.channel_conversation_list_filter_unspecified,
244
+ label: "All",
245
+ ariaLabel: "All conversations",
246
+ },
247
+ {
248
+ value: ChannelConversationListFilter.filter_wants_human,
249
+ label: "Needs human",
250
+ ariaLabel: "Conversations where a human action is wanted",
251
+ },
252
+ ];
253
+
254
+ /**
255
+ * Segmented control for the server-evaluated list filter. Renders as a
256
+ * WAI-ARIA Radio Group with roving tabindex and arrow-key navigation —
257
+ * the `ScopeToggle` pattern (its second sighting; extract a shared
258
+ * primitive if a third segmented control appears).
259
+ *
260
+ * "Needs human" is exactly the nav badge's predicate (DD-011 D-f/D-g),
261
+ * so the number on the badge and the list this control opens can never
262
+ * disagree.
263
+ */
264
+ function ConversationFilterToggle({
265
+ value,
266
+ onChange,
267
+ }: {
268
+ readonly value: ChannelConversationListFilter;
269
+ readonly onChange: (filter: ChannelConversationListFilter) => void;
270
+ }) {
271
+ const optionRefs = useRef<(HTMLButtonElement | null)[]>([]);
272
+
273
+ const handleSelect = useCallback(
274
+ (next: ChannelConversationListFilter) => {
275
+ if (next !== value) onChange(next);
276
+ },
277
+ [value, onChange],
278
+ );
279
+
280
+ const handleKeyDown = useCallback(
281
+ (e: React.KeyboardEvent<HTMLButtonElement>, index: number) => {
282
+ let nextIndex: number | null = null;
283
+ if (e.key === "ArrowRight" || e.key === "ArrowDown") {
284
+ e.preventDefault();
285
+ nextIndex = (index + 1) % FILTER_OPTIONS.length;
286
+ } else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
287
+ e.preventDefault();
288
+ nextIndex = (index - 1 + FILTER_OPTIONS.length) % FILTER_OPTIONS.length;
289
+ }
290
+ if (nextIndex !== null) {
291
+ optionRefs.current[nextIndex]?.focus();
292
+ handleSelect(FILTER_OPTIONS[nextIndex].value);
293
+ }
294
+ },
295
+ [handleSelect],
296
+ );
297
+
298
+ return (
299
+ <div
300
+ role="radiogroup"
301
+ aria-label="Conversation filter"
302
+ className="inline-flex self-start rounded-md bg-muted p-0.5"
303
+ >
304
+ {FILTER_OPTIONS.map((option, index) => {
305
+ const isSelected = value === option.value;
306
+ return (
307
+ <button
308
+ key={option.value}
309
+ ref={(el) => {
310
+ optionRefs.current[index] = el;
311
+ }}
312
+ type="button"
313
+ role="radio"
314
+ aria-checked={isSelected}
315
+ aria-label={option.ariaLabel}
316
+ tabIndex={isSelected ? 0 : -1}
317
+ onClick={() => handleSelect(option.value)}
318
+ onKeyDown={(e) => handleKeyDown(e, index)}
319
+ className={cn(
320
+ "inline-flex cursor-pointer items-center rounded-sm px-2 py-1 text-xs font-medium transition-colors",
321
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
322
+ isSelected
323
+ ? "bg-background text-foreground shadow-sm"
324
+ : "text-muted-foreground hover:text-foreground",
325
+ )}
326
+ >
327
+ {option.label}
328
+ </button>
329
+ );
330
+ })}
331
+ </div>
332
+ );
333
+ }
334
+
335
+ const ConversationRow = memo(function ConversationRow({
336
+ conversation,
337
+ label,
338
+ contact,
339
+ isSelected,
340
+ onSelect,
341
+ now,
342
+ }: {
343
+ readonly conversation: ChannelConversation;
344
+ readonly label: string;
345
+ /**
346
+ * The customer's reachable address when the label hides it
347
+ * (`conversationContactOf`) — `null` when the label already shows it.
348
+ */
349
+ readonly contact: string | null;
350
+ readonly isSelected: boolean;
351
+ readonly onSelect: (conversation: ChannelConversation) => void;
352
+ readonly now?: Date;
353
+ }) {
354
+ const humanHeld = conversation.control === ConversationControl.control_human;
355
+ const awaiting = awaitingIndicatorOf(conversation);
356
+ const lastActivity = conversation.lastActivityAt
357
+ ? timestampDate(conversation.lastActivityAt)
358
+ : null;
359
+
360
+ return (
361
+ <li>
362
+ <button
363
+ type="button"
364
+ onClick={() => onSelect(conversation)}
365
+ aria-current={isSelected ? "true" : undefined}
366
+ className={cn(
367
+ "flex w-full items-center gap-2.5 rounded-md px-2.5 py-2 text-left",
368
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
369
+ isSelected ? "bg-primary-subtle" : "hover:bg-accent",
370
+ )}
371
+ >
372
+ <div className="min-w-0 flex-1">
373
+ <div className="flex items-center gap-1.5">
374
+ {/* No truncation title on the label (F-18): the full name
375
+ renders in the open conversation's header, so a native
376
+ tooltip here added noise without adding reach. */}
377
+ <p className="truncate text-sm font-medium text-foreground">{label}</p>
378
+ {conversation.needsAttention && (
379
+ // The reason rides the house tooltip (F-18). The trigger
380
+ // renders as a span — it sits INSIDE the row button, so a
381
+ // default (button) trigger would nest buttons — and stays
382
+ // out of the tab order; the sr-only text remains the
383
+ // accessible name. The full reason is also always visible
384
+ // in the open conversation's attention banner.
385
+ <Tooltip>
386
+ <TooltipTrigger render={<span className="shrink-0" />}>
387
+ <TriangleAlert
388
+ aria-hidden="true"
389
+ className="size-3.5 text-destructive"
390
+ />
391
+ <span className="sr-only">
392
+ {conversation.attentionReason
393
+ ? `Needs attention: ${conversation.attentionReason}`
394
+ : "Needs attention"}
395
+ </span>
396
+ </TooltipTrigger>
397
+ <TooltipContent side="top">
398
+ {conversation.attentionReason || "Needs attention"}
399
+ </TooltipContent>
400
+ </Tooltip>
401
+ )}
402
+ </div>
403
+ {contact !== null && (
404
+ <p className="mt-0.5 truncate text-xs text-muted-foreground-faint">
405
+ {contact}
406
+ </p>
407
+ )}
408
+ {humanHeld && (
409
+ <p className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground">
410
+ <User aria-hidden="true" className="size-3" />
411
+ Human has the conversation
412
+ </p>
413
+ )}
414
+ </div>
415
+ {(lastActivity !== null || awaiting !== null) && (
416
+ <span className="flex shrink-0 flex-col items-end gap-1">
417
+ {lastActivity && (
418
+ <span className="text-xs text-muted-foreground-faint">
419
+ {formatRelativeTime(lastActivity, now)}
420
+ </span>
421
+ )}
422
+ {awaiting && (
423
+ // The WhatsApp inbox convention: an awaiting conversation
424
+ // carries a dot under its timestamp. Strength maps the
425
+ // holder (DD-011 D-a): strong when a human holds it — the
426
+ // agent will not answer, a person must — muted when the
427
+ // agent does. No tooltip and no tab stop (the F-18
428
+ // discipline); the sr-only text is the accessible meaning.
429
+ <span className="flex">
430
+ <span
431
+ aria-hidden="true"
432
+ className={cn(
433
+ "size-2 rounded-full",
434
+ awaiting === "strong" ? "bg-primary" : "bg-muted-foreground",
435
+ )}
436
+ />
437
+ <span className="sr-only">
438
+ {awaiting === "strong"
439
+ ? "Customer awaiting reply — the conversation is human-held"
440
+ : "Customer awaiting reply"}
441
+ </span>
442
+ </span>
443
+ )}
444
+ </span>
445
+ )}
446
+ </button>
447
+ </li>
448
+ );
449
+ });
450
+
451
+ function ListSkeleton() {
452
+ return (
453
+ <div className="space-y-1 p-1" aria-hidden="true">
454
+ {[1, 2, 3, 4].map((i) => (
455
+ <div key={i} className="h-11 animate-pulse rounded-md bg-muted-faint" />
456
+ ))}
457
+ </div>
458
+ );
459
+ }