@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,378 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
4
|
+
import { MessagesSquare } from "lucide-react";
|
|
5
|
+
import { cn } from "@stigmer/theme";
|
|
6
|
+
import type { AgentChannel } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/api_pb";
|
|
7
|
+
import {
|
|
8
|
+
ChannelConversationListFilter,
|
|
9
|
+
type ChannelConversation,
|
|
10
|
+
} from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/conversation_io_pb";
|
|
11
|
+
import { ChannelSendOutcome } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/message_io_pb";
|
|
12
|
+
import { channelProviderOf } from "../channel/providers.js";
|
|
13
|
+
import { useOrgAgentChannelList } from "../channel/useOrgAgentChannelList.js";
|
|
14
|
+
import { EmptyState } from "../empty-state/EmptyState.js";
|
|
15
|
+
import { ConversationAttentionBanner } from "./ConversationAttentionBanner.js";
|
|
16
|
+
import { ConversationComposer } from "./ConversationComposer.js";
|
|
17
|
+
import { ConversationControlBanner } from "./ConversationControlBanner.js";
|
|
18
|
+
import { ConversationListPane, type ConversationIdentity } from "./ConversationListPane.js";
|
|
19
|
+
import { ConversationTimelineView } from "./ConversationTimelineView.js";
|
|
20
|
+
import {
|
|
21
|
+
conversationContactOf,
|
|
22
|
+
conversationLabelOf,
|
|
23
|
+
outboundItemIdOf,
|
|
24
|
+
serviceWindowOf,
|
|
25
|
+
} from "./conversationPresentation.js";
|
|
26
|
+
import { useConversation } from "./useConversation.js";
|
|
27
|
+
import { useConversationList } from "./useConversationList.js";
|
|
28
|
+
import { useConversationParticipation } from "./useConversationParticipation.js";
|
|
29
|
+
import { useConversationTimeline } from "./useConversationTimeline.js";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Context handed to a function-form
|
|
33
|
+
* {@link ConversationsWorkbenchProps.headerAccessory}.
|
|
34
|
+
*/
|
|
35
|
+
export interface ConversationHeaderContext {
|
|
36
|
+
/**
|
|
37
|
+
* The open conversation's channel, once the org's channel list
|
|
38
|
+
* answers — `null` while it loads. Carries the channel's name (for
|
|
39
|
+
* scope-explicit accessories like the "Channel access" trigger) and
|
|
40
|
+
* `spec.agent_ref` (for navigation), so hosts never re-fetch what the
|
|
41
|
+
* workbench already holds.
|
|
42
|
+
*/
|
|
43
|
+
readonly channel: AgentChannel | null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Props for {@link ConversationsWorkbench}. */
|
|
47
|
+
export interface ConversationsWorkbenchProps {
|
|
48
|
+
/** Organization whose conversations to show. */
|
|
49
|
+
readonly org: string;
|
|
50
|
+
/**
|
|
51
|
+
* The open conversation. Controlled by the host so selection can live
|
|
52
|
+
* in the URL (deep links, reloads, back button).
|
|
53
|
+
*/
|
|
54
|
+
readonly selected: ConversationIdentity | null;
|
|
55
|
+
/** Called when the user opens or leaves a conversation. */
|
|
56
|
+
readonly onSelectionChange: (selection: ConversationIdentity | null) => void;
|
|
57
|
+
/**
|
|
58
|
+
* The signed-in staff member's identity account id, when the host
|
|
59
|
+
* knows it — used only for "You have this conversation" copy.
|
|
60
|
+
*/
|
|
61
|
+
readonly currentIdentityAccountId?: string;
|
|
62
|
+
/**
|
|
63
|
+
* Rendered in the open conversation's header (the host's seam for
|
|
64
|
+
* page-level actions — e.g. the channel access panel trigger). The
|
|
65
|
+
* function form receives {@link ConversationHeaderContext} so the
|
|
66
|
+
* accessory can name the channel it acts on.
|
|
67
|
+
*/
|
|
68
|
+
readonly headerAccessory?:
|
|
69
|
+
| React.ReactNode
|
|
70
|
+
| ((context: ConversationHeaderContext) => React.ReactNode);
|
|
71
|
+
/**
|
|
72
|
+
* Maps the open conversation's channel to the host's channel surface
|
|
73
|
+
* — the owning agent's Channels tab (channels have no standalone
|
|
74
|
+
* page). When provided, the header's channel name renders as a link;
|
|
75
|
+
* `null` for a specific channel keeps it plain text. The SDK never
|
|
76
|
+
* assumes a routing scheme (DD-004).
|
|
77
|
+
*/
|
|
78
|
+
readonly channelHref?: (channel: AgentChannel) => string | null;
|
|
79
|
+
/** Frozen instant for deterministic hosts (tests, documentation tours). */
|
|
80
|
+
readonly now?: Date;
|
|
81
|
+
/** Additional classes for the workbench container. */
|
|
82
|
+
readonly className?: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The Conversations console surface (channel-conversations T04), fully
|
|
87
|
+
* composed: the org-wide inbox on the left; the open conversation's
|
|
88
|
+
* timeline, participation banners, and staff composer on the right.
|
|
89
|
+
*
|
|
90
|
+
* Everything under it is independently importable — hosts that need a
|
|
91
|
+
* different layout compose `ConversationListPane`,
|
|
92
|
+
* `ConversationTimelineView`, the banners, and the hooks directly.
|
|
93
|
+
*/
|
|
94
|
+
export function ConversationsWorkbench({
|
|
95
|
+
org,
|
|
96
|
+
selected,
|
|
97
|
+
onSelectionChange,
|
|
98
|
+
currentIdentityAccountId,
|
|
99
|
+
headerAccessory,
|
|
100
|
+
channelHref,
|
|
101
|
+
now,
|
|
102
|
+
className,
|
|
103
|
+
}: ConversationsWorkbenchProps) {
|
|
104
|
+
const [channelFilter, setChannelFilter] = useState("");
|
|
105
|
+
const [listFilter, setListFilter] = useState(
|
|
106
|
+
ChannelConversationListFilter.channel_conversation_list_filter_unspecified,
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
const { channels } = useOrgAgentChannelList(org || null);
|
|
110
|
+
const list = useConversationList({
|
|
111
|
+
org: org || null,
|
|
112
|
+
agentChannelId: channelFilter,
|
|
113
|
+
filter: listFilter,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const detail = useConversation(
|
|
117
|
+
selected?.agentChannelId ?? "",
|
|
118
|
+
selected?.conversationKey ?? "",
|
|
119
|
+
);
|
|
120
|
+
const timeline = useConversationTimeline(
|
|
121
|
+
selected?.agentChannelId ?? "",
|
|
122
|
+
selected?.conversationKey ?? "",
|
|
123
|
+
);
|
|
124
|
+
// DD-012 D-a: a command answer is fresher than any in-flight poll on
|
|
125
|
+
// BOTH surfaces rendering this conversation — fan it out to the detail
|
|
126
|
+
// seam (banners, composer state) and the list seam (the inbox row), so
|
|
127
|
+
// your own takeover never lags the inbox (F-06).
|
|
128
|
+
const detailApply = detail.applyServerState;
|
|
129
|
+
const listApply = list.applyServerState;
|
|
130
|
+
const adoptConversation = useCallback(
|
|
131
|
+
(fresh: ChannelConversation) => {
|
|
132
|
+
detailApply(fresh);
|
|
133
|
+
listApply(fresh);
|
|
134
|
+
},
|
|
135
|
+
[detailApply, listApply],
|
|
136
|
+
);
|
|
137
|
+
const participation = useConversationParticipation({
|
|
138
|
+
agentChannelId: selected?.agentChannelId ?? "",
|
|
139
|
+
conversationKey: selected?.conversationKey ?? "",
|
|
140
|
+
onConversation: adoptConversation,
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const selectedChannel = useMemo(
|
|
144
|
+
() =>
|
|
145
|
+
selected
|
|
146
|
+
? channels.find((c) => c.metadata?.id === selected.agentChannelId) ?? null
|
|
147
|
+
: null,
|
|
148
|
+
[channels, selected],
|
|
149
|
+
);
|
|
150
|
+
const descriptor = channelProviderOf(selectedChannel?.spec?.providerConfig?.case);
|
|
151
|
+
|
|
152
|
+
// DD-007 D-e: the handback guard arms on the row's server-derived
|
|
153
|
+
// awaiting_reply fact — bounce-aware since T08 (a staff reply the
|
|
154
|
+
// provider later failed does NOT count as an answer), and platform
|
|
155
|
+
// acknowledgments never stamp it. Never re-derive this from timeline
|
|
156
|
+
// authorship: that derivation read a bounced staff reply as "answered"
|
|
157
|
+
// and went silent in exactly the scenario the guard exists for (F-28)
|
|
158
|
+
// — the client-side twin of the re-derivation DD-011 A-1 removed
|
|
159
|
+
// server-side. No loading gap: the Hand back button renders from this
|
|
160
|
+
// same row, so the guard and the control it protects appear together.
|
|
161
|
+
const unansweredCustomer = detail.conversation?.awaitingReply ?? false;
|
|
162
|
+
|
|
163
|
+
const composerDisabledReason =
|
|
164
|
+
descriptor && !descriptor.supportsStaffReplies
|
|
165
|
+
? `Staff replies aren't available on ${descriptor.label} channels yet — the agent keeps serving this conversation.`
|
|
166
|
+
: detail.awaitingCustomer
|
|
167
|
+
? "The customer hasn't written yet — staff replies unlock with their first message."
|
|
168
|
+
: null;
|
|
169
|
+
|
|
170
|
+
// DD-014 D-b/D-e: the closed-window forecast, computed fresh on EVERY
|
|
171
|
+
// render — deliberately NOT memoized on [detail.conversation]: the
|
|
172
|
+
// row's reference is intentionally stable across identical polls
|
|
173
|
+
// (DD-010) while the window is wall-clock-anchored, so a memo keyed on
|
|
174
|
+
// the row would freeze this warning forever on a quiet conversation.
|
|
175
|
+
// The 5s detail poll's re-render is the refresh cadence; the workbench
|
|
176
|
+
// test "the 5s detail poll alone carries the advisory over the
|
|
177
|
+
// boundary" is the guard. The advisory names the rule (DD-014 D-c's
|
|
178
|
+
// one home for it); the failed tick relays the provider's verdict.
|
|
179
|
+
const serviceWindow = detail.conversation
|
|
180
|
+
? serviceWindowOf(detail.conversation, descriptor?.id ?? null, now ?? new Date())
|
|
181
|
+
: null;
|
|
182
|
+
const composerAdvisory =
|
|
183
|
+
serviceWindow === "closed" && descriptor
|
|
184
|
+
? `${descriptor.label} closes free-form replies 24 hours after the customer's last message — a reply sent now will probably fail. The window reopens when the customer writes again.`
|
|
185
|
+
: null;
|
|
186
|
+
|
|
187
|
+
const handleSelect = useCallback(
|
|
188
|
+
(conversation: ChannelConversation) =>
|
|
189
|
+
onSelectionChange({
|
|
190
|
+
agentChannelId: conversation.agentChannelId,
|
|
191
|
+
conversationKey: conversation.conversationKey,
|
|
192
|
+
}),
|
|
193
|
+
[onSelectionChange],
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
// F-05: an accepted reply's ledger item is only visible once the next
|
|
197
|
+
// timeline read answers, so the composer's busy state must span
|
|
198
|
+
// click-to-visible, not end at the RPC. The settling record names the
|
|
199
|
+
// item the reply output promised (`ob:<outbound_message_id>`) and the
|
|
200
|
+
// selection it belongs to — carrying the selection makes stale state
|
|
201
|
+
// from a previously open conversation structurally inert (the derived
|
|
202
|
+
// id is null there) instead of needing a reset effect.
|
|
203
|
+
const [replySettling, setReplySettling] = useState<{
|
|
204
|
+
readonly selectionKey: string;
|
|
205
|
+
readonly itemId: string;
|
|
206
|
+
} | null>(null);
|
|
207
|
+
const selectionKey = selected
|
|
208
|
+
? `${selected.agentChannelId}:${selected.conversationKey}`
|
|
209
|
+
: "";
|
|
210
|
+
const settlingItemId =
|
|
211
|
+
replySettling !== null && replySettling.selectionKey === selectionKey
|
|
212
|
+
? replySettling.itemId
|
|
213
|
+
: null;
|
|
214
|
+
useEffect(() => {
|
|
215
|
+
if (settlingItemId === null) return;
|
|
216
|
+
if (timeline.items.some((item) => item.itemId === settlingItemId)) {
|
|
217
|
+
setReplySettling(null);
|
|
218
|
+
}
|
|
219
|
+
}, [timeline.items, settlingItemId]);
|
|
220
|
+
|
|
221
|
+
const handleSend = useCallback(
|
|
222
|
+
async (text: string) => {
|
|
223
|
+
const output = await participation.reply(text);
|
|
224
|
+
// The ledger row is committed before reply answers, so one head
|
|
225
|
+
// refresh renders the REAL item (with its true status) — the SDK
|
|
226
|
+
// never fabricates an optimistic item that might misstate what
|
|
227
|
+
// the provider did. Until that item lands, the composer stays
|
|
228
|
+
// busy; even if this refetch fails, the head poll delivers the
|
|
229
|
+
// item and clears the hold. A refusal never settles: it restores
|
|
230
|
+
// the draft for editing, and holding the composer busy would
|
|
231
|
+
// fight the correction its notice asks for.
|
|
232
|
+
if (
|
|
233
|
+
output.outcome !== ChannelSendOutcome.refused &&
|
|
234
|
+
output.outboundMessageId !== ""
|
|
235
|
+
) {
|
|
236
|
+
setReplySettling({
|
|
237
|
+
selectionKey,
|
|
238
|
+
itemId: outboundItemIdOf(output.outboundMessageId),
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
timeline.refetch();
|
|
242
|
+
return output;
|
|
243
|
+
},
|
|
244
|
+
[participation.reply, timeline.refetch, selectionKey],
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
const detailLabel = detail.conversation
|
|
248
|
+
? conversationLabelOf(detail.conversation, descriptor?.id ?? null)
|
|
249
|
+
: selected?.conversationKey ?? "";
|
|
250
|
+
// The customer's reachable address (e.g. the WhatsApp number a display
|
|
251
|
+
// name hides) — the call-back path belongs beside the channel name.
|
|
252
|
+
const detailContact = detail.conversation
|
|
253
|
+
? conversationContactOf(detail.conversation, descriptor?.id ?? null)
|
|
254
|
+
: null;
|
|
255
|
+
const channelName = selectedChannel
|
|
256
|
+
? selectedChannel.metadata?.name || selectedChannel.metadata?.slug
|
|
257
|
+
: null;
|
|
258
|
+
const selectedChannelHref =
|
|
259
|
+
selectedChannel && channelHref ? channelHref(selectedChannel) : null;
|
|
260
|
+
const headerAccessoryNode =
|
|
261
|
+
typeof headerAccessory === "function"
|
|
262
|
+
? headerAccessory({ channel: selectedChannel })
|
|
263
|
+
: headerAccessory;
|
|
264
|
+
|
|
265
|
+
return (
|
|
266
|
+
<div
|
|
267
|
+
aria-label="Conversations workbench"
|
|
268
|
+
className={cn("flex min-h-0 flex-1", className)}
|
|
269
|
+
>
|
|
270
|
+
<ConversationListPane
|
|
271
|
+
className="w-80 shrink-0 border-r border-border"
|
|
272
|
+
conversations={list.conversations}
|
|
273
|
+
isLoading={list.isLoading}
|
|
274
|
+
error={list.error}
|
|
275
|
+
hasMore={list.hasMore}
|
|
276
|
+
loadMore={list.loadMore}
|
|
277
|
+
isLoadingMore={list.isLoadingMore}
|
|
278
|
+
channels={channels}
|
|
279
|
+
channelFilter={channelFilter}
|
|
280
|
+
onChannelFilterChange={setChannelFilter}
|
|
281
|
+
filter={listFilter}
|
|
282
|
+
onFilterChange={setListFilter}
|
|
283
|
+
selected={selected}
|
|
284
|
+
onSelect={handleSelect}
|
|
285
|
+
now={now}
|
|
286
|
+
/>
|
|
287
|
+
|
|
288
|
+
{selected === null ? (
|
|
289
|
+
<div className="flex min-w-0 flex-1 items-center justify-center p-6">
|
|
290
|
+
<EmptyState
|
|
291
|
+
variant="first-use"
|
|
292
|
+
icon={<MessagesSquare className="size-8" />}
|
|
293
|
+
title="Select a conversation"
|
|
294
|
+
description="Pick a conversation to read its thread, reply as your business, or take over from the agent."
|
|
295
|
+
/>
|
|
296
|
+
</div>
|
|
297
|
+
) : (
|
|
298
|
+
// Keyed by conversation identity (DD-014: `key` remounts are the
|
|
299
|
+
// clean-reset pattern): every open starts a fresh detail column, so
|
|
300
|
+
// auto-scroll opens following at the newest message and the
|
|
301
|
+
// composer's draft and notice can never travel from one customer's
|
|
302
|
+
// conversation into another's (F-22). The data hooks live above
|
|
303
|
+
// this column, so the remount refetches nothing; the inbox pane
|
|
304
|
+
// stays outside it so its scroll position and filter survive.
|
|
305
|
+
// Channel ids never contain ":", so the joined key is unambiguous.
|
|
306
|
+
<div
|
|
307
|
+
key={`${selected.agentChannelId}:${selected.conversationKey}`}
|
|
308
|
+
className="flex min-w-0 flex-1 flex-col"
|
|
309
|
+
>
|
|
310
|
+
<div className="flex items-center justify-between gap-2 border-b border-border px-4 py-2.5">
|
|
311
|
+
<div className="min-w-0">
|
|
312
|
+
<h2 className="truncate text-sm font-semibold text-foreground">
|
|
313
|
+
{detailLabel}
|
|
314
|
+
</h2>
|
|
315
|
+
{(detailContact !== null || channelName) && (
|
|
316
|
+
<p className="truncate text-xs text-muted-foreground">
|
|
317
|
+
{detailContact}
|
|
318
|
+
{detailContact !== null && channelName && " · "}
|
|
319
|
+
{channelName &&
|
|
320
|
+
// The path from a conversation to its channel (F-11):
|
|
321
|
+
// the channel name links to the host's channel surface
|
|
322
|
+
// when a route exists.
|
|
323
|
+
(selectedChannelHref ? (
|
|
324
|
+
<a
|
|
325
|
+
href={selectedChannelHref}
|
|
326
|
+
className="hover:text-foreground hover:underline"
|
|
327
|
+
>
|
|
328
|
+
{channelName}
|
|
329
|
+
</a>
|
|
330
|
+
) : (
|
|
331
|
+
channelName
|
|
332
|
+
))}
|
|
333
|
+
</p>
|
|
334
|
+
)}
|
|
335
|
+
</div>
|
|
336
|
+
{headerAccessoryNode}
|
|
337
|
+
</div>
|
|
338
|
+
|
|
339
|
+
{detail.conversation && (
|
|
340
|
+
<ConversationAttentionBanner
|
|
341
|
+
conversation={detail.conversation}
|
|
342
|
+
participation={participation}
|
|
343
|
+
/>
|
|
344
|
+
)}
|
|
345
|
+
{detail.conversation && (
|
|
346
|
+
<ConversationControlBanner
|
|
347
|
+
conversation={detail.conversation}
|
|
348
|
+
participation={participation}
|
|
349
|
+
unansweredCustomer={unansweredCustomer}
|
|
350
|
+
supportsStaffReplies={descriptor?.supportsStaffReplies ?? true}
|
|
351
|
+
currentIdentityAccountId={currentIdentityAccountId}
|
|
352
|
+
/>
|
|
353
|
+
)}
|
|
354
|
+
|
|
355
|
+
<ConversationTimelineView
|
|
356
|
+
items={timeline.items}
|
|
357
|
+
isLoading={timeline.isLoading}
|
|
358
|
+
error={timeline.error}
|
|
359
|
+
hasOlder={timeline.hasOlder}
|
|
360
|
+
loadOlder={timeline.loadOlder}
|
|
361
|
+
isLoadingOlder={timeline.isLoadingOlder}
|
|
362
|
+
provider={descriptor?.id ?? null}
|
|
363
|
+
now={now}
|
|
364
|
+
/>
|
|
365
|
+
|
|
366
|
+
<ConversationComposer
|
|
367
|
+
onSend={handleSend}
|
|
368
|
+
isSending={
|
|
369
|
+
participation.pendingCommands.has("reply") || settlingItemId !== null
|
|
370
|
+
}
|
|
371
|
+
disabledReason={composerDisabledReason}
|
|
372
|
+
advisory={composerAdvisory}
|
|
373
|
+
/>
|
|
374
|
+
</div>
|
|
375
|
+
)}
|
|
376
|
+
</div>
|
|
377
|
+
);
|
|
378
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
2
|
+
import { cleanup, render, screen } from "@testing-library/react";
|
|
3
|
+
import userEvent from "@testing-library/user-event";
|
|
4
|
+
import { create } from "@bufbuild/protobuf";
|
|
5
|
+
import { ChannelConversationSchema } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/conversation_io_pb";
|
|
6
|
+
import { ConversationAttentionBanner } from "../ConversationAttentionBanner";
|
|
7
|
+
import type {
|
|
8
|
+
ConversationCommand,
|
|
9
|
+
UseConversationParticipationReturn,
|
|
10
|
+
} from "../useConversationParticipation";
|
|
11
|
+
|
|
12
|
+
function conversation(overrides: Record<string, unknown> = {}) {
|
|
13
|
+
return create(ChannelConversationSchema, {
|
|
14
|
+
agentChannelId: "ach_1",
|
|
15
|
+
conversationKey: "15550001111",
|
|
16
|
+
org: "acme",
|
|
17
|
+
...overrides,
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function participation(
|
|
22
|
+
overrides: Partial<UseConversationParticipationReturn> = {},
|
|
23
|
+
): UseConversationParticipationReturn {
|
|
24
|
+
return {
|
|
25
|
+
reply: vi.fn(),
|
|
26
|
+
takeOver: vi.fn(),
|
|
27
|
+
handBack: vi.fn(),
|
|
28
|
+
clearAttention: vi.fn().mockResolvedValue(conversation()),
|
|
29
|
+
pendingCommands: new Set<ConversationCommand>(),
|
|
30
|
+
commandErrors: new Map<ConversationCommand, Error>(),
|
|
31
|
+
clearErrors: vi.fn(),
|
|
32
|
+
...overrides,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("ConversationAttentionBanner", () => {
|
|
37
|
+
afterEach(() => cleanup());
|
|
38
|
+
|
|
39
|
+
it("renders nothing while the conversation is unflagged", () => {
|
|
40
|
+
const { container } = render(
|
|
41
|
+
<ConversationAttentionBanner
|
|
42
|
+
conversation={conversation()}
|
|
43
|
+
participation={participation()}
|
|
44
|
+
/>,
|
|
45
|
+
);
|
|
46
|
+
expect(container.innerHTML).toBe("");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("shows the escalating agent's reason verbatim and dismisses in place", async () => {
|
|
50
|
+
const user = userEvent.setup();
|
|
51
|
+
const p = participation();
|
|
52
|
+
render(
|
|
53
|
+
<ConversationAttentionBanner
|
|
54
|
+
conversation={conversation({
|
|
55
|
+
needsAttention: true,
|
|
56
|
+
attentionReason: "the member is asking about a refund I cannot process",
|
|
57
|
+
})}
|
|
58
|
+
participation={p}
|
|
59
|
+
/>,
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
expect(
|
|
63
|
+
screen.getByText(/the member is asking about a refund I cannot process/),
|
|
64
|
+
).toBeDefined();
|
|
65
|
+
await user.click(screen.getByRole("button", { name: "Dismiss" }));
|
|
66
|
+
expect(p.clearAttention).toHaveBeenCalled();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("renders a dismissal failure verbatim", () => {
|
|
70
|
+
render(
|
|
71
|
+
<ConversationAttentionBanner
|
|
72
|
+
conversation={conversation({ needsAttention: true })}
|
|
73
|
+
participation={participation({
|
|
74
|
+
commandErrors: new Map([
|
|
75
|
+
["clearAttention", new Error("agent channel ach_1 not found")],
|
|
76
|
+
]),
|
|
77
|
+
})}
|
|
78
|
+
/>,
|
|
79
|
+
);
|
|
80
|
+
expect(screen.getByText("agent channel ach_1 not found")).toBeDefined();
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
2
|
+
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
|
3
|
+
import userEvent from "@testing-library/user-event";
|
|
4
|
+
import { create } from "@bufbuild/protobuf";
|
|
5
|
+
import {
|
|
6
|
+
ChannelSendOutcome,
|
|
7
|
+
SendChannelMessageOutputSchema,
|
|
8
|
+
} from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/message_io_pb";
|
|
9
|
+
import { ConversationComposer } from "../ConversationComposer";
|
|
10
|
+
|
|
11
|
+
function output(outcome: ChannelSendOutcome, detail = "") {
|
|
12
|
+
return create(SendChannelMessageOutputSchema, {
|
|
13
|
+
outcome,
|
|
14
|
+
outboundMessageId: outcome === ChannelSendOutcome.refused && !detail ? "" : "obm_1",
|
|
15
|
+
detail,
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("ConversationComposer", () => {
|
|
20
|
+
afterEach(() => cleanup());
|
|
21
|
+
|
|
22
|
+
it("renders the reason instead of an input when replying is unavailable", () => {
|
|
23
|
+
render(
|
|
24
|
+
<ConversationComposer
|
|
25
|
+
onSend={vi.fn()}
|
|
26
|
+
isSending={false}
|
|
27
|
+
disabledReason="Staff replies aren't available on Slack channels yet — the agent keeps serving this conversation."
|
|
28
|
+
/>,
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
expect(screen.queryByLabelText("Reply to the customer")).toBeNull();
|
|
32
|
+
expect(screen.getByText(/aren't available on Slack channels yet/)).toBeDefined();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("sends the trimmed draft and clears it when the provider accepts", async () => {
|
|
36
|
+
const user = userEvent.setup();
|
|
37
|
+
const onSend = vi.fn().mockResolvedValue(output(ChannelSendOutcome.accepted));
|
|
38
|
+
render(<ConversationComposer onSend={onSend} isSending={false} disabledReason={null} />);
|
|
39
|
+
|
|
40
|
+
const input = screen.getByLabelText("Reply to the customer");
|
|
41
|
+
await user.type(input, "on my way{Enter}");
|
|
42
|
+
|
|
43
|
+
expect(onSend).toHaveBeenCalledWith("on my way");
|
|
44
|
+
await waitFor(() => expect((input as HTMLTextAreaElement).value).toBe(""));
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("keeps the draft and shows the refusal detail verbatim when the send is refused", async () => {
|
|
48
|
+
const user = userEvent.setup();
|
|
49
|
+
const onSend = vi
|
|
50
|
+
.fn()
|
|
51
|
+
.mockResolvedValue(
|
|
52
|
+
output(ChannelSendOutcome.refused, "the 24-hour service window is closed"),
|
|
53
|
+
);
|
|
54
|
+
render(<ConversationComposer onSend={onSend} isSending={false} disabledReason={null} />);
|
|
55
|
+
|
|
56
|
+
const input = screen.getByLabelText("Reply to the customer");
|
|
57
|
+
await user.type(input, "did this arrive?{Enter}");
|
|
58
|
+
|
|
59
|
+
await waitFor(() =>
|
|
60
|
+
expect(screen.getByText("the 24-hour service window is closed")).toBeDefined(),
|
|
61
|
+
);
|
|
62
|
+
// The words never left the platform; the user should not retype them.
|
|
63
|
+
expect((input as HTMLTextAreaElement).value).toBe("did this arrive?");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("reports a queued send as retrying, with the draft cleared", async () => {
|
|
67
|
+
const user = userEvent.setup();
|
|
68
|
+
const onSend = vi.fn().mockResolvedValue(output(ChannelSendOutcome.queued));
|
|
69
|
+
render(<ConversationComposer onSend={onSend} isSending={false} disabledReason={null} />);
|
|
70
|
+
|
|
71
|
+
const input = screen.getByLabelText("Reply to the customer");
|
|
72
|
+
await user.type(input, "hello{Enter}");
|
|
73
|
+
|
|
74
|
+
await waitFor(() =>
|
|
75
|
+
expect(screen.getByText(/the platform is retrying in the background/)).toBeDefined(),
|
|
76
|
+
);
|
|
77
|
+
expect((input as HTMLTextAreaElement).value).toBe("");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("shows the spinner and blocks input while a reply is in flight (F-05)", () => {
|
|
81
|
+
render(<ConversationComposer onSend={vi.fn()} isSending={true} disabledReason={null} />);
|
|
82
|
+
|
|
83
|
+
const button = screen.getByRole("button", { name: "Send reply" });
|
|
84
|
+
expect((button as HTMLButtonElement).disabled).toBe(true);
|
|
85
|
+
expect(button.textContent).toContain("Sending…");
|
|
86
|
+
// The house in-flight glyph (the session composer's pattern): the
|
|
87
|
+
// static Send icon must not render while the reply is in flight.
|
|
88
|
+
expect(button.querySelector("svg.animate-spin")).not.toBeNull();
|
|
89
|
+
expect(
|
|
90
|
+
(screen.getByLabelText("Reply to the customer") as HTMLTextAreaElement).disabled,
|
|
91
|
+
).toBe(true);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("annotates an ENABLED input with the advisory, wired as the input's description (DD-014 D-e)", () => {
|
|
95
|
+
render(
|
|
96
|
+
<ConversationComposer
|
|
97
|
+
onSend={vi.fn()}
|
|
98
|
+
isSending={false}
|
|
99
|
+
disabledReason={null}
|
|
100
|
+
advisory="WhatsApp's 24-hour reply window has closed — a reply sent now will probably fail. It reopens when the customer writes again."
|
|
101
|
+
/>,
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
const input = screen.getByLabelText("Reply to the customer") as HTMLTextAreaElement;
|
|
105
|
+
// A forecast, not a block: the input stays usable.
|
|
106
|
+
expect(input.disabled).toBe(false);
|
|
107
|
+
|
|
108
|
+
// R-2 (Sitting 2 gate ruling): the advisory is the input's
|
|
109
|
+
// aria-describedby target — read at the moment of action — never a
|
|
110
|
+
// live region (it is state a reader meets on open, not an event).
|
|
111
|
+
const describedBy = input.getAttribute("aria-describedby");
|
|
112
|
+
expect(describedBy).toBeTruthy();
|
|
113
|
+
const advisoryEl = document.getElementById(describedBy as string);
|
|
114
|
+
expect(advisoryEl?.textContent).toContain("24-hour reply window has closed");
|
|
115
|
+
expect(advisoryEl?.getAttribute("role")).toBeNull();
|
|
116
|
+
|
|
117
|
+
// Zero new tab stops (the F-18 discipline).
|
|
118
|
+
expect(advisoryEl?.querySelector("button, a, [tabindex]")).toBeNull();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("renders no advisory and no dangling description when there is no claim", () => {
|
|
122
|
+
render(
|
|
123
|
+
<ConversationComposer onSend={vi.fn()} isSending={false} disabledReason={null} advisory={null} />,
|
|
124
|
+
);
|
|
125
|
+
const input = screen.getByLabelText("Reply to the customer");
|
|
126
|
+
expect(input.getAttribute("aria-describedby")).toBeNull();
|
|
127
|
+
expect(screen.queryByText(/reply window/)).toBeNull();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("never shows the advisory in the disabled branch — the two states are structurally exclusive", () => {
|
|
131
|
+
render(
|
|
132
|
+
<ConversationComposer
|
|
133
|
+
onSend={vi.fn()}
|
|
134
|
+
isSending={false}
|
|
135
|
+
disabledReason="The customer hasn't written yet — staff replies unlock with their first message."
|
|
136
|
+
advisory="WhatsApp's 24-hour reply window has closed."
|
|
137
|
+
/>,
|
|
138
|
+
);
|
|
139
|
+
expect(screen.getByText(/staff replies unlock/)).toBeDefined();
|
|
140
|
+
expect(screen.queryByText(/reply window/)).toBeNull();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("keeps the draft and renders a thrown failure", async () => {
|
|
144
|
+
const user = userEvent.setup();
|
|
145
|
+
const onSend = vi
|
|
146
|
+
.fn()
|
|
147
|
+
.mockRejectedValue(new Error("no conversation with this key exists on channel ach_1"));
|
|
148
|
+
render(<ConversationComposer onSend={onSend} isSending={false} disabledReason={null} />);
|
|
149
|
+
|
|
150
|
+
const input = screen.getByLabelText("Reply to the customer");
|
|
151
|
+
await user.type(input, "hello?{Enter}");
|
|
152
|
+
|
|
153
|
+
await waitFor(() =>
|
|
154
|
+
expect(
|
|
155
|
+
screen.getByText(/no conversation with this key exists on channel ach_1/),
|
|
156
|
+
).toBeDefined(),
|
|
157
|
+
);
|
|
158
|
+
expect((input as HTMLTextAreaElement).value).toBe("hello?");
|
|
159
|
+
});
|
|
160
|
+
});
|